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

@@ -16,7 +16,7 @@ Docs: https://docs.openclaw.ai
- Plugins/ClawHub: persist ClawPack digest metadata on ClawHub plugin install and update records so registry refreshes and download verification can reuse stored artifact facts. Thanks @vincentkoc.
- Plugins/ClawHub: allow official bundled-plugin cutovers to prefer ClawHub installs with npm fallback only when the ClawHub package or version is absent. Thanks @vincentkoc.
- Plugins/Crestodian: add ClawHub plugin search plus Crestodian plugin list/search/install/uninstall operations, with approval and audit coverage for install and uninstall.
- Channels/thread bindings: replace split subagent/ACP thread-spawn toggles with `threadBindings.spawnSessions`, default thread-bound spawns on, and let `openclaw doctor --fix` migrate the legacy keys.
- Channels/thread bindings: replace split subagent/ACP thread-spawn toggles with `threadBindings.spawnSessions`, default thread-bound spawns on, and let `openclaw doctor --fix` migrate the legacy keys. (#75943)
- Providers/OpenAI: add `extraBody`/`extra_body` passthrough for OpenAI-compatible TTS endpoints, so custom speech servers can receive fields such as `lang` in `/audio/speech` requests. Fixes #39900. Thanks @R3NK0R.
- Dependencies: refresh workspace dependency pins, including TypeBox 1.1.37, AWS SDK 3.1041.0, Microsoft Teams 2.0.9, and Marked 18.0.3. Thanks @mariozechner, @aws, and @microsoft.
- Discord/channels: add reusable message-channel access groups plus Discord channel-audience DM authorization, so allowlists can reference `accessGroup:<name>` across channel auth paths. (#75813)

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,

View File

@@ -179,6 +179,16 @@ describe("sessions_spawn context modes", () => {
agentId: "main",
sessionsDir: path.dirname(storePath),
});
expect(callGatewayMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "sessions.delete",
params: expect.objectContaining({
key: result.childSessionKey,
deleteTranscript: true,
emitLifecycleHooks: false,
}),
}),
);
expect(prepareSubagentSpawn).not.toHaveBeenCalled();
});

View File

@@ -964,7 +964,7 @@ export async function spawnSubagentDirect(
try {
await callSubagentGateway({
method: "sessions.delete",
params: { key: childSessionKey, emitLifecycleHooks: false },
params: { key: childSessionKey, deleteTranscript: true, emitLifecycleHooks: false },
timeoutMs: SUBAGENT_CONTROL_GATEWAY_TIMEOUT_MS,
});
} catch {