openclaw/extensions/feishu/src/subagent-hooks.test.ts
Tak Hoffman 89e3969d64
feat(feishu): add ACP and subagent session binding (#46819)
* feat(feishu): add ACP session support

* fix(feishu): preserve sender-scoped ACP rebinding

* fix(feishu): recover sender scope from bound ACP sessions

* fix(feishu): support DM ACP binding placement

* feat(feishu): add current-conversation session binding

* fix(feishu): avoid DM parent binding fallback

* fix(feishu): require canonical topic sender ids

* fix(feishu): honor sender-scoped ACP bindings

* fix(feishu): allow user-id ACP DM bindings

* fix(feishu): recover user-id ACP DM bindings
2026-03-15 10:33:49 -05:00

624 lines
18 KiB
TypeScript

import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerFeishuSubagentHooks } from "./subagent-hooks.js";
import {
__testing as threadBindingTesting,
createFeishuThreadBindingManager,
} from "./thread-bindings.js";
const baseConfig = {
session: { mainKey: "main", scope: "per-sender" },
channels: { feishu: {} },
};
function registerHandlersForTest(config: Record<string, unknown> = baseConfig) {
const handlers = new Map<string, (event: unknown, ctx: unknown) => unknown>();
const api = {
config,
on: (hookName: string, handler: (event: unknown, ctx: unknown) => unknown) => {
handlers.set(hookName, handler);
},
} as unknown as OpenClawPluginApi;
registerFeishuSubagentHooks(api);
return handlers;
}
function getRequiredHandler(
handlers: Map<string, (event: unknown, ctx: unknown) => unknown>,
hookName: string,
): (event: unknown, ctx: unknown) => unknown {
const handler = handlers.get(hookName);
if (!handler) {
throw new Error(`expected ${hookName} hook handler`);
}
return handler;
}
describe("feishu subagent hook handlers", () => {
beforeEach(() => {
threadBindingTesting.resetFeishuThreadBindingsForTests();
});
it("registers Feishu subagent hooks", () => {
const handlers = registerHandlersForTest();
expect(handlers.has("subagent_spawning")).toBe(true);
expect(handlers.has("subagent_delivery_target")).toBe(true);
expect(handlers.has("subagent_ended")).toBe(true);
expect(handlers.has("subagent_spawned")).toBe(false);
});
it("binds a Feishu DM conversation on subagent_spawning", async () => {
const handlers = registerHandlersForTest();
const handler = getRequiredHandler(handlers, "subagent_spawning");
createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
const result = await handler(
{
childSessionKey: "agent:main:subagent:child",
agentId: "codex",
label: "banana",
mode: "session",
requester: {
channel: "feishu",
accountId: "work",
to: "user:ou_sender_1",
},
threadRequested: true,
},
{},
);
expect(result).toEqual({ status: "ok", threadBindingReady: true });
const deliveryTargetHandler = getRequiredHandler(handlers, "subagent_delivery_target");
expect(
deliveryTargetHandler(
{
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
requesterOrigin: {
channel: "feishu",
accountId: "work",
to: "user:ou_sender_1",
},
expectsCompletionMessage: true,
},
{},
),
).toEqual({
origin: {
channel: "feishu",
accountId: "work",
to: "user:ou_sender_1",
},
});
});
it("preserves the original Feishu DM delivery target", async () => {
const handlers = registerHandlersForTest();
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
const manager = createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
manager.bindConversation({
conversationId: "ou_sender_1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:chat-dm-child",
metadata: {
deliveryTo: "chat:oc_dm_chat_1",
boundBy: "system",
},
});
expect(
deliveryHandler(
{
childSessionKey: "agent:main:subagent:chat-dm-child",
requesterSessionKey: "agent:main:main",
requesterOrigin: {
channel: "feishu",
accountId: "work",
to: "chat:oc_dm_chat_1",
},
expectsCompletionMessage: true,
},
{},
),
).toEqual({
origin: {
channel: "feishu",
accountId: "work",
to: "chat:oc_dm_chat_1",
},
});
});
it("binds a Feishu topic conversation and preserves parent context", async () => {
const handlers = registerHandlersForTest();
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
const result = await spawnHandler(
{
childSessionKey: "agent:main:subagent:topic-child",
agentId: "codex",
label: "topic-child",
mode: "session",
requester: {
channel: "feishu",
accountId: "work",
to: "chat:oc_group_chat",
threadId: "om_topic_root",
},
threadRequested: true,
},
{},
);
expect(result).toEqual({ status: "ok", threadBindingReady: true });
expect(
deliveryHandler(
{
childSessionKey: "agent:main:subagent:topic-child",
requesterSessionKey: "agent:main:main",
requesterOrigin: {
channel: "feishu",
accountId: "work",
to: "chat:oc_group_chat",
threadId: "om_topic_root",
},
expectsCompletionMessage: true,
},
{},
),
).toEqual({
origin: {
channel: "feishu",
accountId: "work",
to: "chat:oc_group_chat",
threadId: "om_topic_root",
},
});
});
it("uses the requester session binding to preserve sender-scoped topic conversations", async () => {
const handlers = registerHandlersForTest();
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
const manager = createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
manager.bindConversation({
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
parentConversationId: "oc_group_chat",
targetKind: "subagent",
targetSessionKey: "agent:main:parent",
metadata: {
agentId: "codex",
label: "parent",
boundBy: "system",
},
});
const reboundResult = await spawnHandler(
{
childSessionKey: "agent:main:subagent:sender-child",
agentId: "codex",
label: "sender-child",
mode: "session",
requester: {
channel: "feishu",
accountId: "work",
to: "chat:oc_group_chat",
threadId: "om_topic_root",
},
threadRequested: true,
},
{
requesterSessionKey: "agent:main:parent",
},
);
expect(reboundResult).toEqual({ status: "ok", threadBindingReady: true });
expect(manager.listBySessionKey("agent:main:subagent:sender-child")).toMatchObject([
{
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
parentConversationId: "oc_group_chat",
},
]);
expect(
deliveryHandler(
{
childSessionKey: "agent:main:subagent:sender-child",
requesterSessionKey: "agent:main:parent",
requesterOrigin: {
channel: "feishu",
accountId: "work",
to: "chat:oc_group_chat",
threadId: "om_topic_root",
},
expectsCompletionMessage: true,
},
{},
),
).toEqual({
origin: {
channel: "feishu",
accountId: "work",
to: "chat:oc_group_chat",
threadId: "om_topic_root",
},
});
});
it("prefers requester-matching bindings when multiple child bindings exist", async () => {
const handlers = registerHandlersForTest();
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
await spawnHandler(
{
childSessionKey: "agent:main:subagent:shared",
agentId: "codex",
label: "shared",
mode: "session",
requester: {
channel: "feishu",
accountId: "work",
to: "user:ou_sender_1",
},
threadRequested: true,
},
{},
);
await spawnHandler(
{
childSessionKey: "agent:main:subagent:shared",
agentId: "codex",
label: "shared",
mode: "session",
requester: {
channel: "feishu",
accountId: "work",
to: "user:ou_sender_2",
},
threadRequested: true,
},
{},
);
expect(
deliveryHandler(
{
childSessionKey: "agent:main:subagent:shared",
requesterSessionKey: "agent:main:main",
requesterOrigin: {
channel: "feishu",
accountId: "work",
to: "user:ou_sender_2",
},
expectsCompletionMessage: true,
},
{},
),
).toEqual({
origin: {
channel: "feishu",
accountId: "work",
to: "user:ou_sender_2",
},
});
});
it("fails closed when requester-session bindings remain ambiguous for the same topic", async () => {
const handlers = registerHandlersForTest();
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
const manager = createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
manager.bindConversation({
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
parentConversationId: "oc_group_chat",
targetKind: "subagent",
targetSessionKey: "agent:main:parent",
metadata: { boundBy: "system" },
});
manager.bindConversation({
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_2",
parentConversationId: "oc_group_chat",
targetKind: "subagent",
targetSessionKey: "agent:main:parent",
metadata: { boundBy: "system" },
});
await expect(
spawnHandler(
{
childSessionKey: "agent:main:subagent:ambiguous-child",
agentId: "codex",
label: "ambiguous-child",
mode: "session",
requester: {
channel: "feishu",
accountId: "work",
to: "chat:oc_group_chat",
threadId: "om_topic_root",
},
threadRequested: true,
},
{
requesterSessionKey: "agent:main:parent",
},
),
).resolves.toMatchObject({
status: "error",
error: expect.stringContaining("direct messages or topic conversations"),
});
expect(
deliveryHandler(
{
childSessionKey: "agent:main:subagent:ambiguous-child",
requesterSessionKey: "agent:main:parent",
requesterOrigin: {
channel: "feishu",
accountId: "work",
to: "chat:oc_group_chat",
threadId: "om_topic_root",
},
expectsCompletionMessage: true,
},
{},
),
).toBeUndefined();
});
it("fails closed when both topic-level and sender-scoped requester bindings exist", async () => {
const handlers = registerHandlersForTest();
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
const manager = createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
manager.bindConversation({
conversationId: "oc_group_chat:topic:om_topic_root",
parentConversationId: "oc_group_chat",
targetKind: "subagent",
targetSessionKey: "agent:main:parent",
metadata: { boundBy: "system" },
});
manager.bindConversation({
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
parentConversationId: "oc_group_chat",
targetKind: "subagent",
targetSessionKey: "agent:main:parent",
metadata: { boundBy: "system" },
});
await expect(
spawnHandler(
{
childSessionKey: "agent:main:subagent:mixed-topic-child",
agentId: "codex",
label: "mixed-topic-child",
mode: "session",
requester: {
channel: "feishu",
accountId: "work",
to: "chat:oc_group_chat",
threadId: "om_topic_root",
},
threadRequested: true,
},
{
requesterSessionKey: "agent:main:parent",
},
),
).resolves.toMatchObject({
status: "error",
error: expect.stringContaining("direct messages or topic conversations"),
});
expect(
deliveryHandler(
{
childSessionKey: "agent:main:subagent:mixed-topic-child",
requesterSessionKey: "agent:main:parent",
requesterOrigin: {
channel: "feishu",
accountId: "work",
to: "chat:oc_group_chat",
threadId: "om_topic_root",
},
expectsCompletionMessage: true,
},
{},
),
).toBeUndefined();
});
it("no-ops for non-Feishu channels and non-threaded spawns", async () => {
const handlers = registerHandlersForTest();
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
const endedHandler = getRequiredHandler(handlers, "subagent_ended");
await expect(
spawnHandler(
{
childSessionKey: "agent:main:subagent:child",
agentId: "codex",
mode: "run",
requester: {
channel: "discord",
accountId: "work",
to: "channel:123",
},
threadRequested: true,
},
{},
),
).resolves.toBeUndefined();
await expect(
spawnHandler(
{
childSessionKey: "agent:main:subagent:child",
agentId: "codex",
mode: "run",
requester: {
channel: "feishu",
accountId: "work",
to: "user:ou_sender_1",
},
threadRequested: false,
},
{},
),
).resolves.toBeUndefined();
expect(
deliveryHandler(
{
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
requesterOrigin: {
channel: "discord",
accountId: "work",
to: "channel:123",
},
expectsCompletionMessage: true,
},
{},
),
).toBeUndefined();
expect(
endedHandler(
{
targetSessionKey: "agent:main:subagent:child",
targetKind: "subagent",
reason: "done",
accountId: "work",
},
{},
),
).toBeUndefined();
});
it("returns an error for unsupported non-topic Feishu group conversations", async () => {
const handler = getRequiredHandler(registerHandlersForTest(), "subagent_spawning");
createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
await expect(
handler(
{
childSessionKey: "agent:main:subagent:child",
agentId: "codex",
mode: "session",
requester: {
channel: "feishu",
accountId: "work",
to: "chat:oc_group_chat",
},
threadRequested: true,
},
{},
),
).resolves.toMatchObject({
status: "error",
error: expect.stringContaining("direct messages or topic conversations"),
});
});
it("unbinds Feishu bindings on subagent_ended", async () => {
const handlers = registerHandlersForTest();
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
const endedHandler = getRequiredHandler(handlers, "subagent_ended");
createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
await spawnHandler(
{
childSessionKey: "agent:main:subagent:child",
agentId: "codex",
mode: "session",
requester: {
channel: "feishu",
accountId: "work",
to: "user:ou_sender_1",
},
threadRequested: true,
},
{},
);
endedHandler(
{
targetSessionKey: "agent:main:subagent:child",
targetKind: "subagent",
reason: "done",
accountId: "work",
},
{},
);
expect(
deliveryHandler(
{
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
requesterOrigin: {
channel: "feishu",
accountId: "work",
to: "user:ou_sender_1",
},
expectsCompletionMessage: true,
},
{},
),
).toBeUndefined();
});
it("fails closed when the Feishu monitor-owned binding manager is unavailable", async () => {
const handlers = registerHandlersForTest();
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
await expect(
spawnHandler(
{
childSessionKey: "agent:main:subagent:no-manager",
agentId: "codex",
mode: "session",
requester: {
channel: "feishu",
accountId: "work",
to: "user:ou_sender_1",
},
threadRequested: true,
},
{},
),
).resolves.toMatchObject({
status: "error",
error: expect.stringContaining("monitor is not active"),
});
expect(
deliveryHandler(
{
childSessionKey: "agent:main:subagent:no-manager",
requesterSessionKey: "agent:main:main",
requesterOrigin: {
channel: "feishu",
accountId: "work",
to: "user:ou_sender_1",
},
expectsCompletionMessage: true,
},
{},
),
).toBeUndefined();
});
});