mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:40:44 +00:00
fix: harden thread-bound subagent spawning (#75943)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user