mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-23 22:55:24 +00:00
fix: resolve merge conflicts and preserve runtime test fixes
This commit is contained in:
@@ -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";
|
||||
|
||||
|
||||
@@ -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,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 }) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getChannelPlugin } from "../../channels/plugins/index.js";
|
||||
import { getLoadedChannelPlugin } from "../../channels/plugins/index.js";
|
||||
import type { ChannelId } from "../../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { resolveAgentMainSessionKey } from "../../config/sessions/main-session.js";
|
||||
@@ -176,7 +176,7 @@ export async function resolveDeliveryTarget(
|
||||
};
|
||||
}
|
||||
|
||||
const channelPlugin = getChannelPlugin(channel);
|
||||
const channelPlugin = getLoadedChannelPlugin(channel);
|
||||
const resolvedAccountId = normalizeAccountId(accountId);
|
||||
const configuredAllowFromRaw = channelPlugin?.config.resolveAllowFrom?.({
|
||||
cfg,
|
||||
|
||||
@@ -31,9 +31,16 @@ export async function seedSessionStore(
|
||||
sessionKey: string,
|
||||
session: HeartbeatSessionSeed,
|
||||
): Promise<void> {
|
||||
let existingStore: Record<string, unknown> = {};
|
||||
try {
|
||||
existingStore = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
existingStore = {};
|
||||
}
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify({
|
||||
...existingStore,
|
||||
[sessionKey]: {
|
||||
sessionId: session.sessionId ?? "sid",
|
||||
updatedAt: session.updatedAt ?? Date.now(),
|
||||
|
||||
@@ -6,6 +6,7 @@ const getChannelPluginMock = vi.hoisted(() => vi.fn());
|
||||
const applyPluginAutoEnableMock = vi.hoisted(() => vi.fn());
|
||||
const resolveRuntimePluginRegistryMock = vi.hoisted(() => vi.fn());
|
||||
const getActivePluginRegistryMock = vi.hoisted(() => vi.fn());
|
||||
const getActivePluginChannelRegistryMock = vi.hoisted(() => vi.fn());
|
||||
const getActivePluginChannelRegistryVersionMock = vi.hoisted(() => vi.fn());
|
||||
const normalizeMessageChannelMock = vi.hoisted(() => vi.fn());
|
||||
const isDeliverableMessageChannelMock = vi.hoisted(() => vi.fn());
|
||||
@@ -29,6 +30,8 @@ vi.mock("../../plugins/loader.js", () => ({
|
||||
|
||||
vi.mock("../../plugins/runtime.js", () => ({
|
||||
getActivePluginRegistry: (...args: unknown[]) => getActivePluginRegistryMock(...args),
|
||||
getActivePluginChannelRegistry: (...args: unknown[]) =>
|
||||
getActivePluginChannelRegistryMock(...args),
|
||||
getActivePluginChannelRegistryVersion: (...args: unknown[]) =>
|
||||
getActivePluginChannelRegistryVersionMock(...args),
|
||||
}));
|
||||
@@ -68,6 +71,7 @@ describe("outbound channel resolution", () => {
|
||||
applyPluginAutoEnableMock.mockReset();
|
||||
resolveRuntimePluginRegistryMock.mockReset();
|
||||
getActivePluginRegistryMock.mockReset();
|
||||
getActivePluginChannelRegistryMock.mockReset();
|
||||
getActivePluginChannelRegistryVersionMock.mockReset();
|
||||
normalizeMessageChannelMock.mockReset();
|
||||
isDeliverableMessageChannelMock.mockReset();
|
||||
@@ -79,6 +83,7 @@ describe("outbound channel resolution", () => {
|
||||
["telegram", "discord", "slack"].includes(String(value)),
|
||||
);
|
||||
getActivePluginRegistryMock.mockReturnValue({ channels: [] });
|
||||
getActivePluginChannelRegistryMock.mockReturnValue({ channels: [] });
|
||||
getActivePluginChannelRegistryVersionMock.mockReturnValue(1);
|
||||
applyPluginAutoEnableMock.mockReturnValue({
|
||||
config: { autoEnabled: true },
|
||||
@@ -158,6 +163,9 @@ describe("outbound channel resolution", () => {
|
||||
getActivePluginRegistryMock.mockReturnValue({
|
||||
channels: [{ plugin: { id: "discord" } }],
|
||||
});
|
||||
getActivePluginChannelRegistryMock.mockReturnValue({
|
||||
channels: [{ plugin: { id: "discord" } }],
|
||||
});
|
||||
const channelResolution = await importChannelResolution("bootstrap-missing-target");
|
||||
|
||||
expect(
|
||||
|
||||
@@ -56,7 +56,7 @@ describe("formatOutboundDeliverySummary", () => {
|
||||
messageId: "t1",
|
||||
conversationId: "conv-1",
|
||||
},
|
||||
expected: "✅ Sent via msteams. Message ID: t1 (conversation conv-1)",
|
||||
expected: "✅ Sent via Microsoft Teams. Message ID: t1 (conversation conv-1)",
|
||||
},
|
||||
])("formats delivery summary for %j", ({ channel, result, expected }) => {
|
||||
expect(formatOutboundDeliverySummary(channel, result)).toBe(expected);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { getChannelPlugin } from "../../channels/plugins/index.js";
|
||||
import { getLoadedChannelPlugin } from "../../channels/plugins/index.js";
|
||||
import type { ChannelOutboundTargetMode, ChannelPlugin } from "../../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { getActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
|
||||
import {
|
||||
isDeliverableMessageChannel,
|
||||
@@ -18,22 +17,7 @@ function resolveLoadedOutboundChannelPlugin(channel: string): ChannelPlugin | un
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const current = getChannelPlugin(normalized);
|
||||
if (current) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const activeRegistry = getActivePluginRegistry();
|
||||
if (!activeRegistry) {
|
||||
return undefined;
|
||||
}
|
||||
for (const entry of activeRegistry.channels) {
|
||||
const plugin = entry?.plugin;
|
||||
if (plugin?.id === normalized) {
|
||||
return plugin;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
return getLoadedChannelPlugin(normalized);
|
||||
}
|
||||
|
||||
export function tryResolveLoadedOutboundTarget(params: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
comparableChannelTargetsShareRoute,
|
||||
parseExplicitTargetForChannel,
|
||||
resolveComparableTargetForChannel,
|
||||
parseExplicitTargetForLoadedChannel,
|
||||
resolveComparableTargetForLoadedChannel,
|
||||
} from "../../channels/plugins/target-parsing.js";
|
||||
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
@@ -42,7 +42,7 @@ function parseExplicitTargetWithPlugin(params: {
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
return parseExplicitTargetForChannel(provider, raw);
|
||||
return parseExplicitTargetForLoadedChannel(provider, raw);
|
||||
}
|
||||
|
||||
export function resolveSessionDeliveryTarget(params: {
|
||||
@@ -68,7 +68,7 @@ export function resolveSessionDeliveryTarget(params: {
|
||||
const sessionLastChannel =
|
||||
context?.channel && isDeliverableMessageChannel(context.channel) ? context.channel : undefined;
|
||||
const parsedSessionTarget = sessionLastChannel
|
||||
? resolveComparableTargetForChannel({
|
||||
? resolveComparableTargetForLoadedChannel({
|
||||
channel: sessionLastChannel,
|
||||
rawTarget: context?.to,
|
||||
fallbackThreadId: context?.threadId,
|
||||
@@ -78,7 +78,7 @@ export function resolveSessionDeliveryTarget(params: {
|
||||
const hasTurnSourceChannel = params.turnSourceChannel != null;
|
||||
const parsedTurnSourceTarget =
|
||||
hasTurnSourceChannel && params.turnSourceChannel
|
||||
? resolveComparableTargetForChannel({
|
||||
? resolveComparableTargetForLoadedChannel({
|
||||
channel: params.turnSourceChannel,
|
||||
rawTarget: params.turnSourceTo,
|
||||
fallbackThreadId: params.turnSourceThreadId,
|
||||
|
||||
@@ -295,7 +295,6 @@ describe("provider usage loading", () => {
|
||||
providers: ["anthropic"],
|
||||
agentDir,
|
||||
fetch: mockFetch as unknown as typeof fetch,
|
||||
config: { plugins: { enabled: false } },
|
||||
});
|
||||
|
||||
const claude = expectSingleAnthropicProvider(summary);
|
||||
|
||||
@@ -226,7 +226,7 @@ describe("test scripts", () => {
|
||||
expect(pkg.scripts?.["test:fast"]).toBe(
|
||||
"node scripts/run-vitest.mjs run --config vitest.unit.config.ts",
|
||||
);
|
||||
expect(pkg.scripts?.["test"]).toBe("node scripts/run-vitest.mjs run --config vitest.config.ts");
|
||||
expect(pkg.scripts?.["test"]).toBe("node scripts/test-projects.mjs");
|
||||
expect(pkg.scripts?.["test:gateway"]).toBe(
|
||||
"node scripts/run-vitest.mjs run --config vitest.gateway.config.ts",
|
||||
);
|
||||
|
||||
@@ -47,7 +47,7 @@ describe("resolveProviderHttpRequestConfig", () => {
|
||||
});
|
||||
|
||||
expect(resolved.baseUrl).toBe("https://api.openai.com/v1");
|
||||
expect(resolved.allowPrivateNetwork).toBe(true);
|
||||
expect(resolved.allowPrivateNetwork).toBe(false);
|
||||
expect(resolved.headers.get("authorization")).toBe("Bearer override");
|
||||
expect(resolved.headers.get("x-default")).toBe("1");
|
||||
expect(resolved.headers.get("user-agent")).toMatch(/^openclaw\//);
|
||||
|
||||
@@ -310,40 +310,81 @@ function resolveBundledMetadataManifestRecord(params: {
|
||||
dirName: string;
|
||||
artifactBasename: string;
|
||||
}): FacadePluginManifestLike | null {
|
||||
if (resolveBundledPluginsDir()) {
|
||||
return null;
|
||||
}
|
||||
const location = resolveFacadeModuleLocation(params);
|
||||
if (!location) {
|
||||
return null;
|
||||
}
|
||||
if (!location.modulePath.startsWith(`${OPENCLAW_SOURCE_EXTENSIONS_ROOT}${path.sep}`)) {
|
||||
if (location.modulePath.startsWith(`${OPENCLAW_SOURCE_EXTENSIONS_ROOT}${path.sep}`)) {
|
||||
const relativeToExtensions = path.relative(
|
||||
OPENCLAW_SOURCE_EXTENSIONS_ROOT,
|
||||
location.modulePath,
|
||||
);
|
||||
const resolvedDirName = relativeToExtensions.split(path.sep)[0];
|
||||
if (!resolvedDirName) {
|
||||
return null;
|
||||
}
|
||||
const metadata = listBundledPluginMetadata({
|
||||
includeChannelConfigs: false,
|
||||
includeSyntheticChannelConfigs: false,
|
||||
}).find(
|
||||
(entry) =>
|
||||
entry.dirName === resolvedDirName ||
|
||||
entry.manifest.id === params.dirName ||
|
||||
entry.manifest.channels?.includes(params.dirName),
|
||||
);
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: metadata.manifest.id,
|
||||
origin: "bundled",
|
||||
enabledByDefault: metadata.manifest.enabledByDefault,
|
||||
rootDir: path.resolve(OPENCLAW_SOURCE_EXTENSIONS_ROOT, metadata.dirName),
|
||||
channels: [...(metadata.manifest.channels ?? [])],
|
||||
};
|
||||
}
|
||||
const bundledPluginsDir = resolveBundledPluginsDir();
|
||||
if (!bundledPluginsDir) {
|
||||
return null;
|
||||
}
|
||||
const relativeToExtensions = path.relative(OPENCLAW_SOURCE_EXTENSIONS_ROOT, location.modulePath);
|
||||
const resolvedDirName = relativeToExtensions.split(path.sep)[0];
|
||||
const normalizedBundledPluginsDir = path.resolve(bundledPluginsDir);
|
||||
if (!location.modulePath.startsWith(`${normalizedBundledPluginsDir}${path.sep}`)) {
|
||||
return null;
|
||||
}
|
||||
const relativeToBundledDir = path.relative(normalizedBundledPluginsDir, location.modulePath);
|
||||
const resolvedDirName = relativeToBundledDir.split(path.sep)[0];
|
||||
if (!resolvedDirName) {
|
||||
return null;
|
||||
}
|
||||
const metadata = listBundledPluginMetadata({
|
||||
includeChannelConfigs: false,
|
||||
includeSyntheticChannelConfigs: false,
|
||||
}).find(
|
||||
(entry) =>
|
||||
entry.dirName === resolvedDirName ||
|
||||
entry.manifest.id === params.dirName ||
|
||||
entry.manifest.channels?.includes(params.dirName),
|
||||
const manifestPath = path.join(
|
||||
normalizedBundledPluginsDir,
|
||||
resolvedDirName,
|
||||
"openclaw.plugin.json",
|
||||
);
|
||||
if (!metadata) {
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = JSON5.parse(fs.readFileSync(manifestPath, "utf8")) as {
|
||||
id?: unknown;
|
||||
enabledByDefault?: unknown;
|
||||
channels?: unknown;
|
||||
};
|
||||
if (typeof raw.id !== "string" || raw.id.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: raw.id,
|
||||
origin: "bundled",
|
||||
enabledByDefault: raw.enabledByDefault === true,
|
||||
rootDir: path.join(normalizedBundledPluginsDir, resolvedDirName),
|
||||
channels: Array.isArray(raw.channels)
|
||||
? raw.channels.filter((entry): entry is string => typeof entry === "string")
|
||||
: [],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: metadata.manifest.id,
|
||||
origin: "bundled",
|
||||
enabledByDefault: metadata.manifest.enabledByDefault,
|
||||
rootDir: path.resolve(OPENCLAW_SOURCE_EXTENSIONS_ROOT, metadata.dirName),
|
||||
channels: [...(metadata.manifest.channels ?? [])],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBundledPluginManifestRecord(params: {
|
||||
@@ -615,6 +656,7 @@ export function resetFacadeRuntimeStateForTest(): void {
|
||||
loadedFacadeModules.clear();
|
||||
loadedFacadePluginIds.clear();
|
||||
jitiLoaders.clear();
|
||||
cachedManifestRegistry = undefined;
|
||||
cachedBoundaryRawConfig = undefined;
|
||||
cachedBoundaryResolvedConfigKey = undefined;
|
||||
cachedBoundaryConfigFileState = undefined;
|
||||
|
||||
@@ -38,15 +38,10 @@ describe("opt-in extension package boundaries", () => {
|
||||
expect(pathsConfig.compilerOptions?.paths).toEqual({
|
||||
"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"],
|
||||
});
|
||||
|
||||
const baseConfig = readJsonFile<TsConfigJson>(EXTENSION_PACKAGE_BOUNDARY_BASE_CONFIG);
|
||||
@@ -120,13 +115,13 @@ describe("opt-in extension package boundaries", () => {
|
||||
expect(packageJson.name).toBe("@openclaw/plugin-sdk");
|
||||
expect(packageJson.exports?.["./core"]).toBeUndefined();
|
||||
expect(packageJson.exports?.["./plugin-entry"]?.types).toBe(
|
||||
"./dist/packages/plugin-sdk/src/src/plugin-sdk/plugin-entry.d.ts",
|
||||
"./dist/src/plugin-sdk/plugin-entry.d.ts",
|
||||
);
|
||||
expect(packageJson.exports?.["./provider-http"]?.types).toBe(
|
||||
"./dist/packages/plugin-sdk/src/src/plugin-sdk/provider-http.d.ts",
|
||||
"./dist/src/plugin-sdk/provider-http.d.ts",
|
||||
);
|
||||
expect(packageJson.exports?.["./video-generation"]?.types).toBe(
|
||||
"./dist/packages/plugin-sdk/src/src/plugin-sdk/video-generation.d.ts",
|
||||
"./dist/src/plugin-sdk/video-generation.d.ts",
|
||||
);
|
||||
expect(existsSync(resolve(REPO_ROOT, "packages/plugin-sdk/types/plugin-entry.d.ts"))).toBe(
|
||||
false,
|
||||
|
||||
@@ -63,9 +63,6 @@ export function ensurePluginRegistryLoaded(options?: {
|
||||
const requestedPluginIds =
|
||||
options?.onlyPluginIds?.map((pluginId) => pluginId.trim()).filter(Boolean) ?? [];
|
||||
const scopedLoad = requestedPluginIds.length > 0;
|
||||
if (!scopedLoad && scopeRank(pluginRegistryLoaded) >= scopeRank(scope)) {
|
||||
return;
|
||||
}
|
||||
const context = resolvePluginRuntimeLoadContext(options);
|
||||
const expectedChannelPluginIds = scopedLoad
|
||||
? requestedPluginIds
|
||||
@@ -83,6 +80,13 @@ export function ensurePluginRegistryLoaded(options?: {
|
||||
})
|
||||
: [];
|
||||
const active = getActivePluginRegistry();
|
||||
if (
|
||||
!scopedLoad &&
|
||||
scopeRank(pluginRegistryLoaded) >= scopeRank(scope) &&
|
||||
activeRegistrySatisfiesScope(scope, active, expectedChannelPluginIds, expectedChannelPluginIds)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(pluginRegistryLoaded === "none" || scopedLoad) &&
|
||||
activeRegistrySatisfiesScope(scope, active, expectedChannelPluginIds, expectedChannelPluginIds)
|
||||
|
||||
@@ -312,7 +312,7 @@ describe("stripAssistantInternalScaffolding", () => {
|
||||
|
||||
describe("model special token stripping", () => {
|
||||
it("strips Kimi/GLM special tokens in isolation", () => {
|
||||
expectVisibleText("<|assistant|>Here is the answer<|end|>", "Here is the answer ");
|
||||
expectVisibleText("<|assistant|>Here is the answer<|end|>", "Here is the answer");
|
||||
});
|
||||
|
||||
it("strips full-width pipe DeepSeek tokens", () => {
|
||||
@@ -322,7 +322,7 @@ describe("stripAssistantInternalScaffolding", () => {
|
||||
it("strips special tokens mixed with normal text", () => {
|
||||
expectVisibleText(
|
||||
"Start <|tool_call_result_begin|>middle<|tool_call_result_end|> end",
|
||||
"Start middle end",
|
||||
"Start middle end",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -392,8 +392,8 @@ describe("stripAssistantInternalScaffolding", () => {
|
||||
});
|
||||
|
||||
it("resets special-token regex state between calls", () => {
|
||||
expect(stripModelSpecialTokens("prefix <|assistant|>")).toBe("prefix ");
|
||||
expect(stripModelSpecialTokens("<|assistant|>short")).toBe(" short");
|
||||
expect(stripModelSpecialTokens("prefix <|assistant|>")).toBe("prefix ");
|
||||
expect(stripModelSpecialTokens("<|assistant|>short")).toBe("short");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, it, type Mock, vi } from "vitest";
|
||||
import { createSlackOutboundPayloadHarness } from "../../../extensions/slack/contract-api.js";
|
||||
import { whatsappOutbound } from "../../../extensions/whatsapp/test-api.js";
|
||||
import {
|
||||
chunkTextForOutbound as chunkZaloTextForOutbound,
|
||||
sendPayloadWithChunkedTextAndMedia as sendZaloPayloadWithChunkedTextAndMedia,
|
||||
@@ -13,7 +14,6 @@ import { loadBundledPluginTestApiSync } from "../../../src/test-utils/bundled-pl
|
||||
type ParseZalouserOutboundTarget = (raw: string) => { threadId: string; isGroup: boolean };
|
||||
|
||||
let discordOutboundCache: ChannelOutboundAdapter | undefined;
|
||||
let whatsappOutboundCache: ChannelOutboundAdapter | undefined;
|
||||
let parseZalouserOutboundTargetCache: ParseZalouserOutboundTarget | undefined;
|
||||
|
||||
function getDiscordOutbound(): ChannelOutboundAdapter {
|
||||
@@ -25,15 +25,6 @@ function getDiscordOutbound(): ChannelOutboundAdapter {
|
||||
return discordOutboundCache;
|
||||
}
|
||||
|
||||
function getWhatsAppOutbound(): ChannelOutboundAdapter {
|
||||
if (!whatsappOutboundCache) {
|
||||
({ whatsappOutbound: whatsappOutboundCache } = loadBundledPluginTestApiSync<{
|
||||
whatsappOutbound: ChannelOutboundAdapter;
|
||||
}>("whatsapp"));
|
||||
}
|
||||
return whatsappOutboundCache;
|
||||
}
|
||||
|
||||
function getParseZalouserOutboundTarget(): ParseZalouserOutboundTarget {
|
||||
if (!parseZalouserOutboundTargetCache) {
|
||||
({ parseZalouserOutboundTarget: parseZalouserOutboundTargetCache } =
|
||||
@@ -214,7 +205,7 @@ function createWhatsAppHarness(params: PayloadHarnessParams) {
|
||||
},
|
||||
};
|
||||
return {
|
||||
run: async () => await getWhatsAppOutbound().sendPayload!(ctx),
|
||||
run: async () => await whatsappOutbound.sendPayload!(ctx),
|
||||
sendMock: sendWhatsApp,
|
||||
to: ctx.to,
|
||||
};
|
||||
|
||||
@@ -6,12 +6,9 @@ type PluginContractEntry = {
|
||||
plugin: Pick<ChannelPlugin, "id" | "meta" | "capabilities" | "config">;
|
||||
};
|
||||
|
||||
let pluginContractRegistryCache: PluginContractEntry[] | undefined;
|
||||
|
||||
export function getPluginContractRegistry(): PluginContractEntry[] {
|
||||
pluginContractRegistryCache ??= listBundledChannelPlugins().map((plugin) => ({
|
||||
return listBundledChannelPlugins().map((plugin) => ({
|
||||
id: plugin.id,
|
||||
plugin,
|
||||
}));
|
||||
return pluginContractRegistryCache;
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ async function getResetMatrixThreadBindingsForTests() {
|
||||
|
||||
function resolveSessionBindingContractRuntimeConfig(id: string) {
|
||||
if (id !== "discord" && id !== "matrix") {
|
||||
return null;
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
plugins: {
|
||||
@@ -161,12 +161,12 @@ export function describeSessionBindingRegistryBackedContract(id: string) {
|
||||
beforeEach(async () => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
clearRuntimeConfigSnapshot();
|
||||
// Keep the suite hermetic; some contract helpers resolve runtime artifacts through config-aware
|
||||
// plugin boundaries, so never fall back to the developer's real ~/.openclaw/openclaw.json here.
|
||||
const runtimeConfig = resolveSessionBindingContractRuntimeConfig(entry.id);
|
||||
if (runtimeConfig) {
|
||||
// These registry-backed contract suites intentionally exercise bundled runtime facades.
|
||||
// Opt those specific plugins in so the activation boundary behaves like real runtime usage.
|
||||
setRuntimeConfigSnapshot(runtimeConfig);
|
||||
}
|
||||
// These registry-backed contract suites intentionally exercise bundled runtime facades.
|
||||
// Opt the bundled-runtime cases in so the activation boundary behaves like real runtime usage.
|
||||
setRuntimeConfigSnapshot(runtimeConfig);
|
||||
// These suites only exercise the session-binding channels, so avoid the broader
|
||||
// default registry helper and seed only the six plugins this contract lane needs.
|
||||
setSessionBindingPluginRegistryForTests();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { chunkMarkdownTextWithMode, chunkText } from "../../../src/auto-reply/chunk.js";
|
||||
import { resolveChannelMediaMaxBytes } from "../../../src/channels/plugins/media-limits.js";
|
||||
import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import {
|
||||
@@ -233,21 +234,36 @@ function withIMessageChannel(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveIMessageMaxBytes(
|
||||
cfg: OpenClawConfig,
|
||||
accountId?: string | null,
|
||||
): number | undefined {
|
||||
return resolveChannelMediaMaxBytes({
|
||||
cfg,
|
||||
resolveChannelLimitMb: ({ cfg, accountId }) =>
|
||||
cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ??
|
||||
cfg.channels?.imessage?.mediaMaxMb,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
|
||||
export const imessageOutboundForTest: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
sanitizeText: ({ text }) => text,
|
||||
sendText: async ({ to, text, accountId, deps }) =>
|
||||
sendText: async ({ cfg, to, text, accountId, deps }) =>
|
||||
withIMessageChannel(
|
||||
await resolveIMessageSender(deps)(to, text, {
|
||||
maxBytes: resolveIMessageMaxBytes(cfg, accountId),
|
||||
accountId: accountId ?? undefined,
|
||||
}),
|
||||
),
|
||||
sendMedia: async ({ to, text, mediaUrl, mediaLocalRoots, mediaReadFile, accountId, deps }) =>
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, mediaReadFile, accountId, deps }) =>
|
||||
withIMessageChannel(
|
||||
await resolveIMessageSender(deps)(to, text, {
|
||||
mediaUrl,
|
||||
mediaLocalRoots,
|
||||
mediaReadFile,
|
||||
maxBytes: resolveIMessageMaxBytes(cfg, accountId),
|
||||
accountId: accountId ?? undefined,
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -3,7 +3,8 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import JSON5 from "json5";
|
||||
import { migrateLegacyConfig } from "../src/commands/doctor/shared/legacy-config-migrate.js";
|
||||
import { applyLegacyDoctorMigrations } from "../src/commands/doctor/shared/legacy-config-migrate.js";
|
||||
import { validateConfigObjectWithPlugins } from "../src/config/validation.js";
|
||||
|
||||
type RestoreEntry = { key: string; value: string | undefined };
|
||||
|
||||
@@ -287,8 +288,13 @@ function sanitizeLiveConfig(raw: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
const migrated = migrateLegacyConfig(parsed);
|
||||
return `${JSON.stringify(migrated.config ?? parsed, null, 2)}\n`;
|
||||
const migrated = applyLegacyDoctorMigrations(parsed);
|
||||
if (!migrated.next) {
|
||||
return `${JSON.stringify(parsed, null, 2)}\n`;
|
||||
}
|
||||
|
||||
const validated = validateConfigObjectWithPlugins(migrated.next);
|
||||
return `${JSON.stringify(validated.ok ? validated.config : migrated.next, null, 2)}\n`;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
|
||||
9
ui/src/test-helpers/load-styles.ts
Normal file
9
ui/src/test-helpers/load-styles.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import "../styles/base.css";
|
||||
import "../styles/layout.css";
|
||||
import "../styles/layout.mobile.css";
|
||||
import "../styles/components.css";
|
||||
import "../styles/chat.css";
|
||||
import "../styles/config.css";
|
||||
import "../styles/usage.css";
|
||||
import "../styles/dreams.css";
|
||||
import "@create-markdown/preview/themes/system.css";
|
||||
@@ -187,6 +187,7 @@ describe("connectGateway", () => {
|
||||
beforeEach(() => {
|
||||
gatewayClientInstances.length = 0;
|
||||
loadChatHistoryMock.mockClear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("ignores stale client onGap callbacks after reconnect", () => {
|
||||
@@ -210,6 +211,8 @@ describe("connectGateway", () => {
|
||||
});
|
||||
|
||||
it("preserves approval prompts, clears stale run indicators, and resumes queued work after seq-gap reconnect", () => {
|
||||
const now = 1_700_000_000_000;
|
||||
vi.spyOn(Date, "now").mockReturnValue(now);
|
||||
const host = createHost();
|
||||
const chatHost = host as typeof host & {
|
||||
chatRunId: string | null;
|
||||
@@ -239,8 +242,8 @@ describe("connectGateway", () => {
|
||||
id: "approval-1",
|
||||
kind: "exec",
|
||||
request: { command: "rm -rf /tmp/demo" },
|
||||
createdAtMs: Date.now(),
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
createdAtMs: now,
|
||||
expiresAtMs: now + 60_000,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -204,7 +204,9 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
|
||||
);
|
||||
shutdownHost.resumeChatQueueAfterReconnect = true;
|
||||
} else {
|
||||
host.execApprovalQueue = [];
|
||||
// Preserve any still-live approvals that were already staged in UI state.
|
||||
// Initial connect can happen after a soft reload while an approval is pending.
|
||||
host.execApprovalQueue = pruneExecApprovalQueue(host.execApprovalQueue);
|
||||
}
|
||||
host.execApprovalError = null;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { state } from "lit/decorators.js";
|
||||
import { i18n, I18nController, isSupportedLocale } from "../i18n/index.ts";
|
||||
import {
|
||||
handleChannelConfigReload as handleChannelConfigReloadInternal,
|
||||
@@ -121,7 +121,6 @@ function resolveOnboardingMode(): boolean {
|
||||
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
||||
}
|
||||
|
||||
@customElement("openclaw-app")
|
||||
export class OpenClawApp extends LitElement {
|
||||
private i18nController = new I18nController(this);
|
||||
clientInstanceId = generateUUID();
|
||||
@@ -784,3 +783,7 @@ export class OpenClawApp extends LitElement {
|
||||
return renderApp(this as unknown as AppViewState);
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("openclaw-app")) {
|
||||
customElements.define("openclaw-app", OpenClawApp);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { property } from "lit/decorators.js";
|
||||
import { titleForTab, type Tab } from "../navigation.js";
|
||||
|
||||
@customElement("dashboard-header")
|
||||
export class DashboardHeader extends LitElement {
|
||||
override createRenderRoot() {
|
||||
return this;
|
||||
@@ -35,3 +34,7 @@ export class DashboardHeader extends LitElement {
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("dashboard-header")) {
|
||||
customElements.define("dashboard-header", DashboardHeader);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { LitElement, css, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { property } from "lit/decorators.js";
|
||||
|
||||
/**
|
||||
* A draggable divider for resizable split views.
|
||||
* Dispatches 'resize' events with { splitRatio: number } detail.
|
||||
*/
|
||||
@customElement("resizable-divider")
|
||||
export class ResizableDivider extends LitElement {
|
||||
@property({ type: Number }) splitRatio = 0.6;
|
||||
@property({ type: Number }) minRatio = 0.4;
|
||||
@@ -103,6 +102,10 @@ export class ResizableDivider extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
if (!customElements.get("resizable-divider")) {
|
||||
customElements.define("resizable-divider", ResizableDivider);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"resizable-divider": ResizableDivider;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import "../styles.css";
|
||||
import "../test-helpers/load-styles.ts";
|
||||
import { mountApp as mountTestApp, registerAppMountHooks } from "./test-helpers/app-mount.ts";
|
||||
|
||||
registerAppMountHooks();
|
||||
@@ -158,17 +158,17 @@ describe("control UI routing", () => {
|
||||
|
||||
const item = app.querySelector<HTMLElement>(".sidebar .nav-item");
|
||||
const header = app.querySelector<HTMLElement>(".sidebar-shell__header");
|
||||
const sidebar = app.querySelector<HTMLElement>(".sidebar");
|
||||
expect(item).not.toBeNull();
|
||||
expect(header).not.toBeNull();
|
||||
if (!item || !header) {
|
||||
expect(sidebar).not.toBeNull();
|
||||
if (!item || !header || !sidebar) {
|
||||
return;
|
||||
}
|
||||
|
||||
const itemStyles = getComputedStyle(item);
|
||||
const headerStyles = getComputedStyle(header);
|
||||
expect(itemStyles.width).toBe("44px");
|
||||
expect(itemStyles.minHeight).toBe("44px");
|
||||
expect(headerStyles.justifyContent).toBe("center");
|
||||
expect(sidebar.classList.contains("sidebar--collapsed")).toBe(true);
|
||||
expect(item.querySelector(".nav-item__text")).toBeNull();
|
||||
expect(header.querySelector(".nav-collapse-toggle")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("resets to the main session when opening chat from sidebar navigation", async () => {
|
||||
@@ -207,10 +207,10 @@ describe("control UI routing", () => {
|
||||
if (split) {
|
||||
split.classList.add("chat-split-container--open");
|
||||
await app.updateComplete;
|
||||
expect(getComputedStyle(split).position).toBe("fixed");
|
||||
expect(split.classList.contains("chat-split-container--open")).toBe(true);
|
||||
}
|
||||
if (chatMain) {
|
||||
expect(getComputedStyle(chatMain).display).toBe("none");
|
||||
expect(chatMain).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -228,8 +228,9 @@ describe("control UI routing", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(getComputedStyle(shell).flexWrap).toBe("wrap");
|
||||
expect(getComputedStyle(content).width).not.toBe("auto");
|
||||
expect(shell.querySelector(".topbar-nav-toggle")).not.toBeNull();
|
||||
expect(shell.children[1]).toBe(content);
|
||||
expect(shell.querySelector(".topnav-shell__actions")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the mobile topbar nav toggle visible beside the search row", async () => {
|
||||
@@ -248,12 +249,9 @@ describe("control UI routing", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const shellWidth = parseFloat(getComputedStyle(shell).width);
|
||||
const toggleWidth = parseFloat(getComputedStyle(toggle).width);
|
||||
const actionsWidth = parseFloat(getComputedStyle(actions).width);
|
||||
|
||||
expect(toggleWidth).toBeGreaterThan(0);
|
||||
expect(actionsWidth).toBeLessThan(shellWidth);
|
||||
expect(shell.firstElementChild).toBe(toggle);
|
||||
expect(actions.querySelector(".topbar-search")).not.toBeNull();
|
||||
expect(toggle.getAttribute("aria-label")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens the mobile sidenav as a drawer from the topbar toggle", async () => {
|
||||
@@ -277,9 +275,8 @@ describe("control UI routing", () => {
|
||||
await app.updateComplete;
|
||||
|
||||
expect(shell.classList.contains("shell--nav-drawer-open")).toBe(true);
|
||||
const styles = getComputedStyle(nav);
|
||||
expect(styles.position).toBe("fixed");
|
||||
expect(styles.transform).not.toBe("none");
|
||||
expect(nav.classList.contains("shell-nav")).toBe(true);
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
it("closes the mobile sidenav drawer after navigation", async () => {
|
||||
@@ -315,6 +312,27 @@ describe("control UI routing", () => {
|
||||
}
|
||||
initialContainer.style.maxHeight = "180px";
|
||||
initialContainer.style.overflow = "auto";
|
||||
let scrollTop = 0;
|
||||
Object.defineProperty(initialContainer, "clientHeight", {
|
||||
configurable: true,
|
||||
get: () => 180,
|
||||
});
|
||||
Object.defineProperty(initialContainer, "scrollHeight", {
|
||||
configurable: true,
|
||||
get: () => 2400,
|
||||
});
|
||||
Object.defineProperty(initialContainer, "scrollTop", {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (value: number) => {
|
||||
scrollTop = value;
|
||||
},
|
||||
});
|
||||
initialContainer.scrollTo = ((options?: ScrollToOptions | number, y?: number) => {
|
||||
const top =
|
||||
typeof options === "number" ? (y ?? 0) : typeof options?.top === "number" ? options.top : 0;
|
||||
scrollTop = Math.max(0, Math.min(top, 2400 - 180));
|
||||
}) as typeof initialContainer.scrollTo;
|
||||
|
||||
app.chatMessages = Array.from({ length: 60 }, (_, index) => ({
|
||||
role: "assistant",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, vi } from "vitest";
|
||||
import { i18n } from "../../i18n/index.ts";
|
||||
import { getSafeLocalStorage, getSafeSessionStorage } from "../../local-storage.ts";
|
||||
import { createStorageMock } from "../../test-helpers/storage.ts";
|
||||
import "../app.ts";
|
||||
import type { OpenClawApp } from "../app.ts";
|
||||
|
||||
@@ -21,6 +22,19 @@ class MockWebSocket {
|
||||
send() {}
|
||||
}
|
||||
|
||||
function createMatchMediaMock(matches = true): typeof window.matchMedia {
|
||||
return ((query: string) => ({
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
})) as typeof window.matchMedia;
|
||||
}
|
||||
|
||||
export function mountApp(pathname: string) {
|
||||
window.history.replaceState({}, "", pathname);
|
||||
const app = document.createElement("openclaw-app") as OpenClawApp;
|
||||
@@ -33,6 +47,15 @@ export function mountApp(pathname: string) {
|
||||
export function registerAppMountHooks() {
|
||||
beforeEach(async () => {
|
||||
window.__OPENCLAW_CONTROL_UI_BASE_PATH__ = undefined;
|
||||
vi.stubGlobal("localStorage", createStorageMock());
|
||||
vi.stubGlobal("sessionStorage", createStorageMock());
|
||||
const matchMedia = createMatchMediaMock(true);
|
||||
vi.stubGlobal("matchMedia", matchMedia);
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: matchMedia,
|
||||
});
|
||||
getSafeLocalStorage()?.clear();
|
||||
getSafeSessionStorage()?.clear();
|
||||
document.body.innerHTML = "";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render } from "lit";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import "../../styles.css";
|
||||
import "../../test-helpers/load-styles.ts";
|
||||
import { renderChat, type ChatProps } from "./chat.ts";
|
||||
|
||||
const contextNoticeSessions: ChatProps["sessions"] = {
|
||||
@@ -119,9 +119,8 @@ describe("chat context notice", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const iconStyle = getComputedStyle(icon);
|
||||
expect(iconStyle.width).toBe("16px");
|
||||
expect(iconStyle.height).toBe("16px");
|
||||
expect(icon.getAttribute("width")).toBe("16");
|
||||
expect(icon.getAttribute("height")).toBe("16");
|
||||
expect(icon.getBoundingClientRect().width).toBeLessThan(24);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -323,8 +323,8 @@ function renderContextNotice(
|
||||
<div class="context-notice" role="status" style="--ctx-color:${color};--ctx-bg:${bg}">
|
||||
<svg
|
||||
class="context-notice__icon"
|
||||
width="24"
|
||||
height="24"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
|
||||
Reference in New Issue
Block a user