matrix-js: require explicit thread-bound spawn config

This commit is contained in:
Gustavo Madeira Santana
2026-03-08 19:20:57 -04:00
parent 35bce1f128
commit 89efd4cc9b
12 changed files with 286 additions and 25 deletions

View File

@@ -242,7 +242,7 @@ Matrix-js supports native Matrix threads for both automatic replies and message-
- Inbound threaded messages include the thread root message as extra agent context.
- Message-tool sends now auto-inherit the current Matrix thread when the target is the same room, or the same DM user target, unless an explicit `threadId` is provided.
- Runtime thread bindings are supported for Matrix-js. `/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`, and thread-bound `/acp spawn` now work in Matrix rooms and DMs.
- Top-level Matrix room/DM `/focus` creates a new Matrix thread and binds it to the target session.
- Top-level Matrix room/DM `/focus` creates a new Matrix thread and binds it to the target session when `threadBindings.spawnSubagentSessions=true`.
- Running `/focus` or `/acp spawn --thread here` inside an existing Matrix thread binds that current thread instead.
### Thread Binding Config
@@ -255,7 +255,10 @@ Matrix-js inherits global defaults from `session.threadBindings`, and also suppo
- `threadBindings.spawnSubagentSessions`
- `threadBindings.spawnAcpSessions`
For Matrix-js, spawn flags default to enabled unless you turn them off explicitly.
Matrix-js thread-bound spawn flags are opt-in:
- Set `threadBindings.spawnSubagentSessions: true` to allow top-level `/focus` to create and bind new Matrix threads.
- Set `threadBindings.spawnAcpSessions: true` to allow `/acp spawn --thread auto|here` to bind ACP sessions to Matrix threads.
## Reactions

View File

