Merge f1b9e0fde92d5b303ff2b827bf5d2e16ce02f621 into 43513cd1df63af0704dfb351ee7864607f955dcc
This commit is contained in:
commit
4cc5598891
@ -1,6 +1,7 @@
|
||||
import path from "node:path";
|
||||
import type { AnyAgentTool } from "../agents/tools/common.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { registerContextEngineForOwner } from "../context-engine/registry.js";
|
||||
import type {
|
||||
GatewayRequestHandler,
|
||||
@ -48,6 +49,7 @@ import type {
|
||||
PluginOrigin,
|
||||
PluginKind,
|
||||
PluginRegistrationMode,
|
||||
PluginResetSessionResult,
|
||||
PluginHookName,
|
||||
PluginHookHandlerMap,
|
||||
PluginHookRegistration as TypedPluginHookRegistration,
|
||||
@ -55,6 +57,8 @@ import type {
|
||||
WebSearchProviderPlugin,
|
||||
} from "./types.js";
|
||||
|
||||
type GatewaySessionResetModule = typeof import("../gateway/session-reset-service.js");
|
||||
|
||||
export type PluginToolRegistration = {
|
||||
pluginId: string;
|
||||
pluginName?: string;
|
||||
@ -221,12 +225,148 @@ export type PluginRegistryParams = {
|
||||
// When true, skip writing to the global plugin command registry during register().
|
||||
// Used by non-activating snapshot loads to avoid leaking commands into the running gateway.
|
||||
suppressGlobalCommands?: boolean;
|
||||
loadSessionResetModule?: () => Promise<GatewaySessionResetModule>;
|
||||
};
|
||||
|
||||
type PluginTypedHookPolicy = {
|
||||
allowPromptInjection?: boolean;
|
||||
};
|
||||
|
||||
const FALLBACK_AGENT_ID = "main";
|
||||
const DEFAULT_MAIN_SESSION_KEY = "main";
|
||||
const VALID_AGENT_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
|
||||
const INVALID_AGENT_CHARS_RE = /[^a-z0-9_-]+/g;
|
||||
const LEADING_DASH_RE = /^-+/;
|
||||
const TRAILING_DASH_RE = /-+$/;
|
||||
|
||||
const isGlobalSessionKey = (value: string) => value === "global" || value === "unknown";
|
||||
|
||||
const normalizePluginMainKey = (value?: string) => {
|
||||
const trimmed = (value ?? "").trim();
|
||||
return trimmed ? trimmed.toLowerCase() : DEFAULT_MAIN_SESSION_KEY;
|
||||
};
|
||||
|
||||
const normalizePluginAgentId = (value?: string) => {
|
||||
const trimmed = (value ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return FALLBACK_AGENT_ID;
|
||||
}
|
||||
if (VALID_AGENT_ID_RE.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
return (
|
||||
trimmed
|
||||
.toLowerCase()
|
||||
.replace(INVALID_AGENT_CHARS_RE, "-")
|
||||
.replace(LEADING_DASH_RE, "")
|
||||
.replace(TRAILING_DASH_RE, "")
|
||||
.slice(0, 64) || FALLBACK_AGENT_ID
|
||||
);
|
||||
};
|
||||
|
||||
const parsePluginAgentSessionKey = (
|
||||
sessionKey: string,
|
||||
): { agentId: string; rest: string } | null => {
|
||||
const raw = (sessionKey ?? "").trim().toLowerCase();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parts = raw.split(":").filter(Boolean);
|
||||
if (parts.length < 3 || parts[0] !== "agent") {
|
||||
return null;
|
||||
}
|
||||
const agentId = parts[1]?.trim();
|
||||
const rest = parts.slice(2).join(":");
|
||||
if (!agentId || !rest) {
|
||||
return null;
|
||||
}
|
||||
return { agentId, rest };
|
||||
};
|
||||
|
||||
const buildPluginAgentMainSessionKey = (params: { agentId: string; mainKey?: string }): string => {
|
||||
return `agent:${normalizePluginAgentId(params.agentId)}:${normalizePluginMainKey(params.mainKey)}`;
|
||||
};
|
||||
|
||||
const canonicalizePluginMainSessionAlias = (params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
}): string => {
|
||||
const raw = params.sessionKey.trim();
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
const normalized = raw.toLowerCase();
|
||||
const normalizedAgent = normalizePluginAgentId(params.agentId);
|
||||
const normalizedMainKey = normalizePluginMainKey(params.cfg.session?.mainKey);
|
||||
const agentMainSessionKey = buildPluginAgentMainSessionKey({
|
||||
agentId: normalizedAgent,
|
||||
mainKey: normalizedMainKey,
|
||||
});
|
||||
const agentMainAliasKey = buildPluginAgentMainSessionKey({
|
||||
agentId: normalizedAgent,
|
||||
mainKey: DEFAULT_MAIN_SESSION_KEY,
|
||||
});
|
||||
const isAlias =
|
||||
normalized === "main" ||
|
||||
normalized === normalizedMainKey ||
|
||||
normalized === agentMainSessionKey ||
|
||||
normalized === agentMainAliasKey;
|
||||
if (params.cfg.session?.scope === "global" && isAlias) {
|
||||
return "global";
|
||||
}
|
||||
return isAlias ? agentMainSessionKey : normalized;
|
||||
};
|
||||
|
||||
const resolveDefaultPluginAgentId = (cfg: OpenClawConfig): string => {
|
||||
const agents = Array.isArray(cfg.agents?.list)
|
||||
? cfg.agents.list.filter((agent) => Boolean(agent && typeof agent === "object"))
|
||||
: [];
|
||||
if (agents.length === 0) {
|
||||
return FALLBACK_AGENT_ID;
|
||||
}
|
||||
const preferredEntry = agents.find((agent) => agent?.default) ?? agents[0];
|
||||
const preferredId = typeof preferredEntry?.id === "string" ? preferredEntry.id : undefined;
|
||||
return normalizePluginAgentId(preferredId);
|
||||
};
|
||||
|
||||
const resolvePluginMainSessionKey = (cfg: OpenClawConfig): string => {
|
||||
if (cfg.session?.scope === "global") {
|
||||
return "global";
|
||||
}
|
||||
return buildPluginAgentMainSessionKey({
|
||||
agentId: resolveDefaultPluginAgentId(cfg),
|
||||
mainKey: cfg.session?.mainKey,
|
||||
});
|
||||
};
|
||||
|
||||
const resolveCanonicalPluginSessionKey = (cfg: OpenClawConfig, rawKey: string): string => {
|
||||
const trimmedKey = rawKey.trim();
|
||||
if (!trimmedKey) {
|
||||
return "";
|
||||
}
|
||||
const lowered = trimmedKey.toLowerCase();
|
||||
if (isGlobalSessionKey(lowered)) {
|
||||
return lowered;
|
||||
}
|
||||
const parsed = parsePluginAgentSessionKey(trimmedKey);
|
||||
if (parsed) {
|
||||
return canonicalizePluginMainSessionAlias({
|
||||
cfg,
|
||||
agentId: parsed.agentId,
|
||||
sessionKey: trimmedKey,
|
||||
});
|
||||
}
|
||||
const normalizedMainKey = normalizePluginMainKey(cfg.session?.mainKey);
|
||||
if (lowered === "main" || lowered === normalizedMainKey) {
|
||||
return resolvePluginMainSessionKey(cfg);
|
||||
}
|
||||
if (lowered.startsWith("agent:")) {
|
||||
return lowered;
|
||||
}
|
||||
return `agent:${resolveDefaultPluginAgentId(cfg)}:${lowered}`;
|
||||
};
|
||||
|
||||
const constrainLegacyPromptInjectionHook = (
|
||||
handler: PluginHookHandlerMap["before_agent_start"],
|
||||
): PluginHookHandlerMap["before_agent_start"] => {
|
||||
@ -246,11 +386,135 @@ export { createEmptyPluginRegistry } from "./registry-empty.js";
|
||||
export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
const coreGatewayMethods = new Set(Object.keys(registryParams.coreGatewayHandlers ?? {}));
|
||||
const isGatewayRuntimeAvailable = () => {
|
||||
const subagentRuntime = registryParams.runtime.subagent;
|
||||
return (
|
||||
Boolean(subagentRuntime) && !Object.is(subagentRuntime.run, subagentRuntime.deleteSession)
|
||||
);
|
||||
};
|
||||
const runtimeLoadConfig = () => {
|
||||
const loadConfig = registryParams.runtime.config?.loadConfig;
|
||||
if (typeof loadConfig !== "function") {
|
||||
throw new Error("Plugin runtime config loader is unavailable.");
|
||||
}
|
||||
return loadConfig();
|
||||
};
|
||||
const gatewayResetUnavailableError =
|
||||
"resetSession is only available while the gateway is running.";
|
||||
let sessionResetModuleCache: GatewaySessionResetModule | null = null;
|
||||
const loadSessionResetModule = (() => {
|
||||
if (registryParams.loadSessionResetModule) {
|
||||
return registryParams.loadSessionResetModule;
|
||||
}
|
||||
return async () => {
|
||||
if (!sessionResetModuleCache) {
|
||||
sessionResetModuleCache = await import("../gateway/session-reset-service.js");
|
||||
}
|
||||
return sessionResetModuleCache;
|
||||
};
|
||||
})();
|
||||
const resetSessionsInFlight = new Set<string>();
|
||||
|
||||
const pushDiagnostic = (diag: PluginDiagnostic) => {
|
||||
registry.diagnostics.push(diag);
|
||||
};
|
||||
|
||||
const normalizeResetSessionError = (error: unknown): string => {
|
||||
if (error instanceof Error) {
|
||||
return error.message || "Session reset failed.";
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
if (error && typeof error === "object") {
|
||||
const maybeMessage = Reflect.get(error, "message");
|
||||
if (typeof maybeMessage === "string" && maybeMessage.trim()) {
|
||||
return maybeMessage;
|
||||
}
|
||||
const nestedError = Reflect.get(error, "error");
|
||||
if (nestedError && typeof nestedError === "object") {
|
||||
const nestedMessage = Reflect.get(nestedError, "message");
|
||||
if (typeof nestedMessage === "string" && nestedMessage.trim()) {
|
||||
return nestedMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "Session reset failed.";
|
||||
};
|
||||
|
||||
const createResetSessionFailure = (
|
||||
key: string,
|
||||
error: unknown,
|
||||
): Extract<PluginResetSessionResult, { ok: false }> => ({
|
||||
ok: false,
|
||||
key,
|
||||
error: normalizeResetSessionError(error),
|
||||
});
|
||||
|
||||
const createPluginResetSession = (params: {
|
||||
pluginId: string;
|
||||
loadConfig: () => OpenClawConfig;
|
||||
loadSessionResetModule: () => Promise<GatewaySessionResetModule>;
|
||||
isGatewayRuntimeAvailable: () => boolean;
|
||||
}): NonNullable<OpenClawPluginApi["resetSession"]> => {
|
||||
return async (key, reason = "new") => {
|
||||
let responseKey = typeof key === "string" ? key.trim() : "";
|
||||
|
||||
try {
|
||||
if (typeof key !== "string") {
|
||||
throw new TypeError("resetSession key must be a string");
|
||||
}
|
||||
|
||||
const trimmedKey = key.trim();
|
||||
responseKey = trimmedKey;
|
||||
if (!trimmedKey) {
|
||||
throw new Error("resetSession key must be a non-empty string");
|
||||
}
|
||||
|
||||
if (!params.isGatewayRuntimeAvailable()) {
|
||||
return createResetSessionFailure(trimmedKey, gatewayResetUnavailableError);
|
||||
}
|
||||
|
||||
const normalizedReason = reason === "reset" ? "reset" : "new";
|
||||
const liveConfig = params.loadConfig();
|
||||
const canonicalKey = resolveCanonicalPluginSessionKey(liveConfig, trimmedKey).trim();
|
||||
if (!canonicalKey) {
|
||||
throw new Error("Session reset failed to resolve a canonical session key");
|
||||
}
|
||||
|
||||
responseKey = canonicalKey;
|
||||
if (resetSessionsInFlight.has(canonicalKey)) {
|
||||
return createResetSessionFailure(
|
||||
canonicalKey,
|
||||
`Session reset already in progress for ${canonicalKey}.`,
|
||||
);
|
||||
}
|
||||
|
||||
resetSessionsInFlight.add(canonicalKey);
|
||||
try {
|
||||
const { performGatewaySessionReset } = await params.loadSessionResetModule();
|
||||
const result = await performGatewaySessionReset({
|
||||
key: trimmedKey,
|
||||
reason: normalizedReason,
|
||||
commandSource: `plugin:${params.pluginId}`,
|
||||
});
|
||||
if (result.ok) {
|
||||
return {
|
||||
ok: true,
|
||||
key: result.key,
|
||||
sessionId: result.entry.sessionId,
|
||||
};
|
||||
}
|
||||
return createResetSessionFailure(canonicalKey, result.error);
|
||||
} finally {
|
||||
resetSessionsInFlight.delete(canonicalKey);
|
||||
}
|
||||
} catch (error) {
|
||||
return createResetSessionFailure(responseKey, error);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const registerTool = (
|
||||
record: PluginRecord,
|
||||
tool: AnyAgentTool | OpenClawPluginToolFactory,
|
||||
@ -979,6 +1243,15 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
});
|
||||
}
|
||||
},
|
||||
resetSession:
|
||||
registrationMode === "full"
|
||||
? createPluginResetSession({
|
||||
pluginId: record.id,
|
||||
loadConfig: runtimeLoadConfig,
|
||||
loadSessionResetModule,
|
||||
isGatewayRuntimeAvailable,
|
||||
})
|
||||
: undefined,
|
||||
resolvePath: (input: string) => resolveUserPath(input),
|
||||
on: (hookName, handler, opts) =>
|
||||
registrationMode === "full"
|
||||
|
||||
664
src/plugins/reset-session.test.ts
Normal file
664
src/plugins/reset-session.test.ts
Normal file
@ -0,0 +1,664 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { PluginRecord } from "./registry.js";
|
||||
import type { PluginRuntime } from "./runtime/types.js";
|
||||
import type {
|
||||
OpenClawPluginApi,
|
||||
PluginRegistrationMode,
|
||||
PluginResetSessionResult,
|
||||
} from "./types.js";
|
||||
|
||||
const resolveModuleId = (specifier: string) => fileURLToPath(new URL(specifier, import.meta.url));
|
||||
|
||||
const AUTH_PROFILES_OAUTH_MODULE_IDS = [
|
||||
"../agents/auth-profiles/oauth.js",
|
||||
"../agents/auth-profiles/oauth.ts",
|
||||
resolveModuleId("../agents/auth-profiles/oauth.js"),
|
||||
resolveModuleId("../agents/auth-profiles/oauth.ts"),
|
||||
];
|
||||
const MODEL_AUTH_MODULE_IDS = [
|
||||
"../agents/model-auth.js",
|
||||
"../agents/model-auth.ts",
|
||||
resolveModuleId("../agents/model-auth.js"),
|
||||
resolveModuleId("../agents/model-auth.ts"),
|
||||
];
|
||||
|
||||
vi.mock("@mariozechner/pi-ai/oauth", () => ({
|
||||
getOAuthProviders: () => [],
|
||||
getOAuthApiKey: vi.fn(async () => ""),
|
||||
loginOpenAICodex: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockPluginSideEffects = () => {
|
||||
vi.doMock("../agents/sandbox/constants.js", () => {
|
||||
return {
|
||||
DEFAULT_SANDBOX_WORKSPACE_ROOT: "/tmp/sandboxes",
|
||||
DEFAULT_SANDBOX_IMAGE: "sandbox:test",
|
||||
DEFAULT_SANDBOX_CONTAINER_PREFIX: "sandbox-",
|
||||
DEFAULT_SANDBOX_WORKDIR: "/workspace",
|
||||
DEFAULT_SANDBOX_IDLE_HOURS: 1,
|
||||
DEFAULT_SANDBOX_MAX_AGE_DAYS: 1,
|
||||
DEFAULT_TOOL_ALLOW: [] as const,
|
||||
DEFAULT_TOOL_DENY: [] as const,
|
||||
DEFAULT_SANDBOX_BROWSER_IMAGE: "sandbox-browser:test",
|
||||
DEFAULT_SANDBOX_COMMON_IMAGE: "sandbox-common:test",
|
||||
SANDBOX_BROWSER_SECURITY_HASH_EPOCH: "test",
|
||||
DEFAULT_SANDBOX_BROWSER_PREFIX: "sandbox-browser-",
|
||||
DEFAULT_SANDBOX_BROWSER_NETWORK: "sandbox-net",
|
||||
DEFAULT_SANDBOX_BROWSER_CDP_PORT: 0,
|
||||
DEFAULT_SANDBOX_BROWSER_VNC_PORT: 0,
|
||||
DEFAULT_SANDBOX_BROWSER_NOVNC_PORT: 0,
|
||||
DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS: 0,
|
||||
SANDBOX_AGENT_WORKSPACE_MOUNT: "/agent",
|
||||
SANDBOX_STATE_DIR: "/tmp/sandbox",
|
||||
SANDBOX_REGISTRY_PATH: "/tmp/sandbox/containers.json",
|
||||
SANDBOX_BROWSER_REGISTRY_PATH: "/tmp/sandbox/browsers.json",
|
||||
};
|
||||
});
|
||||
|
||||
const mockAuthProfilesOauth = {
|
||||
resolveApiKeyForProfile: vi.fn(async () => ({
|
||||
apiKey: "",
|
||||
provider: "mock",
|
||||
})),
|
||||
getOAuthProviders: vi.fn(() => []),
|
||||
};
|
||||
for (const id of AUTH_PROFILES_OAUTH_MODULE_IDS) {
|
||||
vi.doMock(id, () => mockAuthProfilesOauth);
|
||||
}
|
||||
for (const id of MODEL_AUTH_MODULE_IDS) {
|
||||
vi.doMock(id, () => ({
|
||||
getApiKeyForModel: vi.fn(async () => null),
|
||||
resolveApiKeyForProvider: vi.fn(async () => null),
|
||||
ensureAuthProfileStore: vi.fn(() => ({ version: 1, profiles: {} })),
|
||||
resolveAuthProfileOrder: vi.fn(() => []),
|
||||
}));
|
||||
}
|
||||
|
||||
vi.doMock("openclaw/plugin-sdk/text-runtime", async () => {
|
||||
return await import("../plugin-sdk/text-runtime.js");
|
||||
});
|
||||
|
||||
vi.doMock("../channels/registry.js", () => ({
|
||||
CHAT_CHANNEL_ORDER: [],
|
||||
CHANNEL_IDS: [],
|
||||
listChatChannels: () => [],
|
||||
listChatChannelAliases: () => [],
|
||||
getChatChannelMeta: () => ({ id: "demo-channel" }),
|
||||
normalizeChatChannelId: () => null,
|
||||
normalizeChannelId: () => null,
|
||||
normalizeAnyChannelId: () => null,
|
||||
}));
|
||||
|
||||
vi.doMock("@modelcontextprotocol/sdk/client/index.js", () => ({
|
||||
Client: class {
|
||||
connect = vi.fn(async () => {});
|
||||
listTools = vi.fn(async () => ({ tools: [] }));
|
||||
close = vi.fn(async () => {});
|
||||
},
|
||||
}));
|
||||
|
||||
vi.doMock("@modelcontextprotocol/sdk/client/stdio.js", () => ({
|
||||
StdioClientTransport: class {
|
||||
pid = null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.doMock("../auto-reply/reply/get-reply-directives.js", () => ({
|
||||
resolveReplyDirectives: vi.fn(async () => ({ kind: "reply", reply: undefined })),
|
||||
}));
|
||||
|
||||
vi.doMock("./web-search-providers.js", () => ({
|
||||
resolvePluginWebSearchProviders: () => [],
|
||||
resolveRuntimeWebSearchProviders: () => [],
|
||||
}));
|
||||
|
||||
vi.doMock("../plugins/provider-runtime.runtime.js", () => ({
|
||||
augmentModelCatalogWithProviderPlugins: vi.fn(),
|
||||
buildProviderAuthDoctorHintWithPlugin: vi.fn(() => ""),
|
||||
buildProviderMissingAuthMessageWithPlugin: vi.fn(() => ""),
|
||||
formatProviderAuthProfileApiKeyWithPlugin: vi.fn(
|
||||
({ context }: { context?: { access?: string } }) => context?.access ?? "",
|
||||
),
|
||||
refreshProviderOAuthCredentialWithPlugin: vi.fn(async () => null),
|
||||
}));
|
||||
};
|
||||
|
||||
type SessionResetModule = typeof import("../gateway/session-reset-service.js");
|
||||
|
||||
type SessionResetDeps = {
|
||||
loadConfig: ReturnType<typeof vi.fn>;
|
||||
performGatewaySessionReset: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
type RegistryImportOptions = {
|
||||
sessionResetImportError?: Error;
|
||||
registrationMode?: PluginRegistrationMode;
|
||||
gatewaySupportsReset?: boolean;
|
||||
runtimeAvailable?: boolean;
|
||||
};
|
||||
|
||||
function createRecord(): PluginRecord {
|
||||
return {
|
||||
id: "demo-plugin",
|
||||
name: "Demo Plugin",
|
||||
source: "/tmp/demo-plugin.ts",
|
||||
origin: "workspace",
|
||||
enabled: true,
|
||||
status: "loaded",
|
||||
toolNames: [],
|
||||
hookNames: [],
|
||||
channelIds: [],
|
||||
providerIds: [],
|
||||
speechProviderIds: [],
|
||||
mediaUnderstandingProviderIds: [],
|
||||
imageGenerationProviderIds: [],
|
||||
webSearchProviderIds: [],
|
||||
gatewayMethods: [],
|
||||
cliCommands: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
httpRoutes: 0,
|
||||
hookCount: 0,
|
||||
configSchema: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function createApiHarness(options?: RegistryImportOptions) {
|
||||
vi.resetModules();
|
||||
mockPluginSideEffects();
|
||||
|
||||
const deps: SessionResetDeps = {
|
||||
loadConfig: vi.fn(() => ({}) as OpenClawConfig),
|
||||
performGatewaySessionReset: vi.fn(async (payload) => ({
|
||||
ok: true,
|
||||
key: payload.key,
|
||||
entry: { sessionId: "session-default" },
|
||||
})),
|
||||
};
|
||||
|
||||
const createSessionResetModuleMock = (): SessionResetModule => ({
|
||||
archiveSessionTranscriptsForSession: vi.fn(() => []),
|
||||
cleanupSessionBeforeMutation: vi.fn(async () => undefined),
|
||||
emitSessionUnboundLifecycleEvent: vi.fn(async () => {}),
|
||||
performGatewaySessionReset:
|
||||
deps.performGatewaySessionReset as SessionResetModule["performGatewaySessionReset"],
|
||||
});
|
||||
|
||||
const loadSessionResetModule: () => Promise<SessionResetModule> =
|
||||
typeof options?.sessionResetImportError === "undefined"
|
||||
? async () => createSessionResetModuleMock()
|
||||
: async () => {
|
||||
throw options.sessionResetImportError;
|
||||
};
|
||||
|
||||
const { createPluginRegistry } = await import("./registry.js");
|
||||
const { createApi } = createPluginRegistry({
|
||||
logger: {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
},
|
||||
coreGatewayHandlers:
|
||||
options?.gatewaySupportsReset === false ? {} : { "sessions.reset": async () => {} },
|
||||
runtime: createTestRuntime({
|
||||
available: options?.runtimeAvailable !== false,
|
||||
loadConfig: deps.loadConfig,
|
||||
}),
|
||||
loadSessionResetModule,
|
||||
});
|
||||
|
||||
const api = createApi(createRecord(), {
|
||||
config: {} as OpenClawConfig,
|
||||
registrationMode: options?.registrationMode,
|
||||
});
|
||||
|
||||
return { api, deps };
|
||||
}
|
||||
|
||||
function createTestRuntime(params: {
|
||||
available?: boolean;
|
||||
loadConfig: SessionResetDeps["loadConfig"];
|
||||
}): PluginRuntime {
|
||||
const run = vi.fn(async () => ({ runId: "run" }));
|
||||
const deleteSession =
|
||||
params.available === false
|
||||
? run
|
||||
: vi.fn(async () => {
|
||||
return;
|
||||
});
|
||||
return {
|
||||
version: "test",
|
||||
config: {
|
||||
loadConfig: params.loadConfig as PluginRuntime["config"]["loadConfig"],
|
||||
writeConfigFile: vi.fn(),
|
||||
},
|
||||
subagent: {
|
||||
run,
|
||||
waitForRun: vi.fn(),
|
||||
getSessionMessages: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
deleteSession,
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function createApiWithDefaultMocks() {
|
||||
const harness = await createApiHarness();
|
||||
harness.deps.loadConfig.mockReturnValue({} as OpenClawConfig);
|
||||
return harness;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
vi.doUnmock("../agents/sandbox/constants.js");
|
||||
for (const id of AUTH_PROFILES_OAUTH_MODULE_IDS) {
|
||||
vi.doUnmock(id);
|
||||
}
|
||||
for (const id of MODEL_AUTH_MODULE_IDS) {
|
||||
vi.doUnmock(id);
|
||||
}
|
||||
vi.doUnmock("../channels/registry.js");
|
||||
vi.doUnmock("@modelcontextprotocol/sdk/client/index.js");
|
||||
vi.doUnmock("@modelcontextprotocol/sdk/client/stdio.js");
|
||||
vi.doUnmock("../auto-reply/reply/get-reply-directives.js");
|
||||
vi.doUnmock("../gateway/session-reset-service.js");
|
||||
vi.doUnmock("openclaw/plugin-sdk/text-runtime");
|
||||
vi.doUnmock("./web-search-providers.js");
|
||||
vi.doUnmock("../plugins/provider-runtime.runtime.js");
|
||||
});
|
||||
|
||||
describe("plugin resetSession", () => {
|
||||
describe("type and API exposure", () => {
|
||||
it("keeps the exported API type feature-detectable", () => {
|
||||
expectTypeOf<OpenClawPluginApi["resetSession"]>().toEqualTypeOf<
|
||||
((key: string, reason?: "new" | "reset") => Promise<PluginResetSessionResult>) | undefined
|
||||
>();
|
||||
});
|
||||
|
||||
it("exposes resetSession on the real API object returned by createApi", async () => {
|
||||
const { api } = await createApiHarness();
|
||||
|
||||
expect(api).toHaveProperty("resetSession");
|
||||
expect(api.resetSession).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("omits resetSession in setup-only registration mode", async () => {
|
||||
const { api } = await createApiHarness({ registrationMode: "setup-only" });
|
||||
|
||||
expect(api.resetSession).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits resetSession in setup-runtime registration mode", async () => {
|
||||
const { api } = await createApiHarness({ registrationMode: "setup-runtime" });
|
||||
|
||||
expect(api.resetSession).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("gateway availability guard", () => {
|
||||
it("fails fast when the runtime subagent is unavailable", async () => {
|
||||
const { api, deps } = await createApiHarness({ runtimeAvailable: false });
|
||||
|
||||
await expect(api.resetSession?.("agent:main:demo")).resolves.toEqual({
|
||||
ok: false,
|
||||
key: "agent:main:demo",
|
||||
error: "resetSession is only available while the gateway is running.",
|
||||
});
|
||||
|
||||
expect(deps.loadConfig).not.toHaveBeenCalled();
|
||||
expect(deps.performGatewaySessionReset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not attempt to import the reset service when the gateway is unavailable", async () => {
|
||||
const { api } = await createApiHarness({
|
||||
runtimeAvailable: false,
|
||||
sessionResetImportError: new Error("reset module should not load"),
|
||||
});
|
||||
|
||||
await expect(api.resetSession?.("agent:main:demo")).resolves.toEqual({
|
||||
ok: false,
|
||||
key: "agent:main:demo",
|
||||
error: "resetSession is only available while the gateway is running.",
|
||||
});
|
||||
});
|
||||
|
||||
it("still resolves the reset service when the registry was constructed without sessions.reset", async () => {
|
||||
const { api, deps } = await createApiHarness({ gatewaySupportsReset: false });
|
||||
|
||||
await expect(api.resetSession?.("agent:main:demo")).resolves.toEqual({
|
||||
ok: true,
|
||||
key: "agent:main:demo",
|
||||
sessionId: "session-default",
|
||||
});
|
||||
|
||||
expect(deps.performGatewaySessionReset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validation and success mapping", () => {
|
||||
it("normalizes non-string, empty, and whitespace-only keys to failure results", async () => {
|
||||
const { api, deps } = await createApiHarness();
|
||||
|
||||
await expect(api.resetSession?.(123 as never)).resolves.toEqual({
|
||||
ok: false,
|
||||
key: "",
|
||||
error: "resetSession key must be a string",
|
||||
});
|
||||
await expect(api.resetSession?.("")).resolves.toEqual({
|
||||
ok: false,
|
||||
key: "",
|
||||
error: "resetSession key must be a non-empty string",
|
||||
});
|
||||
await expect(api.resetSession?.(" ")).resolves.toEqual({
|
||||
ok: false,
|
||||
key: "",
|
||||
error: "resetSession key must be a non-empty string",
|
||||
});
|
||||
|
||||
expect(deps.performGatewaySessionReset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("trims the key, defaults reason to new, and maps success without leaking gateway internals", async () => {
|
||||
const { api, deps } = await createApiWithDefaultMocks();
|
||||
deps.performGatewaySessionReset.mockResolvedValue({
|
||||
ok: true,
|
||||
key: "agent:main:canonical",
|
||||
entry: { sessionId: "session-123", hidden: true },
|
||||
});
|
||||
|
||||
const result = await api.resetSession?.(" agent:main:demo ");
|
||||
|
||||
expect(deps.performGatewaySessionReset).toHaveBeenCalledWith({
|
||||
key: "agent:main:demo",
|
||||
reason: "new",
|
||||
commandSource: "plugin:demo-plugin",
|
||||
});
|
||||
expect(deps.loadConfig).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
key: "agent:main:canonical",
|
||||
sessionId: "session-123",
|
||||
});
|
||||
expect(result).not.toHaveProperty("entry");
|
||||
});
|
||||
|
||||
it("preserves an explicit reset reason", async () => {
|
||||
const { api, deps } = await createApiWithDefaultMocks();
|
||||
deps.performGatewaySessionReset.mockResolvedValue({
|
||||
ok: true,
|
||||
key: "agent:main:demo",
|
||||
entry: { sessionId: "session-456" },
|
||||
});
|
||||
|
||||
await api.resetSession?.("agent:main:demo", "reset");
|
||||
|
||||
expect(deps.performGatewaySessionReset).toHaveBeenCalledWith({
|
||||
key: "agent:main:demo",
|
||||
reason: "reset",
|
||||
commandSource: "plugin:demo-plugin",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses live config for canonicalization instead of the captured API config", async () => {
|
||||
const { api, deps } = await createApiHarness();
|
||||
const liveConfig = {
|
||||
agents: { list: [{ id: "ops", default: true }] },
|
||||
session: { mainKey: "work" },
|
||||
} as OpenClawConfig;
|
||||
const pending = deferred<{ ok: true; key: string; entry: { sessionId: string } }>();
|
||||
|
||||
deps.loadConfig.mockReturnValue(liveConfig);
|
||||
deps.performGatewaySessionReset.mockReturnValue(pending.promise);
|
||||
|
||||
const first = api.resetSession?.("agent:ops:MAIN");
|
||||
const second = await api.resetSession?.("agent:ops:work");
|
||||
|
||||
expect(second).toEqual({
|
||||
ok: false,
|
||||
key: "agent:ops:work",
|
||||
error: "Session reset already in progress for agent:ops:work.",
|
||||
});
|
||||
|
||||
pending.resolve({
|
||||
ok: true,
|
||||
key: "agent:ops:work",
|
||||
entry: { sessionId: "session-live-config" },
|
||||
});
|
||||
await expect(first).resolves.toEqual({
|
||||
ok: true,
|
||||
key: "agent:ops:work",
|
||||
sessionId: "session-live-config",
|
||||
});
|
||||
expect(deps.loadConfig).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('falls back to "new" for invalid runtime reason values', async () => {
|
||||
const { api, deps } = await createApiWithDefaultMocks();
|
||||
deps.performGatewaySessionReset.mockResolvedValue({
|
||||
ok: true,
|
||||
key: "agent:main:demo",
|
||||
entry: { sessionId: "session-fallback" },
|
||||
});
|
||||
|
||||
await expect(api.resetSession?.("agent:main:demo", "invalid" as never)).resolves.toEqual({
|
||||
ok: true,
|
||||
key: "agent:main:demo",
|
||||
sessionId: "session-fallback",
|
||||
});
|
||||
|
||||
expect(deps.performGatewaySessionReset).toHaveBeenCalledWith({
|
||||
key: "agent:main:demo",
|
||||
reason: "new",
|
||||
commandSource: "plugin:demo-plugin",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("failure normalization", () => {
|
||||
it("normalizes gateway failure objects to string errors", async () => {
|
||||
const { api, deps } = await createApiWithDefaultMocks();
|
||||
deps.performGatewaySessionReset.mockResolvedValue({
|
||||
ok: false,
|
||||
error: { code: "UNAVAILABLE", message: "try again later" },
|
||||
});
|
||||
|
||||
await expect(api.resetSession?.("agent:main:demo")).resolves.toEqual({
|
||||
ok: false,
|
||||
key: "agent:main:demo",
|
||||
error: "try again later",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes helper throws from Error and string values", async () => {
|
||||
const { api, deps } = await createApiWithDefaultMocks();
|
||||
deps.performGatewaySessionReset.mockRejectedValueOnce(new Error("boom error"));
|
||||
deps.performGatewaySessionReset.mockRejectedValueOnce("boom string");
|
||||
|
||||
await expect(api.resetSession?.("agent:main:demo")).resolves.toEqual({
|
||||
ok: false,
|
||||
key: "agent:main:demo",
|
||||
error: "boom error",
|
||||
});
|
||||
await expect(api.resetSession?.("agent:main:demo")).resolves.toEqual({
|
||||
ok: false,
|
||||
key: "agent:main:demo",
|
||||
error: "boom string",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes canonicalization failure before invoking the reset helper", async () => {
|
||||
const { api, deps } = await createApiHarness();
|
||||
deps.loadConfig.mockImplementation(() => {
|
||||
throw new Error("bad session key");
|
||||
});
|
||||
|
||||
await expect(api.resetSession?.("agent:main:demo")).resolves.toEqual({
|
||||
ok: false,
|
||||
key: "agent:main:demo",
|
||||
error: "bad session key",
|
||||
});
|
||||
expect(deps.performGatewaySessionReset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes session-reset import failures", async () => {
|
||||
const { api } = await createApiHarness({
|
||||
sessionResetImportError: new Error("import setup failed"),
|
||||
});
|
||||
|
||||
await expect(api.resetSession?.("agent:main:demo")).resolves.toEqual({
|
||||
ok: false,
|
||||
key: "agent:main:demo",
|
||||
error: "import setup failed",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves structured failure instead of rejecting for operational failures", async () => {
|
||||
const { api, deps } = await createApiWithDefaultMocks();
|
||||
deps.performGatewaySessionReset.mockResolvedValue({
|
||||
ok: false,
|
||||
error: { message: "gateway rejected" },
|
||||
});
|
||||
|
||||
await expect(api.resetSession?.("agent:main:demo")).resolves.toEqual({
|
||||
ok: false,
|
||||
key: "agent:main:demo",
|
||||
error: "gateway rejected",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("in-flight guard behavior", () => {
|
||||
it("blocks same canonical key while a reset is already in flight", async () => {
|
||||
const { api, deps } = await createApiWithDefaultMocks();
|
||||
const pending = deferred<{ ok: true; key: string; entry: { sessionId: string } }>();
|
||||
deps.performGatewaySessionReset.mockReturnValue(pending.promise);
|
||||
|
||||
const first = api.resetSession?.("agent:main:demo");
|
||||
const second = await api.resetSession?.("agent:main:demo");
|
||||
|
||||
expect(second).toEqual({
|
||||
ok: false,
|
||||
key: "agent:main:demo",
|
||||
error: "Session reset already in progress for agent:main:demo.",
|
||||
});
|
||||
|
||||
pending.resolve({
|
||||
ok: true,
|
||||
key: "agent:main:demo",
|
||||
entry: { sessionId: "session-1" },
|
||||
});
|
||||
await expect(first).resolves.toEqual({
|
||||
ok: true,
|
||||
key: "agent:main:demo",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows different canonical keys concurrently", async () => {
|
||||
const { api, deps } = await createApiHarness();
|
||||
const first = deferred<{ ok: true; key: string; entry: { sessionId: string } }>();
|
||||
const second = deferred<{ ok: true; key: string; entry: { sessionId: string } }>();
|
||||
|
||||
deps.performGatewaySessionReset
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise);
|
||||
|
||||
const firstCall = api.resetSession?.("agent:main:a");
|
||||
const secondCall = api.resetSession?.("agent:main:b");
|
||||
|
||||
second.resolve({
|
||||
ok: true,
|
||||
key: "agent:main:b",
|
||||
entry: { sessionId: "session-b" },
|
||||
});
|
||||
first.resolve({
|
||||
ok: true,
|
||||
key: "agent:main:a",
|
||||
entry: { sessionId: "session-a" },
|
||||
});
|
||||
|
||||
await expect(firstCall).resolves.toEqual({
|
||||
ok: true,
|
||||
key: "agent:main:a",
|
||||
sessionId: "session-a",
|
||||
});
|
||||
await expect(secondCall).resolves.toEqual({
|
||||
ok: true,
|
||||
key: "agent:main:b",
|
||||
sessionId: "session-b",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks alias keys that resolve to the same canonical key", async () => {
|
||||
const { api, deps } = await createApiHarness();
|
||||
const pending = deferred<{ ok: true; key: string; entry: { sessionId: string } }>();
|
||||
|
||||
deps.loadConfig.mockReturnValue({
|
||||
agents: { list: [{ id: "ops", default: true }] },
|
||||
session: { mainKey: "work" },
|
||||
} as OpenClawConfig);
|
||||
deps.performGatewaySessionReset.mockReturnValue(pending.promise);
|
||||
|
||||
const first = api.resetSession?.("agent:ops:MAIN");
|
||||
const second = await api.resetSession?.("agent:ops:work");
|
||||
|
||||
expect(second).toEqual({
|
||||
ok: false,
|
||||
key: "agent:ops:work",
|
||||
error: "Session reset already in progress for agent:ops:work.",
|
||||
});
|
||||
|
||||
pending.resolve({
|
||||
ok: true,
|
||||
key: "agent:ops:work",
|
||||
entry: { sessionId: "session-work" },
|
||||
});
|
||||
await first;
|
||||
});
|
||||
|
||||
it("releases the in-flight guard after success and after failure", async () => {
|
||||
const { api, deps } = await createApiWithDefaultMocks();
|
||||
deps.performGatewaySessionReset
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
key: "agent:main:demo",
|
||||
entry: { sessionId: "session-ok" },
|
||||
})
|
||||
.mockRejectedValueOnce(new Error("temporary failure"))
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
key: "agent:main:demo",
|
||||
entry: { sessionId: "session-after-failure" },
|
||||
});
|
||||
|
||||
await expect(api.resetSession?.("agent:main:demo")).resolves.toEqual({
|
||||
ok: true,
|
||||
key: "agent:main:demo",
|
||||
sessionId: "session-ok",
|
||||
});
|
||||
await expect(api.resetSession?.("agent:main:demo")).resolves.toEqual({
|
||||
ok: false,
|
||||
key: "agent:main:demo",
|
||||
error: "temporary failure",
|
||||
});
|
||||
await expect(api.resetSession?.("agent:main:demo")).resolves.toEqual({
|
||||
ok: true,
|
||||
key: "agent:main:demo",
|
||||
sessionId: "session-after-failure",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1282,6 +1282,9 @@ export type OpenClawPluginModule =
|
||||
| ((api: OpenClawPluginApi) => void | Promise<void>);
|
||||
|
||||
export type PluginRegistrationMode = "full" | "setup-only" | "setup-runtime";
|
||||
export type PluginResetSessionResult =
|
||||
| { ok: true; key: string; sessionId: string }
|
||||
| { ok: false; key: string; error: string };
|
||||
|
||||
export type OpenClawPluginApi = {
|
||||
id: string;
|
||||
@ -1341,6 +1344,7 @@ export type OpenClawPluginApi = {
|
||||
id: string,
|
||||
factory: import("../context-engine/registry.js").ContextEngineFactory,
|
||||
) => void;
|
||||
resetSession?: (key: string, reason?: "new" | "reset") => Promise<PluginResetSessionResult>;
|
||||
resolvePath: (input: string) => string;
|
||||
/** Register a lifecycle hook handler */
|
||||
on: <K extends PluginHookName>(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user