diff --git a/extensions/matrix/src/matrix/subagent-hooks.test.ts b/extensions/matrix/src/matrix/subagent-hooks.test.ts index aade99dd3af..2e19ebfdf4e 100644 --- a/extensions/matrix/src/matrix/subagent-hooks.test.ts +++ b/extensions/matrix/src/matrix/subagent-hooks.test.ts @@ -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", diff --git a/extensions/matrix/src/matrix/subagent-hooks.ts b/extensions/matrix/src/matrix/subagent-hooks.ts index 05a3804cf20..8940b0217af 100644 --- a/extensions/matrix/src/matrix/subagent-hooks.ts +++ b/extensions/matrix/src/matrix/subagent-hooks.ts @@ -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(); + 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(); for (const binding of matching) { + if (removedBindingKeys.has(resolveBindingKey(binding))) { + continue; + } if (removeBindingRecord(binding)) { affectedAccountIds.add(binding.accountId); }