Compare commits

..

11 Commits

Author SHA1 Message Date
Tak Hoffman
91625aa9f3 feat capability CLI on latest main 2026-04-06 17:30:59 -05:00
Peter Steinberger
80c8567f9d fix: resolve merge conflicts and preserve runtime test fixes 2026-04-06 22:46:33 +01:00
Peter Steinberger
9d7459f182 fix(auth): recover Codex OAuth refresh after store reload
Co-authored-by: Owen <oh.whenever@gmail.com>
2026-04-06 22:45:04 +01:00
Peter Steinberger
f7109c15f5 refactor: dedupe core account record helpers 2026-04-06 22:44:14 +01:00
Peter Steinberger
16ec0b5a8c style: format doctor memory search helper 2026-04-06 22:44:14 +01:00
Peter Steinberger
5a4ca2f608 refactor: dedupe doctor memory record helper 2026-04-06 22:44:14 +01:00
Peter Steinberger
223a6a1d9f refactor: dedupe doctor channel record helper 2026-04-06 22:44:14 +01:00
Peter Steinberger
b1905c1423 refactor: dedupe qmd manager record helper 2026-04-06 22:44:14 +01:00
Peter Steinberger
9bee2a4ede refactor: dedupe feishu security record helper 2026-04-06 22:44:14 +01:00
Peter Steinberger
0cc4f50576 refactor: dedupe tlon monitor string helper 2026-04-06 22:44:14 +01:00
Peter Steinberger
e88c39b0a1 refactor: dedupe memory-core error formatting 2026-04-06 22:44:14 +01:00
164 changed files with 6982 additions and 673 deletions

View File

