diff --git a/extensions/mattermost/src/mattermost/slash-state.test.ts b/extensions/mattermost/src/mattermost/slash-state.test.ts index 77d1d7c974d6..6cd2ef08d1fc 100644 --- a/extensions/mattermost/src/mattermost/slash-state.test.ts +++ b/extensions/mattermost/src/mattermost/slash-state.test.ts @@ -1,5 +1,5 @@ // Mattermost tests cover slash state plugin behavior. -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig, RuntimeEnv } from "../runtime-api.js"; import type { ResolvedMattermostAccount } from "./accounts.js"; import type { MattermostRegisteredCommand } from "./slash-commands.js"; @@ -48,6 +48,49 @@ const slashApi = { runtime: RuntimeEnv; }; +const ACCOUNT_STATES_KEY = Symbol.for("openclaw.mattermost.slash-account-states"); + +describe("slash-state global singleton", () => { + afterEach(() => { + deactivateSlashCommands(); + }); + + it("anchors accountStates on globalThis", () => { + deactivateSlashCommands(); + activateSlashCommands({ + account: createResolvedMattermostAccount("a1"), + commandTokens: ["tok-a"], + registeredCommands: [], + api: slashApi, + }); + + const globalStore = globalThis as Record; + const map = globalStore[ACCOUNT_STATES_KEY]; + expect(map).toBeInstanceOf(Map); + expect((map as Map).has("a1")).toBe(true); + }); + + it("preserves slash state across module reloads", async () => { + deactivateSlashCommands(); + activateSlashCommands({ + account: createResolvedMattermostAccount("a1"), + commandTokens: ["tok-reload"], + registeredCommands: [], + api: slashApi, + }); + + vi.resetModules(); + const reloaded = await import("./slash-state.js"); + const match = reloaded.resolveSlashHandlerForToken("tok-reload"); + + expect(match.kind).toBe("single"); + if (match.kind !== "single") { + throw new Error("expected single match after module reload"); + } + expect(match.accountIds).toEqual(["a1"]); + }); +}); + describe("slash-state token routing", () => { it("returns single match when token belongs to one account", () => { deactivateSlashCommands(); diff --git a/extensions/mattermost/src/mattermost/slash-state.ts b/extensions/mattermost/src/mattermost/slash-state.ts index f4005a8d58f1..1dabc47817e5 100644 --- a/extensions/mattermost/src/mattermost/slash-state.ts +++ b/extensions/mattermost/src/mattermost/slash-state.ts @@ -62,8 +62,29 @@ type SlashCommandAccountState = { triggerMap: Map; }; -/** Map from accountId → per-account slash command state. */ -const accountStates = new Map(); +/** + * Map from accountId → per-account slash command state. + * + * Anchored to globalThis so that jiti-loaded (route registration) and + * native-ESM-loaded (monitor/activation) module instances share the + * same Map. Without this, each module loader creates its own copy of + * the module-level variable and the HTTP handler never sees the tokens + * populated by the monitor. + */ +const ACCOUNT_STATES_KEY = Symbol.for("openclaw.mattermost.slash-account-states"); + +function getSlashAccountStates(): Map { + const globalStore = globalThis as Record; + const existing = globalStore[ACCOUNT_STATES_KEY]; + if (existing instanceof Map) { + return existing as Map; + } + const accountStates = new Map(); + globalStore[ACCOUNT_STATES_KEY] = accountStates; + return accountStates; +} + +const accountStates = getSlashAccountStates(); export function resolveSlashHandlerForToken(token: string): SlashHandlerMatch { const matches: Array<{