From a624919c5617c6cd6336d4c2418c418bb547b31a Mon Sep 17 00:00:00 2001 From: master Date: Fri, 20 Mar 2026 13:17:02 -0400 Subject: [PATCH 1/2] feat(plugins): add optional api.resetSession() --- src/plugins/registry.ts | 270 ++++++++++++ src/plugins/reset-session.test.ts | 664 ++++++++++++++++++++++++++++++ src/plugins/types.ts | 4 + 3 files changed, 938 insertions(+) create mode 100644 src/plugins/reset-session.test.ts diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 2fdadfeb94d..2a3055d45fb 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -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,145 @@ 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; }; 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 : []; + const preferred = + agents.find((agent) => agent?.default)?.id ?? + agents.find((agent) => typeof agent?.id === "string")?.id ?? + FALLBACK_AGENT_ID; + return normalizePluginAgentId(preferred); +}; + +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 +383,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(); 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 => ({ + ok: false, + key, + error: normalizeResetSessionError(error), + }); + + const createPluginResetSession = (params: { + pluginId: string; + loadConfig: () => OpenClawConfig; + loadSessionResetModule: () => Promise; + isGatewayRuntimeAvailable: () => boolean; + }): NonNullable => { + 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 +1240,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" diff --git a/src/plugins/reset-session.test.ts b/src/plugins/reset-session.test.ts new file mode 100644 index 00000000000..d59f4ded549 --- /dev/null +++ b/src/plugins/reset-session.test.ts @@ -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; + performGatewaySessionReset: ReturnType; +}; + +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 = + 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() { + let resolve!: (value: T) => void; + let reject!: (error?: unknown) => void; + const promise = new Promise((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().toEqualTypeOf< + ((key: string, reason?: "new" | "reset") => Promise) | 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", + }); + }); + }); +}); diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 343a338c4f8..8d0680c931d 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -1282,6 +1282,9 @@ export type OpenClawPluginModule = | ((api: OpenClawPluginApi) => void | Promise); 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; resolvePath: (input: string) => string; /** Register a lifecycle hook handler */ on: ( From f1b9e0fde92d5b303ff2b827bf5d2e16ce02f621 Mon Sep 17 00:00:00 2001 From: master Date: Fri, 20 Mar 2026 13:27:52 -0400 Subject: [PATCH 2/2] Plugins: align reset default agent --- src/plugins/registry.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 2a3055d45fb..0f8909c713c 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -319,12 +319,15 @@ const canonicalizePluginMainSessionAlias = (params: { }; const resolveDefaultPluginAgentId = (cfg: OpenClawConfig): string => { - const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; - const preferred = - agents.find((agent) => agent?.default)?.id ?? - agents.find((agent) => typeof agent?.id === "string")?.id ?? - FALLBACK_AGENT_ID; - return normalizePluginAgentId(preferred); + 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 => {