fix(agents): make sessions_spawn mode=session errors actionable when thread binding is unavailable

This commit is contained in:
stainlu
2026-04-17 03:13:12 +08:00
committed by Ayaan Zaidi
parent 3507efa4ec
commit 835f768036
3 changed files with 166 additions and 7 deletions

View File

@@ -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.',
});
}

View File

@@ -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<SubagentLifecycleHookRunner["runSubagentSpawning"]>[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");
}
});
});

View File

@@ -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 =