mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-22 15:31:07 +00:00
matrix-js: require explicit thread-bound spawn config
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
62
src/channels/thread-bindings-policy.test.ts
Normal file
62
src/channels/thread-bindings-policy.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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}.`;
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user