diff --git a/extensions/thread-ownership/index.test.ts b/extensions/thread-ownership/index.test.ts index e82b28c202a..e21b8758111 100644 --- a/extensions/thread-ownership/index.test.ts +++ b/extensions/thread-ownership/index.test.ts @@ -5,6 +5,7 @@ import register from "./index.js"; describe("thread-ownership plugin", () => { const hooks: Record = {}; const fetchMock = vi.fn() as unknown as typeof globalThis.fetch; + let configFile: Record = {}; const api = { pluginConfig: {}, config: { @@ -12,6 +13,11 @@ describe("thread-ownership plugin", () => { list: [{ id: "test-agent", default: true, identity: { name: "TestBot" } }], }, }, + runtime: { + config: { + loadConfig: () => configFile, + }, + }, id: "thread-ownership", name: "Thread Ownership", logger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn() }, @@ -26,6 +32,9 @@ describe("thread-ownership plugin", () => { delete hooks[key]; } api.pluginConfig = {}; + configFile = { + agents: api.config.agents, + }; process.env.SLACK_FORWARDER_URL = "http://localhost:8750"; process.env.SLACK_BOT_USER_ID = "U999"; @@ -173,6 +182,35 @@ describe("thread-ownership plugin", () => { ); }); + it("uses live runtime allowlists when deciding whether to claim ownership", async () => { + api.pluginConfig = { abTestChannels: ["C123"] }; + configFile = { + ...configFile, + plugins: { + entries: { + "thread-ownership": { + config: { + abTestChannels: ["C999"], + }, + }, + }, + }, + }; + register.register(api as unknown as OpenClawPluginApi); + + const result = await hooks.message_sending( + { + content: "hello", + replyToId: "1234.5678", + to: "C123", + }, + { channelId: "slack", conversationId: "C123" }, + ); + + expect(result).toBeUndefined(); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + it("cancels when thread owned by another agent", async () => { vi.mocked(globalThis.fetch).mockResolvedValue( new Response(JSON.stringify({ owner: "other-agent" }), { status: 409 }), @@ -326,6 +364,57 @@ describe("thread-ownership plugin", () => { expect(globalThis.fetch).not.toHaveBeenCalled(); }); + it("uses the live runtime agent identity for ownership claims", async () => { + configFile = { + ...configFile, + agents: { + list: [{ id: "live-agent", default: true, identity: { name: "LiveBot" } }], + }, + }; + vi.mocked(globalThis.fetch).mockResolvedValue( + new Response(JSON.stringify({ owner: "live-agent" }), { status: 200 }), + ); + + await hooks.message_sending( + { content: "On it!", replyToId: "8888.0005", metadata: { channelId: "C789" }, to: "C789" }, + { channelId: "slack", conversationId: "C789" }, + ); + + expect(globalThis.fetch).toHaveBeenCalledWith( + "http://localhost:8750/api/v1/ownership/C789/8888.0005", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ agent_id: "live-agent" }), + }), + ); + }); + + it("uses the live runtime agent name for mention tracking", async () => { + configFile = { + ...configFile, + agents: { + list: [{ id: "live-agent", default: true, identity: { name: "LiveBot" } }], + }, + }; + + await hooks.message_received( + { + content: "hey @LiveBot help", + threadId: "8888.0006", + metadata: { channelId: "C789" }, + }, + { channelId: "slack", conversationId: "C789" }, + ); + + const result = await hooks.message_sending( + { content: "On it!", replyToId: "8888.0006", metadata: { channelId: "C789" }, to: "C789" }, + { channelId: "slack", conversationId: "C789" }, + ); + + expect(result).toBeUndefined(); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + it("does not treat superset handles as agent-name mentions", async () => { await hooks.message_received( { diff --git a/extensions/thread-ownership/index.ts b/extensions/thread-ownership/index.ts index c65f7551bf2..dd3964240b8 100644 --- a/extensions/thread-ownership/index.ts +++ b/extensions/thread-ownership/index.ts @@ -72,35 +72,56 @@ function resolveOwnershipAgent(config: OpenClawConfig): { id: string; name: stri return { id, name }; } +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function resolveThreadOwnershipPluginConfigFromConfig( + config: OpenClawConfig, +): ThreadOwnershipConfig | undefined { + return asRecord(asRecord(config.plugins?.entries)?.["thread-ownership"])?.config as + | ThreadOwnershipConfig + | undefined; +} + export default definePluginEntry({ id: "thread-ownership", name: "Thread Ownership", description: "Slack thread claim coordination for multi-agent setups", register(api: OpenClawPluginApi) { - const pluginCfg = (api.pluginConfig ?? {}) as ThreadOwnershipConfig; - const forwarderUrl = ( - pluginCfg.forwarderUrl ?? - process.env.SLACK_FORWARDER_URL ?? - "http://slack-forwarder:8750" - ).replace(/\/$/, ""); - - const abTestChannels = new Set( - ( - pluginCfg.abTestChannels ?? - process.env.THREAD_OWNERSHIP_CHANNELS?.split(",").filter(Boolean) ?? - [] - ) - .map((entry) => resolveSlackConversationId(entry)) - .filter(Boolean), - ); - - const { id: agentId, name: agentName } = resolveOwnershipAgent(api.config); - const botUserId = process.env.SLACK_BOT_USER_ID ?? ""; + const resolveCurrentState = () => { + const currentConfig = api.runtime.config?.loadConfig?.() ?? api.config; + const pluginCfg = + resolveThreadOwnershipPluginConfigFromConfig(currentConfig) || + ((api.pluginConfig ?? {}) as ThreadOwnershipConfig); + return { + currentConfig, + forwarderUrl: ( + pluginCfg.forwarderUrl ?? + process.env.SLACK_FORWARDER_URL ?? + "http://slack-forwarder:8750" + ).replace(/\/$/, ""), + abTestChannels: new Set( + ( + pluginCfg.abTestChannels ?? + process.env.THREAD_OWNERSHIP_CHANNELS?.split(",").filter(Boolean) ?? + [] + ) + .map((entry) => resolveSlackConversationId(entry)) + .filter(Boolean), + ), + botUserId: process.env.SLACK_BOT_USER_ID ?? "", + agent: resolveOwnershipAgent(currentConfig), + }; + }; api.on("message_received", async (event, ctx) => { if (ctx.channelId !== "slack") { return; } + const { agent, botUserId } = resolveCurrentState(); const text = event.content ?? ""; const threadTs = @@ -116,7 +137,7 @@ export default definePluginEntry({ } const mentioned = - containsAgentNameMention(text, agentName) || + containsAgentNameMention(text, agent.name) || (botUserId && text.includes(`<@${botUserId}>`)); if (mentioned) { cleanExpiredMentions(); @@ -128,6 +149,7 @@ export default definePluginEntry({ if (ctx.channelId !== "slack") { return undefined; } + const { abTestChannels, agent, forwarderUrl } = resolveCurrentState(); const threadTs = resolveThreadToken(event.replyToId) || @@ -159,7 +181,7 @@ export default definePluginEntry({ init: { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ agent_id: agentId }), + body: JSON.stringify({ agent_id: agent.id }), }, timeoutMs: 3000, policy: ssrfPolicyFromDangerouslyAllowPrivateNetwork(true),