mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(agents): bind subagent threads in core (#88416)
Move subagent thread binding ownership into core so session-mode spawns prepare channel bindings before launching the child agent. Deprecate the legacy subagent_spawning SDK hook in code, compatibility metadata, diagnostics, and plugin docs; plugin authors should observe subagent_spawned instead. Verification: - node scripts/run-vitest.mjs src/agents/sessions-spawn-hooks.test.ts src/agents/subagent-spawn.thread-binding.test.ts src/agents/subagent-spawn.workspace.test.ts src/agents/subagent-spawn.mode-session-diagnostics.test.ts - node scripts/run-tsgo.mjs -p tsconfig.core.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/core.tsbuildinfo - git diff --check - .agents/skills/autoreview/scripts/autoreview --mode local - CI run 26693808952 green, including checks-node-agentic-agents-core and checks-node-agentic-plugin-sdk
This commit is contained in:
committed by
GitHub
parent
4ac90a5b48
commit
3fc0df953c
@@ -141,7 +141,9 @@ observation-only.
|
||||
|
||||
**Subagents**
|
||||
|
||||
- `subagent_spawning` / `subagent_delivery_target` / `subagent_spawned` / `subagent_ended` - coordinate subagent routing and completion delivery
|
||||
- `subagent_spawned` / `subagent_ended` - observe subagent launch and completion.
|
||||
- `subagent_delivery_target` - compatibility hook for completion delivery when no core session binding can project a route.
|
||||
- `subagent_spawning` - deprecated compatibility hook. Core now prepares `thread: true` subagent bindings through channel session-binding adapters before `subagent_spawned` fires.
|
||||
- `subagent_spawned` includes `resolvedModel` and `resolvedProvider` when OpenClaw has resolved the child session's native model before launch.
|
||||
|
||||
**Lifecycle**
|
||||
@@ -464,6 +466,10 @@ before the next major release:
|
||||
- **`before_agent_start`** remains for compatibility. New plugins should use
|
||||
`before_model_resolve` and `before_prompt_build` instead of the combined
|
||||
phase.
|
||||
- **`subagent_spawning`** remains for compatibility with older plugins, but
|
||||
new plugins should not return thread routing from it. Core prepares
|
||||
`thread: true` subagent bindings through channel session-binding adapters
|
||||
before `subagent_spawned` fires.
|
||||
- **`deactivate`** remains as a deprecated cleanup compatibility alias until
|
||||
after 2026-08-16. New plugins should use `gateway_stop`.
|
||||
- **`onResolution` in `before_tool_call`** now uses the typed
|
||||
|
||||
@@ -792,6 +792,35 @@ canonical replacement.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="subagent_spawning hook → core thread binding">
|
||||
**Old**: `api.on("subagent_spawning", handler)` returning
|
||||
`threadBindingReady` or `deliveryOrigin`.
|
||||
|
||||
**New**: let core prepare `thread: true` subagent bindings through the
|
||||
channel session-binding adapter. Use `api.on("subagent_spawned", handler)`
|
||||
only for post-launch observation.
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
api.on("subagent_spawning", async () => ({
|
||||
status: "ok",
|
||||
threadBindingReady: true,
|
||||
deliveryOrigin: { channel: "discord", to: "channel:123", threadId: "456" },
|
||||
}));
|
||||
|
||||
// After
|
||||
api.on("subagent_spawned", async (event) => {
|
||||
await observeSubagentLaunch(event);
|
||||
});
|
||||
```
|
||||
|
||||
`subagent_spawning`, `PluginHookSubagentSpawningEvent`,
|
||||
`PluginHookSubagentSpawningResult`, and
|
||||
`SubagentLifecycleHookRunner.runSubagentSpawning(...)` remain only as
|
||||
deprecated compatibility surfaces while external plugins migrate.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Provider discovery types → provider catalog types">
|
||||
Four discovery type aliases are now thin wrappers over the
|
||||
catalog-era types:
|
||||
|
||||
@@ -291,14 +291,12 @@ same sub-agent session.
|
||||
|
||||
### Thread supporting channels
|
||||
|
||||
**Discord** is currently the only supported channel. It supports
|
||||
persistent thread-bound subagent sessions (`sessions_spawn` with
|
||||
`thread: true`), manual thread controls (`/focus`, `/unfocus`, `/agents`,
|
||||
`/session idle`, `/session max-age`), and adapter keys
|
||||
`channels.discord.threadBindings.enabled`,
|
||||
`channels.discord.threadBindings.idleHours`,
|
||||
`channels.discord.threadBindings.maxAgeHours`, and
|
||||
`channels.discord.threadBindings.spawnSessions`.
|
||||
Any channel with a session-binding adapter can support persistent
|
||||
thread-bound subagent sessions (`sessions_spawn` with `thread: true`).
|
||||
Bundled adapters currently include Discord threads, Matrix threads,
|
||||
Telegram forum topics, and current-conversation bindings for Feishu.
|
||||
Use the per-channel `threadBindings` config keys for enablement,
|
||||
timeouts, and `spawnSessions`.
|
||||
|
||||
### Quick flow
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { handleDiscordSubagentSpawning } from "./subagent-hooks.js";
|
||||
|
||||
type ThreadBindingRecord = {
|
||||
accountId: string;
|
||||
@@ -85,7 +86,10 @@ function registerHandlersForTest(
|
||||
) {
|
||||
return registerHookHandlersForTest<OpenClawPluginApi>({
|
||||
config,
|
||||
register: registerDiscordSubagentHooks,
|
||||
register: (api) => {
|
||||
registerDiscordSubagentHooks(api);
|
||||
api.on("subagent_spawning", (event) => handleDiscordSubagentSpawning(api, event));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,6 @@ function loadDiscordSubagentHooksModule() {
|
||||
// Subagent hooks live behind a dedicated barrel so the bundled entry can
|
||||
// register one stable hook wiring path while keeping the handler module lazy.
|
||||
export function registerDiscordSubagentHooks(api: OpenClawPluginApi): void {
|
||||
api.on("subagent_spawning", async (event) => {
|
||||
const { handleDiscordSubagentSpawning } = await loadDiscordSubagentHooksModule();
|
||||
return await handleDiscordSubagentSpawning(api, event);
|
||||
});
|
||||
api.on("subagent_ended", async (event) => {
|
||||
const { handleDiscordSubagentEnded } = await loadDiscordSubagentHooksModule();
|
||||
handleDiscordSubagentEnded(event);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { ClawdbotConfig, OpenClawPluginApi } from "../runtime-api.js";
|
||||
import { registerFeishuSubagentHooks } from "../subagent-hooks-api.js";
|
||||
import { handleFeishuSubagentSpawning } from "./subagent-hooks.js";
|
||||
import {
|
||||
createFeishuThreadBindingManager,
|
||||
testing as threadBindingTesting,
|
||||
@@ -18,7 +19,10 @@ const baseConfig: ClawdbotConfig = {
|
||||
function registerHandlersForTest(config: Record<string, unknown> = baseConfig) {
|
||||
return registerHookHandlersForTest<OpenClawPluginApi>({
|
||||
config,
|
||||
register: registerFeishuSubagentHooks,
|
||||
register: (api) => {
|
||||
registerFeishuSubagentHooks(api);
|
||||
api.on("subagent_spawning", (event, ctx) => handleFeishuSubagentSpawning(event, ctx));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,6 @@ function loadFeishuSubagentHooksModule() {
|
||||
}
|
||||
|
||||
export function registerFeishuSubagentHooks(api: OpenClawPluginApi): void {
|
||||
api.on("subagent_spawning", async (event, ctx) => {
|
||||
const { handleFeishuSubagentSpawning } = await loadFeishuSubagentHooksModule();
|
||||
return await handleFeishuSubagentSpawning(event, ctx);
|
||||
});
|
||||
api.on("subagent_delivery_target", async (event) => {
|
||||
const { handleFeishuSubagentDeliveryTarget } = await loadFeishuSubagentHooksModule();
|
||||
return handleFeishuSubagentDeliveryTarget(event);
|
||||
|
||||
@@ -135,17 +135,14 @@ describe("matrix plugin", () => {
|
||||
|
||||
expect(runtimeMocks.ensureMatrixCryptoRuntime).not.toHaveBeenCalled();
|
||||
expect(on.mock.calls.map(([hookName]) => hookName)).toEqual([
|
||||
"subagent_spawning",
|
||||
"subagent_ended",
|
||||
"subagent_delivery_target",
|
||||
]);
|
||||
const handlers = Object.fromEntries(on.mock.calls);
|
||||
await expect(handlers.subagent_spawning({ id: "spawn" })).resolves.toBe("spawned");
|
||||
await expect(handlers.subagent_ended({ id: "ended" })).resolves.toBeUndefined();
|
||||
await expect(handlers.subagent_delivery_target({ id: "target" })).resolves.toBe(
|
||||
"delivery-target",
|
||||
);
|
||||
expect(runtimeMocks.handleMatrixSubagentSpawning).toHaveBeenCalledWith(api, { id: "spawn" });
|
||||
expect(runtimeMocks.handleMatrixSubagentEnded).toHaveBeenCalledWith({ id: "ended" });
|
||||
expect(runtimeMocks.handleMatrixSubagentDeliveryTarget).toHaveBeenCalledWith({ id: "target" });
|
||||
});
|
||||
|
||||
@@ -55,7 +55,10 @@ const fakeApi = { config: {} } as never;
|
||||
function registerHandlersForTest(config: Record<string, unknown> = {}) {
|
||||
return registerHookHandlersForTest<MatrixEntryPluginApi>({
|
||||
config,
|
||||
register: registerMatrixSubagentHooks,
|
||||
register: (api) => {
|
||||
registerMatrixSubagentHooks(api);
|
||||
api.on("subagent_spawning", (event) => handleMatrixSubagentSpawning(api, event));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,6 @@ function loadMatrixSubagentHooksModule() {
|
||||
}
|
||||
|
||||
export function registerMatrixSubagentHooks(api: OpenClawPluginApi): void {
|
||||
api.on("subagent_spawning", async (event) => {
|
||||
const { handleMatrixSubagentSpawning } = await loadMatrixSubagentHooksModule();
|
||||
return await handleMatrixSubagentSpawning(api, event);
|
||||
});
|
||||
api.on("subagent_ended", async (event) => {
|
||||
const { handleMatrixSubagentEnded } = await loadMatrixSubagentHooksModule();
|
||||
await handleMatrixSubagentEnded(event);
|
||||
|
||||
@@ -29,18 +29,7 @@ const fastModeEnv = vi.hoisted(() => {
|
||||
});
|
||||
|
||||
const hookRunnerMocks = vi.hoisted(() => ({
|
||||
runSubagentSpawning: vi.fn(async (event: unknown) => {
|
||||
const input = event as {
|
||||
threadRequested?: boolean;
|
||||
};
|
||||
if (!input.threadRequested) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
status: "ok" as const,
|
||||
threadBindingReady: true,
|
||||
};
|
||||
}),
|
||||
runSubagentSpawning: vi.fn(async () => undefined),
|
||||
runSubagentSpawned: vi.fn(async () => {}),
|
||||
runSubagentEnded: vi.fn(async () => {}),
|
||||
}));
|
||||
@@ -198,9 +187,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
|
||||
hookRunnerMocks.runSubagentEnded.mockClear();
|
||||
setSessionsSpawnHookRunnerOverride({
|
||||
hasHooks: (hookName: string) =>
|
||||
hookName === "subagent_spawning" ||
|
||||
hookName === "subagent_spawned" ||
|
||||
hookName === "subagent_ended",
|
||||
hookName === "subagent_spawned" || hookName === "subagent_ended",
|
||||
runSubagentSpawning: hookRunnerMocks.runSubagentSpawning,
|
||||
runSubagentSpawned: hookRunnerMocks.runSubagentSpawned,
|
||||
runSubagentEnded: hookRunnerMocks.runSubagentEnded,
|
||||
|
||||
@@ -5,6 +5,18 @@ import {
|
||||
} from "./subagent-spawn.test-helpers.js";
|
||||
|
||||
type GatewayRequest = { method?: string; params?: Record<string, unknown> };
|
||||
type TestBindingRequest = {
|
||||
targetSessionKey: string;
|
||||
targetKind?: string;
|
||||
conversation: {
|
||||
channel: string;
|
||||
accountId?: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
};
|
||||
placement: "current" | "child";
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
callGatewayMock: vi.fn(),
|
||||
@@ -14,31 +26,33 @@ const hoisted = vi.hoisted(() => ({
|
||||
|
||||
const hookRunnerMocks = vi.hoisted(() => ({
|
||||
hasSubagentEndedHook: true,
|
||||
runSubagentSpawning: vi.fn(async (event: unknown) => {
|
||||
const input = event as {
|
||||
threadRequested?: boolean;
|
||||
requester?: { channel?: string };
|
||||
};
|
||||
if (!input.threadRequested) {
|
||||
return undefined;
|
||||
}
|
||||
const channel = input.requester?.channel?.trim().toLowerCase();
|
||||
if (channel !== "discord") {
|
||||
const channelLabel = input.requester?.channel?.trim() || "unknown";
|
||||
return {
|
||||
status: "error" as const,
|
||||
error: `thread=true is not supported for channel "${channelLabel}". Only Discord thread-bound subagent sessions are supported right now.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "ok" as const,
|
||||
threadBindingReady: true,
|
||||
};
|
||||
}),
|
||||
runSubagentSpawned: vi.fn(async () => {}),
|
||||
runSubagentEnded: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const bindingMocks = vi.hoisted(() => ({
|
||||
getCapabilities: vi.fn(() => ({
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
placements: ["child"] as Array<"current" | "child">,
|
||||
})),
|
||||
bind: vi.fn(async (request: TestBindingRequest) => {
|
||||
const conversation = request.conversation;
|
||||
return {
|
||||
targetSessionKey: request.targetSessionKey,
|
||||
targetKind: request.targetKind,
|
||||
status: "active",
|
||||
conversation: {
|
||||
channel: conversation.channel,
|
||||
accountId: conversation.accountId ?? "default",
|
||||
conversationId: "456",
|
||||
parentConversationId: conversation.conversationId,
|
||||
},
|
||||
};
|
||||
}),
|
||||
listBySession: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
let resetSubagentRegistryForTests: typeof import("./subagent-registry.js").resetSubagentRegistryForTests;
|
||||
let spawnSubagentDirect: typeof import("./subagent-spawn.js").spawnSubagentDirect;
|
||||
|
||||
@@ -186,13 +200,12 @@ beforeAll(async () => {
|
||||
updateSessionStoreMock: hoisted.updateSessionStoreMock,
|
||||
hookRunner: {
|
||||
hasHooks: (hookName: string) =>
|
||||
hookName === "subagent_spawning" ||
|
||||
hookName === "subagent_spawned" ||
|
||||
(hookName === "subagent_ended" && hookRunnerMocks.hasSubagentEndedHook),
|
||||
runSubagentSpawning: hookRunnerMocks.runSubagentSpawning,
|
||||
runSubagentSpawned: hookRunnerMocks.runSubagentSpawned,
|
||||
runSubagentEnded: hookRunnerMocks.runSubagentEnded,
|
||||
},
|
||||
getSessionBindingService: () => bindingMocks,
|
||||
resetModules: false,
|
||||
sessionStorePath: "/tmp/subagent-spawn-hooks-session-store.json",
|
||||
}));
|
||||
@@ -204,9 +217,30 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
|
||||
hoisted.callGatewayMock.mockReset();
|
||||
hoisted.updateSessionStoreMock.mockReset();
|
||||
hookRunnerMocks.hasSubagentEndedHook = true;
|
||||
hookRunnerMocks.runSubagentSpawning.mockClear();
|
||||
hookRunnerMocks.runSubagentSpawned.mockClear();
|
||||
hookRunnerMocks.runSubagentEnded.mockClear();
|
||||
bindingMocks.getCapabilities.mockClear();
|
||||
bindingMocks.getCapabilities.mockReturnValue({
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
placements: ["child"],
|
||||
});
|
||||
bindingMocks.bind.mockClear();
|
||||
bindingMocks.bind.mockImplementation(async (request: TestBindingRequest) => {
|
||||
const conversation = request.conversation;
|
||||
return {
|
||||
targetSessionKey: request.targetSessionKey,
|
||||
targetKind: request.targetKind,
|
||||
status: "active",
|
||||
conversation: {
|
||||
channel: conversation.channel,
|
||||
accountId: conversation.accountId ?? "default",
|
||||
conversationId: "456",
|
||||
parentConversationId: conversation.conversationId,
|
||||
},
|
||||
};
|
||||
});
|
||||
bindingMocks.listBySession.mockClear();
|
||||
setConfig({
|
||||
session: {
|
||||
mainKey: "main",
|
||||
@@ -245,7 +279,7 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
|
||||
resetSubagentRegistryForTests();
|
||||
});
|
||||
|
||||
it("runs subagent_spawning and emits subagent_spawned with requester metadata", async () => {
|
||||
it("binds the subagent thread in core and emits subagent_spawned with requester metadata", async () => {
|
||||
const result = await spawn({
|
||||
label: "research",
|
||||
model: "openai-codex/gpt-5.4",
|
||||
@@ -267,38 +301,34 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
|
||||
},
|
||||
"spawn result",
|
||||
);
|
||||
expect(hookRunnerMocks.runSubagentSpawning).toHaveBeenCalledTimes(1);
|
||||
const [spawningEvent, spawningContext] = (hookRunnerMocks.runSubagentSpawning.mock.calls.at(
|
||||
0,
|
||||
) ?? []) as unknown as [Record<string, unknown>, Record<string, unknown>];
|
||||
const spawningChildSessionKey = expectSubagentSessionKey(
|
||||
spawningEvent?.childSessionKey,
|
||||
"spawning event child session key",
|
||||
expect(bindingMocks.getCapabilities).toHaveBeenCalledWith({
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
});
|
||||
expect(bindingMocks.bind).toHaveBeenCalledTimes(1);
|
||||
const bindingRequest = requireRecord(bindingMocks.bind.mock.calls[0]?.[0], "binding request");
|
||||
const bindingChildSessionKey = expectSubagentSessionKey(
|
||||
bindingRequest.targetSessionKey,
|
||||
"binding target session key",
|
||||
);
|
||||
expectFields(
|
||||
spawningEvent,
|
||||
bindingRequest,
|
||||
{
|
||||
childSessionKey: spawningChildSessionKey,
|
||||
agentId: "main",
|
||||
label: "research",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
to: "channel:123",
|
||||
threadId: 456,
|
||||
},
|
||||
threadRequested: true,
|
||||
targetSessionKey: bindingChildSessionKey,
|
||||
targetKind: "subagent",
|
||||
placement: "child",
|
||||
},
|
||||
"spawning event",
|
||||
"binding request",
|
||||
);
|
||||
expectFields(
|
||||
spawningContext,
|
||||
bindingRequest.conversation,
|
||||
{
|
||||
childSessionKey: spawningChildSessionKey,
|
||||
requesterSessionKey: "main",
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
conversationId: "456",
|
||||
parentConversationId: "123",
|
||||
},
|
||||
"spawning context",
|
||||
"binding conversation",
|
||||
);
|
||||
|
||||
expect(hookRunnerMocks.runSubagentSpawned).toHaveBeenCalledTimes(1);
|
||||
@@ -345,7 +375,6 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
|
||||
});
|
||||
|
||||
expectFields(result, { status: "accepted", runId: "run-1" }, "spawn result");
|
||||
expect(hookRunnerMocks.runSubagentSpawning).not.toHaveBeenCalled();
|
||||
expect(hookRunnerMocks.runSubagentSpawned).toHaveBeenCalledTimes(1);
|
||||
const event = getSpawnedEventCall();
|
||||
expectFields(
|
||||
@@ -376,7 +405,7 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
|
||||
});
|
||||
|
||||
expectFields(result, { status: "accepted", runId: "run-1", mode: "run" }, "spawn result");
|
||||
expect(hookRunnerMocks.runSubagentSpawning).toHaveBeenCalledTimes(1);
|
||||
expect(bindingMocks.bind).toHaveBeenCalledTimes(1);
|
||||
const event = getSpawnedEventCall();
|
||||
expectFields(
|
||||
event,
|
||||
@@ -389,10 +418,9 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
|
||||
});
|
||||
|
||||
it("returns error when thread binding cannot be created", async () => {
|
||||
hookRunnerMocks.runSubagentSpawning.mockResolvedValueOnce({
|
||||
status: "error",
|
||||
error: "Unable to create or bind a Discord thread for this subagent session.",
|
||||
});
|
||||
bindingMocks.bind.mockRejectedValueOnce(
|
||||
new Error("Unable to create or bind a Discord thread for this subagent session."),
|
||||
);
|
||||
const result = await spawn({
|
||||
toolCallId: "call4",
|
||||
runTimeoutSeconds: 1,
|
||||
@@ -406,10 +434,17 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
|
||||
expectThreadBindFailureCleanup(result, /thread/i);
|
||||
});
|
||||
|
||||
it("returns error when thread binding is not marked ready", async () => {
|
||||
hookRunnerMocks.runSubagentSpawning.mockResolvedValueOnce({
|
||||
status: "ok",
|
||||
threadBindingReady: false,
|
||||
it("returns error when thread binding does not produce a conversation", async () => {
|
||||
bindingMocks.bind.mockResolvedValueOnce({
|
||||
targetSessionKey: "agent:main:subagent:test",
|
||||
targetKind: "subagent",
|
||||
status: "active",
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
conversationId: "",
|
||||
parentConversationId: "123",
|
||||
},
|
||||
});
|
||||
const result = await spawn({
|
||||
toolCallId: "call4b",
|
||||
@@ -431,12 +466,16 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
|
||||
});
|
||||
|
||||
expectErrorResultMessage(result, /requires thread=true/i);
|
||||
expect(hookRunnerMocks.runSubagentSpawning).not.toHaveBeenCalled();
|
||||
expect(hookRunnerMocks.runSubagentSpawned).not.toHaveBeenCalled();
|
||||
expect(hoisted.callGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects thread=true on channels without thread support", async () => {
|
||||
bindingMocks.getCapabilities.mockReturnValueOnce({
|
||||
adapterAvailable: false,
|
||||
bindSupported: false,
|
||||
placements: [],
|
||||
});
|
||||
const result = await spawn({
|
||||
thread: true,
|
||||
mode: "session",
|
||||
@@ -445,8 +484,7 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
|
||||
context: "isolated",
|
||||
});
|
||||
|
||||
expectErrorResultMessage(result, /only discord/i);
|
||||
expect(hookRunnerMocks.runSubagentSpawning).toHaveBeenCalledTimes(1);
|
||||
expectErrorResultMessage(result, /only available on channels that expose thread bindings/i);
|
||||
expect(hookRunnerMocks.runSubagentSpawned).not.toHaveBeenCalled();
|
||||
expectSessionsDeleteWithoutAgentStart();
|
||||
});
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
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;
|
||||
@@ -66,7 +63,7 @@ describe('spawnSubagentDirect mode="session" diagnostics (#67400)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('spawnSubagentDirect mode="session" with registered thread hooks (#67400)', () => {
|
||||
describe('spawnSubagentDirect mode="session" with thread binding-capable channels (#67400)', () => {
|
||||
const callGatewayMock = vi.fn();
|
||||
let spawnSubagentDirect: typeof import("./subagent-spawn.js").spawnSubagentDirect;
|
||||
let resetSubagentRegistryForTests: typeof import("./subagent-registry.js").resetSubagentRegistryForTests;
|
||||
@@ -77,19 +74,6 @@ describe('spawnSubagentDirect mode="session" with registered thread hooks (#6740
|
||||
callGatewayMock,
|
||||
getRuntimeConfig: () => 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();
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ export { ensureContextEnginesInitialized } from "../context-engine/init.js";
|
||||
export { resolveContextEngine } from "../context-engine/registry.js";
|
||||
export { callGateway } from "../gateway/call.js";
|
||||
export { ADMIN_SCOPE, isAdminOnlyMethod } from "../gateway/method-scopes.js";
|
||||
export { getSessionBindingService } from "../infra/outbound/session-binding-service.js";
|
||||
export {
|
||||
pruneLegacyStoreKeys,
|
||||
resolveGatewaySessionStoreTarget,
|
||||
|
||||
@@ -9,8 +9,13 @@ type MockImplementationTarget = {
|
||||
};
|
||||
type SessionStore = Record<string, Record<string, unknown>>;
|
||||
type SessionStoreMutator = (store: SessionStore) => unknown;
|
||||
type HookRunner = Pick<SubagentLifecycleHookRunner, "hasHooks" | "runSubagentSpawning"> &
|
||||
Partial<Pick<SubagentLifecycleHookRunner, "runSubagentSpawned" | "runSubagentEnded">>;
|
||||
type HookRunner = Pick<SubagentLifecycleHookRunner, "hasHooks"> &
|
||||
Partial<
|
||||
Pick<
|
||||
SubagentLifecycleHookRunner,
|
||||
"runSubagentSpawning" | "runSubagentSpawned" | "runSubagentEnded"
|
||||
>
|
||||
>;
|
||||
type SubagentSpawnModuleForTest = Awaited<typeof import("./subagent-spawn.js")> & {
|
||||
resetSubagentRegistryForTests: MockFn;
|
||||
};
|
||||
@@ -136,6 +141,33 @@ export async function loadSubagentSpawnModuleForTest(params: {
|
||||
sessionKey?: string;
|
||||
}) => { sandboxed: boolean };
|
||||
getSessionBindingService?: () => {
|
||||
getCapabilities?: (params: { channel?: string; accountId?: string }) => {
|
||||
adapterAvailable: boolean;
|
||||
bindSupported: boolean;
|
||||
placements: Array<"current" | "child">;
|
||||
};
|
||||
bind?: (params: {
|
||||
targetSessionKey: string;
|
||||
targetKind?: string;
|
||||
conversation: {
|
||||
channel: string;
|
||||
accountId?: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
};
|
||||
placement: "current" | "child";
|
||||
metadata?: Record<string, unknown>;
|
||||
}) => Promise<{
|
||||
targetSessionKey: string;
|
||||
targetKind?: string;
|
||||
status?: string;
|
||||
conversation: {
|
||||
channel: string;
|
||||
accountId?: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
};
|
||||
}>;
|
||||
listBySession: (targetSessionKey: string) => Array<{
|
||||
status?: string;
|
||||
conversation: {
|
||||
@@ -224,7 +256,18 @@ export async function loadSubagentSpawnModuleForTest(params: {
|
||||
method === "sessions.patch" || method === "sessions.delete",
|
||||
pruneLegacyStoreKeys: (...args: unknown[]) => params.pruneLegacyStoreKeysMock?.(...args),
|
||||
getSessionBindingService:
|
||||
params.getSessionBindingService ?? (() => ({ listBySession: () => [] })),
|
||||
params.getSessionBindingService ??
|
||||
(() => ({
|
||||
getCapabilities: () => ({
|
||||
adapterAvailable: false,
|
||||
bindSupported: false,
|
||||
placements: [],
|
||||
}),
|
||||
bind: async () => {
|
||||
throw new Error("session binding adapter unavailable");
|
||||
},
|
||||
listBySession: () => [],
|
||||
})),
|
||||
resolveConversationDeliveryTarget:
|
||||
params.resolveConversationDeliveryTarget ??
|
||||
((targetParams: { channel?: string; conversationId?: string | number }) => ({
|
||||
|
||||
@@ -14,7 +14,6 @@ const hoisted = vi.hoisted(() => ({
|
||||
emitSessionLifecycleEventMock: vi.fn(),
|
||||
hookRunner: {
|
||||
hasHooks: vi.fn(),
|
||||
runSubagentSpawning: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -44,6 +43,10 @@ function firstRegisteredSubagentRun(): {
|
||||
|
||||
describe("spawnSubagentDirect thread binding delivery", () => {
|
||||
type SpawnModule = Awaited<ReturnType<typeof loadSubagentSpawnModuleForTest>>;
|
||||
type SetActivePluginRegistry = typeof import("../plugins/runtime.js").setActivePluginRegistry;
|
||||
type CreateChannelTestPluginBase =
|
||||
typeof import("../test-utils/channel-plugins.js").createChannelTestPluginBase;
|
||||
type CreateTestRegistry = typeof import("../test-utils/channel-plugins.js").createTestRegistry;
|
||||
type SessionBindingService = NonNullable<
|
||||
Parameters<typeof loadSubagentSpawnModuleForTest>[0]["getSessionBindingService"]
|
||||
>;
|
||||
@@ -52,6 +55,9 @@ describe("spawnSubagentDirect thread binding delivery", () => {
|
||||
>;
|
||||
|
||||
let spawnSubagentDirect: SpawnModule["spawnSubagentDirect"];
|
||||
let setActivePluginRegistryForTest: SetActivePluginRegistry;
|
||||
let createChannelTestPluginBaseForTest: CreateChannelTestPluginBase;
|
||||
let createTestRegistryForTest: CreateTestRegistry;
|
||||
let currentConfig: Record<string, unknown>;
|
||||
let currentSessionBindingService: ReturnType<SessionBindingService>;
|
||||
let currentDeliveryTargetResolver: DeliveryTargetResolver;
|
||||
@@ -69,9 +75,47 @@ describe("spawnSubagentDirect thread binding delivery", () => {
|
||||
getSessionBindingService: () => currentSessionBindingService,
|
||||
resolveConversationDeliveryTarget: (params) => currentDeliveryTargetResolver(params),
|
||||
}));
|
||||
({ setActivePluginRegistry: setActivePluginRegistryForTest } =
|
||||
await import("../plugins/runtime.js"));
|
||||
({
|
||||
createChannelTestPluginBase: createChannelTestPluginBaseForTest,
|
||||
createTestRegistry: createTestRegistryForTest,
|
||||
} = await import("../test-utils/channel-plugins.js"));
|
||||
});
|
||||
|
||||
function installChannelRouteProjectionPluginsForTest() {
|
||||
const matrixBase = createChannelTestPluginBaseForTest({ id: "matrix", label: "Matrix" });
|
||||
setActivePluginRegistryForTest(
|
||||
createTestRegistryForTest([
|
||||
{
|
||||
pluginId: "matrix",
|
||||
source: "test",
|
||||
plugin: {
|
||||
...matrixBase,
|
||||
messaging: {
|
||||
resolveDeliveryTarget: ({
|
||||
conversationId,
|
||||
parentConversationId,
|
||||
}: {
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
}) => {
|
||||
const parent = parentConversationId?.trim();
|
||||
const child = conversationId.trim();
|
||||
if (parent && parent !== child) {
|
||||
return { to: `room:${parent}`, threadId: child };
|
||||
}
|
||||
return { to: `room:${child}` };
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
installChannelRouteProjectionPluginsForTest();
|
||||
currentConfig = createSubagentSpawnTestConfig(os.tmpdir(), {
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -85,7 +129,24 @@ describe("spawnSubagentDirect thread binding delivery", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
currentSessionBindingService = { listBySession: () => [] };
|
||||
currentSessionBindingService = {
|
||||
getCapabilities: () => ({
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
placements: ["child"],
|
||||
}),
|
||||
bind: async (request) => ({
|
||||
targetSessionKey: request.targetSessionKey,
|
||||
targetKind: request.targetKind,
|
||||
status: "active",
|
||||
conversation: {
|
||||
channel: request.conversation.channel,
|
||||
accountId: request.conversation.accountId,
|
||||
conversationId: request.conversation.conversationId,
|
||||
},
|
||||
}),
|
||||
listBySession: () => [],
|
||||
};
|
||||
currentDeliveryTargetResolver = (params) => ({
|
||||
to: params.conversationId ? `channel:${String(params.conversationId)}` : undefined,
|
||||
});
|
||||
@@ -94,40 +155,35 @@ describe("spawnSubagentDirect thread binding delivery", () => {
|
||||
hoisted.registerSubagentRunMock.mockReset();
|
||||
hoisted.emitSessionLifecycleEventMock.mockReset();
|
||||
hoisted.hookRunner.hasHooks.mockReset();
|
||||
hoisted.hookRunner.runSubagentSpawning.mockReset();
|
||||
installAcceptedSubagentGatewayMock(hoisted.callGatewayMock);
|
||||
installSessionStoreCaptureMock(hoisted.updateSessionStoreMock);
|
||||
});
|
||||
|
||||
it("passes the target agent's bound account to thread binding hooks", async () => {
|
||||
it("passes the target agent's bound account to core thread binding", async () => {
|
||||
const boundRoom = "!room:example.org";
|
||||
let hookRequester:
|
||||
| { channel?: string; accountId?: string; to?: string; threadId?: string | number }
|
||||
| undefined;
|
||||
hoisted.hookRunner.hasHooks.mockImplementation(
|
||||
(hookName?: string) => hookName === "subagent_spawning",
|
||||
);
|
||||
hoisted.hookRunner.runSubagentSpawning.mockImplementation(async (event: unknown) => {
|
||||
hookRequester = (
|
||||
event as {
|
||||
requester?: {
|
||||
channel?: string;
|
||||
accountId?: string;
|
||||
to?: string;
|
||||
threadId?: string | number;
|
||||
};
|
||||
}
|
||||
).requester;
|
||||
return {
|
||||
status: "ok",
|
||||
threadBindingReady: true,
|
||||
deliveryOrigin: {
|
||||
channel: "matrix",
|
||||
to: `room:${boundRoom}`,
|
||||
threadId: "$thread-root",
|
||||
},
|
||||
};
|
||||
});
|
||||
const bindCalls: Array<Record<string, unknown>> = [];
|
||||
currentSessionBindingService = {
|
||||
getCapabilities: () => ({
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
placements: ["child"],
|
||||
}),
|
||||
bind: async (request) => {
|
||||
bindCalls.push(request as unknown as Record<string, unknown>);
|
||||
return {
|
||||
targetSessionKey: request.targetSessionKey,
|
||||
targetKind: request.targetKind,
|
||||
status: "active",
|
||||
conversation: {
|
||||
channel: request.conversation.channel,
|
||||
accountId: request.conversation.accountId,
|
||||
conversationId: "$thread-root",
|
||||
parentConversationId: request.conversation.conversationId,
|
||||
},
|
||||
};
|
||||
},
|
||||
listBySession: () => [],
|
||||
};
|
||||
currentConfig = createSubagentSpawnTestConfig(os.tmpdir(), {
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -174,9 +230,13 @@ describe("spawnSubagentDirect thread binding delivery", () => {
|
||||
);
|
||||
|
||||
expect(result.status).toBe("accepted");
|
||||
expect(hookRequester?.channel).toBe("matrix");
|
||||
expect(hookRequester?.accountId).toBe("bot-alpha");
|
||||
expect(hookRequester?.to).toBe(`room:${boundRoom}`);
|
||||
expect(bindCalls).toHaveLength(1);
|
||||
const bindingConversation = bindCalls[0]?.conversation as
|
||||
| { channel?: string; accountId?: string; conversationId?: string }
|
||||
| undefined;
|
||||
expect(bindingConversation?.channel).toBe("matrix");
|
||||
expect(bindingConversation?.accountId).toBe("bot-alpha");
|
||||
expect(bindingConversation?.conversationId).toBe(boundRoom);
|
||||
const agentCall = hoisted.callGatewayMock.mock.calls.find(
|
||||
([call]) => (call as { method?: string }).method === "agent",
|
||||
)?.[0] as { params?: Record<string, unknown> } | undefined;
|
||||
@@ -194,20 +254,6 @@ describe("spawnSubagentDirect thread binding delivery", () => {
|
||||
});
|
||||
|
||||
it("uses controller ownership for thread binding while completion routes to owner", async () => {
|
||||
let hookRequesterSessionKey: string | undefined;
|
||||
hoisted.hookRunner.hasHooks.mockImplementation(
|
||||
(hookName?: string) => hookName === "subagent_spawning",
|
||||
);
|
||||
hoisted.hookRunner.runSubagentSpawning.mockImplementation(
|
||||
async (eventValue: unknown, ctx?: { requesterSessionKey?: string }) => {
|
||||
hookRequesterSessionKey = ctx?.requesterSessionKey;
|
||||
return {
|
||||
status: "ok",
|
||||
threadBindingReady: true,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const result = await spawnSubagentDirect(
|
||||
{
|
||||
task: "reply with a marker",
|
||||
@@ -225,22 +271,29 @@ describe("spawnSubagentDirect thread binding delivery", () => {
|
||||
);
|
||||
|
||||
expect(result.status).toBe("accepted");
|
||||
expect(hookRequesterSessionKey).toBe("agent:main:telegram:default:direct:456");
|
||||
const registeredRun = firstRegisteredSubagentRun();
|
||||
expect(registeredRun.controllerSessionKey).toBe("agent:main:telegram:default:direct:456");
|
||||
expect(registeredRun.requesterSessionKey).toBe("agent:main:main");
|
||||
expect(registeredRun.requesterDisplayKey).toBe("agent:main:main");
|
||||
});
|
||||
|
||||
it("keeps completion announcements when only a generic binding is available", async () => {
|
||||
hoisted.hookRunner.hasHooks.mockImplementation(
|
||||
(hookName?: string) => hookName === "subagent_spawning",
|
||||
);
|
||||
hoisted.hookRunner.runSubagentSpawning.mockResolvedValue({
|
||||
status: "ok",
|
||||
threadBindingReady: true,
|
||||
});
|
||||
it("uses core binding delivery when only a generic route projection is available", async () => {
|
||||
currentSessionBindingService = {
|
||||
getCapabilities: () => ({
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
placements: ["child"],
|
||||
}),
|
||||
bind: async (request) => ({
|
||||
targetSessionKey: request.targetSessionKey,
|
||||
targetKind: request.targetKind,
|
||||
status: "active",
|
||||
conversation: {
|
||||
channel: "collabchat",
|
||||
accountId: "work",
|
||||
conversationId: "collab_dm_1",
|
||||
},
|
||||
}),
|
||||
listBySession: () => [
|
||||
{
|
||||
status: "active",
|
||||
@@ -275,12 +328,12 @@ describe("spawnSubagentDirect thread binding delivery", () => {
|
||||
const agentCall = hoisted.callGatewayMock.mock.calls.find(
|
||||
([call]) => (call as { method?: string }).method === "agent",
|
||||
)?.[0] as { params?: Record<string, unknown> } | undefined;
|
||||
expect(agentCall?.params?.channel).toBe("matrix");
|
||||
expect(agentCall?.params?.accountId).toBe("sut");
|
||||
expect(agentCall?.params?.to).toBe("room:!parent:example");
|
||||
expect(agentCall?.params?.deliver).toBe(false);
|
||||
expect(agentCall?.params?.channel).toBe("collabchat");
|
||||
expect(agentCall?.params?.accountId).toBe("work");
|
||||
expect(agentCall?.params?.to).toBe("channel:collab_dm_1");
|
||||
expect(agentCall?.params?.deliver).toBe(true);
|
||||
const registeredRun = firstRegisteredSubagentRun();
|
||||
expect(registeredRun?.expectsCompletionMessage).toBe(true);
|
||||
expect(registeredRun?.expectsCompletionMessage).toBe(false);
|
||||
expect(registeredRun?.requesterOrigin?.channel).toBe("matrix");
|
||||
expect(registeredRun?.requesterOrigin?.accountId).toBe("sut");
|
||||
expect(registeredRun?.requesterOrigin?.to).toBe("room:!parent:example");
|
||||
|
||||
+289
-36
@@ -2,7 +2,22 @@ import crypto from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { isAcpRuntimeSpawnAvailable } from "../acp/runtime/availability.js";
|
||||
import { resolveThreadBindingSpawnPolicy } from "../channels/thread-bindings-policy.js";
|
||||
import {
|
||||
resolveChannelDefaultBindingPlacement,
|
||||
resolveInboundConversationResolution,
|
||||
} from "../channels/conversation-resolution.js";
|
||||
import { routeFromBindingRecord, routeToDeliveryFields } from "../channels/route-projection.js";
|
||||
import {
|
||||
resolveThreadBindingIntroText,
|
||||
resolveThreadBindingThreadName,
|
||||
} from "../channels/thread-bindings-messages.js";
|
||||
import {
|
||||
formatThreadBindingDisabledError,
|
||||
formatThreadBindingSpawnDisabledError,
|
||||
resolveThreadBindingIdleTimeoutMsForChannel,
|
||||
resolveThreadBindingMaxAgeMsForChannel,
|
||||
resolveThreadBindingSpawnPolicy,
|
||||
} from "../channels/thread-bindings-policy.js";
|
||||
import type { SessionEntry } from "../config/sessions/types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { SubagentSpawnPreparation } from "../context-engine/types.js";
|
||||
@@ -11,7 +26,10 @@ import { listRegisteredPluginAgentPromptGuidance } from "../plugins/command-regi
|
||||
import type { SubagentLifecycleHookRunner } from "../plugins/hooks.js";
|
||||
import { isValidAgentId, normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js";
|
||||
import { finiteSecondsToTimerSafeMilliseconds } from "../shared/number-coercion.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import {
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
} from "../shared/string-coerce.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import type { DeliveryContext } from "../utils/delivery-context.types.js";
|
||||
import { listAgentIds, resolveAgentDir } from "./agent-scope-config.js";
|
||||
@@ -60,6 +78,7 @@ import {
|
||||
emitSessionLifecycleEvent,
|
||||
forkSessionFromParent,
|
||||
getGlobalHookRunner,
|
||||
getSessionBindingService,
|
||||
getRuntimeConfig,
|
||||
mergeSessionEntry,
|
||||
mergeDeliveryContext,
|
||||
@@ -642,8 +661,223 @@ function buildThreadBindingUnavailableError(mode: SpawnSubagentMode): string {
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureThreadBindingForSubagentSpawn(params: {
|
||||
hookRunner: SubagentLifecycleHookRunner | null;
|
||||
type PreparedSubagentThreadBinding = {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
placement: "current" | "child";
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
};
|
||||
|
||||
function resolvePlacementWithoutChannelPlugin(params: {
|
||||
capabilities: { placements: Array<"current" | "child"> };
|
||||
}): "current" | "child" {
|
||||
return params.capabilities.placements.includes("child") ? "child" : "current";
|
||||
}
|
||||
|
||||
function resolveSubagentSpawnChannelAccountId(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel?: string;
|
||||
accountId?: string;
|
||||
}): string | undefined {
|
||||
const channel = normalizeOptionalLowercaseString(params.channel);
|
||||
const explicitAccountId = normalizeOptionalString(params.accountId);
|
||||
if (explicitAccountId) {
|
||||
return explicitAccountId;
|
||||
}
|
||||
if (!channel) {
|
||||
return undefined;
|
||||
}
|
||||
const channels = params.cfg.channels as Record<string, { defaultAccount?: unknown } | undefined>;
|
||||
return normalizeOptionalString(channels?.[channel]?.defaultAccount) ?? "default";
|
||||
}
|
||||
|
||||
function resolveConversationRefForThreadBinding(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel?: string;
|
||||
accountId?: string;
|
||||
to?: string;
|
||||
threadId?: string | number;
|
||||
}): { conversationId: string; parentConversationId?: string } | null {
|
||||
const resolution = resolveInboundConversationResolution({
|
||||
cfg: params.cfg,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
to: params.to,
|
||||
threadId: params.threadId,
|
||||
isGroup: true,
|
||||
});
|
||||
return resolution?.canonical ?? null;
|
||||
}
|
||||
|
||||
function resolveRequesterBoundConversationRef(params: {
|
||||
requesterSessionKey?: string;
|
||||
channel: string;
|
||||
accountId: string;
|
||||
fallback?: { conversationId: string; parentConversationId?: string } | null;
|
||||
}): { conversationId: string; parentConversationId?: string } | null | undefined {
|
||||
const requesterSessionKey = normalizeOptionalString(params.requesterSessionKey);
|
||||
if (!requesterSessionKey) {
|
||||
return undefined;
|
||||
}
|
||||
const activeBindings = getSessionBindingService()
|
||||
.listBySession(requesterSessionKey)
|
||||
.filter(
|
||||
(record) =>
|
||||
record.status !== "ended" &&
|
||||
record.conversation.channel === params.channel &&
|
||||
(record.conversation.accountId ?? params.accountId) === params.accountId,
|
||||
);
|
||||
if (activeBindings.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (activeBindings.length === 1) {
|
||||
const conversation = activeBindings[0].conversation;
|
||||
return {
|
||||
conversationId: conversation.conversationId,
|
||||
...(conversation.parentConversationId
|
||||
? { parentConversationId: conversation.parentConversationId }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
if (params.fallback?.conversationId) {
|
||||
const matched = activeBindings.filter(
|
||||
(record) =>
|
||||
record.conversation.conversationId === params.fallback?.conversationId &&
|
||||
normalizeOptionalString(record.conversation.parentConversationId) ===
|
||||
normalizeOptionalString(params.fallback?.parentConversationId),
|
||||
);
|
||||
if (matched.length === 1) {
|
||||
const conversation = matched[0].conversation;
|
||||
return {
|
||||
conversationId: conversation.conversationId,
|
||||
...(conversation.parentConversationId
|
||||
? { parentConversationId: conversation.parentConversationId }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function prepareSubagentThreadBinding(params: {
|
||||
cfg: OpenClawConfig;
|
||||
mode: SpawnSubagentMode;
|
||||
requesterSessionKey?: string;
|
||||
requester: {
|
||||
channel?: string;
|
||||
accountId?: string;
|
||||
to?: string;
|
||||
threadId?: string | number;
|
||||
};
|
||||
}): { ok: true; binding: PreparedSubagentThreadBinding } | { ok: false; error: string } {
|
||||
const channel = normalizeOptionalLowercaseString(params.requester.channel);
|
||||
if (!channel) {
|
||||
return {
|
||||
ok: false,
|
||||
error: buildThreadBindingUnavailableError(params.mode),
|
||||
};
|
||||
}
|
||||
|
||||
const accountId = resolveSubagentSpawnChannelAccountId({
|
||||
cfg: params.cfg,
|
||||
channel,
|
||||
accountId: params.requester.accountId,
|
||||
});
|
||||
const policy = resolveThreadBindingSpawnPolicy({
|
||||
cfg: params.cfg,
|
||||
channel,
|
||||
accountId,
|
||||
kind: "subagent",
|
||||
});
|
||||
if (!policy.enabled) {
|
||||
return {
|
||||
ok: false,
|
||||
error: formatThreadBindingDisabledError({
|
||||
channel: policy.channel,
|
||||
accountId: policy.accountId,
|
||||
kind: "subagent",
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (!policy.spawnEnabled) {
|
||||
return {
|
||||
ok: false,
|
||||
error: formatThreadBindingSpawnDisabledError({
|
||||
channel: policy.channel,
|
||||
accountId: policy.accountId,
|
||||
kind: "subagent",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const bindingService = getSessionBindingService();
|
||||
const capabilities = bindingService.getCapabilities({
|
||||
channel: policy.channel,
|
||||
accountId: policy.accountId,
|
||||
});
|
||||
if (!capabilities.adapterAvailable) {
|
||||
return {
|
||||
ok: false,
|
||||
error: buildThreadBindingUnavailableError(params.mode),
|
||||
};
|
||||
}
|
||||
const pluginPlacement = resolveChannelDefaultBindingPlacement(policy.channel);
|
||||
const placementToUse =
|
||||
pluginPlacement ??
|
||||
resolvePlacementWithoutChannelPlugin({
|
||||
capabilities,
|
||||
});
|
||||
if (!capabilities.bindSupported || !capabilities.placements.includes(placementToUse)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Thread bindings do not support ${placementToUse} placement for ${policy.channel}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const fallbackConversationRef = resolveConversationRefForThreadBinding({
|
||||
cfg: params.cfg,
|
||||
channel: policy.channel,
|
||||
accountId: policy.accountId,
|
||||
to: params.requester.to,
|
||||
threadId: params.requester.threadId,
|
||||
});
|
||||
const requesterConversationRef = resolveRequesterBoundConversationRef({
|
||||
requesterSessionKey: params.requesterSessionKey,
|
||||
channel: policy.channel,
|
||||
accountId: policy.accountId,
|
||||
fallback: fallbackConversationRef,
|
||||
});
|
||||
if (requesterConversationRef === null) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Could not resolve a unique ${policy.channel} requester conversation for subagent thread spawn.`,
|
||||
};
|
||||
}
|
||||
const conversationRef = requesterConversationRef ?? fallbackConversationRef;
|
||||
if (!conversationRef?.conversationId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Could not resolve a ${policy.channel} conversation for subagent thread spawn.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
binding: {
|
||||
channel: policy.channel,
|
||||
accountId: policy.accountId,
|
||||
placement: placementToUse,
|
||||
conversationId: conversationRef.conversationId,
|
||||
...(conversationRef.parentConversationId
|
||||
? { parentConversationId: conversationRef.parentConversationId }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function bindThreadForSubagentSpawn(params: {
|
||||
cfg: OpenClawConfig;
|
||||
childSessionKey: string;
|
||||
agentId: string;
|
||||
label?: string;
|
||||
@@ -656,51 +890,70 @@ async function ensureThreadBindingForSubagentSpawn(params: {
|
||||
threadId?: string | number;
|
||||
};
|
||||
}): Promise<
|
||||
{ status: "ok"; deliveryOrigin?: DeliveryContext } | { status: "error"; error: string }
|
||||
| { status: "ok"; deliveryOrigin?: DeliveryContext }
|
||||
| {
|
||||
status: "error";
|
||||
error: string;
|
||||
}
|
||||
> {
|
||||
if (!params.hookRunner?.hasHooks("subagent_spawning")) {
|
||||
const prepared = prepareSubagentThreadBinding({
|
||||
cfg: params.cfg,
|
||||
mode: params.mode,
|
||||
requesterSessionKey: params.requesterSessionKey,
|
||||
requester: params.requester,
|
||||
});
|
||||
if (!prepared.ok) {
|
||||
return {
|
||||
status: "error",
|
||||
error: buildThreadBindingUnavailableError(params.mode),
|
||||
error: prepared.error,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await params.hookRunner.runSubagentSpawning(
|
||||
{
|
||||
childSessionKey: params.childSessionKey,
|
||||
const binding = await getSessionBindingService().bind({
|
||||
targetSessionKey: params.childSessionKey,
|
||||
targetKind: "subagent",
|
||||
conversation: {
|
||||
channel: prepared.binding.channel,
|
||||
accountId: prepared.binding.accountId,
|
||||
conversationId: prepared.binding.conversationId,
|
||||
...(prepared.binding.parentConversationId
|
||||
? { parentConversationId: prepared.binding.parentConversationId }
|
||||
: {}),
|
||||
},
|
||||
placement: prepared.binding.placement,
|
||||
metadata: {
|
||||
threadName: resolveThreadBindingThreadName({
|
||||
agentId: params.agentId,
|
||||
label: params.label || params.agentId,
|
||||
}),
|
||||
agentId: params.agentId,
|
||||
label: params.label,
|
||||
mode: params.mode,
|
||||
requester: params.requester,
|
||||
threadRequested: true,
|
||||
label: params.label || undefined,
|
||||
boundBy: "system",
|
||||
introText: resolveThreadBindingIntroText({
|
||||
agentId: params.agentId,
|
||||
label: params.label || undefined,
|
||||
idleTimeoutMs: resolveThreadBindingIdleTimeoutMsForChannel({
|
||||
cfg: params.cfg,
|
||||
channel: prepared.binding.channel,
|
||||
accountId: prepared.binding.accountId,
|
||||
}),
|
||||
maxAgeMs: resolveThreadBindingMaxAgeMsForChannel({
|
||||
cfg: params.cfg,
|
||||
channel: prepared.binding.channel,
|
||||
accountId: prepared.binding.accountId,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
{
|
||||
childSessionKey: params.childSessionKey,
|
||||
requesterSessionKey: params.requesterSessionKey,
|
||||
},
|
||||
);
|
||||
if (result?.status === "error") {
|
||||
const error = result.error.trim();
|
||||
return {
|
||||
status: "error",
|
||||
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) {
|
||||
});
|
||||
if (!binding.conversation.conversationId) {
|
||||
return {
|
||||
status: "error",
|
||||
error:
|
||||
"Unable to create or bind a thread for this subagent session. Session mode is unavailable for this target.",
|
||||
};
|
||||
}
|
||||
const deliveryOrigin = normalizeDeliveryContext(result.deliveryOrigin);
|
||||
const deliveryOrigin = routeToDeliveryFields(routeFromBindingRecord(binding)).deliveryContext;
|
||||
return {
|
||||
status: "ok",
|
||||
...(deliveryOrigin ? { deliveryOrigin } : {}),
|
||||
@@ -1030,8 +1283,8 @@ export async function spawnSubagentDirect(
|
||||
modelApplied = true;
|
||||
}
|
||||
if (requestThreadBinding) {
|
||||
const bindResult = await ensureThreadBindingForSubagentSpawn({
|
||||
hookRunner,
|
||||
const bindResult = await bindThreadForSubagentSpawn({
|
||||
cfg,
|
||||
childSessionKey,
|
||||
agentId: targetAgentId,
|
||||
label: label || undefined,
|
||||
|
||||
@@ -18,6 +18,18 @@ type TestConfig = {
|
||||
list?: TestAgentConfig[];
|
||||
};
|
||||
};
|
||||
type TestBindingRequest = {
|
||||
targetSessionKey: string;
|
||||
targetKind?: string;
|
||||
conversation: {
|
||||
channel: string;
|
||||
accountId?: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
};
|
||||
placement: "current" | "child";
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
callGatewayMock: vi.fn(),
|
||||
@@ -28,7 +40,23 @@ const hoisted = vi.hoisted(() => ({
|
||||
>(() => ({ sandboxed: false })),
|
||||
hookRunner: {
|
||||
hasHooks: vi.fn(() => false),
|
||||
runSubagentSpawning: vi.fn(),
|
||||
},
|
||||
bindingService: {
|
||||
getCapabilities: vi.fn(() => ({
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
placements: ["child"] as Array<"current" | "child">,
|
||||
})),
|
||||
bind: vi.fn(async (request: TestBindingRequest) => {
|
||||
const conversation = request.conversation;
|
||||
return {
|
||||
targetSessionKey: request.targetSessionKey,
|
||||
targetKind: request.targetKind,
|
||||
status: "active",
|
||||
conversation,
|
||||
};
|
||||
}),
|
||||
listBySession: vi.fn(() => []),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -111,6 +139,7 @@ describe("spawnSubagentDirect workspace inheritance", () => {
|
||||
resolveAgentConfig: resolveTestAgentConfig,
|
||||
resolveAgentWorkspaceDir: resolveTestAgentWorkspace,
|
||||
resolveSandboxRuntimeStatus: hoisted.resolveSandboxRuntimeStatusMock,
|
||||
getSessionBindingService: () => hoisted.bindingService,
|
||||
resetModules: false,
|
||||
}));
|
||||
});
|
||||
@@ -123,7 +152,9 @@ describe("spawnSubagentDirect workspace inheritance", () => {
|
||||
hoisted.resolveSandboxRuntimeStatusMock.mockImplementation(() => ({ sandboxed: false }));
|
||||
hoisted.hookRunner.hasHooks.mockReset();
|
||||
hoisted.hookRunner.hasHooks.mockImplementation(() => false);
|
||||
hoisted.hookRunner.runSubagentSpawning.mockReset();
|
||||
hoisted.bindingService.getCapabilities.mockClear();
|
||||
hoisted.bindingService.bind.mockClear();
|
||||
hoisted.bindingService.listBySession.mockClear();
|
||||
hoisted.configOverride = createConfigOverride();
|
||||
setupAcceptedSubagentGatewayMock(hoisted.callGatewayMock);
|
||||
});
|
||||
@@ -323,11 +354,6 @@ describe("spawnSubagentDirect workspace inheritance", () => {
|
||||
});
|
||||
|
||||
it("keeps lifecycle hooks enabled when registerSubagentRun fails after thread binding succeeds", async () => {
|
||||
hoisted.hookRunner.hasHooks.mockImplementation((name?: string) => name === "subagent_spawning");
|
||||
hoisted.hookRunner.runSubagentSpawning.mockResolvedValue({
|
||||
status: "ok",
|
||||
threadBindingReady: true,
|
||||
});
|
||||
hoisted.registerSubagentRunMock.mockImplementation(() => {
|
||||
throw new Error("registry unavailable");
|
||||
});
|
||||
|
||||
@@ -163,6 +163,11 @@ const knownDeprecatedSurfaceMarkers = [
|
||||
file: "src/plugins/hook-types.ts",
|
||||
marker: "@deprecated Use gateway_stop",
|
||||
},
|
||||
{
|
||||
code: "legacy-subagent-spawning-hook",
|
||||
file: "src/plugins/hook-types.ts",
|
||||
marker: "@deprecated Core prepares thread-bound subagent bindings",
|
||||
},
|
||||
{
|
||||
code: "deprecated-memory-embedding-provider-api",
|
||||
file: "src/plugins/types.ts",
|
||||
|
||||
@@ -39,6 +39,28 @@ export const PLUGIN_COMPAT_RECORDS = [
|
||||
releaseNote:
|
||||
'`api.on("deactivate", ...)` remains wired as a deprecated compatibility alias while plugins migrate to `gateway_stop`.',
|
||||
},
|
||||
{
|
||||
code: "legacy-subagent-spawning-hook",
|
||||
status: "deprecated",
|
||||
owner: "sdk",
|
||||
introduced: "2026-05-30",
|
||||
deprecated: "2026-05-30",
|
||||
warningStarts: "2026-05-30",
|
||||
removeAfter: "2026-08-30",
|
||||
replacement:
|
||||
"`subagent_spawned` for post-launch observation; core session-binding adapters for thread routing",
|
||||
docsPath: "/plugins/hooks#upcoming-deprecations",
|
||||
surfaces: [
|
||||
'api.on("subagent_spawning", ...)',
|
||||
"PluginHookSubagentSpawningEvent",
|
||||
"PluginHookSubagentSpawningResult",
|
||||
"SubagentLifecycleHookRunner.runSubagentSpawning",
|
||||
],
|
||||
diagnostics: ["plugin runtime compatibility warning"],
|
||||
tests: ["src/plugins/loader.test.ts", "src/plugins/compat/registry.test.ts"],
|
||||
releaseNote:
|
||||
'`api.on("subagent_spawning", ...)` remains wired only for older plugins; core now owns thread-bound subagent routing.',
|
||||
},
|
||||
{
|
||||
code: "hook-only-plugin-shape",
|
||||
status: "active",
|
||||
|
||||
@@ -28,21 +28,9 @@ const BUNDLED_TYPED_HOOK_REGISTRATION_GUARDS = {
|
||||
"extensions/active-memory/index.ts": ["before_prompt_build"],
|
||||
"extensions/codex/index.ts": ["inbound_claim"],
|
||||
"extensions/diffs/src/plugin.ts": ["before_prompt_build"],
|
||||
"extensions/discord/subagent-hooks-api.ts": [
|
||||
"subagent_delivery_target",
|
||||
"subagent_ended",
|
||||
"subagent_spawning",
|
||||
],
|
||||
"extensions/feishu/subagent-hooks-api.ts": [
|
||||
"subagent_delivery_target",
|
||||
"subagent_ended",
|
||||
"subagent_spawning",
|
||||
],
|
||||
"extensions/matrix/subagent-hooks-api.ts": [
|
||||
"subagent_delivery_target",
|
||||
"subagent_ended",
|
||||
"subagent_spawning",
|
||||
],
|
||||
"extensions/discord/subagent-hooks-api.ts": ["subagent_delivery_target", "subagent_ended"],
|
||||
"extensions/feishu/subagent-hooks-api.ts": ["subagent_delivery_target", "subagent_ended"],
|
||||
"extensions/matrix/subagent-hooks-api.ts": ["subagent_delivery_target", "subagent_ended"],
|
||||
"extensions/memory-core/src/dreaming.ts": ["before_agent_reply", "gateway_start", "gateway_stop"],
|
||||
"extensions/memory-lancedb/index.ts": ["agent_end", "before_prompt_build", "session_end"],
|
||||
"extensions/thread-ownership/index.ts": ["message_received", "message_sending"],
|
||||
|
||||
@@ -91,6 +91,11 @@ export type PluginHookName =
|
||||
| "before_message_write"
|
||||
| "session_start"
|
||||
| "session_end"
|
||||
/**
|
||||
* @deprecated Core prepares thread-bound subagent bindings through channel
|
||||
* session-binding adapters before `subagent_spawned` fires. Use
|
||||
* `subagent_spawned` for post-launch observation in new plugins.
|
||||
*/
|
||||
| "subagent_spawning"
|
||||
| "subagent_delivery_target"
|
||||
| "subagent_spawned"
|
||||
@@ -152,6 +157,38 @@ type AssertAllPluginHookNamesListed = MissingPluginHookNames extends never ? tru
|
||||
const assertAllPluginHookNamesListed: AssertAllPluginHookNamesListed = true;
|
||||
void assertAllPluginHookNamesListed;
|
||||
|
||||
export type DeprecatedPluginHookName = "subagent_spawning" | "deactivate";
|
||||
|
||||
export type PluginHookDeprecation = {
|
||||
replacement: string;
|
||||
reason: string;
|
||||
removeAfter?: string;
|
||||
};
|
||||
|
||||
export const DEPRECATED_PLUGIN_HOOKS = {
|
||||
subagent_spawning: {
|
||||
replacement: "`subagent_spawned` for observation; core session bindings for routing",
|
||||
reason:
|
||||
"Core prepares thread-bound subagent bindings through channel session-binding adapters before `subagent_spawned` fires.",
|
||||
removeAfter: "2026-08-30",
|
||||
},
|
||||
deactivate: {
|
||||
replacement: "`gateway_stop`",
|
||||
reason: "`deactivate` is a legacy cleanup hook alias for `gateway_stop`.",
|
||||
removeAfter: "2026-08-16",
|
||||
},
|
||||
} as const satisfies Record<DeprecatedPluginHookName, PluginHookDeprecation>;
|
||||
|
||||
export const DEPRECATED_PLUGIN_HOOK_NAMES = Object.keys(
|
||||
DEPRECATED_PLUGIN_HOOKS,
|
||||
) as DeprecatedPluginHookName[];
|
||||
|
||||
const deprecatedPluginHookNameSet = new Set<PluginHookName>(DEPRECATED_PLUGIN_HOOK_NAMES);
|
||||
|
||||
export const isDeprecatedPluginHookName = (
|
||||
hookName: PluginHookName,
|
||||
): hookName is DeprecatedPluginHookName => deprecatedPluginHookNameSet.has(hookName);
|
||||
|
||||
const pluginHookNameSet = new Set<PluginHookName>(PLUGIN_HOOK_NAMES);
|
||||
|
||||
export const isPluginHookName = (hookName: unknown): hookName is PluginHookName =>
|
||||
@@ -612,8 +649,18 @@ type PluginHookSubagentSpawnBase = {
|
||||
threadRequested: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated Core prepares thread-bound subagent bindings through channel
|
||||
* session-binding adapters before `subagent_spawned` fires. Use
|
||||
* `subagent_spawned` for post-launch observation in new plugins.
|
||||
*/
|
||||
export type PluginHookSubagentSpawningEvent = PluginHookSubagentSpawnBase;
|
||||
|
||||
/**
|
||||
* @deprecated Core prepares thread-bound subagent bindings through channel
|
||||
* session-binding adapters before `subagent_spawned` fires. Returning routing
|
||||
* data from `subagent_spawning` is retained only for older runtimes.
|
||||
*/
|
||||
export type PluginHookSubagentSpawningResult =
|
||||
| {
|
||||
status: "ok";
|
||||
@@ -1040,6 +1087,11 @@ export type PluginHookHandlerMap = {
|
||||
event: PluginHookSessionEndEvent,
|
||||
ctx: PluginHookSessionContext,
|
||||
) => Promise<void> | void;
|
||||
/**
|
||||
* @deprecated Core prepares thread-bound subagent bindings through channel
|
||||
* session-binding adapters before `subagent_spawned` fires. Use
|
||||
* `subagent_spawned` for post-launch observation in new plugins.
|
||||
*/
|
||||
subagent_spawning: (
|
||||
event: PluginHookSubagentSpawningEvent,
|
||||
ctx: PluginHookSubagentContext,
|
||||
|
||||
@@ -1453,8 +1453,9 @@ export function createHookRunner(
|
||||
}
|
||||
|
||||
/**
|
||||
* Run subagent_spawning hook.
|
||||
* Runs sequentially so channel plugins can deterministically provision session bindings.
|
||||
* @deprecated Core prepares thread-bound subagent bindings through channel
|
||||
* session-binding adapters before subagent_spawned fires. This remains only
|
||||
* for older plugins that call the hook runner directly.
|
||||
*/
|
||||
async function runSubagentSpawning(
|
||||
event: PluginHookSubagentSpawningEvent,
|
||||
|
||||
@@ -15,7 +15,11 @@ import {
|
||||
getRegisteredEventKeys,
|
||||
triggerInternalHook,
|
||||
} from "../hooks/internal-hooks.js";
|
||||
import { emitDiagnosticEvent } from "../infra/diagnostic-events.js";
|
||||
import {
|
||||
emitDiagnosticEvent,
|
||||
resetDiagnosticEventsForTest,
|
||||
waitForDiagnosticEventsDrained,
|
||||
} from "../infra/diagnostic-events.js";
|
||||
import {
|
||||
clearDetachedTaskLifecycleRuntimeRegistration,
|
||||
getDetachedTaskLifecycleRuntimeRegistration,
|
||||
@@ -985,6 +989,7 @@ function collectStartupTraceMetrics(
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetDiagnosticEventsForTest();
|
||||
clearRuntimeConfigSnapshot();
|
||||
runtimeRegistryLoaderTesting.resetPluginRegistryLoadedForTests();
|
||||
resetPluginLoaderTestStateForTest();
|
||||
@@ -6862,6 +6867,37 @@ module.exports = {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("warns when plugins register deprecated subagent_spawning typed hooks", () => {
|
||||
useNoBundledPlugins();
|
||||
const plugin = writePlugin({
|
||||
id: "legacy-subagent-spawning-hook",
|
||||
filename: "legacy-subagent-spawning-hook.cjs",
|
||||
body: `module.exports = { id: "legacy-subagent-spawning-hook", register(api) {
|
||||
api.on("subagent_spawning", () => ({ status: "ok" }));
|
||||
} };`,
|
||||
});
|
||||
|
||||
const registry = loadRegistryFromSinglePlugin({
|
||||
plugin,
|
||||
pluginConfig: {
|
||||
allow: ["legacy-subagent-spawning-hook"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
registry.plugins.find((entry) => entry.id === "legacy-subagent-spawning-hook")?.status,
|
||||
).toBe("loaded");
|
||||
expect(registry.typedHooks.map((entry) => entry.hookName)).toEqual(["subagent_spawning"]);
|
||||
expect(
|
||||
registry.diagnostics.some(
|
||||
(diag) =>
|
||||
diag.pluginId === "legacy-subagent-spawning-hook" &&
|
||||
diag.message ===
|
||||
'typed hook "subagent_spawning" is deprecated (legacy-subagent-spawning-hook); Core prepares thread-bound subagent bindings through channel session-binding adapters before `subagent_spawned` fires. Use `subagent_spawned` for observation; core session bindings for routing. This compatibility hook will be removed after 2026-08-30.',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores unknown typed hooks from plugins and keeps loading", () => {
|
||||
useNoBundledPlugins();
|
||||
const plugin = writePlugin({
|
||||
@@ -8059,7 +8095,7 @@ module.exports = {
|
||||
).toBe("loaded");
|
||||
});
|
||||
|
||||
it("supports legacy plugins subscribing to diagnostic events from the root sdk", () => {
|
||||
it("supports legacy plugins subscribing to diagnostic events from the root sdk", async () => {
|
||||
useNoBundledPlugins();
|
||||
const seenKey = "__openclawLegacyRootDiagnosticSeen";
|
||||
delete (globalThis as Record<string, unknown>)[seenKey];
|
||||
@@ -8114,6 +8150,7 @@ module.exports = {
|
||||
sessionKey: "agent:main:test:dm:peer",
|
||||
usage: { total: 1 },
|
||||
});
|
||||
await waitForDiagnosticEventsDrained();
|
||||
|
||||
expect((globalThis as Record<string, unknown>)[seenKey]).toEqual([
|
||||
{
|
||||
|
||||
@@ -152,7 +152,9 @@ import {
|
||||
normalizePluginToolNames,
|
||||
} from "./tool-contracts.js";
|
||||
import {
|
||||
DEPRECATED_PLUGIN_HOOKS,
|
||||
isConversationHookName,
|
||||
isDeprecatedPluginHookName,
|
||||
isPluginHookName,
|
||||
isPromptInjectionHookName,
|
||||
stripPromptMutationFieldsFromLegacyHookResult,
|
||||
@@ -202,6 +204,7 @@ export type PluginHttpRouteRegistration = RegistryTypesPluginHttpRouteRegistrati
|
||||
|
||||
const GATEWAY_METHOD_DISPATCH_CONTRACT = "authenticated-request";
|
||||
const LEGACY_DEACTIVATE_HOOK_ALIAS_COMPAT = getPluginCompatRecord("legacy-deactivate-hook-alias");
|
||||
const LEGACY_SUBAGENT_SPAWNING_HOOK_COMPAT = getPluginCompatRecord("legacy-subagent-spawning-hook");
|
||||
|
||||
function formatLegacyDeactivateHookAliasDiagnostic(): string {
|
||||
const removeAfter =
|
||||
@@ -212,6 +215,22 @@ function formatLegacyDeactivateHookAliasDiagnostic(): string {
|
||||
);
|
||||
}
|
||||
|
||||
function formatDeprecatedTypedHookDiagnostic(hookName: PluginHookName): string | undefined {
|
||||
if (!isDeprecatedPluginHookName(hookName) || hookName === "deactivate") {
|
||||
return undefined;
|
||||
}
|
||||
const deprecation = DEPRECATED_PLUGIN_HOOKS[hookName];
|
||||
const compat =
|
||||
hookName === "subagent_spawning" ? LEGACY_SUBAGENT_SPAWNING_HOOK_COMPAT : undefined;
|
||||
const removeAfter = compat?.removeAfter ?? deprecation.removeAfter ?? "a future breaking release";
|
||||
const code = compat?.code ?? "deprecated-plugin-hook";
|
||||
return (
|
||||
`typed hook "${hookName}" is deprecated (${code}); ` +
|
||||
`${deprecation.reason} Use ${deprecation.replacement}. ` +
|
||||
`This compatibility hook will be removed after ${removeAfter}.`
|
||||
);
|
||||
}
|
||||
|
||||
type PluginOwnedProviderRegistration<T extends { id: string }> = {
|
||||
pluginId: string;
|
||||
pluginName?: string;
|
||||
@@ -2444,6 +2463,16 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
source: record.source,
|
||||
message: formatLegacyDeactivateHookAliasDiagnostic(),
|
||||
});
|
||||
} else {
|
||||
const deprecatedHookDiagnostic = formatDeprecatedTypedHookDiagnostic(hookName);
|
||||
if (deprecatedHookDiagnostic) {
|
||||
pushDiagnostic({
|
||||
level: "warn",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: deprecatedHookDiagnostic,
|
||||
});
|
||||
}
|
||||
}
|
||||
let effectiveHandler = handler;
|
||||
if (policy?.allowPromptInjection === false && isPromptInjectionHookName(effectiveHookName)) {
|
||||
|
||||
Reference in New Issue
Block a user