Compare commits

...

9 Commits

Author SHA1 Message Date
Peter Steinberger
e96087892e fix(discord): keep subagent hooks lazy in channel entry 2026-04-23 09:27:47 +01:00
Peter Steinberger
aef4fc9178 test(docker): make e2e temp logs portable 2026-04-23 08:52:48 +01:00
Peter Steinberger
c9bb56998a perf(discord): narrow monitor runtime imports
(cherry picked from commit e88d8512a7)
2026-04-23 08:43:01 +01:00
Vincent Koc
fdfc901e42 fix(onboarding): surface official WeCom channel install
(cherry picked from commit ce4bb8f638)
2026-04-23 08:42:02 +01:00
Peter Steinberger
5cd79da5b1 chore(release): refresh beta 1 metadata 2026-04-23 08:36:43 +01:00
Peter Steinberger
0ec75a6ab4 chore(release): prepare 2026.4.22 beta 2 2026-04-23 08:22:14 +01:00
Peter Steinberger
435136de8f fix: show fast mode in status
(cherry picked from commit 8714badc0c)
2026-04-23 08:15:06 +01:00
Peter Steinberger
579f00313b chore(release): prepare 2026.4.22 beta 1 2026-04-23 08:12:48 +01:00
Peter Steinberger
bef298d97f fix: resolve implicit default Telegram status sessions
(cherry picked from commit dfca707e4b)
2026-04-23 08:01:24 +01:00
40 changed files with 549 additions and 112 deletions

View File

@@ -25,9 +25,11 @@ Use this skill for release and publish-time workflow. Keep ordinary development
- Before release branching, commit any dirty files in coherent groups, push,
pull/rebase, then run `/changelog` on `main` and commit/push/pull that
changelog rewrite immediately before creating the release branch.
- Do not delete or rewrite beta tags after they leave the machine. If a
published or pushed beta needs a fix, commit the fix on the release branch and
increment to the next `-beta.N`.
- Do not delete or rewrite beta tags after npm publish has completed for that
exact beta version. If a beta tag was only pushed to GitHub and no npm package
was published for it yet, it may be moved/recreated to include late release
fixes when the operator approves that. If npm publish already happened, commit
the fix on the release branch and increment to the next `-beta.N`.
- For a beta release train, run the full pre-npm test roster before publishing
each beta. After a beta is published, run the smaller published-install roster
focused on install/update/Docker/Parallels. If anything fails, fix it on the

View File

