Compare commits

..

9 Commits

Author SHA1 Message Date
Dallin Romney
b53d1a1992 refactor qa taxonomy coverage ids 2026-06-13 01:48:46 -07:00
Dallin Romney
4883a4b9e5 test: update qa evidence coverage expectations 2026-06-13 01:33:00 -07:00
Dallin Romney
618dbd5b73 test: align qa coverage id assertions 2026-06-13 01:27:33 -07:00
Dallin Romney
79c0ba8eb9 refactor: minimize qa coverage id churn 2026-06-13 01:23:24 -07:00
Dallin Romney
4bfe763034 refactor: keep qa coverage ids canonical 2026-06-13 01:15:07 -07:00
Dallin Romney
7cbba96ebc feat: align qa coverage with taxonomy features 2026-06-13 01:14:37 -07:00
Dallin Romney
098811620b test(qa): map scorecard categories by coverage id 2026-06-13 01:08:18 -07:00
Dallin Romney
a39cb5f77d test(qa): use typed scorecard evidence refs 2026-06-13 01:08:18 -07:00
Dallin Romney
58dd194af4 test(qa): simplify scorecard mapping shape 2026-06-13 01:08:18 -07:00
125 changed files with 5458 additions and 7462 deletions

View File

@@ -285,8 +285,10 @@ gh search issues --repo openclaw/openclaw --match title,body --limit 50 \
- Leave a review conversation unresolved only when reviewer or maintainer judgment is still needed.
- Before landing any PR with non-trivial code changes, run `$autoreview` until no accepted/actionable findings remain, unless equivalent manual review already covered it, the change is trivial/docs-only, or the user opts out.
- When landing or merging any PR, follow the global `/landpr` process.
- Do not edit `CHANGELOG.md` for routine PR maintenance, review follow-up, conflict repair, contributor-branch preparation, tests, docs, or feature/fix PR refreshes. Changelog edits are release-managed only and require an explicit release/changelog task.
- Use `scripts/committer "<msg>" <file...>` for scoped commits instead of manual `git add` and `git commit`.
- Keep commit messages concise and action-oriented.
- Do not add assistant, agent, or non-Codex coauthor/credit trailers to commits or public PR comments unless Val explicitly asks for that attribution.
- Group related changes; avoid bundling unrelated refactors.
- Use `.github/pull_request_template.md` for PR submissions and `.github/ISSUE_TEMPLATE/` for issues.
- Do not commit PR-only artifacts such as screenshots under `.github/pr-assets`; attach them to the PR/comment or use an external artifact store instead.

View File

@@ -1,61 +0,0 @@
name: openclaw-codeql-process-exec-boundary-critical-security
disable-default-queries: true
queries:
- uses: security-extended
query-filters:
- include:
precision:
- high
- very-high
tags contain: security
security-severity: /([7-9]|10)\.(\d)+/
paths:
- src/process
- src/tui/tui-local-shell.ts
- src/tui/tui.ts
- src/plugin-sdk/windows-spawn.ts
- packages/agent-core/src/harness/env
- packages/memory-host-sdk/src/host
- extensions/acpx/src
- extensions/bonjour/src/advertiser.ts
- extensions/browser/src/browser/chrome-mcp.ts
- extensions/browser/src/browser/chrome.executables.ts
- extensions/browser/src/browser/chrome.ts
- extensions/codex/src/app-server/sandbox-exec-server
- extensions/codex/src/app-server/transport-stdio.ts
- extensions/codex/src/node-cli-sessions.ts
- extensions/codex-supervisor/src/json-rpc-client.ts
- extensions/file-transfer/src
- extensions/google-meet/src
- extensions/imessage/src
- extensions/memory-core/src/memory/qmd-manager.ts
- extensions/memory-wiki/src/obsidian.ts
- extensions/microsoft-foundry/cli.ts
- extensions/ollama/src/wsl2-crash-loop-check.ts
- extensions/qa-lab/src
- extensions/signal/src/daemon.ts
- extensions/tts-local-cli/speech-provider.ts
- extensions/voice-call/src
- scripts
paths-ignore:
- "**/node_modules"
- "**/coverage"
- "**/*.generated.ts"
- "**/*.bundle.js"
- "**/*-runtime.js"
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/*.spec.ts"
- "**/*.spec.tsx"
- "**/*.e2e.test.ts"
- "**/*.e2e.test.tsx"
- "**/*test-support*"
- "**/*test-helper*"
- "**/*mock*"
- "**/*fixture*"
- "**/*bench*"

View File

@@ -17,28 +17,7 @@ on:
- ".github/actions/**"
- ".github/codeql/**"
- ".github/workflows/**"
- "extensions/acpx/src/**"
- "extensions/bonjour/src/advertiser.ts"
- "extensions/browser/src/browser/chrome-mcp.ts"
- "extensions/browser/src/browser/chrome.executables.ts"
- "extensions/browser/src/browser/chrome.ts"
- "extensions/codex/src/app-server/sandbox-exec-server/**"
- "extensions/codex/src/app-server/transport-stdio.ts"
- "extensions/codex/src/node-cli-sessions.ts"
- "extensions/codex-supervisor/src/json-rpc-client.ts"
- "extensions/file-transfer/src/**"
- "extensions/google-meet/src/**"
- "extensions/imessage/src/**"
- "extensions/memory-core/src/memory/qmd-manager.ts"
- "extensions/memory-wiki/src/obsidian.ts"
- "extensions/microsoft-foundry/cli.ts"
- "extensions/ollama/src/wsl2-crash-loop-check.ts"
- "extensions/qa-lab/src/**"
- "extensions/signal/src/daemon.ts"
- "extensions/tts-local-cli/speech-provider.ts"
- "extensions/voice-call/src/**"
- "packages/**"
- "scripts/**"
- "src/**"
push:
branches:
@@ -47,28 +26,7 @@ on:
- ".github/actions/**"
- ".github/codeql/**"
- ".github/workflows/**"
- "extensions/acpx/src/**"
- "extensions/bonjour/src/advertiser.ts"
- "extensions/browser/src/browser/chrome-mcp.ts"
- "extensions/browser/src/browser/chrome.executables.ts"
- "extensions/browser/src/browser/chrome.ts"
- "extensions/codex/src/app-server/sandbox-exec-server/**"
- "extensions/codex/src/app-server/transport-stdio.ts"
- "extensions/codex/src/node-cli-sessions.ts"
- "extensions/codex-supervisor/src/json-rpc-client.ts"
- "extensions/file-transfer/src/**"
- "extensions/google-meet/src/**"
- "extensions/imessage/src/**"
- "extensions/memory-core/src/memory/qmd-manager.ts"
- "extensions/memory-wiki/src/obsidian.ts"
- "extensions/microsoft-foundry/cli.ts"
- "extensions/ollama/src/wsl2-crash-loop-check.ts"
- "extensions/qa-lab/src/**"
- "extensions/signal/src/daemon.ts"
- "extensions/tts-local-cli/speech-provider.ts"
- "extensions/voice-call/src/**"
- "packages/**"
- "scripts/**"
- "src/**"
schedule:
- cron: "0 6 * * *"
@@ -115,11 +73,6 @@ jobs:
runs_on: blacksmith-4vcpu-ubuntu-2404
timeout_minutes: 25
config_file: ./.github/codeql/codeql-mcp-process-tool-boundary-critical-security.yml
- language: javascript-typescript
category: process-exec-boundary
runs_on: blacksmith-4vcpu-ubuntu-2404
timeout_minutes: 25
config_file: ./.github/codeql/codeql-process-exec-boundary-critical-security.yml
- language: javascript-typescript
category: plugin-trust-boundary
runs_on: blacksmith-4vcpu-ubuntu-2404

View File

@@ -18,6 +18,8 @@ Skills own workflows; root owns hard policy and routing.
- Missing deps: `pnpm install`, retry once, then report first actionable error.
- CODEOWNERS: maint/refactor/tests ok. Larger behavior/product/security/ownership: owner ask/review.
- Product/docs/UI/changelog wording: "plugin/plugins"; `extensions/` is internal.
- Routine PR maintenance, review follow-up, conflict repair, and contributor-branch preparation must not edit `CHANGELOG.md`; release generation owns changelog edits.
- Do not add assistant, agent, or non-Codex coauthor/credit trailers on OpenClaw PR commits or public PR comments unless Val explicitly asks for that attribution.
- New channel/plugin/app/doc surface: update `.github/labeler.yml` + GH labels.
- New `AGENTS.md`: add sibling `CLAUDE.md` symlink; edit `AGENTS.md` only.

View File

@@ -452,7 +452,7 @@ For normal PRs, follow scoped CI/check evidence instead of treating parity as a
The `CodeQL` workflow is intentionally a narrow first-pass security scanner, not the full repository sweep. Daily, manual, and non-draft pull request guard runs scan Actions workflow code plus the highest-risk JavaScript/TypeScript surfaces with high-confidence security queries filtered to high/critical `security-severity`.
The pull request guard stays light: it only starts for changes under `.github/actions`, `.github/codeql`, `.github/workflows`, `packages`, `scripts`, `src`, or process-owning bundled plugin runtime paths, and it runs the same high-confidence security matrix as the scheduled workflow. Android and macOS CodeQL stay out of PR defaults.
The pull request guard stays light: it only starts for changes under `.github/actions`, `.github/codeql`, `.github/workflows`, `packages`, or `src`, and it runs the same high-confidence security matrix as the scheduled workflow. Android and macOS CodeQL stay out of PR defaults.
### Security categories
@@ -462,7 +462,6 @@ The pull request guard stays light: it only starts for changes under `.github/ac
| `/codeql-security-high/channel-runtime-boundary` | Core channel implementation contracts plus the channel plugin runtime, gateway, Plugin SDK, secrets, audit touchpoints |
| `/codeql-security-high/network-ssrf-boundary` | Core SSRF, IP parsing, network guard, web-fetch, and Plugin SDK SSRF policy surfaces |
| `/codeql-security-high/mcp-process-tool-boundary` | MCP servers, process execution helpers, outbound delivery, and agent tool-execution gates |
| `/codeql-security-high/process-exec-boundary` | Local shell, process spawn helpers, subprocess-owning bundled plugin runtimes, and workflow script glue |
| `/codeql-security-high/plugin-trust-boundary` | Plugin install, loader, manifest, registry, package-manager install, source-loading, and Plugin SDK package contract trust surfaces |
### Platform-specific security shards

View File

@@ -35,7 +35,6 @@ openclaw wiki status
openclaw wiki doctor
openclaw wiki init
openclaw wiki ingest ./notes/alpha.md
openclaw wiki okf import ./knowledge-catalog/okf/bundles/ga4
openclaw wiki compile
openclaw wiki lint
openclaw wiki search "alpha"
@@ -105,31 +104,6 @@ Notes:
- imported source pages keep provenance in frontmatter
- auto-compile can run after ingest when enabled
### `wiki okf import <path>`
Import an unpacked Open Knowledge Format bundle into wiki concept pages.
The importer reads every non-reserved `.md` concept document in the OKF
directory tree, requires a non-empty `type` field, and treats unknown OKF
`type` values as generic concepts. Reserved OKF `index.md` and `log.md` files
are not imported as concepts.
Imported pages are flattened under `concepts/` so existing wiki compile,
search, get, digest, and dashboard flows see them immediately. The original OKF
concept ID, `type`, `resource`, `tags`, timestamp, source path, and full
frontmatter are preserved in the page frontmatter. Internal OKF markdown links
are rewritten to the generated wiki pages; broken or external links are left
unchanged.
Examples:
```bash
openclaw wiki okf import ./bundles/ga4
openclaw wiki okf import ./bundles/ga4 --json
openclaw wiki search "BigQuery Table" --mode source-evidence --json
openclaw wiki get <path-from-json-result>
```
### `wiki compile`
Rebuild indexes, related blocks, dashboards, and compiled digests.
@@ -259,8 +233,6 @@ These require the official `obsidian` CLI on `PATH` when
- Use `wiki lint` before trusting contradictory or low-confidence content.
- Use `wiki compile` after bulk imports or source changes when you want fresh
dashboards and compiled digests immediately.
- Use `wiki okf import` when a data catalog, documentation export, or agent
enrichment pipeline already emits OKF markdown bundles.
- Use `wiki bridge import` when bridge mode depends on newly exported memory
artifacts.

View File

@@ -30,23 +30,6 @@ title: "Usage tracking"
- CLI: `openclaw channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
- macOS menu bar: "Usage" section under Context (only if available).
## Custom `/usage full` footer
Set `messages.usageTemplate` to customize the per-response `/usage full`
footer. The value can be an inline template object or a JSON file path:
```json
{
"messages": {
"usageTemplate": "~/.openclaw/usage-footer.json"
}
}
```
Templates read the `openclaw.usageLine.v1` contract and can use `scales`,
`aliases`, and `output.surfaces` to render channel-specific footers. Missing,
unreadable, invalid, or empty templates fall back to the built-in usage line.
## Providers + credentials
- **Anthropic (Claude)**: OAuth tokens in auth profiles.

View File

@@ -75,7 +75,6 @@ Auth matrix:
- honor `x-openclaw-scopes` when the header is present
- fall back to the normal operator default scope set when the header is absent
- only lose owner semantics when the caller explicitly narrows scopes and omits `operator.admin`
- require `operator.admin` for owner-level request controls such as `x-openclaw-model`
See [Security](/gateway/security) and [Remote access](/gateway/remote).
@@ -97,7 +96,7 @@ OpenClaw treats the OpenAI `model` field as an **agent target**, not a raw provi
Optional request headers:
- `x-openclaw-model: <provider/model-or-bare-id>` overrides the backend model for the selected agent. Shared-secret bearer callers can use this header. Identity-bearing callers, such as trusted-proxy or private no-auth ingress requests with `x-openclaw-scopes`, need `operator.admin`; write-only callers get `403 missing scope: operator.admin`.
- `x-openclaw-model: <provider/model-or-bare-id>` overrides the backend model for the selected agent.
- `x-openclaw-agent-id: <agentId>` remains supported as a compatibility override.
- `x-openclaw-session-key: <sessionKey>` fully controls session routing.
- `x-openclaw-message-channel: <channel>` sets the synthetic ingress channel context for channel-aware prompts and policies.
@@ -179,7 +178,7 @@ This is the highest-leverage compatibility set for self-hosted frontends and too
</Accordion>
<Accordion title="How do I override the backend model?">
Use `x-openclaw-model`. This is an owner-level override: it works with the Gateway shared-secret bearer token/password path, and it requires `operator.admin` on identity-bearing HTTP paths such as trusted proxy auth.
Use `x-openclaw-model`.
Examples:
`x-openclaw-model: openai/gpt-5.4`
@@ -192,7 +191,7 @@ This is the highest-leverage compatibility set for self-hosted frontends and too
`/v1/embeddings` uses the same agent-target `model` ids.
Use `model: "openclaw/default"` or `model: "openclaw/<agentId>"`.
When you need a specific embedding model, send it in `x-openclaw-model` from a shared-secret caller or an identity-bearing caller with `operator.admin`.
When you need a specific embedding model, send it in `x-openclaw-model`.
Without that header, the request passes through to the selected agent's normal embedding setup.
</Accordion>
@@ -286,7 +285,7 @@ Expected behavior:
- `GET /v1/models` should list `openclaw/default`
- Open WebUI should use `openclaw/default` as the chat model id
- If you want a specific backend provider/model for that agent, set the agent's normal default model or send `x-openclaw-model` from a shared-secret caller or an identity-bearing caller with `operator.admin`
- If you want a specific backend provider/model for that agent, set the agent's normal default model or send `x-openclaw-model`
Quick smoke:
@@ -371,7 +370,7 @@ Notes:
- `/v1/models` returns OpenClaw agent targets, not raw provider catalogs.
- `openclaw/default` is always present so one stable id works across environments.
- Backend provider/model overrides belong in `x-openclaw-model`, not the OpenAI `model` field. On identity-bearing HTTP auth paths, this header requires `operator.admin`.
- Backend provider/model overrides belong in `x-openclaw-model`, not the OpenAI `model` field.
- `/v1/embeddings` supports `input` as a string or array of strings.
## Related

View File

@@ -951,7 +951,7 @@ Important boundary note:
- Treat credentials that can call `/v1/chat/completions`, `/v1/responses`, plugin routes such as `/api/v1/admin/rpc`, or `/api/channels/*` as full-access operator secrets for that gateway.
- On the OpenAI-compatible HTTP surface, shared-secret bearer auth restores the full default operator scopes (`operator.admin`, `operator.approvals`, `operator.pairing`, `operator.read`, `operator.talk.secrets`, `operator.write`) and owner semantics for agent turns; narrower `x-openclaw-scopes` values do not reduce that shared-secret path.
- Per-request scope semantics on HTTP only apply when the request comes from an identity-bearing mode such as trusted proxy auth, or from an explicitly no-auth private ingress.
- In those identity-bearing modes, omitting `x-openclaw-scopes` falls back to the normal operator default scope set; send the header explicitly when you want a narrower scope set. Owner-level OpenAI-compatible headers such as `x-openclaw-model` require `operator.admin` when scopes are narrowed.
- In those identity-bearing modes, omitting `x-openclaw-scopes` falls back to the normal operator default scope set; send the header explicitly when you want a narrower scope set.
- `/tools/invoke` and HTTP session history endpoints follow the same shared-secret rule: token/password bearer auth is treated as full operator access there too, while identity-bearing modes still honor declared scopes.
- Do not share these credentials with untrusted callers; prefer separate gateways per trust boundary.

View File

@@ -425,10 +425,6 @@ even when the channel payload has no visible text/caption. Rewriting that
`content` updates the hook-visible transcript only; it is not rendered as a
media caption.
`reply_payload_sending` events may include `usageState`, a best-effort live
per-turn model/usage/context snapshot. Durable delivery, recovered replay, and
replies without exact run correlation omit it.
Message hook contexts expose stable correlation fields when available:
`ctx.sessionKey`, `ctx.runId`, `ctx.messageId`, `ctx.senderId`, `ctx.trace`,
`ctx.traceId`, `ctx.spanId`, `ctx.parentSpanId`, and `ctx.callDepth`. Inbound

View File

@@ -25,7 +25,6 @@ less like a pile of Markdown files.
- Page-level provenance, confidence, contradictions, and open questions
- Compiled digests for agent/runtime consumers
- Wiki-native search/get/apply/lint tools
- Open Knowledge Format imports into compiled wiki concepts
- Optional bridge mode that imports public artifacts from the active memory plugin
- Optional Obsidian-friendly render mode and CLI integration
@@ -136,34 +135,6 @@ The main page groups are:
- `syntheses/` for compiled summaries and maintained rollups
- `reports/` for generated dashboards
## Open Knowledge Format imports
`memory-wiki` can import unpacked Open Knowledge Format bundles with:
```bash
openclaw wiki okf import ./bundles/ga4
```
This is the cleanest fit when a data catalog, documentation crawler, or
enrichment agent already produces OKF: keep OKF as the portable exchange
artifact, then let `memory-wiki` turn it into OpenClaw-native concept pages and
compiled digests.
The importer follows the OKF v0.1 shape:
- non-reserved `.md` files are concept documents
- each imported concept needs a non-empty `type` frontmatter field
- unknown OKF `type` values are accepted
- reserved `index.md` and `log.md` files are not imported as concepts
- broken or external markdown links are preserved
Imported concept pages are flattened under `concepts/` so the existing compile,
search, get, dashboard, and prompt-digest paths see them without adding a second
wiki tree. Each page keeps the original OKF concept ID, source path, `type`,
`resource`, `tags`, timestamp, and full producer frontmatter. Internal OKF links
are rewritten to the generated wiki concept pages and also emitted as structured
`relationships` entries with `kind: okf-link`.
## Structured claims and evidence
Pages can carry structured `claims` frontmatter, not just freeform text.

View File

