mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-25 16:53:02 +08:00
Compare commits
11 Commits
feat/slack
...
codex/capa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91625aa9f3 | ||
|
|
80c8567f9d | ||
|
|
9d7459f182 | ||
|
|
f7109c15f5 | ||
|
|
16ec0b5a8c | ||
|
|
5a4ca2f608 | ||
|
|
223a6a1d9f | ||
|
|
b1905c1423 | ||
|
|
9bee2a4ede | ||
|
|
0cc4f50576 | ||
|
|
e88c39b0a1 |
@@ -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
|
||||
|
||||
|
||||
@@ -361,14 +361,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"update_plan": {
|
||||
"emoji": "🗺️",
|
||||
"title": "Update Plan",
|
||||
"detailKeys": [
|
||||
"explanation",
|
||||
"plan.0.step"
|
||||
]
|
||||
},
|
||||
"gateway": {
|
||||
"emoji": "🔌",
|
||||
"title": "Gateway",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
116
docs/cli/capability.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { MattermostChannelConfigSchema } from "./config-surface.js";
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
313
extensions/microsoft-foundry/realtime-transcription-provider.ts
Normal file
313
extensions/microsoft-foundry/realtime-transcription-provider.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -34,7 +34,6 @@ function createTestContext() {
|
||||
replyToMode: "off",
|
||||
threadHistoryScope: "thread",
|
||||
threadInheritParent: false,
|
||||
threadRequireExplicitMention: false,
|
||||
slashCommand: {
|
||||
enabled: true,
|
||||
name: "openclaw",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -384,7 +384,6 @@ export async function prepareSlackMessage(params: {
|
||||
},
|
||||
}));
|
||||
const implicitMention = Boolean(
|
||||
!ctx.threadRequireExplicitMention &&
|
||||
!isDirectMessage &&
|
||||
ctx.botUserId &&
|
||||
message.thread_ts &&
|
||||
|
||||
@@ -142,7 +142,6 @@ const baseParams = () => ({
|
||||
mediaMaxBytes: 1,
|
||||
threadHistoryScope: "thread" as const,
|
||||
threadInheritParent: false,
|
||||
threadRequireExplicitMention: false,
|
||||
removeAckAfterReply: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,6 +9,7 @@ export {
|
||||
isTtsProviderConfigured,
|
||||
listSpeechVoices,
|
||||
maybeApplyTtsToPayload,
|
||||
resolveExplicitTtsOverrides,
|
||||
resolveTtsAutoMode,
|
||||
resolveTtsConfig,
|
||||
resolveTtsPrefsPath,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -606,7 +606,7 @@ describe("spawnAcpDirect", () => {
|
||||
expectAgentGatewayCall({
|
||||
deliver: true,
|
||||
channel: "matrix",
|
||||
to: "channel:child-thread",
|
||||
to: "room:!room:example",
|
||||
threadId: "child-thread",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export { getChannelPlugin, listChannelPlugins, normalizeChannelId } from "./registry.js";
|
||||
export {
|
||||
getChannelPlugin,
|
||||
getLoadedChannelPlugin,
|
||||
listChannelPlugins,
|
||||
normalizeChannelId,
|
||||
} from "./registry.js";
|
||||
export {
|
||||
applyChannelMatchMeta,
|
||||
buildChannelKeyCandidates,
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
703
src/cli/capability-cli.test.ts
Normal file
703
src/cli/capability-cli.test.ts
Normal 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
1805
src/cli/capability-cli.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
: [
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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).",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user