mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-14 01:58:47 +08:00
Compare commits
38 Commits
v2026.6.7-
...
codex/secu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93fc591af9 | ||
|
|
4c23d1d597 | ||
|
|
8eb1fa09c6 | ||
|
|
2d2c1e63f0 | ||
|
|
6cdbccaa9e | ||
|
|
9f522ee7df | ||
|
|
7404b2b5b4 | ||
|
|
73aabcceda | ||
|
|
b1fc8673df | ||
|
|
4cf4e54179 | ||
|
|
84519f7e3c | ||
|
|
6314c377bb | ||
|
|
d3e7e03669 | ||
|
|
64f9f3c278 | ||
|
|
cd3eb438f0 | ||
|
|
26281a8a11 | ||
|
|
4208c89ec4 | ||
|
|
c9c19a1106 | ||
|
|
f78d7b52d8 | ||
|
|
ff6940036b | ||
|
|
b477bfe84b | ||
|
|
d4237cb14d | ||
|
|
20bc546d94 | ||
|
|
069cb8d636 | ||
|
|
8cc5d2d85c | ||
|
|
690f27749c | ||
|
|
7af8153388 | ||
|
|
a66a065ffb | ||
|
|
64d0fc8336 | ||
|
|
f3df863aff | ||
|
|
26b9736922 | ||
|
|
44f45d8729 | ||
|
|
1c655008cd | ||
|
|
618d78144e | ||
|
|
56f2102c28 | ||
|
|
99db98a7ce | ||
|
|
0dbfa1f6be | ||
|
|
f06f2f17c2 |
61
.github/codeql/codeql-process-exec-boundary-critical-security.yml
vendored
Normal file
61
.github/codeql/codeql-process-exec-boundary-critical-security.yml
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
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,7 +17,28 @@ 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:
|
||||
@@ -26,7 +47,28 @@ 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 * * *"
|
||||
@@ -73,6 +115,11 @@ 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
|
||||
|
||||
@@ -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`, or `src`, 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`, `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.
|
||||
|
||||
### Security categories
|
||||
|
||||
@@ -462,6 +462,7 @@ 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,6 +35,7 @@ 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"
|
||||
@@ -104,6 +105,31 @@ 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.
|
||||
@@ -233,6 +259,8 @@ 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,6 +30,23 @@ 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,6 +75,7 @@ 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).
|
||||
|
||||
@@ -96,7 +97,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.
|
||||
- `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-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.
|
||||
@@ -178,7 +179,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`.
|
||||
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.
|
||||
|
||||
Examples:
|
||||
`x-openclaw-model: openai/gpt-5.4`
|
||||
@@ -191,7 +192,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`.
|
||||
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`.
|
||||
Without that header, the request passes through to the selected agent's normal embedding setup.
|
||||
|
||||
</Accordion>
|
||||
@@ -285,7 +286,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`
|
||||
- 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`
|
||||
|
||||
Quick smoke:
|
||||
|
||||
@@ -370,7 +371,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.
|
||||
- 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`.
|
||||
- `/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.
|
||||
- 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.
|
||||
- `/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,6 +425,10 @@ 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,6 +25,7 @@ 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
|
||||
|
||||
@@ -135,6 +136,34 @@ 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,6 +101,28 @@
|
||||
"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",
|
||||
|
||||
57
extensions/anthropic/openclaw.plugin.test.ts
Normal file
57
extensions/anthropic/openclaw.plugin.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// 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,6 +56,10 @@ 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")) {
|
||||
@@ -71,7 +75,15 @@ export function resolveCopilotModelCompat(
|
||||
modelId: string,
|
||||
): ModelDefinitionConfig["compat"] | undefined {
|
||||
const normalized = normalizeOptionalLowercaseString(modelId) ?? "";
|
||||
return isCopilotGeminiModelId(normalized) ? { ...COPILOT_CHAT_COMPLETIONS_COMPAT } : undefined;
|
||||
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;
|
||||
}
|
||||
|
||||
function compatSupportsEffort(
|
||||
|
||||
@@ -90,8 +90,18 @@ 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({
|
||||
@@ -243,6 +253,12 @@ 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");
|
||||
@@ -620,6 +636,7 @@ 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,6 +10,7 @@ import {
|
||||
runWikiChatGptImport,
|
||||
runWikiChatGptRollback,
|
||||
runWikiDoctor,
|
||||
runWikiOkfImport,
|
||||
runWikiStatus,
|
||||
} from "./cli.js";
|
||||
import type { MemoryWikiPluginConfig } from "./config.js";
|
||||
@@ -27,6 +28,7 @@ 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 () => {
|
||||
@@ -41,8 +43,9 @@ describe("memory-wiki cli", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
callGatewayFromCliMock.mockReset();
|
||||
stdoutWriteMock = vi.fn(() => true);
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(
|
||||
(() => true) as typeof process.stdout.write,
|
||||
stdoutWriteMock as unknown as typeof process.stdout.write,
|
||||
);
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
@@ -174,6 +177,65 @@ 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,6 +33,7 @@ import {
|
||||
runObsidianOpen,
|
||||
runObsidianSearch,
|
||||
} from "./obsidian.js";
|
||||
import { formatOkfImportSummary, importMemoryWikiOkfBundle } from "./okf.js";
|
||||
import {
|
||||
getMemoryWikiPage,
|
||||
searchMemoryWiki,
|
||||
@@ -88,6 +89,10 @@ type WikiIngestCommandOptions = {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type WikiOkfImportCommandOptions = {
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type WikiSearchCommandOptions = {
|
||||
json?: boolean;
|
||||
maxResults?: number;
|
||||
@@ -590,6 +595,24 @@ 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;
|
||||
@@ -965,6 +988,16 @@ 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" | "compile" | "lint";
|
||||
type: "init" | "ingest" | "okf-import" | "compile" | "lint";
|
||||
timestamp: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
609
extensions/memory-wiki/src/okf.test.ts
Normal file
609
extensions/memory-wiki/src/okf.test.ts
Normal file
@@ -0,0 +1,609 @@
|
||||
// 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
746
extensions/memory-wiki/src/okf.ts
Normal file
746
extensions/memory-wiki/src/okf.ts
Normal file
@@ -0,0 +1,746 @@
|
||||
// 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,6 +11,24 @@ 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,
|
||||
@@ -204,6 +222,59 @@ 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");
|
||||
@@ -389,6 +460,61 @@ 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 } from "./models.js";
|
||||
import { isOpenRouterMistralModelId, normalizeOpenRouterApiModelId } 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,15 +51,18 @@ 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,
|
||||
};
|
||||
@@ -73,7 +76,8 @@ export default definePluginEntry({
|
||||
function buildDynamicOpenRouterModel(
|
||||
ctx: ProviderResolveDynamicModelContext,
|
||||
): ProviderRuntimeModel {
|
||||
const capabilities = getOpenRouterModelCapabilities(ctx.modelId);
|
||||
const apiModelId = normalizeOpenRouterApiModelId(ctx.modelId) ?? ctx.modelId;
|
||||
const capabilities = getOpenRouterModelCapabilities(apiModelId);
|
||||
return {
|
||||
id: ctx.modelId,
|
||||
name: capabilities?.name ?? ctx.modelId,
|
||||
@@ -166,7 +170,9 @@ export default definePluginEntry({
|
||||
},
|
||||
resolveDynamicModel: (ctx) => buildDynamicOpenRouterModel(ctx),
|
||||
prepareDynamicModel: async (ctx) => {
|
||||
await loadOpenRouterModelCapabilities(ctx.modelId);
|
||||
await loadOpenRouterModelCapabilities(
|
||||
normalizeOpenRouterApiModelId(ctx.modelId) ?? ctx.modelId,
|
||||
);
|
||||
},
|
||||
normalizeConfig: ({ providerConfig }) => {
|
||||
const normalizedBaseUrl = normalizeOpenRouterBaseUrl(providerConfig.baseUrl);
|
||||
|
||||
@@ -12,13 +12,30 @@ 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/") ? normalized.slice("openrouter/".length) : normalized;
|
||||
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;
|
||||
}
|
||||
|
||||
export function isOpenRouterMistralModelId(modelId: unknown): boolean {
|
||||
|
||||
@@ -7,12 +7,17 @@ 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_ID =
|
||||
process.env.OPENCLAW_LIVE_OPENROUTER_PLUGIN_MODEL?.trim() || "openai/gpt-5.4-nano";
|
||||
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_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";
|
||||
@@ -57,6 +62,40 @@ 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" },
|
||||
@@ -69,7 +108,7 @@ async function fetchOpenRouterModelIds(): Promise<string[]> {
|
||||
}
|
||||
|
||||
describeLive("openrouter plugin live", () => {
|
||||
it("registers an OpenRouter provider that can complete a live request", async () => {
|
||||
it("normalizes a prefixed OpenRouter model and completes a live tool call", async () => {
|
||||
const { providers } = await registerOpenRouterPlugin();
|
||||
const provider = requireRegisteredProvider(providers, "openrouter");
|
||||
|
||||
@@ -87,17 +126,35 @@ 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: resolved.baseUrl,
|
||||
baseURL: normalized.baseUrl,
|
||||
});
|
||||
const response = await client.chat.completions.create({
|
||||
model: resolved.id,
|
||||
messages: [{ role: "user", content: "Reply with exactly OK." }],
|
||||
max_tokens: 16,
|
||||
const autoResolved = provider.resolveDynamicModel?.({
|
||||
provider: "openrouter",
|
||||
modelId: "openrouter/auto",
|
||||
modelRegistry: new ModelRegistryCtor(AuthStorage.inMemory()),
|
||||
});
|
||||
|
||||
expect(response.choices[0]?.message?.content?.trim()).toMatch(/^OK[.!]?$/);
|
||||
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);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
|
||||
@@ -315,7 +315,7 @@ describe("qa-lab server", () => {
|
||||
controlUiUrl: string | null;
|
||||
controlUiEmbeddedUrl: string | null;
|
||||
kickoffTask: string;
|
||||
scenarios: Array<{ id: string; title: string }>;
|
||||
scenarios: Array<{ id: string; title: string; execution?: { kind?: string } }>;
|
||||
defaults: { conversationId: string; senderId: string };
|
||||
runner: { status: string; selection: { providerMode: string; scenarioIds: string[] } };
|
||||
};
|
||||
@@ -328,7 +328,12 @@ 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");
|
||||
expect(bootstrap.runner.selection.scenarioIds).toHaveLength(bootstrap.scenarios.length);
|
||||
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);
|
||||
|
||||
const startupStatus = (await (
|
||||
await fetchWithRetry(`${lab.baseUrl}/api/capture/startup-status`)
|
||||
|
||||
@@ -2,6 +2,19 @@
|
||||
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
|
||||
> {
|
||||
@@ -50,6 +63,236 @@ 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,6 +1,8 @@
|
||||
// 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,
|
||||
@@ -48,6 +50,134 @@ 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",
|
||||
@@ -55,5 +185,8 @@ export const slackDoctor: ChannelDoctorAdapter = {
|
||||
warnOnEmptyGroupSenderAllowlist: false,
|
||||
legacyConfigRules: SLACK_LEGACY_CONFIG_RULES,
|
||||
normalizeCompatibilityConfig: normalizeSlackCompatibilityConfig,
|
||||
collectMutableAllowlistWarnings: collectSlackMutableAllowlistWarnings,
|
||||
collectMutableAllowlistWarnings: ({ cfg }) => [
|
||||
...collectSlackMutableAllowlistWarnings({ cfg }),
|
||||
...collectSlackNameKeyedChannelWarnings({ cfg }),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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) ||
|
||||
shouldUseLocalFullSuiteParallelByDefault(process.env);
|
||||
shouldExpandLocalFullSuiteShardsByDefault(process.env);
|
||||
return fullSuiteVitestShards.flatMap((shard) => {
|
||||
if (
|
||||
process.env.OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD === "1" &&
|
||||
@@ -2484,6 +2484,10 @@ 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,6 +3016,52 @@ 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,6 +7,7 @@ import {
|
||||
import { resetTaskRegistryForTests, type TaskRecord } from "../../../tasks/runtime-internal.js";
|
||||
import {
|
||||
requiresCompletionRequiredAsyncTaskWait,
|
||||
shouldWaitForCompletionRequiredAsyncTasks,
|
||||
waitForCompletionRequiredAsyncTasks,
|
||||
type AsyncStartedToolMeta,
|
||||
} from "./attempt.async-tasks.js";
|
||||
@@ -97,6 +98,46 @@ 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,6 +160,23 @@ 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,6 +316,7 @@ import {
|
||||
} from "./attempt-trajectory-status.js";
|
||||
import {
|
||||
requiresCompletionRequiredAsyncTaskWait,
|
||||
shouldWaitForCompletionRequiredAsyncTasks,
|
||||
waitForCompletionRequiredAsyncTasks,
|
||||
type AsyncStartedToolMeta,
|
||||
type CompletionRequiredAsyncTaskWaitResult,
|
||||
@@ -4571,9 +4572,10 @@ export async function runEmbeddedAttempt(
|
||||
await sessionLockController.releaseForPrompt();
|
||||
|
||||
if (
|
||||
requiresCompletionRequiredAsyncTaskWait({
|
||||
shouldWaitForCompletionRequiredAsyncTasks({
|
||||
sessionKey: params.sessionKey,
|
||||
toolMetas,
|
||||
yieldDetected: yieldAborted,
|
||||
})
|
||||
) {
|
||||
const getAsyncStartedToolMetas = () =>
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
* 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",
|
||||
@@ -14,9 +16,12 @@ const PLUGIN_MANIFEST_ENV_KEYS = [
|
||||
"OPENCLAW_TEST_MINIMAL_GATEWAY",
|
||||
] as const;
|
||||
|
||||
function cleanPluginManifestEnv(): Record<(typeof PLUGIN_MANIFEST_ENV_KEYS)[number], undefined> {
|
||||
function cleanPluginManifestEnv(): Record<
|
||||
(typeof PLUGIN_MANIFEST_ENV_KEYS)[number],
|
||||
string | undefined
|
||||
> {
|
||||
return {
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: undefined,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: BUNDLED_PLUGINS_DIR,
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined,
|
||||
OPENCLAW_SKIP_PROVIDERS: undefined,
|
||||
OPENCLAW_SKIP_CHANNELS: undefined,
|
||||
@@ -35,6 +40,7 @@ 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,6 +36,7 @@ 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,6 +29,19 @@ 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,19 +68,35 @@ describe("loadModelCatalogForBrowse", () => {
|
||||
expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: false });
|
||||
});
|
||||
|
||||
it("uses the full catalog when configured visibility has provider wildcards", async () => {
|
||||
it("uses the read-only 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", async () => {
|
||||
it("returns an empty catalog when read-only catalog loading times out with provider wildcards", async () => {
|
||||
const onTimeout = vi.fn();
|
||||
const timeoutHandle = { unref: vi.fn() } as unknown as NodeJS.Timeout;
|
||||
const clearTimeout = vi.fn();
|
||||
@@ -94,7 +110,7 @@ describe("loadModelCatalogForBrowse", () => {
|
||||
const loadCatalog = vi.fn(() => new Promise<ModelCatalogEntry[]>(() => {}));
|
||||
|
||||
const resultPromise = loadModelCatalogForBrowse({
|
||||
cfg: config(),
|
||||
cfg: config({ providerWildcard: true }),
|
||||
loadCatalog,
|
||||
timeoutMs: 5,
|
||||
onTimeout,
|
||||
|
||||
@@ -36,13 +36,6 @@ 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;
|
||||
@@ -51,7 +44,15 @@ export function modelCatalogBrowseRequiresFullDiscovery(params: {
|
||||
const view = params.view ?? "default";
|
||||
return (
|
||||
view === "all" ||
|
||||
parseConfiguredModelVisibilityEntries({ cfg: params.cfg }).providerWildcards.size > 0
|
||||
(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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,7 +66,6 @@ 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,6 +10,11 @@ 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 } = {},
|
||||
@@ -23,7 +28,7 @@ export function createModelPickerVisibleProviderPredicate(
|
||||
);
|
||||
return (provider: string): boolean => {
|
||||
const normalized = normalizeProviderId(provider);
|
||||
return !RETIRED_MODEL_PICKER_PROVIDERS.has(normalized) && !cliRuntimeProviders.has(normalized);
|
||||
return !isRetiredModelPickerProvider(normalized) && !cliRuntimeProviders.has(normalized);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,7 +36,7 @@ export function createModelPickerVisibleProviderPredicate(
|
||||
export function isModelPickerVisibleProvider(provider: string): boolean {
|
||||
const normalized = normalizeProviderId(provider);
|
||||
return (
|
||||
!RETIRED_MODEL_PICKER_PROVIDERS.has(normalized) &&
|
||||
!isRetiredModelPickerProvider(normalized) &&
|
||||
!isCliRuntimeProvider(normalized, { includeSetupRegistry: true })
|
||||
);
|
||||
}
|
||||
|
||||
@@ -234,6 +234,19 @@ 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,6 +127,7 @@ export async function hasAuthForModelProvider(params: {
|
||||
store?: AuthProfileStore;
|
||||
allowPluginSyntheticAuth?: boolean;
|
||||
discoverExternalCliAuth?: boolean;
|
||||
allowPreparedRuntimeAuth?: boolean;
|
||||
runtimeAuthLookup?: RuntimeProviderAuthLookup;
|
||||
resolveRuntimeAuthLookup?: () => RuntimeProviderAuthLookup;
|
||||
}): Promise<boolean> {
|
||||
@@ -162,8 +163,8 @@ export async function hasAuthForModelProvider(params: {
|
||||
configFingerprint === preparedState.configFingerprint &&
|
||||
workspaceDir === expectedWorkspaceDir &&
|
||||
(params.agentDir === undefined || params.agentDir === expectedAgentDir) &&
|
||||
params.discoverExternalCliAuth !== false &&
|
||||
params.allowPluginSyntheticAuth !== false &&
|
||||
(params.allowPreparedRuntimeAuth === true ||
|
||||
(params.discoverExternalCliAuth !== false && params.allowPluginSyntheticAuth !== false)) &&
|
||||
params.env === undefined &&
|
||||
params.store === undefined &&
|
||||
params.modelApi === undefined;
|
||||
@@ -227,6 +228,7 @@ 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;
|
||||
@@ -247,6 +249,7 @@ export function createProviderAuthChecker(params: {
|
||||
env: params.env,
|
||||
allowPluginSyntheticAuth: params.allowPluginSyntheticAuth,
|
||||
discoverExternalCliAuth: params.discoverExternalCliAuth,
|
||||
allowPreparedRuntimeAuth: params.allowPreparedRuntimeAuth,
|
||||
resolveRuntimeAuthLookup: () =>
|
||||
(runtimeAuthLookup ??= createRuntimeProviderAuthLookup({
|
||||
cfg: params.cfg,
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
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 } from "./model-picker-visibility.js";
|
||||
import {
|
||||
createModelPickerVisibleProviderPredicate,
|
||||
isRetiredModelPickerProvider,
|
||||
} from "./model-picker-visibility.js";
|
||||
import {
|
||||
areRuntimeModelRefsEquivalent,
|
||||
isCliRuntimeProvider,
|
||||
@@ -169,6 +172,20 @@ 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,6 +2,7 @@
|
||||
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";
|
||||
@@ -17,6 +18,7 @@ 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 {
|
||||
@@ -533,33 +535,42 @@ 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 planOpenClawModelsJsonWithDeps(
|
||||
const plan = await withEnvAsync(
|
||||
{
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"google-vertex/gemini-2.5-pro": {},
|
||||
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" },
|
||||
},
|
||||
},
|
||||
model: { primary: "google-vertex/gemini-2.5-pro" },
|
||||
models: { providers: {} },
|
||||
},
|
||||
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,
|
||||
},
|
||||
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(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
resolveImplicitProviders: async () => ({
|
||||
"google-vertex": createImplicitGoogleVertexProvider(),
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(plan.action).toBe("write");
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
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,
|
||||
@@ -225,17 +228,26 @@ describe("resolveImplicitProviders startup discovery scope", () => {
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
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,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(providers?.["google-vertex"]?.apiKey).toBe("gcp-vertex-credentials");
|
||||
});
|
||||
|
||||
@@ -1513,11 +1513,10 @@ describe("sessions tools", () => {
|
||||
expect(calls.find((call) => call.method === "send")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sessions_send reroutes run-scoped active deliveries when transcript steering is rejected", async () => {
|
||||
it("sessions_send reports active-run queue rejection without durable-session fallback", 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");
|
||||
});
|
||||
@@ -1539,13 +1538,6 @@ 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 {};
|
||||
});
|
||||
|
||||
@@ -1570,9 +1562,11 @@ describe("sessions tools", () => {
|
||||
timeoutSeconds: 0,
|
||||
});
|
||||
const details = sessionsSendDetails(result.details);
|
||||
expect(details.status).toBe("accepted");
|
||||
expect(details.status).toBe("error");
|
||||
expect(details.sessionKey).toBe(runScopedCallerKey);
|
||||
expect(details.delivery?.status).toBe("pending");
|
||||
expect(details.error).toContain("queue_message_failed reason=runtime_rejected");
|
||||
expect(details.error).toContain("caller-active-session");
|
||||
expect(details.error).not.toContain("fallback_failed");
|
||||
const queuedText = queueMessage.mock.calls[0]?.[0];
|
||||
expect(queuedText).toContain("[Inter-session message]");
|
||||
expect(queuedText).toContain("[TASK-COMPLETE] re-portal occupancy ready");
|
||||
@@ -1583,47 +1577,233 @@ describe("sessions tools", () => {
|
||||
waitForTranscriptCommit: true,
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
});
|
||||
expect(calls.some((call) => call.method === "agent")).toBe(false);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const fallbackCall = calls.find(
|
||||
(call) =>
|
||||
call.method === "agent" &&
|
||||
(call.params as { sessionKey?: string } | undefined)?.sessionKey === durableCallerKey,
|
||||
);
|
||||
expect(fallbackCall).toBeDefined();
|
||||
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 {};
|
||||
});
|
||||
|
||||
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.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);
|
||||
expect(agentCalls).toHaveLength(1);
|
||||
expect(agentParams(agentCalls[0] ?? {}).sessionKey).toBe(ordinaryActiveKey);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const waitCall = calls.find(
|
||||
(call) =>
|
||||
call.method === "agent.wait" &&
|
||||
(call.params as { runId?: string } | undefined)?.runId === "fallback-run",
|
||||
);
|
||||
expect(waitCall).toBeDefined();
|
||||
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 historyCall = calls.find(
|
||||
(call) =>
|
||||
call.method === "chat.history" &&
|
||||
(call.params as { sessionKey?: string } | undefined)?.sessionKey === durableCallerKey,
|
||||
);
|
||||
expect(historyCall).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("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 () => {
|
||||
@@ -1677,7 +1857,7 @@ describe("sessions tools", () => {
|
||||
expect(calls.some((call) => call.method === "agent")).toBe(false);
|
||||
});
|
||||
|
||||
it("sessions_send reports run-scoped fallback admission failures", async () => {
|
||||
it("sessions_send reports run-scoped queue admission failures without gateway fallback", 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");
|
||||
@@ -1720,7 +1900,12 @@ 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).toContain("fallback_failed error=gateway request timeout for agent");
|
||||
expect(details.error).not.toContain("fallback_failed");
|
||||
expect(
|
||||
callGatewayMock.mock.calls.some(
|
||||
(call) => (call[0] as { method?: string } | undefined)?.method === "agent",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("sessions_send preserves terminal timeouts without starting A2A", async () => {
|
||||
|
||||
@@ -73,6 +73,15 @@ 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: {
|
||||
@@ -90,6 +99,8 @@ 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" },
|
||||
},
|
||||
@@ -104,6 +115,15 @@ 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,6 +345,8 @@ async function deliverSlackChannelAnnouncement(params: {
|
||||
queueEmbeddedAgentMessageWithOutcome?: QueueEmbeddedAgentMessageWithOutcome;
|
||||
sendMessage?: typeof runtimeSendMessage;
|
||||
internalEvents?: AgentInternalEvent[];
|
||||
sourceSessionKey?: string;
|
||||
sourceChannel?: string;
|
||||
sourceTool?: string;
|
||||
runtimeConfig?: Record<string, unknown>;
|
||||
}) {
|
||||
@@ -381,6 +383,8 @@ async function deliverSlackChannelAnnouncement(params: {
|
||||
bestEffortDeliver: true,
|
||||
directIdempotencyKey: params.directIdempotencyKey,
|
||||
internalEvents: params.internalEvents,
|
||||
sourceSessionKey: params.sourceSessionKey,
|
||||
sourceChannel: params.sourceChannel,
|
||||
sourceTool: params.sourceTool,
|
||||
});
|
||||
}
|
||||
@@ -4015,8 +4019,21 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("directly delivers stale isolated cron run media completions", async () => {
|
||||
const callGateway = createGatewayMock();
|
||||
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"],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const sendMessage = createSendMessageMock();
|
||||
const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeMock(true);
|
||||
const result = await deliverSlackChannelAnnouncement({
|
||||
@@ -4044,6 +4061,8 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
|
||||
replyInstruction: "Deliver the generated image through the requester run.",
|
||||
},
|
||||
],
|
||||
sourceSessionKey: "image_generate:task-123",
|
||||
sourceChannel: "internal",
|
||||
});
|
||||
|
||||
expectRecordFields(result, {
|
||||
@@ -4051,7 +4070,71 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
|
||||
path: "direct",
|
||||
});
|
||||
expect(queueEmbeddedAgentMessageWithOutcome).not.toHaveBeenCalled();
|
||||
expect(callGateway).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(sendMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "slack",
|
||||
@@ -4059,7 +4142,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
|
||||
to: "channel:C123",
|
||||
content: "The generated image is ready.",
|
||||
mediaUrls: ["/tmp/generated-daily.png"],
|
||||
idempotencyKey: "announce-stale-cron-media:generated-media-direct",
|
||||
idempotencyKey: "announce-stale-cron-media-fallback:generated-media-direct",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1387,7 +1387,8 @@ async function sendSubagentAnnounceDirectly(params: {
|
||||
if (
|
||||
params.expectsCompletionMessage &&
|
||||
isCronRunSessionKey(canonicalRequesterSessionKey) &&
|
||||
!resolveRequesterSessionActivity(canonicalRequesterSessionKey).isActive
|
||||
!resolveRequesterSessionActivity(canonicalRequesterSessionKey).isActive &&
|
||||
!agentMediatedCompletion
|
||||
) {
|
||||
const generatedMediaDelivery = await tryGeneratedMediaDirectDelivery();
|
||||
if (generatedMediaDelivery) {
|
||||
|
||||
@@ -180,6 +180,7 @@ 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: {
|
||||
@@ -264,6 +265,8 @@ 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,6 +2000,185 @@ 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") {
|
||||
if (entry.outcome?.status === "ok" || entry.pauseReason === "sessions_yield") {
|
||||
return;
|
||||
}
|
||||
const completionParams = {
|
||||
@@ -1106,6 +1106,25 @@ 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);
|
||||
@@ -1154,18 +1173,6 @@ 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 = {
|
||||
@@ -1203,6 +1210,7 @@ const subagentRunManager = createSubagentRunManager({
|
||||
stopSweeper,
|
||||
resumeSubagentRun,
|
||||
clearPendingLifecycleError,
|
||||
clearPendingLifecycleTimeout,
|
||||
resolveSubagentWaitTimeoutMs,
|
||||
scheduleOrphanRecovery: (args) => scheduleSubagentOrphanRecovery(args),
|
||||
resolveSubagentSessionCompletion,
|
||||
|
||||
@@ -21,6 +21,7 @@ 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 {
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
import { listAgentIds } from "../agent-scope.js";
|
||||
import {
|
||||
type EmbeddedAgentQueueMessageOptions,
|
||||
type EmbeddedAgentQueueMessageOutcome,
|
||||
formatEmbeddedAgentQueueFailureSummary,
|
||||
queueEmbeddedAgentMessageWithOutcomeAsync,
|
||||
resolveActiveEmbeddedRunSessionId,
|
||||
@@ -92,11 +94,6 @@ 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;
|
||||
@@ -204,13 +201,51 @@ 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;
|
||||
allowActiveRunQueueFallback?: boolean;
|
||||
allowActiveRunQueueDelivery?: boolean;
|
||||
}): Promise<
|
||||
| {
|
||||
ok: true;
|
||||
@@ -222,15 +257,13 @@ async function startAgentRun(params: {
|
||||
| { ok: false; result: ReturnType<typeof jsonResult> }
|
||||
> {
|
||||
try {
|
||||
const activeRunSessionId = params.allowActiveRunQueueFallback
|
||||
? resolveActiveEmbeddedRunSessionId(params.sessionKey)
|
||||
: undefined;
|
||||
const fallbackSessionKey = activeRunSessionId
|
||||
? resolveRunScopedFallbackSessionKey(params.sessionKey)
|
||||
: undefined;
|
||||
const activeRunSessionId =
|
||||
params.allowActiveRunQueueDelivery && isRunScopedAgentSessionKey(params.sessionKey)
|
||||
? resolveActiveEmbeddedRunSessionId(params.sessionKey)
|
||||
: undefined;
|
||||
const messageText =
|
||||
typeof params.sendParams.message === "string" ? params.sendParams.message : undefined;
|
||||
if (activeRunSessionId && fallbackSessionKey && messageText) {
|
||||
if (activeRunSessionId && messageText) {
|
||||
const sourceReplyDeliveryMode =
|
||||
params.sendParams.sourceReplyDeliveryMode === "automatic" ||
|
||||
params.sendParams.sourceReplyDeliveryMode === "message_tool_only"
|
||||
@@ -260,7 +293,8 @@ async function startAgentRun(params: {
|
||||
if (queueOutcome.queued) {
|
||||
return { ok: true, runId: params.runId, activeRunQueue: true };
|
||||
}
|
||||
try {
|
||||
const fallbackSessionKey = resolveCronRunScopedFallbackSessionKey(params.sessionKey);
|
||||
if (fallbackSessionKey && shouldFallbackCronRunScopedActiveDelivery(queueOutcome)) {
|
||||
const response = await params.callGateway<{ runId: string }>({
|
||||
method: "agent",
|
||||
params: {
|
||||
@@ -277,13 +311,10 @@ 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",
|
||||
@@ -643,7 +674,7 @@ export function createSessionsSendTool(opts?: {
|
||||
sendParams,
|
||||
sessionKey: displayKey,
|
||||
deliveryTimeoutMs: announceTimeoutMs,
|
||||
allowActiveRunQueueFallback: true,
|
||||
allowActiveRunQueueDelivery: true,
|
||||
});
|
||||
if (!start.ok) {
|
||||
return start.result;
|
||||
|
||||
@@ -73,6 +73,8 @@ const {
|
||||
dispatchInboundMessageWithBufferedDispatcher,
|
||||
withReplyDispatcher,
|
||||
} = await import("./dispatch.js");
|
||||
const { clearReplyUsageStateForTest, recordReplyUsageState } =
|
||||
await import("./reply/reply-usage-state.js");
|
||||
|
||||
function createDispatcher(record: string[]): ReplyDispatcher {
|
||||
return {
|
||||
@@ -110,6 +112,7 @@ function requireReplyDispatcherOptions(index = 0): Parameters<CreateReplyDispatc
|
||||
describe("withReplyDispatcher", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearReplyUsageStateForTest();
|
||||
hoisted.finalizeInboundContextMock.mockImplementation((ctx: unknown) => ctx);
|
||||
hoisted.deriveInboundMessageHookContextMock.mockReturnValue({
|
||||
channelId: "threads",
|
||||
@@ -424,6 +427,57 @@ 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,6 +35,7 @@ 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";
|
||||
|
||||
@@ -51,6 +52,10 @@ type ForegroundReplyFenceSnapshot = {
|
||||
generation: number;
|
||||
};
|
||||
|
||||
type ReplyPayloadRunState = {
|
||||
runId?: string;
|
||||
};
|
||||
|
||||
const foregroundReplyFenceByKey = new Map<string, ForegroundReplyFenceState>();
|
||||
const replyPayloadSendingDispatchers = new WeakSet<ReplyDispatcher>();
|
||||
|
||||
@@ -348,36 +353,52 @@ function buildMessageSendingBeforeDeliver(
|
||||
|
||||
function buildReplyPayloadSendingBeforeDeliver(
|
||||
ctx: MsgContext | FinalizedMsgContext,
|
||||
opts?: { runId?: string },
|
||||
runState: ReplyPayloadRunState,
|
||||
): 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: opts?.runId,
|
||||
runId,
|
||||
usageState: consumeReplyUsageState(runId),
|
||||
context: {
|
||||
...toPluginMessageContext(hookCtx),
|
||||
runId: opts?.runId,
|
||||
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,
|
||||
opts?: { runId?: string },
|
||||
runState: ReplyPayloadRunState,
|
||||
): void {
|
||||
if (replyPayloadSendingDispatchers.has(dispatcher)) {
|
||||
return;
|
||||
}
|
||||
const beforeDeliver = buildReplyPayloadSendingBeforeDeliver(ctx, opts);
|
||||
const beforeDeliver = buildReplyPayloadSendingBeforeDeliver(ctx, runState);
|
||||
if (!beforeDeliver || !dispatcher.appendBeforeDeliver) {
|
||||
return;
|
||||
}
|
||||
@@ -481,8 +502,13 @@ 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),
|
||||
@@ -501,9 +527,7 @@ export async function dispatchInboundMessage(params: {
|
||||
source: "dispatchInboundMessage",
|
||||
});
|
||||
}
|
||||
installReplyPayloadSendingBeforeDeliver(params.dispatcher, finalized, {
|
||||
runId: replyOptions?.runId,
|
||||
});
|
||||
installReplyPayloadSendingBeforeDeliver(params.dispatcher, finalized, replyPayloadRunState);
|
||||
const result = await withReplyDispatcher({
|
||||
dispatcher: params.dispatcher,
|
||||
run: () =>
|
||||
@@ -514,7 +538,7 @@ export async function dispatchInboundMessage(params: {
|
||||
ctx: finalized,
|
||||
cfg: params.cfg,
|
||||
dispatcher: params.dispatcher,
|
||||
replyOptions,
|
||||
replyOptions: replyOptionsWithRunState,
|
||||
replyResolver: params.replyResolver,
|
||||
onSessionMetadataChanges: params.onSessionMetadataChanges,
|
||||
}),
|
||||
@@ -541,9 +565,13 @@ export async function dispatchInboundMessageWithBufferedDispatcher(params: {
|
||||
const finalized = finalizeInboundContext(params.ctx);
|
||||
const foregroundReplyFence = beginForegroundReplyFence(finalized);
|
||||
const silentReplyContext = resolveDispatcherSilentReplyContext(finalized, params.cfg);
|
||||
const replyPayloadBeforeDeliver = buildReplyPayloadSendingBeforeDeliver(finalized, {
|
||||
const replyPayloadRunState = {
|
||||
runId: params.replyOptions?.runId,
|
||||
});
|
||||
};
|
||||
const replyPayloadBeforeDeliver = buildReplyPayloadSendingBeforeDeliver(
|
||||
finalized,
|
||||
replyPayloadRunState,
|
||||
);
|
||||
const globalBeforeDeliver = combineBeforeDeliverHooks(
|
||||
replyPayloadBeforeDeliver,
|
||||
buildMessageSendingBeforeDeliver(finalized),
|
||||
@@ -603,6 +631,7 @@ export async function dispatchInboundMessageWithBufferedDispatcher(params: {
|
||||
...params.replyOptions,
|
||||
...replyOptions,
|
||||
},
|
||||
replyPayloadRunState,
|
||||
onSessionMetadataChanges: params.onSessionMetadataChanges,
|
||||
});
|
||||
} finally {
|
||||
@@ -635,9 +664,13 @@ export async function dispatchInboundMessageWithDispatcher(params: {
|
||||
replyResolver?: GetReplyFromConfig;
|
||||
}): Promise<DispatchInboundResult> {
|
||||
const silentReplyContext = resolveDispatcherSilentReplyContext(params.ctx, params.cfg);
|
||||
const replyPayloadBeforeDeliver = buildReplyPayloadSendingBeforeDeliver(params.ctx, {
|
||||
const replyPayloadRunState = {
|
||||
runId: params.replyOptions?.runId,
|
||||
});
|
||||
};
|
||||
const replyPayloadBeforeDeliver = buildReplyPayloadSendingBeforeDeliver(
|
||||
params.ctx,
|
||||
replyPayloadRunState,
|
||||
);
|
||||
const globalBeforeDeliver = combineBeforeDeliverHooks(
|
||||
replyPayloadBeforeDeliver,
|
||||
buildMessageSendingBeforeDeliver(params.ctx),
|
||||
@@ -658,5 +691,6 @@ export async function dispatchInboundMessageWithDispatcher(params: {
|
||||
toolsAllow: params.toolsAllow,
|
||||
replyResolver: params.replyResolver,
|
||||
replyOptions: params.replyOptions,
|
||||
replyPayloadRunState,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ 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";
|
||||
@@ -40,6 +42,7 @@ 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";
|
||||
@@ -67,6 +70,9 @@ 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,
|
||||
@@ -117,6 +123,7 @@ 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";
|
||||
@@ -1735,6 +1742,77 @@ 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,
|
||||
@@ -2103,7 +2181,16 @@ export async function runReplyAgent(params: {
|
||||
showCost,
|
||||
costConfig,
|
||||
});
|
||||
if (formatted && responseUsageMode === "full" && sessionKey) {
|
||||
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) {
|
||||
formatted = `${formatted} · session \`${sessionKey}\``;
|
||||
}
|
||||
if (formatted) {
|
||||
|
||||
@@ -29,6 +29,16 @@ 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.";
|
||||
@@ -83,6 +93,10 @@ 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",
|
||||
@@ -160,6 +174,7 @@ 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([
|
||||
@@ -252,6 +267,12 @@ 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);
|
||||
@@ -264,7 +285,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 = firstAuthCheckerParams();
|
||||
const authCheckerParams = preparedAuthCheckerParams();
|
||||
expect(authCheckerParams?.workspaceDir).toBe("/tmp");
|
||||
});
|
||||
|
||||
@@ -272,9 +293,10 @@ describe("handleModelsCommand", () => {
|
||||
await handleModelsCommand(buildParams("/models"), true);
|
||||
|
||||
expect(modelCatalogMocks.loadModelCatalog.mock.calls[0]?.[0]?.readOnly).toBe(true);
|
||||
const authCheckerParams = firstAuthCheckerParams();
|
||||
const authCheckerParams = preparedAuthCheckerParams();
|
||||
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 () => {
|
||||
@@ -302,6 +324,25 @@ 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"]);
|
||||
|
||||
@@ -375,7 +416,7 @@ describe("handleModelsCommand", () => {
|
||||
true,
|
||||
);
|
||||
|
||||
expect(modelCatalogMocks.loadModelCatalog.mock.calls[0]?.[0]?.readOnly).toBe(false);
|
||||
expect(modelCatalogMocks.loadModelCatalog.mock.calls[0]?.[0]?.readOnly).toBe(true);
|
||||
expect(result?.reply?.text).toContain("- openai (2)");
|
||||
expect(result?.reply?.text).toContain("- vllm (2)");
|
||||
expect(result?.reply?.text).not.toContain("- anthropic");
|
||||
@@ -449,6 +490,50 @@ 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,9 +15,8 @@ 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 { isModelPickerVisibleProvider } from "../../agents/model-picker-visibility.js";
|
||||
import { isRetiredModelPickerProvider } 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,
|
||||
@@ -34,6 +33,7 @@ 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,15 +78,14 @@ type ParsedModelsCommand =
|
||||
};
|
||||
|
||||
function isModelsBrowseVisibleProvider(provider: string): boolean {
|
||||
const normalized = normalizeProviderId(provider);
|
||||
return (
|
||||
isCliRuntimeProvider(normalized, { includeSetupRegistry: true }) ||
|
||||
isModelPickerVisibleProvider(normalized)
|
||||
);
|
||||
return !isRetiredModelPickerProvider(provider);
|
||||
}
|
||||
|
||||
function usesUnfilteredCatalogModels(provider: string): boolean {
|
||||
return isCliRuntimeProvider(provider, { includeSetupRegistry: true });
|
||||
function usesUnfilteredCatalogModels(
|
||||
provider: string,
|
||||
cliRuntimeProviders: ReadonlySet<string>,
|
||||
): boolean {
|
||||
return cliRuntimeProviders.has(normalizeProviderId(provider));
|
||||
}
|
||||
|
||||
function normalizeRuntimeChoiceId(runtime: string | undefined): string {
|
||||
@@ -155,11 +154,24 @@ 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 }),
|
||||
loadCatalog: ({ readOnly }) => loadModelCatalog({ config: cfg, readOnly, metadataSnapshot }),
|
||||
});
|
||||
const visibilityPolicy = createModelVisibilityPolicy({
|
||||
cfg,
|
||||
@@ -169,18 +181,27 @@ 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:
|
||||
options.workspaceDir ??
|
||||
(agentId ? resolveAgentWorkspaceDir(cfg, agentId) : undefined) ??
|
||||
resolveDefaultAgentWorkspaceDir(),
|
||||
workspaceDir,
|
||||
view: options.view,
|
||||
runtimeAuthDiscovery: false,
|
||||
providerAuthChecker: hasAuth,
|
||||
});
|
||||
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
@@ -198,7 +219,7 @@ export async function buildModelsProviderData(
|
||||
}
|
||||
if (
|
||||
restrictToProviderWildcards &&
|
||||
!usesUnfilteredCatalogModels(key) &&
|
||||
!usesUnfilteredCatalogModels(key, cliRuntimeProviders) &&
|
||||
!visibilityPolicy.allows({ provider: key, model: m })
|
||||
) {
|
||||
return;
|
||||
@@ -258,20 +279,11 @@ 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) && (await hasAuth(entry.provider))) {
|
||||
if (
|
||||
usesUnfilteredCatalogModels(entry.provider, cliRuntimeProviders) &&
|
||||
(await hasAuth(entry.provider))
|
||||
) {
|
||||
add(entry.provider, entry.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Runs plugin hooks before outbound reply payloads are sent.
|
||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||
import type { PluginHookReplyPayloadSendingContext } from "../../plugins/hook-types.js";
|
||||
import type {
|
||||
PluginHookReplyPayloadSendingContext,
|
||||
PluginHookReplyUsageState,
|
||||
} 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";
|
||||
@@ -17,6 +20,7 @@ export async function runReplyPayloadSendingHook(params: {
|
||||
channel?: string;
|
||||
sessionKey?: string;
|
||||
runId?: string;
|
||||
usageState?: PluginHookReplyUsageState;
|
||||
context: PluginHookReplyPayloadSendingContext;
|
||||
}): Promise<ReplyPayload | null> {
|
||||
const hookRunner = getGlobalHookRunner();
|
||||
@@ -31,6 +35,7 @@ export async function runReplyPayloadSendingHook(params: {
|
||||
channel: params.channel,
|
||||
sessionKey: params.sessionKey,
|
||||
runId: params.runId,
|
||||
usageState: params.usageState,
|
||||
},
|
||||
params.context,
|
||||
);
|
||||
|
||||
39
src/auto-reply/reply/reply-usage-state.test.ts
Normal file
39
src/auto-reply/reply/reply-usage-state.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
37
src/auto-reply/reply/reply-usage-state.ts
Normal file
37
src/auto-reply/reply/reply-usage-state.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
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();
|
||||
}
|
||||
103
src/auto-reply/usage-bar/contract.ts
Normal file
103
src/auto-reply/usage-bar/contract.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
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 },
|
||||
};
|
||||
}
|
||||
72
src/auto-reply/usage-bar/template.test.ts
Normal file
72
src/auto-reply/usage-bar/template.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
86
src/auto-reply/usage-bar/template.ts
Normal file
86
src/auto-reply/usage-bar/template.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
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();
|
||||
}
|
||||
140
src/auto-reply/usage-bar/translator.test.ts
Normal file
140
src/auto-reply/usage-bar/translator.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
288
src/auto-reply/usage-bar/translator.ts
Normal file
288
src/auto-reply/usage-bar/translator.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
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 { TSchema } from "typebox";
|
||||
import { Type, type TSchema } from "typebox";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
@@ -361,9 +361,14 @@ function mergeToolSchemaProperties(
|
||||
return;
|
||||
}
|
||||
for (const [name, schema] of Object.entries(source)) {
|
||||
if (!(name in target)) {
|
||||
target[name] = schema;
|
||||
if (name in target) {
|
||||
continue;
|
||||
}
|
||||
// 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,6 +194,48 @@ 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,6 +1,7 @@
|
||||
// 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";
|
||||
@@ -33,6 +34,33 @@ 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,6 +32,13 @@ 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>,
|
||||
@@ -44,7 +51,7 @@ export async function runCommandWithRuntime(
|
||||
onError(err);
|
||||
return;
|
||||
}
|
||||
runtime.error(String(err));
|
||||
runtime.error(formatCommandRuntimeError(err));
|
||||
runtime.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ vi.mock("../../plugins/provider-runtime.js", () => ({
|
||||
normalizeProviderResolvedModelWithPlugin: mocks.normalizeProviderResolvedModelWithPlugin,
|
||||
}));
|
||||
|
||||
import { appendProviderCatalogRows } from "./list.rows.js";
|
||||
import { appendConfiguredProviderRows, appendProviderCatalogRows } from "./list.rows.js";
|
||||
|
||||
const authIndex = {
|
||||
hasProviderAuth: (provider: string) => provider === "codex",
|
||||
@@ -79,6 +79,7 @@ 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);
|
||||
@@ -189,3 +190,53 @@ 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,6 +145,7 @@ 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,
|
||||
@@ -177,6 +178,7 @@ async function appendVisibleRow(params: {
|
||||
seenKeys?: Set<string>;
|
||||
allowProviderAvailabilityFallback?: boolean;
|
||||
skipSuppression?: boolean;
|
||||
normalizeWithProviderPlugin?: boolean;
|
||||
}): Promise<boolean> {
|
||||
if (params.seenKeys?.has(params.key)) {
|
||||
return false;
|
||||
@@ -184,21 +186,18 @@ async function appendVisibleRow(params: {
|
||||
if (!matchesRowFilter(params.context, params.model)) {
|
||||
return false;
|
||||
}
|
||||
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 })
|
||||
) {
|
||||
const model = params.normalizeWithProviderPlugin
|
||||
? normalizeListRowWithProviderPlugin({
|
||||
model: params.model,
|
||||
context: params.context,
|
||||
})
|
||||
: params.model;
|
||||
if (!params.skipSuppression && shouldSuppressListModel({ model, context: params.context })) {
|
||||
return false;
|
||||
}
|
||||
params.rows.push(
|
||||
await buildRow({
|
||||
model: normalizedModel,
|
||||
model,
|
||||
key: params.key,
|
||||
context: params.context,
|
||||
allowProviderAvailabilityFallback: params.allowProviderAvailabilityFallback,
|
||||
@@ -375,6 +374,7 @@ export async function appendConfiguredProviderRows(params: {
|
||||
context: params.context,
|
||||
seenKeys: params.seenKeys,
|
||||
allowProviderAvailabilityFallback: !params.context.discoveredKeys.has(key),
|
||||
normalizeWithProviderPlugin: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1873,6 +1873,8 @@ 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,6 +967,7 @@ 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,6 +139,8 @@ 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,6 +158,7 @@ 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,21 +278,48 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => {
|
||||
});
|
||||
|
||||
it("supports base64 encoding and agent-scoped auth/config resolution", async () => {
|
||||
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"));
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid input shapes", async () => {
|
||||
@@ -429,6 +456,38 @@ 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,11 +24,13 @@ import type {
|
||||
} from "../plugins/memory-embedding-providers.js";
|
||||
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||
import { sendJson } from "./http-common.js";
|
||||
import { sendJson, sendMissingScopeForbidden } from "./http-common.js";
|
||||
import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js";
|
||||
import {
|
||||
OPENCLAW_MODEL_ID,
|
||||
authorizeOpenAiCompatibleHttpModelOverride,
|
||||
getHeader,
|
||||
isUnknownGatewayAgentError,
|
||||
resolveAgentIdForRequest,
|
||||
resolveAgentIdFromModel,
|
||||
resolveOpenAiCompatibleHttpOperatorScopes,
|
||||
@@ -252,6 +254,11 @@ 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) ?? "";
|
||||
@@ -291,7 +298,18 @@ export async function handleOpenAiEmbeddingsHttpRequest(
|
||||
return true;
|
||||
}
|
||||
|
||||
const agentId = resolveAgentIdForRequest({ req, model: requestModel });
|
||||
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 agentDir = resolveAgentDir(cfg, agentId);
|
||||
const memorySearch = resolveMemorySearchConfig(cfg, agentId);
|
||||
const configuredProvider = memorySearch?.provider ?? "openai";
|
||||
|
||||
@@ -260,3 +260,14 @@ 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,6 +4,7 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
authorizeOpenAiCompatibleHttpModelOverride,
|
||||
resolveOpenAiCompatibleHttpOperatorScopes,
|
||||
resolveOpenAiCompatibleHttpSenderIsOwner,
|
||||
resolveGatewayRequestContext,
|
||||
@@ -54,6 +55,35 @@ 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", () => {
|
||||
@@ -188,3 +218,38 @@ 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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,17 +6,22 @@ import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { modelKey, parseModelRef, resolveDefaultModelForAgent } from "../agents/model-selection.js";
|
||||
import { createModelVisibilityPolicy } from "../agents/model-visibility-policy.js";
|
||||
import { getRuntimeConfig } from "../config/io.js";
|
||||
import { loadManifestMetadataSnapshot } from "../plugins/manifest-contract-eligibility.js";
|
||||
import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-key.js";
|
||||
import {
|
||||
buildAgentMainSessionKey,
|
||||
isValidAgentId,
|
||||
normalizeAgentId,
|
||||
} from "../routing/session-key.js";
|
||||
import { normalizeMessageChannel } from "../utils/message-channel.js";
|
||||
import { getHeader } from "./http-auth-utils.js";
|
||||
import { loadGatewayModelCatalog } from "./server-model-catalog.js";
|
||||
|
||||
export {
|
||||
authorizeOpenAiCompatibleHttpModelOverride,
|
||||
authorizeGatewayHttpRequestOrReply,
|
||||
authorizeScopedGatewayHttpRequestOrReply,
|
||||
checkGatewayHttpRequestAuth,
|
||||
@@ -37,6 +42,23 @@ export const OPENCLAW_MODEL_ID = "openclaw";
|
||||
/** Default OpenAI-compatible model alias that targets the default OpenClaw agent. */
|
||||
export const OPENCLAW_DEFAULT_MODEL_ID = "openclaw/default";
|
||||
|
||||
export class UnknownGatewayAgentError extends Error {
|
||||
constructor(readonly agentId: string) {
|
||||
super(`Unknown agent '${agentId}'.`);
|
||||
this.name = "UnknownGatewayAgentError";
|
||||
}
|
||||
}
|
||||
|
||||
export function isUnknownGatewayAgentError(err: unknown): err is UnknownGatewayAgentError {
|
||||
return err instanceof UnknownGatewayAgentError;
|
||||
}
|
||||
|
||||
function assertKnownAgentId(agentId: string, cfg = getRuntimeConfig()): void {
|
||||
if (!listAgentIds(cfg).includes(agentId)) {
|
||||
throw new UnknownGatewayAgentError(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAgentIdFromHeader(req: IncomingMessage): string | undefined {
|
||||
const raw =
|
||||
normalizeOptionalString(getHeader(req, "x-openclaw-agent-id")) ||
|
||||
@@ -45,6 +67,9 @@ function resolveAgentIdFromHeader(req: IncomingMessage): string | undefined {
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isValidAgentId(raw)) {
|
||||
throw new UnknownGatewayAgentError(raw);
|
||||
}
|
||||
return normalizeAgentId(raw);
|
||||
}
|
||||
|
||||
@@ -139,11 +164,17 @@ export function resolveAgentIdForRequest(params: {
|
||||
const cfg = getRuntimeConfig();
|
||||
const fromHeader = resolveAgentIdFromHeader(params.req);
|
||||
if (fromHeader) {
|
||||
assertKnownAgentId(fromHeader, cfg);
|
||||
return fromHeader;
|
||||
}
|
||||
|
||||
const fromModel = resolveAgentIdFromModel(params.model, cfg);
|
||||
return fromModel ?? resolveDefaultAgentId(cfg);
|
||||
if (fromModel) {
|
||||
assertKnownAgentId(fromModel, cfg);
|
||||
return fromModel;
|
||||
}
|
||||
|
||||
return resolveDefaultAgentId(cfg);
|
||||
}
|
||||
|
||||
function resolveSessionKey(params: {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { subscribeEmbeddedAgentSession } from "../agents/embedded-agent-subscrib
|
||||
import { FailoverError } from "../agents/failover-error.js";
|
||||
import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js";
|
||||
import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js";
|
||||
import { resetConfigRuntimeState } from "../config/config.js";
|
||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||
import { buildAssistantDeltaResult } from "./test-helpers.agent-results.js";
|
||||
import {
|
||||
@@ -187,6 +188,9 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
||||
};
|
||||
|
||||
try {
|
||||
testState.agentsConfig = { list: [{ id: "main" }, { id: "beta" }] };
|
||||
resetConfigRuntimeState();
|
||||
|
||||
{
|
||||
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
||||
method: "GET",
|
||||
@@ -233,6 +237,33 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
||||
matcher: /^agent:main:/,
|
||||
});
|
||||
|
||||
{
|
||||
agentCommand.mockClear();
|
||||
const res = await postChatCompletions(
|
||||
port,
|
||||
{ model: "openclaw", messages: [{ role: "user", content: "hi" }] },
|
||||
{ "x-openclaw-agent-id": "missing-agent" },
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const json = (await res.json()) as { error?: { type?: string; message?: string } };
|
||||
expect(json.error?.type).toBe("invalid_request_error");
|
||||
expect(json.error?.message).toBe("Unknown agent 'missing-agent'.");
|
||||
expect(agentCommand).toHaveBeenCalledTimes(0);
|
||||
}
|
||||
|
||||
{
|
||||
agentCommand.mockClear();
|
||||
const res = await postChatCompletions(port, {
|
||||
model: "openclaw/missing-agent",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const json = (await res.json()) as { error?: { type?: string; message?: string } };
|
||||
expect(json.error?.type).toBe("invalid_request_error");
|
||||
expect(json.error?.message).toBe("Unknown agent 'missing-agent'.");
|
||||
expect(agentCommand).toHaveBeenCalledTimes(0);
|
||||
}
|
||||
|
||||
{
|
||||
mockAgentOnce([{ text: "hello" }]);
|
||||
const res = await postChatCompletions(
|
||||
@@ -287,6 +318,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
||||
},
|
||||
{
|
||||
"x-openclaw-model": "openai/gpt-5.4",
|
||||
"x-openclaw-scopes": "operator.admin, operator.write",
|
||||
},
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
@@ -314,6 +346,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
||||
},
|
||||
{
|
||||
"x-openclaw-model": "gpt-5.4",
|
||||
"x-openclaw-scopes": "operator.admin, operator.write",
|
||||
},
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
@@ -345,7 +378,27 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
||||
model: "openclaw",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
},
|
||||
{ "x-openclaw-model": "openai/" },
|
||||
{ "x-openclaw-model": "openai/gpt-5.4" },
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
const json = (await res.json()) as { error?: { message?: string; type?: string } };
|
||||
expect(json.error?.type).toBe("forbidden");
|
||||
expect(json.error?.message).toBe("missing scope: operator.admin");
|
||||
expect(agentCommand).toHaveBeenCalledTimes(0);
|
||||
}
|
||||
|
||||
{
|
||||
agentCommand.mockClear();
|
||||
const res = await postChatCompletions(
|
||||
port,
|
||||
{
|
||||
model: "openclaw",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
},
|
||||
{
|
||||
"x-openclaw-model": "openai/",
|
||||
"x-openclaw-scopes": "operator.admin, operator.write",
|
||||
},
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const json = (await res.json()) as { error?: { type?: string; message?: string } };
|
||||
@@ -1397,7 +1450,8 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
// shared server
|
||||
testState.agentsConfig = undefined;
|
||||
resetConfigRuntimeState();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -42,9 +42,17 @@ import {
|
||||
} from "./agent-prompt.js";
|
||||
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||
import { sendJson, setSseHeaders, watchClientDisconnect, writeDone } from "./http-common.js";
|
||||
import {
|
||||
sendJson,
|
||||
sendMissingScopeForbidden,
|
||||
setSseHeaders,
|
||||
watchClientDisconnect,
|
||||
writeDone,
|
||||
} from "./http-common.js";
|
||||
import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js";
|
||||
import {
|
||||
authorizeOpenAiCompatibleHttpModelOverride,
|
||||
isUnknownGatewayAgentError,
|
||||
resolveGatewayRequestContext,
|
||||
resolveOpenAiCompatModelOverride,
|
||||
resolveOpenAiCompatibleHttpOperatorScopes,
|
||||
@@ -165,7 +173,7 @@ function buildAgentCommandInput(params: {
|
||||
deliver: false as const,
|
||||
messageChannel: params.messageChannel,
|
||||
bestEffortDeliver: false as const,
|
||||
allowModelOverride: true as const,
|
||||
allowModelOverride: params.modelOverride !== undefined,
|
||||
abortSignal: params.abortSignal,
|
||||
streamParams: params.streamParams,
|
||||
};
|
||||
@@ -886,6 +894,11 @@ export async function handleOpenAiHttpRequest(
|
||||
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 stream = Boolean(payload.stream);
|
||||
const streamIncludeUsage = stream && resolveIncludeUsageForStreaming(payload);
|
||||
@@ -962,14 +975,27 @@ export async function handleOpenAiHttpRequest(
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const { agentId, sessionKey, messageChannel } = resolveGatewayRequestContext({
|
||||
req,
|
||||
model,
|
||||
user,
|
||||
sessionPrefix: "openai",
|
||||
defaultMessageChannel: "webchat",
|
||||
useMessageChannelHeader: true,
|
||||
});
|
||||
let agentId: string;
|
||||
let sessionKey: string;
|
||||
let messageChannel: string;
|
||||
try {
|
||||
({ agentId, sessionKey, messageChannel } = resolveGatewayRequestContext({
|
||||
req,
|
||||
model,
|
||||
user,
|
||||
sessionPrefix: "openai",
|
||||
defaultMessageChannel: "webchat",
|
||||
useMessageChannelHeader: true,
|
||||
}));
|
||||
} catch (err) {
|
||||
if (isUnknownGatewayAgentError(err)) {
|
||||
sendJson(res, 400, {
|
||||
error: { message: err.message, type: "invalid_request_error" },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const { modelOverride, errorMessage: modelError } = await resolveOpenAiCompatModelOverride({
|
||||
req,
|
||||
agentId,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createClientToolNameConflictError } from "../agents/agent-tool-definiti
|
||||
import { FailoverError } from "../agents/failover-error.js";
|
||||
import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js";
|
||||
import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js";
|
||||
import { resetConfigRuntimeState } from "../config/config.js";
|
||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||
import { buildAssistantDeltaResult } from "./test-helpers.agent-results.js";
|
||||
import {
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
getFreePort,
|
||||
installGatewayTestHooks,
|
||||
startGatewayServerWithRetries,
|
||||
testState,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
@@ -263,6 +265,9 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
||||
};
|
||||
|
||||
try {
|
||||
testState.agentsConfig = { list: [{ id: "main" }, { id: "beta" }] };
|
||||
resetConfigRuntimeState();
|
||||
|
||||
const resNonPost = await fetch(`http://127.0.0.1:${port}/v1/responses`, {
|
||||
method: "GET",
|
||||
headers: { authorization: "Bearer secret" },
|
||||
@@ -333,6 +338,30 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
||||
);
|
||||
await ensureResponseConsumed(resDefaultAlias);
|
||||
|
||||
{
|
||||
agentCommand.mockClear();
|
||||
const res = await postResponses(
|
||||
port,
|
||||
{ model: "openclaw", input: "hi" },
|
||||
{ "x-openclaw-agent-id": "missing-agent" },
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const json = (await res.json()) as { error?: { type?: string; message?: string } };
|
||||
expect(json.error?.type).toBe("invalid_request_error");
|
||||
expect(json.error?.message).toBe("Unknown agent 'missing-agent'.");
|
||||
expect(agentCommand).toHaveBeenCalledTimes(0);
|
||||
}
|
||||
|
||||
{
|
||||
agentCommand.mockClear();
|
||||
const res = await postResponses(port, { model: "openclaw/missing-agent", input: "hi" });
|
||||
expect(res.status).toBe(400);
|
||||
const json = (await res.json()) as { error?: { type?: string; message?: string } };
|
||||
expect(json.error?.type).toBe("invalid_request_error");
|
||||
expect(json.error?.message).toBe("Unknown agent 'missing-agent'.");
|
||||
expect(agentCommand).toHaveBeenCalledTimes(0);
|
||||
}
|
||||
|
||||
mockAgentOnce([{ text: "hello" }]);
|
||||
const resChannelHeader = await postResponses(
|
||||
port,
|
||||
@@ -353,7 +382,10 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
||||
model: "openclaw",
|
||||
input: "hi",
|
||||
},
|
||||
{ "x-openclaw-model": "openai/gpt-5.4" },
|
||||
{
|
||||
"x-openclaw-model": "openai/gpt-5.4",
|
||||
"x-openclaw-scopes": "operator.admin, operator.write",
|
||||
},
|
||||
);
|
||||
expect(resModelOverride.status).toBe(200);
|
||||
const optsModelOverride = firstAgentOpts();
|
||||
@@ -364,7 +396,10 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
||||
const resInvalidOverride = await postResponses(
|
||||
port,
|
||||
{ model: "openclaw", input: "hi" },
|
||||
{ "x-openclaw-model": "openai/" },
|
||||
{
|
||||
"x-openclaw-model": "openai/",
|
||||
"x-openclaw-scopes": "operator.admin, operator.write",
|
||||
},
|
||||
);
|
||||
expect(resInvalidOverride.status).toBe(400);
|
||||
const invalidOverrideJson = (await resInvalidOverride.json()) as {
|
||||
@@ -375,6 +410,21 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
||||
expect(agentCommand).toHaveBeenCalledTimes(0);
|
||||
await ensureResponseConsumed(resInvalidOverride);
|
||||
|
||||
agentCommand.mockClear();
|
||||
const resWriteOnlyOverride = await postResponses(
|
||||
port,
|
||||
{ model: "openclaw", input: "hi" },
|
||||
{ "x-openclaw-model": "openai/gpt-5.4" },
|
||||
);
|
||||
expect(resWriteOnlyOverride.status).toBe(403);
|
||||
const writeOnlyJson = (await resWriteOnlyOverride.json()) as {
|
||||
error?: { type?: string; message?: string };
|
||||
};
|
||||
expect(writeOnlyJson.error?.type).toBe("forbidden");
|
||||
expect(writeOnlyJson.error?.message).toBe("missing scope: operator.admin");
|
||||
expect(agentCommand).toHaveBeenCalledTimes(0);
|
||||
await ensureResponseConsumed(resWriteOnlyOverride);
|
||||
|
||||
agentCommand.mockClear();
|
||||
agentCommand.mockRejectedValueOnce(createClientToolNameConflictError(["exec"]));
|
||||
const resToolConflict = await postResponses(port, {
|
||||
@@ -797,7 +847,8 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
||||
);
|
||||
await ensureResponseConsumed(resNoUser);
|
||||
} finally {
|
||||
// shared server
|
||||
testState.agentsConfig = undefined;
|
||||
resetConfigRuntimeState();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -36,11 +36,19 @@ import { defaultRuntime } from "../runtime.js";
|
||||
import { resolveAssistantStreamDeltaText } from "./agent-event-assistant-text.js";
|
||||
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||
import { sendJson, setSseHeaders, watchClientDisconnect, writeDone } from "./http-common.js";
|
||||
import {
|
||||
sendJson,
|
||||
sendMissingScopeForbidden,
|
||||
setSseHeaders,
|
||||
watchClientDisconnect,
|
||||
writeDone,
|
||||
} from "./http-common.js";
|
||||
import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js";
|
||||
import {
|
||||
authorizeOpenAiCompatibleHttpModelOverride,
|
||||
getBearerToken,
|
||||
getHeader,
|
||||
isUnknownGatewayAgentError,
|
||||
resolveAgentIdForRequest,
|
||||
resolveGatewayRequestContext,
|
||||
resolveOpenAiCompatModelOverride,
|
||||
@@ -425,7 +433,7 @@ async function runResponsesAgentCommand(params: {
|
||||
deliver: false,
|
||||
messageChannel: params.messageChannel,
|
||||
bestEffortDeliver: false,
|
||||
allowModelOverride: true,
|
||||
allowModelOverride: params.modelOverride !== undefined,
|
||||
abortSignal: params.abortSignal,
|
||||
},
|
||||
defaultRuntime,
|
||||
@@ -462,6 +470,11 @@ export async function handleOpenResponsesHttpRequest(
|
||||
if (!handled) {
|
||||
return true;
|
||||
}
|
||||
const modelOverrideAuth = authorizeOpenAiCompatibleHttpModelOverride(req, handled.requestAuth);
|
||||
if (!modelOverrideAuth.allowed) {
|
||||
sendMissingScopeForbidden(res, modelOverrideAuth.missingScope);
|
||||
return true;
|
||||
}
|
||||
// Validate request body with Zod
|
||||
const parseResult = CreateResponseBodySchema.safeParse(handled.body);
|
||||
if (!parseResult.success) {
|
||||
@@ -477,7 +490,18 @@ export async function handleOpenResponsesHttpRequest(
|
||||
const stream = Boolean(payload.stream);
|
||||
const model = payload.model;
|
||||
const user = payload.user;
|
||||
const agentId = resolveAgentIdForRequest({ req, model });
|
||||
let agentId: string;
|
||||
try {
|
||||
agentId = resolveAgentIdForRequest({ req, model });
|
||||
} catch (err) {
|
||||
if (isUnknownGatewayAgentError(err)) {
|
||||
sendJson(res, 400, {
|
||||
error: { message: err.message, type: "invalid_request_error" },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const { modelOverride, errorMessage: modelError } = await resolveOpenAiCompatModelOverride({
|
||||
req,
|
||||
agentId,
|
||||
@@ -624,14 +648,25 @@ export async function handleOpenResponsesHttpRequest(
|
||||
});
|
||||
return true;
|
||||
}
|
||||
const resolved = resolveGatewayRequestContext({
|
||||
req,
|
||||
model,
|
||||
user,
|
||||
sessionPrefix: "openresponses",
|
||||
defaultMessageChannel: "webchat",
|
||||
useMessageChannelHeader: true,
|
||||
});
|
||||
let resolved: ReturnType<typeof resolveGatewayRequestContext>;
|
||||
try {
|
||||
resolved = resolveGatewayRequestContext({
|
||||
req,
|
||||
model,
|
||||
user,
|
||||
sessionPrefix: "openresponses",
|
||||
defaultMessageChannel: "webchat",
|
||||
useMessageChannelHeader: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isUnknownGatewayAgentError(err)) {
|
||||
sendJson(res, 400, {
|
||||
error: { message: err.message, type: "invalid_request_error" },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const responseSessionScope = createResponseSessionScope({
|
||||
req,
|
||||
auth: opts.auth,
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
type AuthProfileStore,
|
||||
} from "../../agents/auth-profiles.js";
|
||||
import { DEFAULT_PROVIDER } from "../../agents/defaults.js";
|
||||
import { NON_ENV_SECRETREF_MARKER } from "../../agents/model-auth-markers.js";
|
||||
import { hasRuntimeAvailableProviderAuth } from "../../agents/model-auth.js";
|
||||
import {
|
||||
loadModelCatalogForBrowse,
|
||||
@@ -57,18 +56,6 @@ function omitRuntimeModelParams(entry: ModelCatalogEntry): ModelCatalogEntry {
|
||||
return rest;
|
||||
}
|
||||
|
||||
function modelCatalogEntryHasUnknownSecretRefAvailability(
|
||||
cfg: OpenClawConfig,
|
||||
entry: ModelCatalogEntry,
|
||||
): boolean {
|
||||
const providerId = normalizeProviderId(entry.provider);
|
||||
const provider = Object.entries(cfg.models?.providers ?? {}).find(
|
||||
([id]) => normalizeProviderId(id) === providerId,
|
||||
)?.[1];
|
||||
const apiKey = provider?.apiKey;
|
||||
return apiKey === NON_ENV_SECRETREF_MARKER || (isSecretRef(apiKey) && apiKey.source !== "env");
|
||||
}
|
||||
|
||||
function createInFlightProviderAuthChecker(
|
||||
providerAuthChecker: ModelsListProviderAuthChecker,
|
||||
): ModelsListProviderAuthChecker {
|
||||
@@ -219,16 +206,9 @@ async function resolveModelsListEntryAvailability(
|
||||
|
||||
async function buildPublicModelsListEntry(params: {
|
||||
entry: ModelCatalogEntry;
|
||||
cfg: OpenClawConfig;
|
||||
providerAuthChecker?: ModelsListProviderAuthChecker;
|
||||
}): Promise<ModelsListEntry> {
|
||||
const publicEntry = omitRuntimeModelParams(params.entry);
|
||||
if (modelCatalogEntryHasUnknownSecretRefAvailability(params.cfg, params.entry)) {
|
||||
return {
|
||||
...publicEntry,
|
||||
available: false,
|
||||
};
|
||||
}
|
||||
if (!params.providerAuthChecker) {
|
||||
return publicEntry;
|
||||
}
|
||||
@@ -253,7 +233,6 @@ async function buildPublicModelsListEntries(params: {
|
||||
params.catalog.map((entry) =>
|
||||
buildPublicModelsListEntry({
|
||||
entry,
|
||||
cfg: params.cfg,
|
||||
providerAuthChecker,
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -565,6 +565,90 @@ describe("models.list", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("marks auth profiles available even when provider config uses non-env SecretRef markers", async () => {
|
||||
for (const fixture of [
|
||||
{
|
||||
name: "file",
|
||||
apiKey: {
|
||||
source: "file",
|
||||
provider: "mounted-json",
|
||||
id: "/providers/vllm/apiKey",
|
||||
},
|
||||
},
|
||||
{ name: "managed-marker", apiKey: "secretref-managed" },
|
||||
] as const) {
|
||||
await withOpenClawTestState(
|
||||
{
|
||||
layout: "state-only",
|
||||
prefix: `openclaw-models-list-provider-${fixture.name}-profile-`,
|
||||
agentEnv: "main",
|
||||
env: {
|
||||
OPENCLAW_TEST_PROFILE_API_KEY: "test-token",
|
||||
VLLM_API_KEY: undefined,
|
||||
},
|
||||
},
|
||||
async (state) => {
|
||||
await state.writeAuthProfiles({
|
||||
version: 1,
|
||||
profiles: {
|
||||
"vllm:env": {
|
||||
type: "api_key",
|
||||
provider: "vllm",
|
||||
keyRef: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "OPENCLAW_TEST_PROFILE_API_KEY",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"vllm/*": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
vllm: {
|
||||
apiKey: fixture.apiKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const { request, respond } = requestModelsList({
|
||||
view: "all",
|
||||
runtimeConfig: cfg,
|
||||
loadGatewayModelCatalog: vi.fn(() =>
|
||||
Promise.resolve([{ id: "llama-secure", name: "Llama Secure", provider: "vllm" }]),
|
||||
),
|
||||
reqId: `req-models-list-provider-${fixture.name}-profile`,
|
||||
});
|
||||
await request;
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
{
|
||||
models: [
|
||||
{
|
||||
id: "llama-secure",
|
||||
name: "Llama Secure",
|
||||
provider: "vllm",
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves catalog load errors before the timeout fallback wins", async () => {
|
||||
const { request, respond } = requestModelsList({
|
||||
view: "configured",
|
||||
|
||||
@@ -15,14 +15,14 @@ const REQUESTER_WRITE_HEADERS = {
|
||||
"x-openclaw-scopes": "operator.write",
|
||||
"x-openclaw-requester-session-key": "agent:main:main",
|
||||
};
|
||||
const REQUESTER_ADMIN_HEADERS = {
|
||||
"x-openclaw-scopes": "operator.admin",
|
||||
"x-openclaw-requester-session-key": "agent:other:main",
|
||||
};
|
||||
|
||||
let cfg: Record<string, unknown> = {};
|
||||
const authMock = vi.fn(async (): Promise<GatewayAuthResult> => ({ ok: true }));
|
||||
const isLocalDirectRequestMock = vi.fn(() => true);
|
||||
const loadSessionEntryMock = vi.fn();
|
||||
const getLatestSubagentRunByChildSessionKeyMock = vi.fn();
|
||||
const resolveSubagentControllerMock = vi.fn();
|
||||
const killControlledSubagentRunMock = vi.fn();
|
||||
const killSubagentRunAdminMock = vi.fn();
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
@@ -35,21 +35,14 @@ vi.mock("../config/io.js", () => ({
|
||||
|
||||
vi.mock("./auth.js", () => ({
|
||||
authorizeHttpGatewayConnect: authMock,
|
||||
isLocalDirectRequest: isLocalDirectRequestMock,
|
||||
}));
|
||||
|
||||
vi.mock("./session-utils.js", () => ({
|
||||
loadSessionEntry: loadSessionEntryMock,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/subagent-registry.js", () => ({
|
||||
getLatestSubagentRunByChildSessionKey: getLatestSubagentRunByChildSessionKeyMock,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/subagent-control.js", () => ({
|
||||
killControlledSubagentRun: killControlledSubagentRunMock,
|
||||
killSubagentRunAdmin: killSubagentRunAdminMock,
|
||||
resolveSubagentController: resolveSubagentControllerMock,
|
||||
}));
|
||||
|
||||
const { handleSessionKillHttpRequest } = await import("./session-kill-http.js");
|
||||
@@ -93,13 +86,7 @@ beforeEach(() => {
|
||||
cfg = {};
|
||||
authMock.mockReset();
|
||||
authMock.mockResolvedValue({ ok: true, method: "token" });
|
||||
isLocalDirectRequestMock.mockReset();
|
||||
isLocalDirectRequestMock.mockReturnValue(true);
|
||||
loadSessionEntryMock.mockReset();
|
||||
getLatestSubagentRunByChildSessionKeyMock.mockReset();
|
||||
resolveSubagentControllerMock.mockReset();
|
||||
resolveSubagentControllerMock.mockReturnValue({ controllerSessionKey: "agent:main:main" });
|
||||
killControlledSubagentRunMock.mockReset();
|
||||
killSubagentRunAdminMock.mockReset();
|
||||
});
|
||||
|
||||
@@ -134,12 +121,6 @@ function mockWorkerSession() {
|
||||
});
|
||||
}
|
||||
|
||||
function allowRemoteRequesterKill() {
|
||||
isLocalDirectRequestMock.mockReturnValue(false);
|
||||
allowTrustedProxyAuth();
|
||||
mockWorkerSession();
|
||||
}
|
||||
|
||||
async function expectForbiddenMissingScope(response: Response, message: string) {
|
||||
expect(response.status).toBe(403);
|
||||
expectErrorResponse(await response.json(), {
|
||||
@@ -148,11 +129,6 @@ async function expectForbiddenMissingScope(response: Response, message: string)
|
||||
});
|
||||
}
|
||||
|
||||
async function expectRequesterKillResponse(response: Response, killed: boolean) {
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({ ok: true, killed });
|
||||
}
|
||||
|
||||
function expectErrorResponse(body: unknown, expected: { type: string; message?: string }) {
|
||||
const response = body as {
|
||||
ok?: unknown;
|
||||
@@ -210,7 +186,6 @@ describe("POST /sessions/:sessionKey/kill", () => {
|
||||
expect(authMock).not.toHaveBeenCalled();
|
||||
expect(loadSessionEntryMock).not.toHaveBeenCalled();
|
||||
expect(killSubagentRunAdminMock).not.toHaveBeenCalled();
|
||||
expect(killControlledSubagentRunMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -261,8 +236,7 @@ describe("POST /sessions/:sessionKey/kill", () => {
|
||||
expect(killSubagentRunAdminMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects remote bearer-auth kills without requester ownership", async () => {
|
||||
isLocalDirectRequestMock.mockReturnValue(false);
|
||||
it("rejects bearer-auth kills without a trusted admin scope surface", async () => {
|
||||
mockWorkerSession();
|
||||
|
||||
const response = await postWorkerKill();
|
||||
@@ -271,63 +245,29 @@ describe("POST /sessions/:sessionKey/kill", () => {
|
||||
expect(killSubagentRunAdminMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects remote kills without requester ownership or an authorized token", async () => {
|
||||
isLocalDirectRequestMock.mockReturnValue(false);
|
||||
authMock.mockResolvedValueOnce({ ok: true });
|
||||
it("rejects trusted-proxy requester-session kills without admin scope", async () => {
|
||||
allowTrustedProxyAuth();
|
||||
const response = await postWorkerKill("", REQUESTER_WRITE_HEADERS);
|
||||
await expectForbiddenMissingScope(response, "missing scope: operator.admin");
|
||||
expect(loadSessionEntryMock).not.toHaveBeenCalled();
|
||||
expect(killSubagentRunAdminMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the admin kill path even when the requester session header is present", async () => {
|
||||
allowTrustedProxyAuth();
|
||||
mockWorkerSession();
|
||||
killSubagentRunAdminMock.mockResolvedValue({ found: true, killed: true });
|
||||
|
||||
const response = await postWorkerKill("", {
|
||||
authorization: "",
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
expect(killSubagentRunAdminMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses requester ownership checks when a requester session header is provided without admin bypass", async () => {
|
||||
allowRemoteRequesterKill();
|
||||
getLatestSubagentRunByChildSessionKeyMock.mockReturnValue({
|
||||
runId: "run-1",
|
||||
childSessionKey: WORKER_SESSION_KEY,
|
||||
});
|
||||
killControlledSubagentRunMock.mockResolvedValue({ status: "ok" });
|
||||
|
||||
const response = await postWorkerKill("", REQUESTER_WRITE_HEADERS);
|
||||
await expectRequesterKillResponse(response, true);
|
||||
expect(resolveSubagentControllerMock).toHaveBeenCalledWith({
|
||||
const response = await postWorkerKill("", REQUESTER_ADMIN_HEADERS);
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({ ok: true, killed: true });
|
||||
expect(killSubagentRunAdminMock).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
agentSessionKey: "agent:main:main",
|
||||
sessionKey: WORKER_SESSION_KEY,
|
||||
});
|
||||
expect(getLatestSubagentRunByChildSessionKeyMock).toHaveBeenCalledWith(WORKER_SESSION_KEY);
|
||||
expect(killSubagentRunAdminMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the newest child-session row for requester-owned kills when stale rows still exist", async () => {
|
||||
allowRemoteRequesterKill();
|
||||
getLatestSubagentRunByChildSessionKeyMock.mockReturnValue({
|
||||
runId: "run-current-ended",
|
||||
childSessionKey: WORKER_SESSION_KEY,
|
||||
endedAt: Date.now() - 1,
|
||||
});
|
||||
killControlledSubagentRunMock.mockResolvedValue({ status: "done" });
|
||||
|
||||
const response = await postWorkerKill("", REQUESTER_WRITE_HEADERS);
|
||||
await expectRequesterKillResponse(response, false);
|
||||
expect(killControlledSubagentRunMock).toHaveBeenCalledTimes(1);
|
||||
const killCall = killControlledSubagentRunMock.mock.calls.at(0)?.[0] as
|
||||
| {
|
||||
cfg?: unknown;
|
||||
controller?: { controllerSessionKey?: string };
|
||||
entry?: { runId?: string; childSessionKey?: string };
|
||||
}
|
||||
| undefined;
|
||||
expect(killCall?.cfg).toBe(cfg);
|
||||
expect(killCall?.controller?.controllerSessionKey).toBe("agent:main:main");
|
||||
expect(killCall?.entry?.runId).toBe("run-current-ended");
|
||||
expect(killCall?.entry?.childSessionKey).toBe(WORKER_SESSION_KEY);
|
||||
});
|
||||
|
||||
it("rejects bearer-auth requester kills without a trusted write scope surface", async () => {
|
||||
isLocalDirectRequestMock.mockReturnValue(false);
|
||||
it("rejects bearer-auth requester kills without a trusted admin scope surface", async () => {
|
||||
const response = await post(
|
||||
"/sessions/agent%3Amain%3Asubagent%3Aworker/kill",
|
||||
TEST_GATEWAY_TOKEN,
|
||||
@@ -336,10 +276,9 @@ describe("POST /sessions/:sessionKey/kill", () => {
|
||||
expect(response.status).toBe(403);
|
||||
expectErrorResponse(await response.json(), {
|
||||
type: "forbidden",
|
||||
message: "missing scope: operator.write",
|
||||
message: "missing scope: operator.admin",
|
||||
});
|
||||
expect(loadSessionEntryMock).not.toHaveBeenCalled();
|
||||
expect(killSubagentRunAdminMock).not.toHaveBeenCalled();
|
||||
expect(killControlledSubagentRunMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
// Gateway HTTP session kill handler.
|
||||
// Allows local admins or owning parent sessions to stop subagent runs.
|
||||
// Stops subagent runs through the admin-scoped HTTP control surface.
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import {
|
||||
killControlledSubagentRun,
|
||||
killSubagentRunAdmin,
|
||||
resolveSubagentController,
|
||||
} from "../agents/subagent-control.js";
|
||||
import { getLatestSubagentRunByChildSessionKey } from "../agents/subagent-registry.js";
|
||||
import { killSubagentRunAdmin } from "../agents/subagent-control.js";
|
||||
import { getRuntimeConfig } from "../config/io.js";
|
||||
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
||||
import { isLocalDirectRequest, type ResolvedGatewayAuth } from "./auth.js";
|
||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||
import {
|
||||
sendInvalidRequest,
|
||||
sendJson,
|
||||
@@ -24,8 +18,6 @@ import {
|
||||
import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
|
||||
import { loadSessionEntry } from "./session-utils.js";
|
||||
|
||||
const REQUESTER_SESSION_KEY_HEADER = "x-openclaw-requester-session-key";
|
||||
|
||||
type SessionKeyPathResolution =
|
||||
| { matched: false }
|
||||
| { matched: true; sessionKey: string }
|
||||
@@ -86,30 +78,8 @@ export async function handleSessionKillHttpRequest(
|
||||
return true;
|
||||
}
|
||||
|
||||
const trustedProxies = opts.trustedProxies ?? cfg.gateway?.trustedProxies;
|
||||
const allowRealIpFallback = opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback;
|
||||
const requesterSessionKey = normalizeOptionalString(
|
||||
req.headers[REQUESTER_SESSION_KEY_HEADER]?.toString(),
|
||||
);
|
||||
const allowLocalAdminKill = isLocalDirectRequest(req, trustedProxies, allowRealIpFallback);
|
||||
const requestedScopes = resolveTrustedHttpOperatorScopes(req, requestAuth);
|
||||
|
||||
// Remote browser requests must prove parent-session ownership; local direct
|
||||
// operator requests can perform the stronger admin kill path.
|
||||
if (!requesterSessionKey && !allowLocalAdminKill) {
|
||||
sendJson(res, 403, {
|
||||
ok: false,
|
||||
error: {
|
||||
type: "forbidden",
|
||||
message: "Session kills require a local admin request or requester session ownership.",
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const requiredOperatorMethod =
|
||||
requesterSessionKey && !allowLocalAdminKill ? "sessions.abort" : "sessions.delete";
|
||||
const scopeAuth = authorizeOperatorScopesForMethod(requiredOperatorMethod, requestedScopes);
|
||||
const scopeAuth = authorizeOperatorScopesForMethod("sessions.delete", requestedScopes);
|
||||
if (!scopeAuth.allowed) {
|
||||
sendMissingScopeForbidden(res, scopeAuth.missingScope);
|
||||
return true;
|
||||
@@ -127,38 +97,14 @@ export async function handleSessionKillHttpRequest(
|
||||
return true;
|
||||
}
|
||||
|
||||
let killed = false;
|
||||
if (!allowLocalAdminKill && requesterSessionKey) {
|
||||
const runEntry = getLatestSubagentRunByChildSessionKey(canonicalKey);
|
||||
if (runEntry) {
|
||||
const result = await killControlledSubagentRun({
|
||||
cfg,
|
||||
controller: resolveSubagentController({ cfg, agentSessionKey: requesterSessionKey }),
|
||||
entry: runEntry,
|
||||
});
|
||||
if (result.status === "forbidden") {
|
||||
sendJson(res, 403, {
|
||||
ok: false,
|
||||
error: {
|
||||
type: "forbidden",
|
||||
message: result.error,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
killed = result.status === "ok";
|
||||
}
|
||||
} else {
|
||||
const result = await killSubagentRunAdmin({
|
||||
cfg,
|
||||
sessionKey: canonicalKey,
|
||||
});
|
||||
killed = result.killed;
|
||||
}
|
||||
const result = await killSubagentRunAdmin({
|
||||
cfg,
|
||||
sessionKey: canonicalKey,
|
||||
});
|
||||
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
killed,
|
||||
killed: result.killed,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -86,6 +86,10 @@ export function formatErrorMessage(err: unknown): string {
|
||||
seen.add(cause);
|
||||
if (cause instanceof Error) {
|
||||
appendCauseMessage(cause.message);
|
||||
const code = extractErrorCode(cause);
|
||||
if (code) {
|
||||
appendCauseMessage(code);
|
||||
}
|
||||
cause = cause.cause;
|
||||
} else if (typeof cause === "string") {
|
||||
appendCauseMessage(cause);
|
||||
|
||||
@@ -553,7 +553,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: `${upstreamSha}\n${selectedSha}\n`, stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
|
||||
key.endsWith(` ${upstreamSha}`) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
@@ -582,7 +582,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) &&
|
||||
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
@@ -663,7 +663,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: `${selectedSha}\n`, stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
|
||||
key.endsWith(` ${selectedSha}`) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
@@ -671,7 +671,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: `HEAD is now at ${selectedSha}`, stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith("git -C /tmp/") &&
|
||||
key.startsWith("git -C ") &&
|
||||
preflightPrefixPattern.test(key) &&
|
||||
key.includes(" checkout --detach ") &&
|
||||
key.endsWith(selectedSha)
|
||||
@@ -685,7 +685,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) &&
|
||||
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
@@ -939,7 +939,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: "10.0.0", stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
|
||||
key.endsWith(` ${upstreamSha}`) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
@@ -947,7 +947,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith("git -C /tmp/") &&
|
||||
key.startsWith("git -C ") &&
|
||||
preflightPrefixPattern.test(key) &&
|
||||
key.includes(" checkout --detach ") &&
|
||||
key.endsWith(upstreamSha)
|
||||
@@ -962,7 +962,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) &&
|
||||
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
@@ -1033,7 +1033,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: "10.0.0", stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
|
||||
key.endsWith(` ${upstreamSha}`) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
@@ -1041,7 +1041,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith("git -C /tmp/") &&
|
||||
key.startsWith("git -C ") &&
|
||||
preflightPrefixPattern.test(key) &&
|
||||
key.includes(" checkout --detach ") &&
|
||||
key.endsWith(upstreamSha)
|
||||
@@ -1058,7 +1058,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) &&
|
||||
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
@@ -1120,7 +1120,7 @@ describe("runGatewayUpdate", () => {
|
||||
return toCommandResult(response);
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
|
||||
key.endsWith(` ${upstreamSha}`) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
@@ -1128,7 +1128,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith("git -C /tmp/") &&
|
||||
key.startsWith("git -C ") &&
|
||||
preflightPrefixPattern.test(key) &&
|
||||
key.includes(" checkout --detach ") &&
|
||||
(key.endsWith(upstreamSha) || key.endsWith(selectedSha))
|
||||
@@ -1149,7 +1149,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) &&
|
||||
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
@@ -1211,7 +1211,7 @@ describe("runGatewayUpdate", () => {
|
||||
return toCommandResult(response);
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
|
||||
key.endsWith(` ${upstreamSha}`) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
@@ -1219,7 +1219,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith("git -C /tmp/") &&
|
||||
key.startsWith("git -C ") &&
|
||||
preflightPrefixPattern.test(key) &&
|
||||
key.includes(" checkout --detach ") &&
|
||||
(key.endsWith(upstreamSha) || key.endsWith(olderSha))
|
||||
@@ -1243,7 +1243,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: "", stderr: "build failed", code: 1 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) &&
|
||||
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
@@ -1473,7 +1473,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: "added 1 package", stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
|
||||
key.endsWith(` ${upstreamSha}`) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
@@ -1481,7 +1481,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith("git -C /tmp/") &&
|
||||
key.startsWith("git -C ") &&
|
||||
preflightPrefixPattern.test(key) &&
|
||||
key.includes(" checkout --detach ") &&
|
||||
key.endsWith(upstreamSha)
|
||||
@@ -1494,7 +1494,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) &&
|
||||
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
@@ -1575,7 +1575,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: "10.0.0", stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
|
||||
key.endsWith(` ${upstreamSha}`) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
@@ -1583,7 +1583,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith("git -C /tmp/") &&
|
||||
key.startsWith("git -C ") &&
|
||||
preflightPrefixPattern.test(key) &&
|
||||
key.includes(" checkout --detach ") &&
|
||||
key.endsWith(upstreamSha)
|
||||
@@ -1598,7 +1598,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) &&
|
||||
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
@@ -1673,7 +1673,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: "10.0.0", stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
|
||||
key.endsWith(` ${upstreamSha}`) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
@@ -1681,7 +1681,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith("git -C /tmp/") &&
|
||||
key.startsWith("git -C ") &&
|
||||
preflightPrefixPattern.test(key) &&
|
||||
key.includes(" checkout --detach ") &&
|
||||
key.endsWith(upstreamSha)
|
||||
@@ -1712,7 +1712,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) &&
|
||||
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
@@ -1788,7 +1788,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: "10.0.0", stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
|
||||
key.endsWith(` ${upstreamSha}`) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
@@ -1796,7 +1796,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith("git -C /tmp/") &&
|
||||
key.startsWith("git -C ") &&
|
||||
preflightPrefixPattern.test(key) &&
|
||||
key.includes(" checkout --detach ") &&
|
||||
key.endsWith(upstreamSha)
|
||||
@@ -1887,7 +1887,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: "10.0.0", stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
|
||||
key.endsWith(` ${upstreamSha}`) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
@@ -1895,7 +1895,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith("git -C /tmp/") &&
|
||||
key.startsWith("git -C ") &&
|
||||
preflightPrefixPattern.test(key) &&
|
||||
key.includes(" checkout --detach ") &&
|
||||
key.endsWith(upstreamSha)
|
||||
@@ -1981,7 +1981,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: "10.0.0", stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
|
||||
key.endsWith(` ${upstreamSha}`) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
@@ -1989,7 +1989,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith("git -C /tmp/") &&
|
||||
key.startsWith("git -C ") &&
|
||||
preflightPrefixPattern.test(key) &&
|
||||
key.includes(" checkout --detach ") &&
|
||||
key.endsWith(upstreamSha)
|
||||
@@ -2008,7 +2008,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) &&
|
||||
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
@@ -2068,7 +2068,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: `${targetSha}\n`, stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
|
||||
key.endsWith(` ${targetSha}`) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
@@ -2076,7 +2076,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: `HEAD is now at ${targetSha}`, stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith("git -C /tmp/") &&
|
||||
key.startsWith("git -C ") &&
|
||||
key.includes(` checkout --detach ${targetSha}`) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
@@ -2092,7 +2092,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) &&
|
||||
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
@@ -2149,7 +2149,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: `${targetSha}\n`, stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
|
||||
key.endsWith(` ${targetSha}`) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
@@ -2157,7 +2157,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: `HEAD is now at ${targetSha}`, stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith("git -C /tmp/") &&
|
||||
key.startsWith("git -C ") &&
|
||||
key.includes(` checkout --detach ${targetSha}`) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
@@ -2173,7 +2173,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) &&
|
||||
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
@@ -2233,7 +2233,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: `${targetSha}\n`, stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${gitRoot} worktree add --detach /tmp/`) &&
|
||||
key.startsWith(`git -C ${gitRoot} worktree add --detach `) &&
|
||||
key.endsWith(` ${targetSha}`) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
@@ -2241,7 +2241,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: `HEAD is now at ${targetSha}`, stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith("git -C /tmp/") &&
|
||||
key.startsWith("git -C ") &&
|
||||
key.includes(` checkout --detach ${targetSha}`) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
@@ -2257,7 +2257,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${gitRoot} worktree remove --force /tmp/`) &&
|
||||
key.startsWith(`git -C ${gitRoot} worktree remove --force `) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
@@ -2313,7 +2313,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: `${upstreamSha}\n`, stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach `) &&
|
||||
key.endsWith(` ${upstreamSha}`) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
@@ -2321,7 +2321,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith("git -C /tmp/") &&
|
||||
key.startsWith("git -C ") &&
|
||||
preflightPrefixPattern.test(key) &&
|
||||
key.includes(" checkout --detach ") &&
|
||||
key.endsWith(upstreamSha)
|
||||
@@ -2329,7 +2329,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) &&
|
||||
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
|
||||
@@ -353,6 +353,14 @@ export type PluginHookLlmOutputEvent = {
|
||||
cacheWrite?: number;
|
||||
total?: number;
|
||||
};
|
||||
/**
|
||||
* Requested reasoning/think effort for this call (provider think level, e.g.
|
||||
* "off" | "low" | "medium" | "high"). Lets a passive footer show the mode the
|
||||
* user is actually running without re-deriving it.
|
||||
*/
|
||||
reasoningEffort?: string;
|
||||
/** Whether fast mode was active for this call. */
|
||||
fastMode?: boolean;
|
||||
};
|
||||
|
||||
export type PluginHookAgentEndEvent = {
|
||||
@@ -494,12 +502,84 @@ export type PluginHookReplyDispatchResult = {
|
||||
counts: Record<ReplyDispatchKind, number>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-turn execution state for the outbound reply, available to every harness
|
||||
* (embedded, CLI, Codex app-server) — sourced from the unified `runResult.meta`
|
||||
* at dispatch, not from the harness-specific `llm_output` hook. Lets a plugin
|
||||
* render a passive per-response footer without re-deriving run state.
|
||||
*/
|
||||
export type PluginHookReplyUsageState = {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
/** Resolved provider/model ref actually used (keeps the provider prefix). */
|
||||
resolvedRef?: string;
|
||||
/** Requested reasoning/think effort (e.g. "off" | "low" | "medium" | "high"). */
|
||||
reasoningEffort?: string;
|
||||
fastMode?: boolean;
|
||||
/** True when a model fallback was used for this turn. */
|
||||
fallbackUsed?: boolean;
|
||||
/** Owning agent + session for this reply. */
|
||||
agentId?: string;
|
||||
sessionId?: string;
|
||||
/** Chat surface kind (e.g. "direct" | "group"). */
|
||||
chatType?: string;
|
||||
/** Credential mode the turn ran under (e.g. "oauth" | "api_key"). */
|
||||
authMode?: string;
|
||||
/** Session model-override source, when a non-default model was pinned. */
|
||||
overrideSource?: string;
|
||||
/** Provider/model ref requested for the turn (vs resolvedRef actually used). */
|
||||
requested?: string;
|
||||
/** Estimated cost of this turn in USD, when a cost table is configured. */
|
||||
turnUsd?: number;
|
||||
/** Wall-clock duration of the turn in milliseconds. */
|
||||
durationMs?: number;
|
||||
/** Owning agent's configured identity (name/emoji/avatar), when set. */
|
||||
identity?: { name?: string; emoji?: string; avatar?: string };
|
||||
compactionCount?: number;
|
||||
/** Effective context-token budget after model/config/agent caps. */
|
||||
contextTokenBudget?: number;
|
||||
/**
|
||||
* Actual context-window occupancy at the END of the turn — the final model
|
||||
* call's prompt tokens, NOT the per-turn aggregate. This is the value
|
||||
* `context.used_tokens` / `context.pct_used` must use: the aggregate prompt
|
||||
* total over a multi-call tool loop overstates occupancy (often beyond the
|
||||
* window). Absent on harnesses that don't report it (the contract then falls
|
||||
* back to the aggregate prompt total, which is correct for single-call turns).
|
||||
*/
|
||||
contextUsedTokens?: number;
|
||||
usage?: {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
total?: number;
|
||||
};
|
||||
/**
|
||||
* Usage from the FINAL model call of the turn only — vs `usage`, which is the
|
||||
* turn aggregate summed across every tool-loop call. Lets a footer render the
|
||||
* last exchange's i/o + cache instead of the whole turn. Absent on harnesses
|
||||
* that don't report per-call usage.
|
||||
*/
|
||||
lastUsage?: {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
total?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type PluginHookReplyPayloadSendingEvent = {
|
||||
payload: PluginHookReplyPayload;
|
||||
kind: ReplyDispatchKind;
|
||||
channel?: string;
|
||||
sessionKey?: string;
|
||||
runId?: string;
|
||||
/**
|
||||
* Per-turn usage snapshot for live dispatcher delivery. Absent on durable
|
||||
* delivery/replay paths, and whenever no exact run correlation is available.
|
||||
*/
|
||||
usageState?: PluginHookReplyUsageState;
|
||||
};
|
||||
|
||||
export type PluginHookReplyPayload = Omit<ReplyPayload, "trustedLocalMedia">;
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { normalizeProviderModelIdWithManifest } from "./manifest-model-id-normalization.js";
|
||||
import { resolvePluginMetadataSnapshot } from "./plugin-metadata-snapshot.js";
|
||||
import type { PluginMetadataRegistryView } from "./plugin-metadata-snapshot.types.js";
|
||||
import { resolvePluginDiscoveryProvidersRuntime } from "./provider-discovery.runtime.js";
|
||||
import {
|
||||
clearProviderRuntimePluginCacheForTest,
|
||||
@@ -327,6 +328,7 @@ export function normalizeProviderResolvedModelWithPlugin(params: {
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
pluginMetadataSnapshot?: PluginMetadataRegistryView;
|
||||
context: {
|
||||
config?: OpenClawConfig;
|
||||
agentDir?: string;
|
||||
|
||||
@@ -515,25 +515,29 @@ describe("test-projects args", () => {
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps conservative core full-suite runs on aggregate shards", () => {
|
||||
it("keeps conservative local full-suite runs on leaf project configs", () => {
|
||||
const originalVitestMaxWorkers = process.env.OPENCLAW_VITEST_MAX_WORKERS;
|
||||
const originalTestWorkers = process.env.OPENCLAW_TEST_WORKERS;
|
||||
const originalProjectParallel = process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
|
||||
const originalLeafShards = process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
|
||||
const originalCi = process.env.CI;
|
||||
const originalActions = process.env.GITHUB_ACTIONS;
|
||||
try {
|
||||
process.env.OPENCLAW_VITEST_MAX_WORKERS = "1";
|
||||
delete process.env.OPENCLAW_TEST_WORKERS;
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
|
||||
delete process.env.CI;
|
||||
delete process.env.GITHUB_ACTIONS;
|
||||
|
||||
const configs = buildFullSuiteVitestRunPlans([]).map((plan) => plan.config);
|
||||
|
||||
expect(configs).toContain("test/vitest/vitest.full-core-unit-fast.config.ts");
|
||||
expect(configs).toContain("test/vitest/vitest.full-core-support-boundary.config.ts");
|
||||
expect(configs).not.toContain("test/vitest/vitest.boundary.config.ts");
|
||||
expect(configs).toContain("test/vitest/vitest.full-agentic.config.ts");
|
||||
expect(configs).not.toContain("test/vitest/vitest.agents.config.ts");
|
||||
expect(configs).not.toContain("test/vitest/vitest.plugins.config.ts");
|
||||
expect(configs).toContain("test/vitest/vitest.unit-fast.config.ts");
|
||||
expect(configs).toContain("test/vitest/vitest.boundary.config.ts");
|
||||
expect(configs).toContain("test/vitest/vitest.agents-core.config.ts");
|
||||
expect(configs).toContain("test/vitest/vitest.plugins.config.ts");
|
||||
expect(configs).not.toContain("test/vitest/vitest.full-core-unit-fast.config.ts");
|
||||
expect(configs).not.toContain("test/vitest/vitest.full-agentic.config.ts");
|
||||
} finally {
|
||||
if (originalVitestMaxWorkers === undefined) {
|
||||
delete process.env.OPENCLAW_VITEST_MAX_WORKERS;
|
||||
@@ -555,6 +559,16 @@ describe("test-projects args", () => {
|
||||
} else {
|
||||
process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS = originalLeafShards;
|
||||
}
|
||||
if (originalCi === undefined) {
|
||||
delete process.env.CI;
|
||||
} else {
|
||||
process.env.CI = originalCi;
|
||||
}
|
||||
if (originalActions === undefined) {
|
||||
delete process.env.GITHUB_ACTIONS;
|
||||
} else {
|
||||
process.env.GITHUB_ACTIONS = originalActions;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -2143,29 +2143,25 @@ describe("scripts/test-projects full-suite sharding", () => {
|
||||
).toThrow("OPENCLAW_TEST_WORKERS must be a positive integer; got: 1 worker");
|
||||
});
|
||||
|
||||
it("keeps serial untargeted runs on aggregate shards", () => {
|
||||
it("keeps serial untargeted local runs on leaf project configs", () => {
|
||||
const previousParallel = process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
|
||||
const previousSerial = process.env.OPENCLAW_TEST_PROJECTS_SERIAL;
|
||||
const previousCi = process.env.CI;
|
||||
const previousActions = process.env.GITHUB_ACTIONS;
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
|
||||
delete process.env.OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD;
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
|
||||
delete process.env.CI;
|
||||
delete process.env.GITHUB_ACTIONS;
|
||||
process.env.OPENCLAW_TEST_PROJECTS_SERIAL = "1";
|
||||
try {
|
||||
expect(buildFullSuiteVitestRunPlans([], process.cwd()).map((plan) => plan.config)).toEqual([
|
||||
"test/vitest/vitest.full-core-unit-fast.config.ts",
|
||||
"test/vitest/vitest.full-core-unit-src.config.ts",
|
||||
"test/vitest/vitest.full-core-unit-security.config.ts",
|
||||
"test/vitest/vitest.full-core-unit-ui.config.ts",
|
||||
"test/vitest/vitest.full-core-unit-support.config.ts",
|
||||
"test/vitest/vitest.full-core-support-boundary.config.ts",
|
||||
"test/vitest/vitest.full-core-tooling.config.ts",
|
||||
"test/vitest/vitest.full-core-contracts.config.ts",
|
||||
"test/vitest/vitest.full-core-bundled.config.ts",
|
||||
"test/vitest/vitest.full-core-runtime.config.ts",
|
||||
"test/vitest/vitest.full-agentic.config.ts",
|
||||
"test/vitest/vitest.full-auto-reply.config.ts",
|
||||
"test/vitest/vitest.full-extensions.config.ts",
|
||||
]);
|
||||
const configs = buildFullSuiteVitestRunPlans([], process.cwd()).map((plan) => plan.config);
|
||||
|
||||
expect(configs).toContain("test/vitest/vitest.gateway-server.config.ts");
|
||||
expect(configs).toContain("test/vitest/vitest.auto-reply-reply.config.ts");
|
||||
expect(configs).toContain("test/vitest/vitest.extension-telegram.config.ts");
|
||||
expect(configs).not.toContain("test/vitest/vitest.full-agentic.config.ts");
|
||||
expect(configs).not.toContain("test/vitest/vitest.full-extensions.config.ts");
|
||||
} finally {
|
||||
if (previousParallel === undefined) {
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
|
||||
@@ -2177,6 +2173,16 @@ describe("scripts/test-projects full-suite sharding", () => {
|
||||
} else {
|
||||
process.env.OPENCLAW_TEST_PROJECTS_SERIAL = previousSerial;
|
||||
}
|
||||
if (previousCi === undefined) {
|
||||
delete process.env.CI;
|
||||
} else {
|
||||
process.env.CI = previousCi;
|
||||
}
|
||||
if (previousActions === undefined) {
|
||||
delete process.env.GITHUB_ACTIONS;
|
||||
} else {
|
||||
process.env.GITHUB_ACTIONS = previousActions;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2241,12 +2247,74 @@ describe("scripts/test-projects full-suite sharding", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("expands conservative local worker runs to leaf project configs", () => {
|
||||
const previousLeafShards = process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
|
||||
const previousParallel = process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
|
||||
const previousSerial = process.env.OPENCLAW_TEST_PROJECTS_SERIAL;
|
||||
const previousCi = process.env.CI;
|
||||
const previousActions = process.env.GITHUB_ACTIONS;
|
||||
const previousVitestMaxWorkers = process.env.OPENCLAW_VITEST_MAX_WORKERS;
|
||||
const previousTestWorkers = process.env.OPENCLAW_TEST_WORKERS;
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_SERIAL;
|
||||
delete process.env.CI;
|
||||
delete process.env.GITHUB_ACTIONS;
|
||||
process.env.OPENCLAW_VITEST_MAX_WORKERS = "1";
|
||||
delete process.env.OPENCLAW_TEST_WORKERS;
|
||||
try {
|
||||
const configs = buildFullSuiteVitestRunPlans([], process.cwd()).map((plan) => plan.config);
|
||||
|
||||
expect(configs).toContain("test/vitest/vitest.gateway-server.config.ts");
|
||||
expect(configs).toContain("test/vitest/vitest.auto-reply-reply.config.ts");
|
||||
expect(configs).not.toContain("test/vitest/vitest.full-agentic.config.ts");
|
||||
} finally {
|
||||
if (previousLeafShards === undefined) {
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
|
||||
} else {
|
||||
process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS = previousLeafShards;
|
||||
}
|
||||
if (previousParallel === undefined) {
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
|
||||
} else {
|
||||
process.env.OPENCLAW_TEST_PROJECTS_PARALLEL = previousParallel;
|
||||
}
|
||||
if (previousSerial === undefined) {
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_SERIAL;
|
||||
} else {
|
||||
process.env.OPENCLAW_TEST_PROJECTS_SERIAL = previousSerial;
|
||||
}
|
||||
if (previousCi === undefined) {
|
||||
delete process.env.CI;
|
||||
} else {
|
||||
process.env.CI = previousCi;
|
||||
}
|
||||
if (previousActions === undefined) {
|
||||
delete process.env.GITHUB_ACTIONS;
|
||||
} else {
|
||||
process.env.GITHUB_ACTIONS = previousActions;
|
||||
}
|
||||
if (previousVitestMaxWorkers === undefined) {
|
||||
delete process.env.OPENCLAW_VITEST_MAX_WORKERS;
|
||||
} else {
|
||||
process.env.OPENCLAW_VITEST_MAX_WORKERS = previousVitestMaxWorkers;
|
||||
}
|
||||
if (previousTestWorkers === undefined) {
|
||||
delete process.env.OPENCLAW_TEST_WORKERS;
|
||||
} else {
|
||||
process.env.OPENCLAW_TEST_WORKERS = previousTestWorkers;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("can skip the aggregate extension shard when CI runs dedicated extension shards", () => {
|
||||
const previous = process.env.OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD;
|
||||
const previousParallel = process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
|
||||
const previousSerial = process.env.OPENCLAW_TEST_PROJECTS_SERIAL;
|
||||
const previousCi = process.env.CI;
|
||||
delete process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
|
||||
process.env.OPENCLAW_TEST_PROJECTS_SERIAL = "1";
|
||||
process.env.CI = "true";
|
||||
process.env.OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD = "1";
|
||||
try {
|
||||
const configs = buildFullSuiteVitestRunPlans([], process.cwd()).map((plan) => plan.config);
|
||||
@@ -2269,6 +2337,11 @@ describe("scripts/test-projects full-suite sharding", () => {
|
||||
} else {
|
||||
process.env.OPENCLAW_TEST_PROJECTS_SERIAL = previousSerial;
|
||||
}
|
||||
if (previousCi === undefined) {
|
||||
delete process.env.CI;
|
||||
} else {
|
||||
process.env.CI = previousCi;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -513,7 +513,7 @@ describe("scoped vitest configs", () => {
|
||||
|
||||
it("splits auto-reply into narrower scoped buckets", () => {
|
||||
const coreTestConfig = requireTestConfig(defaultAutoReplyCoreConfig);
|
||||
expect(coreTestConfig.include).toEqual(["*.test.ts"]);
|
||||
expect(coreTestConfig.include).toEqual(["*.test.ts", "usage-bar/*.test.ts"]);
|
||||
expect(coreTestConfig.exclude).toContain("reply*.test.ts");
|
||||
expect(requireTestConfig(defaultAutoReplyTopLevelConfig).include).toEqual(["reply*.test.ts"]);
|
||||
expect(requireTestConfig(defaultAutoReplyReplyConfig).include).toEqual(["reply/**/*.test.ts"]);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// Full-suite Vitest shard definitions used by test-projects and CI planning.
|
||||
export const autoReplyCoreTestInclude = ["src/auto-reply/*.test.ts"];
|
||||
export const autoReplyCoreTestInclude = [
|
||||
"src/auto-reply/*.test.ts",
|
||||
"src/auto-reply/usage-bar/*.test.ts",
|
||||
];
|
||||
|
||||
export const autoReplyCoreTestExclude = ["src/auto-reply/reply*.test.ts"];
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ function createScrollHost(
|
||||
chatLastScrollTop: 0,
|
||||
chatHasAutoScrolled: false,
|
||||
chatUserNearBottom: true,
|
||||
chatFollowLocked: false,
|
||||
chatHeaderControlsHidden: false,
|
||||
chatNewMessagesBelow: false,
|
||||
chatIsProgrammaticScroll: false,
|
||||
@@ -107,10 +108,9 @@ describe("handleChatScroll", () => {
|
||||
expect(host.chatUserNearBottom).toBe(false);
|
||||
});
|
||||
|
||||
it("sets chatUserNearBottom=false when user scrolled up past one long message (>200px <450px)", () => {
|
||||
it("sets chatUserNearBottom=false when scrolled past the near-bottom threshold", () => {
|
||||
const { host } = createScrollHost({});
|
||||
// distanceFromBottom = 2000 - 1250 - 400 = 350 → old threshold would say "near", new says "near"
|
||||
// distanceFromBottom = 2000 - 1100 - 400 = 500 → old threshold would say "not near", new also "not near"
|
||||
// distanceFromBottom = 2000 - 1100 - 400 = 500 → beyond threshold
|
||||
const event = createScrollEvent(2000, 1100, 400);
|
||||
handleChatScroll(host, event);
|
||||
expect(host.chatUserNearBottom).toBe(false);
|
||||
@@ -232,6 +232,24 @@ describe("scheduleChatScroll", () => {
|
||||
expect(container.scrollTop).toBe(container.scrollHeight);
|
||||
});
|
||||
|
||||
it("uses force=true on initial load even after a previous follow lock", async () => {
|
||||
const { host, container } = createScrollHost({
|
||||
scrollHeight: 2000,
|
||||
scrollTop: 500,
|
||||
clientHeight: 400,
|
||||
});
|
||||
host.chatUserNearBottom = false;
|
||||
host.chatFollowLocked = true;
|
||||
host.chatHasAutoScrolled = false;
|
||||
|
||||
scheduleChatScroll(host, true);
|
||||
await host.updateComplete;
|
||||
|
||||
expect(container.scrollTop).toBe(container.scrollHeight);
|
||||
expect(host.chatFollowLocked).toBe(false);
|
||||
expect(host.chatNewMessagesBelow).toBe(false);
|
||||
});
|
||||
|
||||
it("sets chatNewMessagesBelow when not scrolling due to user position", async () => {
|
||||
const { host } = createScrollHost({
|
||||
scrollHeight: 2000,
|
||||
@@ -248,6 +266,62 @@ describe("scheduleChatScroll", () => {
|
||||
expect(host.chatNewMessagesBelow).toBe(true);
|
||||
});
|
||||
|
||||
it("does not re-stick streaming after a user scrolls slightly up near the bottom", async () => {
|
||||
const { host, container } = createScrollHost({
|
||||
scrollHeight: 2000,
|
||||
scrollTop: 1540,
|
||||
clientHeight: 400,
|
||||
});
|
||||
host.chatHasAutoScrolled = true;
|
||||
host.chatUserNearBottom = true;
|
||||
host.chatIsProgrammaticScroll = true;
|
||||
host.chatProgrammaticScrollTarget = 1800;
|
||||
host.chatLastScrollTop = 1600;
|
||||
|
||||
handleChatScroll(host, createScrollEvent(2000, 1540, 400));
|
||||
|
||||
expect(host.chatFollowLocked).toBe(true);
|
||||
expect(host.chatUserNearBottom).toBe(false);
|
||||
|
||||
scheduleChatScroll(host);
|
||||
await host.updateComplete;
|
||||
|
||||
expect(container.scrollTop).toBe(1540);
|
||||
expect(host.chatNewMessagesBelow).toBe(true);
|
||||
|
||||
host.chatIsProgrammaticScroll = false;
|
||||
container.scrollTop = 1600;
|
||||
handleChatScroll(host, createScrollEvent(2000, 1600, 400));
|
||||
|
||||
expect(host.chatFollowLocked).toBe(false);
|
||||
expect(host.chatUserNearBottom).toBe(true);
|
||||
expect(host.chatNewMessagesBelow).toBe(false);
|
||||
});
|
||||
|
||||
it("does not re-stick streaming after a small user scroll-up near the bottom", async () => {
|
||||
const { host, container } = createScrollHost({
|
||||
scrollHeight: 2000,
|
||||
scrollTop: 1589,
|
||||
clientHeight: 400,
|
||||
});
|
||||
host.chatHasAutoScrolled = true;
|
||||
host.chatUserNearBottom = true;
|
||||
host.chatIsProgrammaticScroll = true;
|
||||
host.chatProgrammaticScrollTarget = 1800;
|
||||
host.chatLastScrollTop = 1600;
|
||||
|
||||
handleChatScroll(host, createScrollEvent(2000, 1589, 400));
|
||||
|
||||
expect(host.chatFollowLocked).toBe(true);
|
||||
expect(host.chatUserNearBottom).toBe(false);
|
||||
|
||||
scheduleChatScroll(host);
|
||||
await host.updateComplete;
|
||||
|
||||
expect(container.scrollTop).toBe(1589);
|
||||
expect(host.chatNewMessagesBelow).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT scroll automatically when chat auto-scroll is off", async () => {
|
||||
const { host, container } = createScrollHost({
|
||||
scrollHeight: 2000,
|
||||
@@ -360,6 +434,7 @@ describe("resetChatScroll", () => {
|
||||
const { host } = createScrollHost({});
|
||||
host.chatHasAutoScrolled = true;
|
||||
host.chatUserNearBottom = false;
|
||||
host.chatFollowLocked = true;
|
||||
host.chatLastScrollTop = 300;
|
||||
host.chatHeaderControlsHidden = true;
|
||||
|
||||
@@ -367,6 +442,7 @@ describe("resetChatScroll", () => {
|
||||
|
||||
expect(host.chatHasAutoScrolled).toBe(false);
|
||||
expect(host.chatUserNearBottom).toBe(true);
|
||||
expect(host.chatFollowLocked).toBe(false);
|
||||
expect(host.chatLastScrollTop).toBe(0);
|
||||
expect(host.chatHeaderControlsHidden).toBe(false);
|
||||
expect(host.chatIsProgrammaticScroll).toBe(false);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { normalizeChatAutoScrollMode, type ChatAutoScrollMode } from "./storage.
|
||||
|
||||
/** Distance (px) from the bottom within which we consider the user "near bottom". */
|
||||
const NEAR_BOTTOM_THRESHOLD = 450;
|
||||
const FOLLOW_REACQUIRE_THRESHOLD = 8;
|
||||
const HEADER_HIDE_SCROLL_DELTA = 12;
|
||||
const HEADER_SHOW_TOP_THRESHOLD = 24;
|
||||
|
||||
@@ -15,6 +16,7 @@ type ScrollHost = {
|
||||
chatLastScrollTop: number;
|
||||
chatHasAutoScrolled: boolean;
|
||||
chatUserNearBottom: boolean;
|
||||
chatFollowLocked: boolean;
|
||||
chatHeaderControlsHidden: boolean;
|
||||
chatNewMessagesBelow: boolean;
|
||||
chatIsProgrammaticScroll: boolean;
|
||||
@@ -85,8 +87,8 @@ export function scheduleChatScroll(
|
||||
autoScrollMode === "always" ||
|
||||
(autoScrollMode === "near-bottom" &&
|
||||
(effectiveForce ||
|
||||
host.chatUserNearBottom ||
|
||||
distanceFromBottom < NEAR_BOTTOM_THRESHOLD));
|
||||
(!host.chatFollowLocked &&
|
||||
(host.chatUserNearBottom || distanceFromBottom < NEAR_BOTTOM_THRESHOLD))));
|
||||
|
||||
if (!shouldStick) {
|
||||
// User is scrolled up — flag that new content arrived below.
|
||||
@@ -96,6 +98,7 @@ export function scheduleChatScroll(
|
||||
if (effectiveForce) {
|
||||
host.chatHasAutoScrolled = true;
|
||||
}
|
||||
host.chatFollowLocked = false;
|
||||
const smoothEnabled =
|
||||
smooth &&
|
||||
(typeof window === "undefined" ||
|
||||
@@ -129,8 +132,8 @@ export function scheduleChatScroll(
|
||||
autoScrollMode === "always" ||
|
||||
(autoScrollMode === "near-bottom" &&
|
||||
(effectiveForce ||
|
||||
host.chatUserNearBottom ||
|
||||
latestDistanceFromBottom < NEAR_BOTTOM_THRESHOLD));
|
||||
(!host.chatFollowLocked &&
|
||||
(host.chatUserNearBottom || latestDistanceFromBottom < NEAR_BOTTOM_THRESHOLD))));
|
||||
if (!shouldStickRetry) {
|
||||
return;
|
||||
}
|
||||
@@ -207,21 +210,29 @@ export function handleChatScroll(host: ScrollHost, event: Event) {
|
||||
// Only suppress if scrollTop is still at or above the position we scrolled to;
|
||||
// if it dropped below, the user scrolled up during the guard window and we must
|
||||
// process the event so streaming stops pinning them back to the bottom.
|
||||
const isUserScrollUp = delta < 0;
|
||||
const isDeliberateScrollUp = delta < -HEADER_HIDE_SCROLL_DELTA;
|
||||
if (
|
||||
host.chatIsProgrammaticScroll &&
|
||||
!isUserScrollUp &&
|
||||
container.scrollTop >= host.chatProgrammaticScrollTarget - container.clientHeight
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
host.chatUserNearBottom = distanceFromBottom < NEAR_BOTTOM_THRESHOLD;
|
||||
if (isUserScrollUp && distanceFromBottom > FOLLOW_REACQUIRE_THRESHOLD) {
|
||||
host.chatFollowLocked = true;
|
||||
} else if (distanceFromBottom <= FOLLOW_REACQUIRE_THRESHOLD) {
|
||||
host.chatFollowLocked = false;
|
||||
}
|
||||
host.chatUserNearBottom = !host.chatFollowLocked && distanceFromBottom < NEAR_BOTTOM_THRESHOLD;
|
||||
const hasUsefulScroll = container.scrollHeight - container.clientHeight > NEAR_BOTTOM_THRESHOLD;
|
||||
|
||||
if (!hasUsefulScroll || scrollTop <= HEADER_SHOW_TOP_THRESHOLD || host.chatUserNearBottom) {
|
||||
host.chatHeaderControlsHidden = false;
|
||||
} else if (delta > HEADER_HIDE_SCROLL_DELTA) {
|
||||
host.chatHeaderControlsHidden = true;
|
||||
} else if (delta < -HEADER_HIDE_SCROLL_DELTA) {
|
||||
} else if (isDeliberateScrollUp) {
|
||||
host.chatHeaderControlsHidden = false;
|
||||
}
|
||||
|
||||
@@ -252,6 +263,7 @@ export function handleActivityScroll(host: ScrollHost, event: Event) {
|
||||
export function resetChatScroll(host: ScrollHost) {
|
||||
host.chatHasAutoScrolled = false;
|
||||
host.chatUserNearBottom = true;
|
||||
host.chatFollowLocked = false;
|
||||
host.chatLastScrollTop = 0;
|
||||
host.chatHeaderControlsHidden = false;
|
||||
host.chatNewMessagesBelow = false;
|
||||
|
||||
@@ -697,6 +697,7 @@ export class OpenClawApp extends LitElement {
|
||||
chatLastScrollTop = 0;
|
||||
chatHasAutoScrolled = false;
|
||||
chatUserNearBottom = true;
|
||||
chatFollowLocked = false;
|
||||
chatIsProgrammaticScroll = false;
|
||||
chatProgrammaticScrollTarget = 0;
|
||||
@state() chatNewMessagesBelow = false;
|
||||
|
||||
Reference in New Issue
Block a user