fix: harden thread-bound subagent spawning (#75943)

This commit is contained in:
Peter Steinberger
2026-05-02 06:29:33 +01:00
parent 10b89a3b55
commit b21e312b1a
5 changed files with 94 additions and 27 deletions

View File

@@ -20,23 +20,46 @@ type MockResolvedDiscordAccount = {
};
};
const hookMocks = vi.hoisted(() => ({
resolveDiscordAccount: vi.fn(
(params?: { accountId?: string }): MockResolvedDiscordAccount => ({
accountId: params?.accountId?.trim() || "default",
type MockResolveDiscordAccountParams = {
cfg?: {
channels?: {
discord?: {
defaultAccount?: string;
accounts?: Record<
string,
{ threadBindings?: MockResolvedDiscordAccount["config"]["threadBindings"] }
>;
};
};
};
accountId?: string;
};
const hookMocks = vi.hoisted(() => {
const resolveDiscordAccountImpl = (
params?: MockResolveDiscordAccountParams,
): MockResolvedDiscordAccount => {
const accountId =
params?.accountId?.trim() || params?.cfg?.channels?.discord?.defaultAccount || "default";
return {
accountId,
config: {
threadBindings: {
threadBindings: params?.cfg?.channels?.discord?.accounts?.[accountId]?.threadBindings ?? {
spawnSessions: true,
},
},
}),
),
autoBindSpawnedDiscordSubagent: vi.fn(
async (): Promise<{ threadId: string } | null> => ({ threadId: "thread-1" }),
),
listThreadBindingsBySessionKey: vi.fn((_params?: unknown): ThreadBindingRecord[] => []),
unbindThreadBindingsBySessionKey: vi.fn(() => []),
}));
};
};
return {
resolveDiscordAccountImpl,
resolveDiscordAccount: vi.fn(resolveDiscordAccountImpl),
autoBindSpawnedDiscordSubagent: vi.fn(
async (): Promise<{ threadId: string } | null> => ({ threadId: "thread-1" }),
),
listThreadBindingsBySessionKey: vi.fn((_params?: unknown): ThreadBindingRecord[] => []),
unbindThreadBindingsBySessionKey: vi.fn(() => []),
};
});
let registerDiscordSubagentHooks: typeof import("../subagent-hooks-api.js").registerDiscordSubagentHooks;
@@ -94,7 +117,7 @@ function createSpawnEvent(overrides?: {
mode?: string;
requester?: {
channel?: string;
accountId?: string;
accountId?: string | undefined;
to?: string;
threadId?: string;
};
@@ -106,7 +129,7 @@ function createSpawnEvent(overrides?: {
mode: string;
requester: {
channel: string;
accountId: string;
accountId?: string;
to: string;
threadId?: string;
};
@@ -172,14 +195,7 @@ describe("discord subagent hook handlers", () => {
beforeEach(() => {
hookMocks.resolveDiscordAccount.mockClear();
hookMocks.resolveDiscordAccount.mockImplementation((params?: { accountId?: string }) => ({
accountId: params?.accountId?.trim() || "default",
config: {
threadBindings: {
spawnSessions: true,
},
},
}));
hookMocks.resolveDiscordAccount.mockImplementation(hookMocks.resolveDiscordAccountImpl);
hookMocks.autoBindSpawnedDiscordSubagent.mockClear();
hookMocks.listThreadBindingsBySessionKey.mockClear();
hookMocks.unbindThreadBindingsBySessionKey.mockClear();
@@ -229,6 +245,42 @@ describe("discord subagent hook handlers", () => {
});
});
it("honors defaultAccount policy when requester omits accountId", async () => {
await expectSubagentSpawningError({
config: {
channels: {
discord: {
defaultAccount: "work",
threadBindings: {
spawnSessions: true,
},
accounts: {
work: {
threadBindings: {
spawnSessions: false,
},
},
},
},
},
},
event: createSpawnEvent({
requester: {
accountId: undefined,
channel: "discord",
to: "channel:123",
threadId: undefined,
},
}),
errorContains: "spawnSessions=true",
});
expect(hookMocks.resolveDiscordAccount).toHaveBeenCalledWith(
expect.objectContaining({
accountId: undefined,
}),
);
});
it("returns error when global thread bindings are disabled", async () => {
await expectSubagentSpawningError({
config: {

View File

@@ -8,6 +8,7 @@ import {
normalizeOptionalLowercaseString,
normalizeOptionalStringifiedId,
} from "openclaw/plugin-sdk/text-runtime";
import { resolveDiscordAccount } from "./accounts.js";
import {
autoBindSpawnedDiscordSubagent,
listThreadBindingsBySessionKey,
@@ -91,10 +92,14 @@ export async function handleDiscordSubagentSpawning(
if (channel !== "discord") {
return undefined;
}
const account = resolveDiscordAccount({
cfg: api.config,
accountId: event.requester?.accountId,
});
const threadBindingPolicy = resolveThreadBindingSpawnPolicy({
cfg: api.config,
channel: "discord",
accountId: event.requester?.accountId,
accountId: account.accountId,
kind: "subagent",
});
if (!threadBindingPolicy.enabled) {
@@ -121,7 +126,7 @@ export async function handleDiscordSubagentSpawning(
const agentId = event.agentId?.trim() || "subagent";
const binding = await autoBindSpawnedDiscordSubagent({
cfg: api.config,
accountId: event.requester?.accountId,
accountId: account.accountId,
channel: event.requester?.channel,
to: event.requester?.to,
threadId: event.requester?.threadId,