@@ -101,28 +101,6 @@
"contextWindow": 200000,
"maxTokens": 64000
},
{
"id": "claude-haiku-4-5",
"name": "Claude Haiku 4.5",
"reasoning": true,
"input": ["text", "image"],
"mediaInput": {
"image": { "maxSidePx": 1568, "preferredSidePx": 1568, "tokenMode": "provider" }
},
"contextWindow": 200000,
"maxTokens": 64000
},
{
"id": "claude-haiku-4-5-20251001",
"name": "Claude Haiku 4.5",
"reasoning": true,
"input": ["text", "image"],
"mediaInput": {
"image": { "maxSidePx": 1568, "preferredSidePx": 1568, "tokenMode": "provider" }
},
"contextWindow": 200000,
"maxTokens": 64000
},
{
"id": "claude-sonnet-4-6",
"name": "Claude Sonnet 4.6",

View File

@@ -1,57 +0,0 @@
// Anthropic tests cover provider manifest model catalog behavior.
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
type AnthropicManifest = {
modelCatalog?: {
providers?: {
anthropic?: {
models?: Array<{
id?: string;
name?: string;
reasoning?: boolean;
input?: string[];
mediaInput?: {
image?: {
maxSidePx?: number;
preferredSidePx?: number;
tokenMode?: string;
};
};
contextWindow?: number;
maxTokens?: number;
}>;
};
};
discovery?: Record<string, string>;
};
};
const manifest = JSON.parse(
readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf8"),
) as AnthropicManifest;
describe("Anthropic plugin manifest", () => {
it("resolves both official Claude Haiku 4.5 API identifiers from the static catalog", () => {
expect(manifest.modelCatalog?.discovery?.anthropic).toBe("static");
const models = manifest.modelCatalog?.providers?.anthropic?.models ?? [];
for (const id of ["claude-haiku-4-5", "claude-haiku-4-5-20251001"]) {
expect(models.find((model) => model.id === id)).toEqual({
id,
name: "Claude Haiku 4.5",
reasoning: true,
input: ["text", "image"],
mediaInput: {
image: {
maxSidePx: 1568,
preferredSidePx: 1568,
tokenMode: "provider",
},
},
contextWindow: 200000,
maxTokens: 64000,
});
}
});
});

View File

@@ -56,10 +56,6 @@ function isCopilotGeminiModelId(modelId: string): boolean {
return /(?:^|[-_.])gemini(?:$|[-_.])/.test(modelId);
}
function isCopilotClaude45ModelId(modelId: string): boolean {
return /^claude-(?:haiku|opus|sonnet)-4[.-]5(?:$|[-.])/.test(modelId);
}
export function resolveCopilotTransportApi(modelId: string): CopilotRuntimeApi {
const normalized = normalizeOptionalLowercaseString(modelId) ?? "";
if (normalized.includes("claude")) {
@@ -75,15 +71,7 @@ export function resolveCopilotModelCompat(
modelId: string,
): ModelDefinitionConfig["compat"] | undefined {
const normalized = normalizeOptionalLowercaseString(modelId) ?? "";
if (isCopilotGeminiModelId(normalized)) {
return { ...COPILOT_CHAT_COMPLETIONS_COMPAT };
}
// Copilot's Claude 4.5 endpoints reject Anthropic's eager tool extension,
// while current Claude 4.6+ endpoints accept it.
if (isCopilotClaude45ModelId(normalized)) {
return { supportsEagerToolInputStreaming: false };
}
return undefined;
return isCopilotGeminiModelId(normalized) ? { ...COPILOT_CHAT_COMPLETIONS_COMPAT } : undefined;
}
function compatSupportsEffort(

View File

@@ -90,18 +90,8 @@ describe("github-copilot model defaults", () => {
const def = buildCopilotModelDefinition("claude-sonnet-4.6");
expect(def.id).toBe("claude-sonnet-4.6");
expect(def.api).toBe("anthropic-messages");
expect(def.compat).toBeUndefined();
});
it.each(["claude-haiku-4.5", "claude-sonnet-4-5"])(
"disables eager tool streaming for Copilot Claude 4.5 model %s",
(modelId) => {
expect(buildCopilotModelDefinition(modelId).compat).toEqual({
supportsEagerToolInputStreaming: false,
});
},
);
it("uses static metadata overrides for gpt-5.5 fallback rows", () => {
const def = buildCopilotModelDefinition("gpt-5.5");
expect(def).toEqual({
@@ -253,12 +243,6 @@ describe("resolveCopilotForwardCompatModel", () => {
expect((result as unknown as Record<string, unknown>).input).toEqual(["text", "image"]);
});
it("disables eager tool streaming for synthetic Copilot Claude 4.5 models", () => {
const result = requireResolvedModel(createMockCtx("claude-haiku-4.5"));
expect(result.api).toBe("anthropic-messages");
expect(result.compat).toEqual({ supportsEagerToolInputStreaming: false });
});
it("creates synthetic Gemini models with Chat Completions compatibility", () => {
const result = requireResolvedModel(createMockCtx("gemini-3.1-pro-preview"));
expect((result as unknown as Record<string, unknown>).api).toBe("openai-completions");
@@ -636,7 +620,6 @@ describe("fetchCopilotModelCatalog", () => {
const opus45 = out.find((m) => m.id === "claude-opus-4-5");
expect(opus45?.thinkingLevelMap).toEqual({ xhigh: null, max: null });
expect(opus45?.compat).toEqual({
supportsEagerToolInputStreaming: false,
supportedReasoningEfforts: ["low", "medium", "high", "max"],
});
});

View File

@@ -10,7 +10,6 @@ import {
runWikiChatGptImport,
runWikiChatGptRollback,
runWikiDoctor,
runWikiOkfImport,
runWikiStatus,
} from "./cli.js";
import type { MemoryWikiPluginConfig } from "./config.js";
@@ -28,7 +27,6 @@ vi.mock("openclaw/plugin-sdk/gateway-runtime", () => ({
const { createVault } = createMemoryWikiTestHarness();
let suiteRoot = "";
let caseIndex = 0;
let stdoutWriteMock: ReturnType<typeof vi.fn>;
describe("memory-wiki cli", () => {
beforeAll(async () => {
@@ -43,9 +41,8 @@ describe("memory-wiki cli", () => {
beforeEach(() => {
callGatewayFromCliMock.mockReset();
stdoutWriteMock = vi.fn(() => true);
vi.spyOn(process.stdout, "write").mockImplementation(
stdoutWriteMock as unknown as typeof process.stdout.write,
(() => true) as typeof process.stdout.write,
);
process.exitCode = undefined;
});
@@ -177,65 +174,6 @@ describe("memory-wiki cli", () => {
);
});
it("registers OKF import and searches imported concepts", async () => {
const { rootDir, config } = await createCliVault();
const bundlePath = path.join(rootDir, "okf-bundle");
await fs.mkdir(path.join(bundlePath, "tables"), { recursive: true });
await fs.writeFile(path.join(bundlePath, "index.md"), "# Sales OKF\n", "utf8");
await fs.writeFile(
path.join(bundlePath, "tables", "customers.md"),
`---
type: BigQuery Table
title: Customers
---
Customer rows.
`,
"utf8",
);
await fs.writeFile(
path.join(bundlePath, "tables", "orders.md"),
`---
type: BigQuery Table
title: Orders
description: One row per completed order.
---
Orders join to [customers](/tables/customers.md).
`,
"utf8",
);
const program = new Command();
program.name("test");
registerWikiCli(program, config);
await program.parseAsync(["wiki", "okf", "import", bundlePath, "--json"], { from: "user" });
const importOutput = String(stdoutWriteMock.mock.calls.at(-1)?.[0] ?? "");
const importResult = JSON.parse(importOutput) as Awaited<ReturnType<typeof runWikiOkfImport>>;
expect(importResult.importedCount).toBe(2);
expect(importResult.pagePaths).toEqual(
expect.arrayContaining([
expect.stringMatching(/^concepts\/okf-okf-bundle-[0-9a-f]{8}-tables-orders-/),
]),
);
stdoutWriteMock.mockClear();
await program.parseAsync(["wiki", "search", "completed order", "--json"], { from: "user" });
const searchOutput = String(stdoutWriteMock.mock.calls.at(-1)?.[0] ?? "");
const searchResults = JSON.parse(searchOutput) as Array<{ path: string; title: string }>;
expect(searchResults).toEqual(
expect.arrayContaining([
expect.objectContaining({
title: "Orders",
path: expect.stringMatching(/^concepts\/okf-okf-bundle-[0-9a-f]{8}-tables-orders-/),
}),
]),
);
});
it("rejects apply confidence values outside the documented range", async () => {
const { config } = await createCliVault();
const program = new Command();

View File

@@ -33,7 +33,6 @@ import {
runObsidianOpen,
runObsidianSearch,
} from "./obsidian.js";
import { formatOkfImportSummary, importMemoryWikiOkfBundle } from "./okf.js";
import {
getMemoryWikiPage,
searchMemoryWiki,
@@ -89,10 +88,6 @@ type WikiIngestCommandOptions = {
title?: string;
};
type WikiOkfImportCommandOptions = {
json?: boolean;
};
type WikiSearchCommandOptions = {
json?: boolean;
maxResults?: number;
@@ -595,24 +590,6 @@ export async function runWikiIngest(params: {
});
}
export async function runWikiOkfImport(params: {
config: ResolvedMemoryWikiConfig;
bundlePath: string;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
return runWikiCommandWithSummary({
json: params.json,
stdout: params.stdout,
run: () =>
importMemoryWikiOkfBundle({
config: params.config,
bundlePath: params.bundlePath,
}),
render: formatOkfImportSummary,
});
}
export async function runWikiSearch(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
@@ -988,16 +965,6 @@ export function registerWikiCli(
await runWikiIngest({ config, inputPath, title: opts.title, json: opts.json });
});
const okf = wiki.command("okf").description("Import Open Knowledge Format bundles");
okf
.command("import")
.description("Import an unpacked OKF bundle into wiki concept pages")
.argument("<path>", "OKF bundle directory")
.option("--json", "Print JSON")
.action(async (bundlePath: string, opts: WikiOkfImportCommandOptions) => {
await runWikiOkfImport({ config, bundlePath, json: opts.json });
});
addWikiSearchConfigOptions(
wiki
.command("search")

View File

@@ -4,7 +4,7 @@ import path from "node:path";
import { appendRegularFile } from "openclaw/plugin-sdk/security-runtime";
type MemoryWikiLogEntry = {
type: "init" | "ingest" | "okf-import" | "compile" | "lint";
type: "init" | "ingest" | "compile" | "lint";
timestamp: string;
details?: Record<string, unknown>;
};

View File

@@ -1,609 +0,0 @@
// Memory Wiki tests cover Open Knowledge Format import behavior.
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { parseWikiMarkdown } from "./markdown.js";
import { importMemoryWikiOkfBundle } from "./okf.js";
import { searchMemoryWiki } from "./query.js";
import { createMemoryWikiTestHarness } from "./test-helpers.js";
const { createTempDir, createVault } = createMemoryWikiTestHarness();
function getOnlyPagePath(paths: string[]): string {
expect(paths).toHaveLength(1);
const [pagePath] = paths;
if (!pagePath) {
throw new Error("Expected OKF import to produce one page path.");
}
return pagePath;
}
async function writeOkfBundle(rootDir: string) {
const bundlePath = path.join(rootDir, "sales-okf");
await fs.mkdir(path.join(bundlePath, "tables"), { recursive: true });
await fs.mkdir(path.join(bundlePath, "metrics"), { recursive: true });
await fs.writeFile(
path.join(bundlePath, "index.md"),
`---
id: sales-okf
okf_version: "0.1"
---
# Sales Bundle
`,
"utf8",
);
await fs.writeFile(path.join(bundlePath, "log.md"), "# Directory Update Log\n", "utf8");
await fs.writeFile(
path.join(bundlePath, "tables", "customers.md"),
`---
type: BigQuery Table
title: Customers
description: Customer table.
resource: https://console.cloud.google.com/bigquery?p=acme&d=sales&t=customers
tags: [sales, customers]
timestamp: 2026-05-28T00:00:00Z
producer_field:
owner: data
---
# Schema
Customer rows.
`,
"utf8",
);
await fs.writeFile(
path.join(bundlePath, "tables", "orders.md"),
`---
type: BigQuery Table
title: Orders
description: One row per completed order.
tags:
- sales
- orders
---
# Schema
Joined with [Customers](/tables/customers.md) and the [weekly metric](../metrics/weekly-active-users.md).
Titled link to [weekly metric](../metrics/weekly-active-users.md "metric docs").
Inline code keeps \`[customers](/tables/customers.md)\` unchanged.
\`\`\`markdown
[customers](/tables/customers.md)
\`\`\`
External citation stays as [BigQuery](https://cloud.google.com/bigquery).
`,
"utf8",
);
await fs.writeFile(
path.join(bundlePath, "metrics", "weekly-active-users.md"),
`---
type: Metric
title: Weekly Active Users
---
Computed from [orders](../tables/orders.md).
`,
"utf8",
);
await fs.writeFile(
path.join(bundlePath, "tables", "draft.md"),
`---
title: Draft
---
Missing type.
`,
"utf8",
);
return bundlePath;
}
describe("importMemoryWikiOkfBundle", () => {
it("imports OKF concept documents as searchable wiki concept pages", async () => {
const rootDir = await createTempDir("memory-wiki-okf-");
const bundlePath = await writeOkfBundle(rootDir);
const { config } = await createVault({
rootDir: path.join(rootDir, "vault"),
});
const result = await importMemoryWikiOkfBundle({
config,
bundlePath,
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
});
expect(result.okfVersion).toBe("0.1");
expect(result.importedCount).toBe(3);
expect(result.skippedCount).toBe(1);
expect(result.warnings[0]).toMatchObject({
code: "missing-type",
path: "tables/draft.md",
});
expect(result.pagePaths).toHaveLength(3);
const repeat = await importMemoryWikiOkfBundle({
config,
bundlePath,
nowMs: Date.UTC(2026, 5, 12, 10, 5, 0),
});
expect(repeat.importedCount).toBe(3);
expect(repeat.updatedCount).toBe(0);
const ordersPath = result.pagePaths.find((pagePath) => pagePath.includes("orders"));
expect(ordersPath).toBeTruthy();
const ordersRaw = await fs.readFile(path.join(config.vault.path, ordersPath!), "utf8");
const orders = parseWikiMarkdown(ordersRaw);
expect(orders.frontmatter).toMatchObject({
pageType: "concept",
title: "Orders",
sourceType: "okf",
provenanceMode: "okf-import",
okfConceptId: "tables/orders",
okfType: "BigQuery Table",
});
expect(orders.frontmatter.sourceIds).toEqual([
expect.stringMatching(/^source\.okf\.sales-okf$/),
]);
expect(orders.body).toMatch(/\]\(okf-sales-okf-tables-customers-/);
expect(orders.body).toMatch(/\]\(okf-sales-okf-metrics-weekly-active-users-/);
expect(orders.body).toContain('"metric docs"');
expect(orders.body).toContain("`[customers](/tables/customers.md)`");
expect(orders.body).toContain("```markdown\n[customers](/tables/customers.md)\n```");
expect(orders.body).toContain("https://cloud.google.com/bigquery");
const okf = orders.frontmatter.okf as Record<string, unknown>;
expect(okf).toMatchObject({
version: "0.1",
bundleName: "sales-okf",
conceptId: "tables/orders",
sourceRelativePath: "tables/orders.md",
});
expect(orders.frontmatter.relationships).toEqual(
expect.arrayContaining([
expect.objectContaining({
targetPath: expect.stringMatching(/^concepts\/okf-sales-okf-tables-customers-/),
kind: "okf-link",
}),
expect.objectContaining({
targetPath: expect.stringMatching(
/^concepts\/okf-sales-okf-metrics-weekly-active-users-/,
),
kind: "okf-link",
}),
]),
);
const customersPath = result.pagePaths.find((pagePath) => pagePath.includes("customers"));
const customersRaw = await fs.readFile(path.join(config.vault.path, customersPath!), "utf8");
const customers = parseWikiMarkdown(customersRaw);
const customersOkf = customers.frontmatter.okf as Record<string, unknown>;
expect(customersOkf.frontmatter).toMatchObject({
producer_field: { owner: "data" },
});
const searchResults = await searchMemoryWiki({
config,
query: "completed order",
searchCorpus: "wiki",
});
expect(searchResults.map((searchResult) => searchResult.path)).toContain(ordersPath);
});
it("caps generated concept filenames for long OKF concept paths", async () => {
const rootDir = await createTempDir("memory-wiki-okf-long-");
const bundlePath = path.join(rootDir, "long-okf");
const deepSegments = Array.from({ length: 40 }, (_, index) => `segment-${index}`);
const deepDir = path.join(bundlePath, ...deepSegments);
await fs.mkdir(deepDir, { recursive: true });
await fs.writeFile(
path.join(deepDir, "orders.md"),
`---
type: BigQuery Table
title: Long Orders
---
Long concept body.
`,
"utf8",
);
const { config } = await createVault({
rootDir: path.join(rootDir, "vault"),
});
const result = await importMemoryWikiOkfBundle({
config,
bundlePath,
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
});
expect(result.importedCount).toBe(1);
const [pagePath] = result.pagePaths;
expect(pagePath).toBeDefined();
if (!pagePath) {
throw new Error("Expected OKF import to produce a page path.");
}
const fileName = path.basename(pagePath);
expect(Buffer.byteLength(fileName)).toBeLessThanOrEqual(255);
await expect(fs.readFile(path.join(config.vault.path, pagePath), "utf8")).resolves.toContain(
"Long concept body.",
);
});
it("namespaces concept pages by bundle so repeated OKF paths do not overwrite", async () => {
const rootDir = await createTempDir("memory-wiki-okf-bundles-");
const firstBundle = path.join(rootDir, "first-bundle");
const secondBundle = path.join(rootDir, "second-bundle");
for (const [bundlePath, title] of [
[firstBundle, "First Customers"],
[secondBundle, "Second Customers"],
] as const) {
await fs.mkdir(path.join(bundlePath, "tables"), { recursive: true });
await fs.writeFile(
path.join(bundlePath, "tables", "customers.md"),
`---
type: BigQuery Table
title: ${title}
---
${title} body.
`,
"utf8",
);
}
const { config } = await createVault({
rootDir: path.join(rootDir, "vault"),
});
const first = await importMemoryWikiOkfBundle({
config,
bundlePath: firstBundle,
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
});
const second = await importMemoryWikiOkfBundle({
config,
bundlePath: secondBundle,
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
});
const firstPath = getOnlyPagePath(first.pagePaths);
const secondPath = getOnlyPagePath(second.pagePaths);
expect(firstPath).not.toBe(secondPath);
await expect(fs.readFile(path.join(config.vault.path, firstPath), "utf8")).resolves.toContain(
"First Customers body.",
);
await expect(fs.readFile(path.join(config.vault.path, secondPath), "utf8")).resolves.toContain(
"Second Customers body.",
);
});
it("removes stale concept pages when an OKF bundle drops a concept", async () => {
const rootDir = await createTempDir("memory-wiki-okf-remove-");
const bundlePath = path.join(rootDir, "removing-okf");
await fs.mkdir(path.join(bundlePath, "tables"), { recursive: true });
const customersPath = path.join(bundlePath, "tables", "customers.md");
const ordersPath = path.join(bundlePath, "tables", "orders.md");
await fs.writeFile(
customersPath,
`---
type: BigQuery Table
title: Customers
---
Customer body.
`,
"utf8",
);
await fs.writeFile(
ordersPath,
`---
type: BigQuery Table
title: Orders
---
Order body.
`,
"utf8",
);
const { config } = await createVault({
rootDir: path.join(rootDir, "vault"),
});
const first = await importMemoryWikiOkfBundle({
config,
bundlePath,
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
});
const stalePagePath = first.pagePaths.find((pagePath) => pagePath.includes("orders"));
expect(stalePagePath).toBeDefined();
if (!stalePagePath) {
throw new Error("Expected initial OKF import to include orders.");
}
await fs.rm(ordersPath);
const second = await importMemoryWikiOkfBundle({
config,
bundlePath,
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
});
expect(second.importedCount).toBe(1);
expect(second.removedCount).toBe(1);
expect(second.removedPagePaths).toEqual([stalePagePath]);
await expect(fs.stat(path.join(config.vault.path, stalePagePath))).rejects.toThrow();
const results = await searchMemoryWiki({
config,
query: "Order body",
searchCorpus: "wiki",
});
expect(results).toHaveLength(0);
});
it("does not prune existing pages when current OKF scan has invalid concepts", async () => {
const rootDir = await createTempDir("memory-wiki-okf-invalid-");
const bundlePath = path.join(rootDir, "invalid-okf");
await fs.mkdir(path.join(bundlePath, "tables"), { recursive: true });
const customersPath = path.join(bundlePath, "tables", "customers.md");
await fs.writeFile(
customersPath,
`---
type: BigQuery Table
title: Customers
---
Customer body.
`,
"utf8",
);
const { config } = await createVault({
rootDir: path.join(rootDir, "vault"),
});
const first = await importMemoryWikiOkfBundle({
config,
bundlePath,
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
});
const pagePath = getOnlyPagePath(first.pagePaths);
await fs.writeFile(
customersPath,
`---
title: Customers
---
Temporarily invalid body.
`,
"utf8",
);
const second = await importMemoryWikiOkfBundle({
config,
bundlePath,
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
});
expect(second.importedCount).toBe(0);
expect(second.skippedCount).toBe(1);
expect(second.removedCount).toBe(0);
await expect(fs.readFile(path.join(config.vault.path, pagePath), "utf8")).resolves.toContain(
"Customer body.",
);
});
it("detects body-only changes on timestamp-shaped markdown lines", async () => {
const rootDir = await createTempDir("memory-wiki-okf-body-timestamp-");
const bundlePath = path.join(rootDir, "body-timestamp-okf");
await fs.mkdir(path.join(bundlePath, "tables"), { recursive: true });
const conceptPath = path.join(bundlePath, "tables", "events.md");
await fs.writeFile(
conceptPath,
`---
type: BigQuery Table
title: Events
---
updatedAt: 2026-06-12
`,
"utf8",
);
const { config } = await createVault({
rootDir: path.join(rootDir, "vault"),
});
const first = await importMemoryWikiOkfBundle({
config,
bundlePath,
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
});
const pagePath = getOnlyPagePath(first.pagePaths);
await fs.writeFile(
conceptPath,
`---
type: BigQuery Table
title: Events
---
updatedAt: 2026-06-13
`,
"utf8",
);
const second = await importMemoryWikiOkfBundle({
config,
bundlePath,
nowMs: Date.UTC(2026, 5, 13, 10, 0, 0),
});
expect(second.updatedCount).toBe(1);
await expect(fs.readFile(path.join(config.vault.path, pagePath), "utf8")).resolves.toContain(
"updatedAt: 2026-06-13",
);
});
it("rewrites percent-encoded OKF markdown links and preserves suffixes", async () => {
const rootDir = await createTempDir("memory-wiki-okf-encoded-link-");
const bundlePath = path.join(rootDir, "encoded-okf");
await fs.mkdir(bundlePath, { recursive: true });
await fs.writeFile(
path.join(bundlePath, "BigQuery Table.md"),
`---
type: BigQuery Table
title: BigQuery Table
---
Table body.
`,
"utf8",
);
await fs.writeFile(
path.join(bundlePath, "links.md"),
`---
type: Concept
title: Links
---
See [table](BigQuery%20Table.md?view=compact#columns).
`,
"utf8",
);
const { config } = await createVault({
rootDir: path.join(rootDir, "vault"),
});
const result = await importMemoryWikiOkfBundle({
config,
bundlePath,
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
});
const linksPath = result.pagePaths.find((pagePath) => pagePath.includes("links"));
expect(linksPath).toBeDefined();
if (!linksPath) {
throw new Error("Expected links page to be imported.");
}
await expect(fs.readFile(path.join(config.vault.path, linksPath), "utf8")).resolves.toMatch(
/\[table\]\(okf-encoded-okf-[0-9a-f]{8}-bigquery-table-[^)]+\.md\?view=compact#columns\)/,
);
});
it("imports OKF concept frontmatter with CRLF line endings", async () => {
const rootDir = await createTempDir("memory-wiki-okf-crlf-");
const bundlePath = path.join(rootDir, "crlf-okf");
await fs.mkdir(path.join(bundlePath, "tables"), { recursive: true });
await fs.writeFile(
path.join(bundlePath, "tables", "events.md"),
[
"---",
"type: BigQuery Table",
"title: Events",
"---",
"",
"Windows-flavored frontmatter.",
"",
].join("\r\n"),
"utf8",
);
const { config } = await createVault({
rootDir: path.join(rootDir, "vault"),
});
const result = await importMemoryWikiOkfBundle({
config,
bundlePath,
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
});
expect(result.importedCount).toBe(1);
expect(result.skippedCount).toBe(0);
await expect(
fs.readFile(path.join(config.vault.path, getOnlyPagePath(result.pagePaths)), "utf8"),
).resolves.toContain("Windows-flavored frontmatter.");
});
it("refuses to write imported OKF concept pages through symlinks", async () => {
const rootDir = await createTempDir("memory-wiki-okf-symlink-");
const bundlePath = path.join(rootDir, "safe-okf");
await fs.mkdir(path.join(bundlePath, "tables"), { recursive: true });
const conceptPath = path.join(bundlePath, "tables", "customers.md");
await fs.writeFile(
conceptPath,
`---
type: BigQuery Table
title: Customers
---
Original body.
`,
"utf8",
);
const { config } = await createVault({
rootDir: path.join(rootDir, "vault"),
});
const first = await importMemoryWikiOkfBundle({
config,
bundlePath,
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
});
const pagePath = getOnlyPagePath(first.pagePaths);
const pageAbsolutePath = path.join(config.vault.path, pagePath);
const externalTarget = path.join(rootDir, "outside.md");
await fs.writeFile(externalTarget, "external target\n", "utf8");
await fs.rm(pageAbsolutePath);
await fs.symlink(externalTarget, pageAbsolutePath);
await fs.writeFile(
conceptPath,
`---
type: BigQuery Table
title: Customers
---
Updated body.
`,
"utf8",
);
await expect(
importMemoryWikiOkfBundle({
config,
bundlePath,
nowMs: Date.UTC(2026, 5, 12, 11, 0, 0),
}),
).rejects.toThrow("through symlink");
await expect(fs.readFile(externalTarget, "utf8")).resolves.toBe("external target\n");
});
it("refuses to import OKF concept files through hardlinks", async () => {
const rootDir = await createTempDir("memory-wiki-okf-hardlink-");
const bundlePath = path.join(rootDir, "hardlink-okf");
await fs.mkdir(path.join(bundlePath, "tables"), { recursive: true });
const externalSource = path.join(rootDir, "outside.md");
await fs.writeFile(
externalSource,
`---
type: BigQuery Table
title: Private
---
private body
`,
"utf8",
);
await fs.link(externalSource, path.join(bundlePath, "tables", "private.md"));
const { config } = await createVault({
rootDir: path.join(rootDir, "vault"),
});
const result = await importMemoryWikiOkfBundle({
config,
bundlePath,
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
});
expect(result.importedCount).toBe(0);
expect(result.skippedCount).toBe(1);
expect(result.warnings[0]).toMatchObject({
code: "unreadable-entry",
path: "tables/private.md",
});
});
});

View File

@@ -1,746 +0,0 @@
// Memory Wiki plugin module implements Open Knowledge Format import behavior.
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { FsSafeError, root as fsRoot } from "openclaw/plugin-sdk/security-runtime";
import {
normalizeOptionalString,
normalizeSingleOrTrimmedStringList,
uniqueStrings,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { compileMemoryWikiVault } from "./compile.js";
import type { ResolvedMemoryWikiConfig } from "./config.js";
import { appendMemoryWikiLog } from "./log.js";
import {
createWikiPageFilename,
parseWikiMarkdown,
renderWikiMarkdown,
slugifyWikiSegment,
WIKI_RELATED_END_MARKER,
WIKI_RELATED_START_MARKER,
} from "./markdown.js";
import { resolveMemoryWikiTimestamp } from "./time.js";
import { initializeMemoryWikiVault } from "./vault.js";
const OKF_RESERVED_FILENAMES = new Set(["index.md", "log.md"]);
const OKF_MARKDOWN_LINK_PATTERN = /(!?)\[([^\]]*)\]\(([^)]+)\)/g;
const OKF_FENCE_PATTERN = /^ {0,3}(`{3,}|~{3,})/;
const OKF_RELATED_SECTION_PATTERN = new RegExp(
`\\n+## Related\\n${WIKI_RELATED_START_MARKER}[\\s\\S]*?${WIKI_RELATED_END_MARKER}\\n?`,
"g",
);
const OKF_VOLATILE_TIMESTAMP_LINE_PATTERN = /^(?:importedAt|updatedAt): .*\n/gm;
const OKF_HASH_CHARS = 8;
type FileStatLike = {
isFile?: unknown;
nlink?: unknown;
};
type OkfConceptDocument = {
conceptId: string;
relativePath: string;
absolutePath: string;
frontmatter: Record<string, unknown>;
body: string;
type: string;
title: string;
description?: string;
resource?: string;
tags: string[];
timestamp?: string;
};
type OkfImportedPage = {
conceptId: string;
sourcePath: string;
pageId: string;
pagePath: string;
title: string;
created: boolean;
};
export type ImportMemoryWikiOkfWarning = {
code: "invalid-concept" | "missing-type" | "unreadable-entry";
path: string;
message: string;
};
export type ImportMemoryWikiOkfResult = {
bundlePath: string;
bundleName: string;
okfVersion?: string;
importedCount: number;
updatedCount: number;
removedCount: number;
skippedCount: number;
pagePaths: string[];
removedPagePaths: string[];
warnings: ImportMemoryWikiOkfWarning[];
indexUpdatedFiles: string[];
};
function toPosixPath(value: string): string {
return value.split(path.sep).join("/");
}
function trimMarkdownExtension(value: string): string {
return value.replace(/\.md$/i, "");
}
function isRegularFileStat(value: unknown): value is FileStatLike & { nlink: number } {
if (!value || typeof value !== "object") {
return false;
}
const stat = value as FileStatLike;
const isFile =
typeof stat.isFile === "function"
? (stat.isFile as () => boolean).call(stat)
: stat.isFile === true;
return isFile && typeof stat.nlink === "number";
}
type OkfBundleMetadata = {
key: string;
version?: string;
};
function createOkfBundleKey(params: {
rootFrontmatter: Record<string, unknown>;
bundleName: string;
bundlePath: string;
}): string {
const producerId =
normalizeOptionalString(params.rootFrontmatter.id) ??
normalizeOptionalString(params.rootFrontmatter.okf_id);
if (producerId) {
return slugifyWikiSegment(producerId);
}
const label =
normalizeOptionalString(params.rootFrontmatter.name) ??
normalizeOptionalString(params.rootFrontmatter.title) ??
params.bundleName;
const hash = createHash("sha1").update(params.bundlePath).digest("hex").slice(0, OKF_HASH_CHARS);
return `${slugifyWikiSegment(label)}-${hash}`;
}
function createOkfPageStem(bundleKey: string, conceptId: string): string {
const slug = slugifyWikiSegment(conceptId.replace(/\//g, "-"));
const hash = createHash("sha1").update(conceptId).digest("hex").slice(0, OKF_HASH_CHARS);
return `okf-${bundleKey}-${slug}-${hash}`;
}
function createOkfPageIdentity(
bundleKey: string,
conceptId: string,
): { pageId: string; pagePath: string } {
const fileName = createWikiPageFilename(createOkfPageStem(bundleKey, conceptId));
const stem = trimMarkdownExtension(fileName);
return {
pageId: `concept.${stem}`,
pagePath: `concepts/${fileName}`,
};
}
async function collectOkfMarkdownFiles(
rootDir: string,
warnings: ImportMemoryWikiOkfWarning[],
): Promise<string[]> {
async function walk(relativeDir: string): Promise<string[]> {
const absoluteDir = path.join(rootDir, relativeDir);
const entries = await fs.readdir(absoluteDir, { withFileTypes: true }).catch((err: unknown) => {
warnings.push({
code: "unreadable-entry",
path: toPosixPath(relativeDir) || ".",
message: err instanceof Error ? err.message : "Unable to read OKF directory.",
});
return [];
});
const files: string[] = [];
for (const entry of entries.toSorted((left, right) => left.name.localeCompare(right.name))) {
if (entry.name === ".git" || entry.name === "node_modules") {
continue;
}
const relativePath = path.join(relativeDir, entry.name);
if (entry.isDirectory()) {
files.push(...(await walk(relativePath)));
continue;
}
if (entry.isFile() && entry.name.endsWith(".md")) {
files.push(relativePath);
}
}
return files;
}
return (await walk("")).map(toPosixPath).toSorted((left, right) => left.localeCompare(right));
}
function parseOkfMarkdown(
content: string,
relativePath: string,
): {
frontmatter: Record<string, unknown>;
body: string;
warning?: ImportMemoryWikiOkfWarning;
} {
const normalizedContent = content.replace(/\r\n/g, "\n");
try {
return parseWikiMarkdown(normalizedContent);
} catch (err) {
return {
frontmatter: {},
body: normalizedContent,
warning: {
code: "invalid-concept",
path: relativePath,
message: err instanceof Error ? err.message : "Unable to parse OKF frontmatter.",
},
};
}
}
async function readOkfTextFile(params: {
bundlePath: string;
relativePath: string;
warnings: ImportMemoryWikiOkfWarning[];
}): Promise<string | null> {
const root = await fsRoot(params.bundlePath);
const stat = await root.stat(params.relativePath).catch((err: unknown) => {
params.warnings.push({
code: "unreadable-entry",
path: params.relativePath,
message: err instanceof Error ? err.message : "Unable to read OKF concept.",
});
return null;
});
if (!stat) {
return null;
}
if (!isRegularFileStat(stat)) {
params.warnings.push({
code: "unreadable-entry",
path: params.relativePath,
message: "Refusing to import OKF concept through non-regular or hardlinked file.",
});
return null;
}
return await root.readText(params.relativePath).catch((err: unknown) => {
params.warnings.push({
code: "unreadable-entry",
path: params.relativePath,
message: err instanceof Error ? err.message : "Unable to read OKF concept.",
});
return null;
});
}
function deriveOkfTitle(relativePath: string, frontmatter: Record<string, unknown>): string {
return (
normalizeOptionalString(frontmatter.title) ??
path.posix.basename(relativePath, ".md").replace(/[-_]+/g, " ").trim() ??
trimMarkdownExtension(relativePath)
);
}
function normalizeOkfConcept(params: {
bundlePath: string;
relativePath: string;
content: string;
}): { concept?: OkfConceptDocument; warning?: ImportMemoryWikiOkfWarning } {
const parsed = parseOkfMarkdown(params.content, params.relativePath);
if (parsed.warning) {
return { warning: parsed.warning };
}
const type = normalizeOptionalString(parsed.frontmatter.type);
if (!type) {
return {
warning: {
code: "missing-type",
path: params.relativePath,
message: "OKF concept is missing required non-empty type frontmatter.",
},
};
}
const conceptId = trimMarkdownExtension(params.relativePath);
const timestamp = normalizeOptionalString(parsed.frontmatter.timestamp);
return {
concept: {
conceptId,
relativePath: params.relativePath,
absolutePath: path.join(params.bundlePath, params.relativePath),
frontmatter: parsed.frontmatter,
body: parsed.body,
type,
title: deriveOkfTitle(params.relativePath, parsed.frontmatter),
...(normalizeOptionalString(parsed.frontmatter.description)
? { description: normalizeOptionalString(parsed.frontmatter.description) }
: {}),
...(normalizeOptionalString(parsed.frontmatter.resource)
? { resource: normalizeOptionalString(parsed.frontmatter.resource) }
: {}),
tags: normalizeSingleOrTrimmedStringList(parsed.frontmatter.tags),
...(timestamp ? { timestamp } : {}),
},
};
}
function splitMarkdownLinkDestination(target: string): {
destination: string;
titleSuffix: string;
} {
const trimmed = target.trim();
if (trimmed.startsWith("<")) {
const end = trimmed.indexOf(">");
if (end > 0) {
return {
destination: trimmed.slice(1, end),
titleSuffix: trimmed.slice(end + 1),
};
}
}
const match = trimmed.match(/^(\S+)(\s+[\s\S]+)?$/);
return {
destination: match?.[1] ?? trimmed,
titleSuffix: match?.[2] ?? "",
};
}
function resolveOkfMarkdownTarget(sourceRelativePath: string, target: string): string | null {
const { destination } = splitMarkdownLinkDestination(target);
const trimmed = destination.trim();
if (!trimmed || trimmed.startsWith("#") || /^[a-z][a-z0-9+.-]*:/i.test(trimmed)) {
return null;
}
const rawTargetWithoutSuffix = trimmed.split("#")[0]?.split("?")[0]?.replace(/\\/g, "/").trim();
const targetWithoutSuffix = safeDecodeOkfLinkPath(rawTargetWithoutSuffix);
if (!targetWithoutSuffix || !targetWithoutSuffix.endsWith(".md")) {
return null;
}
const normalized = targetWithoutSuffix.startsWith("/")
? path.posix.normalize(targetWithoutSuffix.slice(1))
: path.posix.normalize(
path.posix.join(path.posix.dirname(sourceRelativePath), targetWithoutSuffix),
);
const conceptId = trimMarkdownExtension(normalized);
return conceptId.startsWith("../") ? null : conceptId;
}
function safeDecodeOkfLinkPath(value: string | undefined): string {
if (!value) {
return "";
}
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function getMarkdownDestinationSuffix(destination: string): string {
const queryIndex = destination.indexOf("?");
const fragmentIndex = destination.indexOf("#");
const suffixIndex = queryIndex === -1
? fragmentIndex
: fragmentIndex === -1
? queryIndex
: Math.min(queryIndex, fragmentIndex);
return suffixIndex === -1 ? "" : destination.slice(suffixIndex);
}
function rewriteOkfMarkdownLinks(params: {
body: string;
sourcePagePath: string;
sourceRelativePath: string;
pageByConceptId: Map<string, { pageId: string; pagePath: string; title: string }>;
}): { body: string; linkedConceptIds: string[] } {
const linkedConceptIds: string[] = [];
const rewriteLinks = (markdown: string) =>
markdown.replace(
OKF_MARKDOWN_LINK_PATTERN,
(match, imagePrefix: string, label: string, rawTarget: string) => {
const conceptId = resolveOkfMarkdownTarget(params.sourceRelativePath, rawTarget);
if (!conceptId) {
return match;
}
const target = params.pageByConceptId.get(conceptId);
if (!target) {
return match;
}
linkedConceptIds.push(conceptId);
const { destination, titleSuffix } = splitMarkdownLinkDestination(rawTarget);
const relativeTarget = path.posix.relative(
path.posix.dirname(params.sourcePagePath),
target.pagePath,
);
const suffix = getMarkdownDestinationSuffix(destination);
return `${imagePrefix}[${label}](${relativeTarget}${suffix}${titleSuffix})`;
},
);
const body = rewriteMarkdownOutsideCode(params.body, rewriteLinks);
return { body, linkedConceptIds: uniqueStrings(linkedConceptIds) };
}
function rewriteMarkdownLineOutsideInlineCode(
line: string,
rewriteLinks: (markdown: string) => string,
): string {
let result = "";
let cursor = 0;
while (cursor < line.length) {
const codeStart = line.indexOf("`", cursor);
if (codeStart === -1) {
result += rewriteLinks(line.slice(cursor));
break;
}
result += rewriteLinks(line.slice(cursor, codeStart));
const delimiter = line.slice(codeStart).match(/^`+/)?.[0] ?? "`";
const codeEnd = line.indexOf(delimiter, codeStart + delimiter.length);
if (codeEnd === -1) {
result += line.slice(codeStart);
break;
}
result += line.slice(codeStart, codeEnd + delimiter.length);
cursor = codeEnd + delimiter.length;
}
return result;
}
function rewriteMarkdownOutsideCode(
markdown: string,
rewriteLinks: (markdown: string) => string,
): string {
const lines = markdown.split(/(\n)/);
let inFence = false;
let fenceDelimiter = "";
return lines
.map((line) => {
if (line === "\n") {
return line;
}
const fenceMatch = line.match(OKF_FENCE_PATTERN);
if (fenceMatch) {
const delimiter = fenceMatch[1] ?? "";
const closesFence =
inFence &&
delimiter.startsWith(fenceDelimiter[0] ?? "") &&
delimiter.length >= fenceDelimiter.length;
const opensFence = !inFence;
if (opensFence) {
inFence = true;
fenceDelimiter = delimiter;
} else if (closesFence) {
inFence = false;
fenceDelimiter = "";
}
return line;
}
return inFence ? line : rewriteMarkdownLineOutsideInlineCode(line, rewriteLinks);
})
.join("");
}
function normalizeOkfRenderedPageForComparison(content: string): string {
const withoutRelated = content.replace(OKF_RELATED_SECTION_PATTERN, "\n");
const frontmatterMatch = withoutRelated.match(/^---\n([\s\S]*?)\n---\n?/);
if (!frontmatterMatch) {
return withoutRelated.trimEnd();
}
const normalizedFrontmatter =
frontmatterMatch[1]?.replace(OKF_VOLATILE_TIMESTAMP_LINE_PATTERN, "") ?? "";
const frontmatterBody = normalizedFrontmatter.endsWith("\n")
? normalizedFrontmatter
: `${normalizedFrontmatter}\n`;
return `---\n${frontmatterBody}---\n${withoutRelated.slice(frontmatterMatch[0].length)}`.trimEnd();
}
async function writeOkfConceptPage(params: {
vaultRoot: string;
pagePath: string;
content: string;
}): Promise<{ changed: boolean; created: boolean }> {
const vault = await fsRoot(params.vaultRoot);
const pageStat = await vault.stat(params.pagePath).catch((error: unknown) => {
if (
error instanceof FsSafeError &&
(error.code === "not-found" || error.code === "path-alias")
) {
return null;
}
throw error;
});
const existing = pageStat ? await vault.readText(params.pagePath).catch(() => "") : "";
if (
existing === params.content ||
normalizeOkfRenderedPageForComparison(existing) ===
normalizeOkfRenderedPageForComparison(params.content)
) {
return { changed: false, created: !pageStat };
}
try {
if (isRegularFileStat(pageStat) && pageStat.nlink > 1) {
await vault.remove(params.pagePath);
}
await vault.write(params.pagePath, params.content);
} catch (error) {
if (error instanceof FsSafeError) {
if (error.code !== "symlink" && error.code !== "path-alias") {
throw new Error(
`Refusing to write OKF concept page (${error.code}): ${params.pagePath}: ${error.message}`,
{ cause: error },
);
}
throw new Error(`Refusing to write OKF concept page through symlink: ${params.pagePath}`, {
cause: error,
});
}
throw error;
}
return { changed: true, created: !pageStat };
}
async function removeStaleOkfConceptPages(params: {
vaultRoot: string;
bundleKey: string;
currentPagePaths: Set<string>;
}): Promise<string[]> {
const vault = await fsRoot(params.vaultRoot);
const conceptsDir = path.join(params.vaultRoot, "concepts");
const entries = await fs.readdir(conceptsDir, { withFileTypes: true }).catch(() => []);
const removedPagePaths: string[] = [];
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name === "index.md") {
continue;
}
const pagePath = `concepts/${entry.name}`;
if (params.currentPagePaths.has(pagePath)) {
continue;
}
const raw = await vault.readText(pagePath).catch(() => "");
const parsed = parseWikiMarkdown(raw);
const okf = parsed.frontmatter.okf;
if (
okf &&
typeof okf === "object" &&
!Array.isArray(okf) &&
(okf as Record<string, unknown>).bundleKey === params.bundleKey
) {
await vault.remove(pagePath);
removedPagePaths.push(pagePath);
}
}
return removedPagePaths;
}
function readRootOkfMetadata(params: {
rootIndex: string | undefined;
bundleName: string;
bundlePath: string;
}): OkfBundleMetadata {
if (!params.rootIndex) {
return {
key: createOkfBundleKey({
rootFrontmatter: {},
bundleName: params.bundleName,
bundlePath: params.bundlePath,
}),
};
}
const parsed = parseOkfMarkdown(params.rootIndex, "index.md");
return {
key: createOkfBundleKey({
rootFrontmatter: parsed.frontmatter,
bundleName: params.bundleName,
bundlePath: params.bundlePath,
}),
...(normalizeOptionalString(parsed.frontmatter.okf_version)
? { version: normalizeOptionalString(parsed.frontmatter.okf_version) }
: {}),
};
}
function formatOkfImportSummary(result: ImportMemoryWikiOkfResult): string {
return `Imported ${result.importedCount} OKF concept${result.importedCount === 1 ? "" : "s"} from ${result.bundlePath} into memory wiki. Updated ${result.updatedCount}; removed ${result.removedCount}; skipped ${result.skippedCount}; refreshed ${result.indexUpdatedFiles.length} index file${result.indexUpdatedFiles.length === 1 ? "" : "s"}.`;
}
export { formatOkfImportSummary };
export async function importMemoryWikiOkfBundle(params: {
config: ResolvedMemoryWikiConfig;
bundlePath: string;
nowMs?: number;
}): Promise<ImportMemoryWikiOkfResult> {
await initializeMemoryWikiVault(params.config, { nowMs: params.nowMs });
const bundlePath = path.resolve(params.bundlePath);
const stat = await fs.stat(bundlePath);
if (!stat.isDirectory()) {
throw new Error("wiki okf import expects an unpacked OKF bundle directory.");
}
const warnings: ImportMemoryWikiOkfWarning[] = [];
const markdownFiles = await collectOkfMarkdownFiles(bundlePath, warnings);
const concepts: OkfConceptDocument[] = [];
let rootIndexContent: string | undefined;
for (const relativePath of markdownFiles) {
if (relativePath === "index.md") {
rootIndexContent =
(await readOkfTextFile({ bundlePath, relativePath, warnings })) ?? undefined;
}
if (OKF_RESERVED_FILENAMES.has(path.posix.basename(relativePath))) {
continue;
}
const content = await readOkfTextFile({ bundlePath, relativePath, warnings });
if (content === null) {
continue;
}
const normalized = normalizeOkfConcept({ bundlePath, relativePath, content });
if (normalized.warning) {
warnings.push(normalized.warning);
continue;
}
if (normalized.concept) {
concepts.push(normalized.concept);
}
}
const timestamp = resolveMemoryWikiTimestamp(params.nowMs);
const bundleName = path.basename(bundlePath);
const bundleMetadata = readRootOkfMetadata({
rootIndex: rootIndexContent,
bundleName,
bundlePath,
});
const bundleKey = bundleMetadata.key;
const pageByConceptId = new Map<string, { pageId: string; pagePath: string; title: string }>();
for (const concept of concepts) {
pageByConceptId.set(concept.conceptId, {
...createOkfPageIdentity(bundleKey, concept.conceptId),
title: concept.title,
});
}
const importedPages: OkfImportedPage[] = [];
let updatedCount = 0;
await fs.mkdir(path.join(params.config.vault.path, "concepts"), { recursive: true });
for (const concept of concepts.toSorted((left, right) =>
left.conceptId.localeCompare(right.conceptId),
)) {
const page = pageByConceptId.get(concept.conceptId);
if (!page) {
continue;
}
const rewritten = rewriteOkfMarkdownLinks({
body: concept.body,
sourcePagePath: page.pagePath,
sourceRelativePath: concept.relativePath,
pageByConceptId,
});
const relationships = rewritten.linkedConceptIds.flatMap((conceptId) => {
const target = pageByConceptId.get(conceptId);
return target
? [
{
targetId: target.pageId,
targetPath: target.pagePath,
targetTitle: target.title,
kind: "okf-link",
evidenceKind: "okf-markdown-link",
},
]
: [];
});
const frontmatter = {
pageType: "concept",
id: page.pageId,
title: concept.title,
sourceType: "okf",
provenanceMode: "okf-import",
sourcePath: concept.absolutePath,
okfConceptId: concept.conceptId,
okfType: concept.type,
sourceIds: [`source.okf.${bundleKey}`],
importedAt: timestamp,
updatedAt: concept.timestamp ?? timestamp,
status: "active",
...(concept.description ? { description: concept.description } : {}),
...(concept.resource ? { resource: concept.resource } : {}),
...(concept.tags.length > 0 ? { tags: concept.tags } : {}),
...(concept.timestamp ? { okfTimestamp: concept.timestamp } : {}),
...(relationships.length > 0 ? { relationships } : {}),
okf: {
...(bundleMetadata.version ? { version: bundleMetadata.version } : {}),
bundleName,
bundleKey,
conceptId: concept.conceptId,
sourceRelativePath: concept.relativePath,
frontmatter: concept.frontmatter,
},
};
const writeResult = await writeOkfConceptPage({
vaultRoot: params.config.vault.path,
pagePath: page.pagePath,
content: renderWikiMarkdown({
frontmatter,
body: rewritten.body,
}),
});
if (!writeResult.created && writeResult.changed) {
updatedCount++;
}
importedPages.push({
conceptId: concept.conceptId,
sourcePath: concept.absolutePath,
pageId: page.pageId,
pagePath: page.pagePath,
title: concept.title,
created: writeResult.created,
});
}
const currentPagePaths = new Set(importedPages.map((page) => page.pagePath));
const removedPagePaths =
warnings.length === 0
? await removeStaleOkfConceptPages({
vaultRoot: params.config.vault.path,
bundleKey,
currentPagePaths,
})
: [];
await appendMemoryWikiLog(params.config.vault.path, {
type: "okf-import",
timestamp,
details: {
bundlePath,
bundleName,
importedCount: importedPages.length,
updatedCount,
removedCount: removedPagePaths.length,
skippedCount: warnings.length,
pagePaths: importedPages.map((page) => page.pagePath),
removedPagePaths,
},
});
const compile = await compileMemoryWikiVault(params.config);
return {
bundlePath,
bundleName,
...(bundleMetadata.version ? { okfVersion: bundleMetadata.version } : {}),
importedCount: importedPages.length,
updatedCount,
removedCount: removedPagePaths.length,
skippedCount: warnings.length,
pagePaths: importedPages.map((page) => page.pagePath),
removedPagePaths,
warnings,
indexUpdatedFiles: compile.updatedFiles,
};
}

View File

@@ -11,24 +11,6 @@ import {
expectUnifiedModelCatalogProviderRegistration,
} from "openclaw/plugin-sdk/provider-test-contracts";
import { describe, expect, it, vi } from "vitest";
const { getOpenRouterModelCapabilitiesMock, loadOpenRouterModelCapabilitiesMock } = vi.hoisted(
() => ({
getOpenRouterModelCapabilitiesMock: vi.fn(),
loadOpenRouterModelCapabilitiesMock: vi.fn(async () => {}),
}),
);
vi.mock("openclaw/plugin-sdk/provider-stream-family", async (importOriginal) => {
const actual =
await importOriginal<typeof import("openclaw/plugin-sdk/provider-stream-family")>();
return {
...actual,
getOpenRouterModelCapabilities: getOpenRouterModelCapabilitiesMock,
loadOpenRouterModelCapabilities: loadOpenRouterModelCapabilitiesMock,
};
});
import openrouterPlugin from "./index.js";
import {
buildOpenrouterProvider,
@@ -222,59 +204,6 @@ describe("openrouter provider hooks", () => {
expect(buildOpenrouterProvider().models?.map((model) => model.id)).not.toContain("auto");
});
it("normalizes OpenRouter API ids before capability loading and lookup", async () => {
getOpenRouterModelCapabilitiesMock.mockReset();
loadOpenRouterModelCapabilitiesMock.mockClear();
getOpenRouterModelCapabilitiesMock.mockReturnValue({
name: "Claude Sonnet 4.6",
reasoning: true,
input: ["text", "image"],
supportsTools: true,
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 64_000,
});
const provider = await registerSingleProviderPlugin(openrouterPlugin);
const modelId = "openrouter/anthropic/claude-sonnet-4.6";
const context = {
provider: "openrouter",
modelId,
modelRegistry: { find: vi.fn(() => null) },
} as never;
await provider.prepareDynamicModel?.(context);
const model = provider.resolveDynamicModel?.(context);
expect(loadOpenRouterModelCapabilitiesMock).toHaveBeenCalledWith("anthropic/claude-sonnet-4.6");
expect(getOpenRouterModelCapabilitiesMock).toHaveBeenCalledWith("anthropic/claude-sonnet-4.6");
expect(model).toMatchObject({
id: modelId,
name: "Claude Sonnet 4.6",
reasoning: true,
input: ["text", "image"],
compat: { supportsTools: true },
contextWindow: 200_000,
maxTokens: 64_000,
});
});
it("keeps native OpenRouter namespace ids for capability lookup", async () => {
getOpenRouterModelCapabilitiesMock.mockReset();
loadOpenRouterModelCapabilitiesMock.mockClear();
const provider = await registerSingleProviderPlugin(openrouterPlugin);
const context = {
provider: "openrouter",
modelId: "openrouter/auto",
modelRegistry: { find: vi.fn(() => null) },
} as never;
await provider.prepareDynamicModel?.(context);
provider.resolveDynamicModel?.(context);
expect(loadOpenRouterModelCapabilitiesMock).toHaveBeenCalledWith("openrouter/auto");
expect(getOpenRouterModelCapabilitiesMock).toHaveBeenCalledWith("openrouter/auto");
});
it("does not include retired stealth models in the bundled catalog", () => {
const modelIds = buildOpenrouterProvider().models?.map((model) => model.id) ?? [];
expect(modelIds).not.toContain("openrouter/hunter-alpha");
@@ -460,61 +389,6 @@ describe("openrouter provider hooks", () => {
},
} as never);
expect(normalizedHunterModel?.reasoning).toBe(false);
expect(normalizedHunterModel?.id).toBe("openrouter/hunter-alpha");
const normalizedAnthropicModel = provider.normalizeResolvedModel?.({
provider: "openrouter",
model: {
provider: "openrouter",
id: "openrouter/anthropic/claude-sonnet-4.6",
name: "anthropic/claude-sonnet-4.6",
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8192,
},
} as never);
expect(normalizedAnthropicModel?.id).toBe("anthropic/claude-sonnet-4.6");
expect(
provider.normalizeResolvedModel?.({
provider: "openrouter",
modelId: "openrouter/auto",
model: {
provider: "openrouter",
id: "openrouter/auto",
name: "OpenRouter Auto",
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8192,
},
} as never),
).toBeUndefined();
const normalizedDuplicatedAutoModel = provider.normalizeResolvedModel?.({
provider: "openrouter",
modelId: "openrouter/openrouter/auto",
model: {
provider: "openrouter",
id: "openrouter/openrouter/auto",
name: "OpenRouter Auto",
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8192,
},
} as never);
expect(normalizedDuplicatedAutoModel?.id).toBe("openrouter/auto");
expect(
provider.normalizeTransport?.({

View File

@@ -17,7 +17,7 @@ import {
} from "openclaw/plugin-sdk/provider-stream-family";
import { buildOpenRouterImageGenerationProvider } from "./image-generation-provider.js";
import { openrouterMediaUnderstandingProvider } from "./media-understanding-provider.js";
import { isOpenRouterMistralModelId, normalizeOpenRouterApiModelId } from "./models.js";
import { isOpenRouterMistralModelId } from "./models.js";
import { buildOpenRouterMusicGenerationProvider } from "./music-generation-provider.js";
import { createOpenRouterOAuthAuthMethod } from "./oauth.js";
import { applyOpenrouterConfig, OPENROUTER_DEFAULT_MODEL_REF } from "./onboard.js";
@@ -51,18 +51,15 @@ const OPENROUTER_CACHE_TTL_MODEL_PREFIXES = [
function normalizeOpenRouterResolvedModel<T extends ProviderRuntimeModel>(model: T): T | undefined {
const normalizedBaseUrl = normalizeOpenRouterBaseUrl(model.baseUrl);
const normalizedId = normalizeOpenRouterApiModelId(model.id);
const reasoning = isOpenRouterProxyReasoningUnsupportedModel(model.id) ? false : model.reasoning;
if (
(!normalizedBaseUrl || normalizedBaseUrl === model.baseUrl) &&
(!normalizedId || normalizedId === model.id) &&
reasoning === model.reasoning
) {
return undefined;
}
return {
...model,
...(normalizedId ? { id: normalizedId } : {}),
...(normalizedBaseUrl ? { baseUrl: normalizedBaseUrl } : {}),
reasoning,
};
@@ -76,8 +73,7 @@ export default definePluginEntry({
function buildDynamicOpenRouterModel(
ctx: ProviderResolveDynamicModelContext,
): ProviderRuntimeModel {
const apiModelId = normalizeOpenRouterApiModelId(ctx.modelId) ?? ctx.modelId;
const capabilities = getOpenRouterModelCapabilities(apiModelId);
const capabilities = getOpenRouterModelCapabilities(ctx.modelId);
return {
id: ctx.modelId,
name: capabilities?.name ?? ctx.modelId,
@@ -170,9 +166,7 @@ export default definePluginEntry({
},
resolveDynamicModel: (ctx) => buildDynamicOpenRouterModel(ctx),
prepareDynamicModel: async (ctx) => {
await loadOpenRouterModelCapabilities(
normalizeOpenRouterApiModelId(ctx.modelId) ?? ctx.modelId,
);
await loadOpenRouterModelCapabilities(ctx.modelId);
},
normalizeConfig: ({ providerConfig }) => {
const normalizedBaseUrl = normalizeOpenRouterBaseUrl(providerConfig.baseUrl);

View File

@@ -12,30 +12,13 @@ const OPENROUTER_MISTRAL_MODEL_PREFIXES = [
"pixtral-",
"voxtral-",
] as const;
const OPENROUTER_MODEL_PREFIX = "openrouter/";
export function normalizeOpenRouterModelId(modelId: unknown): string | undefined {
if (typeof modelId !== "string") {
return undefined;
}
const normalized = normalizeLowercaseStringOrEmpty(modelId);
return normalized.startsWith(OPENROUTER_MODEL_PREFIX)
? normalized.slice(OPENROUTER_MODEL_PREFIX.length)
: normalized;
}
export function normalizeOpenRouterApiModelId(modelId: unknown): string | undefined {
if (typeof modelId !== "string") {
return undefined;
}
const normalized = normalizeLowercaseStringOrEmpty(modelId);
if (!normalized.startsWith(OPENROUTER_MODEL_PREFIX)) {
return normalized;
}
const unprefixed = normalized.slice(OPENROUTER_MODEL_PREFIX.length);
// `openrouter/` is both a provider qualifier and an upstream namespace.
// Strip it only when the remainder is still a namespaced API model id.
return unprefixed.includes("/") ? unprefixed : normalized;
return normalized.startsWith("openrouter/") ? normalized.slice("openrouter/".length) : normalized;
}
export function isOpenRouterMistralModelId(modelId: unknown): boolean {

View File

@@ -7,17 +7,12 @@ import {
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { describe, expect, it } from "vitest";
import plugin from "./index.js";
import { normalizeOpenRouterApiModelId } from "./models.js";
const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
const OPENROUTER_MISTRAL_PROVIDER_PREFIX = "mistralai/";
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY ?? "";
const LIVE_MODEL_REF =
process.env.OPENCLAW_LIVE_OPENROUTER_PLUGIN_MODEL?.trim() ||
"openrouter/anthropic/claude-sonnet-4.6";
const LIVE_MODEL_ID = LIVE_MODEL_REF.startsWith("openrouter/")
? LIVE_MODEL_REF
: `openrouter/${LIVE_MODEL_REF}`;
const LIVE_MODEL_ID =
process.env.OPENCLAW_LIVE_OPENROUTER_PLUGIN_MODEL?.trim() || "openai/gpt-5.4-nano";
const LIVE_CACHE_MODEL_ID =
process.env.OPENCLAW_LIVE_OPENROUTER_CACHE_MODEL?.trim() || "deepseek/deepseek-v3.2";
const liveEnabled = OPENROUTER_API_KEY.trim().length > 0 && process.env.OPENCLAW_LIVE_TEST === "1";
@@ -62,40 +57,6 @@ async function completeOpenRouterChat(params: {
});
}
async function expectWeatherToolCall(client: OpenAI, model: string): Promise<void> {
const response = await client.chat.completions.create({
model,
messages: [{ role: "user", content: "Call get_weather for Paris." }],
tools: [
{
type: "function",
function: {
name: "get_weather",
description: "Get the weather for a city.",
parameters: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
additionalProperties: false,
},
},
},
],
tool_choice: {
type: "function",
function: { name: "get_weather" },
},
max_tokens: 64,
});
const toolCall = response.choices[0]?.message?.tool_calls?.find(
(call) => call.type === "function",
);
expect(toolCall?.type).toBe("function");
expect(toolCall?.function.name).toBe("get_weather");
expect(JSON.parse(toolCall?.function.arguments ?? "{}")).toMatchObject({ city: "Paris" });
}
async function fetchOpenRouterModelIds(): Promise<string[]> {
const response = await fetch(OPENROUTER_MODELS_URL, {
headers: { "accept-encoding": "identity" },
@@ -108,7 +69,7 @@ async function fetchOpenRouterModelIds(): Promise<string[]> {
}
describeLive("openrouter plugin live", () => {
it("normalizes a prefixed OpenRouter model and completes a live tool call", async () => {
it("registers an OpenRouter provider that can complete a live request", async () => {
const { providers } = await registerOpenRouterPlugin();
const provider = requireRegisteredProvider(providers, "openrouter");
@@ -126,35 +87,17 @@ describeLive("openrouter plugin live", () => {
expect(resolved.api).toBe("openai-completions");
expect(resolved.baseUrl).toBe("https://openrouter.ai/api/v1");
const normalized =
provider.normalizeResolvedModel?.({
provider: "openrouter",
modelId: resolved.id,
model: resolved,
}) ?? resolved;
expect(normalized.id).toBe(normalizeOpenRouterApiModelId(LIVE_MODEL_ID));
const client = new OpenAI({
apiKey: OPENROUTER_API_KEY,
baseURL: normalized.baseUrl,
baseURL: resolved.baseUrl,
});
const autoResolved = provider.resolveDynamicModel?.({
provider: "openrouter",
modelId: "openrouter/auto",
modelRegistry: new ModelRegistryCtor(AuthStorage.inMemory()),
const response = await client.chat.completions.create({
model: resolved.id,
messages: [{ role: "user", content: "Reply with exactly OK." }],
max_tokens: 16,
});
if (!autoResolved) {
throw new Error("openrouter provider did not resolve openrouter/auto");
}
const autoModel =
provider.normalizeResolvedModel?.({
provider: "openrouter",
modelId: autoResolved.id,
model: autoResolved,
}) ?? autoResolved;
expect(autoModel.id).toBe("openrouter/auto");
await expectWeatherToolCall(client, autoModel.id);
await expectWeatherToolCall(client, normalized.id);
expect(response.choices[0]?.message?.content?.trim()).toMatch(/^OK[.!]?$/);
}, 30_000);
});

View File

@@ -444,7 +444,7 @@ export function registerQaLabCli(program: Command) {
.option("--summary <path>", "Runtime qa-suite-summary.json to overlay on --tools coverage")
.option(
"--match <query>",
"Search scenario metadata and print matching qa suite targets (repeatable)",
"Search scenario metadata and print matching scenario refs (repeatable)",
collectString,
[],
)

View File

@@ -6,31 +6,95 @@ import {
renderQaCoverageMarkdownReport,
renderQaScenarioMatchesMarkdownReport,
} from "./coverage-report.js";
import { readQaScenarioPack } from "./scenario-catalog.js";
import { buildQaScorecardTaxonomyReport, parseQaScorecardTaxonomy } from "./scorecard-taxonomy.js";
import { readQaScenarioPack, type QaSeedScenarioWithSource } from "./scenario-catalog.js";
import { buildQaScorecardTaxonomyReport } from "./scorecard-taxonomy.js";
const TEST_EXECUTABLE_CATEGORY_ID = "agent-runtime-and-provider-execution.agent-turn-execution";
const TEST_TAXONOMY_REF = {
sourcePath: "taxonomy.yaml",
version: 1,
processVersion: 3,
snapshotDate: "2026-05-26",
sourceRef: "origin/main@41eef4a7965",
};
const TEST_EXECUTABLE_COVERAGE_ID = "channels.dm";
const TEST_BROWSER_CATEGORY_ID = "browser-control-ui-and-webchat.browser-ui";
const TEST_BROWSER_COVERAGE_ID = "ui.control";
const TEST_WEBCHAT_COVERAGE_ID = "ui.webchat";
function testScorecardProfiles(categoryId = TEST_EXECUTABLE_CATEGORY_ID, profileId = "release") {
return [
{
id: "smoke-ci",
description: "Test smoke profile.",
categoryIds: profileId === "smoke-ci" ? [categoryId] : [],
function testMaturityTaxonomy(params?: {
categoryId?: string;
coverageIds?: readonly string[];
profileCategoryIds?: readonly string[];
}) {
const categoryId = params?.categoryId ?? TEST_EXECUTABLE_CATEGORY_ID;
const firstDot = categoryId.indexOf(".");
const surfaceId = firstDot === -1 ? categoryId : categoryId.slice(0, firstDot);
const categoryLocalId = firstDot === -1 ? categoryId : categoryId.slice(firstDot + 1);
return {
version: 1,
title: "Test taxonomy",
profiles: [
{
id: "smoke-ci",
description: "Test smoke profile.",
categoryIds: [],
},
{
id: "release",
description: "Test release profile.",
categoryIds: [...(params?.profileCategoryIds ?? [categoryId])],
},
],
surfaces: [
{
id: surfaceId,
name: "Test surface",
categories: [
{
id: categoryLocalId,
name: "Test category",
features: (params?.coverageIds ?? [TEST_EXECUTABLE_COVERAGE_ID]).map((coverageId) => ({
name: coverageId,
coverageIds: [coverageId],
})),
},
],
},
],
};
}
function scenarioWithCoverage(params: {
primary?: readonly string[];
secondary?: readonly string[];
sourcePath?: string;
executionKind?: "flow" | "vitest" | "playwright";
executionPath?: string;
}): QaSeedScenarioWithSource {
const execution =
params.executionKind === "vitest" || params.executionKind === "playwright"
? {
kind: params.executionKind,
path: params.executionPath ?? "src/test.test.ts",
}
: {
kind: "flow" as const,
flow: {
steps: [
{
name: "noop",
actions: [{ set: "ok", value: true }],
},
],
},
};
return {
id: "test-scenario",
title: "Test scenario",
surface: "test",
coverage: {
primary: [...(params.primary ?? [])],
...(params.secondary ? { secondary: [...params.secondary] } : {}),
},
{
id: "release",
description: "Test release profile.",
categoryIds: profileId === "release" ? [categoryId] : [],
},
];
objective: "Exercise test coverage.",
successCriteria: ["Evidence is recorded."],
sourcePath: params.sourcePath ?? "qa/scenarios/test/test-scenario.md",
execution,
};
}
describe("qa coverage report", () => {
@@ -49,17 +113,21 @@ describe("qa coverage report", () => {
"telegram",
"whatsapp",
]);
expect(inventory.scorecardTaxonomy.taxonomyId).toBe("stable-lts-initial");
expect(inventory.scorecardTaxonomy.profileCount).toBe(2);
expect(inventory.scorecardTaxonomy.categoryCount).toBe(16);
expect(inventory.scorecardTaxonomy.ltsIncludedCategoryCount).toBe(7);
expect(inventory.scorecardTaxonomy.deferredCategoryCount).toBe(8);
expect(inventory.scorecardTaxonomy.advisoryCategoryCount).toBe(1);
expect(inventory.scorecardTaxonomy.releaseBlockingCategoryCount).toBe(7);
expect(inventory.scorecardTaxonomy.mappedCoverageIdCount).toBeGreaterThan(0);
expect(inventory.scorecardTaxonomy.mappedScenarioCount).toBeGreaterThan(0);
expect(inventory.scorecardTaxonomy.unmappedCoverageIdCount).toBeGreaterThan(0);
expect(inventory.scorecardTaxonomy.validationIssues).toStrictEqual([]);
expect(inventory.scorecardTaxonomy.categoryCount).toBeGreaterThan(200);
expect(inventory.scorecardTaxonomy.requiredCategoryCount).toBe(15);
expect(inventory.scorecardTaxonomy.requiredFeatureCount).toBeGreaterThan(0);
expect(inventory.scorecardTaxonomy.fulfilledFeatureCount).toBeGreaterThan(0);
expect(inventory.scorecardTaxonomy.taxonomyFulfillmentPercent).toBeGreaterThan(0);
expect(inventory.scorecardTaxonomy.evidenceRefCount).toBeGreaterThan(0);
expect(inventory.scorecardTaxonomy.scenarioCoverageIdCount).toBeGreaterThan(0);
expect(inventory.scorecardTaxonomy.unmappedCoverageIdCount).toBe(0);
expect(inventory.scorecardTaxonomy.validationIssues.length).toBeGreaterThan(0);
expect(
inventory.scorecardTaxonomy.validationIssues.every(
(issue) => issue.code === "coverage-id-missing-primary-evidence",
),
).toBe(true);
expect(
inventory.scorecardTaxonomy.profiles
.find((profile) => profile.id === "release")
@@ -82,10 +150,15 @@ describe("qa coverage report", () => {
]);
expect(
inventory.scorecardTaxonomy.categories.find(
(category) =>
category.id === "clawhub-and-external-plugin-distribution.compatibility-and-trust",
)?.profiles,
).toStrictEqual([]);
(category) => category.id === TEST_BROWSER_CATEGORY_ID,
)?.evidence,
).toContainEqual({
coverageId: TEST_BROWSER_COVERAGE_ID,
kind: "playwright",
path: "ui/src/ui/e2e/chat-flow.e2e.test.ts",
role: "primary",
scenarioRefs: ["qa/scenarios/ui/control-ui-chat-flow-playwright.md"],
});
expect(inventory.scenarioPacks.map((pack) => pack.id)).toEqual([
"observability",
"personal-agent",
@@ -95,13 +168,11 @@ describe("qa coverage report", () => {
expect(personalPack?.missingScenarioIds).toStrictEqual([]);
expect(personalPack?.scenarioIds).toContain("personal-share-safe-diagnostics-artifact");
expect(personalPack?.coverageIds).toContain("personal.redaction");
expect(personalPack?.coverageIds).toContain("qa.artifact-safety");
expect(observabilityPack?.missingScenarioIds).toStrictEqual([]);
expect(observabilityPack?.scenarioIds).toEqual(["otel-trace-smoke", "docker-prometheus-smoke"]);
expect(observabilityPack?.coverageIds).toContain("telemetry.otel");
expect(observabilityPack?.coverageIds).toContain("telemetry.prometheus");
expect(inventory.byTheme.memory.map((feature) => feature.id)).toContain("memory.recall");
expect(inventory.bySurface.memory.map((feature) => feature.id)).toContain("memory.recall");
expect(inventory.byTheme.memory.map((coverage) => coverage.id)).toContain("memory.recall");
expect(inventory.bySurface.memory.map((coverage) => coverage.id)).toContain("memory.recall");
});
it("renders a compact markdown inventory", () => {
@@ -117,9 +188,11 @@ describe("qa coverage report", () => {
expect(report).toContain("secondary: active-memory-preprompt-recall");
expect(report).toContain("## Scenario Packs");
expect(report).toContain(
"- personal-agent (Personal Agent Benchmark Pack): 10 scenarios; coverage:",
"- personal-agent (Personal Agent Benchmark Pack): 10 scenarios; coverage IDs:",
);
expect(report).toContain(
"- observability (Observability Smoke Pack): 2 scenarios; coverage IDs:",
);
expect(report).toContain("- observability (Observability Smoke Pack): 2 scenarios; coverage:");
expect(report).toContain("otel-trace-smoke, docker-prometheus-smoke");
expect(report).toContain("personal-share-safe-diagnostics-artifact");
expect(report).toContain("## Live Transport Lanes");
@@ -128,19 +201,16 @@ describe("qa coverage report", () => {
);
expect(report).toContain("thread-follow-up: slack-thread-follow-up");
expect(report).toContain("## Scorecard Taxonomy");
expect(report).toContain("- Mapping ID: stable-lts-initial");
expect(report).toContain("- Maturity taxonomy: taxonomy.yaml");
expect(report).toContain("- Maturity score snapshot: docs/maturity-scores.yaml");
expect(report).toContain("- Categories: 16 (7 LTS-included, 8 deferred, 1 advisory)");
expect(report).toContain("- Profiles: 2");
expect(report).toContain("- Taxonomy: taxonomy.yaml");
expect(report).toContain("- Fulfilled taxonomy categories:");
expect(report).toContain("- Fulfilled taxonomy features:");
expect(report).toContain("- Evidence refs:");
expect(report).toContain("- Scenario coverage IDs:");
expect(report).toContain(
"- smoke-ci: 14 categories; agent-runtime-and-provider-execution.agent-turn-execution,",
"- browser-automation-and-exec-sandbox-tools.tool-invocation-and-execution (browser-automation-and-exec-sandbox-tools / Tool Invocation and Execution; partial): profiles: release, smoke-ci; coverage IDs:",
);
expect(report).toContain(
"- browser-automation-and-exec-sandbox-tools.tool-invocation-and-execution (browser-automation-and-exec-sandbox-tools / Tool Invocation and Execution; lts-included, release-blocking, mapped): profiles: release, smoke-ci; coverage: tools.apply-patch, tools.exec, tools.fs.read, tools.fs.write, tools.web-search;",
);
expect(report).toContain("### Unmapped Coverage IDs");
expect(report).toContain("agents.subagents");
expect(report).toContain("primary:playwright:ui/src/ui/e2e/chat-flow.e2e.test.ts (ui.control)");
expect(report).not.toContain("### Unmapped Scenario Coverage IDs");
});
it("renders Playwright matches as qa suite targets", () => {
@@ -154,414 +224,193 @@ describe("qa coverage report", () => {
"- Suite command: `pnpm openclaw qa suite --scenario control-ui-chat-flow-playwright`",
);
expect(report).toContain(" - execution: playwright ui/src/ui/e2e/chat-flow.e2e.test.ts");
expect(report).not.toContain("Native test refs");
});
it("splits qa suite targets when matches mix execution kinds", () => {
const matches = findQaScenarioMatches(readQaScenarioPack().scenarios, "control-ui");
const report = renderQaScenarioMatchesMarkdownReport({
query: "control-ui",
matches,
query: "mixed",
matches: [
scenarioWithCoverage({
primary: [TEST_EXECUTABLE_COVERAGE_ID],
}),
scenarioWithCoverage({
primary: [TEST_BROWSER_COVERAGE_ID],
executionKind: "playwright",
executionPath: "ui/src/ui/e2e/chat-flow.e2e.test.ts",
sourcePath: "qa/scenarios/ui/control-ui-chat-flow-playwright.md",
}),
].map((scenario, index) => ({
...scenario,
id: index === 0 ? "flow-proof" : "playwright-proof",
theme: "test",
surfaces: [scenario.surface],
risk: "unassigned",
coverageIds: [
...(scenario.coverage?.primary ?? []),
...(scenario.coverage?.secondary ?? []),
],
docsRefs: [],
codeRefs: [],
executionKind: scenario.execution.kind,
...(scenario.execution.kind === "flow" ? {} : { executionPath: scenario.execution.path }),
})),
});
expect(report).toContain("- Suite commands:");
expect(report).toContain(" - flow: `pnpm openclaw qa suite --scenario");
expect(report).toContain(" - flow: `pnpm openclaw qa suite --scenario flow-proof`");
expect(report).toContain(
" - playwright: `pnpm openclaw qa suite --scenario control-ui-chat-flow-playwright`",
" - playwright: `pnpm openclaw qa suite --scenario playwright-proof`",
);
});
it("reports taxonomy mapping gaps as scorecard signals", () => {
const taxonomy = parseQaScorecardTaxonomy({
version: 1,
id: "test-taxonomy",
title: "Test taxonomy",
taxonomy: TEST_TAXONOMY_REF,
scoreSnapshotRef: "docs/maturity-scores.yaml",
status: "initial",
profiles: testScorecardProfiles(),
categories: [
{
id: TEST_EXECUTABLE_CATEGORY_ID,
taxonomySurfaceId: "agent-runtime-and-provider-execution",
taxonomyCategoryName: "Agent Turn Execution",
supportStatus: "lts-included",
releaseBlocking: true,
requirement: "Exercise a missing mapping.",
evidenceRequired: "A real scenario mapping before promotion.",
evidence: {
profiles: ["release"],
liveProofRequired: false,
freshness: "target-ref",
coverageIds: ["runtime.missing-coverage"],
scenarioRefs: ["qa/scenarios/runtime/missing-scorecard-scenario.md"],
docsRefs: ["docs/missing-scorecard-doc.md"],
codeRefs: ["src/missing-scorecard-code.ts"],
},
},
it("reports missing taxonomy coverage refs without treating them as fulfilled", () => {
const report = buildQaScorecardTaxonomyReport({
taxonomy: testMaturityTaxonomy(),
repoRoot: process.cwd(),
scenarios: [
scenarioWithCoverage({
primary: ["agent-runtime-and-provider-execution.agent-turn-execution.missing-coverage"],
}),
],
});
const report = buildQaScorecardTaxonomyReport({
taxonomy,
repoRoot: process.cwd(),
scenarios: readQaScenarioPack().scenarios,
});
expect(report.categories[0]?.mappingStatus).toBe("partial");
expect(report.fulfilledFeatureCount).toBe(0);
expect(report.categories[0]?.mappingStatus).toBe("missing");
expect(report.validationIssues.map((issue) => issue.code)).toEqual([
"coverage-id-not-found",
"scenario-ref-not-found",
"docs-ref-not-found",
"code-ref-not-found",
"coverage-id-missing-primary-evidence",
"profile-category-missing-evidence",
]);
});
it("reports release-blocking categories missing release profile membership", () => {
const taxonomy = parseQaScorecardTaxonomy({
version: 1,
id: "test-taxonomy",
title: "Test taxonomy",
taxonomy: TEST_TAXONOMY_REF,
scoreSnapshotRef: "docs/maturity-scores.yaml",
status: "initial",
profiles: testScorecardProfiles(TEST_EXECUTABLE_CATEGORY_ID, "smoke-ci"),
categories: [
{
id: TEST_EXECUTABLE_CATEGORY_ID,
taxonomySurfaceId: "agent-runtime-and-provider-execution",
taxonomyCategoryName: "Agent Turn Execution",
supportStatus: "lts-included",
releaseBlocking: true,
requirement: "Release-blocking rows must be selected by the release profile.",
evidenceRequired: "Release profile membership before promotion.",
evidence: {
profiles: ["smoke-ci"],
liveProofRequired: false,
freshness: "target-ref",
coverageIds: ["channels.dm"],
scenarioRefs: ["qa/scenarios/channels/dm-chat-baseline.md"],
docsRefs: ["docs/concepts/qa-e2e-automation.md"],
codeRefs: ["extensions/qa-lab/src/suite.ts"],
},
},
],
});
it("uses explicit native test evidence as coverage fulfillment", () => {
const report = buildQaScorecardTaxonomyReport({
taxonomy,
taxonomy: testMaturityTaxonomy({
categoryId: TEST_BROWSER_CATEGORY_ID,
coverageIds: [TEST_BROWSER_COVERAGE_ID],
}),
repoRoot: process.cwd(),
scenarios: readQaScenarioPack().scenarios,
});
expect(report.validationIssues.map((issue) => issue.code)).toEqual([
"release-blocking-category-missing-release-profile",
]);
});
it("reports advisory categories that are accidentally assigned to a runnable profile", () => {
const taxonomy = parseQaScorecardTaxonomy({
version: 1,
id: "test-taxonomy",
title: "Test taxonomy",
taxonomy: TEST_TAXONOMY_REF,
scoreSnapshotRef: "docs/maturity-scores.yaml",
status: "initial",
profiles: testScorecardProfiles(
"clawhub-and-external-plugin-distribution.compatibility-and-trust",
"smoke-ci",
),
categories: [
{
id: "clawhub-and-external-plugin-distribution.compatibility-and-trust",
taxonomySurfaceId: "clawhub-and-external-plugin-distribution",
taxonomyCategoryName: "Compatibility and Trust",
supportStatus: "advisory",
releaseBlocking: false,
requirement: "Keep advisory compatibility out of runnable profiles.",
evidenceRequired: "Advisory report metadata only.",
evidence: {
profiles: [],
liveProofRequired: false,
freshness: "latest-advisory-run",
coverageIds: [],
scenarioRefs: [],
docsRefs: ["docs/plugins/architecture.md"],
codeRefs: [],
},
},
scenarios: [
scenarioWithCoverage({
primary: [TEST_BROWSER_COVERAGE_ID],
sourcePath: "qa/scenarios/ui/control-ui-chat-flow-playwright.md",
executionKind: "playwright",
executionPath: "ui/src/ui/e2e/chat-flow.e2e.test.ts",
}),
],
});
const report = buildQaScorecardTaxonomyReport({
taxonomy,
repoRoot: process.cwd(),
scenarios: readQaScenarioPack().scenarios,
});
expect(report.validationIssues.map((issue) => issue.code)).toEqual([
"profile-membership-missing-category-profile",
"advisory-category-has-profile-membership",
]);
});
it("reports non-advisory categories with no runnable profile membership", () => {
const taxonomy = parseQaScorecardTaxonomy({
version: 1,
id: "test-taxonomy",
title: "Test taxonomy",
taxonomy: TEST_TAXONOMY_REF,
scoreSnapshotRef: "docs/maturity-scores.yaml",
status: "initial",
profiles: testScorecardProfiles(TEST_EXECUTABLE_CATEGORY_ID, "none"),
categories: [
{
id: TEST_EXECUTABLE_CATEGORY_ID,
taxonomySurfaceId: "agent-runtime-and-provider-execution",
taxonomyCategoryName: "Agent Turn Execution",
supportStatus: "deferred",
releaseBlocking: false,
requirement: "Non-advisory rows must stay visible to runnable profiles.",
evidenceRequired: "At least one smoke-ci or release membership before promotion.",
evidence: {
profiles: [],
liveProofRequired: false,
freshness: "target-ref",
coverageIds: ["channels.dm"],
scenarioRefs: ["qa/scenarios/channels/dm-chat-baseline.md"],
docsRefs: ["docs/concepts/qa-e2e-automation.md"],
codeRefs: ["extensions/qa-lab/src/suite.ts"],
},
},
],
});
const report = buildQaScorecardTaxonomyReport({
taxonomy,
repoRoot: process.cwd(),
scenarios: readQaScenarioPack().scenarios,
});
expect(report.validationIssues.map((issue) => issue.code)).toEqual([
"non-advisory-category-missing-profile-membership",
]);
});
it("reports executable category refs missing from taxonomy.yaml", () => {
const taxonomy = parseQaScorecardTaxonomy({
version: 1,
id: "test-taxonomy",
title: "Test taxonomy",
taxonomy: TEST_TAXONOMY_REF,
scoreSnapshotRef: "docs/maturity-scores.yaml",
status: "initial",
profiles: testScorecardProfiles(TEST_EXECUTABLE_CATEGORY_ID, "release"),
categories: [
{
id: TEST_EXECUTABLE_CATEGORY_ID,
taxonomySurfaceId: "agent-runtime-and-provider-execution",
taxonomyCategoryName: "Missing Taxonomy Category",
supportStatus: "lts-included",
releaseBlocking: true,
requirement: "Executable refs must resolve against taxonomy.yaml.",
evidenceRequired: "A valid taxonomy surface/category ref.",
evidence: {
profiles: ["release"],
liveProofRequired: false,
freshness: "target-ref",
coverageIds: ["channels.dm"],
scenarioRefs: ["qa/scenarios/channels/dm-chat-baseline.md"],
docsRefs: ["docs/concepts/qa-e2e-automation.md"],
codeRefs: ["extensions/qa-lab/src/suite.ts"],
},
},
],
});
const report = buildQaScorecardTaxonomyReport({
taxonomy,
repoRoot: process.cwd(),
scenarios: readQaScenarioPack().scenarios,
});
expect(report.validationIssues.map((issue) => issue.code)).toEqual([
"taxonomy-category-ref-not-found",
]);
});
it("reports profile membership refs missing from executable categories", () => {
const taxonomy = parseQaScorecardTaxonomy({
version: 1,
id: "test-taxonomy",
title: "Test taxonomy",
taxonomy: TEST_TAXONOMY_REF,
scoreSnapshotRef: "docs/maturity-scores.yaml",
status: "initial",
profiles: [
{
id: "smoke-ci",
description: "Test smoke profile.",
categoryIds: ["missing.category"],
},
{
id: "release",
description: "Test release profile.",
categoryIds: [],
},
],
categories: [
{
id: TEST_EXECUTABLE_CATEGORY_ID,
taxonomySurfaceId: "agent-runtime-and-provider-execution",
taxonomyCategoryName: "Agent Turn Execution",
supportStatus: "advisory",
releaseBlocking: false,
requirement: "Profile selectors must reference executable category IDs.",
evidenceRequired: "Invalid selector refs should be reported.",
evidence: {
profiles: [],
liveProofRequired: false,
freshness: "latest-advisory-run",
coverageIds: [],
scenarioRefs: [],
docsRefs: ["docs/concepts/qa-e2e-automation.md"],
codeRefs: ["extensions/qa-lab/src/suite.ts"],
},
},
],
});
const report = buildQaScorecardTaxonomyReport({
taxonomy,
repoRoot: process.cwd(),
scenarios: readQaScenarioPack().scenarios,
});
expect(report.validationIssues.map((issue) => issue.code)).toEqual([
"profile-category-ref-not-found",
]);
});
it("reports category profile refs missing from top-level mapping profiles", () => {
const taxonomy = parseQaScorecardTaxonomy({
version: 1,
id: "test-taxonomy",
title: "Test taxonomy",
taxonomy: TEST_TAXONOMY_REF,
scoreSnapshotRef: "docs/maturity-scores.yaml",
status: "initial",
profiles: [...testScorecardProfiles(TEST_EXECUTABLE_CATEGORY_ID, "release")],
categories: [
{
id: TEST_EXECUTABLE_CATEGORY_ID,
taxonomySurfaceId: "agent-runtime-and-provider-execution",
taxonomyCategoryName: "Agent Turn Execution",
supportStatus: "lts-included",
releaseBlocking: true,
requirement: "Category profile refs must resolve to top-level mapping profiles.",
evidenceRequired: "Unknown profile refs should be reported.",
evidence: {
profiles: ["release", "nightly"],
liveProofRequired: false,
freshness: "target-ref",
coverageIds: ["channels.dm"],
scenarioRefs: ["qa/scenarios/channels/dm-chat-baseline.md"],
docsRefs: ["docs/concepts/qa-e2e-automation.md"],
codeRefs: ["extensions/qa-lab/src/suite.ts"],
},
},
],
});
const report = buildQaScorecardTaxonomyReport({
taxonomy,
repoRoot: process.cwd(),
scenarios: readQaScenarioPack().scenarios,
});
expect(report.validationIssues.map((issue) => issue.code)).toEqual(["profile-ref-not-found"]);
});
it("counts declared custom profiles as runnable category membership", () => {
const taxonomy = parseQaScorecardTaxonomy({
version: 1,
id: "test-taxonomy",
title: "Test taxonomy",
taxonomy: TEST_TAXONOMY_REF,
scoreSnapshotRef: "docs/maturity-scores.yaml",
status: "initial",
profiles: [
...testScorecardProfiles(TEST_EXECUTABLE_CATEGORY_ID, "none"),
{
id: "nightly",
description: "Nightly mapped profile.",
categoryIds: [TEST_EXECUTABLE_CATEGORY_ID],
},
],
categories: [
{
id: TEST_EXECUTABLE_CATEGORY_ID,
taxonomySurfaceId: "agent-runtime-and-provider-execution",
taxonomyCategoryName: "Agent Turn Execution",
supportStatus: "deferred",
releaseBlocking: false,
requirement: "Declared profile names can satisfy runnable coverage.",
evidenceRequired: "Profile names come from taxonomy-mappings.yaml.",
evidence: {
profiles: ["nightly"],
liveProofRequired: false,
freshness: "target-ref",
coverageIds: ["channels.dm"],
scenarioRefs: ["qa/scenarios/channels/dm-chat-baseline.md"],
docsRefs: ["docs/concepts/qa-e2e-automation.md"],
codeRefs: ["extensions/qa-lab/src/suite.ts"],
},
},
],
});
const report = buildQaScorecardTaxonomyReport({
taxonomy,
repoRoot: process.cwd(),
scenarios: readQaScenarioPack().scenarios,
});
expect(report.validationIssues).toStrictEqual([]);
expect(report.fulfilledCategoryCount).toBe(1);
expect(report.fulfilledFeatureCount).toBe(1);
expect(report.categories[0]?.mappingStatus).toBe("mapped");
expect(report.categories[0]?.scenarioRefs).toStrictEqual([
"qa/scenarios/ui/control-ui-chat-flow-playwright.md",
]);
expect(report.categories[0]?.evidence).toStrictEqual([
{
coverageId: TEST_BROWSER_COVERAGE_ID,
kind: "playwright",
path: "ui/src/ui/e2e/chat-flow.e2e.test.ts",
role: "primary",
scenarioRefs: ["qa/scenarios/ui/control-ui-chat-flow-playwright.md"],
},
]);
});
it("rejects taxonomy refs outside the repository", () => {
expect(() =>
parseQaScorecardTaxonomy({
version: 1,
id: "bad-taxonomy",
title: "Bad taxonomy",
taxonomy: {
...TEST_TAXONOMY_REF,
sourcePath: "../rfcs/rfcs/0007-e2e-qa-lab-scorecard-consolidation.md",
},
scoreSnapshotRef: "docs/maturity-scores.yaml",
status: "initial",
profiles: testScorecardProfiles(TEST_EXECUTABLE_CATEGORY_ID, "smoke-ci"),
categories: [
{
id: TEST_EXECUTABLE_CATEGORY_ID,
taxonomySurfaceId: "agent-runtime-and-provider-execution",
taxonomyCategoryName: "Agent Turn Execution",
supportStatus: "deferred",
releaseBlocking: false,
requirement: "Reject escaped refs.",
evidenceRequired: "Parser rejects refs outside the repository.",
evidence: {
profiles: ["smoke-ci"],
liveProofRequired: false,
freshness: "target-ref",
coverageIds: ["runtime.delivery"],
scenarioRefs: ["qa/scenarios/channels/dm-chat-baseline.md"],
docsRefs: ["/tmp/outside-openclaw.md"],
codeRefs: ["src/agents/../agents/agent-tools.ts"],
},
},
],
it("reports profile membership refs missing from taxonomy categories", () => {
const report = buildQaScorecardTaxonomyReport({
taxonomy: testMaturityTaxonomy({
profileCategoryIds: ["missing.category"],
}),
).toThrow("repo refs must not be absolute or contain parent-directory segments");
repoRoot: process.cwd(),
scenarios: [],
});
expect(report.validationIssues.map((issue) => issue.code)).toContain(
"profile-category-ref-not-found",
);
});
it("reports profile categories missing primary coverage evidence", () => {
const report = buildQaScorecardTaxonomyReport({
taxonomy: testMaturityTaxonomy(),
repoRoot: process.cwd(),
scenarios: [],
});
expect(report.validationIssues.map((issue) => issue.code)).toEqual([
"coverage-id-missing-primary-evidence",
"profile-category-missing-evidence",
]);
});
it("reports native test evidence refs outside the repository", () => {
const report = buildQaScorecardTaxonomyReport({
taxonomy: testMaturityTaxonomy(),
repoRoot: process.cwd(),
scenarios: [
scenarioWithCoverage({
primary: [TEST_EXECUTABLE_COVERAGE_ID],
executionKind: "playwright",
executionPath: "../outside-openclaw.test.ts",
}),
],
});
expect(report.validationIssues.map((issue) => issue.code)).toEqual([
"evidence-ref-not-found",
"coverage-id-missing-primary-evidence",
"profile-category-missing-evidence",
]);
});
it("uses scenario coverage metadata as runnable scenario evidence", () => {
const report = buildQaScorecardTaxonomyReport({
taxonomy: testMaturityTaxonomy(),
repoRoot: process.cwd(),
scenarios: [
scenarioWithCoverage({
primary: [TEST_EXECUTABLE_COVERAGE_ID],
sourcePath: "qa/scenarios/channels/dm-chat-baseline.md",
}),
],
});
expect(report.validationIssues).toStrictEqual([]);
expect(report.categories[0]?.scenarioRefs).toStrictEqual([
"qa/scenarios/channels/dm-chat-baseline.md",
]);
expect(report.categories[0]?.evidence).toStrictEqual([
{
coverageId: TEST_EXECUTABLE_COVERAGE_ID,
kind: "qa-scenario",
path: null,
role: "primary",
scenarioRefs: ["qa/scenarios/channels/dm-chat-baseline.md"],
},
]);
});
it("counts secondary scenario metadata as evidence but not fulfillment", () => {
const report = buildQaScorecardTaxonomyReport({
taxonomy: testMaturityTaxonomy(),
repoRoot: process.cwd(),
scenarios: [
scenarioWithCoverage({
primary: [TEST_WEBCHAT_COVERAGE_ID],
secondary: [TEST_EXECUTABLE_COVERAGE_ID],
}),
],
});
expect(report.fulfilledFeatureCount).toBe(0);
expect(report.categories[0]?.mappingStatus).toBe("partial");
expect(report.validationIssues.map((issue) => issue.code)).toEqual([
"coverage-id-not-found",
"coverage-id-missing-primary-evidence",
"profile-category-missing-evidence",
]);
});
});

View File

@@ -37,7 +37,7 @@ type QaCoverageScenarioReference = QaCoverageScenarioSummary & {
intent: QaCoverageIntent;
};
type QaCoverageFeatureSummary = {
type QaCoverageIdSummary = {
id: string;
scenarios: QaCoverageScenarioReference[];
};
@@ -55,11 +55,11 @@ type QaCoverageInventory = {
coverageIdCount: number;
primaryCoverageIdCount: number;
secondaryCoverageIdCount: number;
features: QaCoverageFeatureSummary[];
overlappingCoverage: QaCoverageFeatureSummary[];
coverageIds: QaCoverageIdSummary[];
overlappingCoverage: QaCoverageIdSummary[];
missingCoverage: QaCoverageScenarioSummary[];
byTheme: Record<string, QaCoverageFeatureSummary[]>;
bySurface: Record<string, QaCoverageFeatureSummary[]>;
byTheme: Record<string, QaCoverageIdSummary[]>;
bySurface: Record<string, QaCoverageIdSummary[]>;
scenarioPacks: QaCoverageScenarioPackSummary[];
liveTransportLanes: LiveTransportCoverageLaneSummary[];
scorecardTaxonomy: QaScorecardTaxonomyReport;
@@ -166,8 +166,8 @@ export function findQaScenarioMatches(
.toSorted((left, right) => left.id.localeCompare(right.id));
}
function sortFeatures(features: readonly QaCoverageFeatureSummary[]) {
return features.toSorted((left, right) => left.id.localeCompare(right.id));
function sortCoverageIds(coverageIds: readonly QaCoverageIdSummary[]) {
return coverageIds.toSorted((left, right) => left.id.localeCompare(right.id));
}
function buildScenarioPackSummaries(
@@ -203,24 +203,24 @@ function buildScenarioPackSummaries(
export function buildQaCoverageInventory(
scenarios: readonly QaSeedScenarioWithSource[],
): QaCoverageInventory {
const byCoverageId = new Map<string, QaCoverageFeatureSummary>();
const byCoverageId = new Map<string, QaCoverageIdSummary>();
const primaryCoverageIds = new Set<string>();
const secondaryCoverageIds = new Set<string>();
const missingCoverage: QaCoverageScenarioSummary[] = [];
const addCoverage = (
const addFeatureCoverage = (
scenario: QaSeedScenarioWithSource,
coverageIds: readonly string[] | undefined,
intent: QaCoverageIntent,
) => {
const summary = summarizeScenario(scenario);
for (const coverageId of coverageIds ?? []) {
const feature = byCoverageId.get(coverageId) ?? {
const coverage = byCoverageId.get(coverageId) ?? {
id: coverageId,
scenarios: [],
};
feature.scenarios.push({ ...summary, intent });
byCoverageId.set(coverageId, feature);
coverage.scenarios.push({ ...summary, intent });
byCoverageId.set(coverageId, coverage);
if (intent === "primary") {
primaryCoverageIds.add(coverageId);
} else {
@@ -234,40 +234,40 @@ export function buildQaCoverageInventory(
missingCoverage.push(summarizeScenario(scenario));
continue;
}
addCoverage(scenario, scenario.coverage.primary, "primary");
addCoverage(scenario, scenario.coverage.secondary, "secondary");
addFeatureCoverage(scenario, scenario.coverage.primary, "primary");
addFeatureCoverage(scenario, scenario.coverage.secondary, "secondary");
}
const features = sortFeatures([...byCoverageId.values()]);
const overlappingCoverage = features.filter((feature) => feature.scenarios.length > 1);
const byTheme: Record<string, QaCoverageFeatureSummary[]> = {};
const bySurface: Record<string, QaCoverageFeatureSummary[]> = {};
const coverageIds = sortCoverageIds([...byCoverageId.values()]);
const overlappingCoverage = coverageIds.filter((coverage) => coverage.scenarios.length > 1);
const byTheme: Record<string, QaCoverageIdSummary[]> = {};
const bySurface: Record<string, QaCoverageIdSummary[]> = {};
for (const feature of features) {
const themes = new Set(feature.scenarios.map((scenario) => scenario.theme));
for (const coverage of coverageIds) {
const themes = new Set(coverage.scenarios.map((scenario) => scenario.theme));
for (const theme of themes) {
byTheme[theme] ??= [];
byTheme[theme].push({
...feature,
scenarios: feature.scenarios.filter((scenario) => scenario.theme === theme),
...coverage,
scenarios: coverage.scenarios.filter((scenario) => scenario.theme === theme),
});
}
const surfaces = new Set(feature.scenarios.flatMap((scenario) => scenario.surfaces));
const surfaces = new Set(coverage.scenarios.flatMap((scenario) => scenario.surfaces));
for (const surface of surfaces) {
bySurface[surface] ??= [];
bySurface[surface].push({
...feature,
scenarios: feature.scenarios.filter((scenario) => scenario.surfaces.includes(surface)),
...coverage,
scenarios: coverage.scenarios.filter((scenario) => scenario.surfaces.includes(surface)),
});
}
}
return {
scenarioCount: scenarios.length,
coverageIdCount: features.length,
coverageIdCount: coverageIds.length,
primaryCoverageIdCount: primaryCoverageIds.size,
secondaryCoverageIdCount: secondaryCoverageIds.size,
features,
coverageIds,
overlappingCoverage,
missingCoverage,
byTheme,
@@ -278,12 +278,12 @@ export function buildQaCoverageInventory(
};
}
function pushFeatureLines(lines: string[], features: readonly QaCoverageFeatureSummary[]) {
for (const feature of sortFeatures(features)) {
const scenarios = feature.scenarios
function pushCoverageIdLines(lines: string[], coverageIds: readonly QaCoverageIdSummary[]) {
for (const coverage of sortCoverageIds(coverageIds)) {
const scenarios = coverage.scenarios
.map((scenario) => `${scenario.intent}: ${scenario.id} (${scenario.sourcePath})`)
.join(", ");
lines.push(`- ${feature.id}: ${scenarios}`);
lines.push(`- ${coverage.id}: ${scenarios}`);
}
}
@@ -314,7 +314,7 @@ function pushScenarioPackLines(lines: string[], packs: readonly QaCoverageScenar
const missing =
pack.missingScenarioIds.length > 0 ? pack.missingScenarioIds.join(", ") : "none";
lines.push(
`- ${pack.id} (${pack.title}): ${pack.scenarioIds.length} scenarios; coverage: ${pack.coverageIds.join(", ")}; missing scenarios: ${missing}`,
`- ${pack.id} (${pack.title}): ${pack.scenarioIds.length} scenarios; coverage IDs: ${pack.coverageIds.join(", ")}; missing scenarios: ${missing}`,
);
lines.push(` - scenarios: ${pack.scenarioIds.join(", ")}`);
}
@@ -322,20 +322,18 @@ function pushScenarioPackLines(lines: string[], packs: readonly QaCoverageScenar
function pushScorecardTaxonomyLines(lines: string[], report: QaScorecardTaxonomyReport) {
lines.push("## Scorecard Taxonomy", "");
lines.push(`- Mapping: ${report.taxonomyPath ?? "missing"}`);
lines.push(`- Mapping ID: ${report.taxonomyId ?? "missing"}`);
lines.push(`- Maturity taxonomy: ${report.taxonomy?.sourcePath ?? "missing"}`);
if (report.scoreSnapshotRef) {
lines.push(`- Maturity score snapshot: ${report.scoreSnapshotRef}`);
}
lines.push(
`- Categories: ${report.categoryCount} (${report.ltsIncludedCategoryCount} LTS-included, ${report.deferredCategoryCount} deferred, ${report.advisoryCategoryCount} advisory)`,
);
lines.push(`- Taxonomy: ${report.taxonomyPath ?? "missing"}`);
lines.push(`- Categories: ${report.categoryCount}`);
lines.push(`- Profiles: ${report.profileCount}`);
lines.push(`- Release-blocking categories: ${report.releaseBlockingCategoryCount}`);
lines.push(`- Mapped coverage IDs: ${report.mappedCoverageIdCount}`);
lines.push(`- Mapped scenarios: ${report.mappedScenarioCount}`);
lines.push(`- Unmapped coverage IDs: ${report.unmappedCoverageIdCount}`);
lines.push(
`- Fulfilled taxonomy categories: ${report.fulfilledCategoryCount}/${report.requiredCategoryCount} (${report.categoryFulfillmentPercent}%)`,
);
lines.push(
`- Fulfilled taxonomy features: ${report.fulfilledFeatureCount}/${report.requiredFeatureCount} (${report.taxonomyFulfillmentPercent}%)`,
);
lines.push(`- Evidence refs: ${report.evidenceRefCount}`);
lines.push(`- Scenario coverage IDs: ${report.scenarioCoverageIdCount}`);
lines.push(`- Unmapped scenario coverage IDs: ${report.unmappedCoverageIdCount}`);
lines.push(`- Validation warnings: ${report.validationIssueCount}`, "");
if (report.profiles.length > 0) {
@@ -350,13 +348,20 @@ function pushScorecardTaxonomyLines(lines: string[], report: QaScorecardTaxonomy
if (report.categories.length > 0) {
lines.push("### Category Mapping", "");
for (const category of report.categories) {
const blocking = category.releaseBlocking ? "release-blocking" : "non-blocking";
const coverage = category.coverageIds.length > 0 ? category.coverageIds.join(", ") : "none";
const scenarios =
category.scenarioRefs.length > 0 ? category.scenarioRefs.join(", ") : "none";
const coverageIds =
category.coverageIds.length > 0 ? category.coverageIds.join(", ") : "none";
const evidence =
category.evidence.length > 0
? category.evidence
.map((ref) => {
const target = ref.path ?? (ref.scenarioRefs.join("|") || "discovered");
return `${ref.role}:${ref.kind}:${target} (${ref.coverageId})`;
})
.join(", ")
: "none";
const profiles = category.profiles.length > 0 ? category.profiles.join(", ") : "none";
lines.push(
`- ${category.id} (${category.taxonomySurfaceId} / ${category.taxonomyCategoryName}; ${category.supportStatus}, ${blocking}, ${category.mappingStatus}): profiles: ${profiles}; coverage: ${coverage}; scenarios: ${scenarios}`,
`- ${category.id} (${category.taxonomySurfaceId} / ${category.taxonomyCategoryName}; ${category.mappingStatus}): profiles: ${profiles}; coverage IDs: ${coverageIds}; evidence: ${evidence}`,
);
}
lines.push("");
@@ -372,7 +377,7 @@ function pushScorecardTaxonomyLines(lines: string[], report: QaScorecardTaxonomy
}
if (report.unmappedCoverageIds.length > 0) {
lines.push("### Unmapped Coverage IDs", "");
lines.push("### Unmapped Scenario Coverage IDs", "");
lines.push(report.unmappedCoverageIds.join(", "));
lines.push("");
}
@@ -383,7 +388,7 @@ export function renderQaCoverageMarkdownReport(inventory: QaCoverageInventory):
"# QA Coverage Inventory",
"",
`- Scenarios: ${inventory.scenarioCount}`,
`- Coverage IDs: ${inventory.coverageIdCount}`,
`- Taxonomy coverage IDs: ${inventory.coverageIdCount}`,
`- Primary coverage IDs: ${inventory.primaryCoverageIdCount}`,
`- Secondary coverage IDs: ${inventory.secondaryCoverageIdCount}`,
`- Overlapping coverage IDs: ${inventory.overlappingCoverage.length}`,
@@ -400,14 +405,14 @@ export function renderQaCoverageMarkdownReport(inventory: QaCoverageInventory):
lines.push("## By Theme", "");
for (const theme of Object.keys(inventory.byTheme).toSorted()) {
lines.push(`### ${theme}`, "");
pushFeatureLines(lines, inventory.byTheme[theme] ?? []);
pushCoverageIdLines(lines, inventory.byTheme[theme] ?? []);
lines.push("");
}
lines.push("## By Surface", "");
for (const surface of Object.keys(inventory.bySurface).toSorted()) {
lines.push(`### ${surface}`, "");
pushFeatureLines(lines, inventory.bySurface[surface] ?? []);
pushCoverageIdLines(lines, inventory.bySurface[surface] ?? []);
lines.push("");
}
@@ -421,7 +426,7 @@ export function renderQaCoverageMarkdownReport(inventory: QaCoverageInventory):
if (inventory.overlappingCoverage.length > 0) {
lines.push("## Overlap", "");
pushFeatureLines(lines, inventory.overlappingCoverage);
pushCoverageIdLines(lines, inventory.overlappingCoverage);
lines.push("");
}
@@ -456,23 +461,22 @@ function formatSuiteCommand(matches: readonly QaScenarioSearchMatch[]) {
function scenarioMatchCommandGroups(matches: readonly QaScenarioSearchMatch[]) {
const groups = new Map<QaScenarioSearchMatch["executionKind"], QaScenarioSearchMatch[]>();
for (const match of matches) {
const existing = groups.get(match.executionKind) ?? [];
existing.push(match);
groups.set(match.executionKind, existing);
const group = groups.get(match.executionKind) ?? [];
group.push(match);
groups.set(match.executionKind, group);
}
const executionOrder: QaScenarioSearchMatch["executionKind"][] = ["flow", "vitest", "playwright"];
return executionOrder
.map((executionKind) => ({
executionKind,
matches: groups.get(executionKind) ?? [],
}))
.filter((group) => group.matches.length > 0);
return executionOrder.flatMap((executionKind) => {
const group = groups.get(executionKind);
return group && group.length > 0 ? [{ executionKind, matches: group }] : [];
});
}
export function renderQaScenarioMatchesMarkdownReport(params: {
query: string;
matches: readonly QaScenarioSearchMatch[];
}) {
const commandGroups = scenarioMatchCommandGroups(params.matches);
const lines = [
"# QA Scenario Matches",
"",
@@ -480,7 +484,6 @@ export function renderQaScenarioMatchesMarkdownReport(params: {
`- Matches: ${params.matches.length}`,
];
const commandGroups = scenarioMatchCommandGroups(params.matches);
if (commandGroups.length === 1) {
lines.push(`- Suite command: \`${formatSuiteCommand(commandGroups[0].matches)}\``);
} else if (commandGroups.length > 1) {
@@ -502,10 +505,10 @@ export function renderQaScenarioMatchesMarkdownReport(params: {
lines.push(` - surface: ${match.surfaces.join(", ")}`);
lines.push(
match.executionKind === "flow"
? " - execution: flow (qa-flow block)"
? " - execution: qa-flow"
: ` - execution: ${match.executionKind} ${match.executionPath ?? "missing"}`,
);
lines.push(` - coverage: ${match.coverageIds.join(", ") || "none"}`);
lines.push(` - coverage IDs: ${match.coverageIds.join(", ") || "none"}`);
lines.push(` - live requirements: ${formatOptionalScenarioMetadata(match)}`);
if (match.codeRefs.length > 0) {
lines.push(` - code refs: ${match.codeRefs.join(", ")}`);

View File

@@ -144,7 +144,7 @@ describe("evidence summary", () => {
checks: [
{
id: "telegram-canary",
standardId: "canary",
coverageIds: ["channels.telegram.canary"],
title: "Telegram canary",
status: "fail",
details: "timed out waiting for SUT reply",
@@ -173,7 +173,7 @@ describe("evidence summary", () => {
},
{
id: "channels.telegram.canary",
role: "live-transport-standard",
role: "live-transport-coverage",
surfaceIds: ["channels.telegram"],
categoryIds: ["channels.telegram.live"],
},
@@ -485,7 +485,7 @@ describe("evidence summary", () => {
id: "telegram-canary",
title: "Telegram canary",
details: "Canary passed.",
standardId: "canary",
coverageIds: ["channels.telegram.canary"],
status: "pass",
},
],
@@ -515,7 +515,7 @@ describe("evidence summary", () => {
id: "telegram-canary",
title: "Telegram canary",
details: "Canary passed.",
standardId: "canary",
coverageIds: ["channels.telegram.canary"],
status: "pass",
},
],
@@ -543,7 +543,7 @@ describe("evidence summary", () => {
id: "telegram-canary",
title: "Telegram canary",
details: "Canary passed.",
standardId: "canary",
coverageIds: ["channels.telegram.canary"],
status: "pass",
},
],

View File

@@ -199,8 +199,7 @@ type QaEvidenceLiveTransportCheckInput = {
rttMeasurement?: {
finalMatchedReplyRttMs?: number;
};
// Here "standard" means a taxonomy-backed requirement standard, not the default lane.
standardId?: string;
coverageIds?: readonly string[];
artifactPaths?: Readonly<Record<string, string>>;
};
@@ -263,8 +262,8 @@ function buildQaEvidenceRefs(params: {
}
function buildQaEvidenceCoverage(params: {
primaryIds?: readonly string[];
secondaryIds?: readonly string[];
primaryCoverageIds?: readonly string[];
secondaryCoverageIds?: readonly string[];
surfaceIds?: readonly string[];
categoryIds?: readonly string[];
}) {
@@ -277,8 +276,12 @@ function buildQaEvidenceCoverage(params: {
categoryIds: role === "primary" ? categoryIds : [],
});
return [
...uniqueSortedStrings(params.primaryIds ?? []).map((id) => buildCoverage(id, "primary")),
...uniqueSortedStrings(params.secondaryIds ?? []).map((id) => buildCoverage(id, "secondary")),
...uniqueSortedStrings(params.primaryCoverageIds ?? []).map((id) =>
buildCoverage(id, "primary"),
),
...uniqueSortedStrings(params.secondaryCoverageIds ?? []).map((id) =>
buildCoverage(id, "secondary"),
),
];
}
@@ -506,8 +509,8 @@ export function buildQaSuiteEvidenceSummary(
mapping: {
profile,
coverage: buildQaEvidenceCoverage({
primaryIds: primaryCoverageIds,
secondaryIds: coverageIds.filter(
primaryCoverageIds,
secondaryCoverageIds: coverageIds.filter(
(coverageId) => !primaryCoverageIds.includes(coverageId),
),
surfaceIds,
@@ -579,8 +582,8 @@ function buildTestRunnerEvidenceSummary(
mapping: {
profile,
coverage: buildQaEvidenceCoverage({
primaryIds: target?.primaryCoverageIds ?? [],
secondaryIds: target?.secondaryCoverageIds ?? [],
primaryCoverageIds: target?.primaryCoverageIds ?? [],
secondaryCoverageIds: target?.secondaryCoverageIds ?? [],
surfaceIds: target?.surfaceIds ?? [],
categoryIds: target?.categoryIds ?? [],
}),
@@ -648,25 +651,25 @@ export function buildLiveTransportEvidenceSummary(
}) ?? { id: "native" };
const entries = params.checks.map((check): QaEvidenceSummaryEntry => {
const testId = check.id;
const standardCoverageId = check.standardId
? `channels.${params.transportId}.${check.standardId}`
: undefined;
const liveCoverageId = `channels.${params.transportId}.live`;
const channelSurfaceId = `channels.${params.transportId}`;
const categoryIds = [liveCoverageId];
const coverage = [
{
id: `channels.${params.transportId}.live`,
id: liveCoverageId,
role: "live-transport",
surfaceIds: [`channels.${params.transportId}`],
categoryIds: [`channels.${params.transportId}.live`],
surfaceIds: [channelSurfaceId],
categoryIds,
},
...uniqueSortedStrings(check.coverageIds ?? [])
.filter((coverageId) => coverageId !== liveCoverageId)
.map((coverageId) => ({
id: coverageId,
role: "live-transport-coverage",
surfaceIds: [channelSurfaceId],
categoryIds,
})),
];
if (standardCoverageId) {
coverage.push({
id: standardCoverageId,
role: "live-transport-standard",
surfaceIds: [`channels.${params.transportId}`],
categoryIds: [`channels.${params.transportId}.live`],
});
}
const timing = timingForRttResult(check);
return {
test: {

View File

@@ -315,7 +315,7 @@ describe("qa-lab server", () => {
controlUiUrl: string | null;
controlUiEmbeddedUrl: string | null;
kickoffTask: string;
scenarios: Array<{ id: string; title: string; execution?: { kind?: string } }>;
scenarios: Array<{ id: string; title: string }>;
defaults: { conversationId: string; senderId: string };
runner: { status: string; selection: { providerMode: string; scenarioIds: string[] } };
};
@@ -328,12 +328,7 @@ describe("qa-lab server", () => {
expect(bootstrap.scenarios.map((scenario) => scenario.id)).toContain("dm-chat-baseline");
expect(bootstrap.runner.status).toBe("idle");
expect(bootstrap.runner.selection.providerMode).toBe("live-frontier");
const flowScenarioIds = bootstrap.scenarios
.filter(
(scenario) => scenario.execution?.kind === undefined || scenario.execution.kind === "flow",
)
.map((scenario) => scenario.id);
expect(bootstrap.runner.selection.scenarioIds).toEqual(flowScenarioIds);
expect(bootstrap.runner.selection.scenarioIds).toHaveLength(bootstrap.scenarios.length);
const startupStatus = (await (
await fetchWithRetry(`${lab.baseUrl}/api/capture/startup-status`)

View File

@@ -1873,7 +1873,10 @@ export async function runDiscordQaLive(params: {
? [{ kind: "reaction-timelines", path: path.basename(reactionTimelinesPath) }]
: []),
],
checks: scenarioResults,
checks: scenarioResults.map(({ standardId, ...check }) => ({
...check,
coverageIds: standardId ? [`channels.discord.${standardId}`] : undefined,
})),
env: process.env,
generatedAt: finishedAt,
primaryModel,

View File

@@ -2083,7 +2083,10 @@ export async function runSlackQaLive(params: {
{ kind: "report", path: path.basename(reportPath) },
{ kind: "transport-observations", path: path.basename(observedMessagesPath) },
],
checks: artifactScenarioResults,
checks: artifactScenarioResults.map(({ standardId, ...check }) => ({
...check,
coverageIds: standardId ? [`channels.slack.${standardId}`] : undefined,
})),
env: process.env,
generatedAt: finishedAt,
primaryModel,

View File

@@ -2005,7 +2005,10 @@ export async function runTelegramQaLive(params: {
generatedAt: finishedAt,
primaryModel,
providerMode,
checks: scenarioResults,
checks: scenarioResults.map(({ standardId, ...check }) => ({
...check,
coverageIds: standardId ? [`channels.telegram.${standardId}`] : undefined,
})),
transportId: "telegram",
});
await fs.writeFile(

View File

@@ -3218,7 +3218,10 @@ export async function runWhatsAppQaLive(params: {
{ kind: "report", path: path.basename(reportPath) },
{ kind: "transport-observations", path: path.basename(observedMessagesPath) },
],
checks: publishedScenarioResults,
checks: publishedScenarioResults.map(({ standardId, ...check }) => ({
...check,
coverageIds: standardId ? [`channels.whatsapp.${standardId}`] : undefined,
})),
env: process.env,
generatedAt: finishedAt,
primaryModel,

View File

@@ -111,12 +111,12 @@ describe("qa scenario catalog", () => {
expect(scenario.gatewayRuntime?.forwardHostHome).toBe(true);
});
it("loads Playwright execution scenarios from markdown", () => {
it("loads native test execution scenarios from markdown", () => {
const scenario = readQaScenarioById("control-ui-chat-flow-playwright");
expect(scenario.execution.kind).toBe("playwright");
if (scenario.execution.kind !== "playwright") {
throw new Error("expected Playwright scenario execution");
throw new Error(`expected Playwright scenario, got ${scenario.execution.kind}`);
}
expect(scenario.execution.path).toBe("ui/src/ui/e2e/chat-flow.e2e.test.ts");
expect(scenario.execution.flow).toBeUndefined();
@@ -257,7 +257,7 @@ describe("qa scenario catalog", () => {
expect(scenario.sourcePath).toBe("qa/scenarios/runtime/qa-bus-tool-trace-visibility.md");
expect(scenario.coverage?.primary).toContain("harness.tool-trace-visibility");
expect(scenario.coverage?.secondary).toContain("runtime.qa-bus");
expect(scenario.coverage?.secondary ?? []).toStrictEqual(["runtime.qa-bus", "tools.trace"]);
expect(config?.expectedToolName).toBe("exec");
expect(config?.expectedRedaction).toBe("[redacted]");
expect(config?.searchQuery).toBe("exec");

View File

@@ -49,7 +49,10 @@ describe("qa scenario packs", () => {
const scenario = readQaScenarioById(scenarioId);
expect(scenario.sourcePath).toMatch(/^qa\/scenarios\/personal\//);
expect(scenario.coverage?.primary.some((id) => id.startsWith("personal."))).toBe(true);
expect(scenario.coverage?.primary.length).toBeGreaterThan(0);
expect(
scenario.coverage?.primary.every((id) => /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/.test(id)),
).toBe(true);
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,9 @@ import {
renderQaToolCoverageMarkdownReport,
} from "./tool-coverage-report.js";
const TEST_TOOL_COVERAGE_ID =
"agent-runtime-and-provider-execution.tool-calls-and-response-handling.tool-call-handling";
function makeScenario(
id: string,
tool: string,
@@ -16,14 +19,17 @@ function makeScenario(
title: id,
surface: "runtime-tools",
coverage: {
primary: [`tools.${tool}`],
primary: [TEST_TOOL_COVERAGE_ID],
},
objective: "exercise tool",
successCriteria: ["tool is exercised"],
sourcePath: `qa/scenarios/runtime/tools/${tool}.md`,
execution: {
kind: "flow",
config,
config: {
...config,
toolCoverage: { ...readToolCoverageConfig(config), family: tool },
},
flow: {
steps: [
{
@@ -36,7 +42,24 @@ function makeScenario(
};
}
function readToolCoverageConfig(config: Record<string, unknown>): Record<string, unknown> {
const toolCoverage = config.toolCoverage;
return typeof toolCoverage === "object" && toolCoverage !== null && !Array.isArray(toolCoverage)
? (toolCoverage as Record<string, unknown>)
: {};
}
describe("qa tool coverage report", () => {
it("derives tool fixture rows from tool coverage metadata", () => {
const report = buildQaToolCoverageReport({
scenarios: [makeScenario("runtime-tool-apply-patch", "apply-patch")],
generatedAt: "2026-05-10T00:00:00.000Z",
});
expect(report.totalTools).toBe(1);
expect(report.rows[0]?.tool).toBe("apply-patch");
});
it("renders catalog-only tool fixture coverage", () => {
const report = buildQaToolCoverageReport({
scenarios: [
@@ -572,12 +595,12 @@ describe("qa tool coverage report", () => {
"bash",
"exec",
"fs.read",
"image-generate",
"image_generate",
"memory.recall",
"message-tool",
"sessions-spawn",
"tavily-search",
"web-fetch",
"sessions_spawn",
"tavily_search",
"web_fetch",
]),
);
const applyPatchRow = report.rows.find((row) => row.tool === "apply-patch");
@@ -602,26 +625,26 @@ describe("qa tool coverage report", () => {
action: "keep report-only in coding profile",
}),
);
expect(report.rows.find((row) => row.tool === "image-generate")).toEqual(
expect(report.rows.find((row) => row.tool === "image_generate")).toEqual(
expect.objectContaining({
bucket: "openclaw-dynamic-integration",
expectedLayer: "openclaw-dynamic",
required: false,
}),
);
expect(report.rows.find((row) => row.tool === "tavily-search")).toEqual(
expect(report.rows.find((row) => row.tool === "tavily_search")).toEqual(
expect.objectContaining({
tracking:
"#80173 Tavily tools are listed in the phase matrix but are not exposed by the current default tool surface.",
}),
);
expect(report.rows.find((row) => row.tool === "web-search")).toEqual(
expect(report.rows.find((row) => row.tool === "web_search")).toEqual(
expect.objectContaining({
bucket: "openclaw-dynamic-integration",
capabilityLayer: "openclaw-dynamic-direct",
required: true,
}),
);
expect(report.rows.find((row) => row.tool === "web-search")?.tracking).toBeUndefined();
expect(report.rows.find((row) => row.tool === "web_search")?.tracking).toBeUndefined();
});
});

View File

@@ -11,6 +11,7 @@ import {
type RuntimeParityResult,
} from "./runtime-parity.js";
import {
readRuntimeToolCoverageConfig,
readScenarioRuntimeToolCoverageMetadata,
type QaRuntimeCapabilityLayer,
type QaRuntimeToolBucket,
@@ -100,17 +101,13 @@ function cellStatus(cell: RuntimeParityCell | undefined): QaToolCoverageStatus {
}
function toolIdsForScenario(scenario: QaSeedScenarioWithSource): string[] {
const coverageIds = [
...(scenario.coverage?.primary ?? []),
...(scenario.coverage?.secondary ?? []),
];
return [
...new Set(
coverageIds
.filter((coverageId) => coverageId.startsWith("tools."))
.map((coverageId) => coverageId.slice("tools.".length)),
),
].toSorted((left, right) => left.localeCompare(right));
const toolCoverage = readRuntimeToolCoverageConfig(scenario.execution.config);
const family =
readString(toolCoverage?.family) ??
readString(toolCoverage?.tool) ??
readString(toolCoverage?.actualTool) ??
readString(scenario.execution.config?.toolName);
return family ? [family] : [];
}
function groupToolFixtures(scenarios: readonly QaSeedScenarioWithSource[]): ToolFixtureGroup[] {

View File

@@ -2,19 +2,6 @@
import { describe, expect, it } from "vitest";
import { slackDoctor } from "./doctor.js";
async function collectSlackWarnings(
slack: Record<string, unknown>,
defaults?: Record<string, unknown>,
) {
return (
(await Promise.resolve(
slackDoctor.collectMutableAllowlistWarnings?.({
cfg: { channels: { ...(defaults ? { defaults } : {}), slack } } as never,
}),
)) ?? []
);
}
function getSlackCompatibilityNormalizer(): NonNullable<
typeof slackDoctor.normalizeCompatibilityConfig
> {
@@ -63,236 +50,6 @@ describe("slack doctor", () => {
).toBe(true);
});
it("warns for name-keyed allowlist channels but accepts routed ID forms (#81665)", async () => {
const warnings = await collectSlackWarnings({
channels: {
"example-channel": {},
community: {},
C0AL2GDUA7J: {},
c0al2gdua7k: {},
"channel:C0AL2GDUA7L": {},
"channel:c0al2gdua7m": {},
D0AL2GDUA7Q: {},
"channel:d0al2gdua7r": {},
"channel:dabcdefgh": {},
"channel:customers": {},
"CHANNEL:C0AL2GDUA7N": {},
"channel:C0al2gdua7p": {},
"*": {},
},
});
const nameKeyWarnings = warnings.filter((warning) =>
warning.includes("Re-key it with the channel's"),
);
expect(nameKeyWarnings).toHaveLength(5);
expect(nameKeyWarnings[0]).toContain('channels.slack.channels."example-channel"');
expect(nameKeyWarnings[0]).toContain('channels.slack.channels."*" applies instead');
expect(nameKeyWarnings[1]).toContain('channels.slack.channels."community" is ambiguous');
expect(nameKeyWarnings[2]).toContain(
'channels.slack.channels."channel:customers" is ambiguous',
);
expect(nameKeyWarnings[3]).toContain('channels.slack.channels."CHANNEL:C0AL2GDUA7N"');
expect(nameKeyWarnings[4]).toContain('channels.slack.channels."channel:C0al2gdua7p"');
const dmWarnings = warnings.filter((warning) =>
warning.includes("is a Slack DM conversation ID"),
);
expect(dmWarnings).toHaveLength(3);
expect(dmWarnings[0]).toContain('channels.slack.channels."D0AL2GDUA7Q"');
expect(dmWarnings[1]).toContain('channels.slack.channels."channel:d0al2gdua7r"');
expect(dmWarnings[2]).toContain('channels.slack.channels."channel:dabcdefgh"');
expect(dmWarnings[0]).toContain("channels.slack.dmPolicy");
});
it("uses account policy and name-matching overrides for name-keyed channels (#81665)", async () => {
const overlongName = "a".repeat(81);
const warnings = await collectSlackWarnings({
groupPolicy: "open",
channels: { "root-room": {} },
accounts: {
inheritedOpen: {
channels: { general: {} },
},
inheritedAllowlist: {
groupPolicy: "allowlist",
},
explicitAllowlist: {
groupPolicy: "allowlist",
channels: { engineering: {} },
},
nameMatching: {
groupPolicy: "allowlist",
dangerouslyAllowNameMatching: true,
channels: {
support: {},
"#help": {},
"crème-brûlée": {},
d0customers: {},
dabcdefgh: {},
"channel:customers": {},
"<#C0AL2GDUA7J>": {},
"slack:C0AL2GDUA7K": {},
"@help": {},
"##help": {},
"help+": {},
Support: {},
"-": {},
___: {},
"#--": {},
[overlongName]: {},
},
},
},
});
const nameKeyWarnings = warnings.filter((warning) =>
warning.includes("Re-key it with the channel's"),
);
expect(nameKeyWarnings).toHaveLength(13);
const rootWarning = nameKeyWarnings.find((warning) =>
warning.includes('channels.slack.channels."root-room"'),
);
expect(rootWarning).toContain("messages from the channel are dropped");
expect(
nameKeyWarnings.some((warning) =>
warning.includes('channels.slack.accounts.explicitAllowlist.channels."engineering"'),
),
).toBe(true);
expect(
nameKeyWarnings.some((warning) =>
warning.includes(
'channels.slack.accounts.nameMatching.channels."channel:customers" is ambiguous',
),
),
).toBe(true);
expect(
nameKeyWarnings.some((warning) =>
warning.includes('channels.slack.accounts.nameMatching.channels."<#C0AL2GDUA7J>"'),
),
).toBe(true);
expect(
nameKeyWarnings.some((warning) =>
warning.includes('channels.slack.accounts.nameMatching.channels."slack:C0AL2GDUA7K"'),
),
).toBe(true);
for (const invalidName of [
"@help",
"##help",
"help+",
"Support",
"-",
"___",
"#--",
overlongName,
]) {
expect(
nameKeyWarnings.some((warning) =>
warning.includes(`channels.slack.accounts.nameMatching.channels."${invalidName}"`),
),
).toBe(true);
}
const sharedOpenWarnings = await collectSlackWarnings(
{ channels: { "shared-room": {} } },
{ groupPolicy: "open" },
);
expect(
sharedOpenWarnings.some((warning) => warning.includes("not a routable Slack channel ID")),
).toBe(true);
});
it("warns when an open-policy override is keyed by channel name (#81665)", async () => {
const warnings = await collectSlackWarnings({
groupPolicy: "open",
channels: {
"private-room": { enabled: false },
},
});
expect(warnings).toEqual([expect.stringContaining('channels.slack.channels."private-room"')]);
expect(warnings[0]).toContain("the channel remains allowed");
});
it("warns for DM IDs regardless of room policy and uses account-scoped remediation", async () => {
const openWarnings = await collectSlackWarnings({
groupPolicy: "open",
channels: {
D0AL2GDUA7S: {},
},
});
expect(openWarnings).toEqual([
expect.stringContaining('channels.slack.channels."D0AL2GDUA7S"'),
]);
const disabledAccountWarnings = await collectSlackWarnings({
accounts: {
work: {
groupPolicy: "disabled",
channels: {
"channel:d0al2gdua7t": {},
},
},
},
});
expect(disabledAccountWarnings).toEqual([
expect.stringContaining('channels.slack.accounts.work.channels."channel:d0al2gdua7t"'),
]);
expect(disabledAccountWarnings[0]).toContain("channels.slack.accounts.work.dmPolicy");
expect(disabledAccountWarnings[0]).toContain("channels.slack.accounts.work.allowFrom");
const inheritedChannelWarnings = await collectSlackWarnings({
channels: {
D0AL2GDUA7U: {},
},
accounts: {
work: {
groupPolicy: "disabled",
dmPolicy: "allowlist",
allowFrom: ["U0AL2GDUA7U"],
},
},
});
expect(inheritedChannelWarnings).toEqual([
expect.stringContaining('channels.slack.channels."D0AL2GDUA7U"'),
]);
expect(inheritedChannelWarnings[0]).toContain("channels.slack.accounts.work.dmPolicy");
});
it("treats bare lowercase D forms as ambiguous without name matching", async () => {
const warnings = await collectSlackWarnings({
channels: {
d0customers: {},
dabcdefgh: {},
},
});
expect(warnings).toHaveLength(2);
expect(warnings[0]).toContain(
'channels.slack.channels."d0customers" is ambiguous: it may be a lowercase Slack DM conversation ID or a channel name',
);
expect(warnings[1]).toContain(
'channels.slack.channels."dabcdefgh" is ambiguous: it may be a lowercase Slack DM conversation ID or a channel name',
);
expect(warnings[0]).toContain("stable C/G ID");
});
it("does not audit provider defaults as a standalone named account (#81665)", async () => {
const warnings = await collectSlackWarnings({
channels: {
"provider-room": { enabled: false },
},
accounts: {
work: {
channels: {
C0AL2GDUA7J: {},
},
},
},
});
expect(warnings.some((warning) => warning.includes("provider-room"))).toBe(false);
});
it("normalizes legacy slack streaming aliases into the nested streaming shape", () => {
const normalize = getSlackCompatibilityNormalizer();

View File

@@ -1,8 +1,6 @@
// Slack plugin module implements doctor behavior.
import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract";
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import type { GroupPolicy, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { listSlackAccountIds, mergeSlackAccountConfig } from "./accounts.js";
import {
legacyConfigRules as SLACK_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig as normalizeSlackCompatibilityConfig,
@@ -50,134 +48,6 @@ const collectSlackMutableAllowlistWarnings =
},
});
const SLACK_CANONICAL_CHANNEL_ID_RE = /^[CG][A-Z0-9]{8,}$/;
const SLACK_LOWERCASE_CHANNEL_ID_RE = /^[cg][0-9][a-z0-9]{7,}$/;
const SLACK_PREFIXED_CANONICAL_CHANNEL_ID_RE = /^channel:[CG][A-Z0-9]{8,}$/;
const SLACK_PREFIXED_LOWERCASE_CHANNEL_ID_RE = /^channel:[cg][0-9][a-z0-9]{7,}$/;
const SLACK_CANONICAL_DM_ID_RE = /^(?:channel:)?D[A-Z0-9]{8,}$/;
const SLACK_PREFIXED_LOWERCASE_DM_ID_RE = /^channel:d[a-z0-9]{8,}$/;
const SLACK_AMBIGUOUS_LOWERCASE_DM_ID_RE = /^d[a-z0-9]{8,}$/;
// Letter-leading lowercase forms may be valid IDs or human names. Warn conditionally instead of
// claiming they are unroutable.
const SLACK_AMBIGUOUS_LOWERCASE_CHANNEL_ID_RE = /^(?:channel:)?[cgd][a-z][a-z0-9]{7,}$/;
// Slack supports international channel names, and runtime name matching preserves exact names.
// Keep Unicode letters/marks/numbers while enforcing lowercase, length, and punctuation rules.
const SLACK_CHANNEL_NAME_RE = /^[\p{L}\p{M}\p{N}_-]{1,80}$/u;
const SLACK_CHANNEL_NAME_ALPHANUMERIC_RE = /[\p{L}\p{N}]/u;
function looksLikeSlackChannelId(channelKey: string): boolean {
return (
SLACK_CANONICAL_CHANNEL_ID_RE.test(channelKey) ||
SLACK_LOWERCASE_CHANNEL_ID_RE.test(channelKey) ||
SLACK_PREFIXED_CANONICAL_CHANNEL_ID_RE.test(channelKey) ||
SLACK_PREFIXED_LOWERCASE_CHANNEL_ID_RE.test(channelKey)
);
}
function looksLikeSlackDmId(channelKey: string): boolean {
return (
SLACK_CANONICAL_DM_ID_RE.test(channelKey) || SLACK_PREFIXED_LOWERCASE_DM_ID_RE.test(channelKey)
);
}
function looksLikeSlackChannelNameKey(channelKey: string): boolean {
const name = channelKey.startsWith("#") ? channelKey.slice(1) : channelKey;
return (
name === name.toLowerCase() &&
SLACK_CHANNEL_NAME_RE.test(name) &&
SLACK_CHANNEL_NAME_ALPHANUMERIC_RE.test(name)
);
}
// Startup resolution updates ctx.channelsConfig, but inbound authorization captures the authored
// channels map and key list when createSlackMonitorContext runs. Diagnose those authored keys.
function collectSlackNameKeyedChannelWarnings({ cfg }: { cfg: OpenClawConfig }): string[] {
const warnings = new Set<string>();
const slackCfg = asObjectRecord(asObjectRecord(cfg.channels)?.slack);
const providerChannels = asObjectRecord(slackCfg?.channels);
const accounts = asObjectRecord(slackCfg?.accounts);
for (const accountId of listSlackAccountIds(cfg)) {
const account = asObjectRecord(mergeSlackAccountConfig(cfg, accountId));
if (!account || slackCfg?.enabled === false || account.enabled === false) {
continue;
}
const scopedGroupPolicy =
typeof account.groupPolicy === "string" ? (account.groupPolicy as GroupPolicy) : undefined;
// Slack's schema materializes this provider default before runtime account merging.
const effectiveGroupPolicy = scopedGroupPolicy ?? "allowlist";
const rawAccount = asObjectRecord(accounts?.[accountId]);
const accountPrefix = rawAccount ? `channels.slack.accounts.${accountId}` : "channels.slack";
const accountChannels = asObjectRecord(rawAccount?.channels);
const channels = accountChannels ?? providerChannels;
if (!channels) {
continue;
}
const channelsPrefix = accountChannels
? `channels.slack.accounts.${accountId}`
: "channels.slack";
const fallbackDescription = Object.hasOwn(channels, "*")
? `${channelsPrefix}.channels."*" applies instead and this entry's overrides are ignored`
: effectiveGroupPolicy === "open"
? 'this entry\'s overrides are ignored and the channel remains allowed by groupPolicy: "open"'
: "messages from the channel are dropped";
for (const channelKey of Object.keys(channels)) {
if (channelKey === "*") {
continue;
}
if (looksLikeSlackDmId(channelKey)) {
warnings.add(
`${channelsPrefix}.channels."${channelKey}" is a Slack DM conversation ID, but ${channelsPrefix}.channels only configures channel and group rooms. ` +
`Configure DM access with ${accountPrefix}.dmPolicy and ${accountPrefix}.allowFrom instead.`,
);
continue;
}
if (SLACK_AMBIGUOUS_LOWERCASE_DM_ID_RE.test(channelKey)) {
if (
account.dangerouslyAllowNameMatching === true &&
looksLikeSlackChannelNameKey(channelKey)
) {
continue;
}
warnings.add(
`${channelsPrefix}.channels."${channelKey}" is ambiguous: it may be a lowercase Slack DM conversation ID or a channel name. ` +
`Configure DMs with ${accountPrefix}.dmPolicy and ${accountPrefix}.allowFrom; otherwise re-key the room with its stable C/G ID.`,
);
continue;
}
if (effectiveGroupPolicy === "disabled") {
continue;
}
const channelConfig = asObjectRecord(channels[channelKey]);
if (effectiveGroupPolicy === "open" && Object.keys(channelConfig ?? {}).length === 0) {
continue;
}
if (looksLikeSlackChannelId(channelKey)) {
continue;
}
if (
account.dangerouslyAllowNameMatching === true &&
looksLikeSlackChannelNameKey(channelKey)
) {
continue;
}
if (SLACK_AMBIGUOUS_LOWERCASE_CHANNEL_ID_RE.test(channelKey)) {
warnings.add(
`${channelsPrefix}.channels."${channelKey}" is ambiguous: it may be a lowercase Slack channel ID or a channel name. ` +
`If it is a channel name, inbound routing will not match it and ${fallbackDescription}. ` +
`Re-key it with the channel's stable ID (e.g. C0123ABCD, from the channel's About details or conversations.info).`,
);
continue;
}
warnings.add(
`${channelsPrefix}.channels."${channelKey}" is keyed by a channel name or non-canonical ID form, not a routable Slack channel ID; ` +
`under groupPolicy: "${effectiveGroupPolicy}" inbound routing does not match this entry, so ${fallbackDescription}. ` +
`Re-key it with the channel's ID (e.g. C0123ABCD, from the channel's About details or conversations.info).`,
);
}
}
return [...warnings];
}
export const slackDoctor: ChannelDoctorAdapter = {
dmAllowFromMode: "topOnly",
groupModel: "route",
@@ -185,8 +55,5 @@ export const slackDoctor: ChannelDoctorAdapter = {
warnOnEmptyGroupSenderAllowlist: false,
legacyConfigRules: SLACK_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig: normalizeSlackCompatibilityConfig,
collectMutableAllowlistWarnings: ({ cfg }) => [
...collectSlackMutableAllowlistWarnings({ cfg }),
...collectSlackNameKeyedChannelWarnings({ cfg }),
],
collectMutableAllowlistWarnings: collectSlackMutableAllowlistWarnings,
};

View File

@@ -5,8 +5,8 @@ Single source of truth for repo-backed QA suite bootstrap data.
- `index.md` defines pack-level bootstrap data
- each nested `*.md` scenario defines one evidence scenario via `qa-scenario`
- flow scenarios add `qa-flow`; Vitest and Playwright scenarios use `execution.path`
- scenario markdown may also define coverage IDs, category metadata, required plugins,
- flow scenarios add `qa-flow`; native test scenarios use `execution.path`
- scenario markdown may also define taxonomy coverage IDs, category metadata, required plugins,
lane filters, runtime parity tiers, and gateway config patching
- kickoff mission
@@ -15,16 +15,16 @@ Single source of truth for repo-backed QA suite bootstrap data.
Coverage tracking:
- add `coverage.primary` IDs to each scenario's `qa-scenario` block
- add taxonomy coverage IDs to `coverage.primary` in each scenario's `qa-scenario`
block
- add `coverage.secondary` only when a scenario intentionally protects another behavior
- keep IDs behavior-shaped, broad enough to reuse, lowercase, and dotted or dashed
- prefer reusing an existing feature ID over minting a scenario-shaped ID
- use the exact values listed under feature `coverageIds` in `taxonomy.yaml`
- prefer reusing an existing coverage ID over minting a scenario-shaped ID
- avoid copying the scenario title into coverage IDs
- use `pnpm openclaw qa coverage` to render the current inventory
- use `execution.kind: vitest` or `execution.kind: playwright` plus `execution.path`
for test files that provide evidence without a `qa-flow` block
- run Vitest and Playwright scenarios with
`pnpm openclaw qa suite --scenario <scenario-id>`
for native test files that provide evidence without a `qa-flow` block
- use `runtimeParityTier` for runtime-pair gate membership: `standard`,
`optional`, `live-only`, or `soak`
- treat the old `coverage: ["id"]` / `coverage: - id` list shape as invalid

View File

@@ -331,12 +331,12 @@ function buildPackageSourceEvidence() {
};
}
function standardIdForScenario(scenarioId) {
function coverageIdForScenario(scenarioId) {
if (scenarioId === "telegram-canary") {
return "canary";
return "channels.telegram.canary";
}
if (scenarioId === "telegram-mentioned-message-reply") {
return "mention-gating";
return "channels.telegram.mention-gating";
}
return undefined;
}
@@ -378,15 +378,15 @@ function buildScenarioCoverage(scenarioId) {
surfaceIds: ["channels.telegram"],
categoryIds: ["channels.telegram.live"],
};
const standardId = standardIdForScenario(scenarioId);
if (!standardId) {
const coverageId = coverageIdForScenario(scenarioId);
if (!coverageId) {
return [liveCoverage];
}
return [
liveCoverage,
{
id: `channels.telegram.${standardId}`,
role: "live-transport-standard",
id: coverageId,
role: "live-transport-coverage",
surfaceIds: ["channels.telegram"],
categoryIds: ["channels.telegram.live"],
},

View File

@@ -58,8 +58,6 @@ source "$script_parent_dir/pr-lib/worktree.sh"
# shellcheck disable=SC1091
source "$script_parent_dir/pr-lib/common.sh"
# shellcheck disable=SC1091
source "$script_parent_dir/pr-lib/changelog.sh"
# shellcheck disable=SC1091
source "$script_parent_dir/pr-lib/gates.sh"
# shellcheck disable=SC1091
source "$script_parent_dir/pr-lib/push.sh"

View File

@@ -1,402 +0,0 @@
changelog_helper_root() {
cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd
}
changelog_attribution_script() {
printf '%s\n' "$(changelog_helper_root)/scripts/check-changelog-attributions.mjs"
}
normalize_pr_changelog_entries() {
local pr="$1"
local changelog_path="CHANGELOG.md"
[ -f "$changelog_path" ] || return 0
PR_NUMBER_FOR_CHANGELOG="$pr" node <<'EOF_NODE'
const fs = require("node:fs");
const pr = process.env.PR_NUMBER_FOR_CHANGELOG;
const path = "CHANGELOG.md";
const original = fs.readFileSync(path, "utf8");
const lines = original.split("\n");
const prPattern = new RegExp(`(?:\\(#${pr}\\)|openclaw#${pr})`, "i");
function findActiveSectionIndex(arr) {
const versionUnreleasedIndex = arr.findIndex((line) =>
/^##\s+.+\(\s*unreleased\s*\)\s*$/i.test(line.trim()),
);
if (versionUnreleasedIndex !== -1) {
return versionUnreleasedIndex;
}
return arr.findIndex((line) => line.trim().toLowerCase() === "## unreleased");
}
function findSectionEnd(arr, start) {
for (let i = start + 1; i < arr.length; i += 1) {
if (/^## /.test(arr[i])) {
return i;
}
}
return arr.length;
}
function ensureActiveSection(arr) {
let activeIndex = findActiveSectionIndex(arr);
if (activeIndex !== -1) {
return activeIndex;
}
let insertAt = arr.findIndex((line, idx) => idx > 0 && /^## /.test(line));
if (insertAt === -1) {
insertAt = arr.length;
}
const block = ["## Unreleased", "", "### Changes", ""];
if (insertAt > 0 && arr[insertAt - 1] !== "") {
block.unshift("");
}
arr.splice(insertAt, 0, ...block);
return findActiveSectionIndex(arr);
}
function contextFor(arr, index) {
let major = "";
let minor = "";
for (let i = index; i >= 0; i -= 1) {
const line = arr[i];
if (!minor && /^### /.test(line)) {
minor = line.trim();
}
if (/^## /.test(line)) {
major = line.trim();
break;
}
}
return { major, minor };
}
function ensureSubsection(arr, subsection) {
const activeIndex = ensureActiveSection(arr);
const activeEnd = findSectionEnd(arr, activeIndex);
const desired = subsection && /^### /.test(subsection) ? subsection : "### Changes";
for (let i = activeIndex + 1; i < activeEnd; i += 1) {
if (arr[i].trim() === desired) {
return i;
}
}
let insertAt = activeEnd;
while (insertAt > activeIndex + 1 && arr[insertAt - 1] === "") {
insertAt -= 1;
}
const block = ["", desired, ""];
arr.splice(insertAt, 0, ...block);
return insertAt + 1;
}
function sectionTailInsertIndex(arr, subsectionIndex) {
let nextHeading = arr.length;
for (let i = subsectionIndex + 1; i < arr.length; i += 1) {
if (/^### /.test(arr[i]) || /^## /.test(arr[i])) {
nextHeading = i;
break;
}
}
let insertAt = nextHeading;
while (insertAt > subsectionIndex + 1 && arr[insertAt - 1] === "") {
insertAt -= 1;
}
return insertAt;
}
const activeHeading = lines[ensureActiveSection(lines)]?.trim() || "## Unreleased";
const moved = [];
for (let i = 0; i < lines.length; i += 1) {
if (!prPattern.test(lines[i])) {
continue;
}
const ctx = contextFor(lines, i);
if (ctx.major === activeHeading) {
continue;
}
moved.push({
line: lines[i],
subsection: ctx.minor || "### Changes",
index: i,
});
}
if (moved.length === 0) {
process.exit(0);
}
const removeIndexes = new Set(moved.map((entry) => entry.index));
const nextLines = lines.filter((_, idx) => !removeIndexes.has(idx));
for (const entry of moved) {
const subsectionIndex = ensureSubsection(nextLines, entry.subsection);
const insertAt = sectionTailInsertIndex(nextLines, subsectionIndex);
let nextHeading = nextLines.length;
for (let i = subsectionIndex + 1; i < nextLines.length; i += 1) {
if (/^### /.test(nextLines[i]) || /^## /.test(nextLines[i])) {
nextHeading = i;
break;
}
}
const alreadyPresent = nextLines
.slice(subsectionIndex + 1, nextHeading)
.some((line) => line === entry.line);
if (alreadyPresent) {
continue;
}
nextLines.splice(insertAt, 0, entry.line);
}
const updated = nextLines.join("\n");
if (updated !== original) {
fs.writeFileSync(path, updated);
}
EOF_NODE
}
validate_changelog_attribution_policy() {
node "$(changelog_attribution_script)" CHANGELOG.md
}
changelog_thanks_required_for_contributor() {
local contrib="${1:-}"
[ -n "$contrib" ] || return 1
node "$(changelog_attribution_script)" --is-forbidden-handle "$contrib" && return 1
return 0
}
changelog_explicit_human_thanks_required_for_contributor() {
local contrib="${1:-}"
[ -n "$contrib" ] || return 1
node "$(changelog_attribution_script)" --requires-explicit-human-thanks "$contrib"
}
validate_changelog_entry_for_pr() {
local pr="$1"
local contrib="$2"
local added_lines
added_lines=$(git diff --unified=0 origin/main...HEAD -- CHANGELOG.md | awk '
/^\+\+\+/ { next }
/^\+/ { print substr($0, 2) }
')
if [ -z "$added_lines" ]; then
echo "CHANGELOG.md is in diff but no added lines were detected."
exit 1
fi
local pr_pattern
pr_pattern="(#$pr|openclaw#$pr)"
local with_pr
with_pr=$(printf '%s\n' "$added_lines" | grep -Ein "$pr_pattern" || true)
if [ -z "$with_pr" ]; then
echo "CHANGELOG.md update must reference PR #$pr (for example, (#$pr))."
exit 1
fi
local diff_file
diff_file=$(mktemp)
git diff --unified=0 origin/main...HEAD -- CHANGELOG.md > "$diff_file"
if ! awk -v pr_pattern="$pr_pattern" '
BEGIN {
line_no = 0
file_line_count = 0
issue_count = 0
}
FNR == NR {
if ($0 ~ /^@@ /) {
if (match($0, /\+[0-9]+/)) {
line_no = substr($0, RSTART + 1, RLENGTH - 1) + 0
} else {
line_no = 0
}
next
}
if ($0 ~ /^\+\+\+/) {
next
}
if ($0 ~ /^\+/) {
if (line_no > 0) {
added[line_no] = 1
added_text = substr($0, 2)
if (added_text ~ pr_pattern) {
pr_added_lines[++pr_added_count] = line_no
pr_added_text[line_no] = added_text
}
line_no++
}
next
}
if ($0 ~ /^-/) {
next
}
if (line_no > 0) {
line_no++
}
next
}
{
changelog[FNR] = $0
file_line_count = FNR
}
END {
active_release_line = 0
bare_release_line = 0
active_release_name = "unreleased"
for (i = 1; i <= file_line_count; i++) {
if (changelog[i] !~ /^## /) {
continue
}
heading = tolower(changelog[i])
if (heading ~ /^##[[:space:]]+.+\([[:space:]]*unreleased[[:space:]]*\)[[:space:]]*$/) {
active_release_line = i
active_release_name = changelog[i]
break
}
if (heading == "## unreleased" && bare_release_line == 0) {
bare_release_line = i
}
}
if (active_release_line == 0 && bare_release_line != 0) {
active_release_line = bare_release_line
active_release_name = changelog[bare_release_line]
}
for (idx = 1; idx <= pr_added_count; idx++) {
entry_line = pr_added_lines[idx]
release_line = 0
section_line = 0
for (i = entry_line; i >= 1; i--) {
if (section_line == 0 && changelog[i] ~ /^### /) {
section_line = i
continue
}
if (changelog[i] ~ /^## /) {
release_line = i
break
}
}
if (release_line == 0 || release_line != active_release_line) {
printf "CHANGELOG.md PR-linked entry must be in %s: line %d: %s\n", active_release_name, entry_line, pr_added_text[entry_line]
issue_count++
continue
}
if (section_line == 0) {
printf "CHANGELOG.md entry must be inside a subsection (### ...): line %d: %s\n", entry_line, pr_added_text[entry_line]
issue_count++
continue
}
section_name = changelog[section_line]
next_heading = file_line_count + 1
for (i = entry_line + 1; i <= file_line_count; i++) {
if (changelog[i] ~ /^### / || changelog[i] ~ /^## /) {
next_heading = i
break
}
}
for (i = entry_line + 1; i < next_heading; i++) {
line_text = changelog[i]
if (line_text ~ /^[[:space:]]*$/) {
continue
}
if (i in added) {
continue
}
printf "CHANGELOG.md PR-linked entry must be appended at the end of section %s: line %d: %s\n", section_name, entry_line, pr_added_text[entry_line]
printf "Found existing non-added line below it at line %d: %s\n", i, line_text
issue_count++
break
}
}
if (issue_count > 0) {
print "Move this PR changelog entry to the end of its section (just before the next heading)."
exit 1
}
}
' "$diff_file" CHANGELOG.md; then
rm -f "$diff_file"
exit 1
fi
rm -f "$diff_file"
echo "changelog placement validated: PR-linked entries are appended at section tail"
if changelog_thanks_required_for_contributor "$contrib"; then
local with_pr_and_thanks
with_pr_and_thanks=$(printf '%s\n' "$added_lines" | grep -Ein "$pr_pattern" | grep -Fi "thanks @$contrib" || true)
if [ -z "$with_pr_and_thanks" ]; then
echo "CHANGELOG.md update must include both PR #$pr and thanks @$contrib on the changelog entry line."
exit 1
fi
echo "changelog validated: found PR #$pr + thanks @$contrib"
return 0
fi
if ! changelog_explicit_human_thanks_required_for_contributor "$contrib"; then
echo "changelog validated: found PR #$pr (no eligible human contributor handle, skipping thanks check)"
return 0
fi
local with_pr_and_any_thanks
with_pr_and_any_thanks=$(printf '%s\n' "$added_lines" | grep -Ein "$pr_pattern" | grep -Ei '(^|[[:space:]])thanks[[:space:]]+@' || true)
if [ -z "$with_pr_and_any_thanks" ]; then
echo "CHANGELOG.md update for bot/app/non-creditable author $contrib must include an explicit human Thanks @handle on the PR #$pr entry line."
echo "Choose the credited original contributor, or stop for maintainer input if authorship is unclear."
exit 1
fi
echo "changelog validated: found PR #$pr + explicit thanks for bot/app/non-creditable author $contrib"
}
validate_changelog_merge_hygiene() {
local diff
diff=$(git diff --unified=0 origin/main...HEAD -- CHANGELOG.md)
local removed_lines
removed_lines=$(printf '%s\n' "$diff" | awk '
/^---/ { next }
/^-/ { print substr($0, 2) }
')
if [ -z "$removed_lines" ]; then
return 0
fi
local removed_refs
removed_refs=$(printf '%s\n' "$removed_lines" | grep -Eo '#[0-9]+' | sort -u || true)
if [ -z "$removed_refs" ]; then
return 0
fi
local added_lines
added_lines=$(printf '%s\n' "$diff" | awk '
/^\+\+\+/ { next }
/^\+/ { print substr($0, 2) }
')
local ref
while IFS= read -r ref; do
[ -z "$ref" ] && continue
if ! printf '%s\n' "$added_lines" | grep -Fq "$ref"; then
echo "CHANGELOG.md drops existing entry reference $ref without re-adding it."
echo "Likely merge conflict loss; restore the dropped entry (or keep the same PR ref in rewritten text)."
exit 1
fi
done <<<"$removed_refs"
echo "changelog merge hygiene validated: no dropped PR references"
}

View File

@@ -177,32 +177,6 @@ merge_author_email_candidates() {
"${reviewer}@users.noreply.github.com" | awk 'NF && !seen[$0]++'
}
pr_contributor_allows_human_trailers() {
local contrib="${1:-}"
local normalized
normalized=$(printf '%s' "$contrib" | tr '[:upper:]' '[:lower:]')
case "$normalized" in
""|"null"|"app/"*|"codex"|"openclaw"|"clawsweeper"|"openclaw-clawsweeper"|"clawsweeper[bot]"|"openclaw-clawsweeper[bot]"|"steipete")
return 1
;;
esac
return 0
}
resolve_contributor_coauthor_email() {
local contrib="${1:-}"
if ! pr_contributor_allows_human_trailers "$contrib"; then
return 1
fi
local contrib_id
contrib_id=$(gh api "users/$contrib" --jq .id) || return 1
printf '%s+%s@users.noreply.github.com\n' "$contrib_id" "$contrib"
}
common_repo_root() {
if command -v repo_root >/dev/null 2>&1; then
repo_root

View File

@@ -56,22 +56,16 @@ prepare_gates() {
if [ -n "$unsupported_changelog_fragments" ]; then
echo "Unsupported changelog fragment files detected:"
printf '%s\n' "$unsupported_changelog_fragments"
echo "Move changelog fragment content into CHANGELOG.md and remove changelog/fragments files."
echo "Remove changelog/fragments files. OpenClaw changelog edits are release-managed only."
exit 1
fi
if [ "$has_changelog_update" = "true" ]; then
normalize_pr_changelog_entries "$pr"
validate_changelog_attribution_policy
echo "CHANGELOG.md changes are release-managed only. Remove CHANGELOG.md from this PR unless this is an explicit release/changelog task."
exit 1
fi
if [ "$changelog_required" = "true" ]; then
local contrib="${PR_AUTHOR:-}"
validate_changelog_merge_hygiene
validate_changelog_entry_for_pr "$pr" "$contrib"
else
echo "Changelog not required for this changed-file set."
fi
echo "Changelog not required for this changed-file set."
local current_head
current_head=$(git rev-parse HEAD)
@@ -96,7 +90,7 @@ prepare_gates() {
if [ "$reuse_gates" = "true" ]; then
gates_mode="reused_docs_only"
echo "Docs/changelog-only delta since last verified head $previous_last_verified_head; reusing prior gates."
echo "Docs-only delta since last verified head $previous_last_verified_head; reusing prior gates."
else
run_quiet_logged "pnpm build" ".local/gates-build.log" pnpm build
run_quiet_logged "pnpm check" ".local/gates-check.log" pnpm check

View File

@@ -183,8 +183,6 @@ merge_run() {
pr_title=$(printf '%s\n' "$pr_meta_json" | jq -r .title)
local pr_number
pr_number=$(printf '%s\n' "$pr_meta_json" | jq -r .number)
local contrib
contrib=$(printf '%s\n' "$pr_meta_json" | jq -r .author.login)
local is_draft
is_draft=$(printf '%s\n' "$pr_meta_json" | jq -r .isDraft)
if [ "$is_draft" = "true" ]; then
@@ -197,15 +195,6 @@ merge_run() {
local reviewer_id
reviewer_id=$(gh api user --jq .id)
local contrib_coauthor_email="${COAUTHOR_EMAIL:-}"
if [ -z "$contrib_coauthor_email" ] || [ "$contrib_coauthor_email" = "null" ]; then
if contrib_coauthor_email=$(resolve_contributor_coauthor_email "$contrib"); then
:
else
contrib_coauthor_email=""
fi
fi
local reviewer_email_candidates=()
local reviewer_email_candidate
while IFS= read -r reviewer_email_candidate; do
@@ -218,17 +207,11 @@ merge_run() {
fi
local reviewer_email="${reviewer_email_candidates[0]}"
local reviewer_coauthor_email="${reviewer_id}+${reviewer}@users.noreply.github.com"
{
echo "Merged via squash."
echo
echo "Prepared head SHA: $PREP_HEAD_SHA"
if [ -n "$contrib_coauthor_email" ]; then
echo "Co-authored-by: $contrib <$contrib_coauthor_email>"
fi
echo "Co-authored-by: $reviewer <$reviewer_coauthor_email>"
echo "Reviewed-by: @$reviewer"
} > .local/merge-body.txt
delete_remote_pr_head_branch_after_merge() {
@@ -349,15 +332,6 @@ merge_run() {
exit 1
fi
local commit_body
commit_body=$(gh api repos/:owner/:repo/commits/"$merge_sha" --jq .commit.message)
if [ -n "$contrib_coauthor_email" ]; then
printf '%s\n' "$commit_body" | rg -q "^Co-authored-by: $contrib <" || { echo "Missing PR author co-author trailer"; exit 1; }
else
echo "Skipping PR author co-author trailer check for bot/app author $contrib."
fi
printf '%s\n' "$commit_body" | rg -q "^Co-authored-by: $reviewer <" || { echo "Missing reviewer co-author trailer"; exit 1; }
local ok=0
local comment_output=""
local attempt
@@ -368,10 +342,6 @@ merge_run() {
echo
echo "- Prepared head SHA: [$PREP_HEAD_SHA]($prep_sha_url)"
echo "- Merge commit: [$merge_sha]($merge_sha_url)"
if pr_contributor_allows_human_trailers "$contrib"; then
echo
echo "Thanks @$contrib!"
fi
} | gh pr comment "$pr" -F - 2>&1
); then
ok=1

View File

@@ -163,12 +163,6 @@ prepare_push() {
if [ -z "$contrib" ]; then
contrib=$(gh pr view "$pr" --json author --jq .author.login)
fi
local coauthor_email=""
if coauthor_email=$(resolve_contributor_coauthor_email "$contrib"); then
:
else
coauthor_email=""
fi
cat >> .local/prep.md <<EOF_PREP
- Gates passed and push succeeded to branch $PR_HEAD.
@@ -185,7 +179,6 @@ EOF_PREP
PR_HEAD "$PR_HEAD" \
PR_HEAD_SHA_BEFORE "$pushed_from_sha" \
PREP_HEAD_SHA "$prep_head_sha" \
COAUTHOR_EMAIL "$coauthor_email" \
> .local/prep.env
ls -la .local/prep.md .local/prep.env >/dev/null
@@ -240,12 +233,6 @@ prepare_sync_head() {
if [ -z "$contrib" ]; then
contrib=$(gh pr view "$pr" --json author --jq .author.login)
fi
local coauthor_email=""
if coauthor_email=$(resolve_contributor_coauthor_email "$contrib"); then
:
else
coauthor_email=""
fi
cat >> .local/prep.md <<EOF_PREP
- Prep head sync completed to branch $PR_HEAD.
@@ -263,7 +250,6 @@ EOF_PREP
PR_HEAD "$PR_HEAD" \
PR_HEAD_SHA_BEFORE "$pushed_from_sha" \
PREP_HEAD_SHA "$prep_head_sha" \
COAUTHOR_EMAIL "$coauthor_email" \
> .local/prep.env
ls -la .local/prep.md .local/prep.env >/dev/null

View File

@@ -2438,7 +2438,7 @@ export function buildFullSuiteVitestRunPlans(args, cwd = process.cwd()) {
const expandToProjectConfigs =
process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS === "1" ||
(Number.isFinite(parallelShardCount) && parallelShardCount > 1) ||
shouldExpandLocalFullSuiteShardsByDefault(process.env);
shouldUseLocalFullSuiteParallelByDefault(process.env);
return fullSuiteVitestShards.flatMap((shard) => {
if (
process.env.OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD === "1" &&
@@ -2484,10 +2484,6 @@ export function shouldUseLocalFullSuiteParallelByDefault(env = process.env) {
);
}
export function shouldExpandLocalFullSuiteShardsByDefault(env = process.env) {
return env.CI !== "true" && env.GITHUB_ACTIONS !== "true";
}
function parsePositiveInt(value, label) {
const text = value?.trim();
if (!text) {

View File

@@ -3016,52 +3016,6 @@ describe("resolveModel", () => {
});
});
it("uses provider-normalized model ids for OpenRouter transport", () => {
const modelId = "openrouter/anthropic/claude-sonnet-4.6";
mockDiscoveredModel(discoverModels, {
provider: "openrouter",
modelId,
templateModel: {
...makeModel(modelId),
provider: "openrouter",
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
},
});
const baseRuntimeHooks = createRuntimeHooks();
const normalizeProviderResolvedModelWithPlugin = vi.fn(
(params: { context: { model: { id: string } } }) => ({
...params.context.model,
id: params.context.model.id.slice("openrouter/".length),
}),
);
const result = resolveModel("openrouter", modelId, "/tmp/agent", undefined, {
authStorage: { mocked: true } as never,
modelRegistry: discoverModels({ mocked: true } as never, "/tmp/agent"),
runtimeHooks: {
...baseRuntimeHooks,
normalizeProviderResolvedModelWithPlugin,
},
});
expect(normalizeProviderResolvedModelWithPlugin).toHaveBeenCalledWith(
expect.objectContaining({
provider: "openrouter",
context: expect.objectContaining({
modelId,
model: expect.objectContaining({ id: modelId }),
}),
}),
);
expectRecordFields(result.model, {
provider: "openrouter",
id: "anthropic/claude-sonnet-4.6",
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
});
});
it("matches prefixed Hugging Face ids against discovered registry models", () => {
mockDiscoveredModel(discoverModels, {
provider: "huggingface",

View File

@@ -7,7 +7,6 @@ import {
import { resetTaskRegistryForTests, type TaskRecord } from "../../../tasks/runtime-internal.js";
import {
requiresCompletionRequiredAsyncTaskWait,
shouldWaitForCompletionRequiredAsyncTasks,
waitForCompletionRequiredAsyncTasks,
type AsyncStartedToolMeta,
} from "./attempt.async-tasks.js";
@@ -98,46 +97,6 @@ describe("waitForCompletionRequiredAsyncTasks", () => {
).toBe(true);
});
it("skips media task waiting after sessions_yield pauses the attempt", () => {
resetTaskRegistryForTests();
const sessionKey = "agent:main:cron:daily-media:run:run-123";
createRunningTaskRun({
runtime: "cli",
taskKind: "image_generation",
sourceId: "image_generate:openai",
requesterSessionKey: sessionKey,
ownerKey: sessionKey,
scopeKind: "session",
runId: "tool:image_generate:run-123",
task: "daily image",
deliveryStatus: "not_applicable",
notifyPolicy: "silent",
startedAt: 1,
lastEventAt: 1,
});
expect(
shouldWaitForCompletionRequiredAsyncTasks({
sessionKey,
toolMetas: [
{
toolName: "image_generate",
asyncStarted: true,
asyncTaskRunId: "tool:image_generate:run-123",
},
],
yieldDetected: true,
}),
).toBe(false);
expect(
shouldWaitForCompletionRequiredAsyncTasks({
sessionKey,
toolMetas: [],
yieldDetected: false,
}),
).toBe(true);
});
it("waits for active cron media tasks from the task registry", async () => {
// Cron media tools may start tasks before metadata is flushed, so the
// registry is also consulted by session key.

View File

@@ -160,23 +160,6 @@ export function requiresCompletionRequiredAsyncTaskWait(params: {
);
}
/** Returns whether the current attempt should synchronously wait for media tasks. */
export function shouldWaitForCompletionRequiredAsyncTasks(params: {
sessionKey: string | undefined;
toolMetas: readonly AsyncStartedToolMeta[];
yieldDetected?: boolean;
}): boolean {
if (params.yieldDetected === true) {
// sessions_yield pauses the turn so the completion event can wake it later;
// waiting here would reuse the internal abort signal and turn the pause into AbortError.
return false;
}
return requiresCompletionRequiredAsyncTaskWait({
sessionKey: params.sessionKey,
toolMetas: params.toolMetas,
});
}
/**
* Polls completion-required async tasks until they reach terminal state, time
* out at the run deadline, or abort. Newly discovered task run ids are folded

View File

@@ -316,7 +316,6 @@ import {
} from "./attempt-trajectory-status.js";
import {
requiresCompletionRequiredAsyncTaskWait,
shouldWaitForCompletionRequiredAsyncTasks,
waitForCompletionRequiredAsyncTasks,
type AsyncStartedToolMeta,
type CompletionRequiredAsyncTaskWaitResult,
@@ -4572,10 +4571,9 @@ export async function runEmbeddedAttempt(
await sessionLockController.releaseForPrompt();
if (
shouldWaitForCompletionRequiredAsyncTasks({
requiresCompletionRequiredAsyncTaskWait({
sessionKey: params.sessionKey,
toolMetas,
yieldDetected: yieldAborted,
})
) {
const getAsyncStartedToolMetas = () =>

View File

@@ -2,11 +2,9 @@
* Regression coverage for non-secret model-auth marker helpers.
* Verifies core, plugin, env-var, OAuth, AWS, and secret-ref marker handling.
*/
import { fileURLToPath } from "node:url";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { withEnv, withEnvAsync } from "../test-utils/env.js";
const BUNDLED_PLUGINS_DIR = fileURLToPath(new URL("../../extensions/", import.meta.url));
const PLUGIN_MANIFEST_ENV_KEYS = [
"OPENCLAW_BUNDLED_PLUGINS_DIR",
"OPENCLAW_DISABLE_BUNDLED_PLUGINS",
@@ -16,12 +14,9 @@ const PLUGIN_MANIFEST_ENV_KEYS = [
"OPENCLAW_TEST_MINIMAL_GATEWAY",
] as const;
function cleanPluginManifestEnv(): Record<
(typeof PLUGIN_MANIFEST_ENV_KEYS)[number],
string | undefined
> {
function cleanPluginManifestEnv(): Record<(typeof PLUGIN_MANIFEST_ENV_KEYS)[number], undefined> {
return {
OPENCLAW_BUNDLED_PLUGINS_DIR: BUNDLED_PLUGINS_DIR,
OPENCLAW_BUNDLED_PLUGINS_DIR: undefined,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined,
OPENCLAW_SKIP_PROVIDERS: undefined,
OPENCLAW_SKIP_CHANNELS: undefined,
@@ -40,7 +35,6 @@ let listKnownNonSecretApiKeyMarkers: typeof import("./model-auth-markers.js").li
let resolveOAuthApiKeyMarker: typeof import("./model-auth-markers.js").resolveOAuthApiKeyMarker;
async function loadMarkerModules() {
vi.doUnmock("../plugins/manifest-metadata-scan.js");
vi.doUnmock("../plugins/manifest-registry.js");
vi.doUnmock("../secrets/provider-env-vars.js");
vi.resetModules();

View File

@@ -36,7 +36,6 @@ const AWS_SDK_ENV_MARKERS = new Set([
const CORE_NON_SECRET_API_KEY_MARKERS = [
CUSTOM_LOCAL_AUTH_MARKER,
CODEX_APP_SERVER_AUTH_MARKER,
GCP_VERTEX_CREDENTIALS_MARKER,
OLLAMA_LOCAL_AUTH_MARKER,
NON_ENV_SECRETREF_MARKER,
] as const;

View File

@@ -29,19 +29,6 @@ vi.mock("../plugins/plugin-registry.js", () => ({
}),
}));
vi.mock("../plugins/manifest-metadata-scan.js", () => ({
listOpenClawPluginManifestMetadata: () => [
{
pluginDir: "/bundled/anthropic-vertex",
origin: "bundled",
manifest: {
id: "anthropic-vertex",
nonSecretAuthMarkers: ["gcp-vertex-credentials"],
},
},
],
}));
vi.mock("../plugins/providers.js", () => ({
resolveOwningPluginIdsForProvider: () => [],
resolveOwningPluginIdsForProviderRef: () => [],

View File

@@ -68,35 +68,19 @@ describe("loadModelCatalogForBrowse", () => {
expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: false });
});
it("uses the read-only catalog when configured visibility has provider wildcards", async () => {
it("uses the full catalog when configured visibility has provider wildcards", async () => {
const loadCatalog = vi.fn(async ({ readOnly }: { readOnly: boolean }) =>
readOnly ? readOnlyCatalog : fullCatalog,
);
await expect(
loadModelCatalogForBrowse({ cfg: config({ providerWildcard: true }), loadCatalog }),
).resolves.toBe(readOnlyCatalog);
expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: true });
});
it("uses the full catalog for configured views with provider wildcards", async () => {
const loadCatalog = vi.fn(async ({ readOnly }: { readOnly: boolean }) =>
readOnly ? readOnlyCatalog : fullCatalog,
);
await expect(
loadModelCatalogForBrowse({
cfg: config({ providerWildcard: true }),
view: "configured",
loadCatalog,
}),
).resolves.toBe(fullCatalog);
expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: false });
});
it("returns an empty catalog when read-only catalog loading times out with provider wildcards", async () => {
it("returns an empty catalog when read-only catalog loading times out", async () => {
const onTimeout = vi.fn();
const timeoutHandle = { unref: vi.fn() } as unknown as NodeJS.Timeout;
const clearTimeout = vi.fn();
@@ -110,7 +94,7 @@ describe("loadModelCatalogForBrowse", () => {
const loadCatalog = vi.fn(() => new Promise<ModelCatalogEntry[]>(() => {}));
const resultPromise = loadModelCatalogForBrowse({
cfg: config({ providerWildcard: true }),
cfg: config(),
loadCatalog,
timeoutMs: 5,
onTimeout,

View File

@@ -36,6 +36,13 @@ export function restoreModelCatalogBrowseTestDeps(): void {
modelCatalogBrowseDeps.clearTimeout = globalThis.clearTimeout;
}
function resolveModelCatalogBrowseTimeoutMs(value: number | undefined): number {
return (
clampTimerTimeoutMs(value, 1) ??
resolveTimerTimeoutMs(DEFAULT_MODEL_CATALOG_BROWSE_TIMEOUT_MS, 1)
);
}
/** True when a browse view cannot be answered from read-only cached catalog entries. */
export function modelCatalogBrowseRequiresFullDiscovery(params: {
cfg: OpenClawConfig;
@@ -44,15 +51,7 @@ export function modelCatalogBrowseRequiresFullDiscovery(params: {
const view = params.view ?? "default";
return (
view === "all" ||
(view === "configured" &&
parseConfiguredModelVisibilityEntries({ cfg: params.cfg }).providerWildcards.size > 0)
);
}
function resolveModelCatalogBrowseTimeoutMs(value: number | undefined): number {
return (
clampTimerTimeoutMs(value, 1) ??
resolveTimerTimeoutMs(DEFAULT_MODEL_CATALOG_BROWSE_TIMEOUT_MS, 1)
parseConfiguredModelVisibilityEntries({ cfg: params.cfg }).providerWildcards.size > 0
);
}
@@ -66,6 +65,7 @@ export async function loadModelCatalogForBrowse(params: {
}): Promise<ModelCatalogEntry[]> {
const view = params.view ?? "default";
if (modelCatalogBrowseRequiresFullDiscovery({ cfg: params.cfg, view })) {
// Wildcards depend on provider discovery; read-only cached entries can hide matching models.
return await params.loadCatalog({ readOnly: false });
}

View File

@@ -10,11 +10,6 @@ import { isCliRuntimeProvider } from "./model-runtime-aliases.js";
// model picker choices. Hide them while keeping real provider/model refs visible.
const RETIRED_MODEL_PICKER_PROVIDERS = new Set(["codex", "codex-cli"]);
/** True for retired provider ids that should stay out of model selection surfaces. */
export function isRetiredModelPickerProvider(provider: string): boolean {
return RETIRED_MODEL_PICKER_PROVIDERS.has(normalizeProviderId(provider));
}
/** Creates a provider visibility predicate for model picker rendering. */
export function createModelPickerVisibleProviderPredicate(
params: { config?: OpenClawConfig; env?: NodeJS.ProcessEnv; includeSetupRegistry?: boolean } = {},
@@ -28,7 +23,7 @@ export function createModelPickerVisibleProviderPredicate(
);
return (provider: string): boolean => {
const normalized = normalizeProviderId(provider);
return !isRetiredModelPickerProvider(normalized) && !cliRuntimeProviders.has(normalized);
return !RETIRED_MODEL_PICKER_PROVIDERS.has(normalized) && !cliRuntimeProviders.has(normalized);
};
}
@@ -36,7 +31,7 @@ export function createModelPickerVisibleProviderPredicate(
export function isModelPickerVisibleProvider(provider: string): boolean {
const normalized = normalizeProviderId(provider);
return (
!isRetiredModelPickerProvider(normalized) &&
!RETIRED_MODEL_PICKER_PROVIDERS.has(normalized) &&
!isCliRuntimeProvider(normalized, { includeSetupRegistry: true })
);
}

View File

@@ -234,19 +234,6 @@ describe("prepared provider auth state", () => {
).resolves.toBe(false);
expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(2);
// Bounded browse callers may explicitly consume the prepared broad answer
// while keeping slow fallback discovery disabled.
await expect(
hasAuthForModelProvider({
provider: "openai",
cfg,
discoverExternalCliAuth: false,
allowPluginSyntheticAuth: false,
allowPreparedRuntimeAuth: true,
}),
).resolves.toBe(true);
expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(2);
// Broad-scope caller (default flags) still hits the prepared map.
await expect(hasAuthForModelProvider({ provider: "openai", cfg })).resolves.toBe(true);
expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(2);

View File

@@ -127,7 +127,6 @@ export async function hasAuthForModelProvider(params: {
store?: AuthProfileStore;
allowPluginSyntheticAuth?: boolean;
discoverExternalCliAuth?: boolean;
allowPreparedRuntimeAuth?: boolean;
runtimeAuthLookup?: RuntimeProviderAuthLookup;
resolveRuntimeAuthLookup?: () => RuntimeProviderAuthLookup;
}): Promise<boolean> {
@@ -163,8 +162,8 @@ export async function hasAuthForModelProvider(params: {
configFingerprint === preparedState.configFingerprint &&
workspaceDir === expectedWorkspaceDir &&
(params.agentDir === undefined || params.agentDir === expectedAgentDir) &&
(params.allowPreparedRuntimeAuth === true ||
(params.discoverExternalCliAuth !== false && params.allowPluginSyntheticAuth !== false)) &&
params.discoverExternalCliAuth !== false &&
params.allowPluginSyntheticAuth !== false &&
params.env === undefined &&
params.store === undefined &&
params.modelApi === undefined;
@@ -228,7 +227,6 @@ export function createProviderAuthChecker(params: {
env?: NodeJS.ProcessEnv;
allowPluginSyntheticAuth?: boolean;
discoverExternalCliAuth?: boolean;
allowPreparedRuntimeAuth?: boolean;
}): (provider: string, modelApi?: string) => Promise<boolean> {
const authCache = new Map<string, boolean>();
let runtimeAuthLookup: RuntimeProviderAuthLookup | undefined;
@@ -249,7 +247,6 @@ export function createProviderAuthChecker(params: {
env: params.env,
allowPluginSyntheticAuth: params.allowPluginSyntheticAuth,
discoverExternalCliAuth: params.discoverExternalCliAuth,
allowPreparedRuntimeAuth: params.allowPreparedRuntimeAuth,
resolveRuntimeAuthLookup: () =>
(runtimeAuthLookup ??= createRuntimeProviderAuthLookup({
cfg: params.cfg,

View File

@@ -2,10 +2,7 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { testing as cliBackendsTesting } from "./cli-backends.js";
import {
createModelPickerVisibleProviderPredicate,
isRetiredModelPickerProvider,
} from "./model-picker-visibility.js";
import { createModelPickerVisibleProviderPredicate } from "./model-picker-visibility.js";
import {
areRuntimeModelRefsEquivalent,
isCliRuntimeProvider,
@@ -172,20 +169,6 @@ describe("resolveCliRuntimeExecutionProvider", () => {
expect(isCliRuntimeProvider("acme-cli")).toBe(false);
expect(isVisibleProvider("acme-cli")).toBe(true);
});
it("recognizes retired picker providers without loading CLI backend metadata", () => {
cliBackendsTesting.setDepsForTest({
resolvePluginSetupRegistry: () => {
throw new Error("retired provider checks should not load setup metadata");
},
resolveRuntimeCliBackends: () => {
throw new Error("retired provider checks should not load runtime metadata");
},
});
expect(isRetiredModelPickerProvider("CODEX-CLI")).toBe(true);
expect(isRetiredModelPickerProvider("anthropic")).toBe(false);
});
});
describe("areRuntimeModelRefsEquivalent", () => {

View File

@@ -2,7 +2,6 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { beforeAll, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { createConfigRuntimeEnv } from "../config/env-vars.js";
@@ -18,7 +17,6 @@ import type { ProviderConfig } from "./models-config.providers.secrets.js";
import { encodePluginModelCatalogRelativePath } from "./plugin-model-catalog.js";
const TEST_ENV_VAR = "OPENCLAW_MODELS_CONFIG_TEST_ENV";
const BUNDLED_PLUGINS_DIR = fileURLToPath(new URL("../../extensions/", import.meta.url));
function createImplicitOpenRouterProvider(): ProviderConfig {
return {
@@ -535,42 +533,33 @@ describe("models-config", () => {
const credentialsPath = path.join(agentDir, "application_default_credentials.json");
await fs.writeFile(credentialsPath, JSON.stringify({ type: "authorized_user" }), "utf8");
try {
const plan = await withEnvAsync(
const plan = await planOpenClawModelsJsonWithDeps(
{
OPENCLAW_BUNDLED_PLUGINS_DIR: BUNDLED_PLUGINS_DIR,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined,
},
async () =>
await planOpenClawModelsJsonWithDeps(
{
cfg: {
agents: {
defaults: {
models: {
"google-vertex/gemini-2.5-pro": {},
},
model: { primary: "google-vertex/gemini-2.5-pro" },
},
cfg: {
agents: {
defaults: {
models: {
"google-vertex/gemini-2.5-pro": {},
},
models: { providers: {} },
model: { primary: "google-vertex/gemini-2.5-pro" },
},
agentDir,
env: {
OPENCLAW_BUNDLED_PLUGINS_DIR: BUNDLED_PLUGINS_DIR,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined,
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
GOOGLE_CLOUD_PROJECT: "vertex-project",
GOOGLE_CLOUD_LOCATION: "global",
} as NodeJS.ProcessEnv,
existingRaw: "",
existingParsed: null,
},
{
resolveImplicitProviders: async () => ({
"google-vertex": createImplicitGoogleVertexProvider(),
}),
},
),
models: { providers: {} },
},
agentDir,
env: {
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
GOOGLE_CLOUD_PROJECT: "vertex-project",
GOOGLE_CLOUD_LOCATION: "global",
} as NodeJS.ProcessEnv,
existingRaw: "",
existingParsed: null,
},
{
resolveImplicitProviders: async () => ({
"google-vertex": createImplicitGoogleVertexProvider(),
}),
},
);
expect(plan.action).toBe("write");

View File

@@ -2,18 +2,15 @@
import { mkdtemp, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginMetadataSnapshotOwnerMaps } from "../plugins/plugin-metadata-snapshot.js";
import type { ProviderPlugin } from "../plugins/types.js";
import { withEnvAsync } from "../test-utils/env.js";
const mocks = vi.hoisted(() => ({
resolveRuntimePluginDiscoveryProviders: vi.fn(),
runProviderCatalog: vi.fn(),
runProviderStaticCatalog: vi.fn(),
}));
const BUNDLED_PLUGINS_DIR = fileURLToPath(new URL("../../extensions/", import.meta.url));
vi.mock("../plugins/provider-discovery.js", () => ({
resolveRuntimePluginDiscoveryProviders: mocks.resolveRuntimePluginDiscoveryProviders,
@@ -228,26 +225,17 @@ describe("resolveImplicitProviders startup discovery scope", () => {
},
});
const providers = await withEnvAsync(
{
OPENCLAW_BUNDLED_PLUGINS_DIR: BUNDLED_PLUGINS_DIR,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined,
},
async () =>
await resolveImplicitProviders({
agentDir: "/tmp/openclaw-agent",
config: {},
env: {
OPENCLAW_BUNDLED_PLUGINS_DIR: BUNDLED_PLUGINS_DIR,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined,
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
GOOGLE_CLOUD_PROJECT: "vertex-project",
GOOGLE_CLOUD_LOCATION: "global",
} as NodeJS.ProcessEnv,
explicitProviders: {},
providerDiscoveryEntriesOnly: true,
}),
);
const providers = await resolveImplicitProviders({
agentDir: "/tmp/openclaw-agent",
config: {},
env: {
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
GOOGLE_CLOUD_PROJECT: "vertex-project",
GOOGLE_CLOUD_LOCATION: "global",
} as NodeJS.ProcessEnv,
explicitProviders: {},
providerDiscoveryEntriesOnly: true,
});
expect(providers?.["google-vertex"]?.apiKey).toBe("gcp-vertex-credentials");
});

View File

@@ -1513,10 +1513,11 @@ describe("sessions tools", () => {
expect(calls.find((call) => call.method === "send")).toBeUndefined();
});
it("sessions_send reports active-run queue rejection without durable-session fallback", async () => {
it("sessions_send reroutes run-scoped active deliveries when transcript steering is rejected", async () => {
const calls: Array<{ method?: string; params?: unknown }> = [];
const requesterKey = "agent:re-portal:main";
const runScopedCallerKey = "agent:leasing-ops:cron:monthly-utility:run:run-fast";
const durableCallerKey = "agent:leasing-ops:cron:monthly-utility";
const queueMessage = vi.fn(async (_text: string, _options?: unknown) => {
throw new Error("active session ended before queued steering message was committed");
});
@@ -1538,6 +1539,13 @@ describe("sessions tools", () => {
if (request.method === "agent") {
return { runId: "fallback-run", status: "accepted", acceptedAt: 2000 };
}
if (request.method === "agent.wait") {
const params = request.params as { runId?: string } | undefined;
return { runId: params?.runId ?? "fallback-run", status: "ok" };
}
if (request.method === "chat.history") {
return { messages: [] };
}
return {};
});
@@ -1562,11 +1570,9 @@ describe("sessions tools", () => {
timeoutSeconds: 0,
});
const details = sessionsSendDetails(result.details);
expect(details.status).toBe("error");
expect(details.status).toBe("accepted");
expect(details.sessionKey).toBe(runScopedCallerKey);
expect(details.error).toContain("queue_message_failed reason=runtime_rejected");
expect(details.error).toContain("caller-active-session");
expect(details.error).not.toContain("fallback_failed");
expect(details.delivery?.status).toBe("pending");
const queuedText = queueMessage.mock.calls[0]?.[0];
expect(queuedText).toContain("[Inter-session message]");
expect(queuedText).toContain("[TASK-COMPLETE] re-portal occupancy ready");
@@ -1577,233 +1583,47 @@ describe("sessions tools", () => {
waitForTranscriptCommit: true,
sourceReplyDeliveryMode: "message_tool_only",
});
expect(calls.some((call) => call.method === "agent")).toBe(false);
});
it("sessions_send reports source reply delivery mode mismatch without durable-session fallback", async () => {
const calls: Array<{ method?: string; params?: unknown }> = [];
const runScopedCallerKey = "agent:leasing-ops:cron:monthly-utility:run:run-fast";
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun(
"caller-active-session",
{
queueMessage,
isStreaming: () => true,
isCompacting: () => false,
supportsTranscriptCommitWait: true,
sourceReplyDeliveryMode: "automatic",
abort: () => {},
},
runScopedCallerKey,
);
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
return { runId: "fallback-run", status: "accepted", acceptedAt: 2000 };
}
return {};
await vi.waitFor(() => {
const fallbackCall = calls.find(
(call) =>
call.method === "agent" &&
(call.params as { sessionKey?: string } | undefined)?.sessionKey === durableCallerKey,
);
expect(fallbackCall).toBeDefined();
});
const tool = createOpenClawTools({
agentSessionKey: "agent:re-portal:main",
agentChannel: "telegram",
config: {
...TEST_CONFIG,
session: {
...TEST_CONFIG.session,
agentToAgent: { maxPingPongTurns: 0 },
},
},
}).find((candidate) => candidate.name === "sessions_send");
if (!tool) {
throw new Error("missing sessions_send tool");
}
const result = await tool.execute("call-run-scoped-caller", {
sessionKey: runScopedCallerKey,
message: "[TASK-COMPLETE] re-portal occupancy ready",
timeoutSeconds: 0,
});
const details = sessionsSendDetails(result.details);
expect(details.status).toBe("error");
expect(details.sessionKey).toBe(runScopedCallerKey);
expect(details.error).toContain(
"queue_message_failed reason=source_reply_delivery_mode_mismatch",
);
expect(queueMessage).not.toHaveBeenCalled();
expect(calls.some((call) => call.method === "agent")).toBe(false);
});
it("sessions_send keeps ordinary active session targets on the gateway agent path", async () => {
const calls: Array<{ method?: string; params?: unknown }> = [];
const ordinaryActiveKey = "agent:main:main";
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun(
"ordinary-active-session",
{
queueMessage,
isStreaming: () => true,
isCompacting: () => false,
supportsTranscriptCommitWait: true,
sourceReplyDeliveryMode: "automatic",
abort: () => {},
},
ordinaryActiveKey,
);
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
return { runId: "ordinary-agent-run", status: "accepted", acceptedAt: 2000 };
}
return {};
});
const tool = createOpenClawTools({
agentSessionKey: "agent:re-portal:main",
agentChannel: "telegram",
config: {
...TEST_CONFIG,
session: {
...TEST_CONFIG.session,
agentToAgent: { maxPingPongTurns: 0 },
},
},
}).find((candidate) => candidate.name === "sessions_send");
if (!tool) {
throw new Error("missing sessions_send tool");
}
const result = await tool.execute("call-ordinary-active", {
sessionKey: ordinaryActiveKey,
message: "ordinary active target should stay gateway routed",
timeoutSeconds: 0,
});
const details = sessionsSendDetails(result.details);
expect(details.status).toBe("accepted");
expect(details.runId).toBe("ordinary-agent-run");
expect(details.sessionKey).toBe(ordinaryActiveKey);
expect(queueMessage).not.toHaveBeenCalled();
const agentCalls = calls.filter((call) => call.method === "agent");
expect(agentCalls).toHaveLength(1);
expect(agentParams(agentCalls[0] ?? {}).sessionKey).toBe(ordinaryActiveKey);
});
expect(
agentCalls.some(
(call) =>
(call.params as { sessionKey?: string } | undefined)?.sessionKey === runScopedCallerKey,
),
).toBe(false);
const fallbackParams = agentCalls.find(
(call) =>
(call.params as { sessionKey?: string } | undefined)?.sessionKey === durableCallerKey,
)?.params as { inputProvenance?: { sourceSessionKey?: string }; message?: string } | undefined;
expect(fallbackParams?.message).toContain("[Inter-session message]");
expect(fallbackParams?.message).toContain("[TASK-COMPLETE] re-portal occupancy ready");
expect(fallbackParams?.inputProvenance?.sourceSessionKey).toBe(requesterKey);
it("sessions_send falls back from stranded cron run key to durable cron parent", async () => {
const calls: Array<{ method?: string; params?: unknown }> = [];
const runScopedCallerKey = "agent:leasing-ops:cron:monthly-utility:run:run-fast";
const durableCronCallerKey = "agent:leasing-ops:cron:monthly-utility";
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun(
"caller-active-session",
{
queueMessage,
isStreaming: () => false,
isCompacting: () => false,
supportsTranscriptCommitWait: true,
sourceReplyDeliveryMode: "message_tool_only",
abort: () => {},
},
runScopedCallerKey,
);
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
return { runId: "durable-fallback-run", status: "accepted", acceptedAt: 2000 };
}
return {};
await vi.waitFor(() => {
const waitCall = calls.find(
(call) =>
call.method === "agent.wait" &&
(call.params as { runId?: string } | undefined)?.runId === "fallback-run",
);
expect(waitCall).toBeDefined();
});
const tool = createOpenClawTools({
agentSessionKey: "agent:re-portal:main",
agentChannel: "telegram",
config: {
...TEST_CONFIG,
session: {
...TEST_CONFIG.session,
agentToAgent: { maxPingPongTurns: 0 },
},
},
}).find((candidate) => candidate.name === "sessions_send");
if (!tool) {
throw new Error("missing sessions_send tool");
}
const result = await tool.execute("call-run-scoped-caller", {
sessionKey: runScopedCallerKey,
message: "[TASK-COMPLETE] re-portal occupancy ready",
timeoutSeconds: 0,
await vi.waitFor(() => {
const historyCall = calls.find(
(call) =>
call.method === "chat.history" &&
(call.params as { sessionKey?: string } | undefined)?.sessionKey === durableCallerKey,
);
expect(historyCall).toBeDefined();
});
const details = sessionsSendDetails(result.details);
expect(details.status).toBe("accepted");
expect(details.runId).toBe("durable-fallback-run");
expect(details.sessionKey).toBe(runScopedCallerKey);
expect(queueMessage).not.toHaveBeenCalled();
const agentCalls = calls.filter((call) => call.method === "agent");
expect(agentCalls).toHaveLength(1);
const params = agentParams(agentCalls[0] ?? {});
expect(params.sessionKey).toBe(durableCronCallerKey);
expect(params.message).toContain("[Inter-session message]");
expect(params.message).toContain("[TASK-COMPLETE] re-portal occupancy ready");
});
it("sessions_send rejects non-cron run-looking keys without durable-session fallback", async () => {
const calls: Array<{ method?: string; params?: unknown }> = [];
const runScopedCallerKey = "agent:leasing-ops:slack:channel:c-room:run:run-fast";
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun(
"caller-active-session",
{
queueMessage,
isStreaming: () => false,
isCompacting: () => false,
supportsTranscriptCommitWait: true,
sourceReplyDeliveryMode: "message_tool_only",
abort: () => {},
},
runScopedCallerKey,
);
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
return { runId: "durable-fallback-run", status: "accepted", acceptedAt: 2000 };
}
return {};
});
const tool = createOpenClawTools({
agentSessionKey: "agent:re-portal:main",
agentChannel: "telegram",
config: {
...TEST_CONFIG,
session: {
...TEST_CONFIG.session,
agentToAgent: { maxPingPongTurns: 0 },
},
},
}).find((candidate) => candidate.name === "sessions_send");
if (!tool) {
throw new Error("missing sessions_send tool");
}
const result = await tool.execute("call-run-scoped-caller", {
sessionKey: runScopedCallerKey,
message: "[TASK-COMPLETE] re-portal occupancy ready",
timeoutSeconds: 0,
});
const details = sessionsSendDetails(result.details);
expect(details.status).toBe("error");
expect(details.sessionKey).toBe(runScopedCallerKey);
expect(details.error).toContain("queue_message_failed reason=not_streaming");
expect(queueMessage).not.toHaveBeenCalled();
expect(calls.some((call) => call.method === "agent")).toBe(false);
});
it("sessions_send preserves active delivery when transcript commit wait is unsupported", async () => {
@@ -1857,7 +1677,7 @@ describe("sessions tools", () => {
expect(calls.some((call) => call.method === "agent")).toBe(false);
});
it("sessions_send reports run-scoped queue admission failures without gateway fallback", async () => {
it("sessions_send reports run-scoped fallback admission failures", async () => {
const runScopedCallerKey = "agent:leasing-ops:cron:monthly-utility:run:run-fast";
const queueMessage = vi.fn(async () => {
throw new Error("active session ended before queued steering message was committed");
@@ -1900,12 +1720,7 @@ describe("sessions tools", () => {
expect(details.status).toBe("error");
expect(details.sessionKey).toBe(runScopedCallerKey);
expect(details.error).toContain("queue_message_failed reason=runtime_rejected");
expect(details.error).not.toContain("fallback_failed");
expect(
callGatewayMock.mock.calls.some(
(call) => (call[0] as { method?: string } | undefined)?.method === "agent",
),
).toBe(false);
expect(details.error).toContain("fallback_failed error=gateway request timeout for agent");
});
it("sessions_send preserves terminal timeouts without starting A2A", async () => {

View File

@@ -73,15 +73,6 @@ const providerEndpointPlugins = vi.hoisted(() => [
hosts: ["integrate.api.nvidia.com"],
baseUrls: ["https://integrate.api.nvidia.com/v1"],
},
{
endpointClass: "xiaomi-native",
hosts: [
"api.xiaomimimo.com",
"token-plan-ams.xiaomimimo.com",
"token-plan-cn.xiaomimimo.com",
"token-plan-sgp.xiaomimimo.com",
],
},
],
providerRequest: {
providers: {
@@ -99,8 +90,6 @@ const providerEndpointPlugins = vi.hoisted(() => [
openrouter: { family: "openrouter" },
qwen: { family: "modelstudio" },
together: { family: "together" },
xiaomi: { family: "xiaomi" },
"xiaomi-token-plan": { family: "xiaomi" },
xai: { family: "xai" },
zai: { family: "zai" },
},
@@ -115,15 +104,6 @@ vi.mock("../plugins/plugin-registry.js", () => ({
}),
}));
vi.mock("../plugins/manifest-metadata-scan.js", () => ({
listOpenClawPluginManifestMetadata: () =>
providerEndpointPlugins.map((manifest, index) => ({
pluginDir: `provider-endpoint-fixture-${index}`,
manifest,
origin: "bundled",
})),
}));
import {
listProviderAttributionPolicies,
resolveProviderAttributionHeaders,

View File

@@ -345,8 +345,6 @@ async function deliverSlackChannelAnnouncement(params: {
queueEmbeddedAgentMessageWithOutcome?: QueueEmbeddedAgentMessageWithOutcome;
sendMessage?: typeof runtimeSendMessage;
internalEvents?: AgentInternalEvent[];
sourceSessionKey?: string;
sourceChannel?: string;
sourceTool?: string;
runtimeConfig?: Record<string, unknown>;
}) {
@@ -383,8 +381,6 @@ async function deliverSlackChannelAnnouncement(params: {
bestEffortDeliver: true,
directIdempotencyKey: params.directIdempotencyKey,
internalEvents: params.internalEvents,
sourceSessionKey: params.sourceSessionKey,
sourceChannel: params.sourceChannel,
sourceTool: params.sourceTool,
});
}
@@ -4019,21 +4015,8 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
expect(sendMessage).not.toHaveBeenCalled();
});
it("runs inactive isolated cron media completions through the requester agent first", async () => {
const callGateway = createGatewayMock({
result: {
payloads: [{ text: "queued the generated image confirmation" }],
messagingToolSentTargets: [
{
tool: "sessions_send",
provider: "slack",
to: "channel:C123",
text: "The daily media workflow continued after the image callback.",
mediaUrls: ["/tmp/generated-daily.png"],
},
],
},
});
it("directly delivers stale isolated cron run media completions", async () => {
const callGateway = createGatewayMock();
const sendMessage = createSendMessageMock();
const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeMock(true);
const result = await deliverSlackChannelAnnouncement({
@@ -4061,8 +4044,6 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
replyInstruction: "Deliver the generated image through the requester run.",
},
],
sourceSessionKey: "image_generate:task-123",
sourceChannel: "internal",
});
expectRecordFields(result, {
@@ -4070,71 +4051,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
path: "direct",
});
expect(queueEmbeddedAgentMessageWithOutcome).not.toHaveBeenCalled();
expect(callGateway).toHaveBeenCalledTimes(1);
const params = expectGatewayAgentParams(callGateway, {
sessionKey: "agent:main:cron:daily-media:run:run-123",
deliver: true,
channel: "slack",
accountId: "acct-1",
to: "channel:C123",
idempotencyKey: "announce-stale-cron-media",
});
expectRecordFields(params.inputProvenance, {
kind: "inter_session",
sourceSessionKey: "image_generate:task-123",
sourceChannel: "internal",
sourceTool: "image_generate",
});
expect(sendMessage).not.toHaveBeenCalled();
});
it("directly delivers inactive isolated cron media only after requester-agent fallback misses media", async () => {
const callGateway = createGatewayMock();
const sendMessage = createSendMessageMock();
const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeMock(true);
const result = await deliverSlackChannelAnnouncement({
callGateway,
sendMessage,
queueEmbeddedAgentMessageWithOutcome,
sessionId: "stale-cron-run-session",
isActive: false,
requesterSessionKey: "agent:main:cron:daily-media:run:run-123",
expectsCompletionMessage: true,
directIdempotencyKey: "announce-stale-cron-media-fallback",
sourceTool: "image_generate",
internalEvents: [
{
type: "task_completion",
source: "image_generation",
childSessionKey: "image_generate:task-123",
childSessionId: "task-123",
announceType: "image generation task",
taskLabel: "daily media",
status: "ok",
statusLabel: "completed successfully",
result: "Generated 1 image.\nMEDIA:/tmp/generated-daily.png",
mediaUrls: ["/tmp/generated-daily.png"],
replyInstruction: "Deliver the generated image through the requester run.",
},
],
sourceSessionKey: "image_generate:task-123",
sourceChannel: "internal",
});
expectRecordFields(result, {
delivered: true,
path: "direct",
});
expect(queueEmbeddedAgentMessageWithOutcome).not.toHaveBeenCalled();
expect(callGateway).toHaveBeenCalledTimes(1);
expectGatewayAgentParams(callGateway, {
sessionKey: "agent:main:cron:daily-media:run:run-123",
deliver: true,
channel: "slack",
accountId: "acct-1",
to: "channel:C123",
idempotencyKey: "announce-stale-cron-media-fallback",
});
expect(callGateway).not.toHaveBeenCalled();
expect(sendMessage).toHaveBeenCalledWith(
expect.objectContaining({
channel: "slack",
@@ -4142,7 +4059,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
to: "channel:C123",
content: "The generated image is ready.",
mediaUrls: ["/tmp/generated-daily.png"],
idempotencyKey: "announce-stale-cron-media-fallback:generated-media-direct",
idempotencyKey: "announce-stale-cron-media:generated-media-direct",
}),
);
});

View File

@@ -1387,8 +1387,7 @@ async function sendSubagentAnnounceDirectly(params: {
if (
params.expectsCompletionMessage &&
isCronRunSessionKey(canonicalRequesterSessionKey) &&
!resolveRequesterSessionActivity(canonicalRequesterSessionKey).isActive &&
!agentMediatedCompletion
!resolveRequesterSessionActivity(canonicalRequesterSessionKey).isActive
) {
const generatedMediaDelivery = await tryGeneratedMediaDirectDelivery();
if (generatedMediaDelivery) {

View File

@@ -180,7 +180,6 @@ export function createSubagentRunManager(params: {
stopSweeper(): void;
resumeSubagentRun(runId: string): void;
clearPendingLifecycleError(runId: string): void;
clearPendingLifecycleTimeout(runId: string): void;
resolveSubagentWaitTimeoutMs(cfg: OpenClawConfig, runTimeoutSeconds?: number): number;
scheduleOrphanRecovery(args?: { delayMs?: number; maxRetries?: number }): void;
resolveSubagentSessionCompletion(args: {
@@ -265,8 +264,6 @@ export function createSubagentRunManager(params: {
waitTerminalOutcome?.reason === "aborted" || waitTerminalOutcome?.reason === "cancelled";
const waitStatus = waitTerminalOutcome?.status ?? wait.status;
if (wait.yielded === true && waitStatus !== "timeout" && !waitBlocked) {
params.clearPendingLifecycleError(runId);
params.clearPendingLifecycleTimeout(runId);
if (
markSubagentRunPausedAfterYield({
entry,

View File

@@ -2000,185 +2000,6 @@ describe("subagent registry seam flow", () => {
expect(replacement?.endedAt).toBeUndefined();
});
it("keeps yield terminals paused when the lifecycle event also signals abort (#92448)", async () => {
// sessions_yield ends the turn by aborting the run signal, so a depth-1
// subagent's yield terminal can arrive carrying yielded plus aborted (or
// stopReason="aborted"). The event handler must still pause the run, not
// settle it `cancelled` and deliver a false notice to the requester.
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
if (request.method === "agent.wait") {
return { status: "pending" };
}
return {};
});
const cases = [
{ runId: "run-yield-stopreason-aborted", extra: { stopReason: "aborted" } },
{ runId: "run-yield-aborted-flag", extra: { aborted: true } },
];
for (const testCase of cases) {
mod.registerSubagentRun({
runId: testCase.runId,
childSessionKey: `agent:main:subagent:${testCase.runId}`,
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "wait for child continuation",
cleanup: "keep",
});
const lastOnAgentEventCall = mocks.onAgentEvent.mock.calls[
mocks.onAgentEvent.mock.calls.length - 1
] as unknown as
| [(evt: { runId: string; stream: string; data: Record<string, unknown> }) => void]
| undefined;
const lifecycleHandler = lastOnAgentEventCall?.[0];
expect(lifecycleHandler).toBeTypeOf("function");
lifecycleHandler?.({
runId: testCase.runId,
stream: "lifecycle",
data: {
phase: "end",
startedAt: 111,
endedAt: 222,
yielded: true,
...testCase.extra,
},
});
await waitForFast(() => {
const run = mod
.listSubagentRunsForRequester("agent:main:main")
.find((entry) => entry.runId === testCase.runId);
expect(run?.pauseReason).toBe("sessions_yield");
expect(run?.outcome?.status).not.toBe("error");
});
}
// Paused, never killed → no farewell/cancellation notice reaches the requester.
expect(mocks.runSubagentAnnounceFlow).not.toHaveBeenCalled();
});
it("cancels a pending grace timer when a yield follows an intermediate aborted terminal (#92448)", async () => {
// An earlier aborted terminal schedules a deferred kill grace timer; a
// following yield must clear it, or it fires and settles the now-paused run.
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
if (request.method === "agent.wait") {
return { status: "pending" };
}
return {};
});
mod.registerSubagentRun({
runId: "run-yield-after-pending-timeout",
childSessionKey: "agent:main:subagent:pending-timeout",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "wait for child continuation",
cleanup: "keep",
});
const lastOnAgentEventCall = mocks.onAgentEvent.mock.calls[
mocks.onAgentEvent.mock.calls.length - 1
] as unknown as
| [(evt: { runId: string; stream: string; data: Record<string, unknown> }) => void]
| undefined;
const lifecycleHandler = lastOnAgentEventCall?.[0];
expect(lifecycleHandler).toBeTypeOf("function");
// Intermediate aborted terminal → schedules the deferred kill grace timer.
lifecycleHandler?.({
runId: "run-yield-after-pending-timeout",
stream: "lifecycle",
data: { phase: "end", startedAt: 111, endedAt: 222, aborted: true },
});
// Yield terminal → must pause and cancel the pending grace timer.
lifecycleHandler?.({
runId: "run-yield-after-pending-timeout",
stream: "lifecycle",
data: { phase: "end", startedAt: 111, endedAt: 333, yielded: true },
});
await waitForFast(() => {
const run = mod
.listSubagentRunsForRequester("agent:main:main")
.find((entry) => entry.runId === "run-yield-after-pending-timeout");
expect(run?.pauseReason).toBe("sessions_yield");
});
// Advancing well past the 15s grace window must not undo the pause.
await vi.advanceTimersByTimeAsync(60_000);
const run = mod
.listSubagentRunsForRequester("agent:main:main")
.find((entry) => entry.runId === "run-yield-after-pending-timeout");
expect(run?.pauseReason).toBe("sessions_yield");
expect(run?.outcome?.status).not.toBe("error");
expect(mocks.runSubagentAnnounceFlow).not.toHaveBeenCalled();
});
it("cancels a pending grace timer when agent.wait observes the yield after an aborted terminal (#92448)", async () => {
let resolveWait: (value: {
status: "ok";
startedAt: number;
endedAt: number;
yielded: true;
}) => void = () => {};
const waitResult = new Promise<{
status: "ok";
startedAt: number;
endedAt: number;
yielded: true;
}>((resolve) => {
resolveWait = resolve;
});
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
if (request.method === "agent.wait") {
return waitResult;
}
return {};
});
mod.registerSubagentRun({
runId: "run-wait-yield-after-pending-timeout",
childSessionKey: "agent:main:subagent:pending-wait-timeout",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "wait for child continuation through wait",
cleanup: "keep",
});
const lastOnAgentEventCall = mocks.onAgentEvent.mock.calls[
mocks.onAgentEvent.mock.calls.length - 1
] as unknown as
| [(evt: { runId: string; stream: string; data: Record<string, unknown> }) => void]
| undefined;
const lifecycleHandler = lastOnAgentEventCall?.[0];
expect(lifecycleHandler).toBeTypeOf("function");
lifecycleHandler?.({
runId: "run-wait-yield-after-pending-timeout",
stream: "lifecycle",
data: { phase: "end", startedAt: 111, endedAt: 222, aborted: true },
});
resolveWait({ status: "ok", startedAt: 111, endedAt: 333, yielded: true });
await waitForFast(() => {
const run = mod
.listSubagentRunsForRequester("agent:main:main")
.find((entry) => entry.runId === "run-wait-yield-after-pending-timeout");
expect(run?.pauseReason).toBe("sessions_yield");
});
await vi.advanceTimersByTimeAsync(60_000);
const run = mod
.listSubagentRunsForRequester("agent:main:main")
.find((entry) => entry.runId === "run-wait-yield-after-pending-timeout");
expect(run?.pauseReason).toBe("sessions_yield");
expect(run?.outcome?.status).not.toBe("timeout");
expect(mocks.runSubagentAnnounceFlow).not.toHaveBeenCalled();
});
it("announces blocked agent.wait snapshots as errors instead of success", async () => {
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
if (request.method === "agent.wait") {

View File

@@ -480,7 +480,7 @@ function schedulePendingLifecycleTimeout(params: {
if (!entry) {
return;
}
if (entry.outcome?.status === "ok" || entry.pauseReason === "sessions_yield") {
if (entry.outcome?.status === "ok") {
return;
}
const completionParams = {
@@ -1106,25 +1106,6 @@ function ensureListener() {
});
return;
}
// sessions_yield ends the turn by aborting the run signal, so a yielded
// terminal can also look aborted. An explicit yield is authoritative — pause,
// don't kill — else the tracking task settles `cancelled` with a false notice (#92448).
if (evt.data?.yielded === true) {
// Drop any grace timer from an earlier aborted/error terminal so it can't
// later fire and settle this now-paused run with a false notice.
clearPendingLifecycleError(evt.runId);
clearPendingLifecycleTimeout(evt.runId);
if (
markSubagentRunPausedAfterYield({
entry,
endedAt,
startedAt: startedAt ?? entry.startedAt,
})
) {
persistSubagentRuns();
}
return;
}
if (isAbortedAgentStopReason(stopReason)) {
clearPendingLifecycleError(evt.runId);
clearPendingLifecycleTimeout(evt.runId);
@@ -1173,6 +1154,18 @@ function ensureListener() {
});
return;
}
if (evt.data?.yielded === true) {
if (
markSubagentRunPausedAfterYield({
entry,
endedAt,
startedAt: startedAt ?? entry.startedAt,
})
) {
persistSubagentRuns();
}
return;
}
clearPendingLifecycleError(evt.runId);
clearPendingLifecycleTimeout(evt.runId);
const completionParams = {
@@ -1210,7 +1203,6 @@ const subagentRunManager = createSubagentRunManager({
stopSweeper,
resumeSubagentRun,
clearPendingLifecycleError,
clearPendingLifecycleTimeout,
resolveSubagentWaitTimeoutMs,
scheduleOrphanRecovery: (args) => scheduleSubagentOrphanRecovery(args),
resolveSubagentSessionCompletion,

View File

@@ -21,7 +21,6 @@ import {
toAgentStoreSessionKey,
} from "../../routing/session-key.js";
import { annotateInterSessionPromptText } from "../../sessions/input-provenance.js";
import { isCronRunSessionKey, parseAgentSessionKey } from "../../sessions/session-key-utils.js";
import { SESSION_LABEL_MAX_LENGTH } from "../../sessions/session-label.js";
import { stripFormattedReasoningMessage } from "../../shared/text/formatted-reasoning-message.js";
import {
@@ -31,7 +30,6 @@ import {
import { listAgentIds } from "../agent-scope.js";
import {
type EmbeddedAgentQueueMessageOptions,
type EmbeddedAgentQueueMessageOutcome,
formatEmbeddedAgentQueueFailureSummary,
queueEmbeddedAgentMessageWithOutcomeAsync,
resolveActiveEmbeddedRunSessionId,
@@ -94,6 +92,11 @@ function normalizeSessionsSendArguments(args: unknown): Record<string, unknown>
return params;
}
function resolveRunScopedFallbackSessionKey(sessionKey: string): string | undefined {
const match = /^(agent:[^:]+:.+):run:[^:]+$/.exec(sessionKey.trim());
return match?.[1];
}
function resolveConfiguredAgentMainSessionKey(params: {
cfg: OpenClawConfig;
agentId: string;
@@ -201,51 +204,13 @@ function isPendingErrorAgentWaitTimeout(result: AgentWaitResult): boolean {
);
}
function isRunScopedAgentSessionKey(sessionKey: string): boolean {
const parsed = parseAgentSessionKey(normalizeOptionalString(sessionKey));
return Boolean(parsed && /(?:^|:)run:[^:]+(?::|$)/.test(parsed.rest));
}
function resolveCronRunScopedFallbackSessionKey(sessionKey: string): string | undefined {
const normalizedSessionKey = normalizeOptionalString(sessionKey);
if (!normalizedSessionKey || !isCronRunSessionKey(normalizedSessionKey)) {
return undefined;
}
const parsed = parseAgentSessionKey(normalizedSessionKey);
if (!parsed) {
return undefined;
}
const runMarker = ":run:";
const runMarkerIndex = parsed.rest.lastIndexOf(runMarker);
if (runMarkerIndex <= 0) {
return undefined;
}
const runId = parsed.rest.slice(runMarkerIndex + runMarker.length);
if (!runId || runId.includes(":")) {
return undefined;
}
const fallbackRest = parsed.rest.slice(0, runMarkerIndex);
if (!fallbackRest) {
return undefined;
}
return `agent:${parsed.agentId}:${fallbackRest}`;
}
function shouldFallbackCronRunScopedActiveDelivery(
outcome: EmbeddedAgentQueueMessageOutcome,
): boolean {
return (
!outcome.queued && (outcome.reason === "not_streaming" || outcome.reason === "no_active_run")
);
}
async function startAgentRun(params: {
callGateway: GatewayCaller;
runId: string;
sendParams: Record<string, unknown>;
sessionKey: string;
deliveryTimeoutMs?: number;
allowActiveRunQueueDelivery?: boolean;
allowActiveRunQueueFallback?: boolean;
}): Promise<
| {
ok: true;
@@ -257,13 +222,15 @@ async function startAgentRun(params: {
| { ok: false; result: ReturnType<typeof jsonResult> }
> {
try {
const activeRunSessionId =
params.allowActiveRunQueueDelivery && isRunScopedAgentSessionKey(params.sessionKey)
? resolveActiveEmbeddedRunSessionId(params.sessionKey)
: undefined;
const activeRunSessionId = params.allowActiveRunQueueFallback
? resolveActiveEmbeddedRunSessionId(params.sessionKey)
: undefined;
const fallbackSessionKey = activeRunSessionId
? resolveRunScopedFallbackSessionKey(params.sessionKey)
: undefined;
const messageText =
typeof params.sendParams.message === "string" ? params.sendParams.message : undefined;
if (activeRunSessionId && messageText) {
if (activeRunSessionId && fallbackSessionKey && messageText) {
const sourceReplyDeliveryMode =
params.sendParams.sourceReplyDeliveryMode === "automatic" ||
params.sendParams.sourceReplyDeliveryMode === "message_tool_only"
@@ -293,8 +260,7 @@ async function startAgentRun(params: {
if (queueOutcome.queued) {
return { ok: true, runId: params.runId, activeRunQueue: true };
}
const fallbackSessionKey = resolveCronRunScopedFallbackSessionKey(params.sessionKey);
if (fallbackSessionKey && shouldFallbackCronRunScopedActiveDelivery(queueOutcome)) {
try {
const response = await params.callGateway<{ runId: string }>({
method: "agent",
params: {
@@ -311,10 +277,13 @@ async function startAgentRun(params: {
a2aSessionKey: fallbackSessionKey,
a2aDisplayKey: fallbackSessionKey,
};
} catch (err) {
const queueSummary =
formatEmbeddedAgentQueueFailureSummary(queueOutcome) ?? "active run queue rejected";
throw new Error(`${queueSummary}; fallback_failed error=${formatErrorMessage(err)}`, {
cause: err,
});
}
const queueSummary =
formatEmbeddedAgentQueueFailureSummary(queueOutcome) ?? "active run queue rejected";
throw new Error(queueSummary);
}
const response = await params.callGateway<{ runId: string }>({
method: "agent",
@@ -674,7 +643,7 @@ export function createSessionsSendTool(opts?: {
sendParams,
sessionKey: displayKey,
deliveryTimeoutMs: announceTimeoutMs,
allowActiveRunQueueDelivery: true,
allowActiveRunQueueFallback: true,
});
if (!start.ok) {
return start.result;

View File

@@ -73,8 +73,6 @@ const {
dispatchInboundMessageWithBufferedDispatcher,
withReplyDispatcher,
} = await import("./dispatch.js");
const { clearReplyUsageStateForTest, recordReplyUsageState } =
await import("./reply/reply-usage-state.js");
function createDispatcher(record: string[]): ReplyDispatcher {
return {
@@ -112,7 +110,6 @@ function requireReplyDispatcherOptions(index = 0): Parameters<CreateReplyDispatc
describe("withReplyDispatcher", () => {
beforeEach(() => {
vi.clearAllMocks();
clearReplyUsageStateForTest();
hoisted.finalizeInboundContextMock.mockImplementation((ctx: unknown) => ctx);
hoisted.deriveInboundMessageHookContextMock.mockReturnValue({
channelId: "threads",
@@ -427,57 +424,6 @@ describe("withReplyDispatcher", () => {
);
});
it("correlates reply_payload_sending usageState with the generated run id", async () => {
const usageState = { provider: "openai", model: "gpt-5.5" };
const runReplyPayloadSending = vi.fn(async ({ payload }: { payload: { text?: string } }) => ({
payload,
}));
hoisted.getGlobalHookRunnerMock.mockReturnValue({
hasHooks: vi.fn((hookName?: string) => hookName === "reply_payload_sending"),
runMessageSending: vi.fn(async () => undefined),
runReplyPayloadSending,
});
hoisted.createReplyDispatcherMock.mockReturnValueOnce(createDispatcher([]));
hoisted.dispatchReplyFromConfigMock.mockImplementationOnce(async ({ replyOptions }) => {
replyOptions?.onAgentRunStart?.("generated-run");
recordReplyUsageState("generated-run", usageState);
return { text: "ok" };
});
await dispatchInboundMessageWithDispatcher({
ctx: buildTestCtx({ Surface: "telegram", SessionKey: "agent:test:session" }),
cfg: {} as OpenClawConfig,
dispatcherOptions: {
deliver: async () => undefined,
},
replyResolver: async () => ({ text: "ok" }),
});
const dispatcherOptions = requireReplyDispatcherOptions();
if (!dispatcherOptions?.beforeDeliver) {
throw new Error("expected beforeDeliver hook");
}
await dispatcherOptions.beforeDeliver({ text: "original reply" }, { kind: "final" });
expect(runReplyPayloadSending).toHaveBeenCalledWith(
{
payload: { text: "original reply" },
kind: "final",
channel: "telegram",
sessionKey: "agent:test:session",
runId: "generated-run",
usageState,
},
{
accountId: "acct-1",
channelId: "threads",
conversationId: "conv-1",
runId: "generated-run",
},
);
});
it("runs message_sending after reply_payload_sending for inbound dispatcher delivery", async () => {
const runReplyPayloadSending = vi.fn(async ({ payload }: { payload: { text?: string } }) => ({
payload: {

View File

@@ -35,7 +35,6 @@ import {
} from "./reply/reply-dispatcher.js";
import type { ReplyDispatcher } from "./reply/reply-dispatcher.types.js";
import { runReplyPayloadSendingHook } from "./reply/reply-payload-sending-hook.js";
import { consumeReplyUsageState } from "./reply/reply-usage-state.js";
import type { FinalizedMsgContext, MsgContext } from "./templating.js";
import type { GetReplyOptions, ReplyPayload } from "./types.js";
@@ -52,10 +51,6 @@ type ForegroundReplyFenceSnapshot = {
generation: number;
};
type ReplyPayloadRunState = {
runId?: string;
};
const foregroundReplyFenceByKey = new Map<string, ForegroundReplyFenceState>();
const replyPayloadSendingDispatchers = new WeakSet<ReplyDispatcher>();
@@ -353,52 +348,36 @@ function buildMessageSendingBeforeDeliver(
function buildReplyPayloadSendingBeforeDeliver(
ctx: MsgContext | FinalizedMsgContext,
runState: ReplyPayloadRunState,
opts?: { runId?: string },
): ReplyDispatchBeforeDeliver {
const finalized = finalizeInboundContext(ctx);
const hookCtx = deriveInboundMessageHookContext(finalized);
return async (payload: ReplyPayload, info): Promise<ReplyPayload | null> => {
const runId = runState.runId;
const hookedPayload = await runReplyPayloadSendingHook({
payload,
kind: info.kind,
channel: finalized.Surface ?? finalized.Provider,
sessionKey: finalized.SessionKey,
runId,
usageState: consumeReplyUsageState(runId),
runId: opts?.runId,
context: {
...toPluginMessageContext(hookCtx),
runId,
runId: opts?.runId,
},
});
return hookedPayload && hasOutboundReplyContent(hookedPayload) ? hookedPayload : null;
};
}
function bindReplyPayloadRunState(
replyOptions: Omit<GetReplyOptions, "onBlockReply"> | undefined,
runState: ReplyPayloadRunState,
): Omit<GetReplyOptions, "onBlockReply"> {
const onAgentRunStart = replyOptions?.onAgentRunStart;
return {
...replyOptions,
onAgentRunStart: (runId) => {
runState.runId = runId;
onAgentRunStart?.(runId);
},
};
}
function installReplyPayloadSendingBeforeDeliver(
dispatcher: ReplyDispatcher,
ctx: MsgContext | FinalizedMsgContext,
runState: ReplyPayloadRunState,
opts?: { runId?: string },
): void {
if (replyPayloadSendingDispatchers.has(dispatcher)) {
return;
}
const beforeDeliver = buildReplyPayloadSendingBeforeDeliver(ctx, runState);
const beforeDeliver = buildReplyPayloadSendingBeforeDeliver(ctx, opts);
if (!beforeDeliver || !dispatcher.appendBeforeDeliver) {
return;
}
@@ -502,13 +481,8 @@ export async function dispatchInboundMessage(params: {
replyOptions?: Omit<GetReplyOptions, "onBlockReply">;
replyResolver?: GetReplyFromConfig;
onSessionMetadataChanges?: (changes: CommandSessionMetadataChange[]) => void;
replyPayloadRunState?: ReplyPayloadRunState;
}): Promise<DispatchInboundResult> {
const replyOptions = applyRuntimeToolsAllow(params.replyOptions, params.toolsAllow);
const replyPayloadRunState = params.replyPayloadRunState ?? {
runId: replyOptions?.runId,
};
const replyOptionsWithRunState = bindReplyPayloadRunState(replyOptions, replyPayloadRunState);
const finalized = measureDiagnosticsTimelineSpanSync(
"auto_reply.finalize_context",
() => finalizeInboundContext(params.ctx),
@@ -527,7 +501,9 @@ export async function dispatchInboundMessage(params: {
source: "dispatchInboundMessage",
});
}
installReplyPayloadSendingBeforeDeliver(params.dispatcher, finalized, replyPayloadRunState);
installReplyPayloadSendingBeforeDeliver(params.dispatcher, finalized, {
runId: replyOptions?.runId,
});
const result = await withReplyDispatcher({
dispatcher: params.dispatcher,
run: () =>
@@ -538,7 +514,7 @@ export async function dispatchInboundMessage(params: {
ctx: finalized,
cfg: params.cfg,
dispatcher: params.dispatcher,
replyOptions: replyOptionsWithRunState,
replyOptions,
replyResolver: params.replyResolver,
onSessionMetadataChanges: params.onSessionMetadataChanges,
}),
@@ -565,13 +541,9 @@ export async function dispatchInboundMessageWithBufferedDispatcher(params: {
const finalized = finalizeInboundContext(params.ctx);
const foregroundReplyFence = beginForegroundReplyFence(finalized);
const silentReplyContext = resolveDispatcherSilentReplyContext(finalized, params.cfg);
const replyPayloadRunState = {
const replyPayloadBeforeDeliver = buildReplyPayloadSendingBeforeDeliver(finalized, {
runId: params.replyOptions?.runId,
};
const replyPayloadBeforeDeliver = buildReplyPayloadSendingBeforeDeliver(
finalized,
replyPayloadRunState,
);
});
const globalBeforeDeliver = combineBeforeDeliverHooks(
replyPayloadBeforeDeliver,
buildMessageSendingBeforeDeliver(finalized),
@@ -631,7 +603,6 @@ export async function dispatchInboundMessageWithBufferedDispatcher(params: {
...params.replyOptions,
...replyOptions,
},
replyPayloadRunState,
onSessionMetadataChanges: params.onSessionMetadataChanges,
});
} finally {
@@ -664,13 +635,9 @@ export async function dispatchInboundMessageWithDispatcher(params: {
replyResolver?: GetReplyFromConfig;
}): Promise<DispatchInboundResult> {
const silentReplyContext = resolveDispatcherSilentReplyContext(params.ctx, params.cfg);
const replyPayloadRunState = {
const replyPayloadBeforeDeliver = buildReplyPayloadSendingBeforeDeliver(params.ctx, {
runId: params.replyOptions?.runId,
};
const replyPayloadBeforeDeliver = buildReplyPayloadSendingBeforeDeliver(
params.ctx,
replyPayloadRunState,
);
});
const globalBeforeDeliver = combineBeforeDeliverHooks(
replyPayloadBeforeDeliver,
buildMessageSendingBeforeDeliver(params.ctx),
@@ -691,6 +658,5 @@ export async function dispatchInboundMessageWithDispatcher(params: {
toolsAllow: params.toolsAllow,
replyResolver: params.replyResolver,
replyOptions: params.replyOptions,
replyPayloadRunState,
});
}

View File

@@ -15,8 +15,6 @@ import {
formatEmbeddedAgentQueueFailureSummary,
queueEmbeddedAgentMessageWithOutcomeAsync,
} from "../../agents/embedded-agent-runner/runs.js";
import { resolveFastModeState } from "../../agents/fast-mode.js";
import { resolveAgentIdentity } from "../../agents/identity.js";
import { resolveModelAuthMode } from "../../agents/model-auth.js";
import { isCliProvider } from "../../agents/model-selection.js";
import { deriveContextPromptTokens, hasNonzeroUsage, normalizeUsage } from "../../agents/usage.js";
@@ -42,7 +40,6 @@ import {
} from "../../infra/diagnostic-trace-context.js";
import { measureDiagnosticsTimelineSpan } from "../../infra/diagnostics-timeline.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import type { PluginHookReplyUsageState } from "../../plugins/hook-types.js";
import { CommandLaneClearedError, GatewayDrainingError } from "../../process/command-queue.js";
import { shouldPreserveUserFacingSessionStateForInputProvenance } from "../../sessions/input-provenance.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
@@ -70,9 +67,6 @@ import type { OriginatingChannelType, TemplateContext } from "../templating.js";
import { resolveResponseUsageMode, type VerboseLevel } from "../thinking.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import { buildUsageContract } from "../usage-bar/contract.js";
import { loadUsageBarTemplate } from "../usage-bar/template.js";
import { renderUsageBar } from "../usage-bar/translator.js";
import {
buildKnownAgentRunFailureReplyPayload,
runAgentTurnWithFallback,
@@ -123,7 +117,6 @@ import { createReplyMediaContext } from "./reply-media-paths.js";
import { replyRunRegistry, type ReplyOperation } from "./reply-run-registry.js";
import { createReplyToModeFilterForChannel, resolveReplyToMode } from "./reply-threading.js";
import { admitReplyTurn, resolveReplyTurnKind } from "./reply-turn-admission.js";
import { recordReplyUsageState } from "./reply-usage-state.js";
import { resolveRoutedDeliveryThreadId } from "./routed-delivery-thread.js";
import { incrementRunCompactionCount, persistRunSessionUsage } from "./session-run-accounting.js";
import { resolveSourceReplyVisibilityPolicy } from "./source-reply-delivery-mode.js";
@@ -1742,77 +1735,6 @@ export async function runReplyAgent(params: {
const modelUsed = runResult.meta?.agentMeta?.model ?? fallbackModel ?? defaultModel;
const providerUsed =
runResult.meta?.agentMeta?.provider ?? fallbackProvider ?? followupRun.run.provider;
let replyUsageState: PluginHookReplyUsageState | undefined;
{
const winnerProvider = runResult.meta?.executionTrace?.winnerProvider ?? providerUsed;
const winnerModel = runResult.meta?.executionTrace?.winnerModel ?? modelUsed;
const ctxTokens = runResult.meta?.agentMeta?.contextTokens;
const compactions = runResult.meta?.agentMeta?.compactionCount;
const lastCallUsage = runResult.meta?.agentMeta?.lastCallUsage;
replyUsageState = {
provider: providerUsed,
model: modelUsed,
resolvedRef: winnerProvider && winnerModel ? `${winnerProvider}/${winnerModel}` : undefined,
reasoningEffort:
typeof followupRun.run.thinkLevel === "string" ? followupRun.run.thinkLevel : undefined,
fastMode: resolveFastModeState({
cfg,
provider: providerUsed ?? "",
model: modelUsed ?? "",
agentId: followupRun.run.agentId,
sessionEntry: activeSessionEntry,
}).enabled,
fallbackUsed: runResult.meta?.executionTrace?.fallbackUsed === true,
agentId: followupRun.run.agentId,
sessionId: followupRun.run.sessionId,
chatType: typeof sessionCtx.ChatType === "string" ? sessionCtx.ChatType : undefined,
authMode: runResult.meta?.requestShaping?.authMode ?? undefined,
overrideSource: activeSessionEntry?.modelOverrideSource ?? undefined,
requested:
followupRun.run.provider && followupRun.run.model
? `${followupRun.run.provider}/${followupRun.run.model}`
: undefined,
turnUsd: usage
? estimateUsageCost({
usage,
cost: resolveModelCostConfig({
provider: providerUsed,
model: modelUsed,
config: cfg,
}),
})
: undefined,
durationMs: Date.now() - runStartedAt,
identity: resolveAgentIdentity(cfg, followupRun.run.agentId),
compactionCount: typeof compactions === "number" ? compactions : undefined,
contextTokenBudget:
typeof ctxTokens === "number" && Number.isFinite(ctxTokens) ? ctxTokens : undefined,
contextUsedTokens:
typeof promptTokens === "number" && Number.isFinite(promptTokens)
? promptTokens
: undefined,
usage: usage
? {
input: usage.input,
output: usage.output,
cacheRead: usage.cacheRead,
cacheWrite: usage.cacheWrite,
total: usage.total,
}
: undefined,
lastUsage: lastCallUsage
? {
input: lastCallUsage.input,
output: lastCallUsage.output,
cacheRead: lastCallUsage.cacheRead,
cacheWrite: lastCallUsage.cacheWrite,
total: lastCallUsage.total,
}
: undefined,
};
recordReplyUsageState(runId, replyUsageState);
}
const verboseEnabled = resolvedVerboseLevel !== "off";
const preserveUserFacingSessionState = shouldPreserveUserFacingSessionStateForInputProvenance(
followupRun.run.inputProvenance,
@@ -2181,16 +2103,7 @@ export async function runReplyAgent(params: {
showCost,
costConfig,
});
const usageTemplate =
responseUsageMode === "full" && replyUsageState
? loadUsageBarTemplate(cfg.messages?.usageTemplate)
: undefined;
const renderedUsageLine = usageTemplate
? renderUsageBar(usageTemplate, buildUsageContract(replyUsageState, replyToChannel))
: undefined;
if (renderedUsageLine) {
formatted = renderedUsageLine;
} else if (formatted && responseUsageMode === "full" && sessionKey) {
if (formatted && responseUsageMode === "full" && sessionKey) {
formatted = `${formatted} · session \`${sessionKey}\``;
}
if (formatted) {

View File

@@ -29,16 +29,6 @@ const modelProviderAuthMocks = vi.hoisted(() => {
return state;
});
const normalizeProviderModelIdWithRuntimeMock = vi.hoisted(() => vi.fn());
const pluginMetadataMocks = vi.hoisted(() => ({
snapshot: undefined as
| {
plugins: unknown[];
owners: {
cliBackends: Map<string, string>;
};
}
| undefined,
}));
const MODELS_ADD_DEPRECATED_TEXT =
"⚠️ /models add is deprecated. Use /models to browse providers and /model to switch models.";
@@ -93,10 +83,6 @@ vi.mock("../../agents/provider-model-normalization.runtime.js", () => ({
normalizeProviderModelIdWithRuntimeMock(params),
}));
vi.mock("../../plugins/current-plugin-metadata-snapshot.js", () => ({
getCurrentPluginMetadataSnapshot: () => pluginMetadataMocks.snapshot,
}));
const telegramModelsTestPlugin: ChannelPlugin = {
...createChannelTestPluginBase({
id: "telegram",
@@ -174,7 +160,6 @@ beforeEach(() => {
modelAuthLabelMocks.resolveModelAuthLabel.mockReset();
modelAuthLabelMocks.resolveModelAuthLabel.mockReturnValue(undefined);
normalizeProviderModelIdWithRuntimeMock.mockReset();
pluginMetadataMocks.snapshot = undefined;
modelProviderAuthMocks.authenticatedProviders = new Set(["anthropic", "google", "openai"]);
modelProviderAuthMocks.createProviderAuthChecker.mockClear();
const registry = createTestRegistry([
@@ -267,12 +252,6 @@ function firstAuthCheckerParams() {
return modelProviderAuthMocks.createProviderAuthChecker.mock.calls[0]?.[0];
}
function preparedAuthCheckerParams() {
return modelProviderAuthMocks.createProviderAuthChecker.mock.calls
.map(([params]) => params)
.find((params) => params.allowPreparedRuntimeAuth === true);
}
describe("handleModelsCommand", () => {
it("shows a simple providers menu on text surfaces", async () => {
const result = await handleModelsCommand(buildParams("/models"), true);
@@ -285,7 +264,7 @@ describe("handleModelsCommand", () => {
expect(result?.reply?.text).toContain("Use: /models <provider>");
expect(result?.reply?.text).toContain("Switch: /model <provider/model>");
expect(result?.reply?.text).not.toContain("Add: /models add");
const authCheckerParams = preparedAuthCheckerParams();
const authCheckerParams = firstAuthCheckerParams();
expect(authCheckerParams?.workspaceDir).toBe("/tmp");
});
@@ -293,10 +272,9 @@ describe("handleModelsCommand", () => {
await handleModelsCommand(buildParams("/models"), true);
expect(modelCatalogMocks.loadModelCatalog.mock.calls[0]?.[0]?.readOnly).toBe(true);
const authCheckerParams = preparedAuthCheckerParams();
const authCheckerParams = firstAuthCheckerParams();
expect(authCheckerParams?.allowPluginSyntheticAuth).toBe(false);
expect(authCheckerParams?.discoverExternalCliAuth).toBe(false);
expect(authCheckerParams?.allowPreparedRuntimeAuth).toBe(true);
});
it("does not block default browse when read-only catalog loading is slow", async () => {
@@ -324,25 +302,6 @@ describe("handleModelsCommand", () => {
expect(modelCatalogMocks.loadModelCatalog.mock.calls[0]?.[0]?.readOnly).toBe(false);
});
it("reuses the current plugin metadata snapshot for read-only catalog loading", async () => {
const metadataSnapshot = {
plugins: [],
owners: {
cliBackends: new Map<string, string>(),
},
};
pluginMetadataMocks.snapshot = metadataSnapshot;
await handleModelsCommand(buildParams("/models"), true);
expect(modelCatalogMocks.loadModelCatalog).toHaveBeenCalledWith(
expect.objectContaining({
readOnly: true,
metadataSnapshot,
}),
);
});
it("hides unauthenticated providers by default and keeps all as explicit browse", async () => {
modelProviderAuthMocks.authenticatedProviders = new Set(["anthropic"]);
@@ -416,7 +375,7 @@ describe("handleModelsCommand", () => {
true,
);
expect(modelCatalogMocks.loadModelCatalog.mock.calls[0]?.[0]?.readOnly).toBe(true);
expect(modelCatalogMocks.loadModelCatalog.mock.calls[0]?.[0]?.readOnly).toBe(false);
expect(result?.reply?.text).toContain("- openai (2)");
expect(result?.reply?.text).toContain("- vllm (2)");
expect(result?.reply?.text).not.toContain("- anthropic");
@@ -490,50 +449,6 @@ describe("handleModelsCommand", () => {
]);
});
it("does not treat standalone CLI backends as canonical provider aliases", async () => {
cliBackendsTesting.setDepsForTest({
resolvePluginSetupRegistry: () => ({
providers: [],
cliBackends: [],
configMigrations: [],
autoEnableProbes: [],
diagnostics: [],
}),
resolveRuntimeCliBackends: () => [
{
id: "acme-cli",
pluginId: "acme",
config: { command: "acme" },
bundleMcp: false,
},
],
});
pluginMetadataMocks.snapshot = {
plugins: [],
owners: {
cliBackends: new Map([["acme-cli", "acme"]]),
},
};
modelCatalogMocks.loadModelCatalog.mockResolvedValue([
{ provider: "anthropic", id: "claude-opus-4-7", name: "Claude Opus 4.7" },
{ provider: "acme-cli", id: "acme-model", name: "Acme Model" },
]);
modelProviderAuthMocks.authenticatedProviders = new Set(["anthropic", "acme-cli"]);
const data = await buildModelsProviderData({
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-7" },
models: {
"anthropic/*": {},
},
},
},
} as OpenClawConfig);
expect(data.byProvider.has("acme-cli")).toBe(false);
});
it("keeps non-CLI configured provider model lists scoped to user config", async () => {
modelCatalogMocks.loadModelCatalog.mockResolvedValue([
{ provider: "claude-cli", id: "claude-opus-4-7", name: "Claude Opus 4.7" },

View File

@@ -15,8 +15,9 @@ import { resolveModelAuthLabel } from "../../agents/model-auth-label.js";
import { loadModelCatalogForBrowse } from "../../agents/model-catalog-browse.js";
import { resolveVisibleModelCatalog } from "../../agents/model-catalog-visibility.js";
import { loadModelCatalog } from "../../agents/model-catalog.js";
import { isRetiredModelPickerProvider } from "../../agents/model-picker-visibility.js";
import { isModelPickerVisibleProvider } from "../../agents/model-picker-visibility.js";
import { createProviderAuthChecker } from "../../agents/model-provider-auth.js";
import { isCliRuntimeProvider } from "../../agents/model-runtime-aliases.js";
import {
buildModelAliasIndex,
normalizeProviderId,
@@ -33,7 +34,6 @@ import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
import { getChannelPlugin } from "../../channels/plugins/index.js";
import type { SessionEntry } from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { getCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.js";
import { resolveAgentRuntimeLabel } from "../../status/agent-runtime-label.js";
import type { ReplyPayload } from "../types.js";
import { rejectUnauthorizedCommand } from "./command-gates.js";
@@ -78,14 +78,15 @@ type ParsedModelsCommand =
};
function isModelsBrowseVisibleProvider(provider: string): boolean {
return !isRetiredModelPickerProvider(provider);
const normalized = normalizeProviderId(provider);
return (
isCliRuntimeProvider(normalized, { includeSetupRegistry: true }) ||
isModelPickerVisibleProvider(normalized)
);
}
function usesUnfilteredCatalogModels(
provider: string,
cliRuntimeProviders: ReadonlySet<string>,
): boolean {
return cliRuntimeProviders.has(normalizeProviderId(provider));
function usesUnfilteredCatalogModels(provider: string): boolean {
return isCliRuntimeProvider(provider, { includeSetupRegistry: true });
}
function normalizeRuntimeChoiceId(runtime: string | undefined): string {
@@ -154,24 +155,11 @@ export async function buildModelsProviderData(
cfg,
agentId,
});
const workspaceDir =
options.workspaceDir ??
(agentId ? resolveAgentWorkspaceDir(cfg, agentId) : undefined) ??
resolveDefaultAgentWorkspaceDir();
const metadataSnapshot = getCurrentPluginMetadataSnapshot({
config: cfg,
workspaceDir,
env: process.env,
allowScopedSnapshot: true,
});
const cliRuntimeProviders = new Set(
listCliRuntimeModelBackendBindings().map((binding) => normalizeProviderId(binding.runtime)),
);
const catalog = await loadModelCatalogForBrowse({
cfg,
view: options.view ?? "default",
loadCatalog: ({ readOnly }) => loadModelCatalog({ config: cfg, readOnly, metadataSnapshot }),
loadCatalog: ({ readOnly }) => loadModelCatalog({ config: cfg, readOnly }),
});
const visibilityPolicy = createModelVisibilityPolicy({
cfg,
@@ -181,27 +169,18 @@ export async function buildModelsProviderData(
agentId,
...RUNTIME_MODEL_VISIBILITY_NORMALIZATION,
});
const hasAuth: (provider: string) => Promise<boolean> =
options.view === "all"
? async () => true
: createProviderAuthChecker({
cfg,
workspaceDir,
agentId,
allowPluginSyntheticAuth: false,
discoverExternalCliAuth: false,
allowPreparedRuntimeAuth: true,
});
const visibleCatalog = await resolveVisibleModelCatalog({
cfg,
catalog,
defaultProvider: resolvedDefault.provider,
defaultModel: resolvedDefault.model,
agentId,
workspaceDir,
workspaceDir:
options.workspaceDir ??
(agentId ? resolveAgentWorkspaceDir(cfg, agentId) : undefined) ??
resolveDefaultAgentWorkspaceDir(),
view: options.view,
runtimeAuthDiscovery: false,
providerAuthChecker: hasAuth,
});
const aliasIndex = buildModelAliasIndex({
@@ -219,7 +198,7 @@ export async function buildModelsProviderData(
}
if (
restrictToProviderWildcards &&
!usesUnfilteredCatalogModels(key, cliRuntimeProviders) &&
!usesUnfilteredCatalogModels(key) &&
!visibilityPolicy.allows({ provider: key, model: m })
) {
return;
@@ -279,11 +258,20 @@ export async function buildModelsProviderData(
add(entry.provider, entry.id);
}
const hasAuth: (provider: string) => Promise<boolean> =
options.view === "all"
? async () => true
: createProviderAuthChecker({
cfg,
workspaceDir:
options.workspaceDir ??
(agentId ? resolveAgentWorkspaceDir(cfg, agentId) : undefined) ??
resolveDefaultAgentWorkspaceDir(),
agentId,
});
for (const entry of catalog) {
if (
usesUnfilteredCatalogModels(entry.provider, cliRuntimeProviders) &&
(await hasAuth(entry.provider))
) {
if (usesUnfilteredCatalogModels(entry.provider) && (await hasAuth(entry.provider))) {
add(entry.provider, entry.id);
}
}

View File

@@ -1,9 +1,6 @@
// Runs plugin hooks before outbound reply payloads are sent.
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import type {
PluginHookReplyPayloadSendingContext,
PluginHookReplyUsageState,
} from "../../plugins/hook-types.js";
import type { PluginHookReplyPayloadSendingContext } from "../../plugins/hook-types.js";
import { copyReplyPayloadMetadata } from "../reply-payload.js";
import type { ReplyPayload } from "../reply-payload.js";
import type { ReplyDispatchKind } from "./reply-dispatcher.types.js";
@@ -20,7 +17,6 @@ export async function runReplyPayloadSendingHook(params: {
channel?: string;
sessionKey?: string;
runId?: string;
usageState?: PluginHookReplyUsageState;
context: PluginHookReplyPayloadSendingContext;
}): Promise<ReplyPayload | null> {
const hookRunner = getGlobalHookRunner();
@@ -35,7 +31,6 @@ export async function runReplyPayloadSendingHook(params: {
channel: params.channel,
sessionKey: params.sessionKey,
runId: params.runId,
usageState: params.usageState,
},
params.context,
);

View File

@@ -1,39 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clearReplyUsageStateForTest,
consumeReplyUsageState,
recordReplyUsageState,
} from "./reply-usage-state.js";
afterEach(() => {
vi.useRealTimers();
clearReplyUsageStateForTest();
});
describe("reply usage state handoff", () => {
it("requires exact run correlation", () => {
const snapshot = { provider: "openai", model: "gpt-5.5" };
recordReplyUsageState("run-a", snapshot);
expect(consumeReplyUsageState()).toBeUndefined();
expect(consumeReplyUsageState("run-b")).toBeUndefined();
expect(consumeReplyUsageState("run-a")).toBe(snapshot);
});
it("ignores snapshots without a run id", () => {
recordReplyUsageState(undefined, { provider: "openai" });
expect(consumeReplyUsageState()).toBeUndefined();
});
it("expires snapshots", () => {
vi.useFakeTimers();
vi.setSystemTime(0);
recordReplyUsageState("run-a", { provider: "openai" });
vi.setSystemTime(5 * 60_000 + 1);
expect(consumeReplyUsageState("run-a")).toBeUndefined();
});
});

View File

@@ -1,37 +0,0 @@
import type { PluginHookReplyUsageState } from "../../plugins/hook-types.js";
const TTL_MS = 5 * 60_000;
const store = new Map<string, { snapshot: PluginHookReplyUsageState; expiresAt: number }>();
function prune(now: number): void {
for (const [key, value] of store) {
if (value.expiresAt < now) {
store.delete(key);
}
}
}
export function recordReplyUsageState(
runId: string | undefined,
snapshot: PluginHookReplyUsageState,
): void {
if (!runId) {
return;
}
const now = Date.now();
store.set(runId, { snapshot, expiresAt: now + TTL_MS });
prune(now);
}
export function consumeReplyUsageState(runId?: string): PluginHookReplyUsageState | undefined {
if (!runId) {
return undefined;
}
const value = store.get(runId);
return value && value.expiresAt >= Date.now() ? value.snapshot : undefined;
}
export function clearReplyUsageStateForTest(): void {
store.clear();
}

View File

@@ -1,103 +0,0 @@
import type { PluginHookReplyUsageState } from "../../plugins/hook-types.js";
import type { UsageContract } from "./translator.js";
export function buildUsageContract(
state: PluginHookReplyUsageState,
surface?: string,
): UsageContract {
const usage = state.usage ?? {};
const input = usage.input;
const output = usage.output;
const cacheRead = usage.cacheRead;
const cacheWrite = usage.cacheWrite;
const total = usage.total;
const promptTotal = (cacheRead ?? 0) + (cacheWrite ?? 0) + (input ?? 0);
const cacheHitPct =
promptTotal > 0 ? Math.round(((cacheRead ?? 0) / promptTotal) * 100) : undefined;
const last = state.lastUsage;
const lastPromptTotal = last
? (last.cacheRead ?? 0) + (last.cacheWrite ?? 0) + (last.input ?? 0)
: 0;
const lastCacheHitPct =
last && lastPromptTotal > 0
? Math.round(((last.cacheRead ?? 0) / lastPromptTotal) * 100)
: undefined;
const maxTokens = state.contextTokenBudget;
const usedTokens =
typeof state.contextUsedTokens === "number" && state.contextUsedTokens > 0
? state.contextUsedTokens
: promptTotal > 0
? promptTotal
: undefined;
const pctUsed =
maxTokens && usedTokens !== undefined ? Math.round((usedTokens / maxTokens) * 100) : undefined;
const overrideSource = state.overrideSource ?? null;
const isOverride =
typeof state.overrideSource === "string" &&
state.overrideSource !== "" &&
state.overrideSource !== "auto";
return {
schema: "openclaw.usageLine.v1",
surface: surface ?? null,
agentId: state.agentId ?? null,
chat_type: state.chatType ?? null,
model: {
id: state.model ?? null,
display_name: state.model ?? null,
provider: state.provider ?? null,
reasoning: state.reasoningEffort ?? null,
actual: state.resolvedRef ?? null,
resolved_ref: state.resolvedRef ?? null,
requested: state.requested ?? null,
is_fallback: state.fallbackUsed === true,
is_override: isOverride,
override_source: overrideSource,
auth_mode: state.authMode ?? null,
},
state: {
fast_mode: typeof state.fastMode === "boolean" ? state.fastMode : null,
compactions: typeof state.compactionCount === "number" ? state.compactionCount : null,
},
usage: {
input_tokens: input,
output_tokens: output,
cache_read_tokens: cacheRead,
cache_write_tokens: cacheWrite,
total_tokens: total,
cache_hit_pct: cacheHitPct,
last: last
? {
input_tokens: last.input,
output_tokens: last.output,
cache_read_tokens: last.cacheRead,
cache_write_tokens: last.cacheWrite,
total_tokens: last.total,
cache_hit_pct: lastCacheHitPct,
}
: undefined,
},
context: {
used_tokens: usedTokens,
max_tokens: maxTokens,
pct_used: pctUsed,
},
cost: {
turn_usd: typeof state.turnUsd === "number" ? state.turnUsd : null,
available: typeof state.turnUsd === "number",
},
timing: {
duration_ms: typeof state.durationMs === "number" ? state.durationMs : null,
},
identity: {
name: state.identity?.name ?? null,
emoji: state.identity?.emoji ?? null,
avatar: state.identity?.avatar ?? null,
},
session: { id: state.sessionId ?? null },
};
}

View File

@@ -1,72 +0,0 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { clearUsageBarTemplateCacheForTest, loadUsageBarTemplate } from "./template.js";
const tplA = { segments: [{ text: "A" }] };
const tplB = { output: { lines: [] } };
let dir: string | undefined;
afterEach(() => {
clearUsageBarTemplateCacheForTest();
if (dir) {
rmSync(dir, { recursive: true, force: true });
dir = undefined;
}
});
function tmpFile(name: string, contents: string): string {
dir = mkdtempSync(join(tmpdir(), "usage-template-"));
const path = join(dir, name);
writeFileSync(path, contents);
return path;
}
describe("loadUsageBarTemplate", () => {
it("returns an inline template object when usable", () => {
expect(loadUsageBarTemplate(tplA as Record<string, unknown>)).toBe(tplA);
});
it("returns undefined for an unusable inline object or when unset", () => {
expect(loadUsageBarTemplate({ nope: true })).toBeUndefined();
expect(loadUsageBarTemplate(undefined)).toBeUndefined();
});
it("loads and parses a template file", () => {
const path = tmpFile("t.json", JSON.stringify(tplA));
expect(loadUsageBarTemplate(path)).toMatchObject(tplA);
});
it("falls back (undefined) for invalid JSON", () => {
const path = tmpFile("bad.json", "{ not json");
expect(loadUsageBarTemplate(path)).toBeUndefined();
});
it("reloads a path after an initial miss", () => {
dir = mkdtempSync(join(tmpdir(), "usage-template-"));
const missing = join(dir, "missing.json");
expect(loadUsageBarTemplate(missing)).toBeUndefined();
writeFileSync(missing, JSON.stringify(tplB));
expect(loadUsageBarTemplate(missing)).toMatchObject(tplB);
});
it("reloads a path after invalid JSON is fixed", () => {
const path = tmpFile("bad.json", "{ not json");
expect(loadUsageBarTemplate(path)).toBeUndefined();
writeFileSync(path, JSON.stringify(tplB));
expect(loadUsageBarTemplate(path)).toMatchObject(tplB);
});
it("serves the cached template without re-reading the file", () => {
const path = tmpFile("t.json", JSON.stringify(tplA));
expect(loadUsageBarTemplate(path)).toMatchObject(tplA);
writeFileSync(path, JSON.stringify(tplB));
expect(loadUsageBarTemplate(path)).toMatchObject(tplA);
clearUsageBarTemplateCacheForTest();
expect(loadUsageBarTemplate(path)).toMatchObject(tplB);
});
});

View File

@@ -1,86 +0,0 @@
import { type FSWatcher, readFileSync, watch } from "node:fs";
import { homedir } from "node:os";
import { isAbsolute, resolve } from "node:path";
import type { UsageBarTemplate } from "./translator.js";
export type UsageTemplateConfig = string | Record<string, unknown> | undefined;
type CacheEntry = { template: UsageBarTemplate | undefined; watcher?: FSWatcher };
const fileCache = new Map<string, CacheEntry>();
function expandPath(p: string): string {
if (p === "~") {
return homedir();
}
if (p.startsWith("~/")) {
return resolve(homedir(), p.slice(2));
}
return isAbsolute(p) ? p : resolve(p);
}
function isUsableTemplate(value: unknown): value is UsageBarTemplate {
if (typeof value !== "object" || value === null) {
return false;
}
const obj = value as Record<string, unknown>;
const hasOutput = typeof obj.output === "object" && obj.output !== null;
return hasOutput || Array.isArray(obj.segments);
}
function readTemplateFile(path: string): UsageBarTemplate | undefined {
let raw: string;
try {
raw = readFileSync(path, "utf8");
} catch {
return undefined;
}
try {
const parsed: unknown = JSON.parse(raw);
return isUsableTemplate(parsed) ? parsed : undefined;
} catch {
return undefined;
}
}
function cacheTemplateFile(path: string): UsageBarTemplate | undefined {
const entry: CacheEntry = { template: readTemplateFile(path) };
if (entry.template) {
try {
const watcher = watch(path, { persistent: false }, () => {
entry.template = readTemplateFile(path);
});
watcher.on("error", () => {
watcher.close();
});
entry.watcher = watcher;
} catch {
// Cache remains valid without live refresh.
}
}
fileCache.set(path, entry);
return entry.template;
}
export function loadUsageBarTemplate(
configured: UsageTemplateConfig,
): UsageBarTemplate | undefined {
if (!configured) {
return undefined;
}
if (typeof configured === "object") {
return isUsableTemplate(configured) ? configured : undefined;
}
const path = expandPath(configured);
const cached = fileCache.get(path);
if (cached) {
return cached.template ?? (cached.watcher ? undefined : cacheTemplateFile(path));
}
return cacheTemplateFile(path);
}
export function clearUsageBarTemplateCacheForTest(): void {
for (const entry of fileCache.values()) {
entry.watcher?.close();
}
fileCache.clear();
}

View File

@@ -1,140 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildUsageContract } from "./contract.js";
import { renderUsageBar, type UsageBarTemplate } from "./translator.js";
const SCALES = {
braille: "⠐⡀⡄⡆⡇⣇⣧⣷⣿",
moon: "🌑🌘🌗🌖🌕",
weather: ["🥶", "☁️", "🌥", "⛅️", "🌤", "☀️"],
plants: ["🪾", "🍂", "🌱", "☘️", "🍀", "🌿"],
};
function tpl(pieces: unknown[]): UsageBarTemplate {
return {
scales: SCALES,
aliases: { models: { "claude-opus-4-6": "opus46" }, reasoning: { medium: "med" } },
output: { sep: "", surfaces: { discord: pieces } },
};
}
function render(pieces: unknown[], contract: Record<string, unknown>): string {
return renderUsageBar(tpl(pieces), { surface: "discord", ...contract });
}
describe("usage-bar verbs", () => {
it("num — compact counts", () => {
expect(render([{ text: "{usage.input_tokens|num}" }], { usage: { input_tokens: 3000 } })).toBe(
"3.0k",
);
expect(render([{ text: "{x|num}" }], { x: 272000 })).toBe("272k");
expect(render([{ text: "{x|num}" }], { x: 128 })).toBe("128");
});
it("fixed — fixed-decimal precision", () => {
expect(render([{ text: "{cost|fixed:4}" }], { cost: 0.03771985 })).toBe("0.0377");
expect(render([{ text: "{cost|fixed}" }], { cost: 1.5 })).toBe("1.50");
expect(render([{ text: "{cost|fixed:0}" }], { cost: 2.7 })).toBe("3");
expect(render([{ text: "{cost|fixed:4}" }], { cost: "nope" })).toBe("");
});
it("dur — seconds to reset", () => {
expect(render([{ text: "{x|dur}" }], { x: 14820 })).toBe("4h07m");
expect(render([{ text: "{x|dur}" }], { x: 449280 })).toBe("5.2d");
expect(render([{ text: "{x|dur}" }], { x: 1980 })).toBe("33m");
});
it("pct and inv", () => {
expect(render([{ text: "{x|pct}" }], { x: 96 })).toBe("96%");
expect(render([{ text: "{x|inv|pct}" }], { x: 75 })).toBe("25%");
});
it("meter — multi-cell braille bar", () => {
expect(render([{ text: "[{x|meter:5:braille}]" }], { x: 75 })).toBe("[⣿⣿⣿⣧⠐]");
expect(render([{ text: "[{x|meter:5:braille}]" }], { x: 0 })).toBe("[⠐⠐⠐⠐⠐]");
expect(render([{ text: "[{x|meter:5:braille}]" }], { x: 100 })).toBe("[⣿⣿⣿⣿⣿]");
});
it("meter:1 — single glyph, codepoint-correct for astral scales", () => {
expect(render([{ text: "{x|meter:1:moon}" }], { x: 0 })).toBe("🌑");
expect(render([{ text: "{x|meter:1:moon}" }], { x: 50 })).toBe("🌗");
expect(render([{ text: "{x|meter:1:moon}" }], { x: 100 })).toBe("🌕");
});
it("alias — listed shortens, unlisted echoes through", () => {
expect(render([{ text: "{m|alias:models}" }], { m: "claude-opus-4-6" })).toBe("opus46");
expect(render([{ text: "{m|alias:models}" }], { m: "some-new-model" })).toBe("some-new-model");
});
it("fallback when path is missing/empty", () => {
expect(render([{ text: "{identity.emoji|🤖} hi" }], {})).toBe("🤖 hi");
expect(render([{ text: "{identity.emoji|🤖} hi" }], { identity: { emoji: "🩺" } })).toBe(
"🩺 hi",
);
});
});
describe("usage-bar segment forms", () => {
it("when drops on null/false/empty, keeps on 0", () => {
const seg = [{ when: "u.cache_hit_pct", text: "🗄 {u.cache_hit_pct|pct}" }];
expect(render(seg, { u: {} })).toBe("");
expect(render(seg, { u: { cache_hit_pct: 0 } })).toBe("🗄 0%");
});
it("map resolves enum/bool, drops on no match", () => {
const seg = [{ map: "state.fast_mode", cases: { true: "⚡", false: "🐌" } }];
expect(render(seg, { state: { fast_mode: true } })).toBe("⚡");
expect(render(seg, { state: { fast_mode: false } })).toBe("🐌");
expect(render(seg, { state: {} })).toBe("");
});
it("each with item_scales picks a scale per window by position", () => {
const seg = [
{
text: "W",
each: "windows",
item: "{pct_left|meter:1:*}{resets_in_s|dur}",
item_scales: ["weather", "plants"],
},
];
const out = render(seg, {
windows: [
{ pct_left: 92, resets_in_s: 17100 },
{ pct_left: 70, resets_in_s: 570240 },
],
});
expect(out).toBe("W ☀4h45m 🍀6.6d");
});
it("each drops the whole segment when the array is empty", () => {
expect(render([{ text: "W", each: "windows", item: "{x}" }], {})).toBe("");
});
});
describe("usage-bar end-to-end with buildUsageContract", () => {
it("renders a full footer from a reply usage snapshot", () => {
const contract = buildUsageContract(
{
provider: "openai",
model: "claude-opus-4-6",
reasoningEffort: "medium",
fastMode: false,
fallbackUsed: false,
contextTokenBudget: 272000,
contextUsedTokens: 204000,
usage: { input: 204000, output: 15, cacheRead: 0, cacheWrite: 0, total: 204015 },
turnUsd: 0.03771985,
},
"discord",
);
const pieces = [
{ text: "{model.display_name|alias:models}" },
{ map: "model.is_fallback", cases: { true: "🔄" } },
{ text: " | " },
{ when: "model.reasoning", text: "{model.reasoning|alias:reasoning}" },
{ map: "state.fast_mode", cases: { true: "⚡", false: "🐌" } },
{ text: " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}" },
{ text: " | ${cost.turn_usd|fixed:4}" },
];
expect(renderUsageBar(tpl(pieces), contract)).toBe("opus46 | med🐌 | 📚 [⣿⣿⣿⣧⠐]272k | $0.0377");
});
});

View File

@@ -1,288 +0,0 @@
export type UsageBarTemplate = Record<string, unknown>;
export type UsageContract = Record<string, unknown>;
type Vocab = Record<string, unknown>;
const isObject = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
function toGlyphs(scale: unknown): string[] {
if (Array.isArray(scale)) {
return scale.filter((g): g is string => typeof g === "string");
}
if (typeof scale === "string") {
return Array.from(scale);
}
return [];
}
function num(value: unknown): string {
if (value === null || value === undefined || value === "") {
return "";
}
const n = Number(value);
if (!Number.isFinite(n)) {
return "";
}
if (Math.abs(n) >= 1000) {
const v = n / 1000;
return Math.abs(v) < 10 ? `${v.toFixed(1)}k` : `${Math.round(v)}k`;
}
return String(Math.trunc(n));
}
function fixed(value: unknown, digits: number): string {
if (value === null || value === undefined || value === "") {
return "";
}
const n = Number(value);
if (!Number.isFinite(n)) {
return "";
}
return n.toFixed(Math.max(0, digits));
}
function dur(value: unknown): string {
if (value === null || value === undefined || value === "") {
return "";
}
const raw = Number(value);
if (!Number.isFinite(raw)) {
return "";
}
const s = Math.max(0, Math.trunc(raw));
if (s >= 86400) {
return `${(s / 86400).toFixed(1)}d`;
}
if (s >= 3600) {
const m = Math.floor((s % 3600) / 60);
return `${Math.floor(s / 3600)}h${String(m).padStart(2, "0")}m`;
}
return `${Math.floor(s / 60)}m`;
}
function pct(value: unknown): string {
if (value === null || value === undefined || value === "") {
return "";
}
const n = Number(value);
return Number.isFinite(n) ? `${Math.round(n)}%` : "";
}
function inv(value: unknown): unknown {
if (value === null || value === undefined || value === "") {
return value;
}
const n = Number(value);
if (!Number.isFinite(n)) {
return value;
}
return 100 - Math.max(0, Math.min(100, n));
}
function norm(value: unknown): number {
const n = Number(value);
if (value === null || value === undefined || !Number.isFinite(n)) {
return 0;
}
return Math.max(0, Math.min(100, n)) / 100;
}
function meter(value: unknown, width: number, scale: unknown): string {
const glyphs = toGlyphs(scale);
if (glyphs.length < 2 || width < 1) {
return "";
}
const empty = glyphs[0];
const full = glyphs[glyphs.length - 1];
const total = norm(value) * width;
const fullc = Math.trunc(total);
const cells: string[] = [];
for (let i = 0; i < Math.min(fullc, width); i++) {
cells.push(full);
}
if (cells.length < width) {
cells.push(glyphs[Math.round((total - fullc) * (glyphs.length - 1))]);
}
while (cells.length < width) {
cells.push(empty);
}
return cells.slice(0, width).join("");
}
const VERB_NAMES = new Set(["num", "fixed", "dur", "pct", "inv", "alias", "meter"]);
function applyVerb(name: string, args: string[], value: unknown, vocab: Vocab): unknown {
switch (name) {
case "num":
return num(value);
case "fixed": {
const digits = args[0] ? Number.parseInt(args[0], 10) || 0 : 2;
return fixed(value, digits);
}
case "dur":
return dur(value);
case "pct":
return pct(value);
case "inv":
return inv(value);
case "alias": {
const aliases = isObject(vocab["_aliases"]) ? vocab["_aliases"] : {};
const table =
args[0] && isObject(aliases[args[0]]) ? (aliases[args[0]] as Record<string, unknown>) : {};
const key = String(value);
if (key in table) {
return table[key];
}
const lower = key.toLowerCase();
return lower in table ? table[lower] : value;
}
case "meter": {
const width = args[0] ? Number.parseInt(args[0], 10) || 5 : 5;
const scale = args.length > 1 ? vocab[args[1]] : undefined;
return meter(value, width, scale);
}
default:
return String(value);
}
}
function getPath(ctx: unknown, path: string): unknown {
let cur: unknown = ctx;
for (const part of path.split(".")) {
if (!isObject(cur)) {
return undefined;
}
cur = cur[part];
if (cur === null || cur === undefined) {
return undefined;
}
}
return cur;
}
const TOKEN = /\{([^}]+)\}/g;
function interp(text: string, ctx: unknown, vocab: Vocab): string {
return text.replace(TOKEN, (_match, body: string) => {
const parts = body.split("|");
let val = getPath(ctx, (parts[0] ?? "").trim());
const ops: Array<{ name: string; args: string[] }> = [];
let fallback: string | undefined;
for (const segRaw of parts.slice(1)) {
const seg = segRaw.trim();
const name = seg.split(":")[0];
if (VERB_NAMES.has(name)) {
ops.push({ name, args: seg.split(":").slice(1) });
} else {
fallback = seg;
}
}
if (val === null || val === undefined || val === "") {
return fallback ?? "";
}
for (const op of ops) {
val = applyVerb(op.name, op.args, val, vocab);
}
return String(val);
});
}
type Segment = Record<string, unknown>;
function renderSegment(seg: Segment, ctx: unknown, vocab: Vocab): string | null {
if ("when" in seg) {
const v = getPath(ctx, String(seg.when));
if (v === null || v === undefined || v === false || v === "") {
return null;
}
}
if ("map" in seg) {
const v = getPath(ctx, String(seg.map));
const key = typeof v === "boolean" ? String(v) : String(v);
const cases = isObject(seg.cases) ? seg.cases : {};
const hit = key in cases ? cases[key] : cases["_default"];
return typeof hit === "string" ? hit : null;
}
if ("each" in seg) {
const arr = getPath(ctx, String(seg.each));
const items = Array.isArray(arr) ? arr : [];
const itemTpl = typeof seg.item === "string" ? seg.item : "";
const names = Array.isArray(seg.item_scales) ? (seg.item_scales as string[]) : undefined;
const parts: string[] = [];
items.forEach((el, i) => {
let iv = vocab;
if (names && names.length > 0) {
iv = { ...vocab, "*": vocab[names[Math.min(i, names.length - 1)]] };
}
const r = interp(itemTpl, el, iv);
if (r) {
parts.push(r);
}
});
const join = typeof seg.join === "string" ? seg.join : " ";
const body = parts.join(join);
if (!body) {
return null;
}
const prefix = typeof seg.text === "string" ? seg.text : "";
return prefix ? `${prefix} ${body}` : body;
}
if ("text" in seg) {
return interp(String(seg.text), ctx, vocab) || null;
}
return null;
}
function resolveLayout(
template: UsageBarTemplate,
surface: unknown,
): { sep: string; pieces: Segment[] } {
const output = template.output;
if (isObject(output)) {
const surfaces = isObject(output.surfaces) ? output.surfaces : {};
let pieces = typeof surface === "string" ? surfaces[surface] : undefined;
if (pieces === undefined) {
pieces = output.default;
}
const sep = typeof output.sep === "string" ? output.sep : "";
return { sep, pieces: Array.isArray(pieces) ? (pieces as Segment[]) : [] };
}
const ov =
typeof surface === "string" &&
isObject(template.surfaces) &&
isObject(template.surfaces[surface])
? template.surfaces[surface]
: {};
const sep =
typeof ov.sep === "string" ? ov.sep : typeof template.sep === "string" ? template.sep : " ";
const segments = Array.isArray(ov.segments)
? ov.segments
: Array.isArray(template.segments)
? template.segments
: [];
return { sep, pieces: segments as Segment[] };
}
export function renderUsageBar(template: UsageBarTemplate, contract: UsageContract): string {
try {
const { sep, pieces } = resolveLayout(template, contract.surface);
const vocab: Vocab = {
...(isObject(template.ramps) ? template.ramps : {}),
...(isObject(template.series) ? template.series : {}),
...(isObject(template.scales) ? template.scales : {}),
};
vocab["_aliases"] = isObject(template.aliases) ? template.aliases : {};
const out: string[] = [];
for (const piece of pieces) {
if (isObject(piece)) {
const r = renderSegment(piece, contract, vocab);
if (r) {
out.push(r);
}
}
}
return out.join(sep);
} catch {
return "";
}
}

View File

@@ -5,7 +5,7 @@
*/
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { Type, type TSchema } from "typebox";
import type { TSchema } from "typebox";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { defaultRuntime } from "../../runtime.js";
@@ -361,14 +361,9 @@ function mergeToolSchemaProperties(
return;
}
for (const [name, schema] of Object.entries(source)) {
if (name in target) {
continue;
if (!(name in target)) {
target[name] = schema;
}
// Message-tool params dispatch on `action`; no contributed property may be
// object-level required. Type.Object treats schemas missing typebox's
// non-enumerable `~optional` marker (plain JSON or cloned/serialized plugin
// schemas) as required, which fails validation for every message call.
target[name] = Type.IsOptional(schema) ? schema : Type.Optional(schema);
}
}

View File

@@ -194,48 +194,6 @@ describe("message action capability checks", () => {
).toHaveProperty("components");
});
it("keeps contributed schema properties optional so only action stays required", () => {
const contributingPlugin: ChannelPlugin = {
...createChannelTestPluginBase({
id: "demo-contrib",
label: "Demo Contrib",
capabilities: { chatTypes: ["direct", "group"] },
config: {
listAccountIds: () => ["default"],
},
}),
actions: {
describeMessageTool: () => ({
actions: ["send"],
schema: {
properties: {
// Non-optional TypeBox schema: plugin forgot Type.Optional.
components: Type.Array(Type.String()),
// Cloning strips typebox's non-enumerable `~optional` marker;
// mirrors serialized/external plugin contributions.
chatRef: structuredClone(Type.Optional(Type.String())),
media: Type.Optional(Type.String()),
},
},
}),
},
};
setActivePluginRegistry(
createTestRegistry([
{ pluginId: "demo-contrib", source: "test", plugin: contributingPlugin },
]),
);
const properties = resolveChannelMessageToolSchemaProperties({
cfg: {} as OpenClawConfig,
channel: "demo-contrib",
});
// Regression: required leakage made every message tool call fail validation
// with "must have required properties chatRef, media, ...".
const toolSchema = Type.Object({ action: Type.String(), ...properties });
expect(toolSchema.required).toEqual(["action"]);
});
it("filters only actions that depend on current-channel-only schema", () => {
const scopedSchemaPlugin: ChannelPlugin = {
...createChannelTestPluginBase({

View File

@@ -1,7 +1,6 @@
// CLI utility tests cover shared command helpers, option parsing, and output formatting.
import { Command } from "commander";
import { describe, expect, it, vi } from "vitest";
import { runCommandWithRuntime } from "./cli-utils.js";
import { registerDnsCli } from "./dns-cli.js";
import { parseByteSize } from "./parse-bytes.js";
import { parseDurationMs } from "./parse-duration.js";
@@ -34,33 +33,6 @@ describe("waitForever", () => {
});
});
describe("runCommandWithRuntime", () => {
it("surfaces cause chains and error codes through the default runtime", async () => {
const messages: string[] = [];
const exits: number[] = [];
const cause = Object.assign(new Error("invalid onRequestStart method"), {
code: "UND_ERR_INVALID_ARG",
});
const fetchError = Object.assign(new TypeError("fetch failed"), { cause });
await runCommandWithRuntime(
{
error: (message) => messages.push(message),
exit: (code) => exits.push(code),
},
async () => {
throw fetchError;
},
);
expect(messages).toHaveLength(1);
expect(messages[0]).toContain("TypeError: fetch failed");
expect(messages[0]).toContain("invalid onRequestStart method");
expect(messages[0]).toContain("UND_ERR_INVALID_ARG");
expect(exits).toEqual([1]);
});
});
describe("shouldSkipRespawnForArgv", () => {
it.each([
{ argv: ["node", "openclaw", "--help"] },

View File

@@ -32,13 +32,6 @@ export async function withManager<T>(params: {
}
}
function formatCommandRuntimeError(err: unknown): string {
if (err instanceof Error) {
return formatErrorMessage(new Error(String(err), { cause: err.cause }));
}
return formatErrorMessage(err);
}
export async function runCommandWithRuntime(
runtime: { error: (message: string) => void; exit: (code: number) => void },
action: () => Promise<void>,
@@ -51,7 +44,7 @@ export async function runCommandWithRuntime(
onError(err);
return;
}
runtime.error(formatCommandRuntimeError(err));
runtime.error(String(err));
runtime.exit(1);
}
}

View File

@@ -19,7 +19,7 @@ vi.mock("../../plugins/provider-runtime.js", () => ({
normalizeProviderResolvedModelWithPlugin: mocks.normalizeProviderResolvedModelWithPlugin,
}));
import { appendConfiguredProviderRows, appendProviderCatalogRows } from "./list.rows.js";
import { appendProviderCatalogRows } from "./list.rows.js";
const authIndex = {
hasProviderAuth: (provider: string) => provider === "codex",
@@ -79,7 +79,6 @@ describe("appendProviderCatalogRows", () => {
models: { providers: {} },
},
});
expect(mocks.normalizeProviderResolvedModelWithPlugin).not.toHaveBeenCalled();
const row = requireOnlyRow(rows);
expect(row.key).toBe("codex/gpt-5.5");
expect(row.available).toBe(true);
@@ -190,53 +189,3 @@ describe("appendProviderCatalogRows", () => {
expect(row.tags).toEqual(["configured"]);
});
});
describe("appendConfiguredProviderRows", () => {
it("keeps provider normalization for configured provider models", async () => {
mocks.normalizeProviderResolvedModelWithPlugin.mockReturnValueOnce({
provider: "anthropic",
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
input: ["text", "image"],
contextWindow: 200_000,
} as never);
const rows: ModelRow[] = [];
await appendConfiguredProviderRows({
rows,
seenKeys: new Set(),
context: {
cfg: {
models: {
providers: {
anthropic: {
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
models: [
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8192,
},
],
},
},
},
},
agentDir: "/tmp/openclaw-agent",
authIndex,
configuredByKey: new Map(),
discoveredKeys: new Set(),
filter: { provider: "anthropic", local: false },
skipRuntimeModelSuppression: true,
},
});
expect(mocks.normalizeProviderResolvedModelWithPlugin).toHaveBeenCalledOnce();
expect(requireOnlyRow(rows).input).toBe("text+image");
});
});

View File

@@ -145,7 +145,6 @@ function normalizeListRowWithProviderPlugin(params: {
provider: params.model.provider,
config: params.context.cfg,
workspaceDir: params.context.workspaceDir,
pluginMetadataSnapshot: params.context.metadataSnapshot,
context: {
config: params.context.cfg,
agentDir: params.context.agentDir,
@@ -178,7 +177,6 @@ async function appendVisibleRow(params: {
seenKeys?: Set<string>;
allowProviderAvailabilityFallback?: boolean;
skipSuppression?: boolean;
normalizeWithProviderPlugin?: boolean;
}): Promise<boolean> {
if (params.seenKeys?.has(params.key)) {
return false;
@@ -186,18 +184,21 @@ async function appendVisibleRow(params: {
if (!matchesRowFilter(params.context, params.model)) {
return false;
}
const model = params.normalizeWithProviderPlugin
? normalizeListRowWithProviderPlugin({
model: params.model,
context: params.context,
})
: params.model;
if (!params.skipSuppression && shouldSuppressListModel({ model, context: params.context })) {
const normalizedModel = normalizeListRowWithProviderPlugin({
model: params.model,
context: params.context,
});
// Normalize provider-owned runtime model ids before suppression/filtering so
// list output matches the model ids users can actually select.
if (
!params.skipSuppression &&
shouldSuppressListModel({ model: normalizedModel, context: params.context })
) {
return false;
}
params.rows.push(
await buildRow({
model,
model: normalizedModel,
key: params.key,
context: params.context,
allowProviderAvailabilityFallback: params.allowProviderAvailabilityFallback,
@@ -374,7 +375,6 @@ export async function appendConfiguredProviderRows(params: {
context: params.context,
seenKeys: params.seenKeys,
allowProviderAvailabilityFallback: !params.context.discoveredKeys.has(key),
normalizeWithProviderPlugin: true,
});
}
}

View File

@@ -1873,8 +1873,6 @@ export const FIELD_HELP: Record<string, string> = {
'Controls visible source replies across direct, group, and channel conversations. "message_tool" requires message(action=send) for visible output and keeps normal final text private. "automatic" posts normal replies as before.',
"messages.responsePrefix":
"Prefix text prepended to outbound assistant replies before sending to channels. Use for lightweight branding/context tags and avoid long prefixes that reduce content density.",
"messages.usageTemplate":
"Custom /usage full footer template, either an inline object or a JSON file path. Invalid or unavailable templates fall back to the built-in usage line.",
"messages.groupChat":
"Group-message handling controls including mention triggers and history window sizing. Keep mention patterns narrow so group channels do not trigger on every message.",
"messages.groupChat.mentionPatterns":

View File

@@ -967,7 +967,6 @@ export const FIELD_LABELS: Record<string, string> = {
"messages.messagePrefix": "Inbound Message Prefix",
"messages.visibleReplies": "Visible Replies",
"messages.responsePrefix": "Outbound Response Prefix",
"messages.usageTemplate": "Usage Footer Template",
"messages.groupChat": "Group Chat Rules",
"messages.groupChat.mentionPatterns": "Group Mention Patterns",
"messages.groupChat.historyLimit": "Group History Limit",

View File

@@ -139,8 +139,6 @@ export type MessagesConfig = {
* Default: none
*/
responsePrefix?: string;
/** Custom `/usage full` footer template, inline or JSON file path. */
usageTemplate?: string | Record<string, unknown>;
groupChat?: GroupChatConfig;
queue?: QueueConfig;
/** Debounce rapid inbound messages per sender (global + per-channel overrides). */

View File

@@ -158,7 +158,6 @@ export const MessagesSchema = z
messagePrefix: z.string().optional(),
visibleReplies: VisibleRepliesSchema.optional(),
responsePrefix: z.string().optional(),
usageTemplate: z.union([z.string(), z.record(z.string(), z.unknown())]).optional(),
groupChat: GroupChatSchema,
queue: QueueSchema,
inbound: InboundDebounceSchema,

View File

@@ -278,48 +278,21 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => {
});
it("supports base64 encoding and agent-scoped auth/config resolution", async () => {
try {
testState.agentsConfig = { list: [{ id: "main" }, { id: "beta" }] };
resetConfigRuntimeState();
const res = await postEmbeddings(
{
model: "openclaw/beta",
input: "hello",
encoding_format: "base64",
},
{ "x-openclaw-agent-id": "beta" },
);
expect(res.status).toBe(200);
const json = (await res.json()) as { data?: Array<{ embedding?: string }> };
expect(typeof json.data?.[0]?.embedding).toBe("string");
expect(createEmbeddingProviderMock).toHaveBeenCalled();
const lastCall = latestCreateEmbeddingProviderOptions();
expect(typeof lastCall.model).toBe("string");
expect(lastCall.agentDir).toBe(resolveAgentDir({}, "beta"));
} finally {
testState.agentsConfig = undefined;
resetConfigRuntimeState();
}
});
it("rejects explicit unknown agent ids", async () => {
try {
testState.agentsConfig = { list: [{ id: "main" }, { id: "beta" }] };
resetConfigRuntimeState();
const header = await postEmbeddings(
{ model: "openclaw/default", input: "hello" },
{ "x-openclaw-agent-id": "missing-agent" },
);
await expectInvalidEmbeddingRequest(header, "Unknown agent 'missing-agent'.");
const model = await postEmbeddings({ model: "openclaw/missing-agent", input: "hello" });
await expectInvalidEmbeddingRequest(model, "Unknown agent 'missing-agent'.");
} finally {
testState.agentsConfig = undefined;
resetConfigRuntimeState();
}
const res = await postEmbeddings(
{
model: "openclaw/beta",
input: "hello",
encoding_format: "base64",
},
{ "x-openclaw-agent-id": "beta" },
);
expect(res.status).toBe(200);
const json = (await res.json()) as { data?: Array<{ embedding?: string }> };
expect(typeof json.data?.[0]?.embedding).toBe("string");
expect(createEmbeddingProviderMock).toHaveBeenCalled();
const lastCall = latestCreateEmbeddingProviderOptions();
expect(typeof lastCall.model).toBe("string");
expect(lastCall.agentDir).toBe(resolveAgentDir({}, "beta"));
});
it("rejects invalid input shapes", async () => {
@@ -456,38 +429,6 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => {
);
});
it("rejects x-openclaw-model for trusted write-only callers", async () => {
const port = await getFreePort();
const server = await startOpenAiCompatGatewayServer({
startGatewayServer,
port,
auth: { mode: "none" },
openAiChatCompletionsEnabled: true,
});
try {
createEmbeddingProviderMock.mockClear();
const res = await fetch(`http://127.0.0.1:${port}/v1/embeddings`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-openclaw-scopes": "operator.write",
"x-openclaw-model": "openai/text-embedding-3-small",
},
body: JSON.stringify({
model: "openclaw/default",
input: "hello",
}),
});
expect(res.status).toBe(403);
const json = (await res.json()) as { error?: { type?: string; message?: string } };
expect(json.error?.type).toBe("forbidden");
expect(json.error?.message).toBe("missing scope: operator.admin");
expect(createEmbeddingProviderMock).not.toHaveBeenCalled();
} finally {
await server.close({ reason: "embeddings model override auth test done" });
}
});
it("rejects oversized batches", async () => {
const res = await postEmbeddings({
model: "openclaw/default",

View File

@@ -24,13 +24,11 @@ import type {
} from "../plugins/memory-embedding-providers.js";
import type { AuthRateLimiter } from "./auth-rate-limit.js";
import type { ResolvedGatewayAuth } from "./auth.js";
import { sendJson, sendMissingScopeForbidden } from "./http-common.js";
import { sendJson } from "./http-common.js";
import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js";
import {
OPENCLAW_MODEL_ID,
authorizeOpenAiCompatibleHttpModelOverride,
getHeader,
isUnknownGatewayAgentError,
resolveAgentIdForRequest,
resolveAgentIdFromModel,
resolveOpenAiCompatibleHttpOperatorScopes,
@@ -254,11 +252,6 @@ export async function handleOpenAiEmbeddingsHttpRequest(
if (!handled) {
return true;
}
const modelOverrideAuth = authorizeOpenAiCompatibleHttpModelOverride(req, handled.requestAuth);
if (!modelOverrideAuth.allowed) {
sendMissingScopeForbidden(res, modelOverrideAuth.missingScope);
return true;
}
const payload = coerceRequest(handled.body);
const requestModel = normalizeOptionalString(payload.model) ?? "";
@@ -298,18 +291,7 @@ export async function handleOpenAiEmbeddingsHttpRequest(
return true;
}
let agentId: string;
try {
agentId = resolveAgentIdForRequest({ req, model: requestModel });
} catch (err) {
if (isUnknownGatewayAgentError(err)) {
sendJson(res, 400, {
error: { message: err.message, type: "invalid_request_error" },
});
return true;
}
throw err;
}
const agentId = resolveAgentIdForRequest({ req, model: requestModel });
const agentDir = resolveAgentDir(cfg, agentId);
const memorySearch = resolveMemorySearchConfig(cfg, agentId);
const configuredProvider = memorySearch?.provider ?? "openai";

View File

@@ -260,14 +260,3 @@ export function resolveOpenAiCompatibleHttpSenderIsOwner(
}
return resolveHttpSenderIsOwner(req, requestAuth);
}
export function authorizeOpenAiCompatibleHttpModelOverride(
req: IncomingMessage,
requestAuth: AuthorizedGatewayHttpRequest,
): { allowed: true } | { allowed: false; missingScope: typeof ADMIN_SCOPE } {
const requestedModelOverride = normalizeOptionalString(getHeader(req, "x-openclaw-model"));
if (!requestedModelOverride || resolveOpenAiCompatibleHttpSenderIsOwner(req, requestAuth)) {
return { allowed: true };
}
return { allowed: false, missingScope: ADMIN_SCOPE };
}

View File

@@ -4,7 +4,6 @@
import type { IncomingMessage } from "node:http";
import { describe, expect, it } from "vitest";
import {
authorizeOpenAiCompatibleHttpModelOverride,
resolveOpenAiCompatibleHttpOperatorScopes,
resolveOpenAiCompatibleHttpSenderIsOwner,
resolveGatewayRequestContext,
@@ -55,35 +54,6 @@ describe("resolveGatewayRequestContext", () => {
expect(result.sessionKey).toContain("openresponses-user:alice");
});
it("does not build session state for explicit unknown agent ids", () => {
expect(() =>
resolveGatewayRequestContext({
req: createReq({ "x-openclaw-agent-id": "missing-agent" }),
model: "openclaw",
sessionPrefix: "openai",
defaultMessageChannel: "webchat",
}),
).toThrow(/Unknown agent/);
expect(() =>
resolveGatewayRequestContext({
req: createReq(),
model: "openclaw/missing-agent",
sessionPrefix: "openai",
defaultMessageChannel: "webchat",
}),
).toThrow(/Unknown agent/);
expect(() =>
resolveGatewayRequestContext({
req: createReq({ "x-openclaw-agent-id": "!!!" }),
model: "openclaw",
sessionPrefix: "openai",
defaultMessageChannel: "webchat",
}),
).toThrow("Unknown agent '!!!'.");
});
});
describe("resolveTrustedHttpOperatorScopes", () => {
@@ -218,38 +188,3 @@ describe("resolveOpenAiCompatibleHttpSenderIsOwner", () => {
).toBe(true);
});
});
describe("authorizeOpenAiCompatibleHttpModelOverride", () => {
it("allows shared-secret bearer callers to use x-openclaw-model", () => {
expect(
authorizeOpenAiCompatibleHttpModelOverride(
createReq({ authorization: "Bearer secret", "x-openclaw-model": "openai/gpt-5.4" }),
{ authMethod: "token", trustDeclaredOperatorScopes: false },
),
).toEqual({ allowed: true });
});
it("allows trusted admin callers to use x-openclaw-model", () => {
expect(
authorizeOpenAiCompatibleHttpModelOverride(
createReq({
"x-openclaw-scopes": "operator.admin, operator.write",
"x-openclaw-model": "openai/gpt-5.4",
}),
{ authMethod: "trusted-proxy", trustDeclaredOperatorScopes: true },
),
).toEqual({ allowed: true });
});
it("rejects trusted write-only callers that try to use x-openclaw-model", () => {
expect(
authorizeOpenAiCompatibleHttpModelOverride(
createReq({
"x-openclaw-scopes": "operator.write",
"x-openclaw-model": "openai/gpt-5.4",
}),
{ authMethod: "trusted-proxy", trustDeclaredOperatorScopes: true },
),
).toEqual({ allowed: false, missingScope: "operator.admin" });
});
});

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