@@ -651,6 +651,7 @@ Run multiple accounts per channel (each with its own `accountId`):
### Other extension channels
Many extension channels are configured as `channels.<id>` and documented in their dedicated channel pages (for example Feishu, Matrix, LINE, Nostr, Zalo, Nextcloud Talk, Synology Chat, and Twitch).
Matrix-js also supports top-level `bindings[]` entries with `type: "acp"` for persistent ACP bindings. Use the Matrix room id or Matrix thread root event id in `match.peer.id`.
See the full channel index: [Channels](/channels).
### Group chat mention gating
@@ -1358,7 +1359,7 @@ Run multiple isolated agents inside one Gateway. See [Multi-Agent](/concepts/mul
Within each tier, the first matching `bindings` entry wins.
For `type: "acp"` entries, OpenClaw resolves by exact conversation identity (`match.channel` + account + `match.peer.id`) and does not use the route binding tier order above.
For `type: "acp"` entries, OpenClaw resolves by exact conversation identity (`match.channel` + account + `match.peer.id`) and does not use the route binding tier order above. For example, use a Discord channel/thread id, a Matrix room id or thread root event id, or a Telegram canonical topic id.
### Per-agent access profiles

View File

@@ -79,6 +79,7 @@ Required feature flags for thread-bound ACP:
- `acp.dispatch.enabled` is on by default (set `false` to pause ACP dispatch)
- Channel-adapter ACP thread-spawn flag enabled (adapter-specific)
- Discord: `channels.discord.threadBindings.spawnAcpSessions=true`
- Matrix-js: `channels["matrix-js"].threadBindings.spawnAcpSessions=true`
- Telegram: `channels.telegram.threadBindings.spawnAcpSessions=true`
### Thread supporting channels
@@ -86,6 +87,7 @@ Required feature flags for thread-bound ACP:
- Any channel adapter that exposes session/thread binding capability.
- Current built-in support:
- Discord threads/channels
- Matrix-js room threads and DMs
- Telegram topics (forum topics in groups/supergroups and DM topics)
- Plugin channels can add support through the same binding interface.
@@ -98,6 +100,7 @@ For non-ephemeral workflows, configure persistent ACP bindings in top-level `bin
- `bindings[].type="acp"` marks a persistent ACP conversation binding.
- `bindings[].match` identifies the target conversation:
- Discord channel or thread: `match.channel="discord"` + `match.peer.id="<channelOrThreadId>"`
- Matrix room or thread: `match.channel="matrix-js"` + `match.peer.id="<roomIdOrThreadRootEventId>"`
- Telegram forum topic: `match.channel="telegram"` + `match.peer.id="<chatId>:topic:<topicId>"`
- `bindings[].agentId` is the owning OpenClaw agent id.
- Optional ACP overrides live under `bindings[].acp`:
@@ -375,6 +378,7 @@ Notes:
- On non-thread binding surfaces, default behavior is effectively `off`.
- Thread-bound spawn requires channel policy support:
- Discord: `channels.discord.threadBindings.spawnAcpSessions=true`
- Matrix-js: `channels["matrix-js"].threadBindings.spawnAcpSessions=true`
- Telegram: `channels.telegram.threadBindings.spawnAcpSessions=true`
## ACP controls
@@ -475,7 +479,7 @@ Core ACP baseline:
}
```
Thread binding config is channel-adapter specific. Example for Discord:
Thread binding config is channel-adapter specific. Example for Discord and Matrix-js:
```json5
{
@@ -493,6 +497,12 @@ Thread binding config is channel-adapter specific. Example for Discord:
spawnAcpSessions: true,
},
},
"matrix-js": {
threadBindings: {
enabled: true,
spawnAcpSessions: true,
},
},
},
}
```
@@ -500,6 +510,7 @@ Thread binding config is channel-adapter specific. Example for Discord:
If thread-bound ACP spawn does not work, verify the adapter feature flag first:
- Discord: `channels.discord.threadBindings.spawnAcpSessions=true`
- Matrix-js: `channels["matrix-js"].threadBindings.spawnAcpSessions=true`
See [Configuration Reference](/gateway/configuration-reference).

View File

@@ -99,7 +99,11 @@ When thread bindings are enabled for a channel, a sub-agent can stay bound to a
### Thread supporting channels
- Discord (currently the only supported channel): supports persistent thread-bound subagent sessions (`sessions_spawn` with `thread: true`), manual thread controls (`/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`), and adapter keys `channels.discord.threadBindings.enabled`, `channels.discord.threadBindings.idleHours`, `channels.discord.threadBindings.maxAgeHours`, and `channels.discord.threadBindings.spawnSubagentSessions`.
- `sessions_spawn` with `thread: true`: currently supported on Discord only.
- Manual thread/conversation controls:
- Discord: `/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`
- Matrix-js: `/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`
- Telegram: `/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`
Quick flow:

View File

@@ -963,6 +963,18 @@ describe("spawnAcpDirect", () => {
});
it("keeps inline delivery for thread-bound ACP session mode", async () => {
hoisted.state.cfg = {
...hoisted.state.cfg,
channels: {
...hoisted.state.cfg.channels,
telegram: {
threadBindings: {
spawnAcpSessions: true,
},
},
},
};
const result = await spawnAcpDirect(
{
task: "Investigate flaky tests",

View File

@@ -243,9 +243,10 @@ function createSessionBindingCapabilities() {
type AcpBindInput = {
targetSessionKey: string;
conversation: {
channel?: "discord" | "telegram";
channel?: "discord" | "matrix-js" | "telegram";
accountId: string;
conversationId: string;
parentConversationId?: string;
};
placement: "current" | "child";
metadata?: Record<string, unknown>;
@@ -266,11 +267,18 @@ function createAcpThreadBinding(input: AcpBindInput): FakeBinding {
conversationId: nextConversationId,
parentConversationId: "parent-1",
}
: {
channel: "telegram",
accountId: input.conversation.accountId,
conversationId: nextConversationId,
},
: channel === "matrix-js"
? {
channel: "matrix-js",
accountId: input.conversation.accountId,
conversationId: nextConversationId,
parentConversationId: input.conversation.parentConversationId ?? "!room:example",
}
: {
channel: "telegram",
accountId: input.conversation.accountId,
conversationId: nextConversationId,
},
metadata: { boundBy, webhookId: "wh-1" },
});
}
@@ -334,6 +342,24 @@ function createTelegramDmParams(commandBody: string, cfg: OpenClawConfig = baseC
return params;
}
function createMatrixRoomParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
const params = buildCommandTestParams(commandBody, cfg, {
Provider: "matrix-js",
Surface: "matrix-js",
OriginatingChannel: "matrix-js",
OriginatingTo: "room:!room:example",
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-42";
return params;
}
async function runDiscordAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
return handleAcpCommand(createDiscordParams(commandBody, cfg), true);
}
@@ -350,6 +376,14 @@ async function runTelegramDmAcpCommand(commandBody: string, cfg: OpenClawConfig
return handleAcpCommand(createTelegramDmParams(commandBody, cfg), true);
}
async function runMatrixRoomAcpCommand(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);
}
describe("/acp command", () => {
beforeEach(() => {
acpManagerTesting.resetAcpSessionManagerForTests();
@@ -518,7 +552,18 @@ describe("/acp command", () => {
});
it("binds Telegram topic ACP spawns to full conversation ids", async () => {
const result = await runTelegramAcpCommand("/acp spawn codex --thread here");
const cfg = {
...baseCfg,
channels: {
...baseCfg.channels,
telegram: {
threadBindings: {
spawnAcpSessions: true,
},
},
},
} satisfies OpenClawConfig;
const result = await runTelegramAcpCommand("/acp spawn codex --thread here", cfg);
expect(result?.reply?.text).toContain("Spawned ACP session agent:codex:acp:");
expect(result?.reply?.text).toContain("Bound this conversation to");
@@ -536,7 +581,18 @@ describe("/acp command", () => {
});
it("binds Telegram DM ACP spawns to the DM conversation id", async () => {
const result = await runTelegramDmAcpCommand("/acp spawn codex --thread here");
const cfg = {
...baseCfg,
channels: {
...baseCfg.channels,
telegram: {
threadBindings: {
spawnAcpSessions: true,
},
},
},
} satisfies OpenClawConfig;
const result = await runTelegramDmAcpCommand("/acp spawn codex --thread here", cfg);
expect(result?.reply?.text).toContain("Spawned ACP session agent:codex:acp:");
expect(result?.reply?.text).toContain("Bound this conversation to");
@@ -592,6 +648,47 @@ describe("/acp command", () => {
);
});
it("rejects Matrix thread-bound ACP spawn when spawnAcpSessions is not enabled", async () => {
const result = await runMatrixRoomAcpCommand("/acp spawn codex --thread auto");
expect(result?.reply?.text).toContain(
"channels.matrix-js.threadBindings.spawnAcpSessions=true",
);
expect(hoisted.closeMock).toHaveBeenCalledTimes(1);
expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled();
});
it("binds Matrix thread-bound ACP spawns when enabled explicitly", async () => {
const cfg = {
...baseCfg,
channels: {
...baseCfg.channels,
"matrix-js": {
threadBindings: {
enabled: true,
spawnAcpSessions: true,
},
},
},
} satisfies OpenClawConfig;
const result = await runMatrixThreadAcpCommand("/acp spawn codex --thread here", cfg);
expect(result?.reply?.text).toContain("Spawned ACP session agent:codex:acp:");
expect(result?.reply?.text).toContain("Bound this thread to");
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: "current",
conversation: expect.objectContaining({
channel: "matrix-js",
accountId: "default",
conversationId: "$thread-42",
parentConversationId: "!room:example",
}),
}),
);
});
it("forbids /acp spawn from sandboxed requester sessions", async () => {
const cfg = {
...baseCfg,

View File

@@ -157,6 +157,9 @@ async function bindSpawnedAcpSessionToThread(params: {
channel: spawnPolicy.channel,
accountId: spawnPolicy.accountId,
conversationId: currentConversationId,
...(bindingContext.parentConversationId
? { parentConversationId: bindingContext.parentConversationId }
: {}),
});
const boundBy =
typeof existingBinding?.metadata?.boundBy === "string"
@@ -181,6 +184,9 @@ async function bindSpawnedAcpSessionToThread(params: {
channel: spawnPolicy.channel,
accountId: spawnPolicy.accountId,
conversationId,
...(bindingContext.parentConversationId
? { parentConversationId: bindingContext.parentConversationId }
: {}),
},
placement,
metadata: {

View File

@@ -105,8 +105,8 @@ function createTelegramTopicCommandParams(commandBody: string) {
return params;
}
function createMatrixCommandParams(commandBody: string) {
const params = buildCommandTestParams(commandBody, baseCfg, {
function createMatrixCommandParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
const params = buildCommandTestParams(commandBody, cfg, {
Provider: "matrix-js",
Surface: "matrix-js",
OriginatingChannel: "matrix-js",
@@ -236,7 +236,17 @@ describe("/focus, /unfocus, /agents", () => {
});
it("/focus creates Matrix child thread bindings from top-level rooms", async () => {
const result = await focusCodexAcp(createMatrixCommandParams("/focus codex-acp"));
const cfg = {
...baseCfg,
channels: {
"matrix-js": {
threadBindings: {
spawnAcpSessions: true,
},
},
},
} satisfies OpenClawConfig;
const result = await focusCodexAcp(createMatrixCommandParams("/focus codex-acp", cfg));
expect(result?.reply?.text).toContain("created thread");
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
@@ -251,6 +261,15 @@ describe("/focus, /unfocus, /agents", () => {
);
});
it("/focus rejects Matrix child thread creation when spawn config is not enabled", async () => {
const result = await focusCodexAcp(createMatrixCommandParams("/focus codex-acp"));
expect(result?.reply?.text).toContain(
"channels.matrix-js.threadBindings.spawnAcpSessions=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",

View File

@@ -8,8 +8,10 @@ import {
resolveThreadBindingThreadName,
} from "../../../channels/thread-bindings-messages.js";
import {
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";
@@ -183,6 +185,23 @@ export async function handleSubagentsFocusAction(
if (!capabilities.placements.includes(bindingContext.placement)) {
return stopWithText(`⚠️ ${channel} bindings are unavailable for this account.`);
}
if (bindingContext.channel === "matrix-js" && bindingContext.placement === "child") {
const spawnPolicy = resolveThreadBindingSpawnPolicy({
cfg: params.cfg,
channel: bindingContext.channel,
accountId,
kind: focusTarget.targetKind === "acp" ? "acp" : "subagent",
});
if (!spawnPolicy.spawnEnabled) {
return stopWithText(
`⚠️ ${formatThreadBindingSpawnDisabledError({
channel: spawnPolicy.channel,
accountId: spawnPolicy.accountId,
kind: focusTarget.targetKind === "acp" ? "acp" : "subagent",
})}`,
);
}
}
let binding;
try {

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { resolveThreadBindingSpawnPolicy } from "./thread-bindings-policy.js";
const baseCfg = {
session: { mainKey: "main", scope: "per-sender" },
} satisfies OpenClawConfig;
describe("resolveThreadBindingSpawnPolicy", () => {
it("defaults thread-bound spawns to opt-in across supported channels", () => {
expect(
resolveThreadBindingSpawnPolicy({
cfg: baseCfg,
channel: "discord",
kind: "subagent",
}).spawnEnabled,
).toBe(false);
expect(
resolveThreadBindingSpawnPolicy({
cfg: baseCfg,
channel: "matrix-js",
kind: "subagent",
}).spawnEnabled,
).toBe(false);
expect(
resolveThreadBindingSpawnPolicy({
cfg: baseCfg,
channel: "telegram",
kind: "acp",
}).spawnEnabled,
).toBe(false);
});
it("honors explicit per-channel spawn flags", () => {
const cfg = {
...baseCfg,
channels: {
"matrix-js": {
threadBindings: {
spawnSubagentSessions: true,
spawnAcpSessions: true,
},
},
},
} satisfies OpenClawConfig;
expect(
resolveThreadBindingSpawnPolicy({
cfg,
channel: "matrix-js",
kind: "subagent",
}).spawnEnabled,
).toBe(true);
expect(
resolveThreadBindingSpawnPolicy({
cfg,
channel: "matrix-js",
kind: "acp",
}).spawnEnabled,
).toBe(true);
});
});

View File

@@ -2,6 +2,8 @@ import type { OpenClawConfig } from "../config/config.js";
import { normalizeAccountId } from "../routing/session-key.js";
export const DISCORD_THREAD_BINDING_CHANNEL = "discord";
export const MATRIX_JS_THREAD_BINDING_CHANNEL = "matrix-js";
export const TELEGRAM_THREAD_BINDING_CHANNEL = "telegram";
const DEFAULT_THREAD_BINDING_IDLE_HOURS = 24;
const DEFAULT_THREAD_BINDING_MAX_AGE_HOURS = 0;
@@ -106,6 +108,22 @@ function resolveSpawnFlagKey(
return kind === "subagent" ? "spawnSubagentSessions" : "spawnAcpSessions";
}
function resolveSpawnConfigPath(params: {
channel: string;
kind: ThreadBindingSpawnKind;
}): string | undefined {
const suffix =
params.kind === "subagent" ? "spawnSubagentSessions=true" : "spawnAcpSessions=true";
if (
params.channel === DISCORD_THREAD_BINDING_CHANNEL ||
params.channel === MATRIX_JS_THREAD_BINDING_CHANNEL ||
params.channel === TELEGRAM_THREAD_BINDING_CHANNEL
) {
return `channels.${params.channel}.threadBindings.${suffix}`;
}
return undefined;
}
export function resolveThreadBindingSpawnPolicy(params: {
cfg: OpenClawConfig;
channel: string;
@@ -127,8 +145,7 @@ 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 ?? false;
return {
channel,
accountId,
@@ -191,11 +208,21 @@ export function formatThreadBindingSpawnDisabledError(params: {
accountId: string;
kind: ThreadBindingSpawnKind;
}): string {
if (params.channel === DISCORD_THREAD_BINDING_CHANNEL && params.kind === "acp") {
return "Discord thread-bound ACP spawns are disabled for this account (set channels.discord.threadBindings.spawnAcpSessions=true to enable).";
}
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).";
const configPath = resolveSpawnConfigPath({
channel: params.channel,
kind: params.kind,
});
const label =
params.channel === DISCORD_THREAD_BINDING_CHANNEL
? "Discord"
: params.channel === MATRIX_JS_THREAD_BINDING_CHANNEL
? "Matrix-js"
: params.channel === TELEGRAM_THREAD_BINDING_CHANNEL
? "Telegram"
: params.channel;
if (configPath) {
const noun = params.kind === "acp" ? "ACP" : "subagent";
return `${label} thread-bound ${noun} spawns are disabled for this account (set ${configPath} to enable).`;
}
return `Thread-bound ${params.kind} spawns are disabled for ${params.channel}.`;
}

View File

@@ -1550,9 +1550,9 @@ export const FIELD_HELP: Record<string, string> = {
"channels.matrix-js.threadBindings.maxAgeHours":
"Optional hard max age in hours for Matrix-js thread-bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set.",
"channels.matrix-js.threadBindings.spawnSubagentSessions":
"Allow subagent spawns/focus flows to auto-create and bind Matrix threads when starting from a top-level Matrix room or DM.",
"Allow top-level /focus flows to auto-create and bind Matrix threads for subagent/session targets (default: false; opt-in). Set true to enable Matrix thread creation/binding from room or DM contexts.",
"channels.matrix-js.threadBindings.spawnAcpSessions":
"Allow /acp spawn to auto-create and bind Matrix threads for ACP sessions when starting from a top-level Matrix room or DM.",
"Allow /acp spawn to create or bind Matrix threads for ACP sessions (default: false; opt-in). Set true to enable thread-bound ACP spawns for this account/channel.",
"channels.discord.ui.components.accentColor":
"Accent color for Discord component containers (hex). Set per account via channels.discord.accounts.<id>.ui.components.accentColor.",
"channels.discord.voice.enabled":