mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:30:42 +00:00
fix(agents): make sessions_spawn mode=session errors actionable when thread binding is unavailable
This commit is contained in:
@@ -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.',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
136
src/agents/subagent-spawn.mode-session-diagnostics.test.ts
Normal file
136
src/agents/subagent-spawn.mode-session-diagnostics.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user