diff --git a/CHANGELOG.md b/CHANGELOG.md index a887a1d86df..372dba86c8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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:` across channel auth paths. (#75813) diff --git a/extensions/discord/src/subagent-hooks.test.ts b/extensions/discord/src/subagent-hooks.test.ts index 2534f49a6a4..02655a1c018 100644 --- a/extensions/discord/src/subagent-hooks.test.ts +++ b/extensions/discord/src/subagent-hooks.test.ts @@ -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: { diff --git a/extensions/discord/src/subagent-hooks.ts b/extensions/discord/src/subagent-hooks.ts index 040bdfacc5a..c99d22b79c7 100644 --- a/extensions/discord/src/subagent-hooks.ts +++ b/extensions/discord/src/subagent-hooks.ts @@ -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, diff --git a/src/agents/subagent-spawn.context.test.ts b/src/agents/subagent-spawn.context.test.ts index 713a5abedf6..5d91d64c366 100644 --- a/src/agents/subagent-spawn.context.test.ts +++ b/src/agents/subagent-spawn.context.test.ts @@ -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(); }); diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index fba5ffe0992..eed7fef2e2d 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -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 {