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

@@ -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":