fix(hooks): use live thread ownership config

This commit is contained in:
Vincent Koc
2026-04-22 13:01:32 -07:00
parent fbf554397f
commit 834e50f83c
2 changed files with 132 additions and 21 deletions

View File

@@ -5,6 +5,7 @@ import register from "./index.js";
describe("thread-ownership plugin", () => {
const hooks: Record<string, Function> = {};
const fetchMock = vi.fn() as unknown as typeof globalThis.fetch;
let configFile: Record<string, unknown> = {};
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(
{

View File

@@ -72,35 +72,56 @@ function resolveOwnershipAgent(config: OpenClawConfig): { id: string; name: stri
return { id, name };
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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),