Matrix: send subagent binding farewells

This commit is contained in:
Gustavo Madeira Santana
2026-04-17 01:45:14 -04:00
parent f81d65d828
commit a04b8c16d1
2 changed files with 71 additions and 1 deletions

View File

@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
// Hoisted stubs referenced in vi.mock factories below
const bindMock = vi.hoisted(() => vi.fn());
const unbindMock = vi.hoisted(() => vi.fn());
const getManagerMock = vi.hoisted(() => vi.fn());
const listAllBindingsMock = vi.hoisted(() => vi.fn((): any[] => []));
const listBindingsForAccountMock = vi.hoisted(() => vi.fn((): any[] => []));
@@ -10,7 +11,7 @@ const resolveMatrixBaseConfigMock = vi.hoisted(() => vi.fn((): any => ({})));
const findMatrixAccountConfigMock = vi.hoisted(() => vi.fn((): any => undefined));
vi.mock("openclaw/plugin-sdk/conversation-binding-runtime", () => ({
getSessionBindingService: () => ({ bind: bindMock }),
getSessionBindingService: () => ({ bind: bindMock, unbind: unbindMock }),
}));
vi.mock("./account-config.js", () => ({
@@ -23,6 +24,12 @@ vi.mock("./thread-bindings-shared.js", () => ({
listAllBindings: listAllBindingsMock,
listBindingsForAccount: listBindingsForAccountMock,
removeBindingRecord: removeBindingRecordMock,
resolveBindingKey: (params: {
accountId: string;
conversationId: string;
parentConversationId?: string;
}) =>
`${params.accountId}:${params.parentConversationId?.trim() || "-"}:${params.conversationId}`,
}));
import {
@@ -280,6 +287,7 @@ describe("handleMatrixSubagentEnded", () => {
listAllBindingsMock.mockReset();
listBindingsForAccountMock.mockReset();
removeBindingRecordMock.mockReset();
unbindMock.mockReset();
mockManager.persist.mockReset();
});
@@ -319,6 +327,49 @@ describe("handleMatrixSubagentEnded", () => {
expect(mockManager.persist).toHaveBeenCalled();
});
it("sends farewell through the binding service when requested", async () => {
const binding = {
targetSessionKey: "agent:ops:subagent:child",
targetKind: "subagent",
accountId: "ops",
conversationId: "$thread",
parentConversationId: "!room:example",
boundAt: 0,
lastActivityAt: 0,
};
listBindingsForAccountMock.mockReturnValue([binding]);
unbindMock.mockResolvedValue([
{
bindingId: "ops:!room:example:$thread",
targetSessionKey: "agent:ops:subagent:child",
targetKind: "subagent",
conversation: {
channel: "matrix",
accountId: "ops",
conversationId: "$thread",
parentConversationId: "!room:example",
},
status: "active",
boundAt: 0,
},
]);
await handleMatrixSubagentEnded({
targetSessionKey: "agent:ops:subagent:child",
targetKind: "subagent",
accountId: "ops",
reason: "spawn-failed",
sendFarewell: true,
});
expect(unbindMock).toHaveBeenCalledWith({
bindingId: "ops:!room:example:$thread",
reason: "spawn-failed",
});
expect(removeBindingRecordMock).not.toHaveBeenCalled();
expect(getManagerMock).not.toHaveBeenCalled();
});
it("skips persist when removeBindingRecord returns false (binding not found in store)", async () => {
const binding = {
targetSessionKey: "agent:ops:subagent:orphan",

View File

@@ -12,6 +12,7 @@ import {
listAllBindings,
listBindingsForAccount,
removeBindingRecord,
resolveBindingKey,
} from "./thread-bindings-shared.js";
type MatrixSubagentSpawningEvent = {
@@ -31,6 +32,8 @@ type MatrixSubagentEndedEvent = {
targetSessionKey: string;
targetKind: string;
accountId?: string;
reason?: string;
sendFarewell?: boolean;
};
type MatrixSubagentDeliveryTargetEvent = {
@@ -219,8 +222,24 @@ export async function handleMatrixSubagentEnded(event: MatrixSubagentEndedEvent)
const matching = candidates.filter(
(entry) => entry.targetSessionKey === event.targetSessionKey && entry.targetKind === "subagent",
);
const removedBindingKeys = new Set<string>();
if (event.sendFarewell) {
const bindingService = getSessionBindingService();
const reason = normalizeOptionalString(event.reason) || "subagent-ended";
for (const binding of matching) {
const bindingId = resolveBindingKey(binding);
const removed = await bindingService.unbind({ bindingId, reason });
if (removed.some((entry) => entry.bindingId === bindingId)) {
removedBindingKeys.add(bindingId);
}
}
}
const affectedAccountIds = new Set<string>();
for (const binding of matching) {
if (removedBindingKeys.has(resolveBindingKey(binding))) {
continue;
}
if (removeBindingRecord(binding)) {
affectedAccountIds.add(binding.accountId);
}