@@ -6,15 +6,18 @@ Docs: https://docs.openclaw.ai
### Changes
- CLI/capabilities: add a first-class `openclaw capability ...` hub for provider-backed inference workflows across model, media, web, and embedding tasks, with capability inspection, provider discovery, and consistent JSON output. Thanks @Takhoffman.
- Providers/Anthropic: restore Claude CLI as the preferred local Anthropic path in onboarding, model-auth guidance, and doctor flows again, and keep the Docker Claude CLI live lane aligned with the restored guidance.
- Plugins/webhooks: add a bundled webhook ingress plugin so external automation can create and drive bound TaskFlows through per-route shared-secret endpoints. (#61892) Thanks @mbelinky.
- Tools/media: document per-provider music and video generation capabilities, and add shared live video-to-video sweep coverage for providers that support local reference clips.
### Fixes
- CLI/capabilities: keep provider-backed capability behavior aligned with actual runtime execution by fixing explicit TTS override handling, profile-aware gateway TTS prefs resolution, per-request transcription `prompt`/`language` overrides, image output MIME/extension mismatches, configured web-search fallback behavior, and agent-vs-CLI web-search execution drift.
- Channels/secrets: keep bundled channel artifact and secret-contract loading stable under lazy loading so bundled channel secrets continue to appear in `openclaw secret`, status, and security-audit surfaces.
- Providers/xAI: recognize `api.grok.x.ai` as an xAI-native endpoint again so native xAI web-search attribution keeps working on Grok-hosted base URLs. (#61377) Thanks @jjjojoj.
- Providers/Anthropic/cache: preserve thinking blocks for Claude Opus 4.5+, Sonnet 4.5+, and newer Claude 4-family models so Anthropic prompt-cache prefixes keep matching after thinking turns. (#61793)
- Auth/OpenAI Codex OAuth: reload fresh on-disk credentials inside the locked refresh path and retry once after `refresh_token_reused` rotates only the stored refresh token, so relogin/restart recovery stops getting stuck on stale cached auth state. Thanks @owen-ever.
- Providers/Ollama: honor the selected provider's `baseUrl` during streaming so multi-Ollama setups stop routing every stream to the first configured Ollama endpoint. (#61678)
- Browser/remote CDP: retry the DevTools websocket once after remote browser restarts so healthy remote browser profiles do not fail availability checks during CDP warm-up. (#57397) Thanks @ThanhNguyxn07.
- Memory/vector recall: surface explicit warnings when `sqlite-vec` is unavailable or vector writes are degraded so memory indexing no longer reports false-success while semantic recall is impaired.
@@ -65,7 +68,6 @@ Docs: https://docs.openclaw.ai
- QQ Bot/media: route gateway-side attachment and fallback downloads through guarded QQ/Tencent HTTPS fetches so QQ media handling no longer follows arbitrary remote hosts.
- Exec/runtime events: mark background `notifyOnExit` summaries and ACP parent-stream relays as untrusted system events so lower-trust runtime output no longer re-enters later turns as trusted `System:` text.
- Hooks/wake: queue direct and mapped wake-hook payloads as untrusted system events so external wake content no longer enters the main session as trusted input. (#62003)
- Slack/thread mentions: add `channels.slack.thread.requireExplicitMention` so Slack channels that already require mentions can also require explicit `@bot` mentions inside bot-participated threads. (#58276) Thanks @praktika-engineer.
## 2026.4.5

View File

@@ -361,14 +361,6 @@
}
}
},
"update_plan": {
"emoji": "🗺️",
"title": "Update Plan",
"detailKeys": [
"explanation",
"plan.0.step"
]
},
"gateway": {
"emoji": "🔌",
"title": "Gateway",

View File

@@ -1,4 +1,4 @@
f1aa95045ae4d2bd5aa1790627277655860aafba1da7c87a7ed118f8ae978b17 config-baseline.json
e742d7392b2d9b90a6cd9cdf0fb2878951474f17fdeb2a58d4b44271a83f2153 config-baseline.core.json
d22f4414b79ee03d896e58d875c80523bcc12303cbacb1700261e6ec73945187 config-baseline.channel.json
1891bcb68d80ab8b7546a2946b5a9d82b18c3e92ffd2c834d15928e73fa11564 config-baseline.plugin.json
1c74540dd152c55dbda3e5dee1e37008ee3e6aabb0608e571292832c7a1c012c config-baseline.json
7e30316f2326b7d07b71d7b8a96049a74b81428921299b5c4b5aa3d080e03305 config-baseline.core.json
66edc86a9d16db1b9e9e7dd99b7032e2d9bcfb9ff210256a21f4b4f088cb3dc1 config-baseline.channel.json
d6ebc4948499b997c4a3727cf31849d4a598de9f1a4c197417dcc0b0ec1b734f config-baseline.plugin.json

View File

@@ -399,7 +399,7 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r
- explicit app mention (`<@botId>`)
- mention regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`)
- implicit reply-to-bot thread behavior (disabled when `thread.requireExplicitMention` is `true`)
- implicit reply-to-bot thread behavior
Per-channel controls (`channels.slack.channels.<id>`; names only via startup resolution or `dangerouslyAllowNameMatching`):
@@ -423,7 +423,6 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r
- Thread replies can create thread session suffixes (`:thread:<threadTs>`) when applicable.
- `channels.slack.thread.historyScope` default is `thread`; `thread.inheritParent` default is `false`.
- `channels.slack.thread.initialHistoryLimit` controls how many existing thread messages are fetched when a new thread session starts (default `20`; set `0` to disable).
- `channels.slack.thread.requireExplicitMention` (default `false`): when `true`, suppress implicit thread mentions so the bot only responds to explicit `@bot` mentions inside threads, even when the bot already participated in the thread. Without this, replies in a bot-participated thread bypass `requireMention` gating.
Reply threading controls:

116
docs/cli/capability.md Normal file
View File

@@ -0,0 +1,116 @@
---
summary: "Capability-first CLI for provider-backed model, media, web, and embedding workflows"
read_when:
- Adding or modifying `openclaw capability` commands
- Designing stable headless capability automation
title: "Capability CLI"
---
# Capability CLI
`openclaw capability` is the canonical headless surface for provider-backed capabilities.
It intentionally exposes capability families, not raw gateway RPC names and not raw agent tool ids.
## Command tree
```text
openclaw capability
list
inspect
model
run
list
inspect
providers
auth login
auth logout
auth status
media
image
generate
edit
describe
describe-many
providers
audio
transcribe
providers
tts
convert
voices
providers
status
enable
disable
set-provider
video
generate
describe
providers
web
search
fetch
providers
memory
embedding
create
providers
```
## Transport
Supported transport flags:
- `--local`
- `--gateway`
Default transport is implicit auto at the command-family level:
- Stateless execution commands default to local.
- Gateway-managed state commands default to gateway.
Examples:
```bash
openclaw capability model run --prompt "hello" --json
openclaw capability media image generate --prompt "friendly lobster" --json
openclaw capability media tts status --json
openclaw capability embedding create --text "hello world" --json
```
## JSON output
Capability commands normalize JSON output under a shared envelope:
```json
{
"ok": true,
"capability": "media.image.generate",
"transport": "local",
"provider": "openai",
"model": "gpt-image-1",
"attempts": [],
"outputs": []
}
```
Top-level fields are stable:
- `ok`
- `capability`
- `transport`
- `provider`
- `model`
- `attempts`
- `outputs`
- `error`
## Notes
- `model run` reuses the agent runtime so provider/model overrides behave like normal agent execution.
- `media tts status` defaults to gateway because it reflects gateway-managed TTS state.

View File

@@ -35,6 +35,7 @@ This page describes the current CLI behavior. If commands change, update this do
- [`logs`](/cli/logs)
- [`system`](/cli/system)
- [`models`](/cli/models)
- [`capability`](/cli/capability)
- [`memory`](/cli/memory)
- [`directory`](/cli/directory)
- [`nodes`](/cli/nodes)
@@ -248,6 +249,16 @@ openclaw [--dev] [--profile <name>] <command>
fallbacks list|add|remove|clear
image-fallbacks list|add|remove|clear
scan
capability
list
inspect
model run|list|inspect|providers|auth login|logout|status
media image generate|edit|describe|describe-many|providers
media audio transcribe|providers
media tts convert|voices|providers|status|enable|disable|set-provider
media video generate|describe|providers
web search|fetch|providers
embedding create|providers
auth add|login|login-github-copilot|setup-token|paste-token
auth order get|set|clear
sandbox

View File

@@ -17,6 +17,13 @@ vi.mock("../runtime-api.js", () => ({
},
}));
vi.mock("./runtime.js", () => ({
ACPX_BACKEND_ID: "acpx",
AcpxRuntime: class {},
createAgentRegistry: vi.fn(() => ({})),
createFileSessionStore: vi.fn(() => ({})),
}));
import { getAcpRuntimeBackend } from "../runtime-api.js";
import { createAcpxRuntimeService } from "./service.js";

View File

@@ -18,6 +18,13 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}
export function extractCommentElementText(element: unknown): string | undefined {
if (!isRecord(element)) {
return undefined;

View File

@@ -1,12 +1,6 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/setup";
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}
import { asRecord } from "./comment-shared.js";
function hasNonEmptyString(value: unknown): boolean {
return typeof value === "string" && value.trim().length > 0;

View File

@@ -462,6 +462,10 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
: "channel",
),
},
resolveReplyTransport: ({ threadId, replyToId }) => ({
replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined),
threadId,
}),
},
security: mattermostSecurityAdapter,
outbound: {

View File

@@ -1 +0,0 @@
export { MattermostChannelConfigSchema } from "./config-surface.js";

View File

@@ -13,7 +13,7 @@ import {
} from "openclaw/plugin-sdk/memory-core-host-status";
import { writeDailyDreamingPhaseBlock } from "./dreaming-markdown.js";
import { generateAndAppendDreamNarrative, type NarrativePhaseData } from "./dreaming-narrative.js";
import { asRecord, normalizeTrimmedString } from "./dreaming-shared.js";
import { asRecord, formatErrorMessage, normalizeTrimmedString } from "./dreaming-shared.js";
import {
readShortTermRecallEntries,
recordDreamingPhaseSignals,
@@ -99,13 +99,6 @@ const MANAGED_DAILY_DREAMING_BLOCKS = [
},
] as const;
function formatErrorMessage(err: unknown): string {
if (err instanceof Error) {
return err.message;
}
return String(err);
}
function buildCronDescription(params: {
tag: string;
phase: "light" | "rem";

View File

@@ -12,3 +12,7 @@ export function normalizeTrimmedString(value: unknown): string | undefined {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
export function formatErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}

View File

@@ -13,7 +13,7 @@ import {
import { writeDeepDreamingReport } from "./dreaming-markdown.js";
import { generateAndAppendDreamNarrative, type NarrativePhaseData } from "./dreaming-narrative.js";
import { runDreamingSweepPhases } from "./dreaming-phases.js";
import { asRecord, normalizeTrimmedString } from "./dreaming-shared.js";
import { asRecord, formatErrorMessage, normalizeTrimmedString } from "./dreaming-shared.js";
import {
applyShortTermPromotions,
repairShortTermPromotionArtifacts,
@@ -104,13 +104,6 @@ type ReconcileResult =
| { status: "updated"; removed: number }
| { status: "noop"; removed: number };
function formatErrorMessage(err: unknown): string {
if (err instanceof Error) {
return err.message;
}
return String(err);
}
function formatRepairSummary(repair: {
rewroteStore: boolean;
removedInvalidEntries: number;

View File

@@ -12,6 +12,7 @@ import {
type MemoryEmbeddingProviderCreateOptions,
type MemoryEmbeddingProviderRuntime,
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { formatErrorMessage } from "../dreaming-shared.js";
import { canAutoSelectLocal } from "./provider-adapters.js";
export {
@@ -43,10 +44,6 @@ type CreateEmbeddingProviderOptions = MemoryEmbeddingProviderCreateOptions & {
fallback: EmbeddingProviderFallback;
};
function formatErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function formatProviderError(adapter: MemoryEmbeddingProviderAdapter, err: unknown): string {
return adapter.formatSetupError?.(err) ?? formatErrorMessage(err);
}

View File

@@ -21,6 +21,7 @@ import {
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { resolveUserPath } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
import { getProviderEnvVars } from "openclaw/plugin-sdk/provider-env-vars";
import { formatErrorMessage } from "../dreaming-shared.js";
import { filterUnregisteredMemoryEmbeddingProviderAdapters } from "./provider-adapter-registration.js";
export type BuiltinMemoryEmbeddingProviderDoctorMetadata = {
@@ -31,10 +32,6 @@ export type BuiltinMemoryEmbeddingProviderDoctorMetadata = {
autoSelectPriority?: number;
};
function formatErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function isMissingApiKeyError(err: unknown): boolean {
return formatErrorMessage(err).includes("No API key found for provider");
}

View File

@@ -41,6 +41,7 @@ import {
type ResolvedQmdConfig,
type ResolvedQmdMcporterConfig,
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import { asRecord } from "../dreaming-shared.js";
import { resolveQmdCollectionPatternFlags, type QmdCollectionPatternFlag } from "./qmd-compat.js";
type SqliteDatabase = import("node:sqlite").DatabaseSync;
@@ -1759,16 +1760,14 @@ export class QmdMemoryManager implements MemorySearchManager {
}
const parsedUnknown: unknown = JSON.parse(result.stdout);
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const structured =
isRecord(parsedUnknown) && isRecord(parsedUnknown.structuredContent)
asRecord(parsedUnknown) && asRecord(parsedUnknown.structuredContent)
? parsedUnknown.structuredContent
: parsedUnknown;
const results: unknown[] =
isRecord(structured) && Array.isArray(structured.results)
asRecord(structured) && Array.isArray(structured.results)
? (structured.results as unknown[])
: Array.isArray(structured)
? structured
@@ -1776,7 +1775,7 @@ export class QmdMemoryManager implements MemorySearchManager {
const out: QmdQueryResult[] = [];
for (const item of results) {
if (!isRecord(item)) {
if (!asRecord(item)) {
continue;
}
const docidRaw = item.docid;

View File

@@ -1,5 +1,6 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { buildMicrosoftFoundryProvider } from "./provider.js";
import { buildMicrosoftFoundryRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js";
export default definePluginEntry({
id: "microsoft-foundry",
@@ -7,5 +8,6 @@ export default definePluginEntry({
description: "Microsoft Foundry provider with Entra ID and API key auth",
register(api) {
api.registerProvider(buildMicrosoftFoundryProvider());
api.registerRealtimeTranscriptionProvider(buildMicrosoftFoundryRealtimeTranscriptionProvider());
},
});

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { buildMicrosoftFoundryRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js";
describe("buildMicrosoftFoundryRealtimeTranscriptionProvider", () => {
it("normalizes foundry config from the voice provider block", () => {
const provider = buildMicrosoftFoundryRealtimeTranscriptionProvider();
const resolved = provider.resolveConfig?.({
cfg: {} as never,
rawConfig: {
providers: {
"microsoft-foundry": {
apiKey: "azure-test-key",
baseUrl: "https://example.services.ai.azure.com/openai/v1",
deployment: "gpt-realtime",
apiVersion: "2025-04-01-preview",
},
},
},
});
expect(resolved).toEqual({
apiKey: "azure-test-key",
baseUrl: "https://example.services.ai.azure.com/openai/v1",
deployment: "gpt-realtime",
apiVersion: "2025-04-01-preview",
});
});
it("accepts model-provider style config with api-key headers", () => {
const provider = buildMicrosoftFoundryRealtimeTranscriptionProvider();
const resolved = provider.resolveConfig?.({
cfg: {} as never,
rawConfig: {
providers: {
"microsoft-foundry": {
baseUrl: "https://example.services.ai.azure.com/openai/v1",
headers: {
"api-key": "azure-test-key",
},
model: "gpt-realtime",
},
},
},
});
expect(resolved).toEqual({
apiKey: "azure-test-key",
baseUrl: "https://example.services.ai.azure.com/openai/v1",
deployment: "gpt-realtime",
model: "gpt-realtime",
});
});
it("registers foundry aliases for voice provider selection", () => {
const provider = buildMicrosoftFoundryRealtimeTranscriptionProvider();
expect(provider.aliases).toContain("azure-foundry");
});
});

View File

@@ -0,0 +1,313 @@
import type {
RealtimeTranscriptionProviderConfig,
RealtimeTranscriptionProviderPlugin,
RealtimeTranscriptionSession,
RealtimeTranscriptionSessionCreateRequest,
} from "openclaw/plugin-sdk/realtime-transcription";
import WebSocket from "ws";
import { normalizeFoundryEndpoint, PROVIDER_ID } from "./shared.js";
type FoundryRealtimeTranscriptionProviderConfig = {
apiKey?: string;
baseUrl?: string;
endpoint?: string;
deployment?: string;
model?: string;
apiVersion?: string;
silenceDurationMs?: number;
vadThreshold?: number;
};
type FoundryRealtimeTranscriptionSessionConfig = RealtimeTranscriptionSessionCreateRequest & {
apiKey: string;
baseUrl: string;
deployment: string;
apiVersion: string;
silenceDurationMs: number;
vadThreshold: number;
};
type RealtimeEvent = {
type: string;
delta?: string;
transcript?: string;
error?: unknown;
item?: { transcript?: string } | null;
};
function trimToUndefined(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function asNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function asObject(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function extractFoundryProviderConfig(
rawConfig: RealtimeTranscriptionProviderConfig,
): FoundryRealtimeTranscriptionProviderConfig {
const providers = asObject(rawConfig.providers);
const raw =
asObject(providers?.[PROVIDER_ID]) ??
asObject(rawConfig[PROVIDER_ID]) ??
asObject(rawConfig.microsoftFoundry) ??
asObject(rawConfig);
const providerBaseUrl = trimToUndefined(raw?.baseUrl);
const endpoint = trimToUndefined(raw?.endpoint);
return {
apiKey:
trimToUndefined(raw?.apiKey) ??
trimToUndefined(asObject(raw?.headers)?.["api-key"]) ??
trimToUndefined(asObject(raw?.headers)?.Authorization)?.replace(/^Bearer\s+/i, ""),
baseUrl: providerBaseUrl,
endpoint,
deployment:
trimToUndefined(raw?.deployment) ??
trimToUndefined(raw?.model) ??
trimToUndefined(raw?.deploymentName),
model: trimToUndefined(raw?.transcriptionModel) ?? trimToUndefined(raw?.model),
apiVersion: trimToUndefined(raw?.apiVersion),
silenceDurationMs: asNumber(raw?.silenceDurationMs),
vadThreshold: asNumber(raw?.vadThreshold),
};
}
function resolveFoundryRealtimeBaseUrl(
config: FoundryRealtimeTranscriptionProviderConfig,
): string | undefined {
if (config.endpoint) {
return normalizeFoundryEndpoint(config.endpoint);
}
if (!config.baseUrl) {
return undefined;
}
return normalizeFoundryEndpoint(config.baseUrl);
}
class FoundryRealtimeTranscriptionSession implements RealtimeTranscriptionSession {
private static readonly MAX_RECONNECT_ATTEMPTS = 5;
private static readonly RECONNECT_DELAY_MS = 1000;
private static readonly CONNECT_TIMEOUT_MS = 10_000;
private ws: WebSocket | null = null;
private connected = false;
private closed = false;
private reconnectAttempts = 0;
private pendingTranscript = "";
constructor(private readonly config: FoundryRealtimeTranscriptionSessionConfig) {}
async connect(): Promise<void> {
this.closed = false;
this.reconnectAttempts = 0;
await this.doConnect();
}
sendAudio(audio: Buffer): void {
if (this.ws?.readyState !== WebSocket.OPEN) {
return;
}
this.sendEvent({
type: "input_audio_buffer.append",
audio: audio.toString("base64"),
});
}
close(): void {
this.closed = true;
this.connected = false;
if (this.ws) {
this.ws.close(1000, "Transcription session closed");
this.ws = null;
}
}
isConnected(): boolean {
return this.connected;
}
private async doConnect(): Promise<void> {
await new Promise<void>((resolve, reject) => {
const wsUrl = this.buildWebSocketUrl();
this.ws = new WebSocket(wsUrl, {
headers: {
"api-key": this.config.apiKey,
},
});
const connectTimeout = setTimeout(() => {
reject(new Error("Microsoft Foundry realtime transcription connection timeout"));
}, FoundryRealtimeTranscriptionSession.CONNECT_TIMEOUT_MS);
this.ws.on("open", () => {
clearTimeout(connectTimeout);
this.connected = true;
this.reconnectAttempts = 0;
this.sendEvent({
type: "session.update",
session: {
input_audio_format: "pcm16",
input_audio_transcription: {
model: this.config.deployment,
},
turn_detection: {
type: "server_vad",
threshold: this.config.vadThreshold,
prefix_padding_ms: 300,
silence_duration_ms: this.config.silenceDurationMs,
},
},
});
resolve();
});
this.ws.on("message", (data: Buffer) => {
try {
this.handleEvent(JSON.parse(data.toString()) as RealtimeEvent);
} catch (error) {
this.config.onError?.(error instanceof Error ? error : new Error(String(error)));
}
});
this.ws.on("error", (error) => {
if (!this.connected) {
clearTimeout(connectTimeout);
reject(error);
return;
}
this.config.onError?.(error instanceof Error ? error : new Error(String(error)));
});
this.ws.on("close", () => {
this.connected = false;
if (this.closed) {
return;
}
void this.attemptReconnect();
});
});
}
private buildWebSocketUrl(): string {
const httpBaseUrl = this.config.baseUrl.replace(/\/+$/, "");
const wsBaseUrl = httpBaseUrl.replace(/^http:/i, "ws:").replace(/^https:/i, "wss:");
const url = new URL(`${wsBaseUrl}/openai/realtime`);
url.searchParams.set("api-version", this.config.apiVersion);
url.searchParams.set("deployment", this.config.deployment);
return url.toString();
}
private async attemptReconnect(): Promise<void> {
if (this.closed) {
return;
}
if (this.reconnectAttempts >= FoundryRealtimeTranscriptionSession.MAX_RECONNECT_ATTEMPTS) {
this.config.onError?.(
new Error("Microsoft Foundry realtime transcription reconnect limit reached"),
);
return;
}
this.reconnectAttempts += 1;
const delay =
FoundryRealtimeTranscriptionSession.RECONNECT_DELAY_MS * 2 ** (this.reconnectAttempts - 1);
await new Promise((resolve) => setTimeout(resolve, delay));
if (this.closed) {
return;
}
try {
await this.doConnect();
} catch (error) {
this.config.onError?.(error instanceof Error ? error : new Error(String(error)));
await this.attemptReconnect();
}
}
private handleEvent(event: RealtimeEvent): void {
switch (event.type) {
case "conversation.item.input_audio_transcription.delta":
case "conversation.item.audio_transcription.delta":
if (event.delta) {
this.pendingTranscript += event.delta;
this.config.onPartial?.(this.pendingTranscript);
}
return;
case "conversation.item.input_audio_transcription.completed":
case "conversation.item.audio_transcription.completed": {
const transcript = event.transcript ?? event.item?.transcript;
if (transcript) {
this.config.onTranscript?.(transcript);
}
this.pendingTranscript = "";
return;
}
case "input_audio_buffer.speech_started":
this.pendingTranscript = "";
this.config.onSpeechStart?.();
return;
case "error": {
const detail =
event.error && typeof event.error === "object" && "message" in event.error
? String((event.error as { message?: unknown }).message ?? "Unknown error")
: event.error
? String(event.error)
: "Unknown error";
this.config.onError?.(new Error(detail));
return;
}
default:
return;
}
}
private sendEvent(event: unknown): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(event));
}
}
}
export function buildMicrosoftFoundryRealtimeTranscriptionProvider(): RealtimeTranscriptionProviderPlugin {
return {
id: PROVIDER_ID,
label: "Microsoft Foundry Realtime Transcription",
aliases: ["azure-foundry", "azure-openai-foundry"],
autoSelectOrder: 20,
resolveConfig: ({ rawConfig }) => extractFoundryProviderConfig(rawConfig),
isConfigured: ({ providerConfig }) => {
const config = extractFoundryProviderConfig(providerConfig);
return Boolean(config.apiKey && resolveFoundryRealtimeBaseUrl(config) && config.deployment);
},
createSession: (req) => {
const config = extractFoundryProviderConfig(req.providerConfig);
const baseUrl = resolveFoundryRealtimeBaseUrl(config);
if (!config.apiKey) {
throw new Error("Microsoft Foundry realtime transcription API key missing");
}
if (!baseUrl) {
throw new Error("Microsoft Foundry realtime transcription endpoint missing");
}
if (!config.deployment) {
throw new Error("Microsoft Foundry realtime transcription deployment missing");
}
return new FoundryRealtimeTranscriptionSession({
...req,
apiKey: config.apiKey,
baseUrl,
deployment: config.deployment,
apiVersion: config.apiVersion ?? "2025-04-01-preview",
silenceDurationMs: config.silenceDurationMs ?? 800,
vadThreshold: config.vadThreshold ?? 0.5,
});
},
};
}

View File

@@ -18,6 +18,7 @@ type OpenAIRealtimeTranscriptionProviderConfig = {
model?: string;
silenceDurationMs?: number;
vadThreshold?: number;
inputAudioFormat?: string;
};
type OpenAIRealtimeTranscriptionSessionConfig = RealtimeTranscriptionSessionCreateRequest & {
@@ -25,6 +26,7 @@ type OpenAIRealtimeTranscriptionSessionConfig = RealtimeTranscriptionSessionCrea
model: string;
silenceDurationMs: number;
vadThreshold: number;
inputAudioFormat: string;
};
type RealtimeEvent = {
@@ -51,6 +53,7 @@ function normalizeProviderConfig(
model: trimToUndefined(raw?.model) ?? trimToUndefined(raw?.sttModel),
silenceDurationMs: asFiniteNumber(raw?.silenceDurationMs),
vadThreshold: asFiniteNumber(raw?.vadThreshold),
inputAudioFormat: trimToUndefined(raw?.inputAudioFormat),
};
}
@@ -116,7 +119,7 @@ class OpenAIRealtimeTranscriptionSession implements RealtimeTranscriptionSession
this.sendEvent({
type: "transcription_session.update",
session: {
input_audio_format: "g711_ulaw",
input_audio_format: this.config.inputAudioFormat,
input_audio_transcription: {
model: this.config.model,
},
@@ -241,6 +244,7 @@ export function buildOpenAIRealtimeTranscriptionProvider(): RealtimeTranscriptio
model: config.model ?? "gpt-4o-transcribe",
silenceDurationMs: config.silenceDurationMs ?? 800,
vadThreshold: config.vadThreshold ?? 0.5,
inputAudioFormat: config.inputAudioFormat ?? "g711_ulaw",
});
},
};

View File

@@ -1,4 +1,4 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import os from "node:os";
import path from "node:path";
@@ -182,9 +182,20 @@ describe("qa-lab server", () => {
});
it("serves the built QA UI bundle when available", async () => {
const uiDistDir = await mkdtemp(path.join(os.tmpdir(), "qa-lab-ui-dist-"));
cleanups.push(async () => {
await rm(uiDistDir, { recursive: true, force: true });
});
await writeFile(
path.join(uiDistDir, "index.html"),
"<!doctype html><html><head><title>QA Lab</title></head><body><div id='app'></div></body></html>",
"utf8",
);
const lab = await startQaLabServer({
host: "127.0.0.1",
port: 0,
uiDistDir,
});
cleanups.push(async () => {
await lab.stop();

View File

@@ -159,13 +159,24 @@ function missingUiHtml() {
</html>`;
}
function resolveUiDistDir() {
function resolveUiDistDir(overrideDir?: string | null) {
if (overrideDir?.trim()) {
return overrideDir;
}
const candidates = [
fileURLToPath(new URL("../web/dist", import.meta.url)),
path.resolve(process.cwd(), "extensions/qa-lab/web/dist"),
path.resolve(process.cwd(), "dist/extensions/qa-lab/web/dist"),
];
return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[0];
return (
candidates.find((candidate) => {
if (!fs.existsSync(candidate)) {
return false;
}
const indexPath = path.join(candidate, "index.html");
return fs.existsSync(indexPath) && fs.statSync(indexPath).isFile();
}) ?? candidates[0]
);
}
function resolveAdvertisedBaseUrl(params: {
@@ -335,8 +346,8 @@ function proxyUpgradeRequest(params: {
params.socket.on("close", closeBoth);
}
function tryResolveUiAsset(pathname: string): string | null {
const distDir = resolveUiDistDir();
function tryResolveUiAsset(pathname: string, overrideDir?: string | null): string | null {
const distDir = resolveUiDistDir(overrideDir);
if (!fs.existsSync(distDir)) {
return null;
}
@@ -415,6 +426,7 @@ export async function startQaLabServer(params?: {
controlUiUrl?: string;
controlUiToken?: string;
controlUiProxyTarget?: string;
uiDistDir?: string;
autoKickoffTarget?: string;
embeddedGateway?: string;
sendKickoffOnStart?: boolean;
@@ -676,7 +688,7 @@ export async function startQaLabServer(params?: {
return;
}
const asset = tryResolveUiAsset(url.pathname);
const asset = tryResolveUiAsset(url.pathname, params?.uiDistDir);
if (!asset) {
const html = missingUiHtml();
res.writeHead(200, {

View File

@@ -124,6 +124,10 @@ export function buildQaGatewayConfig(params: {
providerMode === "live-openai"
? Object.fromEntries(selectedProviderIds.map((providerId) => [providerId, { enabled: true }]))
: {};
const allowedPlugins =
providerMode === "live-openai"
? ["memory-core", ...selectedProviderIds, "qa-channel"]
: ["memory-core", "qa-channel"];
const liveModelParams =
providerMode === "live-openai"
? {
@@ -147,7 +151,7 @@ export function buildQaGatewayConfig(params: {
return {
plugins: {
...(providerMode === "mock-openai" ? { allow: ["memory-core", "qa-channel"] } : {}),
allow: allowedPlugins,
entries: {
acpx: {
enabled: false,

View File

@@ -71,7 +71,10 @@ export function createSignalToolResultConfig(
};
}
export const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
export async function flush() {
await Promise.resolve();
await Promise.resolve();
}
export function createMockSignalDaemonHandle(
overrides: {

View File

@@ -51,7 +51,10 @@ import {
import { resolveSlackChannelType } from "./channel-type.js";
import { shouldSuppressLocalSlackExecApprovalPrompt } from "./exec-approvals.js";
import { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy } from "./group-policy.js";
import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js";
import {
compileSlackInteractiveReplies,
isSlackInteractiveRepliesEnabled,
} from "./interactive-replies.js";
import { SLACK_TEXT_LIMIT } from "./limits.js";
import { slackOutbound } from "./outbound-adapter.js";
import type { SlackProbe } from "./probe.js";
@@ -324,6 +327,10 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
parseExplicitTarget: ({ raw }) => parseSlackExplicitTarget(raw),
inferTargetChatType: ({ to }) => parseSlackExplicitTarget(to)?.chatType,
resolveOutboundSessionRoute: async (params) => await resolveSlackOutboundSessionRoute(params),
transformReplyPayload: ({ payload, cfg, accountId }) =>
isSlackInteractiveRepliesEnabled({ cfg, accountId })
? compileSlackInteractiveReplies(payload)
: payload,
enableInteractiveReplies: ({ cfg, accountId }) =>
isSlackInteractiveRepliesEnabled({ cfg, accountId }),
hasStructuredReplyPayload: ({ payload }) => {

View File

@@ -109,8 +109,4 @@ export const slackChannelConfigUiHints = {
label: "Slack Thread Initial History Limit",
help: "Maximum number of existing Slack thread messages to fetch when starting a new thread session (default: 20, set to 0 to disable).",
},
"thread.requireExplicitMention": {
label: "Slack Thread Require Explicit Mention",
help: "If true, require an explicit @mention even inside threads where the bot has participated. Suppresses implicit thread mention behavior so the bot only responds to explicit @bot mentions in threads (default: false).",
},
} satisfies Record<string, ChannelConfigUiHint>;

View File

@@ -34,7 +34,6 @@ function createTestContext() {
replyToMode: "off",
threadHistoryScope: "thread",
threadInheritParent: false,
threadRequireExplicitMention: false,
slashCommand: {
enabled: true,
name: "openclaw",

View File

@@ -54,7 +54,6 @@ export type SlackMonitorContext = {
replyToMode: "off" | "first" | "all" | "batched";
threadHistoryScope: "thread" | "channel";
threadInheritParent: boolean;
threadRequireExplicitMention: boolean;
slashCommand: Required<import("openclaw/plugin-sdk/config-runtime").SlackSlashCommandConfig>;
textLimit: number;
ackReactionScope: string;
@@ -119,7 +118,6 @@ export function createSlackMonitorContext(params: {
replyToMode: SlackMonitorContext["replyToMode"];
threadHistoryScope: SlackMonitorContext["threadHistoryScope"];
threadInheritParent: SlackMonitorContext["threadInheritParent"];
threadRequireExplicitMention: SlackMonitorContext["threadRequireExplicitMention"];
slashCommand: SlackMonitorContext["slashCommand"];
textLimit: number;
ackReactionScope: string;
@@ -420,7 +418,6 @@ export function createSlackMonitorContext(params: {
replyToMode: params.replyToMode,
threadHistoryScope: params.threadHistoryScope,
threadInheritParent: params.threadInheritParent,
threadRequireExplicitMention: params.threadRequireExplicitMention,
slashCommand: params.slashCommand,
textLimit: params.textLimit,
ackReactionScope: params.ackReactionScope,

View File

@@ -11,7 +11,6 @@ export function createInboundSlackTestContext(params: {
defaultRequireMention?: boolean;
replyToMode?: "off" | "all" | "first";
channelsConfig?: SlackChannelConfigEntries;
threadRequireExplicitMention?: boolean;
}) {
return createSlackMonitorContext({
cfg: params.cfg,
@@ -40,7 +39,6 @@ export function createInboundSlackTestContext(params: {
replyToMode: params.replyToMode ?? "off",
threadHistoryScope: "thread",
threadInheritParent: false,
threadRequireExplicitMention: params.threadRequireExplicitMention ?? false,
slashCommand: {
enabled: false,
name: "openclaw",

View File

@@ -645,7 +645,6 @@ describe("prepareSlackMessage sender prefix", () => {
replyToMode: "off",
threadHistoryScope: "channel",
threadInheritParent: false,
threadRequireExplicitMention: false,
slashCommand: params.slashCommand,
textLimit: 2000,
ackReactionScope: "off",
@@ -711,121 +710,3 @@ describe("prepareSlackMessage sender prefix", () => {
expect(result?.ctxPayload.CommandAuthorized).toBe(true);
});
});
describe("slack thread.requireExplicitMention", () => {
let fixtureRoot = "";
let caseId = 0;
function makeTmpStorePath() {
if (!fixtureRoot) {
throw new Error("fixtureRoot missing");
}
const dir = path.join(fixtureRoot, `require-explicit-${caseId++}`);
fs.mkdirSync(dir);
return { dir, storePath: path.join(dir, "sessions.json") };
}
beforeAll(() => {
fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-slack-explicit-mention-"));
});
afterAll(() => {
if (fixtureRoot) {
fs.rmSync(fixtureRoot, { recursive: true, force: true });
fixtureRoot = "";
}
});
function createCtxWithExplicitMention(requireExplicitMention: boolean) {
const ctx = createInboundSlackTestContext({
cfg: {
channels: { slack: { enabled: true } },
session: {},
} as OpenClawConfig,
threadRequireExplicitMention: requireExplicitMention,
});
ctx.resolveUserName = async () => ({ name: "Alice" }) as any;
return ctx;
}
it("drops thread reply without explicit mention when requireExplicitMention is true", async () => {
const ctx = createCtxWithExplicitMention(true);
const { storePath } = makeTmpStorePath();
vi.spyOn(
await import("openclaw/plugin-sdk/config-runtime"),
"resolveStorePath",
).mockReturnValue(storePath);
const account = createSlackTestAccount();
const message: SlackMessageEvent = {
type: "message",
channel: "C123",
channel_type: "channel",
user: "U1",
text: "hello",
ts: "1700000001.000001",
thread_ts: "1700000000.000000",
parent_user_id: "B1", // bot is thread parent
};
const result = await prepareSlackMessage({
ctx,
account,
message,
opts: { source: "message" },
});
expect(result).toBeNull();
});
it("allows thread reply with explicit @mention when requireExplicitMention is true", async () => {
const ctx = createCtxWithExplicitMention(true);
const { storePath } = makeTmpStorePath();
vi.spyOn(
await import("openclaw/plugin-sdk/config-runtime"),
"resolveStorePath",
).mockReturnValue(storePath);
const account = createSlackTestAccount();
const message: SlackMessageEvent = {
type: "message",
channel: "C123",
channel_type: "channel",
user: "U1",
text: "<@B1> hello",
ts: "1700000001.000002",
thread_ts: "1700000000.000000",
parent_user_id: "B1",
};
const result = await prepareSlackMessage({
ctx,
account,
message,
opts: { source: "message" },
});
expect(result).not.toBeNull();
});
it("allows thread reply without explicit mention when requireExplicitMention is false (default)", async () => {
const ctx = createCtxWithExplicitMention(false);
const { storePath } = makeTmpStorePath();
vi.spyOn(
await import("openclaw/plugin-sdk/config-runtime"),
"resolveStorePath",
).mockReturnValue(storePath);
const account = createSlackTestAccount();
const message: SlackMessageEvent = {
type: "message",
channel: "C123",
channel_type: "channel",
user: "U1",
text: "hello",
ts: "1700000001.000003",
thread_ts: "1700000000.000000",
parent_user_id: "B1",
};
const result = await prepareSlackMessage({
ctx,
account,
message,
opts: { source: "message" },
});
expect(result).not.toBeNull();
});
});

View File

@@ -384,7 +384,6 @@ export async function prepareSlackMessage(params: {
},
}));
const implicitMention = Boolean(
!ctx.threadRequireExplicitMention &&
!isDirectMessage &&
ctx.botUserId &&
message.thread_ts &&

View File

@@ -142,7 +142,6 @@ const baseParams = () => ({
mediaMaxBytes: 1,
threadHistoryScope: "thread" as const,
threadInheritParent: false,
threadRequireExplicitMention: false,
removeAckAfterReply: false,
});

View File

@@ -312,7 +312,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const replyToMode = slackCfg.replyToMode ?? "off";
const threadHistoryScope = slackCfg.thread?.historyScope ?? "thread";
const threadInheritParent = slackCfg.thread?.inheritParent ?? false;
const threadRequireExplicitMention = slackCfg.thread?.requireExplicitMention ?? false;
const slashCommand = resolveSlackSlashCommandConfig(opts.slashCommand ?? slackCfg.slashCommand);
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId, {
fallbackLimit: SLACK_TEXT_LIMIT,
@@ -424,7 +423,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
replyToMode,
threadHistoryScope,
threadInheritParent,
threadRequireExplicitMention,
slashCommand,
textLimit,
ackReactionScope,

View File

@@ -9,6 +9,7 @@ export {
isTtsProviderConfigured,
listSpeechVoices,
maybeApplyTtsToPayload,
resolveExplicitTtsOverrides,
resolveTtsAutoMode,
resolveTtsConfig,
resolveTtsPrefsPath,

View File

@@ -23,7 +23,7 @@ import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-pay
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { isVerbose, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/sandbox";
import { CONFIG_DIR, resolveUserPath, stripMarkdown } from "openclaw/plugin-sdk/text-runtime";
import { resolveConfigDir, resolveUserPath, stripMarkdown } from "openclaw/plugin-sdk/text-runtime";
import {
canonicalizeSpeechProviderId,
getSpeechProvider,
@@ -35,6 +35,7 @@ import {
summarizeText,
type SpeechModelOverridePolicy,
type SpeechProviderConfig,
type SpeechProviderOverrides,
type SpeechVoiceOption,
type TtsDirectiveOverrides,
type TtsDirectiveParseResult,
@@ -167,7 +168,7 @@ function resolveTtsPrefsPathValue(prefsPath: string | undefined): string {
if (envPath) {
return resolveUserPath(envPath);
}
return path.join(CONFIG_DIR, "settings", "tts.json");
return path.join(resolveConfigDir(process.env), "settings", "tts.json");
}
function resolveModelOverridePolicy(
@@ -494,6 +495,66 @@ export function setTtsProvider(prefsPath: string, provider: TtsProvider): void {
});
}
export function resolveExplicitTtsOverrides(params: {
cfg: OpenClawConfig;
prefsPath?: string;
provider?: string;
modelId?: string;
voiceId?: string;
}): TtsDirectiveOverrides {
const providerInput = params.provider?.trim();
const modelId = params.modelId?.trim();
const voiceId = params.voiceId?.trim();
const config = resolveTtsConfig(params.cfg);
const prefsPath = params.prefsPath ?? resolveTtsPrefsPath(config);
const selectedProvider =
canonicalizeSpeechProviderId(providerInput, params.cfg) ??
(modelId || voiceId ? getTtsProvider(config, prefsPath) : undefined);
if (providerInput && !selectedProvider) {
throw new Error(`Unknown TTS provider "${providerInput}".`);
}
if (!modelId && !voiceId) {
return selectedProvider ? { provider: selectedProvider } : {};
}
if (!selectedProvider) {
throw new Error("TTS model or voice overrides require a resolved provider.");
}
const provider = getSpeechProvider(selectedProvider, params.cfg);
if (!provider) {
throw new Error(`speech provider ${selectedProvider} is not registered`);
}
if (!provider.resolveTalkOverrides) {
throw new Error(
`TTS provider "${selectedProvider}" does not support model or voice overrides.`,
);
}
const providerOverrides = provider.resolveTalkOverrides({
talkProviderConfig: {},
params: {
...(voiceId ? { voiceId } : {}),
...(modelId ? { modelId } : {}),
},
});
if ((voiceId || modelId) && (!providerOverrides || Object.keys(providerOverrides).length === 0)) {
throw new Error(
`TTS provider "${selectedProvider}" ignored the requested model or voice overrides.`,
);
}
const overridesRecord = providerOverrides as SpeechProviderOverrides;
return {
provider: selectedProvider,
providerOverrides: {
[provider.id]: overridesRecord,
},
};
}
export function getTtsMaxLength(prefsPath: string): number {
const prefs = readPrefs(prefsPath);
return prefs.tts?.maxLength ?? DEFAULT_TTS_MAX_LENGTH;

View File

@@ -3,8 +3,8 @@ import os from "node:os";
import path from "node:path";
import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { loadBundledPluginTestApiSync } from "openclaw/plugin-sdk/testing";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { loadBundledPluginTestApiSync } from "../../../src/test-utils/bundled-plugin-public-surface.js";
import { importFreshModule } from "../../../test/helpers/import-fresh.js";
import {
__testing,

View File

@@ -856,7 +856,6 @@ export async function resetTelegramThreadBindingsForTests() {
for (const manager of getThreadBindingsState().managersByAccountId.values()) {
manager.stop();
}
await Promise.allSettled(getThreadBindingsState().persistQueueByAccountId.values());
getThreadBindingsState().persistQueueByAccountId.clear();
getThreadBindingsState().managersByAccountId.clear();
getThreadBindingsState().bindingsByAccountConversation.clear();

View File

@@ -28,7 +28,7 @@ import {
mergeUniqueStrings,
shouldMigrateTlonSetting,
} from "./settings-helpers.js";
import { asRecord, formatErrorMessage } from "./utils.js";
import { asRecord, formatErrorMessage, readString } from "./utils.js";
import {
extractMessageText,
formatModelName,
@@ -46,11 +46,6 @@ export type MonitorTlonOpts = {
accountId?: string | null;
};
function readString(record: Record<string, unknown> | null, key: string): string | undefined {
const value = record?.[key];
return typeof value === "string" ? value : undefined;
}
function readNumber(record: Record<string, unknown> | null, key: string): number | undefined {
const value = record?.[key];
return typeof value === "number" && Number.isFinite(value) ? value : undefined;

View File

@@ -189,8 +189,11 @@ export function formatErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function readString(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key];
export function readString(
record: Record<string, unknown> | null,
key: string,
): string | undefined {
const value = record?.[key];
return typeof value === "string" ? value : undefined;
}

View File

@@ -4,15 +4,10 @@
"paths": {
"openclaw/extension-api": ["../src/extensionAPI.ts"],
"openclaw/plugin-sdk": ["../packages/plugin-sdk/dist/src/plugin-sdk/index.d.ts"],
"openclaw/plugin-sdk/*": [
"../packages/plugin-sdk/dist/src/plugin-sdk/*.d.ts",
"../packages/plugin-sdk/dist/packages/plugin-sdk/src/*.d.ts"
],
"openclaw/plugin-sdk/*": ["../packages/plugin-sdk/dist/src/plugin-sdk/*.d.ts"],
"openclaw/plugin-sdk/account-id": ["../src/plugin-sdk/account-id.ts"],
"@openclaw/*": ["../packages/plugin-sdk/dist/packages/plugin-sdk/src/extensions/*"],
"@openclaw/plugin-sdk/*": [
"../packages/plugin-sdk/dist/packages/plugin-sdk/src/src/plugin-sdk/*"
]
"@openclaw/*": ["../packages/plugin-sdk/dist/extensions/*", "../extensions/*"],
"@openclaw/plugin-sdk/*": ["../packages/plugin-sdk/dist/src/plugin-sdk/*.d.ts"]
}
}
}

View File

@@ -5,63 +5,63 @@
"type": "module",
"exports": {
"./config-runtime": {
"types": "./dist/packages/plugin-sdk/src/src/plugin-sdk/config-runtime.d.ts",
"types": "./dist/src/plugin-sdk/config-runtime.d.ts",
"default": "./src/config-runtime.ts"
},
"./plugin-entry": {
"types": "./dist/packages/plugin-sdk/src/src/plugin-sdk/plugin-entry.d.ts",
"types": "./dist/src/plugin-sdk/plugin-entry.d.ts",
"default": "./src/plugin-entry.ts"
},
"./provider-auth": {
"types": "./dist/packages/plugin-sdk/src/src/plugin-sdk/provider-auth.d.ts",
"types": "./dist/src/plugin-sdk/provider-auth.d.ts",
"default": "./src/provider-auth.ts"
},
"./provider-auth-runtime": {
"types": "./dist/packages/plugin-sdk/src/src/plugin-sdk/provider-auth-runtime.d.ts",
"types": "./dist/src/plugin-sdk/provider-auth-runtime.d.ts",
"default": "./src/provider-auth-runtime.ts"
},
"./provider-entry": {
"types": "./dist/packages/plugin-sdk/src/src/plugin-sdk/provider-entry.d.ts",
"types": "./dist/src/plugin-sdk/provider-entry.d.ts",
"default": "./src/provider-entry.ts"
},
"./provider-http": {
"types": "./dist/packages/plugin-sdk/src/src/plugin-sdk/provider-http.d.ts",
"types": "./dist/src/plugin-sdk/provider-http.d.ts",
"default": "./src/provider-http.ts"
},
"./provider-model-shared": {
"types": "./dist/packages/plugin-sdk/src/src/plugin-sdk/provider-model-shared.d.ts",
"types": "./dist/src/plugin-sdk/provider-model-shared.d.ts",
"default": "./src/provider-model-shared.ts"
},
"./provider-onboard": {
"types": "./dist/packages/plugin-sdk/src/src/plugin-sdk/provider-onboard.d.ts",
"types": "./dist/src/plugin-sdk/provider-onboard.d.ts",
"default": "./src/provider-onboard.ts"
},
"./provider-stream-shared": {
"types": "./dist/packages/plugin-sdk/src/src/plugin-sdk/provider-stream-shared.d.ts",
"types": "./dist/src/plugin-sdk/provider-stream-shared.d.ts",
"default": "./src/provider-stream-shared.ts"
},
"./provider-tools": {
"types": "./dist/packages/plugin-sdk/src/src/plugin-sdk/provider-tools.d.ts",
"types": "./dist/src/plugin-sdk/provider-tools.d.ts",
"default": "./src/provider-tools.ts"
},
"./provider-web-search": {
"types": "./dist/packages/plugin-sdk/src/src/plugin-sdk/provider-web-search.d.ts",
"types": "./dist/src/plugin-sdk/provider-web-search.d.ts",
"default": "./src/provider-web-search.ts"
},
"./runtime-env": {
"types": "./dist/packages/plugin-sdk/src/src/plugin-sdk/runtime-env.d.ts",
"types": "./dist/src/plugin-sdk/runtime-env.d.ts",
"default": "./src/runtime-env.ts"
},
"./secret-input": {
"types": "./dist/packages/plugin-sdk/src/src/plugin-sdk/secret-input.d.ts",
"types": "./dist/src/plugin-sdk/secret-input.d.ts",
"default": "./src/secret-input.ts"
},
"./testing": {
"types": "./dist/packages/plugin-sdk/src/src/plugin-sdk/testing.d.ts",
"types": "./dist/src/plugin-sdk/testing.d.ts",
"default": "./src/testing.ts"
},
"./video-generation": {
"types": "./dist/packages/plugin-sdk/src/src/plugin-sdk/video-generation.d.ts",
"types": "./dist/src/plugin-sdk/video-generation.d.ts",
"default": "./src/video-generation.ts"
}
}

View File

@@ -98,10 +98,6 @@ export function resolveExtensionTestPlan(params = {}) {
const relativeExtensionDir = normalizeRelative(path.relative(repoRoot, extensionDir));
const roots = [relativeExtensionDir];
const pairedCoreRoot = path.join(repoRoot, "src", extensionId);
if (fs.existsSync(pairedCoreRoot)) {
roots.push(normalizeRelative(path.relative(repoRoot, pairedCoreRoot)));
}
const usesChannelConfig = roots.some((root) => channelTestRoots.includes(root));
const usesAcpxConfig = roots.some((root) => isAcpxExtensionRoot(root));

View File

@@ -606,7 +606,7 @@ describe("spawnAcpDirect", () => {
expectAgentGatewayCall({
deliver: true,
channel: "matrix",
to: "channel:child-thread",
to: "room:!room:example",
threadId: "child-thread",
});
});

View File

@@ -11,9 +11,10 @@ import {
} from "./store.js";
import type { AuthProfileStore, OAuthCredential } from "./types.js";
let resolveApiKeyForProfile: typeof import("./oauth.js").resolveApiKeyForProfile;
type GetOAuthApiKey = typeof import("@mariozechner/pi-ai/oauth").getOAuthApiKey;
const { getOAuthApiKeyMock } = vi.hoisted(() => ({
getOAuthApiKeyMock: vi.fn(async () => {
getOAuthApiKeyMock: vi.fn<GetOAuthApiKey>(async () => {
throw new Error("Failed to extract accountId from token");
}),
}));
@@ -106,7 +107,10 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => {
beforeEach(async () => {
resetFileLockStateForTest();
getOAuthApiKeyMock.mockClear();
getOAuthApiKeyMock.mockReset();
getOAuthApiKeyMock.mockImplementation(async () => {
throw new Error("Failed to extract accountId from token");
});
readCodexCliCredentialsCachedMock.mockReset();
readCodexCliCredentialsCachedMock.mockReturnValue(null);
writeCodexCliCredentialsMock.mockReset();
@@ -317,6 +321,114 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => {
);
});
it("adopts fresher stored credentials after refresh_token_reused", async () => {
const profileId = "openai-codex:default";
saveAuthProfileStore(
createExpiredOauthStore({
profileId,
provider: "openai-codex",
}),
agentDir,
);
getOAuthApiKeyMock.mockImplementationOnce(async () => {
saveAuthProfileStore(
{
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider: "openai-codex",
access: "reloaded-access-token",
refresh: "reloaded-refresh-token",
expires: Date.now() + 60_000,
},
},
},
agentDir,
);
throw new Error(
'401 {"error":{"message":"Your refresh token has already been used to generate a new access token.","code":"refresh_token_reused"}}',
);
});
await expect(
resolveApiKeyForProfile({
store: ensureAuthProfileStore(agentDir),
profileId,
agentDir,
}),
).resolves.toEqual({
apiKey: "reloaded-access-token",
provider: "openai-codex",
email: undefined,
});
expect(getOAuthApiKeyMock).toHaveBeenCalledTimes(1);
});
it("retries Codex refresh once after refresh_token_reused updates only the stored refresh token", async () => {
const profileId = "openai-codex:default";
saveAuthProfileStore(
createExpiredOauthStore({
profileId,
provider: "openai-codex",
}),
agentDir,
);
getOAuthApiKeyMock
.mockImplementationOnce(async (_provider, creds) => {
expect(creds["openai-codex"]?.refresh).toBe("refresh-token");
saveAuthProfileStore(
{
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider: "openai-codex",
access: "still-expired-access-token",
refresh: "rotated-refresh-token",
expires: Date.now() - 5_000,
},
},
},
agentDir,
);
throw new Error(
'401 {"error":{"message":"Your refresh token has already been used to generate a new access token.","code":"refresh_token_reused"}}',
);
})
.mockImplementationOnce(async (_provider, creds) => {
expect(creds["openai-codex"]?.refresh).toBe("rotated-refresh-token");
return {
apiKey: "retried-access-token",
newCredentials: {
access: "retried-access-token",
refresh: "retried-refresh-token",
expires: Date.now() + 60_000,
},
};
});
await expect(
resolveApiKeyForProfile({
store: ensureAuthProfileStore(agentDir),
profileId,
agentDir,
}),
).resolves.toEqual({
apiKey: "retried-access-token",
provider: "openai-codex",
email: undefined,
});
expect(getOAuthApiKeyMock).toHaveBeenCalledTimes(2);
const persisted = await readPersistedStore(agentDir);
expect(persisted.profiles[profileId]).toMatchObject({
access: "retried-access-token",
refresh: "retried-refresh-token",
});
});
it("keeps throwing for non-codex providers on the same refresh error", async () => {
const profileId = "anthropic:default";
saveAuthProfileStore(

View File

@@ -24,7 +24,11 @@ import {
import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js";
import { assertNoOAuthSecretRefPolicyViolations } from "./policy.js";
import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js";
import { ensureAuthProfileStore, saveAuthProfileStore } from "./store.js";
import {
ensureAuthProfileStore,
loadAuthProfileStoreForSecretsRuntime,
saveAuthProfileStore,
} from "./store.js";
import type { AuthProfileStore, OAuthCredential } from "./types.js";
function listOAuthProviderIds(): string[] {
@@ -118,6 +122,51 @@ function extractErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function isRefreshTokenReusedError(error: unknown): boolean {
const message = extractErrorMessage(error).toLowerCase();
return (
message.includes("refresh_token_reused") ||
message.includes("refresh token has already been used") ||
message.includes("already been used to generate a new access token")
);
}
function hasOAuthCredentialChanged(
previous: Pick<OAuthCredential, "access" | "refresh" | "expires">,
current: Pick<OAuthCredential, "access" | "refresh" | "expires">,
): boolean {
return (
previous.access !== current.access ||
previous.refresh !== current.refresh ||
previous.expires !== current.expires
);
}
async function loadFreshStoredOAuthCredential(params: {
profileId: string;
agentDir?: string;
provider: string;
previous?: Pick<OAuthCredential, "access" | "refresh" | "expires">;
requireChange?: boolean;
}): Promise<OAuthCredential | null> {
const reloadedStore = loadAuthProfileStoreForSecretsRuntime(params.agentDir);
const reloaded = reloadedStore.profiles[params.profileId];
if (reloaded?.type !== "oauth" || reloaded.provider !== params.provider) {
return null;
}
if (!Number.isFinite(reloaded.expires) || Date.now() >= reloaded.expires) {
return null;
}
if (
params.requireChange &&
params.previous &&
!hasOAuthCredentialChanged(params.previous, reloaded)
) {
return null;
}
return reloaded;
}
type ResolveApiKeyForProfileParams = {
cfg?: OpenClawConfig;
store: AuthProfileStore;
@@ -172,7 +221,9 @@ async function refreshOAuthTokenWithLock(params: {
ensureAuthStoreFile(authPath);
return await withFileLock(authPath, AUTH_STORE_LOCK_OPTIONS, async () => {
const store = ensureAuthProfileStore(params.agentDir);
// Locked refresh must bypass runtime snapshots so we can adopt fresher
// on-disk credentials written by another refresh attempt.
const store = loadAuthProfileStoreForSecretsRuntime(params.agentDir);
const cred = store.profiles[params.profileId];
if (!cred || cred.type !== "oauth") {
return null;
@@ -478,7 +529,7 @@ export async function resolveApiKeyForProfile(
email: cred.email,
});
} catch (error) {
const refreshedStore = ensureAuthProfileStore(params.agentDir);
const refreshedStore = loadAuthProfileStoreForSecretsRuntime(params.agentDir);
const refreshed = refreshedStore.profiles[profileId];
if (refreshed?.type === "oauth" && Date.now() < refreshed.expires) {
return await buildOAuthProfileResult({
@@ -487,6 +538,38 @@ export async function resolveApiKeyForProfile(
email: refreshed.email ?? cred.email,
});
}
if (
isRefreshTokenReusedError(error) &&
refreshed?.type === "oauth" &&
refreshed.provider === cred.provider &&
hasOAuthCredentialChanged(cred, refreshed)
) {
const recovered = await loadFreshStoredOAuthCredential({
profileId,
agentDir: params.agentDir,
provider: cred.provider,
previous: cred,
requireChange: true,
});
if (recovered) {
return await buildOAuthProfileResult({
provider: recovered.provider,
credentials: recovered,
email: recovered.email ?? cred.email,
});
}
const retried = await refreshOAuthTokenWithLock({
profileId,
agentDir: params.agentDir,
});
if (retried) {
return buildApiKeyProfileResult({
apiKey: retried.apiKey,
provider: cred.provider,
email: cred.email,
});
}
}
const fallbackProfileId = suggestOAuthProfileIdForLegacyDefault({
cfg,
store: refreshedStore,

View File

@@ -267,7 +267,7 @@ describe("models-config merge helpers", () => {
const merged = mergeWithExistingProviderSecrets({
nextProviders: {
custom: {
apiKey: "OPENAI_API_KEY", // pragma: allowlist secret
apiKey: "GOOGLE_API_KEY", // pragma: allowlist secret
models: [createModel({ id: "model", api: "openai-responses" })],
} as ProviderConfig,
},
@@ -281,7 +281,7 @@ describe("models-config merge helpers", () => {
explicitBaseUrlProviders: new Set<string>(),
});
expect(merged.custom?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret
expect(merged.custom?.apiKey).toBe("GOOGLE_API_KEY"); // pragma: allowlist secret
});
it("does not preserve a stale non-env marker when config returns to plaintext", async () => {

View File

@@ -12,6 +12,7 @@ import {
resolveNonEnvSecretRefHeaderValueMarker,
} from "./model-auth-markers.js";
import { resolveAwsSdkEnvVarName } from "./model-auth-runtime-shared.js";
import { normalizeProviderIdForAuth } from "./provider-id.js";
type ModelsConfig = NonNullable<OpenClawConfig["models"]>;
export type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string];
@@ -323,14 +324,19 @@ export function createProviderApiKeyResolver(
config?: OpenClawConfig,
): ProviderApiKeyResolver {
return (provider: string): { apiKey: string | undefined; discoveryApiKey?: string } => {
const envVar = resolveEnvApiKeyVarName(provider, env);
const authProvider = normalizeProviderIdForAuth(provider);
const envVar = resolveEnvApiKeyVarName(authProvider, env);
if (envVar) {
return {
apiKey: envVar,
discoveryApiKey: toDiscoveryApiKey(env[envVar]),
};
}
const fromProfiles = resolveApiKeyFromProfiles({ provider, store: authStore, env });
const fromProfiles = resolveApiKeyFromProfiles({
provider: authProvider,
store: authStore,
env,
});
if (fromProfiles?.apiKey) {
return {
apiKey: fromProfiles.apiKey,
@@ -338,7 +344,7 @@ export function createProviderApiKeyResolver(
};
}
const fromConfig = resolveConfigBackedProviderAuth({
provider,
provider: authProvider,
config,
});
return {
@@ -354,7 +360,8 @@ export function createProviderAuthResolver(
config?: OpenClawConfig,
): ProviderAuthResolver {
return (provider: string, options?: { oauthMarker?: string }) => {
const ids = listProfilesForProvider(authStore, provider);
const authProvider = normalizeProviderIdForAuth(provider);
const ids = listProfilesForProvider(authStore, authProvider);
let oauthCandidate:
| {
apiKey: string | undefined;
@@ -395,7 +402,7 @@ export function createProviderAuthResolver(
return oauthCandidate;
}
const envVar = resolveEnvApiKeyVarName(provider, env);
const envVar = resolveEnvApiKeyVarName(authProvider, env);
if (envVar) {
return {
apiKey: envVar,
@@ -406,7 +413,7 @@ export function createProviderAuthResolver(
}
const fromConfig = resolveConfigBackedProviderAuth({
provider,
provider: authProvider,
config,
});
if (fromConfig) {
@@ -438,13 +445,14 @@ function resolveConfigBackedProviderAuth(params: { provider: string; config?: Op
// Providers own any provider-specific fallback auth logic via
// resolveSyntheticAuth(...). Discovery/bootstrap callers may consume
// non-secret markers from source config, but must never persist plaintext.
const authProvider = normalizeProviderIdForAuth(params.provider);
const synthetic = resolveProviderSyntheticAuthWithPlugin({
provider: params.provider,
provider: authProvider,
config: params.config,
context: {
config: params.config,
provider: params.provider,
providerConfig: params.config?.models?.providers?.[params.provider],
provider: authProvider,
providerConfig: params.config?.models?.providers?.[authProvider],
},
});
const apiKey = synthetic?.apiKey?.trim();

View File

@@ -131,9 +131,6 @@ function normalizeResolvedModel(params: {
const normalizedInputModel = {
...params.model,
input: resolveProviderModelInput({
provider: params.provider,
modelId: params.model.id,
modelName: params.model.name,
input: params.model.input,
}),
} as Model<Api>;
@@ -233,7 +230,6 @@ function findInlineModelMatch(params: {
}
export { buildModelAliasLines };
export { buildInlineProviderModels };
function resolveConfiguredProviderConfig(
cfg: OpenClawConfig | undefined,
@@ -250,6 +246,17 @@ function resolveConfiguredProviderConfig(
return findNormalizedProviderValue(configuredProviders, provider);
}
function resolveProviderModelInput(params: {
input?: unknown;
fallbackInput?: unknown;
}): Array<"text" | "image"> {
const resolvedInput = Array.isArray(params.input) ? params.input : params.fallbackInput;
const normalizedInput = Array.isArray(resolvedInput)
? resolvedInput.filter((item): item is "text" | "image" => item === "text" || item === "image")
: [];
return normalizedInput.length > 0 ? normalizedInput : ["text"];
}
function applyConfiguredProviderOverrides(params: {
provider: string;
discoveredModel: ProviderRuntimeModel;
@@ -290,9 +297,6 @@ function applyConfiguredProviderOverrides(params: {
};
}
const normalizedInput = resolveProviderModelInput({
provider: params.provider,
modelId,
modelName: configuredModel?.name ?? discoveredModel.name,
input: configuredModel?.input,
fallbackInput: discoveredModel.input,
});
@@ -337,6 +341,54 @@ function applyConfiguredProviderOverrides(params: {
);
}
export function buildInlineProviderModels(
providers: Record<string, InlineProviderConfig>,
): InlineModelEntry[] {
return Object.entries(providers).flatMap(([providerId, entry]) => {
const trimmed = providerId.trim();
if (!trimmed) {
return [];
}
const providerHeaders = sanitizeModelHeaders(entry?.headers, {
stripSecretRefMarkers: true,
});
const providerRequest = sanitizeConfiguredModelProviderRequest(entry?.request);
return (entry?.models ?? []).map((model) => {
const transport = resolveProviderTransport({
provider: trimmed,
api: model.api ?? entry?.api,
baseUrl: entry?.baseUrl,
});
const modelHeaders = sanitizeModelHeaders((model as InlineModelEntry).headers, {
stripSecretRefMarkers: true,
});
const requestConfig = resolveProviderRequestConfig({
provider: trimmed,
api: transport.api ?? model.api,
baseUrl: transport.baseUrl,
providerHeaders,
modelHeaders,
authHeader: entry?.authHeader,
request: providerRequest,
capability: "llm",
transport: "stream",
});
return attachModelProviderRequestTransport(
{
...model,
input: resolveProviderModelInput({
input: model.input,
}),
provider: trimmed,
baseUrl: requestConfig.baseUrl ?? transport.baseUrl,
api: requestConfig.api ?? model.api,
headers: requestConfig.headers,
},
providerRequest,
);
});
});
}
function resolveExplicitModelWithRegistry(params: {
provider: string;
modelId: string;
@@ -505,9 +557,6 @@ function resolveConfiguredFallbackModel(params: {
baseUrl: requestConfig.baseUrl,
reasoning: configuredModel?.reasoning ?? false,
input: resolveProviderModelInput({
provider,
modelId,
modelName: configuredModel?.name ?? modelId,
input: configuredModel?.input,
}),
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },

View File

@@ -97,8 +97,25 @@ describe("runEmbeddedAttempt context injection", () => {
);
});
it("records full bootstrap completion after a successful non-heartbeat turn", async () => {
await createContextEngineAttemptRunner({
it("runs full bootstrap injection after a successful non-heartbeat turn", async () => {
hoisted.resolveBootstrapContextForRunMock.mockResolvedValue({
bootstrapFiles: [
{
name: "AGENTS.md",
path: "AGENTS.md",
content: "bootstrap context",
missing: false,
},
],
contextFiles: [
{
path: "AGENTS.md",
content: "bootstrap context",
},
],
});
const result = await createContextEngineAttemptRunner({
contextEngine: {
assemble: async ({ messages }) => ({ messages, estimatedTokens: 1 }),
},
@@ -110,11 +127,11 @@ describe("runEmbeddedAttempt context injection", () => {
tempPaths,
});
expect(hoisted.sessionManager.appendCustomEntry).toHaveBeenCalledWith(
"openclaw:bootstrap-context:full",
expect(result.promptError).toBeNull();
expect(hoisted.resolveBootstrapContextForRunMock).toHaveBeenCalledWith(
expect.objectContaining({
runId: "run-context-engine-forwarding",
sessionId: "embedded-session",
contextMode: "full",
runKind: undefined,
}),
);
});

View File

@@ -237,6 +237,9 @@ vi.mock("../../docs-path.js", () => ({
vi.mock("../../pi-project-settings.js", () => ({
createPreparedEmbeddedPiSettingsManager: () => ({
getCompactionReserveTokens: () => 0,
getCompactionKeepRecentTokens: () => 40_000,
applyOverrides: () => {},
setCompactionEnabled: () => {},
}),
}));
@@ -659,6 +662,7 @@ export async function cleanupTempPaths(tempPaths: string[]) {
}
export function createDefaultEmbeddedSession(params?: {
initialMessages?: unknown[];
prompt?: (
session: MutableSession,
prompt: string,
@@ -667,7 +671,7 @@ export function createDefaultEmbeddedSession(params?: {
}): MutableSession {
const session: MutableSession = {
sessionId: "embedded-session",
messages: [],
messages: [...(params?.initialMessages ?? [])],
isCompacting: false,
isStreaming: false,
agent: {
@@ -824,12 +828,9 @@ export async function createContextEngineAttemptRunner(params: {
.mockReset()
.mockReturnValue({ messages: seedMessages });
hoisted.createAgentSessionMock.mockImplementation(async () => {
const session = createDefaultEmbeddedSession();
session.messages = [...seedMessages];
session.agent.state.messages = [...seedMessages];
return { session };
});
hoisted.createAgentSessionMock.mockImplementation(async () => ({
session: createDefaultEmbeddedSession({ initialMessages: seedMessages }),
}));
return await (
await loadRunEmbeddedAttempt()

View File

@@ -249,11 +249,6 @@ export const TOOL_DISPLAY_CONFIG: ToolDisplayConfig = {
},
},
},
update_plan: {
emoji: "🗺️",
title: "Update Plan",
detailKeys: ["explanation", "plan.0.step"],
},
gateway: {
emoji: "🔌",
title: "Gateway",

View File

@@ -4,6 +4,7 @@ import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.t
import {
resolveWebSearchDefinition,
resolveWebSearchProviderId,
runWebSearch,
} from "../../web-search/runtime.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult } from "./common.js";
@@ -16,16 +17,17 @@ export function createWebSearchTool(options?: {
}): AnyAgentTool | null {
const runtimeProviderId =
options?.runtimeWebSearch?.selectedProvider ?? options?.runtimeWebSearch?.providerConfigured;
const preferRuntimeProviders =
Boolean(runtimeProviderId) &&
!resolveManifestContractOwnerPluginId({
contract: "webSearchProviders",
value: runtimeProviderId,
origin: "bundled",
config: options?.config,
});
const resolved = resolveWebSearchDefinition({
...options,
preferRuntimeProviders:
Boolean(runtimeProviderId) &&
!resolveManifestContractOwnerPluginId({
contract: "webSearchProviders",
value: runtimeProviderId,
origin: "bundled",
config: options?.config,
}),
preferRuntimeProviders,
});
if (!resolved) {
return null;
@@ -36,7 +38,19 @@ export function createWebSearchTool(options?: {
name: "web_search",
description: resolved.definition.description,
parameters: resolved.definition.parameters,
execute: async (_toolCallId, args) => jsonResult(await resolved.definition.execute(args)),
execute: async (_toolCallId, args) => {
const result = await runWebSearch({
config: options?.config,
sandboxed: options?.sandboxed,
runtimeWebSearch: options?.runtimeWebSearch,
preferRuntimeProviders,
args,
});
return jsonResult({
...result.result,
provider: result.provider,
});
},
};
}

View File

@@ -1,23 +1,33 @@
import { listChannelPlugins } from "../channels/plugins/index.js";
import { getActivePluginChannelRegistryVersion } from "../plugins/runtime.js";
import {
getActivePluginChannelRegistryVersion,
requireActivePluginChannelRegistry,
} from "../plugins/runtime.js";
import type { ShouldHandleTextCommandsParams } from "./commands-registry.types.js";
let cachedNativeCommandSurfaces: Set<string> | null = null;
let cachedNativeCommandSurfacesVersion = -1;
let cachedNativeCommandSurfacesRegistry: object | null = null;
export function isNativeCommandSurface(surface?: string): boolean {
const normalized = surface?.trim().toLowerCase();
if (!normalized) {
return false;
}
const activeRegistry = requireActivePluginChannelRegistry();
const registryVersion = getActivePluginChannelRegistryVersion();
if (!cachedNativeCommandSurfaces || cachedNativeCommandSurfacesVersion !== registryVersion) {
if (
!cachedNativeCommandSurfaces ||
cachedNativeCommandSurfacesVersion !== registryVersion ||
cachedNativeCommandSurfacesRegistry !== activeRegistry
) {
cachedNativeCommandSurfaces = new Set(
listChannelPlugins()
.filter((plugin) => plugin.capabilities?.nativeCommands === true)
.map((plugin) => plugin.id),
);
cachedNativeCommandSurfacesVersion = registryVersion;
cachedNativeCommandSurfacesRegistry = activeRegistry;
}
return cachedNativeCommandSurfaces.has(normalized);
}

View File

@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { importFreshModule } from "../../../test/helpers/import-fresh.ts";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
import type { HandleCommandsParams } from "./commands-types.js";
@@ -268,7 +269,7 @@ vi.mock("../../infra/outbound/session-binding-service.js", () => {
};
});
const { handleSessionCommand } = await import("./commands-session.js");
let handleSessionCommand: (typeof import("./commands-session.js"))["handleSessionCommand"];
const baseCfg = {
session: { mainKey: "main", scope: "per-sender" },
} satisfies OpenClawConfig;
@@ -487,6 +488,7 @@ function expectIdleTimeoutSetReply(
describe("/session idle and /session max-age", () => {
beforeEach(() => {
vi.resetModules();
hoisted.setThreadBindingIdleTimeoutBySessionKeyMock.mockReset();
hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockReset();
hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock.mockReset();
@@ -497,6 +499,13 @@ describe("/session idle and /session max-age", () => {
vi.useRealTimers();
});
beforeEach(async () => {
({ handleSessionCommand } = await importFreshModule<typeof import("./commands-session.js")>(
import.meta.url,
"./commands-session.js?scope=commands-session-lifecycle",
));
});
it("sets idle timeout for the focused thread-chat session", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));

View File

@@ -414,8 +414,41 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
params.command.channelId ??
normalizeChannelId(resolveCommandSurfaceChannel(params)) ??
undefined;
const channelPlugin = channelId ? getChannelPlugin(channelId) : undefined;
const conversationBindings = channelPlugin?.conversationBindings;
const commandConversationBindings = channelId
? getChannelPlugin(channelId)?.conversationBindings
: undefined;
const commandSupportsCurrentConversationBinding = Boolean(
commandConversationBindings?.supportsCurrentConversationBinding,
);
const commandSupportsLifecycleUpdate =
action === SESSION_ACTION_IDLE
? typeof commandConversationBindings?.setIdleTimeoutBySessionKey === "function"
: typeof commandConversationBindings?.setMaxAgeBySessionKey === "function";
const bindingContext = resolveConversationBindingContextFromAcpCommand(params);
if (!bindingContext) {
if (
!channelId ||
!commandSupportsCurrentConversationBinding ||
!commandSupportsLifecycleUpdate
) {
return {
shouldContinue: false,
reply: {
text: "⚠️ /session idle and /session max-age are currently available only on channels that support focused conversation bindings.",
},
};
}
return {
shouldContinue: false,
reply: {
text: "⚠️ /session idle and /session max-age must be run inside a focused conversation.",
},
};
}
const resolvedChannelId = bindingContext.channel || channelId;
const conversationBindings = resolvedChannelId
? getChannelPlugin(resolvedChannelId)?.conversationBindings
: undefined;
const supportsCurrentConversationBinding = Boolean(
conversationBindings?.supportsCurrentConversationBinding,
);
@@ -423,7 +456,7 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
action === SESSION_ACTION_IDLE
? typeof conversationBindings?.setIdleTimeoutBySessionKey === "function"
: typeof conversationBindings?.setMaxAgeBySessionKey === "function";
if (!channelId || !supportsCurrentConversationBinding || !supportsLifecycleUpdate) {
if (!resolvedChannelId || !supportsCurrentConversationBinding || !supportsLifecycleUpdate) {
return {
shouldContinue: false,
reply: {
@@ -433,15 +466,6 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
}
const sessionBindingService = getSessionBindingService();
const bindingContext = resolveConversationBindingContextFromAcpCommand(params);
if (!bindingContext) {
return {
shouldContinue: false,
reply: {
text: "⚠️ /session idle and /session max-age must be run inside a focused conversation.",
},
};
}
const activeBinding = sessionBindingService.resolveByConversation(bindingContext);
if (!activeBinding) {

View File

@@ -1,4 +1,5 @@
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import { getBundledChannelPlugin } from "../../channels/plugins/bundled.js";
import { getLoadedChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
export function extractExplicitGroupId(raw: string | undefined | null): string | undefined {
const trimmed = (raw ?? "").trim();
@@ -24,9 +25,11 @@ export function extractExplicitGroupId(raw: string | undefined | null): string |
}
}
const channelId = normalizeChannelId(parts[0] ?? "") ?? parts[0]?.trim().toLowerCase();
const parsed = channelId
? getChannelPlugin(channelId)?.messaging?.parseExplicitTarget?.({ raw: trimmed })
: null;
const messaging = channelId
? (getLoadedChannelPlugin(channelId)?.messaging ??
getBundledChannelPlugin(channelId)?.messaging)
: undefined;
const parsed = messaging?.parseExplicitTarget?.({ raw: trimmed }) ?? null;
if (parsed && parsed.chatType && parsed.chatType !== "direct") {
return parsed.to.replace(/:topic:.*$/, "") || undefined;
}

View File

@@ -1,5 +1,6 @@
import { normalizeChatType } from "../../channels/chat-type.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import { getBundledChannelPlugin } from "../../channels/plugins/bundled.js";
import { getLoadedChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import { resolveSenderLabel } from "../../channels/sender-label.js";
import type { EnvelopeFormatOptions } from "../envelope.js";
import { formatEnvelopeTimestamp } from "../envelope.js";
@@ -45,7 +46,10 @@ function resolveInboundFormattingHints(ctx: TemplateContext):
return undefined;
}
const normalizedChannel = normalizeChannelId(channelValue) ?? channelValue;
return getChannelPlugin(normalizedChannel)?.agentPrompt?.inboundFormattingHints?.({
const agentPrompt =
getLoadedChannelPlugin(normalizedChannel)?.agentPrompt ??
getBundledChannelPlugin(normalizedChannel)?.agentPrompt;
return agentPrompt?.inboundFormattingHints?.({
accountId: safeTrim(ctx.AccountId) ?? undefined,
});
}

View File

@@ -1,9 +1,5 @@
import {
getChannelPlugin,
normalizeChannelId as normalizePluginChannelId,
} from "../../channels/plugins/index.js";
import { normalizeChannelId as normalizePluginChannelId } from "../../channels/plugins/index.js";
import type { ChannelThreadingAdapter } from "../../channels/plugins/types.core.js";
import { normalizeChannelId as normalizeBuiltInChannelId } from "../../channels/registry.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { ReplyToMode } from "../../config/types.js";
import type { OriginatingChannelType } from "../templating.js";
@@ -27,15 +23,11 @@ function normalizeReplyToModeChatType(
}
function resolveReplyToModeChannelKey(channel?: OriginatingChannelType): string | undefined {
if (typeof channel !== "string") {
return undefined;
const normalized = normalizePluginChannelId(channel);
if (normalized) {
return normalized;
}
return (
(normalizeBuiltInChannelId(channel) ??
normalizePluginChannelId(channel) ??
channel.trim().toLowerCase()) ||
undefined
);
return typeof channel === "string" ? channel.trim().toLowerCase() || undefined : undefined;
}
export function resolveConfiguredReplyToMode(
@@ -89,16 +81,8 @@ export function resolveReplyToMode(
accountId?: string | null,
chatType?: string | null,
): ReplyToMode {
const provider = normalizePluginChannelId(channel);
return resolveReplyToModeWithThreading(
cfg,
provider ? getChannelPlugin(provider)?.threading : undefined,
{
channel,
accountId,
chatType,
},
);
void accountId;
return resolveConfiguredReplyToMode(cfg, channel, chatType);
}
export function createReplyToModeFilter(
@@ -175,15 +159,11 @@ export function createReplyToModeFilterForChannel(
mode: ReplyToMode,
channel?: OriginatingChannelType,
) {
const provider = normalizePluginChannelId(channel);
const normalized = typeof channel === "string" ? channel.trim().toLowerCase() : undefined;
const isWebchat = normalized === "webchat";
// Default: allow explicit reply tags/directives even when replyToMode is "off".
// Unknown channels fail closed; internal webchat stays allowed.
const threading = provider ? getChannelPlugin(provider)?.threading : undefined;
const allowExplicitReplyTagsWhenOff = provider
? (threading?.allowExplicitReplyTagsWhenOff ?? threading?.allowTagsWhenOff ?? true)
: isWebchat;
const allowExplicitReplyTagsWhenOff = normalized ? true : isWebchat;
return createReplyToModeFilter(mode, {
allowExplicitReplyTagsWhenOff,
});

View File

@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { compileSlackInteractiveReplies } from "../../../extensions/slack/src/interactive-replies.ts";
import type {
ChannelMessagingAdapter,
ChannelPlugin,
@@ -29,6 +30,11 @@ vi.mock("../../infra/outbound/deliver-runtime.js", async () => {
const { routeReply } = await import("./route-reply.js");
const slackMessaging: ChannelMessagingAdapter = {
transformReplyPayload: ({ payload, cfg }) =>
(cfg.channels?.slack as { capabilities?: { interactiveReplies?: boolean } } | undefined)
?.capabilities?.interactiveReplies === true
? compileSlackInteractiveReplies(payload)
: payload,
enableInteractiveReplies: ({ cfg }) =>
(cfg.channels?.slack as { capabilities?: { interactiveReplies?: boolean } } | undefined)
?.capabilities?.interactiveReplies === true,
@@ -48,6 +54,13 @@ const slackThreading: ChannelThreadingAdapter = {
}),
};
const mattermostThreading: ChannelThreadingAdapter = {
resolveReplyTransport: ({ threadId, replyToId }) => ({
replyToId: replyToId ?? (threadId != null && threadId !== "" ? String(threadId) : undefined),
threadId,
}),
};
function createChannelPlugin(
id: ChannelPlugin["id"],
options: {
@@ -135,7 +148,10 @@ describe("routeReply", () => {
},
{
pluginId: "mattermost",
plugin: createChannelPlugin("mattermost", { label: "Mattermost" }),
plugin: createChannelPlugin("mattermost", {
label: "Mattermost",
threading: mattermostThreading,
}),
source: "test",
},
]),

View File

@@ -9,7 +9,8 @@
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import { getBundledChannelPlugin } from "../../channels/plugins/bundled.js";
import { getLoadedChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import { normalizeChatChannelId } from "../../channels/registry.js";
import type { OpenClawConfig } from "../../config/config.js";
import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js";
@@ -80,8 +81,13 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
return { ok: true };
}
const normalizedChannel = normalizeMessageChannel(channel);
const channelId = normalizeChannelId(channel) ?? null;
const plugin = channelId ? getChannelPlugin(channelId) : undefined;
const channelId =
normalizeChannelId(channel) ??
(typeof channel === "string" ? channel.trim().toLowerCase() : null);
const loadedPlugin = channelId ? getLoadedChannelPlugin(channelId) : undefined;
const bundledPlugin = channelId ? getBundledChannelPlugin(channelId) : undefined;
const messaging = loadedPlugin?.messaging ?? bundledPlugin?.messaging;
const threading = loadedPlugin?.threading ?? bundledPlugin?.threading;
const resolvedAgentId = params.sessionKey
? resolveSessionAgentId({
sessionKey: params.sessionKey,
@@ -101,9 +107,9 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
: cfg.messages?.responsePrefix;
const normalized = normalizeReplyPayload(payload, {
responsePrefix,
transformReplyPayload: plugin?.messaging?.transformReplyPayload
transformReplyPayload: messaging?.transformReplyPayload
? (nextPayload) =>
plugin.messaging?.transformReplyPayload?.({
messaging.transformReplyPayload?.({
payload: nextPayload,
cfg,
accountId,
@@ -125,7 +131,7 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
? [externalPayload.mediaUrl]
: [];
const replyToId = externalPayload.replyToId;
const hasChannelData = plugin?.messaging?.hasStructuredReplyPayload?.({
const hasChannelData = messaging?.hasStructuredReplyPayload?.({
payload: externalPayload,
});
@@ -160,7 +166,7 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
}
const replyTransport =
plugin?.threading?.resolveReplyTransport?.({
threading?.resolveReplyTransport?.({
cfg,
accountId,
threadId,

View File

@@ -1,4 +1,5 @@
import { stripUrlUserInfo } from "../shared/net/url-userinfo.js";
import { isRecord } from "../utils.js";
import type { ChannelAccountSnapshot } from "./plugins/types.core.js";
// Read-only status commands project a safe subset of account fields into snapshots
@@ -15,13 +16,6 @@ const CREDENTIAL_STATUS_KEYS = [
type CredentialStatusKey = (typeof CREDENTIAL_STATUS_KEYS)[number];
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function readTrimmedString(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key];
if (typeof value !== "string") {
@@ -60,7 +54,7 @@ function readCredentialStatus(record: Record<string, unknown>, key: CredentialSt
}
export function resolveConfiguredFromCredentialStatuses(account: unknown): boolean | undefined {
const record = asRecord(account);
const record = isRecord(account) ? account : null;
if (!record) {
return undefined;
}
@@ -82,7 +76,7 @@ export function resolveConfiguredFromRequiredCredentialStatuses(
account: unknown,
requiredKeys: CredentialStatusKey[],
): boolean | undefined {
const record = asRecord(account);
const record = isRecord(account) ? account : null;
if (!record) {
return undefined;
}
@@ -101,7 +95,7 @@ export function resolveConfiguredFromRequiredCredentialStatuses(
}
export function hasConfiguredUnavailableCredentialStatus(account: unknown): boolean {
const record = asRecord(account);
const record = isRecord(account) ? account : null;
if (!record) {
return false;
}
@@ -111,7 +105,7 @@ export function hasConfiguredUnavailableCredentialStatus(account: unknown): bool
}
export function hasResolvedCredentialValue(account: unknown): boolean {
const record = asRecord(account);
const record = isRecord(account) ? account : null;
if (!record) {
return false;
}
@@ -137,7 +131,7 @@ export function projectCredentialSnapshotFields(
| "signingSecretStatus"
| "userTokenStatus"
> {
const record = asRecord(account);
const record = isRecord(account) ? account : null;
if (!record) {
return {};
}
@@ -176,7 +170,7 @@ export function projectCredentialSnapshotFields(
export function projectSafeChannelAccountSnapshotFields(
account: unknown,
): Partial<ChannelAccountSnapshot> {
const record = asRecord(account);
const record = isRecord(account) ? account : null;
if (!record) {
return {};
}

View File

@@ -38,6 +38,27 @@ describe("bundled channel entry shape guards", () => {
expect(bundled.listBundledChannelPlugins()).toEqual([]);
expect(bundled.listBundledChannelSetupPlugins()).toEqual([]);
});
it("loads real bundled channel entries from the source tree", async () => {
const bundled = await importFreshModule<typeof import("./bundled.js")>(
import.meta.url,
"./bundled.js?scope=real-bundled-source-tree",
);
expect(bundled.requireBundledChannelPlugin("slack").id).toBe("slack");
expect(() =>
bundled.setBundledChannelRuntime("line", {
channel: {
line: {
listLineAccountIds: () => [],
resolveDefaultLineAccountId: () => undefined,
resolveLineAccount: () => null,
},
},
} as never),
).not.toThrow();
});
it("keeps channel entrypoints on the dedicated entry-contract SDK surface", () => {
const offenders: string[] = [];

View File

@@ -1,6 +1,9 @@
import { listConfiguredBindings } from "../../config/bindings.js";
import type { OpenClawConfig } from "../../config/config.js";
import { getActivePluginChannelRegistryVersion } from "../../plugins/runtime.js";
import {
getActivePluginChannelRegistryVersion,
requireActivePluginChannelRegistry,
} from "../../plugins/runtime.js";
import { pickFirstExistingAgentId } from "../../routing/resolve-route.js";
import { resolveChannelConfiguredBindingProvider } from "./binding-provider.js";
import type { CompiledConfiguredBinding, ConfiguredBindingChannel } from "./binding-types.js";
@@ -19,6 +22,7 @@ export type CompiledConfiguredBindingRegistry = {
};
type CachedCompiledConfiguredBindingRegistry = {
registryRef: object | null;
registryVersion: number;
registry: CompiledConfiguredBindingRegistry;
};
@@ -173,9 +177,10 @@ function compileConfiguredBindingRegistry(params: {
export function resolveCompiledBindingRegistry(
cfg: OpenClawConfig,
): CompiledConfiguredBindingRegistry {
const activeRegistry = requireActivePluginChannelRegistry();
const registryVersion = getActivePluginChannelRegistryVersion();
const cached = compiledRegistryCache.get(cfg);
if (cached?.registryVersion === registryVersion) {
if (cached?.registryVersion === registryVersion && cached.registryRef === activeRegistry) {
return cached.registry;
}
@@ -183,6 +188,7 @@ export function resolveCompiledBindingRegistry(
cfg,
});
compiledRegistryCache.set(cfg, {
registryRef: activeRegistry,
registryVersion,
registry,
});
@@ -192,8 +198,10 @@ export function resolveCompiledBindingRegistry(
export function primeCompiledBindingRegistry(
cfg: OpenClawConfig,
): CompiledConfiguredBindingRegistry {
const activeRegistry = requireActivePluginChannelRegistry();
const registry = compileConfiguredBindingRegistry({ cfg });
compiledRegistryCache.set(cfg, {
registryRef: activeRegistry,
registryVersion: getActivePluginChannelRegistryVersion(),
registry,
});

View File

@@ -1,4 +1,9 @@
export { getChannelPlugin, listChannelPlugins, normalizeChannelId } from "./registry.js";
export {
getChannelPlugin,
getLoadedChannelPlugin,
listChannelPlugins,
normalizeChannelId,
} from "./registry.js";
export {
applyChannelMatchMeta,
buildChannelKeyCandidates,

View File

@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js";
import type { PluginRegistry } from "../../plugins/registry.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
import { listChannelPlugins } from "./registry.js";
import { getChannelPlugin, listChannelPlugins } from "./registry.js";
function withMalformedChannels(registry: PluginRegistry): PluginRegistry {
const malformed = { ...registry } as PluginRegistry;
@@ -21,4 +21,49 @@ describe("listChannelPlugins", () => {
expect(listChannelPlugins()).toEqual([]);
});
it("falls back to bundled channel plugins for direct lookups before registry bootstrap", () => {
setActivePluginRegistry(createEmptyPluginRegistry());
expect(getChannelPlugin("googlechat")?.doctor).toMatchObject({
dmAllowFromMode: "nestedOnly",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: false,
});
});
it("rebuilds channel lookups when the active registry object changes without a version bump", () => {
const first = createEmptyPluginRegistry();
first.channels = [
{
pluginId: "alpha",
plugin: {
id: "alpha",
meta: { label: "alpha" },
} as never,
source: "test",
},
];
setActivePluginRegistry(first);
expect(getChannelPlugin("alpha")?.meta.label).toBe("alpha");
expect(getChannelPlugin("beta")).toBeUndefined();
const second = createEmptyPluginRegistry();
second.channels = [
{
pluginId: "beta",
plugin: {
id: "beta",
meta: { label: "beta" },
} as never,
source: "test",
},
];
setActivePluginRegistry(second);
expect(getChannelPlugin("alpha")).toBeUndefined();
expect(getChannelPlugin("beta")?.meta.label).toBe("beta");
expect(listChannelPlugins().map((plugin) => plugin.id)).toEqual(["beta"]);
});
});

View File

@@ -3,6 +3,7 @@ import {
requireActivePluginChannelRegistry,
} from "../../plugins/runtime.js";
import { CHAT_CHANNEL_ORDER, type ChatChannelId, normalizeAnyChannelId } from "../registry.js";
import { getBundledChannelPlugin } from "./bundled.js";
import type { ChannelId, ChannelPlugin } from "./types.js";
function dedupeChannels(channels: ChannelPlugin[]): ChannelPlugin[] {
@@ -21,12 +22,14 @@ function dedupeChannels(channels: ChannelPlugin[]): ChannelPlugin[] {
type CachedChannelPlugins = {
registryVersion: number;
registryRef: object | null;
sorted: ChannelPlugin[];
byId: Map<string, ChannelPlugin>;
};
const EMPTY_CHANNEL_PLUGIN_CACHE: CachedChannelPlugins = {
registryVersion: -1,
registryRef: null,
sorted: [],
byId: new Map(),
};
@@ -37,7 +40,7 @@ function resolveCachedChannelPlugins(): CachedChannelPlugins {
const registry = requireActivePluginChannelRegistry();
const registryVersion = getActivePluginChannelRegistryVersion();
const cached = cachedChannelPlugins;
if (cached.registryVersion === registryVersion) {
if (cached.registryVersion === registryVersion && cached.registryRef === registry) {
return cached;
}
@@ -67,6 +70,7 @@ function resolveCachedChannelPlugins(): CachedChannelPlugins {
const next: CachedChannelPlugins = {
registryVersion,
registryRef: registry,
sorted,
byId,
};
@@ -78,7 +82,7 @@ export function listChannelPlugins(): ChannelPlugin[] {
return resolveCachedChannelPlugins().sorted.slice();
}
export function getChannelPlugin(id: ChannelId): ChannelPlugin | undefined {
export function getLoadedChannelPlugin(id: ChannelId): ChannelPlugin | undefined {
const resolvedId = String(id).trim();
if (!resolvedId) {
return undefined;
@@ -86,6 +90,14 @@ export function getChannelPlugin(id: ChannelId): ChannelPlugin | undefined {
return resolveCachedChannelPlugins().byId.get(resolvedId);
}
export function getChannelPlugin(id: ChannelId): ChannelPlugin | undefined {
const resolvedId = String(id).trim();
if (!resolvedId) {
return undefined;
}
return getLoadedChannelPlugin(resolvedId) ?? getBundledChannelPlugin(resolvedId);
}
export function normalizeChannelId(raw?: string | null): ChannelId | null {
return normalizeAnyChannelId(raw);
}

View File

@@ -6,7 +6,7 @@ import {
type RawSessionConversationRef,
} from "../../sessions/session-key-utils.js";
import { normalizeChannelId as normalizeChatChannelId } from "../registry.js";
import { getChannelPlugin, normalizeChannelId as normalizeAnyChannelId } from "./registry.js";
import { getLoadedChannelPlugin, normalizeChannelId as normalizeAnyChannelId } from "./registry.js";
export type ResolvedSessionConversation = {
id: string;
@@ -61,7 +61,7 @@ function normalizeResolvedChannel(channel: string): string {
function getMessagingAdapter(channel: string) {
const normalizedChannel = normalizeResolvedChannel(channel);
try {
return getChannelPlugin(normalizedChannel)?.messaging;
return getLoadedChannelPlugin(normalizedChannel)?.messaging;
} catch {
return undefined;
}

View File

@@ -1,6 +1,7 @@
import { z, type ZodType } from "zod";
import type { OpenClawConfig } from "../../config/config.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import { getBundledChannelPlugin } from "./bundled.js";
import { getChannelPlugin } from "./registry.js";
import type { ChannelSetupAdapter } from "./types.adapters.js";
import type { ChannelSetupInput } from "./types.core.js";
@@ -422,7 +423,7 @@ type ChannelSetupPromotionSurface = {
};
function getChannelSetupPromotionSurface(channelKey: string): ChannelSetupPromotionSurface | null {
const setup = getChannelPlugin(channelKey)?.setup;
const setup = getChannelPlugin(channelKey)?.setup ?? getBundledChannelPlugin(channelKey)?.setup;
if (!setup || typeof setup !== "object") {
return null;
}

View File

@@ -8,12 +8,14 @@ import type { ChannelId, ChannelPlugin } from "./types.js";
type CachedChannelSetupPlugins = {
registryVersion: number;
registryRef: object | null;
sorted: ChannelPlugin[];
byId: Map<string, ChannelPlugin>;
};
const EMPTY_CHANNEL_SETUP_CACHE: CachedChannelSetupPlugins = {
registryVersion: -1,
registryRef: null,
sorted: [],
byId: new Map(),
};
@@ -51,7 +53,7 @@ function resolveCachedChannelSetupPlugins(): CachedChannelSetupPlugins {
const registry = requireActivePluginRegistry();
const registryVersion = getActivePluginRegistryVersion();
const cached = cachedChannelSetupPlugins;
if (cached.registryVersion === registryVersion) {
if (cached.registryVersion === registryVersion && cached.registryRef === registry) {
return cached;
}
@@ -66,6 +68,7 @@ function resolveCachedChannelSetupPlugins(): CachedChannelSetupPlugins {
const next: CachedChannelSetupPlugins = {
registryVersion,
registryRef: registry,
sorted,
byId,
};

View File

@@ -1,6 +1,6 @@
import type { ChatType } from "../chat-type.js";
import { normalizeChatChannelId } from "../registry.js";
import { getChannelPlugin, normalizeChannelId } from "./index.js";
import { getChannelPlugin, getLoadedChannelPlugin, normalizeChannelId } from "./index.js";
export type ParsedChannelExplicitTarget = {
to: string;
@@ -29,6 +29,7 @@ function normalizeComparableThreadId(
}
function parseWithPlugin(
getPlugin: (channel: string) => ReturnType<typeof getChannelPlugin>,
rawChannel: string,
rawTarget: string,
): ParsedChannelExplicitTarget | null {
@@ -36,14 +37,21 @@ function parseWithPlugin(
if (!channel) {
return null;
}
return getChannelPlugin(channel)?.messaging?.parseExplicitTarget?.({ raw: rawTarget }) ?? null;
return getPlugin(channel)?.messaging?.parseExplicitTarget?.({ raw: rawTarget }) ?? null;
}
export function parseExplicitTargetForChannel(
channel: string,
rawTarget: string,
): ParsedChannelExplicitTarget | null {
return parseWithPlugin(channel, rawTarget);
return parseWithPlugin(getChannelPlugin, channel, rawTarget);
}
export function parseExplicitTargetForLoadedChannel(
channel: string,
rawTarget: string,
): ParsedChannelExplicitTarget | null {
return parseWithPlugin(getLoadedChannelPlugin, channel, rawTarget);
}
export function resolveComparableTargetForChannel(params: {
@@ -65,6 +73,25 @@ export function resolveComparableTargetForChannel(params: {
};
}
export function resolveComparableTargetForLoadedChannel(params: {
channel: string;
rawTarget?: string | null;
fallbackThreadId?: string | number | null;
}): ComparableChannelTarget | null {
const rawTo = params.rawTarget?.trim();
if (!rawTo) {
return null;
}
const parsed = parseExplicitTargetForLoadedChannel(params.channel, rawTo);
const fallbackThreadId = normalizeComparableThreadId(params.fallbackThreadId);
return {
rawTo,
to: parsed?.to ?? rawTo,
threadId: normalizeComparableThreadId(parsed?.threadId ?? fallbackThreadId),
chatType: parsed?.chatType,
};
}
export function comparableChannelTargetsMatch(params: {
left?: ComparableChannelTarget | null;
right?: ComparableChannelTarget | null;

View File

@@ -0,0 +1,703 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { runRegisteredCli } from "../test-utils/command-runner.js";
import { registerCapabilityCli } from "./capability-cli.js";
const mocks = vi.hoisted(() => ({
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number) => {
throw new Error(`exit ${code}`);
}),
writeJson: vi.fn(),
writeStdout: vi.fn(),
},
loadConfig: vi.fn(() => ({})),
loadAuthProfileStoreForRuntime: vi.fn(() => ({ profiles: {}, order: {} })),
listProfilesForProvider: vi.fn(() => []),
resolveMemorySearchConfig: vi.fn(() => null),
loadModelCatalog: vi.fn(async () => []),
agentCommand: vi.fn(async () => ({
payloads: [{ text: "local reply" }],
meta: { agentMeta: { provider: "openai", model: "gpt-5.4" } },
})),
callGateway: vi.fn(async ({ method }: { method: string }) => {
if (method === "tts.status") {
return { enabled: true, provider: "openai" };
}
if (method === "agent") {
return {
result: {
payloads: [{ text: "gateway reply" }],
meta: { agentMeta: { provider: "anthropic", model: "claude-sonnet-4-6" } },
},
};
}
return {};
}),
describeImageFile: vi.fn(async () => ({
text: "friendly lobster",
provider: "openai",
model: "gpt-4.1-mini",
})),
generateImage: vi.fn(),
transcribeAudioFile: vi.fn(async () => ({ text: "meeting notes" })),
textToSpeech: vi.fn(async () => ({
success: true,
audioPath: "/tmp/tts-source.mp3",
provider: "openai",
outputFormat: "mp3",
voiceCompatible: false,
attempts: [],
})),
setTtsProvider: vi.fn(),
resolveExplicitTtsOverrides: vi.fn(
({
provider,
modelId,
voiceId,
}: {
provider?: string;
modelId?: string;
voiceId?: string;
}) => ({
...(provider ? { provider } : {}),
...(modelId || voiceId
? {
providerOverrides: {
[provider ?? "openai"]: {
...(modelId ? { modelId } : {}),
...(voiceId ? { voiceId } : {}),
},
},
}
: {}),
}),
),
createEmbeddingProvider: vi.fn(async () => ({
provider: {
id: "openai",
model: "text-embedding-3-small",
embedQuery: async () => [0.1, 0.2],
embedBatch: async (texts: string[]) => texts.map(() => [0.1, 0.2]),
},
})),
registerMemoryEmbeddingProvider: vi.fn(),
listMemoryEmbeddingProviders: vi.fn(() => [
{ id: "openai", defaultModel: "text-embedding-3-small", transport: "remote" },
]),
registerBuiltInMemoryEmbeddingProviders: vi.fn(),
isWebSearchProviderConfigured: vi.fn(() => false),
isWebFetchProviderConfigured: vi.fn(() => false),
modelsStatusCommand: vi.fn(
async (_opts: unknown, runtime: { log: (...args: unknown[]) => void }) => {
runtime.log(JSON.stringify({ ok: true, providers: [{ id: "openai" }] }));
},
),
}));
vi.mock("../runtime.js", () => ({
defaultRuntime: mocks.runtime,
writeRuntimeJson: (runtime: { writeJson: (value: unknown) => void }, value: unknown) =>
runtime.writeJson(value),
}));
vi.mock("../config/config.js", () => ({
loadConfig: (...args: unknown[]) => mocks.loadConfig(...args),
}));
vi.mock("../agents/agent-command.js", () => ({
agentCommand: (...args: unknown[]) => mocks.agentCommand(...args),
}));
vi.mock("../agents/agent-scope.js", () => ({
resolveDefaultAgentId: () => "main",
resolveAgentDir: () => "/tmp/agent",
}));
vi.mock("../agents/model-catalog.js", () => ({
loadModelCatalog: (...args: unknown[]) => mocks.loadModelCatalog(...args),
}));
vi.mock("../agents/auth-profiles.js", () => ({
loadAuthProfileStoreForRuntime: (...args: unknown[]) =>
mocks.loadAuthProfileStoreForRuntime(...args),
listProfilesForProvider: (...args: unknown[]) => mocks.listProfilesForProvider(...args),
}));
vi.mock("../agents/memory-search.js", () => ({
resolveMemorySearchConfig: (...args: unknown[]) => mocks.resolveMemorySearchConfig(...args),
}));
vi.mock("../commands/models.js", () => ({
modelsAuthLoginCommand: vi.fn(),
modelsStatusCommand: (...args: unknown[]) => mocks.modelsStatusCommand(...args),
}));
vi.mock("../gateway/call.js", () => ({
callGateway: (...args: unknown[]) => mocks.callGateway(...args),
randomIdempotencyKey: () => "run-1",
}));
vi.mock("../gateway/connection-details.js", () => ({
buildGatewayConnectionDetailsWithResolvers: vi.fn(() => ({
url: "ws://127.0.0.1:18789",
urlSource: "local loopback",
message: "Gateway target: ws://127.0.0.1:18789",
})),
}));
vi.mock("../media-understanding/runtime.js", () => ({
describeImageFile: (...args: unknown[]) => mocks.describeImageFile(...args),
describeVideoFile: vi.fn(),
transcribeAudioFile: (...args: unknown[]) => mocks.transcribeAudioFile(...args),
}));
vi.mock("../../extensions/memory-core/src/memory/embeddings.js", () => ({
createEmbeddingProvider: (...args: unknown[]) => mocks.createEmbeddingProvider(...args),
}));
vi.mock("../plugins/memory-embedding-providers.js", () => ({
listMemoryEmbeddingProviders: (...args: unknown[]) => mocks.listMemoryEmbeddingProviders(...args),
registerMemoryEmbeddingProvider: (...args: unknown[]) =>
mocks.registerMemoryEmbeddingProvider(...args),
}));
vi.mock("../../extensions/memory-core/src/memory/provider-adapters.js", () => ({
registerBuiltInMemoryEmbeddingProviders: (...args: unknown[]) =>
mocks.registerBuiltInMemoryEmbeddingProviders(...args),
}));
vi.mock("../image-generation/runtime.js", () => ({
generateImage: (...args: unknown[]) => mocks.generateImage(...args),
listRuntimeImageGenerationProviders: vi.fn(() => []),
}));
vi.mock("../video-generation/runtime.js", () => ({
generateVideo: vi.fn(),
listRuntimeVideoGenerationProviders: vi.fn(() => []),
}));
vi.mock("../tts/tts.js", () => ({
getTtsProvider: vi.fn(() => "openai"),
listSpeechVoices: vi.fn(async () => []),
resolveTtsConfig: vi.fn(() => ({})),
resolveTtsPrefsPath: vi.fn(() => "/tmp/tts.json"),
setTtsEnabled: vi.fn(),
setTtsProvider: (...args: unknown[]) => mocks.setTtsProvider(...args),
resolveExplicitTtsOverrides: (...args: unknown[]) => mocks.resolveExplicitTtsOverrides(...args),
textToSpeech: (...args: unknown[]) => mocks.textToSpeech(...args),
}));
vi.mock("../tts/provider-registry.js", () => ({
canonicalizeSpeechProviderId: vi.fn((provider: string) => provider),
listSpeechProviders: vi.fn(() => []),
}));
vi.mock("../web-search/runtime.js", () => ({
listWebSearchProviders: vi.fn(() => []),
isWebSearchProviderConfigured: (...args: unknown[]) =>
mocks.isWebSearchProviderConfigured(...args),
runWebSearch: vi.fn(),
}));
vi.mock("../web-fetch/runtime.js", () => ({
listWebFetchProviders: vi.fn(() => []),
isWebFetchProviderConfigured: (...args: unknown[]) => mocks.isWebFetchProviderConfigured(...args),
resolveWebFetchDefinition: vi.fn(),
}));
describe("capability cli", () => {
beforeEach(() => {
mocks.runtime.log.mockClear();
mocks.runtime.error.mockClear();
mocks.runtime.writeJson.mockClear();
mocks.loadModelCatalog
.mockReset()
.mockResolvedValue([{ id: "gpt-5.4", provider: "openai", name: "GPT-5.4" }]);
mocks.loadAuthProfileStoreForRuntime.mockReset().mockReturnValue({ profiles: {}, order: {} });
mocks.listProfilesForProvider.mockReset().mockReturnValue([]);
mocks.resolveMemorySearchConfig.mockReset().mockReturnValue(null);
mocks.agentCommand.mockClear();
mocks.callGateway.mockClear().mockImplementation(async ({ method }: { method: string }) => {
if (method === "tts.status") {
return { enabled: true, provider: "openai" };
}
if (method === "agent") {
return {
result: {
payloads: [{ text: "gateway reply" }],
meta: { agentMeta: { provider: "anthropic", model: "claude-sonnet-4-6" } },
},
};
}
return {};
});
mocks.describeImageFile.mockClear();
mocks.generateImage.mockReset();
mocks.transcribeAudioFile.mockClear();
mocks.textToSpeech.mockClear();
mocks.setTtsProvider.mockClear();
mocks.resolveExplicitTtsOverrides.mockClear();
mocks.createEmbeddingProvider.mockClear();
mocks.registerMemoryEmbeddingProvider.mockClear();
mocks.registerBuiltInMemoryEmbeddingProviders.mockClear();
mocks.isWebSearchProviderConfigured.mockReset().mockReturnValue(false);
mocks.isWebFetchProviderConfigured.mockReset().mockReturnValue(false);
mocks.modelsStatusCommand.mockClear();
mocks.callGateway.mockImplementation(async ({ method }: { method: string }) => {
if (method === "tts.status") {
return { enabled: true, provider: "openai" };
}
if (method === "tts.convert") {
return {
audioPath: "/tmp/gateway-tts.mp3",
provider: "openai",
outputFormat: "mp3",
voiceCompatible: false,
};
}
if (method === "agent") {
return {
result: {
payloads: [{ text: "gateway reply" }],
meta: { agentMeta: { provider: "anthropic", model: "claude-sonnet-4-6" } },
},
};
}
return {};
});
});
it("lists canonical capabilities", async () => {
await runRegisteredCli({
register: registerCapabilityCli as (program: Command) => void,
argv: ["capability", "list", "--json"],
});
const payload = mocks.runtime.writeJson.mock.calls[0]?.[0] as Array<{ id: string }>;
expect(payload.some((entry) => entry.id === "model.run")).toBe(true);
expect(payload.some((entry) => entry.id === "media.image.describe")).toBe(true);
});
it("defaults model run to local transport", async () => {
await runRegisteredCli({
register: registerCapabilityCli as (program: Command) => void,
argv: ["capability", "model", "run", "--prompt", "hello", "--json"],
});
expect(mocks.agentCommand).toHaveBeenCalledTimes(1);
expect(mocks.callGateway).not.toHaveBeenCalled();
expect(mocks.runtime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
capability: "model.run",
transport: "local",
}),
);
});
it("defaults tts status to gateway transport", async () => {
await runRegisteredCli({
register: registerCapabilityCli as (program: Command) => void,
argv: ["capability", "media", "tts", "status", "--json"],
});
expect(mocks.callGateway).toHaveBeenCalledWith(
expect.objectContaining({ method: "tts.status" }),
);
expect(mocks.runtime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({ transport: "gateway" }),
);
});
it("routes image describe through media understanding, not generation", async () => {
await runRegisteredCli({
register: registerCapabilityCli as (program: Command) => void,
argv: ["capability", "media", "image", "describe", "--file", "photo.jpg", "--json"],
});
expect(mocks.describeImageFile).toHaveBeenCalledWith(
expect.objectContaining({ filePath: expect.stringMatching(/photo\.jpg$/) }),
);
expect(mocks.runtime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
capability: "media.image.describe",
outputs: [expect.objectContaining({ kind: "image.description" })],
}),
);
});
it("fails image describe when no description text is returned", async () => {
mocks.describeImageFile.mockResolvedValueOnce({
text: undefined,
provider: undefined,
model: undefined,
});
await expect(
runRegisteredCli({
register: registerCapabilityCli as (program: Command) => void,
argv: ["capability", "media", "image", "describe", "--file", "photo.jpg", "--json"],
}),
).rejects.toThrow("exit 1");
expect(mocks.runtime.error).toHaveBeenCalledWith(
expect.stringMatching(/No description returned for image/),
);
});
it("rewrites mismatched explicit image output extensions to the detected file type", async () => {
const jpegBase64 =
"/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAkGBxAQEBUQEBAVFRUVFRUVFRUVFRUVFRUVFRUXFhUVFRUYHSggGBolHRUVITEhJSkrLi4uFx8zODMsNygtLisBCgoKDg0OGhAQGi0fHyUtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLf/AABEIAAEAAQMBIgACEQEDEQH/xAAXAAEBAQEAAAAAAAAAAAAAAAAAAQID/8QAFhEBAQEAAAAAAAAAAAAAAAAAAAER/9oADAMBAAIQAxAAAAH2AP/EABgQAQEAAwAAAAAAAAAAAAAAAAEAEQIS/9oACAEBAAEFAk1o7//EABYRAQEBAAAAAAAAAAAAAAAAAAABEf/aAAgBAwEBPwGn/8QAFhEBAQEAAAAAAAAAAAAAAAAAABEB/9oACAECAQE/AYf/xAAaEAACAgMAAAAAAAAAAAAAAAABEQAhMUFh/9oACAEBAAY/AjK9cY2f/8QAGhABAQACAwAAAAAAAAAAAAAAAAERITFBUf/aAAgBAQABPyGQk7W5jVYkA//Z";
mocks.generateImage.mockResolvedValue({
provider: "openai",
model: "gpt-image-1",
attempts: [],
images: [
{
buffer: Buffer.from(jpegBase64, "base64"),
mimeType: "image/png",
fileName: "provider-output.png",
},
],
});
const tempOutput = path.join(os.tmpdir(), `openclaw-image-mismatch-${Date.now()}.png`);
await fs.rm(tempOutput, { force: true });
await fs.rm(tempOutput.replace(/\.png$/, ".jpg"), { force: true });
await runRegisteredCli({
register: registerCapabilityCli as (program: Command) => void,
argv: [
"capability",
"media",
"image",
"generate",
"--prompt",
"friendly lobster",
"--output",
tempOutput,
"--json",
],
});
expect(mocks.runtime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
outputs: [
expect.objectContaining({
path: tempOutput.replace(/\.png$/, ".jpg"),
mimeType: "image/jpeg",
}),
],
}),
);
});
it("routes audio transcribe through transcription, not realtime", async () => {
await runRegisteredCli({
register: registerCapabilityCli as (program: Command) => void,
argv: ["capability", "media", "audio", "transcribe", "--file", "memo.m4a", "--json"],
});
expect(mocks.transcribeAudioFile).toHaveBeenCalledWith(
expect.objectContaining({ filePath: expect.stringMatching(/memo\.m4a$/) }),
);
expect(mocks.runtime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
capability: "media.audio.transcribe",
outputs: [expect.objectContaining({ kind: "audio.transcription" })],
}),
);
});
it("fails audio transcribe when no transcript text is returned", async () => {
mocks.transcribeAudioFile.mockResolvedValueOnce({ text: undefined });
await expect(
runRegisteredCli({
register: registerCapabilityCli as (program: Command) => void,
argv: ["capability", "media", "audio", "transcribe", "--file", "memo.m4a", "--json"],
}),
).rejects.toThrow("exit 1");
expect(mocks.runtime.error).toHaveBeenCalledWith(
expect.stringMatching(/No transcript returned for audio/),
);
});
it("forwards transcription prompt and language hints", async () => {
await runRegisteredCli({
register: registerCapabilityCli as (program: Command) => void,
argv: [
"capability",
"media",
"audio",
"transcribe",
"--file",
"memo.m4a",
"--language",
"en",
"--prompt",
"Focus on names",
"--json",
],
});
expect(mocks.transcribeAudioFile).toHaveBeenCalledWith(
expect.objectContaining({
filePath: expect.stringMatching(/memo\.m4a$/),
language: "en",
prompt: "Focus on names",
}),
);
});
it("uses request-scoped TTS overrides without mutating prefs", async () => {
await runRegisteredCli({
register: registerCapabilityCli as (program: Command) => void,
argv: [
"capability",
"media",
"tts",
"convert",
"--text",
"hello",
"--model",
"openai/gpt-4o-mini-tts",
"--voice",
"alloy",
"--json",
],
});
expect(mocks.textToSpeech).toHaveBeenCalledWith(
expect.objectContaining({
overrides: expect.objectContaining({
provider: "openai",
providerOverrides: expect.objectContaining({
openai: expect.objectContaining({
modelId: "gpt-4o-mini-tts",
voiceId: "alloy",
}),
}),
}),
}),
);
expect(mocks.setTtsProvider).not.toHaveBeenCalled();
});
it("disables TTS fallback when explicit provider or voice/model selection is requested", async () => {
await runRegisteredCli({
register: registerCapabilityCli as (program: Command) => void,
argv: [
"capability",
"media",
"tts",
"convert",
"--text",
"hello",
"--model",
"openai/gpt-4o-mini-tts",
"--voice",
"alloy",
"--json",
],
});
expect(mocks.textToSpeech).toHaveBeenCalledWith(
expect.objectContaining({
disableFallback: true,
}),
);
});
it("does not infer and forward a local provider guess for gateway TTS overrides", async () => {
await runRegisteredCli({
register: registerCapabilityCli as (program: Command) => void,
argv: [
"capability",
"media",
"tts",
"convert",
"--gateway",
"--text",
"hello",
"--voice",
"alloy",
"--json",
],
});
expect(mocks.callGateway).toHaveBeenCalledWith(
expect.objectContaining({
method: "tts.convert",
params: expect.objectContaining({
provider: undefined,
voiceId: "alloy",
}),
}),
);
});
it("fails clearly when gateway TTS output is requested against a remote gateway", async () => {
const gatewayConnection = await import("../gateway/connection-details.js");
vi.mocked(gatewayConnection.buildGatewayConnectionDetailsWithResolvers).mockReturnValueOnce({
url: "wss://gateway.example.com",
urlSource: "config gateway.remote.url",
message: "Gateway target: wss://gateway.example.com",
});
await expect(
runRegisteredCli({
register: registerCapabilityCli as (program: Command) => void,
argv: [
"capability",
"media",
"tts",
"convert",
"--gateway",
"--text",
"hello",
"--output",
"hello.mp3",
"--json",
],
}),
).rejects.toThrow("exit 1");
expect(mocks.runtime.error).toHaveBeenCalledWith(
expect.stringContaining("--output is not supported for remote gateway TTS yet"),
);
});
it("uses only embedding providers for embedding creation", async () => {
await runRegisteredCli({
register: registerCapabilityCli as (program: Command) => void,
argv: ["capability", "embedding", "create", "--text", "hello", "--json"],
});
expect(mocks.createEmbeddingProvider).toHaveBeenCalledWith(
expect.objectContaining({
provider: "auto",
fallback: "none",
}),
);
expect(mocks.runtime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
capability: "embedding.create",
provider: "openai",
model: "text-embedding-3-small",
}),
);
});
it("bootstraps built-in embedding providers when the registry is empty", async () => {
mocks.listMemoryEmbeddingProviders.mockReturnValueOnce([]);
await runRegisteredCli({
register: registerCapabilityCli as (program: Command) => void,
argv: ["capability", "embedding", "providers", "--json"],
});
expect(mocks.registerBuiltInMemoryEmbeddingProviders).toHaveBeenCalledWith(
expect.objectContaining({
registerMemoryEmbeddingProvider: expect.any(Function),
}),
);
});
it("surfaces available, configured, and selected for web providers", async () => {
mocks.loadConfig.mockReturnValue({
tools: {
web: {
search: { provider: "gemini" },
fetch: { provider: "firecrawl" },
},
},
});
const webSearchRuntime = await import("../web-search/runtime.js");
const webFetchRuntime = await import("../web-fetch/runtime.js");
vi.mocked(webSearchRuntime.listWebSearchProviders).mockReturnValue([
{ id: "brave", envVars: ["BRAVE_API_KEY"] } as never,
{ id: "gemini", envVars: ["GEMINI_API_KEY"] } as never,
]);
vi.mocked(webFetchRuntime.listWebFetchProviders).mockReturnValue([
{ id: "firecrawl", envVars: ["FIRECRAWL_API_KEY"] } as never,
]);
mocks.isWebSearchProviderConfigured.mockReturnValueOnce(false).mockReturnValueOnce(true);
mocks.isWebFetchProviderConfigured.mockReturnValueOnce(true);
await runRegisteredCli({
register: registerCapabilityCli as (program: Command) => void,
argv: ["capability", "web", "providers", "--json"],
});
expect(mocks.runtime.writeJson).toHaveBeenCalledWith({
search: [
{
available: true,
configured: false,
selected: false,
id: "brave",
envVars: ["BRAVE_API_KEY"],
},
{
available: true,
configured: true,
selected: true,
id: "gemini",
envVars: ["GEMINI_API_KEY"],
},
],
fetch: [
{
available: true,
configured: true,
selected: true,
id: "firecrawl",
envVars: ["FIRECRAWL_API_KEY"],
},
],
});
});
it("surfaces selected and configured embedding provider state", async () => {
mocks.loadConfig.mockReturnValue({});
mocks.resolveMemorySearchConfig.mockReturnValue({
provider: "gemini",
model: "gemini-embedding-001",
});
mocks.listMemoryEmbeddingProviders.mockReturnValue([
{ id: "openai", defaultModel: "text-embedding-3-small", transport: "remote" },
{ id: "gemini", defaultModel: "gemini-embedding-001", transport: "remote" },
]);
await runRegisteredCli({
register: registerCapabilityCli as (program: Command) => void,
argv: ["capability", "embedding", "providers", "--json"],
});
expect(mocks.runtime.writeJson).toHaveBeenCalledWith([
{
available: true,
configured: false,
selected: false,
id: "openai",
defaultModel: "text-embedding-3-small",
transport: "remote",
autoSelectPriority: undefined,
},
{
available: true,
configured: true,
selected: true,
id: "gemini",
defaultModel: "gemini-embedding-001",
transport: "remote",
autoSelectPriority: undefined,
},
]);
});
});

1805
src/cli/capability-cli.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -17,31 +17,31 @@ const SECRET_TARGET_CALLSITES = [
function hasSupportedTargetIdsWiring(source: string): boolean {
return (
/targetIds:\s*get[A-Za-z0-9_]+\(\)/m.test(source) ||
/targetIds:\s*scopedTargets\.targetIds/m.test(source) ||
source.includes("collectStatusScanOverview({")
/targetIds:\s*scopedTargets\.targetIds/m.test(source)
);
}
function usesSharedSecretResolver(source: string): boolean {
function hasSupportedSecretResolutionWiring(source: string): boolean {
return (
source.includes("resolveCommandSecretRefsViaGateway") ||
source.includes("resolveCommandConfigWithSecrets") ||
source.includes("collectStatusScanOverview({")
/resolveCommandConfigWithSecrets\(/.test(source) ||
/resolveCommandSecretRefsViaGateway\(/.test(source) ||
/collectStatusScanOverview\(/.test(source)
);
}
function usesDelegatedStatusOverviewFlow(source: string): boolean {
return /collectStatusScanOverview\(/.test(source);
}
describe("command secret resolution coverage", () => {
it.each(SECRET_TARGET_CALLSITES)(
"routes target-id command path through shared secret resolver: %s",
"routes target-id command path through shared secret resolution flow: %s",
async (relativePath) => {
const source = await readCommandSource(relativePath);
expect(usesSharedSecretResolver(source)).toBe(true);
expect(hasSupportedTargetIdsWiring(source)).toBe(true);
expect(
source.includes("resolveCommandSecretRefsViaGateway({") ||
source.includes("resolveCommandConfigWithSecrets({") ||
source.includes("collectStatusScanOverview({"),
).toBe(true);
expect(hasSupportedSecretResolutionWiring(source)).toBe(true);
if (!usesDelegatedStatusOverviewFlow(source)) {
expect(hasSupportedTargetIdsWiring(source)).toBe(true);
}
},
);
});

View File

@@ -1,5 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { __testing, ensurePluginRegistryLoaded } from "./plugin-registry.js";
const mocks = vi.hoisted(() => ({
applyPluginAutoEnable: vi.fn(),
@@ -36,8 +35,13 @@ vi.mock("../plugins/runtime.js", () => ({
getActivePluginRegistry: mocks.getActivePluginRegistry,
}));
let ensurePluginRegistryLoaded: typeof import("./plugin-registry.js").ensurePluginRegistryLoaded;
let __testing: typeof import("./plugin-registry.js").__testing;
describe("ensurePluginRegistryLoaded", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({ ensurePluginRegistryLoaded, __testing } = await import("./plugin-registry.js"));
vi.clearAllMocks();
__testing.resetPluginRegistryLoadedForTests();
mocks.getActivePluginRegistry.mockReturnValue({
@@ -230,9 +234,6 @@ describe("ensurePluginRegistryLoaded", () => {
channels: [{ plugin: { id: "demo-channel-b" } }],
tools: [],
});
const { ensurePluginRegistryLoaded } = await import("./plugin-registry.js");
ensurePluginRegistryLoaded({ scope: "configured-channels" });
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(1);

View File

@@ -74,6 +74,15 @@ const entrySpecs: readonly CommandGroupDescriptorSpec<SubCliRegistrar>[] = [
loadModule: () => import("../models-cli.js"),
exportName: "registerModelsCli",
},
{
name: "capability",
description: "Run provider-backed capability commands",
hasSubcommands: true,
register: async (program) => {
const mod = await import("../capability-cli.js");
mod.registerCapabilityCli(program);
},
},
{
commandNames: ["approvals"],
loadModule: () => import("../exec-approvals-cli.js"),

View File

@@ -22,6 +22,11 @@ const subCliCommandCatalog = defineCommandDescriptorCatalog([
description: "Discover, scan, and configure models",
hasSubcommands: true,
},
{
name: "capability",
description: "Run provider-backed capability commands",
hasSubcommands: true,
},
{
name: "approvals",
description: "Manage exec approvals (gateway or node host)",

View File

@@ -3,6 +3,7 @@ import type { ChannelPlugin } from "../channels/plugins/types.js";
import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js";
import type { OpenClawConfig } from "../config/config.js";
import { formatErrorMessage } from "../infra/errors.js";
import { isRecord } from "../utils.js";
export type ChannelDefaultAccountContext = {
accountIds: string[];
@@ -20,15 +21,8 @@ export type ChannelDefaultAccountContext = {
export type ChannelAccountContextMode = "strict" | "read_only";
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function getBooleanField(value: unknown, key: string): boolean | undefined {
const record = asRecord(value);
const record = isRecord(value) ? value : null;
if (!record) {
return undefined;
}

View File

@@ -13,7 +13,6 @@ import { emitDoctorNotes } from "./doctor/emit-notes.js";
import { finalizeDoctorConfigFlow } from "./doctor/finalize-config-flow.js";
import { runDoctorRepairSequence } from "./doctor/repair-sequencing.js";
import {
collectChannelDoctorCompatibilityMutations,
collectChannelDoctorMutableAllowlistWarnings,
collectChannelDoctorStaleConfigMutations,
runChannelDoctorConfigSequences,
@@ -110,19 +109,6 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
}));
}
for (const compatibility of collectChannelDoctorCompatibilityMutations(candidate)) {
if (compatibility.changes.length === 0) {
continue;
}
note(compatibility.changes.join("\n"), "Doctor changes");
({ cfg, candidate, pendingChanges, fixHints } = applyDoctorConfigMutation({
state: { cfg, candidate, pendingChanges, fixHints },
mutation: compatibility,
shouldRepair,
fixHint: `Run "${doctorFixCommand}" to apply these changes.`,
}));
}
const autoEnable = applyPluginAutoEnable({ config: candidate, env: process.env });
if (autoEnable.changes.length > 0) {
note(autoEnable.changes.join("\n"), "Doctor changes");

View File

@@ -25,6 +25,7 @@ import {
import { note } from "../terminal/note.js";
import { resolveUserPath } from "../utils.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
import { isRecord } from "./doctor/shared/legacy-config-record-shared.js";
function resolveSuggestedRemoteMemoryProvider(): string | undefined {
return listBuiltinAutoSelectMemoryEmbeddingProviderDoctorMetadata().find(
@@ -39,13 +40,6 @@ type RuntimeMemoryAuditContext = {
qmdCollections?: number;
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
async function resolveRuntimeMemoryAuditContext(
cfg: OpenClawConfig,
): Promise<RuntimeMemoryAuditContext | null> {
@@ -61,7 +55,8 @@ async function resolveRuntimeMemoryAuditContext(
}
try {
const status = manager.status();
const customQmd = asRecord(asRecord(status.custom)?.qmd);
const customQmd =
isRecord(status.custom) && isRecord(status.custom.qmd) ? status.custom.qmd : null;
return {
workspaceDir: status.workspaceDir?.trim(),
backend: status.backend,

View File

@@ -1,3 +1,4 @@
import { getBundledChannelPlugin } from "../../channels/plugins/bundled.js";
import { getChannelPlugin } from "../../channels/plugins/index.js";
import type { AllowFromMode } from "./shared/allow-from-mode.js";
@@ -21,7 +22,8 @@ export function getDoctorChannelCapabilities(channelName?: string): DoctorChanne
if (!channelName) {
return DEFAULT_DOCTOR_CHANNEL_CAPABILITIES;
}
const pluginDoctor = getChannelPlugin(channelName)?.doctor;
const pluginDoctor =
getChannelPlugin(channelName)?.doctor ?? getBundledChannelPlugin(channelName)?.doctor;
if (pluginDoctor) {
return {
dmAllowFromMode:

View File

@@ -1,3 +1,4 @@
import { listBundledChannelPlugins } from "../../../channels/plugins/bundled.js";
import { listChannelPlugins } from "../../../channels/plugins/registry.js";
import type {
ChannelDoctorAdapter,
@@ -12,16 +13,36 @@ type ChannelDoctorEntry = {
doctor: ChannelDoctorAdapter;
};
function listChannelDoctorEntries(): ChannelDoctorEntry[] {
function safeListActiveChannelPlugins() {
try {
return listChannelPlugins()
.flatMap((plugin) => (plugin.doctor ? [{ channelId: plugin.id, doctor: plugin.doctor }] : []))
.filter((entry) => entry.doctor);
return listChannelPlugins();
} catch {
return [];
}
}
function safeListBundledChannelPlugins() {
try {
return listBundledChannelPlugins();
} catch {
return [];
}
}
function listChannelDoctorEntries(): ChannelDoctorEntry[] {
const byId = new Map<string, ChannelDoctorEntry>();
for (const plugin of [...safeListActiveChannelPlugins(), ...safeListBundledChannelPlugins()]) {
if (!plugin.doctor) {
continue;
}
const existing = byId.get(plugin.id);
if (!existing) {
byId.set(plugin.id, { channelId: plugin.id, doctor: plugin.doctor });
}
}
return [...byId.values()];
}
export async function runChannelDoctorConfigSequences(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;

View File

@@ -1,14 +1,9 @@
import type { OpenClawConfig } from "../../../config/types.js";
import { applyPluginDoctorCompatibilityMigrations } from "../../../plugins/doctor-contract-registry.js";
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
import { isRecord } from "./legacy-config-record-shared.js";
function collectRelevantDoctorChannelIds(raw: unknown): string[] {
const channels = asRecord(asRecord(raw)?.channels);
const channels = isRecord(raw) && isRecord(raw.channels) ? raw.channels : null;
if (!channels) {
return [];
}

View File

@@ -35,6 +35,45 @@ describe("doctor config flow steps", () => {
expect(result.state.fixHints).toContain(
'Run "openclaw doctor --fix" to migrate legacy config keys.',
);
expect(result.state.pendingChanges).toBe(true);
});
it("keeps pending repair state for legacy issues even when the snapshot is already normalized", () => {
const result = applyLegacyCompatibilityStep({
snapshot: {
exists: true,
parsed: { talk: { voiceId: "voice-1", modelId: "eleven_v3" } },
legacyIssues: [
{
path: "talk",
message: "talk.voiceId/talk.voiceAliases/talk.modelId/talk.outputFormat/talk.apiKey",
},
],
path: "/tmp/config.json",
valid: true,
issues: [],
raw: "{}",
resolved: {},
sourceConfig: {},
config: {},
runtimeConfig: {},
warnings: [],
} satisfies DoctorConfigPreflightResult["snapshot"],
state: {
cfg: {},
candidate: {},
pendingChanges: false,
fixHints: [],
},
shouldRepair: false,
doctorFixCommand: "openclaw doctor --fix",
});
expect(result.changeLines).toEqual([]);
expect(result.state.pendingChanges).toBe(true);
expect(result.state.fixHints).toContain(
'Run "openclaw doctor --fix" to migrate legacy config keys.',
);
});
it("removes unknown keys and adds preview hint", () => {

View File

@@ -28,6 +28,7 @@ export function applyLegacyCompatibilityStep(params: {
return {
state: {
...params.state,
pendingChanges: params.state.pendingChanges || params.snapshot.legacyIssues.length > 0,
fixHints: params.shouldRepair
? params.state.fixHints
: [
@@ -46,7 +47,10 @@ export function applyLegacyCompatibilityStep(params: {
// during preview mode; confirmation only controls whether we write it.
cfg: migrated,
candidate: migrated,
pendingChanges: params.state.pendingChanges || changes.length > 0,
// The read path can normalize legacy config into the snapshot before
// migrateLegacyConfig emits concrete mutations. Legacy issues still mean
// the on-disk config needs a doctor --fix path.
pendingChanges: params.state.pendingChanges || params.snapshot.legacyIssues.length > 0,
fixHints: params.shouldRepair
? params.state.fixHints
: [

View File

@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "../../../config/config.js";
import { runPluginSetupConfigMigrations } from "../../../plugins/setup-registry.js";
import { collectChannelDoctorCompatibilityMutations } from "./channel-doctor.js";
import {
normalizeLegacyBrowserConfig,
normalizeLegacyCrossContextMessageConfig,
@@ -47,6 +48,13 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
next = normalizeLegacyCrossContextMessageConfig(next, changes);
next = normalizeLegacyMediaProviderOptions(next, changes);
next = normalizeLegacyMistralModelMaxTokens(next, changes);
for (const mutation of collectChannelDoctorCompatibilityMutations(next)) {
if (mutation.changes.length === 0) {
continue;
}
next = mutation.config;
changes.push(...mutation.changes);
}
return { config: next, changes };
}

View File

@@ -11,6 +11,18 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function buildLegacyTalkProviderCompat(
talk: Record<string, unknown>,
): Record<string, unknown> | undefined {
const compat: Record<string, unknown> = {};
for (const key of ["voiceId", "voiceAliases", "modelId", "outputFormat", "apiKey"] as const) {
if (talk[key] !== undefined) {
compat[key] = talk[key];
}
}
return Object.keys(compat).length > 0 ? compat : undefined;
}
export function normalizeLegacyBrowserConfig(
cfg: OpenClawConfig,
changes: string[],
@@ -317,8 +329,18 @@ export function normalizeLegacyTalkConfig(cfg: OpenClawConfig, changes: string[]
return cfg;
}
const normalizedTalk = normalizeTalkSection(rawTalk as OpenClawConfig["talk"]);
if (!normalizedTalk || isDeepStrictEqual(normalizedTalk, rawTalk)) {
const normalizedTalk = normalizeTalkSection(rawTalk as OpenClawConfig["talk"]) ?? {};
const legacyProviderCompat = buildLegacyTalkProviderCompat(rawTalk);
if (legacyProviderCompat) {
normalizedTalk.providers = {
...normalizedTalk.providers,
elevenlabs: {
...legacyProviderCompat,
...normalizedTalk.providers?.elevenlabs,
},
};
}
if (Object.keys(normalizedTalk).length === 0 || isDeepStrictEqual(normalizedTalk, rawTalk)) {
return cfg;
}

View File

@@ -136,7 +136,10 @@ describe("legacy migrate mention routing", () => {
});
expect(res.config).toBeNull();
expect(res.changes).toEqual([]);
expect(res.changes).toEqual([
"Skipped channels.telegram.groupMentionsOnly migration because channels.telegram.groups already has an incompatible shape; fix remaining issues manually.",
"Migration applied, but config still invalid; fix remaining issues manually.",
]);
});
it('does not overwrite invalid channels.telegram.groups."*" when migrating groupMentionsOnly', () => {
@@ -152,7 +155,10 @@ describe("legacy migrate mention routing", () => {
});
expect(res.config).toBeNull();
expect(res.changes).toEqual([]);
expect(res.changes).toEqual([
"Skipped channels.telegram.groupMentionsOnly migration because channels.telegram.groups already has an incompatible shape; fix remaining issues manually.",
"Migration applied, but config still invalid; fix remaining issues manually.",
]);
});
});
@@ -366,7 +372,7 @@ describe("legacy migrate channel streaming aliases", () => {
});
});
it("removes legacy googlechat streamMode aliases", () => {
it("rejects legacy googlechat streamMode aliases during validation and removes them in migration", () => {
const raw = {
channels: {
googlechat: {
@@ -381,17 +387,16 @@ describe("legacy migrate channel streaming aliases", () => {
};
const validated = validateConfigObjectWithPlugins(raw);
expect(validated.ok).toBe(true);
if (!validated.ok) {
expect(validated.ok).toBe(false);
if (validated.ok) {
return;
}
expect(
(validated.config.channels?.googlechat as Record<string, unknown> | undefined)?.streamMode,
).toBeUndefined();
expect(
(validated.config.channels?.googlechat?.accounts?.work as Record<string, unknown> | undefined)
?.streamMode,
).toBeUndefined();
expect(validated.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "channels.googlechat" }),
expect.objectContaining({ path: "channels.googlechat.accounts" }),
]),
);
const res = migrateLegacyConfig(raw);
expect(res.changes).toContain(
@@ -411,7 +416,7 @@ describe("legacy migrate channel streaming aliases", () => {
});
describe("legacy migrate nested channel enabled aliases", () => {
it("accepts legacy allow aliases through with-plugins validation and normalizes them", () => {
it("rejects legacy allow aliases during validation and normalizes them in migration", () => {
const raw = {
channels: {
slack: {
@@ -443,26 +448,39 @@ describe("legacy migrate nested channel enabled aliases", () => {
};
const validated = validateConfigObjectWithPlugins(raw);
expect(validated.ok).toBe(true);
if (!validated.ok) {
expect(validated.ok).toBe(false);
if (validated.ok) {
return;
}
expect(validated.config.channels?.slack?.channels?.ops).toEqual({
enabled: false,
});
expect(validated.config.channels?.googlechat?.groups?.["spaces/aaa"]).toEqual({
enabled: true,
});
expect(validated.config.channels?.discord?.guilds?.["100"]?.channels?.general).toEqual({
enabled: false,
});
expect(validated.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "channels.slack" }),
expect.objectContaining({ path: "channels.googlechat" }),
expect.objectContaining({ path: "channels.discord" }),
]),
);
const rawValidated = validateConfigObjectRawWithPlugins(raw);
expect(rawValidated.ok).toBe(true);
if (!rawValidated.ok) {
expect(rawValidated.ok).toBe(false);
if (rawValidated.ok) {
return;
}
expect(rawValidated.config.channels?.slack?.channels?.ops).toEqual({
expect(rawValidated.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "channels.slack" }),
expect.objectContaining({ path: "channels.googlechat" }),
expect.objectContaining({ path: "channels.discord" }),
]),
);
const migrated = migrateLegacyConfig(raw);
expect(migrated.config?.channels?.slack?.channels?.ops).toEqual({
enabled: false,
});
expect(migrated.config?.channels?.googlechat?.groups?.["spaces/aaa"]).toEqual({
enabled: true,
});
expect(migrated.config?.channels?.discord?.guilds?.["100"]?.channels?.general).toEqual({
enabled: false,
});
});
@@ -650,7 +668,7 @@ describe("legacy migrate nested channel enabled aliases", () => {
});
describe("legacy migrate bundled channel private-network aliases", () => {
it("accepts legacy Mattermost private-network aliases through validation and normalizes them", () => {
it("rejects legacy Mattermost private-network aliases during validation and normalizes them in migration", () => {
const raw = {
channels: {
mattermost: {
@@ -665,50 +683,44 @@ describe("legacy migrate bundled channel private-network aliases", () => {
};
const validated = validateConfigObjectWithPlugins(raw);
expect(validated.ok).toBe(true);
if (!validated.ok) {
expect(validated.ok).toBe(false);
if (validated.ok) {
return;
}
expect(validated.config.channels?.mattermost).toEqual({
dmPolicy: "pairing",
groupPolicy: "allowlist",
network: {
dangerouslyAllowPrivateNetwork: true,
},
accounts: {
work: {
dmPolicy: "pairing",
groupPolicy: "allowlist",
network: {
dangerouslyAllowPrivateNetwork: false,
},
},
},
});
expect(validated.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "channels.mattermost" }),
expect.objectContaining({ path: "channels.mattermost.accounts" }),
]),
);
const rawValidated = validateConfigObjectRawWithPlugins(raw);
expect(rawValidated.ok).toBe(true);
if (!rawValidated.ok) {
expect(rawValidated.ok).toBe(false);
if (rawValidated.ok) {
return;
}
expect(rawValidated.config.channels?.mattermost).toEqual({
dmPolicy: "pairing",
groupPolicy: "allowlist",
network: {
dangerouslyAllowPrivateNetwork: true,
},
accounts: {
work: {
dmPolicy: "pairing",
groupPolicy: "allowlist",
network: {
dangerouslyAllowPrivateNetwork: false,
},
},
},
});
expect(rawValidated.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "channels.mattermost" }),
expect.objectContaining({ path: "channels.mattermost.accounts" }),
]),
);
const res = migrateLegacyConfig(raw);
expect(res.config?.channels?.mattermost).toEqual(
expect.objectContaining({
network: {
dangerouslyAllowPrivateNetwork: true,
},
accounts: {
work: expect.objectContaining({
network: {
dangerouslyAllowPrivateNetwork: false,
},
}),
},
}),
);
expect(res.changes).toEqual(
expect.arrayContaining([
"Moved channels.mattermost.allowPrivateNetwork → channels.mattermost.network.dangerouslyAllowPrivateNetwork (true).",

View File

@@ -403,12 +403,17 @@ function moveLegacyStreamingShapeForPath(params: {
}
delete params.entry.nativeStreaming;
changed = true;
} else if (params.resolveNativeTransport && typeof legacyStreaming === "boolean") {
} else if (
params.resolveNativeTransport &&
(typeof legacyStreaming === "boolean" || hadLegacyStreamMode)
) {
const streaming = ensureNestedRecord(params.entry, "streaming");
if (!hasOwnKey(streaming, "nativeTransport")) {
streaming.nativeTransport = params.resolveNativeTransport(legacyNativeTransportInput);
params.changes.push(
`Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.nativeTransport.`,
hadLegacyStreamMode
? `Filled ${params.pathPrefix}.streaming.nativeTransport from legacy ${params.pathPrefix}.streamMode semantics.`
: `Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.nativeTransport.`,
);
changed = true;
}

View File

@@ -10840,9 +10840,6 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
minimum: 0,
maximum: 9007199254740991,
},
requireExplicitMention: {
type: "boolean",
},
},
additionalProperties: false,
},
@@ -11749,9 +11746,6 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
minimum: 0,
maximum: 9007199254740991,
},
requireExplicitMention: {
type: "boolean",
},
},
additionalProperties: false,
},
@@ -12136,10 +12130,6 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
label: "Slack Thread Initial History Limit",
help: "Maximum number of existing Slack thread messages to fetch when starting a new thread session (default: 20, set to 0 to disable).",
},
"thread.requireExplicitMention": {
label: "Slack Thread Require Explicit Mention",
help: "If true, require an explicit @mention even inside threads where the bot has participated. Suppresses implicit thread mention behavior so the bot only responds to explicit @bot mentions in threads (default: false).",
},
},
},
{

View File

@@ -68,7 +68,11 @@ export function collectChannelSchemaMetadata(
for (const [channelId, channelConfig] of Object.entries(record.channelConfigs ?? {})) {
const current = byChannelId.get(channelId);
if (current && current.originRank < originRank) {
if (
current &&
current.originRank < originRank &&
(current.configSchema !== undefined || current.configUiHints !== undefined)
) {
continue;
}
byChannelId.set(channelId, {

View File

@@ -331,7 +331,9 @@ describe("config plugin validation", () => {
}
expect(res.warnings).toContainEqual({
path: "plugins.entries.google",
message: "plugin disabled (not in allowlist) but config is present",
message: expect.stringContaining(
"plugin google: duplicate plugin id detected; bundled plugin will be overridden by config plugin",
),
});
});

View File

@@ -75,6 +75,16 @@ export const FIELD_HELP: Record<string, string> = {
"Control UI hosting settings including enablement, pathing, and browser-origin/auth hardening behavior. Keep UI exposure minimal and pair with strong auth controls before internet-facing deployments.",
"gateway.controlUi.enabled":
"Enables serving the gateway Control UI from the gateway HTTP process when true. Keep enabled for local administration, and disable when an external control surface replaces it.",
"gateway.controlUi.voice":
"Browser voice settings for the Control UI chat, including realtime transcription provider selection and optional assistant speech playback.",
"gateway.controlUi.voice.enabled":
"Enables realtime browser voice sessions for the Control UI chat when a transcription provider is configured.",
"gateway.controlUi.voice.transcriptionProvider":
"Registered realtime transcription provider id used for browser mic input. Keep this explicit so browser voice fails closed when no provider is configured.",
"gateway.controlUi.voice.providers":
"Provider-owned realtime transcription config keyed by provider id for browser voice sessions.",
"gateway.controlUi.voice.playbackEnabled":
"Enables browser speech-synthesis playback for finalized assistant replies during a voice session.",
"gateway.auth":
"Authentication policy for gateway HTTP/WebSocket access including mode, credentials, trusted-proxy behavior, and rate limiting. Keep auth enabled for every non-loopback deployment.",
"gateway.auth.mode":

View File

@@ -62,6 +62,30 @@ describe("talk normalization", () => {
});
});
it("merges duplicate provider ids after trimming", () => {
const normalized = normalizeTalkSection({
provider: " elevenlabs ",
providers: {
" elevenlabs ": {
voiceId: "voice-123",
},
elevenlabs: {
apiKey: "secret-key",
},
},
});
expect(normalized).toEqual({
provider: "elevenlabs",
providers: {
elevenlabs: {
voiceId: "voice-123",
apiKey: "secret-key",
},
},
});
});
it("builds a canonical resolved talk payload for clients", () => {
const payload = buildTalkConfigResponse({
provider: "acme",

View File

@@ -71,7 +71,10 @@ function normalizeTalkProviders(value: unknown): Record<string, TalkProviderConf
if (!normalizedProvider) {
continue;
}
providers[providerId] = normalizedProvider;
providers[providerId] = {
...providers[providerId],
...normalizedProvider,
};
}
return Object.keys(providers).length > 0 ? providers : undefined;
}

View File

@@ -100,6 +100,17 @@ export type GatewayControlUiConfig = {
allowInsecureAuth?: boolean;
/** DANGEROUS: Disable device identity checks for the Control UI (default: false). */
dangerouslyDisableDeviceAuth?: boolean;
/** Realtime voice settings for the browser chat UI. */
voice?: {
/** Enable browser voice sessions for the Control UI chat. */
enabled?: boolean;
/** Registered realtime transcription provider id to use for browser voice. */
transcriptionProvider?: string;
/** Provider-owned realtime transcription config keyed by provider id. */
providers?: Record<string, Record<string, unknown>>;
/** Enable browser speech synthesis playback for assistant replies. */
playbackEnabled?: boolean;
};
};
export type GatewayAuthMode = "none" | "token" | "password" | "trusted-proxy";

View File

@@ -96,14 +96,6 @@ export type SlackThreadConfig = {
inheritParent?: boolean;
/** Maximum number of thread messages to fetch as context when starting a new thread session (default: 20). Set to 0 to disable thread history fetching. */
initialHistoryLimit?: number;
/**
* If true, require explicit @mention even inside threads where the bot has
* previously participated. By default (false), replying in a thread where
* the bot is a participant counts as an implicit mention and bypasses
* requireMention gating. Set to true to suppress implicit thread mentions
* so only explicit @bot mentions trigger replies in threads.
*/
requireExplicitMention?: boolean;
};
export type SlackAccountConfig = {

View File

@@ -49,7 +49,9 @@ describe("config validation allowed-values metadata", () => {
if (!result.ok) {
const issue = result.issues.find((entry) => entry.path === "channels.telegram");
expect(issue).toBeDefined();
expect(issue?.message).toContain('channels.telegram.streaming="off|partial|block"');
expect(issue?.message).toContain(
"channels.telegram.streamMode, channels.telegram.streaming (scalar)",
);
expect(issue?.allowedValues).toBeUndefined();
}
});

View File

@@ -143,7 +143,7 @@ describe("validateConfigObjectRawWithPlugins channel metadata", () => {
});
describe("validateConfigObjectRawWithPlugins plugin config defaults", () => {
it("still injects plugin AJV defaults in raw mode for required defaulted fields", async () => {
it("does not inject plugin AJV defaults in raw mode for required defaulted fields", async () => {
setupPluginSchemaWithRequiredDefault();
await loadValidationModule();
@@ -159,9 +159,7 @@ describe("validateConfigObjectRawWithPlugins plugin config defaults", () => {
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.config.plugins?.entries?.opik?.config).toEqual(
expect.objectContaining({ workspace: "default-workspace" }),
);
expect(result.config.plugins?.entries?.opik?.config).toBeUndefined();
}
});
});

View File

@@ -865,7 +865,6 @@ export const SlackThreadSchema = z
historyScope: z.enum(["thread", "channel"]).optional(),
inheritParent: z.boolean().optional(),
initialHistoryLimit: z.number().int().min(0).optional(),
requireExplicitMention: z.boolean().optional(),
})
.strict();

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