mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-14 01:58:47 +08:00
Compare commits
9 Commits
codex/secu
...
dallin/sco
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b53d1a1992 | ||
|
|
4883a4b9e5 | ||
|
|
618dbd5b73 | ||
|
|
79c0ba8eb9 | ||
|
|
4bfe763034 | ||
|
|
7cbba96ebc | ||
|
|
098811620b | ||
|
|
a39cb5f77d | ||
|
|
58dd194af4 |
@@ -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.
|
||||
|
||||
@@ -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*"
|
||||
47
.github/workflows/codeql.yml
vendored
47
.github/workflows/codeql.yml
vendored
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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?.({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
[],
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(", ")}`);
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: () => [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user