feat: simplify thread-bound session spawning

This commit is contained in:
Peter Steinberger
2026-05-02 04:52:17 +01:00
parent 5ac0ff1812
commit 8612af754b
53 changed files with 892 additions and 219 deletions

View File

@@ -26,6 +26,8 @@ const matrixThreadBindingsSchema = z
enabled: z.boolean().optional(),
idleHours: z.number().nonnegative().optional(),
maxAgeHours: z.number().nonnegative().optional(),
spawnSessions: z.boolean().optional(),
defaultSpawnContext: z.enum(["isolated", "fork"]).optional(),
spawnSubagentSessions: z.boolean().optional(),
spawnAcpSessions: z.boolean().optional(),
})

View File

@@ -90,9 +90,8 @@ describe("handleMatrixSubagentSpawning", () => {
getManagerMock.mockReset();
resolveMatrixBaseConfigMock.mockReset();
findMatrixAccountConfigMock.mockReset();
// Default: bindings enabled, spawn enabled
resolveMatrixBaseConfigMock.mockReturnValue({
threadBindings: { enabled: true, spawnSubagentSessions: true },
threadBindings: { enabled: true, spawnSessions: true },
});
findMatrixAccountConfigMock.mockReturnValue(undefined);
getCapabilitiesMock.mockReturnValue({
@@ -140,40 +139,46 @@ describe("handleMatrixSubagentSpawning", () => {
});
it("returns error when thread bindings are disabled", async () => {
resolveMatrixBaseConfigMock.mockReturnValue({
threadBindings: { enabled: false, spawnSubagentSessions: true },
});
const result = await handleMatrixSubagentSpawning(fakeApi, makeSpawnEvent());
const result = await handleMatrixSubagentSpawning(
{
config: {
channels: {
matrix: {
threadBindings: { enabled: false, spawnSessions: true },
},
},
},
} as never,
makeSpawnEvent(),
);
expect(result).toEqual(expect.objectContaining({ status: "error" }));
expect((result as { error?: string }).error).toMatch(/thread bindings are disabled/i);
});
it("returns error when spawnSessions is false", async () => {
const result = await handleMatrixSubagentSpawning(
{
config: {
channels: {
matrix: {
threadBindings: { enabled: true, spawnSessions: false },
},
},
},
} as never,
makeSpawnEvent(),
);
expect(result).toEqual(
expect.objectContaining({
status: "error",
error: expect.stringContaining("thread bindings are disabled"),
error: expect.stringContaining("spawnSessions"),
}),
);
});
it("returns error when spawnSubagentSessions is false", async () => {
resolveMatrixBaseConfigMock.mockReturnValue({
threadBindings: { enabled: true, spawnSubagentSessions: false },
});
it("allows thread-bound subagent spawn by default", async () => {
const result = await handleMatrixSubagentSpawning(fakeApi, makeSpawnEvent());
expect(result).toEqual(
expect.objectContaining({
status: "error",
error: expect.stringContaining("spawnSubagentSessions"),
}),
);
});
it("returns error when spawnSubagentSessions defaults to false (no config)", async () => {
resolveMatrixBaseConfigMock.mockReturnValue({});
const result = await handleMatrixSubagentSpawning(fakeApi, makeSpawnEvent());
expect(result).toEqual(
expect.objectContaining({
status: "error",
error: expect.stringContaining("spawnSubagentSessions"),
}),
);
expect(result).toMatchObject({ status: "ok", threadBindingReady: true });
});
it("returns error when requester.to has no room target", async () => {
@@ -295,17 +300,23 @@ describe("handleMatrixSubagentSpawning", () => {
});
it("respects per-account threadBindings override over base config", async () => {
// Base says spawnSubagentSessions=false; account override says true
resolveMatrixBaseConfigMock.mockReturnValue({
threadBindings: { enabled: true, spawnSubagentSessions: false },
});
findMatrixAccountConfigMock.mockReturnValue({
threadBindings: { spawnSubagentSessions: true },
});
bindMock.mockResolvedValue({ conversation: {} });
const result = await handleMatrixSubagentSpawning(
fakeApi,
{
config: {
channels: {
matrix: {
threadBindings: { enabled: true, spawnSessions: false },
accounts: {
forge: {
threadBindings: { spawnSessions: true },
},
},
},
},
},
} as never,
makeSpawnEvent({ accountId: "forge" }),
);
expect(result).toMatchObject({ status: "ok", threadBindingReady: true });
@@ -322,7 +333,7 @@ describe("matrix subagent hook registration", () => {
listBindingsForAccountMock.mockReset();
listAllBindingsMock.mockReset();
resolveMatrixBaseConfigMock.mockReturnValue({
threadBindings: { enabled: true, spawnSubagentSessions: true },
threadBindings: { enabled: true, spawnSessions: true },
});
findMatrixAccountConfigMock.mockReturnValue(undefined);
getCapabilitiesMock.mockReturnValue({
@@ -784,7 +795,7 @@ describe("concurrent spawns across accounts", () => {
resolveMatrixBaseConfigMock.mockReset();
findMatrixAccountConfigMock.mockReset();
resolveMatrixBaseConfigMock.mockReturnValue({
threadBindings: { enabled: true, spawnSubagentSessions: true },
threadBindings: { enabled: true, spawnSessions: true },
});
findMatrixAccountConfigMock.mockReturnValue(undefined);
getCapabilitiesMock.mockReturnValue({

View File

@@ -3,9 +3,13 @@ import {
getSessionBindingService,
type SessionBindingRecord,
} from "openclaw/plugin-sdk/conversation-binding-runtime";
import {
formatThreadBindingDisabledError,
formatThreadBindingSpawnDisabledError,
resolveThreadBindingSpawnPolicy,
} from "openclaw/plugin-sdk/conversation-runtime";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { findMatrixAccountConfig, resolveMatrixBaseConfig } from "./account-config.js";
import { resolveMatrixTargetIdentity } from "./target-ids.js";
import {
getMatrixThreadBindingManager,
@@ -76,28 +80,6 @@ function summarizeError(err: unknown): string {
return "error";
}
function resolveThreadBindingFlags(
api: OpenClawPluginApi,
accountId?: string,
): { enabled: boolean; spawnSubagentSessions: boolean } {
const matrix = resolveMatrixBaseConfig(api.config);
const baseThreadBindings = matrix.threadBindings;
const accountThreadBindings = accountId
? findMatrixAccountConfig(api.config, accountId)?.threadBindings
: undefined;
return {
enabled:
accountThreadBindings?.enabled ??
baseThreadBindings?.enabled ??
api.config.session?.threadBindings?.enabled ??
true,
spawnSubagentSessions:
accountThreadBindings?.spawnSubagentSessions ??
baseThreadBindings?.spawnSubagentSessions ??
false,
};
}
function resolveMatrixBindingThreadId(binding: SessionBindingRecord): string | undefined {
const { conversationId, parentConversationId } = binding.conversation;
return parentConversationId && parentConversationId !== conversationId
@@ -136,20 +118,31 @@ export async function handleMatrixSubagentSpawning(
// Falls back to DEFAULT_ACCOUNT_ID so accounts.default.threadBindings.* is
// respected even when the requester omits accountId.
const accountId = normalizeOptionalString(event.requester?.accountId) || DEFAULT_ACCOUNT_ID;
const flags = resolveThreadBindingFlags(api, accountId);
const policy = resolveThreadBindingSpawnPolicy({
cfg: api.config,
channel: "matrix",
accountId,
kind: "subagent",
});
if (!flags.enabled) {
if (!policy.enabled) {
return {
status: "error",
error:
"Matrix thread bindings are disabled (set channels.matrix.threadBindings.enabled=true to override for this account, or session.threadBindings.enabled=true globally).",
error: formatThreadBindingDisabledError({
channel: policy.channel,
accountId: policy.accountId,
kind: "subagent",
}),
} satisfies SpawningResult;
}
if (!flags.spawnSubagentSessions) {
if (!policy.spawnEnabled) {
return {
status: "error",
error:
"Matrix thread-bound subagent spawns are disabled for this account (set channels.matrix.threadBindings.spawnSubagentSessions=true to enable).",
error: formatThreadBindingSpawnDisabledError({
channel: policy.channel,
accountId: policy.accountId,
kind: "subagent",
}),
};
}

View File

@@ -63,7 +63,11 @@ type MatrixThreadBindingsConfig = {
enabled?: boolean;
idleHours?: number;
maxAgeHours?: number;
spawnSessions?: boolean;
defaultSpawnContext?: "isolated" | "fork";
/** @deprecated Use spawnSessions instead. */
spawnSubagentSessions?: boolean;
/** @deprecated Use spawnSessions instead. */
spawnAcpSessions?: boolean;
};