@@ -39,6 +39,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Status: show `Fast` in `/status` when fast mode is enabled, including config/default-derived fast mode, and omit it when disabled.
- Models/auth: merge provider-owned default-model additions from `openclaw models auth login` instead of replacing `agents.defaults.models`, so re-authenticating an OAuth provider such as OpenAI Codex no longer wipes other providers' aliases and per-model params. Migrations that must rename keys (Anthropic -> Claude CLI) opt in with `replaceDefaultModels`. Fixes #69414. (#70435) Thanks @neeravmakwana.
- Media understanding/audio: prefer configured or key-backed STT providers before auto-detected local Whisper CLIs, so installed local transcription tools no longer shadow API providers such as Groq/OpenAI in `tools.media.audio` auto mode. Fixes #68727.
- Providers/OpenAI: lock the auth picker wording for OpenAI API key, Codex browser login, and Codex device pairing so the setup choices no longer imply a mixed Codex/API-key auth path. (#67848) Thanks @tmlxrd.

View File

@@ -1,4 +1,4 @@
1d08257f068365d84ea1163baf2bca00484bb2689cd1ad2f80e97d3269b8a318 config-baseline.json
ea6681d3c14934385bda669a2b1de6ca0155a914217bb7cd96d894081bf1dce8 config-baseline.json
a4e167f169db58d71c385a31fa2b980772f9fee963e70dd9553f63536cae5aed config-baseline.core.json
22d7cd6d8279146b2d79c9531a55b80b52a2c99c81338c508104729154fdd02d config-baseline.channel.json
8580cad7a65a9dc04a3e8f98b1e9252992aea2dedff16d5483934e4bc2841d57 config-baseline.channel.json
5b4d18610693d9c4f3cbac51d011b4eb47b0fb11772ba3d2aa3e3499d474260d config-baseline.plugin.json

View File

@@ -582,10 +582,10 @@ plugin on older hosts.
Exact npm version pinning already lives in `npmSpec`, for example
`"npmSpec": "@wecom/wecom-openclaw-plugin@1.2.3"`. Pair that with
`expectedIntegrity` when you want update flows to fail closed if the fetched
npm artifact no longer matches the pinned release. Interactive onboarding only
offers npm install choices from trusted catalog metadata when `npmSpec` is an
exact version and `expectedIntegrity` is present; otherwise it falls back to a
local source or skip.
npm artifact no longer matches the pinned release. Interactive onboarding
offers trusted registry npm specs, including bare package names and dist-tags.
When `expectedIntegrity` is present, install/update flows enforce it; when it
is omitted, the registry resolution is recorded without an integrity pin.
Channel plugins should provide `openclaw.setupEntry` when status, channel list,
or SecretRef scans need to identify configured accounts without loading the full

View File

@@ -162,11 +162,11 @@ Interactive onboarding also uses `openclaw.install` for install-on-demand
surfaces. If your plugin exposes provider auth choices or channel setup/catalog
metadata before runtime loads, onboarding can show that choice, prompt for npm
vs local install, install or enable the plugin, then continue the selected
flow. Npm onboarding choices require trusted catalog metadata with an exact
`npmSpec` version and `expectedIntegrity`; unpinned package names and dist-tags
are not offered for automatic onboarding installs. Keep the "what to show"
metadata in `openclaw.plugin.json` and the "how to install it" metadata in
`package.json`.
flow. Npm onboarding choices require trusted catalog metadata with a registry
`npmSpec`; exact versions and `expectedIntegrity` are optional pins. If
`expectedIntegrity` is present, install/update flows enforce it. Keep the "what
to show" metadata in `openclaw.plugin.json` and the "how to install it"
metadata in `package.json`.
If `minHostVersion` is set, install and manifest-registry loading both enforce
it. Older hosts skip the plugin; invalid version strings are rejected.

View File

@@ -68,6 +68,7 @@ title: "Thinking Levels"
- For direct public `anthropic/*` requests, including OAuth-authenticated traffic sent to `api.anthropic.com`, fast mode maps to Anthropic service tiers: `/fast on` sets `service_tier=auto`, `/fast off` sets `service_tier=standard_only`.
- For `minimax/*` on the Anthropic-compatible path, `/fast on` (or `params.fastMode: true`) rewrites `MiniMax-M2.7` to `MiniMax-M2.7-highspeed`.
- Explicit Anthropic `serviceTier` / `service_tier` model params override the fast-mode default when both are set. OpenClaw still skips Anthropic service-tier injection for non-Anthropic proxy base URLs.
- `/status` shows `Fast` only when fast mode is enabled.
## Verbose directives (/verbose or /v)

View File

@@ -175,15 +175,15 @@ vi.spyOn(conversationRuntimeModule, "recordInboundSession").mockImplementation(
recordInboundSession(params) as never) as never,
);
const configRuntimeModule = await import("openclaw/plugin-sdk/config-runtime");
vi.spyOn(configRuntimeModule, "readSessionUpdatedAt").mockImplementation(
((params: Parameters<typeof configRuntimeModule.readSessionUpdatedAt>[0]) =>
const sessionStoreRuntimeModule = await import("openclaw/plugin-sdk/session-store-runtime");
vi.spyOn(sessionStoreRuntimeModule, "readSessionUpdatedAt").mockImplementation(
((params: Parameters<typeof sessionStoreRuntimeModule.readSessionUpdatedAt>[0]) =>
configSessionsMocks.readSessionUpdatedAt(params) as never) as never,
);
vi.spyOn(configRuntimeModule, "resolveStorePath").mockImplementation(
vi.spyOn(sessionStoreRuntimeModule, "resolveStorePath").mockImplementation(
((
path: Parameters<typeof configRuntimeModule.resolveStorePath>[0],
opts: Parameters<typeof configRuntimeModule.resolveStorePath>[1],
path: Parameters<typeof sessionStoreRuntimeModule.resolveStorePath>[0],
opts: Parameters<typeof sessionStoreRuntimeModule.resolveStorePath>[1],
) => configSessionsMocks.resolveStorePath(path, opts) as never) as never,
);

View File

@@ -21,14 +21,10 @@ import {
resolveChannelStreamingBlockEnabled,
resolveChannelStreamingPreviewToolProgress,
} from "openclaw/plugin-sdk/channel-streaming";
import {
isDangerousNameMatchingEnabled,
readSessionUpdatedAt,
resolveChannelContextVisibilityMode,
resolveMarkdownTableMode,
resolveStorePath,
} from "openclaw/plugin-sdk/config-runtime";
import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/context-visibility-runtime";
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import { resolveChunkMode } from "openclaw/plugin-sdk/reply-chunking";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-dispatch-runtime";
@@ -41,6 +37,7 @@ import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-pay
import { buildAgentSessionKey, resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import { evaluateSupplementalContextVisibility } from "openclaw/plugin-sdk/security-runtime";
import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
import {
convertMarkdownTables,
stripInlineDirectiveTagsForDelivery,

View File

@@ -3,8 +3,8 @@ import {
createChannelInboundDebouncer,
shouldDebounceTextInbound,
} from "openclaw/plugin-sdk/channel-inbound";
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/config-runtime";
import { danger } from "openclaw/plugin-sdk/runtime-env";
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
import {
buildDiscordInboundReplayKey,
claimDiscordInboundReplay,

View File

@@ -2,12 +2,12 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { ChannelType } from "discord-api-types/v10";
import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime";
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
type OpenClawConfig,
} from "openclaw/plugin-sdk/config-runtime";
import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime";
} from "openclaw/plugin-sdk/runtime-config-snapshot";
import { beforeEach, describe, expect, it, vi } from "vitest";
const hoisted = vi.hoisted(() => {

View File

@@ -25,9 +25,3 @@ export function registerDiscordSubagentHooks(api: OpenClawPluginApi): void {
return handleDiscordSubagentDeliveryTarget(event);
});
}
export {
handleDiscordSubagentDeliveryTarget,
handleDiscordSubagentEnded,
handleDiscordSubagentSpawning,
} from "./src/subagent-hooks.js";

View File

@@ -7,10 +7,11 @@ import type { VoiceCallProvider } from "../providers/base.js";
import type { HangupCallInput, NormalizedEvent } from "../types.js";
import type { CallManagerContext } from "./context.js";
import { processEvent } from "./events.js";
import { flushPendingCallRecordWritesForTest } from "./store.js";
const contexts: CallManagerContext[] = [];
afterEach(() => {
afterEach(async () => {
for (const ctx of contexts.splice(0)) {
for (const timer of ctx.maxDurationTimers.values()) {
clearTimeout(timer);
@@ -20,6 +21,7 @@ afterEach(() => {
clearTimeout(waiter.timeout);
}
ctx.transcriptWaiters.clear();
await flushPendingCallRecordWritesForTest();
fs.rmSync(ctx.storePath, { recursive: true, force: true });
}
});

View File

@@ -3,13 +3,25 @@ import fsp from "node:fs/promises";
import path from "node:path";
import { CallRecordSchema, TerminalStates, type CallId, type CallRecord } from "../types.js";
const pendingPersistWrites = new Set<Promise<void>>();
export function persistCallRecord(storePath: string, call: CallRecord): void {
const logPath = path.join(storePath, "calls.jsonl");
const line = `${JSON.stringify(call)}\n`;
// Fire-and-forget async write to avoid blocking event loop.
fsp.appendFile(logPath, line).catch((err) => {
console.error("[voice-call] Failed to persist call record:", err);
});
const write = fsp
.appendFile(logPath, line)
.catch((err) => {
console.error("[voice-call] Failed to persist call record:", err);
})
.finally(() => {
pendingPersistWrites.delete(write);
});
pendingPersistWrites.add(write);
}
export async function flushPendingCallRecordWritesForTest(): Promise<void> {
await Promise.allSettled(pendingPersistWrites);
}
export function loadActiveCallsFromStore(storePath: string): {

View File

@@ -1,6 +1,6 @@
{
"name": "openclaw",
"version": "2026.4.22",
"version": "2026.4.22-beta.1",
"description": "Multi-channel AI gateway with extensible messaging integrations",
"keywords": [],
"homepage": "https://github.com/openclaw/openclaw#readme",

View File

@@ -53,7 +53,7 @@ prepare_package_tgz() {
prepare_package_tgz
DOCKER_PACKAGE_TGZ="/tmp/openclaw-current.tgz"
run_log="$(mktemp "${TMPDIR:-/tmp}/openclaw-npm-onboard-channel-agent.XXXXXX.log")"
run_log="$(mktemp "${TMPDIR:-/tmp}/openclaw-npm-onboard-channel-agent.XXXXXX")"
echo "Running npm tarball onboard/channel/agent Docker E2E ($CHANNEL)..."
if ! docker run --rm \

View File

@@ -16,7 +16,7 @@ if [[ -n "${OPENAI_BASE_URL:-}" && "${OPENAI_BASE_URL:-}" != "undefined" && "${O
fi
echo "Running plugins Docker E2E..."
RUN_LOG="$(mktemp "${TMPDIR:-/tmp}/openclaw-plugins-run.XXXXXX.log")"
RUN_LOG="$(mktemp "${TMPDIR:-/tmp}/openclaw-plugins-run.XXXXXX")"
if ! docker run --rm "${DOCKER_ENV_ARGS[@]}" -i "$IMAGE_NAME" bash -s >"$RUN_LOG" 2>&1 <<'EOF'
set -euo pipefail

View File

@@ -4,7 +4,9 @@ run_logged() {
local label="$1"
shift
local log_file
log_file="$(mktemp "${TMPDIR:-/tmp}/openclaw-${label}.XXXXXX.log")"
local tmp_dir="${TMPDIR:-/tmp}"
tmp_dir="${tmp_dir%/}"
log_file="$(mktemp "$tmp_dir/openclaw-${label}.XXXXXX")"
if ! "$@" >"$log_file" 2>&1; then
cat "$log_file"
rm -f "$log_file"

View File

@@ -0,0 +1,27 @@
{
"entries": [
{
"name": "@wecom/wecom-openclaw-plugin",
"description": "OpenClaw WeCom channel plugin by the Tencent WeCom team.",
"source": "external",
"kind": "channel",
"openclaw": {
"channel": {
"id": "wecom",
"label": "WeCom",
"selectionLabel": "WeCom (Enterprise WeChat)",
"detailLabel": "WeCom",
"docsPath": "/plugins/community#wecom",
"docsLabel": "wecom",
"blurb": "Enterprise WeChat bot and conversation channel.",
"aliases": ["qywx", "wework", "enterprise-wechat"],
"order": 45
},
"install": {
"npmSpec": "@wecom/wecom-openclaw-plugin",
"defaultChoice": "npm"
}
}
}
]
}

View File

@@ -144,7 +144,15 @@ function assertEntryFileExists(entry) {
async function smokeChannelEntry(entryFile) {
assertEntryFileExists(entryFile);
const entry = (await importBuiltModule(entryFile.path)).default;
let entry;
try {
entry = (await importBuiltModule(entryFile.path)).default;
} catch (error) {
throw new Error(
`${entryFile.id} ${entryFile.kind} entry failed to import ${entryFile.path}: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
assert.equal(entry.kind, "bundled-channel-entry", `${entryFile.id} channel entry kind mismatch`);
assert.equal(
typeof entry.loadChannelPlugin,
@@ -163,7 +171,15 @@ async function smokeChannelEntry(entryFile) {
async function smokeSetupEntry(entryFile) {
assertEntryFileExists(entryFile);
const entry = (await importBuiltModule(entryFile.path)).default;
let entry;
try {
entry = (await importBuiltModule(entryFile.path)).default;
} catch (error) {
throw new Error(
`${entryFile.id} ${entryFile.kind} entry failed to import ${entryFile.path}: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
if (entry?.kind !== "bundled-channel-setup-entry") {
return false;
}

View File

@@ -1,6 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import officialExternalChannelCatalog from "./lib/official-external-channel-catalog.json" with { type: "json" };
import { isRecord, trimString } from "./lib/record-shared.mjs";
import { writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs";
@@ -13,9 +14,14 @@ function toCatalogInstall(value, packageName) {
return null;
}
const defaultChoice = trimString(install.defaultChoice);
const minHostVersion = trimString(install.minHostVersion);
const expectedIntegrity = trimString(install.expectedIntegrity);
return {
npmSpec,
...(defaultChoice === "npm" || defaultChoice === "local" ? { defaultChoice } : {}),
...(minHostVersion ? { minHostVersion } : {}),
...(expectedIntegrity ? { expectedIntegrity } : {}),
...(install.allowInvalidConfigRecovery === true ? { allowInvalidConfigRecovery: true } : {}),
};
}
@@ -50,7 +56,9 @@ function buildCatalogEntry(packageJson) {
export function buildOfficialChannelCatalog(params = {}) {
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
const extensionsRoot = path.join(repoRoot, "extensions");
const entries = [];
const entries = Array.isArray(officialExternalChannelCatalog.entries)
? [...officialExternalChannelCatalog.entries]
: [];
if (!fs.existsSync(extensionsRoot)) {
return { entries };
}

View File

@@ -467,6 +467,55 @@ describe("session_status tool", () => {
expect(details.sessionKey).toBe("main");
});
it("falls back from implicit default-account direct policy keys to persisted direct sessions", async () => {
resetSessionStore({
"agent:main:telegram:direct:1053274893": {
sessionId: "s-direct",
updatedAt: 10,
},
});
const tool = getSessionStatusTool("agent:main:telegram:default:direct:1053274893");
const result = await tool.execute("call-default-direct", {});
const details = result.details as { ok?: boolean; sessionKey?: string };
expect(details.ok).toBe(true);
expect(details.sessionKey).toBe("agent:main:telegram:direct:1053274893");
});
it("falls back from implicit default-account direct policy keys to main sessions", async () => {
resetSessionStore({
"agent:main:main": {
sessionId: "s-main",
updatedAt: 10,
},
});
const tool = getSessionStatusTool("agent:main:telegram:default:direct:1053274893");
const result = await tool.execute("call-default-direct-main", {});
const details = result.details as { ok?: boolean; sessionKey?: string };
expect(details.ok).toBe(true);
expect(details.sessionKey).toBe("agent:main:main");
});
it("keeps explicit default-account direct session lookups strict", async () => {
resetSessionStore({
"agent:main:main": {
sessionId: "s-main",
updatedAt: 10,
},
});
const tool = getSessionStatusTool("agent:main:telegram:default:direct:1053274893");
await expect(
tool.execute("call-default-direct-explicit", {
sessionKey: "agent:main:telegram:default:direct:1053274893",
}),
).rejects.toThrow("Unknown sessionKey: agent:main:telegram:default:direct:1053274893");
});
it("prefers a literal current session key in session_status", async () => {
resetSessionStore({
main: {

View File

@@ -133,6 +133,33 @@ function resolveStoreScopedRequesterKey(params: {
return parsed.rest === params.mainKey ? params.mainKey : params.requesterKey;
}
function listImplicitDefaultDirectFallbackKeys(params: {
keyRaw: string;
mainKey: string;
}): string[] {
const parsed = parseAgentSessionKey(params.keyRaw.trim());
if (!parsed) {
return [];
}
const parts = parsed.rest.split(":");
if (parts.length < 4 || parts[1] !== "default" || parts[2] !== "direct") {
return [];
}
const [channel, , , ...peerParts] = parts;
if (!channel || peerParts.length === 0) {
return [];
}
const candidates = [
`agent:${parsed.agentId}:${channel}:direct:${peerParts.join(":")}`,
buildAgentMainSessionKey({
agentId: parsed.agentId,
mainKey: params.mainKey,
}),
params.mainKey,
];
return [...new Set(candidates)];
}
function formatSessionTaskLine(params: {
relatedSessionKey: string;
callerOwnerKey: string;
@@ -295,6 +322,7 @@ export function createSessionStatusTool(opts?: {
let requestedKeyRaw = requestedKeyParam ?? opts?.agentSessionKey;
const requestedKeyInput = requestedKeyRaw?.trim() ?? "";
let resolvedViaSessionId = false;
let resolvedViaImplicitCurrentFallback = false;
if (!requestedKeyRaw?.trim()) {
throw new Error("sessionKey required");
}
@@ -402,17 +430,39 @@ export function createSessionStatusTool(opts?: {
});
}
if (!resolved && requestedKeyParam === undefined) {
for (const fallbackKey of listImplicitDefaultDirectFallbackKeys({
keyRaw: requestedKeyRaw,
mainKey,
})) {
resolved = resolveSessionEntry({
store,
keyRaw: fallbackKey,
alias,
mainKey,
requesterInternalKey: storeScopedRequesterKey,
includeAliasFallback: true,
});
if (resolved) {
resolvedViaImplicitCurrentFallback = true;
break;
}
}
}
if (!resolved) {
const kind = shouldResolveSessionIdInput(requestedKeyRaw) ? "sessionId" : "sessionKey";
throw new Error(`Unknown ${kind}: ${requestedKeyRaw}`);
}
// Preserve caller-scoped raw-key/current lookups as "self" for visibility checks.
const visibilityTargetKey =
!resolvedViaSessionId &&
(requestedKeyInput === "current" || resolved.key === requestedKeyInput)
? visibilityRequesterKey
: normalizeVisibilityTargetSessionKey(resolved.key, agentId);
const shouldTreatVisibilityTargetAsSelf =
resolvedViaImplicitCurrentFallback ||
(!resolvedViaSessionId &&
(requestedKeyInput === "current" || resolved.key === requestedKeyInput));
const visibilityTargetKey = shouldTreatVisibilityTargetAsSelf
? visibilityRequesterKey
: normalizeVisibilityTargetSessionKey(resolved.key, agentId);
const access = visibilityGuard.check(visibilityTargetKey);
if (!access.allowed) {
throw new Error(access.error);

View File

@@ -268,6 +268,23 @@ describe("info command handlers", () => {
);
});
it("forwards resolved fast mode to /status", async () => {
const params = buildInfoParams("/status", {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig);
params.resolvedFastMode = true;
const statusResult = await handleStatusCommand(params, true);
expect(statusResult?.shouldContinue).toBe(false);
expect(vi.mocked(buildStatusReply)).toHaveBeenCalledWith(
expect.objectContaining({
resolvedFastMode: true,
}),
);
});
it("uses the canonical target session agent when listing /commands", async () => {
const { handleCommandsListCommand } = await import("./commands-info.js");
const params = buildInfoParams("/commands", {

View File

@@ -204,6 +204,7 @@ export const handleStatusCommand: CommandHandler = async (params, allowTextComma
model: params.model,
contextTokens: params.contextTokens,
resolvedThinkLevel: params.resolvedThinkLevel,
resolvedFastMode: params.resolvedFastMode,
resolvedVerboseLevel: params.resolvedVerboseLevel,
resolvedReasoningLevel: params.resolvedReasoningLevel,
resolvedElevatedLevel: params.resolvedElevatedLevel,

View File

@@ -53,6 +53,7 @@ export type HandleCommandsParams = {
opts?: GetReplyOptions;
defaultGroupActivation: () => "always" | "mention";
resolvedThinkLevel?: ThinkLevel;
resolvedFastMode?: boolean;
resolvedVerboseLevel: VerboseLevel;
resolvedReasoningLevel: ReasoningLevel;
resolvedElevatedLevel?: ElevatedLevel;

View File

@@ -276,7 +276,24 @@ describe("buildStatusMessage", () => {
queue: { mode: "collect", depth: 0 },
});
expect(normalizeTestText(text)).toContain("Fast: on");
expect(normalizeTestText(text)).toContain("Fast");
});
it("hides fast mode when disabled", () => {
const text = buildStatusMessage({
agent: {
model: "anthropic/claude-opus-4-6",
},
sessionEntry: {
sessionId: "fast-off",
updatedAt: 0,
fastMode: false,
},
sessionKey: "agent:main:main",
queue: { mode: "collect", depth: 0 },
});
expect(normalizeTestText(text)).not.toContain("Fast");
});
it("shows configured text verbosity for the active model", () => {

View File

@@ -1,5 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import officialExternalChannelCatalog from "../../../scripts/lib/official-external-channel-catalog.json" with { type: "json" };
import { MANIFEST_KEY } from "../../compat/legacy-names.js";
import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js";
import { listChannelCatalogEntries } from "../../plugins/channel-catalog-registry.js";
@@ -162,7 +163,9 @@ function resolveOfficialCatalogPaths(options: CatalogOptions): string[] {
}
function loadOfficialCatalogEntries(options: CatalogOptions): ChannelPluginCatalogEntry[] {
return loadCatalogEntriesFromPaths(resolveOfficialCatalogPaths(options))
const builtInEntries = parseCatalogEntries(officialExternalChannelCatalog);
const fileEntries = loadCatalogEntriesFromPaths(resolveOfficialCatalogPaths(options));
return [...builtInEntries, ...fileEntries]
.map((entry) => buildExternalCatalogEntry(entry))
.filter((entry): entry is ChannelPluginCatalogEntry => Boolean(entry));
}

View File

@@ -36,3 +36,9 @@ describeOfficialFallbackChannelCatalogContract({
externalNpmSpec: "@vendor/whatsapp-fork",
externalLabel: "WhatsApp Fork",
});
describeChannelCatalogEntryContract({
channelId: "wecom",
npmSpec: "@wecom/wecom-openclaw-plugin",
alias: "wework",
});

View File

@@ -52,7 +52,7 @@ describe("ensureOnboardingPluginInstalled", () => {
withTimeout.mockImplementation(async <T>(promise: Promise<T>) => await promise);
});
it("passes pinned npm specs and expected integrity to npm installs with progress", async () => {
it("passes npm specs and optional expected integrity to npm installs with progress", async () => {
installPluginFromNpmSpec.mockImplementation(async (params) => {
params.logger?.info?.("Downloading demo-plugin…");
return {
@@ -137,7 +137,7 @@ describe("ensureOnboardingPluginInstalled", () => {
);
});
it("does not offer npm installs without an exact version and integrity pin", async () => {
it("offers registry npm specs without requiring an exact version or integrity pin", async () => {
let captured:
| {
options: Array<{ value: "npm" | "local" | "skip"; label: string; hint?: string }>;
@@ -163,8 +163,11 @@ describe("ensureOnboardingPluginInstalled", () => {
runtime: {} as never,
});
expect(captured?.options).toEqual([{ value: "skip", label: "Skip for now" }]);
expect(captured?.initialValue).toBe("skip");
expect(captured?.options).toEqual([
{ value: "npm", label: "Download from npm (@demo/plugin)" },
{ value: "skip", label: "Skip for now" },
]);
expect(captured?.initialValue).toBe("npm");
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
});

View File

@@ -191,14 +191,13 @@ function resolveBundledLocalPath(params: {
);
}
function resolvePinnedNpmSpecForOnboarding(install: PluginPackageInstall): string | null {
function resolveNpmSpecForOnboarding(install: PluginPackageInstall): string | null {
const npmSpec = install.npmSpec?.trim();
const expectedIntegrity = install.expectedIntegrity?.trim();
if (!npmSpec || !expectedIntegrity) {
if (!npmSpec) {
return null;
}
const parsed = parseRegistryNpmSpec(npmSpec);
return parsed?.selectorKind === "exact-version" ? npmSpec : null;
return parsed ? npmSpec : null;
}
function resolveInstallDefaultChoice(params: {
@@ -241,7 +240,7 @@ async function promptInstallChoice(params: {
defaultChoice: InstallChoice;
prompter: WizardPrompter;
}): Promise<InstallChoice> {
const npmSpec = resolvePinnedNpmSpecForOnboarding(params.entry.install);
const npmSpec = resolveNpmSpecForOnboarding(params.entry.install);
const safeLabel = sanitizeTerminalText(params.entry.label);
const safeNpmSpec = npmSpec ? sanitizeTerminalText(npmSpec) : null;
const safeLocalPath = params.localPath ? sanitizeTerminalText(params.localPath) : null;
@@ -399,7 +398,7 @@ export async function ensureOnboardingPluginInstalled(params: {
workspaceDir,
allowLocal,
});
const npmSpec = resolvePinnedNpmSpecForOnboarding(entry.install);
const npmSpec = resolveNpmSpecForOnboarding(entry.install);
const defaultChoice = resolveInstallDefaultChoice({
cfg: next,
entry,

View File

@@ -893,6 +893,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
},
additionalProperties: false,
},
toolProgress: {
type: "boolean",
},
},
additionalProperties: false,
},
@@ -2066,6 +2069,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
},
additionalProperties: false,
},
toolProgress: {
type: "boolean",
},
},
additionalProperties: false,
},
@@ -3081,6 +3087,10 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
label: "Discord Draft Chunk Break Preference",
help: "Preferred breakpoints for Discord draft chunks (paragraph | newline | sentence). Default: paragraph.",
},
"streaming.preview.toolProgress": {
label: "Discord Draft Tool Progress",
help: "Show tool/progress activity in the live draft preview message (default: true). Set false to keep tool updates as separate messages.",
},
"retry.attempts": {
label: "Discord Retry Attempts",
help: "Max retry attempts for outbound Discord API calls (default: 3).",
@@ -9417,6 +9427,27 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
],
},
},
groupAllowFrom: {
type: "array",
items: {
anyOf: [
{
type: "string",
},
{
type: "number",
},
],
},
},
dmPolicy: {
type: "string",
enum: ["open", "allowlist", "disabled"],
},
groupPolicy: {
type: "string",
enum: ["open", "allowlist", "disabled"],
},
systemPrompt: {
type: "string",
},
@@ -9479,42 +9510,41 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
},
],
},
tts: {
execApprovals: {
type: "object",
properties: {
enabled: {
type: "boolean",
anyOf: [
{
type: "boolean",
},
{
type: "string",
const: "auto",
},
],
},
provider: {
type: "string",
},
baseUrl: {
type: "string",
},
apiKey: {
type: "string",
},
model: {
type: "string",
},
voice: {
type: "string",
},
authStyle: {
type: "string",
enum: ["bearer", "api-key"],
},
queryParams: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
approvers: {
type: "array",
items: {
type: "string",
},
},
speed: {
type: "number",
agentFilter: {
type: "array",
items: {
type: "string",
},
},
sessionFilter: {
type: "array",
items: {
type: "string",
},
},
target: {
type: "string",
enum: ["dm", "channel", "both"],
},
},
additionalProperties: false,
@@ -9637,6 +9667,27 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
],
},
},
groupAllowFrom: {
type: "array",
items: {
anyOf: [
{
type: "string",
},
{
type: "number",
},
],
},
},
dmPolicy: {
type: "string",
enum: ["open", "allowlist", "disabled"],
},
groupPolicy: {
type: "string",
enum: ["open", "allowlist", "disabled"],
},
systemPrompt: {
type: "string",
},
@@ -9699,6 +9750,45 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
},
],
},
execApprovals: {
type: "object",
properties: {
enabled: {
anyOf: [
{
type: "boolean",
},
{
type: "string",
const: "auto",
},
],
},
approvers: {
type: "array",
items: {
type: "string",
},
},
agentFilter: {
type: "array",
items: {
type: "string",
},
},
sessionFilter: {
type: "array",
items: {
type: "string",
},
},
target: {
type: "string",
enum: ["dm", "channel", "both"],
},
},
additionalProperties: false,
},
},
additionalProperties: {},
},
@@ -10866,6 +10956,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
},
additionalProperties: false,
},
toolProgress: {
type: "boolean",
},
},
additionalProperties: false,
},
@@ -11775,6 +11868,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
},
additionalProperties: false,
},
toolProgress: {
type: "boolean",
},
},
additionalProperties: false,
},
@@ -12311,6 +12407,10 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
label: "Slack Native Streaming",
help: "Enable native Slack text streaming (chat.startStream/chat.appendStream/chat.stopStream) when channels.slack.streaming.mode is partial (default: true). Requires a reply thread target; top-level DMs stay on the non-thread fallback path.",
},
"streaming.preview.toolProgress": {
label: "Slack Draft Tool Progress",
help: "Show tool/progress activity in the live draft preview message (default: true). Set false to keep tool updates as separate messages.",
},
"thread.historyScope": {
label: "Slack Thread History Scope",
help: 'Scope for Slack thread history context ("thread" isolates per thread; "channel" reuses channel history).',
@@ -13058,6 +13158,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
},
additionalProperties: false,
},
toolProgress: {
type: "boolean",
},
},
additionalProperties: false,
},
@@ -14096,6 +14199,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
},
additionalProperties: false,
},
toolProgress: {
type: "boolean",
},
},
additionalProperties: false,
},
@@ -14498,6 +14604,10 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
label: "Telegram Draft Chunk Break Preference",
help: "Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence).",
},
"streaming.preview.toolProgress": {
label: "Telegram Draft Tool Progress",
help: "Show tool/progress activity in the live draft preview message (default: true). Set false to keep tool updates as separate messages.",
},
"retry.attempts": {
label: "Telegram Retry Attempts",
help: "Max retry attempts for outbound Telegram API calls (default: 3).",

View File

@@ -27728,6 +27728,6 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
tags: ["advanced", "url-secret"],
},
},
version: "2026.4.22",
version: "2026.4.22-beta.1",
generatedAt: "2026-03-22T21:17:33.302Z",
};

View File

@@ -1,4 +1,11 @@
const LEGACY_QA_LAB_DIR = ["qa", "lab"].join("-");
const LEGACY_QA_CHANNEL_DIR = ["qa", "channel"].join("-");
export const LEGACY_QA_CHANNEL_RUNTIME_API_PATH = [
"dist",
"extensions",
LEGACY_QA_CHANNEL_DIR,
"runtime-api.js",
].join("/");
type NpmUpdateCompatSidecar = {
path: string;
@@ -9,7 +16,7 @@ const EMPTY_RUNTIME_SIDECAR = "export {};\n";
export const NPM_UPDATE_COMPAT_SIDECARS = [
{
path: "dist/extensions/qa-channel/runtime-api.js",
path: LEGACY_QA_CHANNEL_RUNTIME_API_PATH,
content: EMPTY_RUNTIME_SIDECAR,
},
{

View File

@@ -1,9 +1,12 @@
import fs from "node:fs/promises";
import path from "node:path";
import { NPM_UPDATE_COMPAT_SIDECAR_PATHS } from "./npm-update-compat-sidecars.js";
import {
LEGACY_QA_CHANNEL_RUNTIME_API_PATH,
NPM_UPDATE_COMPAT_SIDECAR_PATHS,
} from "./npm-update-compat-sidecars.js";
export const PACKAGE_DIST_INVENTORY_RELATIVE_PATH = "dist/postinstall-inventory.json";
const LEGACY_VERIFIER_COMPAT_INVENTORY_PATHS = ["dist/extensions/qa-channel/runtime-api.js"];
const LEGACY_VERIFIER_COMPAT_INVENTORY_PATHS = [LEGACY_QA_CHANNEL_RUNTIME_API_PATH];
const LEGACY_QA_LAB_DIR = ["qa", "lab"].join("-");
const OMITTED_QA_EXTENSION_PREFIXES = [
"dist/extensions/qa-channel/",

View File

@@ -1,2 +1,6 @@
export { getRuntimeConfigSnapshot } from "../config/runtime-snapshot.js";
export {
clearRuntimeConfigSnapshot,
getRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
} from "../config/runtime-snapshot.js";
export type { OpenClawConfig } from "../config/types.js";

View File

@@ -219,6 +219,60 @@ describe("provider install catalog", () => {
});
});
it("exposes trusted registry npm specs without requiring an exact version or integrity pin", () => {
discoverOpenClawPlugins.mockReturnValue({
candidates: [
{
idHint: "vllm",
origin: "config",
rootDir: "/Users/test/.openclaw/extensions/vllm",
source: "/Users/test/.openclaw/extensions/vllm/index.js",
packageName: "@openclaw/vllm",
packageDir: "/Users/test/.openclaw/extensions/vllm",
packageManifest: {
install: {
npmSpec: "@openclaw/vllm",
},
},
},
],
diagnostics: [],
});
loadPluginManifest.mockReturnValue({
ok: true,
manifestPath: "/Users/test/.openclaw/extensions/vllm/openclaw.plugin.json",
manifest: {
id: "vllm",
configSchema: {
type: "object",
},
},
});
resolveManifestProviderAuthChoices.mockReturnValue([
{
pluginId: "vllm",
providerId: "vllm",
methodId: "server",
choiceId: "vllm",
choiceLabel: "vLLM",
},
]);
expect(resolveProviderInstallCatalogEntry("vllm")).toEqual({
pluginId: "vllm",
providerId: "vllm",
methodId: "server",
choiceId: "vllm",
choiceLabel: "vLLM",
label: "vLLM",
origin: "config",
install: {
npmSpec: "@openclaw/vllm",
defaultChoice: "npm",
},
});
});
it("does not expose npm install specs from untrusted package metadata", () => {
discoverOpenClawPlugins.mockReturnValue({
candidates: [

View File

@@ -53,7 +53,7 @@ function resolvePluginManifest(
return manifest.ok ? manifest : null;
}
function resolveTrustedPinnedNpmSpec(params: {
function resolveTrustedNpmSpec(params: {
origin: PluginOrigin;
install?: PluginPackageInstall;
}): string | undefined {
@@ -61,12 +61,11 @@ function resolveTrustedPinnedNpmSpec(params: {
return undefined;
}
const npmSpec = params.install?.npmSpec?.trim();
const expectedIntegrity = params.install?.expectedIntegrity?.trim();
if (!npmSpec || !expectedIntegrity) {
if (!npmSpec) {
return undefined;
}
const parsed = parseRegistryNpmSpec(npmSpec);
return parsed?.selectorKind === "exact-version" ? npmSpec : undefined;
return parsed ? npmSpec : undefined;
}
function resolveInstallInfo(params: {
@@ -75,7 +74,7 @@ function resolveInstallInfo(params: {
packageDir?: string;
workspaceDir?: string;
}): PluginPackageInstall | null {
const npmSpec = resolveTrustedPinnedNpmSpec({
const npmSpec = resolveTrustedNpmSpec({
origin: params.origin,
install: params.install,
});

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { normalizeTestText } from "../../test/helpers/normalize-text.js";
import { buildStatusMessage } from "./status-message.js";
const buildFastStatus = (model: string, fastMode: boolean) =>
normalizeTestText(
buildStatusMessage({
modelAuth: "api-key",
activeModelAuth: "api-key",
agent: { model },
sessionEntry: {
sessionId: "fast-status",
updatedAt: 0,
fastMode,
},
sessionKey: "agent:main:main",
queue: { mode: "collect", depth: 0 },
}),
);
describe("buildStatusMessage fast mode labels", () => {
it("shows fast mode when enabled", () => {
expect(buildFastStatus("openai/gpt-5.4", true)).toContain("Fast");
});
it("hides fast mode when disabled", () => {
expect(buildFastStatus("anthropic/claude-opus-4-6", false)).not.toContain("Fast");
});
});

View File

@@ -237,6 +237,13 @@ const formatQueueDetails = (queue?: QueueStatus) => {
return detailParts.length ? ` (${detailParts.join(" · ")})` : "";
};
const formatFastModeLabel = (enabled: boolean) => {
if (!enabled) {
return null;
}
return "Fast";
};
const readUsageFromSessionLog = (
sessionId?: string,
sessionEntry?: SessionEntry,
@@ -705,7 +712,7 @@ export function buildStatusMessage(args: StatusArgs): string {
const optionParts = [
`Runtime: ${runtime.label}`,
`Think: ${thinkLevel}`,
fastMode ? "Fast: on" : null,
formatFastModeLabel(fastMode),
textVerbosity ? `Text: ${textVerbosity}` : null,
verboseLabel,
traceLabel,

View File

@@ -68,8 +68,21 @@ describe("buildOfficialChannelCatalog", () => {
},
});
expect(buildOfficialChannelCatalog({ repoRoot })).toEqual({
entries: [
expect(buildOfficialChannelCatalog({ repoRoot }).entries).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "@wecom/wecom-openclaw-plugin",
openclaw: expect.objectContaining({
channel: expect.objectContaining({
id: "wecom",
label: "WeCom",
}),
install: {
npmSpec: "@wecom/wecom-openclaw-plugin",
defaultChoice: "npm",
},
}),
}),
{
name: "@openclaw/whatsapp",
version: "2026.3.23",
@@ -89,8 +102,8 @@ describe("buildOfficialChannelCatalog", () => {
},
},
},
],
});
]),
);
});
it("writes the official catalog under dist", () => {
@@ -118,8 +131,11 @@ describe("buildOfficialChannelCatalog", () => {
const outputPath = path.join(repoRoot, OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH);
expect(fs.existsSync(outputPath)).toBe(true);
expect(JSON.parse(fs.readFileSync(outputPath, "utf8"))).toEqual({
entries: [
expect(JSON.parse(fs.readFileSync(outputPath, "utf8")).entries).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "@wecom/wecom-openclaw-plugin",
}),
{
name: "@openclaw/whatsapp",
openclaw: {
@@ -135,7 +151,7 @@ describe("buildOfficialChannelCatalog", () => {
},
},
},
],
});
]),
);
});
});