diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index 91eeae3436a..71f5e510ac4 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -1070,7 +1070,9 @@ export async function spawnAcpDirect( return createAcpSpawnFailure({ status: "error", errorCode: "thread_required", - error: 'mode="session" requires thread=true so the ACP session can stay bound to a thread.', + error: + 'sessions_spawn(runtime="acp", mode="session") requires thread=true so the ACP session can stay bound to a channel thread. ' + + 'Retry with { mode: "session", thread: true } on a channel that exposes threads (e.g. Discord, Slack, Telegram topics), or use mode="run" for one-shot work.', }); } diff --git a/src/agents/subagent-spawn.mode-session-diagnostics.test.ts b/src/agents/subagent-spawn.mode-session-diagnostics.test.ts new file mode 100644 index 00000000000..3cae57392b5 --- /dev/null +++ b/src/agents/subagent-spawn.mode-session-diagnostics.test.ts @@ -0,0 +1,136 @@ +import os from "node:os"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { SubagentLifecycleHookRunner } from "../plugins/hooks.js"; +import { + createSubagentSpawnTestConfig, + loadSubagentSpawnModuleForTest, +} from "./subagent-spawn.test-helpers.js"; + +type SubagentSpawningEvent = Parameters[0]; + +describe('spawnSubagentDirect mode="session" diagnostics (#67400)', () => { + const callGatewayMock = vi.fn(); + let spawnSubagentDirect: typeof import("./subagent-spawn.js").spawnSubagentDirect; + let resetSubagentRegistryForTests: typeof import("./subagent-registry.js").resetSubagentRegistryForTests; + + beforeEach(async () => { + callGatewayMock.mockReset(); + ({ spawnSubagentDirect, resetSubagentRegistryForTests } = await loadSubagentSpawnModuleForTest({ + callGatewayMock, + loadConfig: () => createSubagentSpawnTestConfig(os.tmpdir()), + workspaceDir: os.tmpdir(), + })); + resetSubagentRegistryForTests(); + }); + + it("names usable alternatives before a thread retry", async () => { + const result = await spawnSubagentDirect( + { + task: "persistent planning session", + mode: "session", + }, + { + agentSessionKey: "agent:main:main", + agentChannel: "webchat", + }, + ); + + expect(result.status).toBe("error"); + if (result.status === "error") { + expect(result.error).toContain("thread: true"); + expect(result.error).toContain('mode="run"'); + expect(result.error).toContain("sessions_send"); + } + }); + + it("rejects thread=true with actionable guidance when no hook is registered", async () => { + const result = await spawnSubagentDirect( + { + task: "persistent planning session", + mode: "session", + thread: true, + }, + { + agentSessionKey: "agent:main:main", + agentChannel: "webchat", + }, + ); + + expect(result.status).toBe("error"); + if (result.status === "error") { + expect(result.error).toContain("not running on a channel"); + expect(result.error).toContain('mode="run"'); + expect(result.error).toContain("sessions_send"); + } + }); +}); + +describe('spawnSubagentDirect mode="session" with registered thread hooks (#67400)', () => { + const callGatewayMock = vi.fn(); + let spawnSubagentDirect: typeof import("./subagent-spawn.js").spawnSubagentDirect; + let resetSubagentRegistryForTests: typeof import("./subagent-registry.js").resetSubagentRegistryForTests; + + beforeEach(async () => { + callGatewayMock.mockReset(); + ({ spawnSubagentDirect, resetSubagentRegistryForTests } = await loadSubagentSpawnModuleForTest({ + callGatewayMock, + loadConfig: () => createSubagentSpawnTestConfig(os.tmpdir()), + workspaceDir: os.tmpdir(), + hookRunner: { + hasHooks: () => true, + runSubagentSpawning: async (event: SubagentSpawningEvent) => { + const requesterChannel = event.requester?.channel; + if (requesterChannel !== "discord") { + return undefined; + } + return { + status: "ok" as const, + threadBindingReady: true, + }; + }, + }, + })); + resetSubagentRegistryForTests(); + }); + + it("names thread=true and the non-thread alternatives when hooks are registered", async () => { + const result = await spawnSubagentDirect( + { + task: "persistent planning session", + mode: "session", + }, + { + agentSessionKey: "agent:main:main", + agentChannel: "discord", + }, + ); + + expect(result.status).toBe("error"); + if (result.status === "error") { + expect(result.error).toContain("thread: true"); + expect(result.error).toContain('mode="run"'); + expect(result.error).toContain("sessions_send"); + } + }); + + it("rejects thread=true with actionable guidance when hooks do not bind the requester channel", async () => { + const result = await spawnSubagentDirect( + { + task: "persistent planning session", + mode: "session", + thread: true, + }, + { + agentSessionKey: "agent:main:main", + agentChannel: "webchat", + }, + ); + + expect(result.status).toBe("error"); + if (result.status === "error") { + expect(result.error).toContain("not running on a channel"); + expect(result.error).toContain('mode="run"'); + expect(result.error).toContain("sessions_send"); + } + }); +}); diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index ce3773aec79..9710cee6726 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -475,6 +475,21 @@ function summarizeError(err: unknown): string { return "error"; } +function buildThreadBindingUnavailableError(mode: SpawnSubagentMode): string { + if (mode === "session") { + return ( + 'sessions_spawn(mode="session") is only available on channels that expose thread bindings (e.g. Discord threads, Slack threads, Telegram forum topics). ' + + "This request is not running on a channel that can bind a subagent thread. " + + 'Use mode="run" for one-shot subagent work, or sessions_send(sessionKey=...) to keep talking to a persistent session without thread binding.' + ); + } + return ( + "thread=true is only available on channels that expose thread bindings (e.g. Discord threads, Slack threads, Telegram forum topics). " + + "This request is not running on a channel that can bind a subagent thread. " + + "Retry without thread=true, or re-run sessions_spawn from a channel that supports threads." + ); +} + async function ensureThreadBindingForSubagentSpawn(params: { hookRunner: SubagentLifecycleHookRunner | null; childSessionKey: string; @@ -491,17 +506,15 @@ async function ensureThreadBindingForSubagentSpawn(params: { }): Promise< { status: "ok"; deliveryOrigin?: DeliveryContext } | { status: "error"; error: string } > { - const hookRunner = params.hookRunner; - if (!hookRunner?.hasHooks("subagent_spawning")) { + if (!params.hookRunner?.hasHooks("subagent_spawning")) { return { status: "error", - error: - "thread=true is unavailable because no channel plugin registered subagent_spawning hooks.", + error: buildThreadBindingUnavailableError(params.mode), }; } try { - const result = await hookRunner.runSubagentSpawning( + const result = await params.hookRunner.runSubagentSpawning( { childSessionKey: params.childSessionKey, agentId: params.agentId, @@ -522,6 +535,12 @@ async function ensureThreadBindingForSubagentSpawn(params: { error: error || "Failed to prepare thread binding for this subagent session.", }; } + if (!result) { + return { + status: "error", + error: buildThreadBindingUnavailableError(params.mode), + }; + } if (result?.status !== "ok" || !result.threadBindingReady) { return { status: "error", @@ -578,7 +597,9 @@ export async function spawnSubagentDirect( if (spawnMode === "session" && !requestThreadBinding) { return { status: "error", - error: 'mode="session" requires thread=true so the subagent can stay bound to a thread.', + error: + 'sessions_spawn(mode="session") requires thread=true so the subagent can stay bound to a channel thread. ' + + 'Retry with { mode: "session", thread: true } on a channel that supports threads, use mode="run" for one-shot work, or use sessions_send(sessionKey=...) to keep talking to a persistent session without thread binding.', }; } const cleanup =