From dd10f290e825d6fa2d04f805234c4508c763804b Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 19 Mar 2026 09:24:24 -0400 Subject: [PATCH] Matrix: wire thread binding command support --- docs/channels/matrix.md | 5 +- docs/install/migrating-matrix.md | 6 +- src/auto-reply/reply/channel-context.ts | 4 + src/auto-reply/reply/commands-acp.test.ts | 130 ++++++++++++++-- .../reply/commands-acp/context.test.ts | 21 +++ src/auto-reply/reply/commands-acp/context.ts | 28 ++++ .../reply/commands-acp/lifecycle.ts | 22 +-- src/auto-reply/reply/commands-acp/shared.ts | 8 +- .../reply/commands-session-lifecycle.test.ts | 130 +++++++++++++++- src/auto-reply/reply/commands-session.ts | 145 ++++++++++++++++-- .../reply/commands-subagents-focus.test.ts | 116 +++++++++++++- .../reply/commands-subagents/action-focus.ts | 95 +++++++++++- .../commands-subagents/action-unfocus.ts | 54 ++++++- .../reply/commands-subagents/shared.ts | 2 + src/channels/thread-bindings-policy.ts | 15 +- src/plugin-sdk/matrix.ts | 4 + src/plugins/runtime/runtime-channel.ts | 6 +- src/plugins/runtime/runtime-matrix.ts | 14 ++ src/plugins/runtime/types-channel.ts | 6 + .../helpers/extensions/plugin-runtime-mock.ts | 1 + 20 files changed, 756 insertions(+), 56 deletions(-) create mode 100644 src/plugins/runtime/runtime-matrix.ts diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 4d9d0fa0e4f..d6ec40ff4db 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -372,7 +372,7 @@ Planned improvement: ## Automatic verification notices -Matrix now posts verification lifecycle notices directly into the Matrix room as `m.notice` messages. +Matrix now posts verification lifecycle notices directly into the strict DM verification room as `m.notice` messages. That includes: - verification request notices @@ -381,7 +381,8 @@ That includes: - SAS details (emoji and decimal) when available Incoming verification requests from another Matrix client are tracked and auto-accepted by OpenClaw. -When SAS emoji verification becomes available, OpenClaw starts that SAS flow automatically for inbound requests and confirms its own side. +For self-verification flows, OpenClaw also starts the SAS flow automatically when emoji verification becomes available and confirms its own side. +For verification requests from another Matrix user/device, OpenClaw auto-accepts the request and then waits for the SAS flow to proceed normally. You still need to compare the emoji or decimal SAS in your Matrix client and confirm "They match" there to complete the verification. OpenClaw does not auto-accept self-initiated duplicate flows blindly. Startup skips creating a new request when a self-verification request is already pending. diff --git a/docs/install/migrating-matrix.md b/docs/install/migrating-matrix.md index d1e85c5ecd1..bd8772e29f6 100644 --- a/docs/install/migrating-matrix.md +++ b/docs/install/migrating-matrix.md @@ -204,7 +204,9 @@ If the old store reports room keys that were never backed up, OpenClaw warns ins - Meaning: OpenClaw found a helper file path that escapes the plugin root or fails plugin boundary checks, so it refused to import it. - What to do: reinstall the Matrix plugin from a trusted path, then rerun `openclaw doctor --fix` or restart the gateway. -`gateway: failed creating a Matrix migration snapshot; skipping Matrix migration for now: ...` +`- Failed creating a Matrix migration snapshot before repair: ...` + +`- Skipping Matrix migration changes for now. Resolve the snapshot failure, then rerun "openclaw doctor --fix".` - Meaning: OpenClaw refused to mutate Matrix state because it could not create the recovery snapshot first. - What to do: resolve the backup error, then rerun `openclaw doctor --fix` or restart the gateway. @@ -236,7 +238,7 @@ If the old store reports room keys that were never backed up, OpenClaw warns ins - Meaning: backup exists, but OpenClaw could not recover the recovery key automatically. - What to do: run `openclaw matrix verify backup restore --recovery-key ""`. -`Failed inspecting legacy Matrix encrypted state for account "...": ...` +`Failed inspecting legacy Matrix encrypted state for account "..." (...): ...` - Meaning: OpenClaw found the old encrypted store, but it could not inspect it safely enough to prepare recovery. - What to do: rerun `openclaw doctor --fix`. If it repeats, keep the old state directory intact and recover using another verified Matrix client plus `openclaw matrix verify backup restore --recovery-key ""`. diff --git a/src/auto-reply/reply/channel-context.ts b/src/auto-reply/reply/channel-context.ts index d8ffb261eb8..afe77e32805 100644 --- a/src/auto-reply/reply/channel-context.ts +++ b/src/auto-reply/reply/channel-context.ts @@ -24,6 +24,10 @@ export function isTelegramSurface(params: DiscordSurfaceParams): boolean { return resolveCommandSurfaceChannel(params) === "telegram"; } +export function isMatrixSurface(params: DiscordSurfaceParams): boolean { + return resolveCommandSurfaceChannel(params) === "matrix"; +} + export function resolveCommandSurfaceChannel(params: DiscordSurfaceParams): string { const channel = params.ctx.OriginatingChannel ?? diff --git a/src/auto-reply/reply/commands-acp.test.ts b/src/auto-reply/reply/commands-acp.test.ts index 5d732e4b4e6..ca8ece9b3cc 100644 --- a/src/auto-reply/reply/commands-acp.test.ts +++ b/src/auto-reply/reply/commands-acp.test.ts @@ -120,7 +120,7 @@ type FakeBinding = { targetSessionKey: string; targetKind: "subagent" | "session"; conversation: { - channel: "discord" | "telegram" | "feishu"; + channel: "discord" | "matrix" | "telegram" | "feishu"; accountId: string; conversationId: string; parentConversationId?: string; @@ -245,9 +245,10 @@ function createSessionBindingCapabilities() { type AcpBindInput = { targetSessionKey: string; conversation: { - channel?: "discord" | "telegram" | "feishu"; + channel?: "discord" | "matrix" | "telegram" | "feishu"; accountId: string; conversationId: string; + parentConversationId?: string; }; placement: "current" | "child"; metadata?: Record; @@ -266,17 +267,27 @@ function createAcpThreadBinding(input: AcpBindInput): FakeBinding { conversationId: nextConversationId, parentConversationId: "parent-1", } - : channel === "feishu" + : channel === "matrix" ? { - channel: "feishu" as const, + channel: "matrix" as const, accountId: input.conversation.accountId, conversationId: nextConversationId, + parentConversationId: + input.placement === "child" + ? input.conversation.conversationId + : input.conversation.parentConversationId, } - : { - channel: "telegram" as const, - accountId: input.conversation.accountId, - conversationId: nextConversationId, - }; + : channel === "feishu" + ? { + channel: "feishu" as const, + accountId: input.conversation.accountId, + conversationId: nextConversationId, + } + : { + channel: "telegram" as const, + accountId: input.conversation.accountId, + conversationId: nextConversationId, + }; return createSessionBinding({ targetSessionKey: input.targetSessionKey, conversation, @@ -359,6 +370,32 @@ async function runTelegramDmAcpCommand(commandBody: string, cfg: OpenClawConfig return handleAcpCommand(createTelegramDmParams(commandBody, cfg), true); } +function createMatrixRoomParams(commandBody: string, cfg: OpenClawConfig = baseCfg) { + const params = buildCommandTestParams(commandBody, cfg, { + Provider: "matrix", + Surface: "matrix", + OriginatingChannel: "matrix", + OriginatingTo: "room:!room:example.org", + AccountId: "default", + }); + params.command.senderId = "user-1"; + return params; +} + +function createMatrixThreadParams(commandBody: string, cfg: OpenClawConfig = baseCfg) { + const params = createMatrixRoomParams(commandBody, cfg); + params.ctx.MessageThreadId = "$thread-root"; + return params; +} + +async function runMatrixAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) { + return handleAcpCommand(createMatrixRoomParams(commandBody, cfg), true); +} + +async function runMatrixThreadAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) { + return handleAcpCommand(createMatrixThreadParams(commandBody, cfg), true); +} + function createFeishuDmParams(commandBody: string, cfg: OpenClawConfig = baseCfg) { const params = buildCommandTestParams(commandBody, cfg, { Provider: "feishu", @@ -598,6 +635,63 @@ describe("/acp command", () => { ); }); + it("creates Matrix thread-bound ACP spawns from top-level rooms when enabled", async () => { + const cfg = { + ...baseCfg, + channels: { + matrix: { + threadBindings: { + enabled: true, + spawnAcpSessions: true, + }, + }, + }, + } satisfies OpenClawConfig; + + const result = await runMatrixAcpCommand("/acp spawn codex", cfg); + + expect(result?.reply?.text).toContain("Created thread thread-created and bound it"); + expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( + expect.objectContaining({ + placement: "child", + conversation: expect.objectContaining({ + channel: "matrix", + accountId: "default", + conversationId: "!room:example.org", + }), + }), + ); + }); + + it("binds Matrix thread ACP spawns to the current thread with the parent room id", async () => { + const cfg = { + ...baseCfg, + channels: { + matrix: { + threadBindings: { + enabled: true, + spawnAcpSessions: true, + }, + }, + }, + } satisfies OpenClawConfig; + + const result = await runMatrixThreadAcpCommand("/acp spawn codex --thread here", cfg); + + expect(result?.reply?.text).toContain("Bound this thread to"); + expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( + expect.objectContaining({ + placement: "current", + conversation: expect.objectContaining({ + channel: "matrix", + accountId: "default", + conversationId: "$thread-root", + parentConversationId: "!room:example.org", + }), + }), + ); + }); + it("binds Feishu DM ACP spawns to the current DM conversation", async () => { const result = await runFeishuDmAcpCommand("/acp spawn codex --thread here"); @@ -654,6 +748,24 @@ describe("/acp command", () => { ); }); + it("rejects Matrix thread-bound ACP spawn when spawnAcpSessions is unset", async () => { + const cfg = { + ...baseCfg, + channels: { + matrix: { + threadBindings: { + enabled: true, + }, + }, + }, + } satisfies OpenClawConfig; + + const result = await runMatrixAcpCommand("/acp spawn codex", cfg); + + expect(result?.reply?.text).toContain("spawnAcpSessions=true"); + expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled(); + }); + it("forbids /acp spawn from sandboxed requester sessions", async () => { const cfg = { ...baseCfg, diff --git a/src/auto-reply/reply/commands-acp/context.test.ts b/src/auto-reply/reply/commands-acp/context.test.ts index 5b1e60ad1fc..721ee325b48 100644 --- a/src/auto-reply/reply/commands-acp/context.test.ts +++ b/src/auto-reply/reply/commands-acp/context.test.ts @@ -141,6 +141,27 @@ describe("commands-acp context", () => { expect(resolveAcpCommandConversationId(params)).toBe("123456789"); }); + it("resolves Matrix thread context from the current room and thread root", () => { + const params = buildCommandTestParams("/acp status", baseCfg, { + Provider: "matrix", + Surface: "matrix", + OriginatingChannel: "matrix", + OriginatingTo: "room:!room:example.org", + AccountId: "work", + MessageThreadId: "$thread-root", + }); + + expect(resolveAcpCommandBindingContext(params)).toEqual({ + channel: "matrix", + accountId: "work", + threadId: "$thread-root", + conversationId: "$thread-root", + parentConversationId: "!room:example.org", + }); + expect(resolveAcpCommandConversationId(params)).toBe("$thread-root"); + expect(resolveAcpCommandParentConversationId(params)).toBe("!room:example.org"); + }); + it("builds Feishu topic conversation ids from chat target + root message id", () => { const params = buildCommandTestParams("/acp status", baseCfg, { Provider: "feishu", diff --git a/src/auto-reply/reply/commands-acp/context.ts b/src/auto-reply/reply/commands-acp/context.ts index de3a615eb4b..7a326f4d564 100644 --- a/src/auto-reply/reply/commands-acp/context.ts +++ b/src/auto-reply/reply/commands-acp/context.ts @@ -9,6 +9,10 @@ import { getSessionBindingService } from "../../../infra/outbound/session-bindin import { parseAgentSessionKey } from "../../../routing/session-key.js"; import type { HandleCommandsParams } from "../commands-types.js"; import { parseDiscordParentChannelFromSessionKey } from "../discord-parent-channel.js"; +import { + resolveMatrixConversationId, + resolveMatrixParentConversationId, +} from "../matrix-context.js"; import { resolveTelegramConversationId } from "../telegram-context.js"; type FeishuGroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender"; @@ -161,6 +165,18 @@ export function resolveAcpCommandThreadId(params: HandleCommandsParams): string export function resolveAcpCommandConversationId(params: HandleCommandsParams): string | undefined { const channel = resolveAcpCommandChannel(params); + if (channel === "matrix") { + return resolveMatrixConversationId({ + ctx: { + MessageThreadId: params.ctx.MessageThreadId, + OriginatingTo: params.ctx.OriginatingTo, + To: params.ctx.To, + }, + command: { + to: params.command.to, + }, + }); + } if (channel === "telegram") { const telegramConversationId = resolveTelegramConversationId({ ctx: { @@ -231,6 +247,18 @@ export function resolveAcpCommandParentConversationId( params: HandleCommandsParams, ): string | undefined { const channel = resolveAcpCommandChannel(params); + if (channel === "matrix") { + return resolveMatrixParentConversationId({ + ctx: { + MessageThreadId: params.ctx.MessageThreadId, + OriginatingTo: params.ctx.OriginatingTo, + To: params.ctx.To, + }, + command: { + to: params.command.to, + }, + }); + } if (channel === "telegram") { return ( parseTelegramChatIdFromTarget(params.ctx.OriginatingTo) ?? diff --git a/src/auto-reply/reply/commands-acp/lifecycle.ts b/src/auto-reply/reply/commands-acp/lifecycle.ts index 42ee1d2e184..89615c9e74e 100644 --- a/src/auto-reply/reply/commands-acp/lifecycle.ts +++ b/src/auto-reply/reply/commands-acp/lifecycle.ts @@ -157,12 +157,17 @@ async function bindSpawnedAcpSessionToThread(params: { } const senderId = commandParams.command.senderId?.trim() || ""; + const parentConversationId = bindingContext.parentConversationId?.trim() || undefined; + const conversationRef = { + channel: spawnPolicy.channel, + accountId: spawnPolicy.accountId, + conversationId: currentConversationId, + ...(parentConversationId && parentConversationId !== currentConversationId + ? { parentConversationId } + : {}), + }; if (placement === "current") { - const existingBinding = bindingService.resolveByConversation({ - channel: spawnPolicy.channel, - accountId: spawnPolicy.accountId, - conversationId: currentConversationId, - }); + const existingBinding = bindingService.resolveByConversation(conversationRef); const boundBy = typeof existingBinding?.metadata?.boundBy === "string" ? existingBinding.metadata.boundBy.trim() @@ -176,17 +181,12 @@ async function bindSpawnedAcpSessionToThread(params: { } const label = params.label || params.agentId; - const conversationId = currentConversationId; try { const binding = await bindingService.bind({ targetSessionKey: params.sessionKey, targetKind: "session", - conversation: { - channel: spawnPolicy.channel, - accountId: spawnPolicy.accountId, - conversationId, - }, + conversation: conversationRef, placement, metadata: { threadName: resolveThreadBindingThreadName({ diff --git a/src/auto-reply/reply/commands-acp/shared.ts b/src/auto-reply/reply/commands-acp/shared.ts index 2b0571b332f..438fe963c11 100644 --- a/src/auto-reply/reply/commands-acp/shared.ts +++ b/src/auto-reply/reply/commands-acp/shared.ts @@ -2,7 +2,10 @@ import { randomUUID } from "node:crypto"; import { toAcpRuntimeErrorText } from "../../../acp/runtime/error-text.js"; import type { AcpRuntimeError } from "../../../acp/runtime/errors.js"; import type { AcpRuntimeSessionMode } from "../../../acp/runtime/types.js"; -import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js"; +import { + DISCORD_THREAD_BINDING_CHANNEL, + MATRIX_THREAD_BINDING_CHANNEL, +} from "../../../channels/thread-bindings-policy.js"; import type { AcpSessionRuntimeOptions } from "../../../config/sessions/types.js"; import { normalizeAgentId } from "../../../routing/session-key.js"; import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js"; @@ -168,7 +171,8 @@ function normalizeAcpOptionToken(raw: string): string { } function resolveDefaultSpawnThreadMode(params: HandleCommandsParams): AcpSpawnThreadMode { - if (resolveAcpCommandChannel(params) !== DISCORD_THREAD_BINDING_CHANNEL) { + const channel = resolveAcpCommandChannel(params); + if (channel !== DISCORD_THREAD_BINDING_CHANNEL && channel !== MATRIX_THREAD_BINDING_CHANNEL) { return "off"; } const currentThreadId = resolveAcpCommandThreadId(params); diff --git a/src/auto-reply/reply/commands-session-lifecycle.test.ts b/src/auto-reply/reply/commands-session-lifecycle.test.ts index bb56ef82bd9..8d31fbf8c0d 100644 --- a/src/auto-reply/reply/commands-session-lifecycle.test.ts +++ b/src/auto-reply/reply/commands-session-lifecycle.test.ts @@ -9,6 +9,8 @@ const hoisted = vi.hoisted(() => { const getThreadBindingManagerMock = vi.fn(); const setThreadBindingIdleTimeoutBySessionKeyMock = vi.fn(); const setThreadBindingMaxAgeBySessionKeyMock = vi.fn(); + const setMatrixThreadBindingIdleTimeoutBySessionKeyMock = vi.fn(); + const setMatrixThreadBindingMaxAgeBySessionKeyMock = vi.fn(); const setTelegramThreadBindingIdleTimeoutBySessionKeyMock = vi.fn(); const setTelegramThreadBindingMaxAgeBySessionKeyMock = vi.fn(); const sessionBindingResolveByConversationMock = vi.fn(); @@ -16,6 +18,8 @@ const hoisted = vi.hoisted(() => { getThreadBindingManagerMock, setThreadBindingIdleTimeoutBySessionKeyMock, setThreadBindingMaxAgeBySessionKeyMock, + setMatrixThreadBindingIdleTimeoutBySessionKeyMock, + setMatrixThreadBindingMaxAgeBySessionKeyMock, setTelegramThreadBindingIdleTimeoutBySessionKeyMock, setTelegramThreadBindingMaxAgeBySessionKeyMock, sessionBindingResolveByConversationMock, @@ -48,6 +52,12 @@ vi.mock("../../plugins/runtime/index.js", async () => { setMaxAgeBySessionKey: hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock, }, }, + matrix: { + threadBindings: { + setIdleTimeoutBySessionKey: hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock, + setMaxAgeBySessionKey: hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock, + }, + }, }, }), }; @@ -114,6 +124,29 @@ function createTelegramCommandParams(commandBody: string, overrides?: Record) { + return buildCommandTestParams(commandBody, baseCfg, { + Provider: "matrix", + Surface: "matrix", + OriginatingChannel: "matrix", + OriginatingTo: "room:!room:example.org", + AccountId: "default", + MessageThreadId: "$thread-1", + ...overrides, + }); +} + +function createMatrixRoomCommandParams(commandBody: string, overrides?: Record) { + return buildCommandTestParams(commandBody, baseCfg, { + Provider: "matrix", + Surface: "matrix", + OriginatingChannel: "matrix", + OriginatingTo: "room:!room:example.org", + AccountId: "default", + ...overrides, + }); +} + function createFakeBinding(overrides: Partial = {}): FakeBinding { const now = Date.now(); return { @@ -152,6 +185,29 @@ function createTelegramBinding(overrides?: Partial): Sessi }; } +function createMatrixBinding(overrides?: Partial): SessionBindingRecord { + return { + bindingId: "default:$thread-1", + targetSessionKey: "agent:main:subagent:child", + targetKind: "subagent", + conversation: { + channel: "matrix", + accountId: "default", + conversationId: "$thread-1", + parentConversationId: "!room:example.org", + }, + status: "active", + boundAt: Date.now(), + metadata: { + boundBy: "user-1", + lastActivityAt: Date.now(), + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + }, + ...overrides, + }; +} + function expectIdleTimeoutSetReply( mock: ReturnType, text: string, @@ -183,6 +239,8 @@ describe("/session idle and /session max-age", () => { hoisted.getThreadBindingManagerMock.mockReset(); hoisted.setThreadBindingIdleTimeoutBySessionKeyMock.mockReset(); hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockReset(); + hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock.mockReset(); + hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock.mockReset(); hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock.mockReset(); hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock.mockReset(); hoisted.sessionBindingResolveByConversationMock.mockReset().mockReturnValue(null); @@ -286,6 +344,66 @@ describe("/session idle and /session max-age", () => { ); }); + it("sets idle timeout for focused Matrix threads", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z")); + + hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createMatrixBinding()); + hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock.mockReturnValue([ + { + targetSessionKey: "agent:main:subagent:child", + boundAt: Date.now(), + lastActivityAt: Date.now(), + idleTimeoutMs: 2 * 60 * 60 * 1000, + }, + ]); + + const result = await handleSessionCommand( + createMatrixThreadCommandParams("/session idle 2h"), + true, + ); + const text = result?.reply?.text ?? ""; + + expectIdleTimeoutSetReply( + hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock, + text, + 2 * 60 * 60 * 1000, + "2h", + ); + }); + + it("sets max age for focused Matrix threads", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z")); + + const boundAt = Date.parse("2026-02-19T22:00:00.000Z"); + hoisted.sessionBindingResolveByConversationMock.mockReturnValue( + createMatrixBinding({ boundAt }), + ); + hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock.mockReturnValue([ + { + targetSessionKey: "agent:main:subagent:child", + boundAt, + lastActivityAt: Date.now(), + maxAgeMs: 3 * 60 * 60 * 1000, + }, + ]); + + const result = await handleSessionCommand( + createMatrixThreadCommandParams("/session max-age 3h"), + true, + ); + const text = result?.reply?.text ?? ""; + + expect(hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock).toHaveBeenCalledWith({ + targetSessionKey: "agent:main:subagent:child", + accountId: "default", + maxAgeMs: 3 * 60 * 60 * 1000, + }); + expect(text).toContain("Max age set to 3h"); + expect(text).toContain("2026-02-20T01:00:00.000Z"); + }); + it("reports Telegram max-age expiry from the original bind time", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z")); @@ -340,10 +458,20 @@ describe("/session idle and /session max-age", () => { const params = buildCommandTestParams("/session idle 2h", baseCfg); const result = await handleSessionCommand(params, true); expect(result?.reply?.text).toContain( - "currently available for Discord and Telegram bound sessions", + "currently available for Discord, Matrix, and Telegram bound sessions", ); }); + it("requires a focused Matrix thread for lifecycle updates", async () => { + const result = await handleSessionCommand( + createMatrixRoomCommandParams("/session idle 2h"), + true, + ); + + expect(result?.reply?.text).toContain("must be run inside a focused Matrix thread"); + expect(hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock).not.toHaveBeenCalled(); + }); + it("requires binding owner for lifecycle updates", async () => { const binding = createFakeBinding({ boundBy: "owner-1" }); hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding)); diff --git a/src/auto-reply/reply/commands-session.ts b/src/auto-reply/reply/commands-session.ts index 0359c77331b..29f85050a43 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -12,10 +12,19 @@ import { formatTokenCount, formatUsd } from "../../utils/usage-format.js"; import { parseActivationCommand } from "../group-activation.js"; import { parseSendPolicyCommand } from "../send-policy.js"; import { normalizeFastMode, normalizeUsageDisplay, resolveResponseUsageMode } from "../thinking.js"; -import { isDiscordSurface, isTelegramSurface, resolveChannelAccountId } from "./channel-context.js"; +import { + isDiscordSurface, + isMatrixSurface, + isTelegramSurface, + resolveChannelAccountId, +} from "./channel-context.js"; import { handleAbortTrigger, handleStopCommand } from "./commands-session-abort.js"; import { persistSessionEntry } from "./commands-session-store.js"; import type { CommandHandler } from "./commands-types.js"; +import { + resolveMatrixConversationId, + resolveMatrixParentConversationId, +} from "./matrix-context.js"; import { resolveTelegramConversationId } from "./telegram-context.js"; const SESSION_COMMAND_PREFIX = "/session"; @@ -55,7 +64,7 @@ function formatSessionExpiry(expiresAt: number) { return new Date(expiresAt).toISOString(); } -function resolveTelegramBindingDurationMs( +function resolveSessionBindingDurationMs( binding: SessionBindingRecord, key: "idleTimeoutMs" | "maxAgeMs", fallbackMs: number, @@ -67,7 +76,7 @@ function resolveTelegramBindingDurationMs( return Math.max(0, Math.floor(raw)); } -function resolveTelegramBindingLastActivityAt(binding: SessionBindingRecord): number { +function resolveSessionBindingLastActivityAt(binding: SessionBindingRecord): number { const raw = binding.metadata?.lastActivityAt; if (typeof raw !== "number" || !Number.isFinite(raw)) { return binding.boundAt; @@ -75,7 +84,7 @@ function resolveTelegramBindingLastActivityAt(binding: SessionBindingRecord): nu return Math.max(Math.floor(raw), binding.boundAt); } -function resolveTelegramBindingBoundBy(binding: SessionBindingRecord): string { +function resolveSessionBindingBoundBy(binding: SessionBindingRecord): string { const raw = binding.metadata?.boundBy; return typeof raw === "string" ? raw.trim() : ""; } @@ -87,6 +96,46 @@ type UpdatedLifecycleBinding = { maxAgeMs?: number; }; +function isSessionBindingRecord( + binding: UpdatedLifecycleBinding | SessionBindingRecord, +): binding is SessionBindingRecord { + return "bindingId" in binding; +} + +function resolveUpdatedLifecycleDurationMs( + binding: UpdatedLifecycleBinding | SessionBindingRecord, + key: "idleTimeoutMs" | "maxAgeMs", +): number | undefined { + if (!isSessionBindingRecord(binding)) { + const raw = binding[key]; + if (typeof raw === "number" && Number.isFinite(raw)) { + return Math.max(0, Math.floor(raw)); + } + } + if (!isSessionBindingRecord(binding)) { + return undefined; + } + const raw = binding.metadata?.[key]; + if (typeof raw !== "number" || !Number.isFinite(raw)) { + return undefined; + } + return Math.max(0, Math.floor(raw)); +} + +function toUpdatedLifecycleBinding( + binding: UpdatedLifecycleBinding | SessionBindingRecord, +): UpdatedLifecycleBinding { + const lastActivityAt = isSessionBindingRecord(binding) + ? resolveSessionBindingLastActivityAt(binding) + : Math.max(Math.floor(binding.lastActivityAt), binding.boundAt); + return { + boundAt: binding.boundAt, + lastActivityAt, + idleTimeoutMs: resolveUpdatedLifecycleDurationMs(binding, "idleTimeoutMs"), + maxAgeMs: resolveUpdatedLifecycleDurationMs(binding, "maxAgeMs"), + }; +} + function resolveUpdatedBindingExpiry(params: { action: typeof SESSION_ACTION_IDLE | typeof SESSION_ACTION_MAX_AGE; bindings: UpdatedLifecycleBinding[]; @@ -363,12 +412,13 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm } const onDiscord = isDiscordSurface(params); + const onMatrix = isMatrixSurface(params); const onTelegram = isTelegramSurface(params); - if (!onDiscord && !onTelegram) { + if (!onDiscord && !onMatrix && !onTelegram) { return { shouldContinue: false, reply: { - text: "⚠️ /session idle and /session max-age are currently available for Discord and Telegram bound sessions.", + text: "⚠️ /session idle and /session max-age are currently available for Discord, Matrix, and Telegram bound sessions.", }, }; } @@ -377,6 +427,30 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm const sessionBindingService = getSessionBindingService(); const threadId = params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : ""; + const matrixConversationId = onMatrix + ? resolveMatrixConversationId({ + ctx: { + MessageThreadId: params.ctx.MessageThreadId, + OriginatingTo: params.ctx.OriginatingTo, + To: params.ctx.To, + }, + command: { + to: params.command.to, + }, + }) + : undefined; + const matrixParentConversationId = onMatrix + ? resolveMatrixParentConversationId({ + ctx: { + MessageThreadId: params.ctx.MessageThreadId, + OriginatingTo: params.ctx.OriginatingTo, + To: params.ctx.To, + }, + command: { + to: params.command.to, + }, + }) + : undefined; const telegramConversationId = onTelegram ? resolveTelegramConversationId(params) : undefined; const channelRuntime = getChannelRuntime(); @@ -400,6 +474,17 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm conversationId: telegramConversationId, }) : null; + const matrixBinding = + onMatrix && matrixConversationId + ? sessionBindingService.resolveByConversation({ + channel: "matrix", + accountId, + conversationId: matrixConversationId, + ...(matrixParentConversationId && matrixParentConversationId !== matrixConversationId + ? { parentConversationId: matrixParentConversationId } + : {}), + }) + : null; if (onDiscord && !discordBinding) { if (onDiscord && !threadId) { return { @@ -414,6 +499,20 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm reply: { text: "ℹ️ This thread is not currently focused." }, }; } + if (onMatrix && !matrixBinding) { + if (!threadId) { + return { + shouldContinue: false, + reply: { + text: "⚠️ /session idle and /session max-age must be run inside a focused Matrix thread.", + }, + }; + } + return { + shouldContinue: false, + reply: { text: "ℹ️ This thread is not currently focused." }, + }; + } if (onTelegram && !telegramBinding) { if (!telegramConversationId) { return { @@ -434,28 +533,33 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm record: discordBinding!, defaultIdleTimeoutMs: discordManager!.getIdleTimeoutMs(), }) - : resolveTelegramBindingDurationMs(telegramBinding!, "idleTimeoutMs", 24 * 60 * 60 * 1000); + : resolveSessionBindingDurationMs( + (onMatrix ? matrixBinding : telegramBinding)!, + "idleTimeoutMs", + 24 * 60 * 60 * 1000, + ); const idleExpiresAt = onDiscord ? channelRuntime.discord.threadBindings.resolveInactivityExpiresAt({ record: discordBinding!, defaultIdleTimeoutMs: discordManager!.getIdleTimeoutMs(), }) : idleTimeoutMs > 0 - ? resolveTelegramBindingLastActivityAt(telegramBinding!) + idleTimeoutMs + ? resolveSessionBindingLastActivityAt((onMatrix ? matrixBinding : telegramBinding)!) + + idleTimeoutMs : undefined; const maxAgeMs = onDiscord ? channelRuntime.discord.threadBindings.resolveMaxAgeMs({ record: discordBinding!, defaultMaxAgeMs: discordManager!.getMaxAgeMs(), }) - : resolveTelegramBindingDurationMs(telegramBinding!, "maxAgeMs", 0); + : resolveSessionBindingDurationMs((onMatrix ? matrixBinding : telegramBinding)!, "maxAgeMs", 0); const maxAgeExpiresAt = onDiscord ? channelRuntime.discord.threadBindings.resolveMaxAgeExpiresAt({ record: discordBinding!, defaultMaxAgeMs: discordManager!.getMaxAgeMs(), }) : maxAgeMs > 0 - ? telegramBinding!.boundAt + maxAgeMs + ? (onMatrix ? matrixBinding : telegramBinding)!.boundAt + maxAgeMs : undefined; const durationArgRaw = tokens.slice(1).join(""); @@ -500,14 +604,16 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm const senderId = params.command.senderId?.trim() || ""; const boundBy = onDiscord ? discordBinding!.boundBy - : resolveTelegramBindingBoundBy(telegramBinding!); + : resolveSessionBindingBoundBy((onMatrix ? matrixBinding : telegramBinding)!); if (boundBy && boundBy !== "system" && senderId && senderId !== boundBy) { return { shouldContinue: false, reply: { text: onDiscord ? `⚠️ Only ${boundBy} can update session lifecycle settings for this thread.` - : `⚠️ Only ${boundBy} can update session lifecycle settings for this conversation.`, + : onMatrix + ? `⚠️ Only ${boundBy} can update session lifecycle settings for this thread.` + : `⚠️ Only ${boundBy} can update session lifecycle settings for this conversation.`, }, }; } @@ -536,6 +642,19 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm maxAgeMs: durationMs, }); } + if (onMatrix) { + return action === SESSION_ACTION_IDLE + ? channelRuntime.matrix.threadBindings.setIdleTimeoutBySessionKey({ + targetSessionKey: matrixBinding!.targetSessionKey, + accountId, + idleTimeoutMs: durationMs, + }) + : channelRuntime.matrix.threadBindings.setMaxAgeBySessionKey({ + targetSessionKey: matrixBinding!.targetSessionKey, + accountId, + maxAgeMs: durationMs, + }); + } return action === SESSION_ACTION_IDLE ? channelRuntime.telegram.threadBindings.setIdleTimeoutBySessionKey({ targetSessionKey: telegramBinding!.targetSessionKey, @@ -574,7 +693,7 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm const nextExpiry = resolveUpdatedBindingExpiry({ action, - bindings: updatedBindings, + bindings: updatedBindings.map((binding) => toUpdatedLifecycleBinding(binding)), }); const expiryLabel = typeof nextExpiry === "number" && Number.isFinite(nextExpiry) diff --git a/src/auto-reply/reply/commands-subagents-focus.test.ts b/src/auto-reply/reply/commands-subagents-focus.test.ts index 651d8088486..de799e5208b 100644 --- a/src/auto-reply/reply/commands-subagents-focus.test.ts +++ b/src/auto-reply/reply/commands-subagents-focus.test.ts @@ -103,6 +103,31 @@ function createTelegramTopicCommandParams(commandBody: string) { return params; } +function createMatrixThreadCommandParams(commandBody: string, cfg: OpenClawConfig = baseCfg) { + const params = buildCommandTestParams(commandBody, cfg, { + Provider: "matrix", + Surface: "matrix", + OriginatingChannel: "matrix", + OriginatingTo: "room:!room:example.org", + AccountId: "default", + MessageThreadId: "$thread-1", + }); + params.command.senderId = "user-1"; + return params; +} + +function createMatrixRoomCommandParams(commandBody: string, cfg: OpenClawConfig = baseCfg) { + const params = buildCommandTestParams(commandBody, cfg, { + Provider: "matrix", + Surface: "matrix", + OriginatingChannel: "matrix", + OriginatingTo: "room:!room:example.org", + AccountId: "default", + }); + params.command.senderId = "user-1"; + return params; +} + function createSessionBindingRecord( overrides?: Partial, ): SessionBindingRecord { @@ -144,7 +169,13 @@ async function focusCodexAcp( hoisted.sessionBindingBindMock.mockImplementation( async (input: { targetSessionKey: string; - conversation: { channel: string; accountId: string; conversationId: string }; + placement: "current" | "child"; + conversation: { + channel: string; + accountId: string; + conversationId: string; + parentConversationId?: string; + }; metadata?: Record; }) => createSessionBindingRecord({ @@ -152,7 +183,11 @@ async function focusCodexAcp( conversation: { channel: input.conversation.channel, accountId: input.conversation.accountId, - conversationId: input.conversation.conversationId, + conversationId: + input.placement === "child" ? "thread-created" : input.conversation.conversationId, + ...(input.conversation.parentConversationId + ? { parentConversationId: input.conversation.parentConversationId } + : {}), }, metadata: { boundBy: typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "user-1", @@ -220,6 +255,51 @@ describe("/focus, /unfocus, /agents", () => { ); }); + it("/focus creates a Matrix thread from a top-level room when spawnSubagentSessions is enabled", async () => { + const cfg = { + ...baseCfg, + channels: { + matrix: { + threadBindings: { + enabled: true, + spawnSubagentSessions: true, + }, + }, + }, + } satisfies OpenClawConfig; + + const result = await focusCodexAcp(createMatrixRoomCommandParams("/focus codex-acp", cfg)); + + expect(result?.reply?.text).toContain("created thread thread-created and bound it"); + expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( + expect.objectContaining({ + placement: "child", + conversation: expect.objectContaining({ + channel: "matrix", + conversationId: "!room:example.org", + }), + }), + ); + }); + + it("/focus rejects Matrix top-level thread creation when spawnSubagentSessions is disabled", async () => { + const cfg = { + ...baseCfg, + channels: { + matrix: { + threadBindings: { + enabled: true, + }, + }, + }, + } satisfies OpenClawConfig; + + const result = await focusCodexAcp(createMatrixRoomCommandParams("/focus codex-acp", cfg)); + + expect(result?.reply?.text).toContain("spawnSubagentSessions=true"); + expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled(); + }); + it("/focus includes ACP session identifiers in intro text when available", async () => { hoisted.readAcpSessionEntryMock.mockReturnValue({ sessionKey: "agent:codex-acp:session-1", @@ -283,6 +363,36 @@ describe("/focus, /unfocus, /agents", () => { }); }); + it("/unfocus removes an active Matrix thread binding for the binding owner", async () => { + const params = createMatrixThreadCommandParams("/unfocus"); + hoisted.sessionBindingResolveByConversationMock.mockReturnValue( + createSessionBindingRecord({ + bindingId: "default:matrix-thread-1", + conversation: { + channel: "matrix", + accountId: "default", + conversationId: "$thread-1", + parentConversationId: "!room:example.org", + }, + metadata: { boundBy: "user-1" }, + }), + ); + + const result = await handleSubagentsCommand(params, true); + + expect(result?.reply?.text).toContain("Thread unfocused"); + expect(hoisted.sessionBindingResolveByConversationMock).toHaveBeenCalledWith({ + channel: "matrix", + accountId: "default", + conversationId: "$thread-1", + parentConversationId: "!room:example.org", + }); + expect(hoisted.sessionBindingUnbindMock).toHaveBeenCalledWith({ + bindingId: "default:matrix-thread-1", + reason: "manual", + }); + }); + it("/focus rejects rebinding when the thread is focused by another user", async () => { const result = await focusCodexAcp(undefined, { existingBinding: createSessionBindingRecord({ @@ -401,6 +511,6 @@ describe("/focus, /unfocus, /agents", () => { it("/focus rejects unsupported channels", async () => { const params = buildCommandTestParams("/focus codex-acp", baseCfg); const result = await handleSubagentsCommand(params, true); - expect(result?.reply?.text).toContain("only available on Discord and Telegram"); + expect(result?.reply?.text).toContain("only available on Discord, Matrix, and Telegram"); }); }); diff --git a/src/auto-reply/reply/commands-subagents/action-focus.ts b/src/auto-reply/reply/commands-subagents/action-focus.ts index df7a268b3b0..f55cbe95a39 100644 --- a/src/auto-reply/reply/commands-subagents/action-focus.ts +++ b/src/auto-reply/reply/commands-subagents/action-focus.ts @@ -8,14 +8,22 @@ import { resolveThreadBindingThreadName, } from "../../../channels/thread-bindings-messages.js"; import { + formatThreadBindingDisabledError, + formatThreadBindingSpawnDisabledError, resolveThreadBindingIdleTimeoutMsForChannel, resolveThreadBindingMaxAgeMsForChannel, + resolveThreadBindingSpawnPolicy, } from "../../../channels/thread-bindings-policy.js"; import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js"; import type { CommandHandlerResult } from "../commands-types.js"; +import { + resolveMatrixConversationId, + resolveMatrixParentConversationId, +} from "../matrix-context.js"; import { type SubagentsCommandContext, isDiscordSurface, + isMatrixSurface, isTelegramSurface, resolveChannelAccountId, resolveCommandSurfaceChannel, @@ -26,9 +34,10 @@ import { } from "./shared.js"; type FocusBindingContext = { - channel: "discord" | "telegram"; + channel: "discord" | "matrix" | "telegram"; accountId: string; conversationId: string; + parentConversationId?: string; placement: "current" | "child"; labelNoun: "thread" | "conversation"; }; @@ -65,6 +74,41 @@ function resolveFocusBindingContext( labelNoun: "conversation", }; } + if (isMatrixSurface(params)) { + const conversationId = resolveMatrixConversationId({ + ctx: { + MessageThreadId: params.ctx.MessageThreadId, + OriginatingTo: params.ctx.OriginatingTo, + To: params.ctx.To, + }, + command: { + to: params.command.to, + }, + }); + if (!conversationId) { + return null; + } + const parentConversationId = resolveMatrixParentConversationId({ + ctx: { + MessageThreadId: params.ctx.MessageThreadId, + OriginatingTo: params.ctx.OriginatingTo, + To: params.ctx.To, + }, + command: { + to: params.command.to, + }, + }); + const currentThreadId = + params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : ""; + return { + channel: "matrix", + accountId: resolveChannelAccountId(params), + conversationId, + ...(parentConversationId ? { parentConversationId } : {}), + placement: currentThreadId ? "current" : "child", + labelNoun: "thread", + }; + } return null; } @@ -73,8 +117,8 @@ export async function handleSubagentsFocusAction( ): Promise { const { params, runs, restTokens } = ctx; const channel = resolveCommandSurfaceChannel(params); - if (channel !== "discord" && channel !== "telegram") { - return stopWithText("⚠️ /focus is only available on Discord and Telegram."); + if (channel !== "discord" && channel !== "matrix" && channel !== "telegram") { + return stopWithText("⚠️ /focus is only available on Discord, Matrix, and Telegram."); } const token = restTokens.join(" ").trim(); @@ -89,7 +133,12 @@ export async function handleSubagentsFocusAction( accountId, }); if (!capabilities.adapterAvailable || !capabilities.bindSupported) { - const label = channel === "discord" ? "Discord thread" : "Telegram conversation"; + const label = + channel === "discord" + ? "Discord thread" + : channel === "matrix" + ? "Matrix thread" + : "Telegram conversation"; return stopWithText(`⚠️ ${label} bindings are unavailable for this account.`); } @@ -105,14 +154,48 @@ export async function handleSubagentsFocusAction( "⚠️ /focus on Telegram requires a topic context in groups, or a direct-message conversation.", ); } + if (channel === "matrix") { + return stopWithText("⚠️ Could not resolve a Matrix room for /focus."); + } return stopWithText("⚠️ Could not resolve a Discord channel for /focus."); } + if (channel === "matrix") { + const spawnPolicy = resolveThreadBindingSpawnPolicy({ + cfg: params.cfg, + channel, + accountId: bindingContext.accountId, + kind: "subagent", + }); + if (!spawnPolicy.enabled) { + return stopWithText( + `⚠️ ${formatThreadBindingDisabledError({ + channel: spawnPolicy.channel, + accountId: spawnPolicy.accountId, + kind: "subagent", + })}`, + ); + } + if (bindingContext.placement === "child" && !spawnPolicy.spawnEnabled) { + return stopWithText( + `⚠️ ${formatThreadBindingSpawnDisabledError({ + channel: spawnPolicy.channel, + accountId: spawnPolicy.accountId, + kind: "subagent", + })}`, + ); + } + } + const senderId = params.command.senderId?.trim() || ""; const existingBinding = bindingService.resolveByConversation({ channel: bindingContext.channel, accountId: bindingContext.accountId, conversationId: bindingContext.conversationId, + ...(bindingContext.parentConversationId && + bindingContext.parentConversationId !== bindingContext.conversationId + ? { parentConversationId: bindingContext.parentConversationId } + : {}), }); const boundBy = typeof existingBinding?.metadata?.boundBy === "string" @@ -143,6 +226,10 @@ export async function handleSubagentsFocusAction( channel: bindingContext.channel, accountId: bindingContext.accountId, conversationId: bindingContext.conversationId, + ...(bindingContext.parentConversationId && + bindingContext.parentConversationId !== bindingContext.conversationId + ? { parentConversationId: bindingContext.parentConversationId } + : {}), }, placement: bindingContext.placement, metadata: { diff --git a/src/auto-reply/reply/commands-subagents/action-unfocus.ts b/src/auto-reply/reply/commands-subagents/action-unfocus.ts index 78bb02b2427..0331772316e 100644 --- a/src/auto-reply/reply/commands-subagents/action-unfocus.ts +++ b/src/auto-reply/reply/commands-subagents/action-unfocus.ts @@ -1,8 +1,13 @@ import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js"; import type { CommandHandlerResult } from "../commands-types.js"; +import { + resolveMatrixConversationId, + resolveMatrixParentConversationId, +} from "../matrix-context.js"; import { type SubagentsCommandContext, isDiscordSurface, + isMatrixSurface, isTelegramSurface, resolveChannelAccountId, resolveCommandSurfaceChannel, @@ -15,8 +20,8 @@ export async function handleSubagentsUnfocusAction( ): Promise { const { params } = ctx; const channel = resolveCommandSurfaceChannel(params); - if (channel !== "discord" && channel !== "telegram") { - return stopWithText("⚠️ /unfocus is only available on Discord and Telegram."); + if (channel !== "discord" && channel !== "matrix" && channel !== "telegram") { + return stopWithText("⚠️ /unfocus is only available on Discord, Matrix, and Telegram."); } const accountId = resolveChannelAccountId(params); @@ -30,13 +35,43 @@ export async function handleSubagentsUnfocusAction( if (isTelegramSurface(params)) { return resolveTelegramConversationId(params); } + if (isMatrixSurface(params)) { + return resolveMatrixConversationId({ + ctx: { + MessageThreadId: params.ctx.MessageThreadId, + OriginatingTo: params.ctx.OriginatingTo, + To: params.ctx.To, + }, + command: { + to: params.command.to, + }, + }); + } return undefined; })(); + const parentConversationId = (() => { + if (!isMatrixSurface(params)) { + return undefined; + } + return resolveMatrixParentConversationId({ + ctx: { + MessageThreadId: params.ctx.MessageThreadId, + OriginatingTo: params.ctx.OriginatingTo, + To: params.ctx.To, + }, + command: { + to: params.command.to, + }, + }); + })(); if (!conversationId) { if (channel === "discord") { return stopWithText("⚠️ /unfocus must be run inside a Discord thread."); } + if (channel === "matrix") { + return stopWithText("⚠️ /unfocus must be run inside a Matrix thread."); + } return stopWithText( "⚠️ /unfocus on Telegram requires a topic context in groups, or a direct-message conversation.", ); @@ -46,12 +81,17 @@ export async function handleSubagentsUnfocusAction( channel, accountId, conversationId, + ...(parentConversationId && parentConversationId !== conversationId + ? { parentConversationId } + : {}), }); if (!binding) { return stopWithText( channel === "discord" ? "ℹ️ This thread is not currently focused." - : "ℹ️ This conversation is not currently focused.", + : channel === "matrix" + ? "ℹ️ This thread is not currently focused." + : "ℹ️ This conversation is not currently focused.", ); } @@ -62,7 +102,9 @@ export async function handleSubagentsUnfocusAction( return stopWithText( channel === "discord" ? `⚠️ Only ${boundBy} can unfocus this thread.` - : `⚠️ Only ${boundBy} can unfocus this conversation.`, + : channel === "matrix" + ? `⚠️ Only ${boundBy} can unfocus this thread.` + : `⚠️ Only ${boundBy} can unfocus this conversation.`, ); } @@ -71,6 +113,8 @@ export async function handleSubagentsUnfocusAction( reason: "manual", }); return stopWithText( - channel === "discord" ? "✅ Thread unfocused." : "✅ Conversation unfocused.", + channel === "discord" || channel === "matrix" + ? "✅ Thread unfocused." + : "✅ Conversation unfocused.", ); } diff --git a/src/auto-reply/reply/commands-subagents/shared.ts b/src/auto-reply/reply/commands-subagents/shared.ts index 9781683267e..3d2b9726da3 100644 --- a/src/auto-reply/reply/commands-subagents/shared.ts +++ b/src/auto-reply/reply/commands-subagents/shared.ts @@ -30,6 +30,7 @@ import { } from "../../../shared/subagents-format.js"; import { isDiscordSurface, + isMatrixSurface, isTelegramSurface, resolveCommandSurfaceChannel, resolveDiscordAccountId, @@ -47,6 +48,7 @@ import { resolveTelegramConversationId } from "../telegram-context.js"; export { extractAssistantText, stripToolMessages }; export { isDiscordSurface, + isMatrixSurface, isTelegramSurface, resolveCommandSurfaceChannel, resolveDiscordAccountId, diff --git a/src/channels/thread-bindings-policy.ts b/src/channels/thread-bindings-policy.ts index 15f3f5557fe..5fe30994da0 100644 --- a/src/channels/thread-bindings-policy.ts +++ b/src/channels/thread-bindings-policy.ts @@ -2,6 +2,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { normalizeAccountId } from "../routing/session-key.js"; export const DISCORD_THREAD_BINDING_CHANNEL = "discord"; +export const MATRIX_THREAD_BINDING_CHANNEL = "matrix"; const DEFAULT_THREAD_BINDING_IDLE_HOURS = 24; const DEFAULT_THREAD_BINDING_MAX_AGE_HOURS = 0; @@ -127,8 +128,9 @@ export function resolveThreadBindingSpawnPolicy(params: { const spawnFlagKey = resolveSpawnFlagKey(params.kind); const spawnEnabledRaw = normalizeBoolean(account?.[spawnFlagKey]) ?? normalizeBoolean(root?.[spawnFlagKey]); - // Non-Discord channels currently have no dedicated spawn gate config keys. - const spawnEnabled = spawnEnabledRaw ?? channel !== DISCORD_THREAD_BINDING_CHANNEL; + const spawnEnabled = + spawnEnabledRaw ?? + (channel !== DISCORD_THREAD_BINDING_CHANNEL && channel !== MATRIX_THREAD_BINDING_CHANNEL); return { channel, accountId, @@ -183,6 +185,9 @@ export function formatThreadBindingDisabledError(params: { if (params.channel === DISCORD_THREAD_BINDING_CHANNEL) { return "Discord thread bindings are disabled (set channels.discord.threadBindings.enabled=true to override for this account, or session.threadBindings.enabled=true globally)."; } + if (params.channel === MATRIX_THREAD_BINDING_CHANNEL) { + return "Matrix thread bindings are disabled (set channels.matrix.threadBindings.enabled=true to override for this account, or session.threadBindings.enabled=true globally)."; + } return `Thread bindings are disabled for ${params.channel} (set session.threadBindings.enabled=true to enable).`; } @@ -197,5 +202,11 @@ export function formatThreadBindingSpawnDisabledError(params: { if (params.channel === DISCORD_THREAD_BINDING_CHANNEL && params.kind === "subagent") { return "Discord thread-bound subagent spawns are disabled for this account (set channels.discord.threadBindings.spawnSubagentSessions=true to enable)."; } + if (params.channel === MATRIX_THREAD_BINDING_CHANNEL && params.kind === "acp") { + return "Matrix thread-bound ACP spawns are disabled for this account (set channels.matrix.threadBindings.spawnAcpSessions=true to enable)."; + } + if (params.channel === MATRIX_THREAD_BINDING_CHANNEL && params.kind === "subagent") { + return "Matrix thread-bound subagent spawns are disabled for this account (set channels.matrix.threadBindings.spawnSubagentSessions=true to enable)."; + } return `Thread-bound ${params.kind} spawns are disabled for ${params.channel}.`; } diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index b1cfd8c5195..a85e8997389 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -82,6 +82,10 @@ export { resolveThreadBindingIdleTimeoutMsForChannel, resolveThreadBindingMaxAgeMsForChannel, } from "../channels/thread-bindings-policy.js"; +export { + setMatrixThreadBindingIdleTimeoutBySessionKey, + setMatrixThreadBindingMaxAgeBySessionKey, +} from "../../extensions/matrix/src/matrix/thread-bindings.js"; export { createTypingCallbacks } from "../channels/typing.js"; export { createChannelReplyPipeline } from "./channel-reply-pipeline.js"; export type { OpenClawConfig } from "../config/config.js"; diff --git a/src/plugins/runtime/runtime-channel.ts b/src/plugins/runtime/runtime-channel.ts index 80bb1aba736..0617cb7f8ff 100644 --- a/src/plugins/runtime/runtime-channel.ts +++ b/src/plugins/runtime/runtime-channel.ts @@ -78,6 +78,7 @@ import { import { buildAgentSessionKey, resolveAgentRoute } from "../../routing/resolve-route.js"; import { createRuntimeDiscord } from "./runtime-discord.js"; import { createRuntimeIMessage } from "./runtime-imessage.js"; +import { createRuntimeMatrix } from "./runtime-matrix.js"; import { createRuntimeSignal } from "./runtime-signal.js"; import { createRuntimeSlack } from "./runtime-slack.js"; import { createRuntimeTelegram } from "./runtime-telegram.js"; @@ -206,18 +207,19 @@ export function createRuntimeChannel(): PluginRuntime["channel"] { }, } satisfies Omit< PluginRuntime["channel"], - "discord" | "slack" | "telegram" | "signal" | "imessage" | "whatsapp" + "discord" | "slack" | "telegram" | "matrix" | "signal" | "imessage" | "whatsapp" > & Partial< Pick< PluginRuntime["channel"], - "discord" | "slack" | "telegram" | "signal" | "imessage" | "whatsapp" + "discord" | "slack" | "telegram" | "matrix" | "signal" | "imessage" | "whatsapp" > >; defineCachedValue(channelRuntime, "discord", createRuntimeDiscord); defineCachedValue(channelRuntime, "slack", createRuntimeSlack); defineCachedValue(channelRuntime, "telegram", createRuntimeTelegram); + defineCachedValue(channelRuntime, "matrix", createRuntimeMatrix); defineCachedValue(channelRuntime, "signal", createRuntimeSignal); defineCachedValue(channelRuntime, "imessage", createRuntimeIMessage); defineCachedValue(channelRuntime, "whatsapp", createRuntimeWhatsApp); diff --git a/src/plugins/runtime/runtime-matrix.ts b/src/plugins/runtime/runtime-matrix.ts new file mode 100644 index 00000000000..d97734397c0 --- /dev/null +++ b/src/plugins/runtime/runtime-matrix.ts @@ -0,0 +1,14 @@ +import { + setMatrixThreadBindingIdleTimeoutBySessionKey, + setMatrixThreadBindingMaxAgeBySessionKey, +} from "openclaw/plugin-sdk/matrix"; +import type { PluginRuntimeChannel } from "./types-channel.js"; + +export function createRuntimeMatrix(): PluginRuntimeChannel["matrix"] { + return { + threadBindings: { + setIdleTimeoutBySessionKey: setMatrixThreadBindingIdleTimeoutBySessionKey, + setMaxAgeBySessionKey: setMatrixThreadBindingMaxAgeBySessionKey, + }, + }; +} diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index a0fe9a1d9bc..0a7eab63727 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -193,6 +193,12 @@ export type PluginRuntimeChannel = { unpinMessage: typeof import("../../../extensions/telegram/runtime-api.js").unpinMessageTelegram; }; }; + matrix: { + threadBindings: { + setIdleTimeoutBySessionKey: typeof import("../../../extensions/matrix/runtime-api.js").setMatrixThreadBindingIdleTimeoutBySessionKey; + setMaxAgeBySessionKey: typeof import("../../../extensions/matrix/runtime-api.js").setMatrixThreadBindingMaxAgeBySessionKey; + }; + }; signal: { probeSignal: typeof import("../../../extensions/signal/runtime-api.js").probeSignal; sendMessageSignal: typeof import("../../../extensions/signal/runtime-api.js").sendMessageSignal; diff --git a/test/helpers/extensions/plugin-runtime-mock.ts b/test/helpers/extensions/plugin-runtime-mock.ts index d71eeb2d584..c0b73a6e15d 100644 --- a/test/helpers/extensions/plugin-runtime-mock.ts +++ b/test/helpers/extensions/plugin-runtime-mock.ts @@ -297,6 +297,7 @@ export function createPluginRuntimeMock(overrides: DeepPartial = line: {} as PluginRuntime["channel"]["line"], slack: {} as PluginRuntime["channel"]["slack"], telegram: {} as PluginRuntime["channel"]["telegram"], + matrix: {} as PluginRuntime["channel"]["matrix"], signal: {} as PluginRuntime["channel"]["signal"], imessage: {} as PluginRuntime["channel"]["imessage"], whatsapp: {} as PluginRuntime["channel"]["whatsapp"],