diff --git a/extensions/thread-ownership/index.test.ts b/extensions/thread-ownership/index.test.ts index b195c9e9581..27bafe3fbc9 100644 --- a/extensions/thread-ownership/index.test.ts +++ b/extensions/thread-ownership/index.test.ts @@ -122,6 +122,30 @@ describe("thread-ownership plugin", () => { ); }); + it("canonicalizes non-canonical Slack targets when shared conversationId is missing", async () => { + vi.mocked(globalThis.fetch).mockResolvedValue( + new Response(JSON.stringify({ owner: "test-agent" }), { status: 200 }), + ); + + const result = await hooks.message_sending( + { + content: "hello", + replyToId: "1234.5678", + to: "channel:C123", + }, + { channelId: "slack", conversationId: "" }, + ); + + expect(result).toBeUndefined(); + expect(globalThis.fetch).toHaveBeenCalledWith( + "http://localhost:8750/api/v1/ownership/C123/1234.5678", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ agent_id: "test-agent" }), + }), + ); + }); + it("cancels when thread owned by another agent", async () => { vi.mocked(globalThis.fetch).mockResolvedValue( new Response(JSON.stringify({ owner: "other-agent" }), { status: 409 }), @@ -194,6 +218,29 @@ describe("thread-ownership plugin", () => { expect(globalThis.fetch).not.toHaveBeenCalled(); }); + it("canonicalizes inbound non-canonical metadata without shared conversation context", async () => { + await hooks.message_received( + { + content: "Hey @TestBot help me", + threadId: "9999.0003", + metadata: { channelId: "channel:C456" }, + }, + { channelId: "slack", conversationId: "" }, + ); + + const result = await hooks.message_sending( + { + content: "Sure!", + replyToId: "9999.0003", + to: "C456", + }, + { channelId: "slack", conversationId: "C456" }, + ); + + expect(result).toBeUndefined(); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + it("ignores @-mentions on non-slack channels", async () => { // Use a unique thread key so module-level state from other tests doesn't interfere. await hooks.message_received( diff --git a/extensions/thread-ownership/index.ts b/extensions/thread-ownership/index.ts index 03739a1c5c2..ab75ed22992 100644 --- a/extensions/thread-ownership/index.ts +++ b/extensions/thread-ownership/index.ts @@ -24,6 +24,16 @@ function resolveThreadToken(value: unknown): string { return typeof value === "string" || typeof value === "number" ? String(value) : ""; } +function resolveSlackConversationId(value: unknown): string { + const raw = normalizeOptionalString(value) ?? ""; + if (!raw) { + return ""; + } + const trimmed = raw.trim(); + const match = /^(?:slack:)?channel:(.+)$/i.exec(trimmed); + return match?.[1]?.trim() || trimmed; +} + function cleanExpiredMentions(): void { const now = Date.now(); for (const [key, ts] of mentionedThreads) { @@ -81,8 +91,8 @@ export default definePluginEntry({ resolveThreadToken(event.metadata?.threadId) || resolveThreadToken(event.metadata?.threadTs); const channelId = - normalizeOptionalString(ctx.conversationId) || - normalizeOptionalString(event.metadata?.channelId) || + resolveSlackConversationId(ctx.conversationId) || + resolveSlackConversationId(event.metadata?.channelId) || ""; if (!threadTs || !channelId) { return; @@ -108,9 +118,9 @@ export default definePluginEntry({ resolveThreadToken(event.metadata?.threadId) || resolveThreadToken(event.metadata?.threadTs); const channelId = - normalizeOptionalString(ctx.conversationId) || - normalizeOptionalString(event.metadata?.channelId) || - normalizeOptionalString(event.to) || + resolveSlackConversationId(ctx.conversationId) || + resolveSlackConversationId(event.metadata?.channelId) || + resolveSlackConversationId(event.to) || ""; if (!threadTs || !channelId) { return undefined;