feat(discord): show subagent progress (#95604)

* feat(discord): show subagent progress

Co-authored-by: Kyle Klouzal <kklouzal@users.noreply.github.com>

* fix(discord): serialize progress cleanup ownership

Co-authored-by: Kyle Klouzal <kklouzal@users.noreply.github.com>

* test(discord): type progress state fixtures

* refactor(discord): split subagent progress state

* fix(discord): persist terminal progress outcomes

* fix(discord): narrow persisted cleanup rows

* refactor(discord): keep progress internals private

* perf(discord): preserve lazy progress loading

* chore(plugin-sdk): refresh API baseline after rebase

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: Kyle Klouzal <kklouzal@users.noreply.github.com>
This commit is contained in:
Kyle Klouzal 2026-07-17 04:02:33 -07:00 committed by GitHub
parent b2e34b03b5
commit e316e1c440
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 2465 additions and 39 deletions

View file

@ -1,4 +1,4 @@
07edb0e3c7841952ccfd5111b4be02db9c7c52ff8fc4bb185dc87552f7776116 config-baseline.json
337632b33a9e0c27c5e4f1ed8229216e9a0a54d3f277d95d0782840f4e1ae4f9 config-baseline.core.json
911a0a6bedaeebda7c92ea1ccfaedb223f23e96bf577398601ec61e34d35ed08 config-baseline.channel.json
bb46810530bb11b70d3a477d815d2534ba2230b9179c0a17790a36272bc6425e config-baseline.json
a965433f02c4b3e1e3eacb57b17ca6c9e0916fa26d91e1d6413e65eec3873f72 config-baseline.core.json
c87db673c4b6f3339213ee979c16365e3b738070879f1a8747604165fa222102 config-baseline.channel.json
5ccd3695d1c1a60efa2654e0fd9a0397ef9c45fd1e1fd30a3cbed9b343a5c8ca config-baseline.plugin.json

View file

@ -1,2 +1,2 @@
244b02936e24b4c5ae8ca596c7a2255e2da1026b23ad445108bd5135d6ed8564 plugin-sdk-api-baseline.json
3566fb622a7173c4e238d449d2c605a43a21cae6d966782acb046a7925d3a20e plugin-sdk-api-baseline.jsonl
d6856bc1215580fb139b591b4b5b8aa34d7283ce136b24b358c686638a298eaa plugin-sdk-api-baseline.json
205ff66234e0bf7f9c5f83ef7bd6222b696d544b6db7defec1162174ba28b5b2 plugin-sdk-api-baseline.jsonl

View file

@ -790,6 +790,25 @@ See [Slash commands](/tools/slash-commands) for the command catalog and behavior
</Accordion>
<Accordion title="Subagent progress on the source message">
Set `channels.discord.subagentProgress: true` to show background child activity on the Discord message that started the parent run.
```json5
{
channels: {
discord: {
subagentProgress: true,
},
},
}
```
While child runs are active, OpenClaw keeps Discord typing active for up to one hour and replaces one count reaction (`1⃣` through `🔟`) as the concurrent count changes; `🔟` also represents 10 or more. The count reaction is removed after the final child ends. A failed, timed-out, or killed child leaves a `🔴` reaction.
This is opt-in and uses fixed internal timing and emoji defaults. The bot needs **Add Reactions** permission for reaction feedback. Account-level `channels.discord.accounts.<id>.subagentProgress` overrides the top-level value.
</Accordion>
<Accordion title="Persistent ACP channel bindings">
For stable "always-on" ACP workspaces, configure top-level typed ACP bindings targeting Discord conversations.

View file

@ -115,6 +115,10 @@ export const discordChannelConfigUiHints = {
label: "Discord Thread Spawn Context",
help: 'Default native subagent context for thread-bound spawns. "fork" starts from the requester transcript; "isolated" starts clean. Default: "fork".',
},
subagentProgress: {
label: "Discord Subagent Progress",
help: "Show active subagent count reactions and typing on the source message. Default: false.",
},
"ui.components.accentColor": {
label: "Discord Component Accent Color",
help: "Accent color for Discord component containers (hex). Set per account via channels.discord.accounts.<id>.ui.components.accentColor.",

View file

@ -60,6 +60,9 @@ const hookMocks = vi.hoisted(() => {
),
listThreadBindingsBySessionKey: vi.fn((_params?: unknown): ThreadBindingRecord[] => []),
unbindThreadBindingsBySessionKey: vi.fn(() => []),
progressModuleFactory: vi.fn(),
handleDiscordSubagentProgress: vi.fn(),
recoverDiscordSubagentProgress: vi.fn(),
};
});
@ -73,6 +76,13 @@ vi.mock("./monitor/thread-bindings.js", () => ({
listThreadBindingsBySessionKey: hookMocks.listThreadBindingsBySessionKey,
unbindThreadBindingsBySessionKey: hookMocks.unbindThreadBindingsBySessionKey,
}));
vi.mock("./subagent-progress.js", () => {
hookMocks.progressModuleFactory();
return {
handleDiscordSubagentProgress: hookMocks.handleDiscordSubagentProgress,
recoverDiscordSubagentProgress: hookMocks.recoverDiscordSubagentProgress,
};
});
function registerHandlersForTest(
config: Record<string, unknown> = {
@ -210,6 +220,38 @@ describe("discord subagent hook handlers", () => {
hookMocks.autoBindSpawnedDiscordSubagent.mockClear();
hookMocks.listThreadBindingsBySessionKey.mockClear();
hookMocks.unbindThreadBindingsBySessionKey.mockClear();
hookMocks.progressModuleFactory.mockClear();
hookMocks.handleDiscordSubagentProgress.mockClear();
hookMocks.recoverDiscordSubagentProgress.mockClear();
});
it("keeps progress runtime lazy for unrelated subagent hooks", async () => {
const handlers = registerHandlersForTest();
const handler = getRequiredHookHandler(handlers, "subagent_delivery_target");
await handler(
{
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
requesterOrigin: { channel: "signal" },
childRunId: "run-1",
spawnMode: "session",
expectsCompletionMessage: true,
},
{},
);
expect(hookMocks.progressModuleFactory).not.toHaveBeenCalled();
});
it("loads progress runtime for gateway recovery", async () => {
const handlers = registerHandlersForTest();
const handler = getRequiredHookHandler(handlers, "gateway_start");
await handler({}, {});
expect(hookMocks.progressModuleFactory).toHaveBeenCalledTimes(1);
expect(hookMocks.recoverDiscordSubagentProgress).toHaveBeenCalledTimes(1);
});
it("binds thread routing on subagent_spawning", async () => {

View file

@ -0,0 +1,68 @@
import { DEFAULT_EMOJIS } from "openclaw/plugin-sdk/channel-feedback";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
normalizeOptionalLowercaseString,
normalizeOptionalStringifiedId,
} from "openclaw/plugin-sdk/string-coerce-runtime";
export const RUNNING_EMOJIS = ["1⃣", "2⃣", "3⃣", "4⃣", "5⃣", "6⃣", "7⃣", "8⃣", "9⃣", "🔟"];
export const FAILURE_EMOJI = "🔴";
export type DiscordProgressRequester = {
channel?: string;
accountId?: string;
to?: string;
threadId?: string | number;
channelId?: string | number;
messageId?: string | number;
};
function channelIdFromTarget(target?: string): string | undefined {
const trimmed = target?.trim();
if (!trimmed) {
return undefined;
}
if (trimmed.startsWith("channel:")) {
return trimmed.slice("channel:".length).trim() || undefined;
}
return /^\d+$/u.test(trimmed) ? trimmed : undefined;
}
export function resolveDiscordProgressTarget(requester?: DiscordProgressRequester) {
if (normalizeOptionalLowercaseString(requester?.channel) !== "discord") {
return undefined;
}
const channelId =
normalizeOptionalStringifiedId(requester?.channelId) ?? channelIdFromTarget(requester?.to);
const messageId = normalizeOptionalStringifiedId(requester?.messageId);
if (!channelId || !messageId) {
return undefined;
}
return { channelId, messageId };
}
export function reservedReactionEmojis(config: OpenClawConfig, ackReaction?: string): Set<string> {
const reserved = new Set<string>(Object.values(DEFAULT_EMOJIS));
for (const emoji of Object.values(config.messages?.statusReactions?.emojis ?? {})) {
if (emoji?.trim()) {
reserved.add(emoji.trim());
}
}
for (const emoji of [config.messages?.ackReaction, ackReaction]) {
if (emoji?.trim()) {
reserved.add(emoji.trim());
}
}
for (const agent of config.agents?.list ?? []) {
const emoji = agent.identity?.emoji?.trim();
if (emoji) {
reserved.add(emoji);
}
}
return reserved;
}
export function reactionsAreAvailable(config: OpenClawConfig, ackReaction?: string): boolean {
const reserved = reservedReactionEmojis(config, ackReaction);
return !RUNNING_EMOJIS.some((emoji) => reserved.has(emoji)) && !reserved.has(FAILURE_EMOJI);
}

View file

@ -0,0 +1,264 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { PluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
export const PROGRESS_STORE_TTL_MS = 7 * 24 * 60 * 60_000;
export const MAX_TRACKED_RUNS = 4_096;
const TERMINAL_TOMBSTONE_TTL_MS = 60 * 60_000;
export type SubagentProgressOutcome = "ok" | "error" | "timeout" | "killed" | "unknown";
export type ProgressApi = {
config: OpenClawConfig;
logger: { debug?: (message: string) => void };
runtime?: {
state: {
openKeyedStore<T>(options: {
namespace: string;
maxEntries: number;
overflowPolicy: "reject-new";
defaultTtlMs: number;
}): PluginStateKeyedStore<T>;
};
};
};
type PersistedProgressRunBase = {
/** Plugin SQLite ownership lets an ended run clean reactions after gateway restart. */
key: string;
accountId: string;
channelId: string;
messageId: string;
runningEmoji?: string;
};
type PersistedActiveProgressRun = PersistedProgressRunBase & { status: "active" };
type PersistedCleanupProgressRun = PersistedProgressRunBase & {
status: "cleanup";
outcome: SubagentProgressOutcome;
};
export type PersistedProgressRun = PersistedActiveProgressRun | PersistedCleanupProgressRun;
export function persistedTerminalOutcome(
persisted?: PersistedProgressRun,
): SubagentProgressOutcome | undefined {
return persisted?.status === "cleanup" ? persisted.outcome : undefined;
}
export type ProgressTracker = {
accountId: string;
channelId: string;
messageId: string;
activeRunIds: Set<string>;
persistedRunIds: Set<string>;
runningEmoji?: string;
runningEmojiConfirmed: boolean;
reactionsEnabled: boolean;
typingTimer?: ReturnType<typeof setInterval>;
typingExpiresAt: number;
};
type ProgressRunLookupResult =
| { status: "found"; value: PersistedProgressRun }
| { status: "missing" }
| { status: "error" };
export type PersistProgressResult = "persisted" | "terminal" | "conflict" | "error";
type ProgressStateForKeyResult =
| {
ok: true;
activeRunIds: string[];
cleanupRuns: Array<{ runId: string; value: PersistedCleanupProgressRun }>;
ownedEmojis: string[];
}
| { ok: false };
let progressStores = new WeakMap<object, PluginStateKeyedStore<PersistedProgressRun> | null>();
const trackerQueues = new Map<string, Promise<void>>();
const terminalRuns = new Map<string, { expiresAt: number; outcome: SubagentProgressOutcome }>();
export function markRunTerminal(runId: string, outcome: SubagentProgressOutcome) {
const now = Date.now();
for (const [trackedRunId, terminal] of terminalRuns) {
if (terminal.expiresAt <= now) {
terminalRuns.delete(trackedRunId);
}
}
terminalRuns.set(runId, { expiresAt: now + TERMINAL_TOMBSTONE_TTL_MS, outcome });
if (terminalRuns.size > MAX_TRACKED_RUNS) {
const oldest = terminalRuns.keys().next().value;
if (oldest) {
terminalRuns.delete(oldest);
}
}
}
export function terminalOutcome(runId: string): SubagentProgressOutcome | undefined {
const terminal = terminalRuns.get(runId);
if (!terminal) {
return undefined;
}
if (terminal.expiresAt <= Date.now()) {
terminalRuns.delete(runId);
return undefined;
}
return terminal.outcome;
}
export function logFailure(api: ProgressApi, action: string, error: unknown) {
const message = error instanceof Error ? error.message : String(error);
api.logger.debug?.(`discord subagent progress ${action} failed: ${message}`);
}
export function getProgressStore(
api: ProgressApi,
): PluginStateKeyedStore<PersistedProgressRun> | undefined {
const cached = progressStores.get(api);
if (cached !== undefined) {
return cached ?? undefined;
}
if (!api.runtime?.state) {
progressStores.set(api, null);
return undefined;
}
try {
const store = api.runtime.state.openKeyedStore<PersistedProgressRun>({
namespace: "subagent-progress",
maxEntries: MAX_TRACKED_RUNS,
overflowPolicy: "reject-new",
defaultTtlMs: PROGRESS_STORE_TTL_MS,
});
progressStores.set(api, store);
return store;
} catch (error) {
logFailure(api, "state store open", error);
return undefined;
}
}
export function persistedProgressRunFromTracker(tracker: ProgressTracker): PersistedProgressRun {
return {
key: `${tracker.accountId}:${tracker.channelId}:${tracker.messageId}`,
accountId: tracker.accountId,
channelId: tracker.channelId,
messageId: tracker.messageId,
status: "active",
...(tracker.runningEmoji ? { runningEmoji: tracker.runningEmoji } : {}),
};
}
export async function persistProgressRun(
api: ProgressApi,
runId: string,
tracker: ProgressTracker,
): Promise<PersistProgressResult> {
const store = getProgressStore(api);
if (!store) {
return "error";
}
const value = persistedProgressRunFromTracker(tracker);
try {
if (await store.registerIfAbsent(runId, value)) {
return "persisted";
}
const existing = await store.lookup(runId);
if (!existing) {
return "error";
}
if (existing.status === "cleanup") {
return "terminal";
}
return existing.key === value.key ? "persisted" : "conflict";
} catch (error) {
logFailure(api, "state store write", error);
return "error";
}
}
export async function markProgressRunForCleanup(
api: ProgressApi,
runId: string,
persisted: PersistedProgressRun,
outcome: SubagentProgressOutcome,
) {
try {
await getProgressStore(api)?.register(runId, { ...persisted, status: "cleanup", outcome });
return true;
} catch (error) {
logFailure(api, "state store cleanup mark", error);
return false;
}
}
export async function lookupProgressRun(
api: ProgressApi,
runId: string,
): Promise<ProgressRunLookupResult> {
const store = getProgressStore(api);
if (!store) {
return { status: "error" };
}
try {
const value = await store.lookup(runId);
return value ? { status: "found", value } : { status: "missing" };
} catch (error) {
logFailure(api, "state store read", error);
return { status: "error" };
}
}
export async function consumeProgressRun(api: ProgressApi, runId: string) {
try {
return await getProgressStore(api)?.consume(runId);
} catch (error) {
logFailure(api, "state store consume", error);
return undefined;
}
}
export async function listProgressStateForKey(
api: ProgressApi,
key: string,
): Promise<ProgressStateForKeyResult> {
const store = getProgressStore(api);
if (!store) {
return { ok: false };
}
try {
const entries = await store.entries();
const matching = entries.filter((entry) => entry.value.key === key);
const cleanupRuns = matching.flatMap((entry) =>
entry.value.status === "cleanup" ? [{ runId: entry.key, value: entry.value }] : [],
);
return {
ok: true,
activeRunIds: matching
.filter((entry) => entry.value.status === "active")
.map((entry) => entry.key),
cleanupRuns,
ownedEmojis: Array.from(new Set(matching.flatMap((entry) => entry.value.runningEmoji ?? []))),
};
} catch (error) {
logFailure(api, "state store list", error);
return { ok: false };
}
}
export async function runQueued(key: string, task: () => Promise<void>) {
const previous = trackerQueues.get(key) ?? Promise.resolve();
const current = previous.catch(() => undefined).then(task);
trackerQueues.set(key, current);
try {
await current;
} finally {
if (trackerQueues.get(key) === current) {
trackerQueues.delete(key);
}
}
}
export function resetDiscordSubagentProgressStateForTest() {
trackerQueues.clear();
terminalRuns.clear();
progressStores = new WeakMap();
}

View file

@ -0,0 +1,959 @@
// Discord tests cover portable subagent progress presentation.
import type { PluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
handleDiscordSubagentProgress,
recoverDiscordSubagentProgress,
} from "./subagent-progress.js";
const sendMocks = vi.hoisted(() => ({
react: vi.fn(async (_channelId: string, _messageId: string, _emoji: string, _opts: unknown) => ({
ok: true,
})),
remove: vi.fn(async (_channelId: string, _messageId: string, _emoji: string, _opts: unknown) => ({
ok: true,
})),
typing: vi.fn(async (_channelId: string, _opts: unknown) => ({
ok: true,
channelId: "123",
})),
}));
const resetDiscordSubagentProgressForTest = handleDiscordSubagentProgress.resetForTest;
vi.mock("./send.reactions.js", () => ({
reactMessageDiscord: sendMocks.react,
removeReactionDiscord: sendMocks.remove,
}));
vi.mock("./send.typing.js", () => ({ sendTypingDiscord: sendMocks.typing }));
type StoredProgressRunBase = {
key: string;
accountId: string;
channelId: string;
messageId: string;
runningEmoji?: string;
};
type StoredProgressRun = StoredProgressRunBase &
(
| { status: "active" }
| { status: "cleanup"; outcome: "ok" | "error" | "timeout" | "killed" | "unknown" }
);
function createProgressStore() {
const values = new Map<string, StoredProgressRun>();
return {
values,
store: {
register: vi.fn(async (key: string, value: StoredProgressRun) => {
values.set(key, value);
}),
registerIfAbsent: vi.fn(async (key: string, value: StoredProgressRun) => {
if (values.has(key)) {
return false;
}
values.set(key, value);
return true;
}),
lookup: vi.fn(async (key: string) => values.get(key)),
consume: vi.fn(async (key: string) => {
const value = values.get(key);
values.delete(key);
return value;
}),
delete: vi.fn(async (key: string) => values.delete(key)),
entries: vi.fn(async () =>
Array.from(values, ([key, value]) => ({ key, value, createdAt: Date.now() })),
),
clear: vi.fn(async () => {
values.clear();
}),
} satisfies PluginStateKeyedStore<StoredProgressRun>,
};
}
function createApi(config?: Record<string, unknown>) {
const progressStore = createProgressStore();
const openKeyedStore = vi.fn(<T>() => progressStore.store as unknown as PluginStateKeyedStore<T>);
const api = {
config: config ?? {
channels: { discord: { token: "test-token", subagentProgress: true } },
},
logger: { debug: vi.fn() },
runtime: {
state: {
openKeyedStore,
},
},
} as unknown as Parameters<typeof handleDiscordSubagentProgress>[0];
return {
api,
progressStore,
openKeyedStore,
};
}
let api: ReturnType<typeof createApi>["api"];
let progressStore: ReturnType<typeof createProgressStore>;
function started(runId: string) {
return {
phase: "started" as const,
runId,
requester: {
channel: "discord",
accountId: "default",
to: "channel:123",
messageId: "456",
},
};
}
describe("Discord subagent progress", () => {
beforeEach(() => {
vi.useFakeTimers();
({ api, progressStore } = createApi());
sendMocks.react.mockReset().mockResolvedValue({ ok: true });
sendMocks.remove.mockReset().mockResolvedValue({ ok: true });
sendMocks.typing.mockReset().mockResolvedValue({ ok: true, channelId: "123" });
});
afterEach(() => {
resetDiscordSubagentProgressForTest();
vi.useRealTimers();
});
it("updates one source-message count reaction for concurrent runs", async () => {
await handleDiscordSubagentProgress(api, started("run-1"));
await handleDiscordSubagentProgress(api, started("run-2"));
await handleDiscordSubagentProgress(api, {
phase: "ended",
runId: "run-1",
outcome: "ok",
});
await handleDiscordSubagentProgress(api, {
phase: "ended",
runId: "run-2",
outcome: "ok",
});
expect(sendMocks.react.mock.calls.map((call) => call[2])).toEqual(["1⃣", "2⃣", "1⃣"]);
expect(sendMocks.remove.mock.calls.map((call) => call[2])).toEqual(["1⃣", "2⃣", "1⃣"]);
});
it("refreshes reaction ownership inside the source queue for concurrent endings", async () => {
await handleDiscordSubagentProgress(api, started("run-one"));
await handleDiscordSubagentProgress(api, started("run-two"));
sendMocks.remove.mockImplementation(async (_channelId, _messageId, emoji) => ({
ok: emoji !== "1⃣" || sendMocks.remove.mock.calls.length !== 3,
}));
await Promise.all([
handleDiscordSubagentProgress(api, { phase: "ended", runId: "run-one", outcome: "ok" }),
handleDiscordSubagentProgress(api, { phase: "ended", runId: "run-two", outcome: "ok" }),
]);
await vi.advanceTimersByTimeAsync(1_000);
expect(sendMocks.remove).toHaveBeenCalledWith("123", "456", "1⃣", expect.any(Object));
expect(progressStore.values.size).toBe(0);
});
it("adds the internal failure marker after clearing the running count", async () => {
await handleDiscordSubagentProgress(api, started("run-error"));
await handleDiscordSubagentProgress(api, {
phase: "ended",
runId: "run-error",
outcome: "error",
});
expect(sendMocks.remove).toHaveBeenCalledWith("123", "456", "1⃣", expect.any(Object));
expect(sendMocks.react).toHaveBeenLastCalledWith("123", "456", "🔴", expect.any(Object));
});
it("clears a count reaction whose successful add response was lost", async () => {
sendMocks.react.mockResolvedValueOnce({ ok: false });
await handleDiscordSubagentProgress(api, started("run-ambiguous-add"));
await handleDiscordSubagentProgress(api, {
phase: "ended",
runId: "run-ambiguous-add",
outcome: "ok",
});
expect(sendMocks.remove).toHaveBeenCalledWith("123", "456", "1⃣", expect.any(Object));
expect(progressStore.values.has("run-ambiguous-add")).toBe(false);
});
it("retries an unconfirmed replacement count reaction", async () => {
await handleDiscordSubagentProgress(api, started("run-one"));
await handleDiscordSubagentProgress(api, started("run-two"));
sendMocks.react.mockResolvedValueOnce({ ok: false });
await handleDiscordSubagentProgress(api, {
phase: "ended",
runId: "run-two",
outcome: "ok",
});
await vi.advanceTimersByTimeAsync(1_000);
expect(sendMocks.react.mock.calls.filter((call) => call[2] === "1⃣")).toHaveLength(3);
expect(progressStore.values.has("run-two")).toBe(false);
});
it("keeps typing alive only while the source has active runs", async () => {
await handleDiscordSubagentProgress(api, started("run-typing"));
expect(sendMocks.typing).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(8_500);
expect(sendMocks.typing).toHaveBeenCalledTimes(2);
await handleDiscordSubagentProgress(api, {
phase: "ended",
runId: "run-typing",
outcome: "ok",
});
await vi.advanceTimersByTimeAsync(8_500);
expect(sendMocks.typing).toHaveBeenCalledTimes(2);
});
it("does nothing unless the single enable toggle is true", async () => {
await handleDiscordSubagentProgress(
createApi({ channels: { discord: { token: "test-token" } } }).api,
started("run-disabled"),
);
expect(sendMocks.react).not.toHaveBeenCalled();
expect(sendMocks.typing).not.toHaveBeenCalled();
});
it("ignores a started event that arrives after its terminal event", async () => {
await handleDiscordSubagentProgress(api, {
phase: "ended",
runId: "run-raced",
outcome: "ok",
});
await handleDiscordSubagentProgress(api, started("run-raced"));
expect(sendMocks.react).not.toHaveBeenCalled();
expect(sendMocks.typing).not.toHaveBeenCalled();
});
it("restores durable ownership to clear a running reaction after restart", async () => {
await handleDiscordSubagentProgress(api, started("run-restart"));
resetDiscordSubagentProgressForTest();
await handleDiscordSubagentProgress(api, {
phase: "ended",
runId: "run-restart",
outcome: "ok",
});
expect(progressStore.values.size).toBe(0);
expect(sendMocks.remove).toHaveBeenCalledWith("123", "456", "1⃣", expect.any(Object));
});
it("restores typing for sibling runs when one ends after restart", async () => {
await handleDiscordSubagentProgress(api, started("run-sibling"));
await handleDiscordSubagentProgress(api, started("run-ending"));
resetDiscordSubagentProgressForTest();
sendMocks.react.mockClear();
sendMocks.typing.mockClear();
await handleDiscordSubagentProgress(api, {
phase: "ended",
runId: "run-ending",
outcome: "ok",
});
expect(sendMocks.react).toHaveBeenLastCalledWith("123", "456", "1⃣", expect.any(Object));
expect(sendMocks.typing).toHaveBeenCalledTimes(1);
});
it("does not restore sibling typing after the feature is disabled", async () => {
await handleDiscordSubagentProgress(api, started("run-sibling"));
await handleDiscordSubagentProgress(api, started("run-ending"));
resetDiscordSubagentProgressForTest();
sendMocks.typing.mockClear();
api.config = { channels: { discord: { token: "test-token", subagentProgress: false } } };
await handleDiscordSubagentProgress(api, {
phase: "ended",
runId: "run-ending",
outcome: "ok",
});
expect(sendMocks.typing).not.toHaveBeenCalled();
});
it("restores active counts before presenting a new run after restart", async () => {
await handleDiscordSubagentProgress(api, started("run-before-restart"));
resetDiscordSubagentProgressForTest();
await handleDiscordSubagentProgress(api, started("run-after-restart"));
expect(sendMocks.remove).toHaveBeenCalledWith("123", "456", "1⃣", expect.any(Object));
expect(sendMocks.react).toHaveBeenLastCalledWith("123", "456", "2⃣", expect.any(Object));
});
it("rebuilds a restored count when durable state outran Discord presentation", async () => {
await handleDiscordSubagentProgress(api, started("run-visible"));
progressStore.values.set("run-not-visible", {
key: "default:123:456",
accountId: "default",
channelId: "123",
messageId: "456",
status: "active",
});
resetDiscordSubagentProgressForTest();
sendMocks.remove.mockClear();
await handleDiscordSubagentProgress(api, started("run-after-restart"));
expect(sendMocks.remove).toHaveBeenCalledTimes(1);
expect(sendMocks.remove).toHaveBeenCalledWith("123", "456", "1⃣", expect.any(Object));
expect(sendMocks.remove).not.toHaveBeenCalledWith("123", "456", "2⃣", expect.any(Object));
expect(sendMocks.react).toHaveBeenLastCalledWith("123", "456", "3⃣", expect.any(Object));
});
it("keeps failed terminal cleanup durable until the next source event", async () => {
await handleDiscordSubagentProgress(api, started("run-cleanup"));
sendMocks.remove.mockRejectedValueOnce(new Error("transient Discord failure"));
await handleDiscordSubagentProgress(api, {
phase: "ended",
runId: "run-cleanup",
outcome: "ok",
});
expect(progressStore.values.get("run-cleanup")?.status).toBe("cleanup");
resetDiscordSubagentProgressForTest();
await handleDiscordSubagentProgress(api, started("run-after-cleanup"));
expect(progressStore.values.has("run-cleanup")).toBe(false);
expect(progressStore.values.get("run-after-cleanup")?.status).toBe("active");
expect(sendMocks.react).toHaveBeenLastCalledWith("123", "456", "1⃣", expect.any(Object));
});
it("retries failed terminal reaction cleanup without another source event", async () => {
await handleDiscordSubagentProgress(api, started("run-retry-cleanup"));
sendMocks.remove.mockRejectedValueOnce(new Error("transient Discord failure"));
await handleDiscordSubagentProgress(api, {
phase: "ended",
runId: "run-retry-cleanup",
outcome: "ok",
});
expect(progressStore.values.get("run-retry-cleanup")?.status).toBe("cleanup");
await vi.advanceTimersByTimeAsync(1_000);
expect(progressStore.values.has("run-retry-cleanup")).toBe(false);
expect(sendMocks.remove).toHaveBeenCalledWith("123", "456", "1⃣", expect.any(Object));
});
it("retries a resolved false terminal reaction removal", async () => {
await handleDiscordSubagentProgress(api, started("run-false-cleanup"));
sendMocks.remove.mockResolvedValueOnce({ ok: false });
await handleDiscordSubagentProgress(api, {
phase: "ended",
runId: "run-false-cleanup",
outcome: "ok",
});
expect(progressStore.values.get("run-false-cleanup")?.status).toBe("cleanup");
await vi.advanceTimersByTimeAsync(1_000);
expect(progressStore.values.has("run-false-cleanup")).toBe(false);
});
it("retries a failed terminal outcome marker before consuming ownership", async () => {
await handleDiscordSubagentProgress(api, started("run-failure-marker"));
sendMocks.react.mockResolvedValueOnce({ ok: false }).mockResolvedValue({ ok: true });
await handleDiscordSubagentProgress(api, {
phase: "ended",
runId: "run-failure-marker",
outcome: "error",
});
expect(progressStore.values.get("run-failure-marker")).toMatchObject({
status: "cleanup",
outcome: "error",
});
await vi.advanceTimersByTimeAsync(1_000);
expect(progressStore.values.has("run-failure-marker")).toBe(false);
expect(sendMocks.react.mock.calls.filter((call) => call[2] === "🔴")).toHaveLength(2);
});
it("preserves cleanup ownership when a retry refresh lookup fails", async () => {
await handleDiscordSubagentProgress(api, started("run-refresh-error"));
sendMocks.remove.mockResolvedValueOnce({ ok: false });
await handleDiscordSubagentProgress(api, {
phase: "ended",
runId: "run-refresh-error",
outcome: "ok",
});
progressStore.store.lookup.mockImplementationOnce(async (key) => progressStore.values.get(key));
progressStore.store.lookup.mockRejectedValueOnce(new Error("state store unavailable"));
await vi.advanceTimersByTimeAsync(1_000);
expect(sendMocks.remove).toHaveBeenLastCalledWith("123", "456", "1⃣", expect.any(Object));
expect(progressStore.values.has("run-refresh-error")).toBe(false);
});
it("bounds permanent terminal reaction retries while retaining ownership", async () => {
await handleDiscordSubagentProgress(api, started("run-permanent-failure"));
sendMocks.remove.mockResolvedValue({ ok: false });
await handleDiscordSubagentProgress(api, {
phase: "ended",
runId: "run-permanent-failure",
outcome: "ok",
});
await vi.advanceTimersByTimeAsync(2 * 60 * 60_000);
expect(sendMocks.remove).toHaveBeenCalledTimes(13);
expect(progressStore.values.get("run-permanent-failure")?.status).toBe("cleanup");
});
it("recovers durable cleanup rows on gateway startup", async () => {
progressStore.values.set("run-restart-cleanup", {
key: "default:123:456",
accountId: "default",
channelId: "123",
messageId: "456",
status: "cleanup",
outcome: "ok",
runningEmoji: "1⃣",
});
await recoverDiscordSubagentProgress(api);
expect(sendMocks.remove).toHaveBeenCalledTimes(1);
expect(progressStore.values.has("run-restart-cleanup")).toBe(false);
});
it("restores a persisted terminal outcome marker on gateway startup", async () => {
progressStore.values.set("run-restart-timeout", {
key: "default:123:456",
accountId: "default",
channelId: "123",
messageId: "456",
status: "cleanup",
outcome: "timeout",
runningEmoji: "1⃣",
});
await recoverDiscordSubagentProgress(api);
expect(sendMocks.remove).toHaveBeenCalledWith("123", "456", "1⃣", expect.any(Object));
expect(sendMocks.react).toHaveBeenCalledWith("123", "456", "🔴", expect.any(Object));
expect(progressStore.values.has("run-restart-timeout")).toBe(false);
});
it("cleans interrupted active rows on gateway startup", async () => {
progressStore.values.set("run-interrupted", {
key: "default:123:456",
accountId: "default",
channelId: "123",
messageId: "456",
status: "active",
runningEmoji: "1⃣",
});
await recoverDiscordSubagentProgress(api);
expect(sendMocks.remove).toHaveBeenCalledTimes(1);
expect(progressStore.values.has("run-interrupted")).toBe(false);
});
it("does not remove a progress glyph newly reserved by configuration", async () => {
const collision = createApi({
messages: { ackReaction: "1⃣" },
channels: { discord: { token: "test-token", subagentProgress: true } },
});
collision.progressStore.values.set("run-collision-cleanup", {
key: "default:123:456",
accountId: "default",
channelId: "123",
messageId: "456",
status: "cleanup",
outcome: "ok",
runningEmoji: "1⃣",
});
await recoverDiscordSubagentProgress(collision.api);
expect(sendMocks.remove).not.toHaveBeenCalled();
expect(sendMocks.remove).not.toHaveBeenCalledWith("123", "456", "1⃣", expect.any(Object));
expect(collision.progressStore.values.has("run-collision-cleanup")).toBe(false);
});
it("removes owned progress reactions after the feature is disabled", async () => {
const disabled = createApi({ channels: { discord: { token: "test-token" } } });
disabled.progressStore.values.set("run-disabled-cleanup", {
key: "default:123:456",
accountId: "default",
channelId: "123",
messageId: "456",
status: "cleanup",
outcome: "ok",
runningEmoji: "1⃣",
});
await recoverDiscordSubagentProgress(disabled.api);
expect(sendMocks.remove).toHaveBeenCalledTimes(1);
expect(disabled.progressStore.values.has("run-disabled-cleanup")).toBe(false);
});
it("stops live presentation when the feature is disabled", async () => {
await handleDiscordSubagentProgress(api, started("run-one"));
await handleDiscordSubagentProgress(api, started("run-two"));
api.config.channels!.discord!.subagentProgress = false;
sendMocks.react.mockClear();
sendMocks.remove.mockClear();
const typingCalls = sendMocks.typing.mock.calls.length;
await handleDiscordSubagentProgress(api, {
phase: "ended",
runId: "run-two",
outcome: "error",
});
await vi.advanceTimersByTimeAsync(8_500);
expect(sendMocks.remove).toHaveBeenCalledTimes(1);
expect(sendMocks.react).not.toHaveBeenCalled();
expect(sendMocks.typing).toHaveBeenCalledTimes(typingCalls);
});
it("tears down live presentation when a disabled start arrives", async () => {
await handleDiscordSubagentProgress(api, started("run-active"));
api.config.channels!.discord!.subagentProgress = false;
sendMocks.remove.mockClear();
const typingCalls = sendMocks.typing.mock.calls.length;
await handleDiscordSubagentProgress(api, started("run-ignored"));
await vi.advanceTimersByTimeAsync(8_500);
expect(sendMocks.remove).toHaveBeenCalledWith("123", "456", "1⃣", expect.any(Object));
expect(sendMocks.typing).toHaveBeenCalledTimes(typingCalls);
expect(progressStore.values.has("run-ignored")).toBe(false);
});
it("retains cleanup ownership while the Discord account is disabled", async () => {
const disabled = createApi({
channels: {
discord: { enabled: false, token: "test-token", subagentProgress: true },
},
});
disabled.progressStore.values.set("run-account-disabled", {
key: "default:123:456",
accountId: "default",
channelId: "123",
messageId: "456",
status: "cleanup",
outcome: "ok",
runningEmoji: "1⃣",
});
await recoverDiscordSubagentProgress(disabled.api);
expect(sendMocks.remove).not.toHaveBeenCalled();
expect(disabled.progressStore.values.get("run-account-disabled")?.status).toBe("cleanup");
});
it("retries startup recovery after a transient state listing failure", async () => {
progressStore.values.set("run-list-retry", {
key: "default:123:456",
accountId: "default",
channelId: "123",
messageId: "456",
status: "cleanup",
outcome: "ok",
runningEmoji: "1⃣",
});
progressStore.store.entries.mockRejectedValueOnce(new Error("state store unavailable"));
await recoverDiscordSubagentProgress(api);
await vi.advanceTimersByTimeAsync(1_000);
expect(progressStore.store.entries).toHaveBeenCalledTimes(3);
expect(progressStore.values.has("run-list-retry")).toBe(false);
});
it("uses listed ownership when startup lookup fails transiently", async () => {
progressStore.values.set("run-lookup-recovery", {
key: "default:123:456",
accountId: "default",
channelId: "123",
messageId: "456",
status: "cleanup",
outcome: "ok",
runningEmoji: "1⃣",
});
progressStore.store.lookup.mockRejectedValueOnce(new Error("state store unavailable"));
await recoverDiscordSubagentProgress(api);
expect(sendMocks.remove).toHaveBeenCalledTimes(1);
expect(progressStore.values.has("run-lookup-recovery")).toBe(false);
});
it("rebuilds presentation for a duplicate start replayed after restart", async () => {
await handleDiscordSubagentProgress(api, started("run-replayed"));
resetDiscordSubagentProgressForTest();
sendMocks.react.mockClear();
sendMocks.remove.mockClear();
sendMocks.typing.mockClear();
await handleDiscordSubagentProgress(api, started("run-replayed"));
expect(progressStore.values.size).toBe(1);
expect(sendMocks.remove).toHaveBeenCalledTimes(1);
expect(sendMocks.react).toHaveBeenLastCalledWith("123", "456", "1⃣", expect.any(Object));
expect(sendMocks.typing).toHaveBeenCalledTimes(1);
});
it("retains cleanup when restoring the remaining count fails", async () => {
await handleDiscordSubagentProgress(api, started("run-remaining"));
await handleDiscordSubagentProgress(api, started("run-ending"));
resetDiscordSubagentProgressForTest();
sendMocks.react.mockRejectedValueOnce(new Error("transient Discord failure"));
await handleDiscordSubagentProgress(api, {
phase: "ended",
runId: "run-ending",
outcome: "ok",
});
expect(progressStore.values.get("run-remaining")?.status).toBe("active");
expect(progressStore.values.get("run-ending")?.status).toBe("cleanup");
});
it("persists a replacement count before adding it during restart recovery", async () => {
await handleDiscordSubagentProgress(api, started("run-remaining"));
await handleDiscordSubagentProgress(api, started("run-ending"));
resetDiscordSubagentProgressForTest();
await handleDiscordSubagentProgress(api, {
phase: "ended",
runId: "run-ending",
outcome: "ok",
});
expect(progressStore.values.get("run-remaining")?.runningEmoji).toBe("1⃣");
resetDiscordSubagentProgressForTest();
sendMocks.remove.mockClear();
await recoverDiscordSubagentProgress(api);
expect(sendMocks.remove).toHaveBeenCalledWith("123", "456", "1⃣", expect.any(Object));
});
it("does not resurrect a durable cleanup row on a replayed start", async () => {
progressStore.values.set("run-terminal", {
key: "default:123:456",
accountId: "default",
channelId: "123",
messageId: "456",
status: "cleanup",
outcome: "ok",
});
await handleDiscordSubagentProgress(api, started("run-terminal"));
expect(progressStore.values.has("run-terminal")).toBe(false);
expect(sendMocks.react).not.toHaveBeenCalled();
expect(sendMocks.typing).not.toHaveBeenCalled();
});
it("restores other active runs when a terminal start is replayed", async () => {
progressStore.values.set("run-active", {
key: "default:123:456",
accountId: "default",
channelId: "123",
messageId: "456",
status: "active",
});
progressStore.values.set("run-terminal", {
key: "default:123:456",
accountId: "default",
channelId: "123",
messageId: "456",
status: "cleanup",
outcome: "ok",
});
await handleDiscordSubagentProgress(api, started("run-terminal"));
expect(progressStore.values.has("run-terminal")).toBe(false);
expect(sendMocks.react).toHaveBeenLastCalledWith("123", "456", "1⃣", expect.any(Object));
expect(sendMocks.typing).toHaveBeenCalledTimes(1);
});
it("still clears in-memory presentation when cleanup marking fails", async () => {
await handleDiscordSubagentProgress(api, started("run-store-error"));
progressStore.store.register.mockRejectedValueOnce(new Error("state store unavailable"));
await handleDiscordSubagentProgress(api, {
phase: "ended",
runId: "run-store-error",
outcome: "ok",
});
await vi.advanceTimersByTimeAsync(8_500);
expect(sendMocks.remove).toHaveBeenCalledWith("123", "456", "1⃣", expect.any(Object));
expect(sendMocks.typing).toHaveBeenCalledTimes(1);
expect(progressStore.values.has("run-store-error")).toBe(false);
});
it("excludes the ending row when cleanup marking fails after restart", async () => {
await handleDiscordSubagentProgress(api, started("run-store-error"));
resetDiscordSubagentProgressForTest();
sendMocks.react.mockClear();
sendMocks.remove.mockClear();
progressStore.store.register.mockRejectedValueOnce(new Error("state store unavailable"));
await handleDiscordSubagentProgress(api, {
phase: "ended",
runId: "run-store-error",
outcome: "ok",
});
expect(sendMocks.remove).toHaveBeenCalledTimes(1);
expect(sendMocks.react).not.toHaveBeenCalled();
expect(progressStore.values.has("run-store-error")).toBe(false);
});
it("retains cleanup without mutating reactions when state listing fails", async () => {
await handleDiscordSubagentProgress(api, started("run-list-error"));
resetDiscordSubagentProgressForTest();
sendMocks.remove.mockClear();
progressStore.store.entries.mockRejectedValueOnce(new Error("state store unavailable"));
await handleDiscordSubagentProgress(api, {
phase: "ended",
runId: "run-list-error",
outcome: "ok",
});
expect(sendMocks.remove).not.toHaveBeenCalled();
expect(progressStore.values.get("run-list-error")?.status).toBe("cleanup");
});
it("repairs an in-memory run after a durable lookup failure", async () => {
await handleDiscordSubagentProgress(api, started("run-lookup-error"));
progressStore.store.lookup.mockRejectedValueOnce(new Error("state store unavailable"));
await handleDiscordSubagentProgress(api, {
phase: "ended",
runId: "run-lookup-error",
outcome: "ok",
});
expect(sendMocks.remove).toHaveBeenCalledWith("123", "456", "1⃣", expect.any(Object));
expect(progressStore.values.has("run-lookup-error")).toBe(false);
});
it("retries a terminal lookup after restart", async () => {
await handleDiscordSubagentProgress(api, started("run-lookup-retry"));
resetDiscordSubagentProgressForTest();
sendMocks.remove.mockClear();
progressStore.store.lookup.mockRejectedValueOnce(new Error("state store unavailable"));
await handleDiscordSubagentProgress(api, {
phase: "ended",
runId: "run-lookup-retry",
outcome: "ok",
requester: started("ignored").requester,
});
expect(sendMocks.remove).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1_000);
expect(sendMocks.remove).toHaveBeenCalledWith("123", "456", "1⃣", expect.any(Object));
expect(progressStore.values.has("run-lookup-retry")).toBe(false);
});
it("reopens the state store after a transient open failure", async () => {
const reopened = createApi();
api = reopened.api;
progressStore = reopened.progressStore;
progressStore.values.set("run-open-retry", {
key: "default:123:456",
accountId: "default",
channelId: "123",
messageId: "456",
status: "active",
});
reopened.openKeyedStore.mockImplementationOnce(() => {
throw new Error("state store unavailable");
});
await handleDiscordSubagentProgress(api, {
phase: "ended",
runId: "run-open-retry",
outcome: "ok",
requester: started("ignored").requester,
});
await vi.advanceTimersByTimeAsync(1_000);
expect(reopened.openKeyedStore).toHaveBeenCalledTimes(2);
expect(progressStore.values.has("run-open-retry")).toBe(false);
});
it("does not retry an unowned non-Discord terminal event", async () => {
progressStore.store.lookup.mockRejectedValue(new Error("state store unavailable"));
await handleDiscordSubagentProgress(api, {
phase: "ended",
runId: "run-slack",
outcome: "ok",
requester: { channel: "slack", channelId: "123", messageId: "456" },
});
await vi.advanceTimersByTimeAsync(60_000);
expect(progressStore.store.lookup).toHaveBeenCalledTimes(1);
});
it("never reconciles reactions for a typing-only tracker", async () => {
const typingOnly = createApi();
typingOnly.progressStore.store.registerIfAbsent.mockRejectedValueOnce(
new Error("state store unavailable"),
);
await handleDiscordSubagentProgress(typingOnly.api, started("run-typing-only"));
await handleDiscordSubagentProgress(typingOnly.api, {
phase: "ended",
runId: "run-typing-only",
outcome: "ok",
});
expect(sendMocks.remove).not.toHaveBeenCalled();
expect(sendMocks.typing).toHaveBeenCalledTimes(1);
});
it("defers a new start when restored state cannot be listed", async () => {
progressStore.values.set("run-existing", {
key: "default:123:456",
accountId: "default",
channelId: "123",
messageId: "456",
status: "active",
});
progressStore.store.entries.mockRejectedValueOnce(new Error("state store unavailable"));
await handleDiscordSubagentProgress(api, started("run-deferred"));
expect(progressStore.values.has("run-deferred")).toBe(false);
expect(sendMocks.react).not.toHaveBeenCalled();
expect(sendMocks.typing).not.toHaveBeenCalled();
});
it("keeps consumed cleanup rows as process terminal tombstones", async () => {
progressStore.values.set("run-terminal", {
key: "default:123:456",
accountId: "default",
channelId: "123",
messageId: "456",
status: "cleanup",
outcome: "ok",
});
await handleDiscordSubagentProgress(api, started("run-other"));
await handleDiscordSubagentProgress(api, started("run-terminal"));
expect(progressStore.values.has("run-terminal")).toBe(false);
expect(progressStore.values.get("run-other")?.status).toBe("active");
expect(sendMocks.react).toHaveBeenLastCalledWith("123", "456", "1⃣", expect.any(Object));
});
it("does not overwrite a cleanup tombstone while a sibling tracker is active", async () => {
await handleDiscordSubagentProgress(api, started("run-active"));
progressStore.values.set("run-terminal", {
key: "default:123:456",
accountId: "default",
channelId: "123",
messageId: "456",
status: "cleanup",
outcome: "ok",
});
sendMocks.react.mockClear();
await handleDiscordSubagentProgress(api, started("run-terminal"));
expect(progressStore.values.get("run-terminal")?.status).toBe("cleanup");
expect(sendMocks.react).not.toHaveBeenCalled();
});
it("does not treat a terminal-event locator as reaction ownership", async () => {
await handleDiscordSubagentProgress(api, {
phase: "ended",
runId: "run-terminal-locator",
outcome: "ok",
requester: started("ignored").requester,
});
expect(sendMocks.remove).not.toHaveBeenCalled();
});
it("keeps reactions off when an agent identity owns a progress emoji", async () => {
const collisionApi = createApi({
agents: { list: [{ id: "main", identity: { emoji: "1⃣" } }] },
channels: { discord: { token: "test-token", subagentProgress: true } },
}).api;
await handleDiscordSubagentProgress(collisionApi, started("run-collision"));
expect(sendMocks.react).not.toHaveBeenCalled();
expect(sendMocks.typing).toHaveBeenCalledTimes(1);
});
it("preserves a live progress glyph newly reserved by configuration", async () => {
await handleDiscordSubagentProgress(api, started("run-before-reload"));
api.config.messages = { ackReaction: "1⃣" };
sendMocks.react.mockClear();
sendMocks.remove.mockClear();
await handleDiscordSubagentProgress(api, started("run-after-reload"));
await handleDiscordSubagentProgress(api, {
phase: "ended",
runId: "run-before-reload",
outcome: "ok",
});
expect(sendMocks.remove).not.toHaveBeenCalledWith("123", "456", "1⃣", expect.any(Object));
expect(sendMocks.react).not.toHaveBeenCalled();
});
it("retains ownership when collision cleanup fails", async () => {
await handleDiscordSubagentProgress(api, started("run-before-collision"));
api.config.messages = { ackReaction: "2⃣" };
sendMocks.remove.mockResolvedValueOnce({ ok: false });
await handleDiscordSubagentProgress(api, started("run-after-collision"));
expect(progressStore.values.get("run-before-collision")?.runningEmoji).toBe("1⃣");
expect(progressStore.values.has("run-after-collision")).toBe(false);
});
it("uses the native source channel for Discord DM routes", async () => {
await handleDiscordSubagentProgress(api, {
...started("run-dm"),
requester: {
channel: "discord",
accountId: "default",
to: "user:789",
channelId: "987",
messageId: "456",
},
});
expect(sendMocks.react).toHaveBeenCalledWith("987", "456", "1⃣", expect.any(Object));
expect(sendMocks.typing).toHaveBeenCalledWith("987", expect.any(Object));
});
});

View file

@ -0,0 +1,736 @@
// Discord plugin module maps portable subagent progress onto source-message feedback.
import { resolveDiscordAccount } from "./accounts.js";
import { reactMessageDiscord, removeReactionDiscord } from "./send.reactions.js";
import { sendTypingDiscord } from "./send.typing.js";
import {
FAILURE_EMOJI,
RUNNING_EMOJIS,
reactionsAreAvailable,
reservedReactionEmojis,
resolveDiscordProgressTarget,
type DiscordProgressRequester,
} from "./subagent-progress-config.js";
import {
MAX_TRACKED_RUNS,
PROGRESS_STORE_TTL_MS,
consumeProgressRun,
getProgressStore,
listProgressStateForKey,
logFailure,
lookupProgressRun,
markRunTerminal,
markProgressRunForCleanup,
persistedProgressRunFromTracker,
persistedTerminalOutcome,
persistProgressRun,
resetDiscordSubagentProgressStateForTest,
runQueued,
terminalOutcome,
type PersistedProgressRun,
type PersistProgressResult,
type ProgressApi,
type ProgressTracker,
type SubagentProgressOutcome,
} from "./subagent-progress-state.js";
const TYPING_INTERVAL_MS = 8_500;
const TYPING_TTL_MS = 60 * 60_000;
const TERMINAL_LOOKUP_RETRY_MS = 1_000;
const TERMINAL_RETRY_MAX_DELAY_MS = 60 * 60_000;
const TERMINAL_RETRY_MAX_ATTEMPTS = 12;
const STARTUP_RETRY_MAX_ATTEMPTS = 12;
type SubagentProgressEvent =
| {
phase: "started";
runId: string;
requester?: DiscordProgressRequester;
}
| {
phase: "ended";
runId: string;
outcome: SubagentProgressOutcome;
requester?: Extract<SubagentProgressEvent, { phase: "started" }>["requester"];
};
type PersistedReconciliationResult =
| { ok: false }
| {
ok: true;
activeRunIds: string[];
reactionsEnabled: boolean;
typingEnabled: boolean;
runningEmoji?: string;
};
const trackers = new Map<string, ProgressTracker>();
const trackerKeyByRunId = new Map<string, string>();
const terminalRetryTimers = new Map<string, ReturnType<typeof setTimeout>>();
const terminalRetryExpiresAt = new Map<string, number>();
const terminalRetryAttempts = new Map<string, number>();
const startupRecoveryRetries = new Map<
ProgressApi,
{ attempts: number; timer?: ReturnType<typeof setTimeout> }
>();
function clearTerminalRetry(runId: string) {
const timer = terminalRetryTimers.get(runId);
if (timer) {
clearTimeout(timer);
}
terminalRetryTimers.delete(runId);
terminalRetryExpiresAt.delete(runId);
terminalRetryAttempts.delete(runId);
}
function cancelTerminalRetryTimer(runId: string) {
const timer = terminalRetryTimers.get(runId);
if (timer) {
clearTimeout(timer);
}
terminalRetryTimers.delete(runId);
}
async function setReaction(api: ProgressApi, tracker: ProgressTracker, emoji: string) {
try {
const result = await reactMessageDiscord(tracker.channelId, tracker.messageId, emoji, {
cfg: api.config,
accountId: tracker.accountId,
});
return result.ok;
} catch (error) {
logFailure(api, "reaction add", error);
return false;
}
}
async function clearReaction(api: ProgressApi, tracker: ProgressTracker, emoji?: string) {
if (!emoji) {
return true;
}
try {
const result = await removeReactionDiscord(tracker.channelId, tracker.messageId, emoji, {
cfg: api.config,
accountId: tracker.accountId,
});
return result.ok;
} catch (error) {
logFailure(api, "reaction remove", error);
return false;
}
}
async function clearRunningReactions(
api: ProgressApi,
tracker: ProgressTracker,
emojis: readonly string[],
) {
const results = await Promise.all(emojis.map((emoji) => clearReaction(api, tracker, emoji)));
return results.every(Boolean);
}
async function persistTrackerRunningEmoji(api: ProgressApi, tracker: ProgressTracker) {
const store = getProgressStore(api);
if (!store) {
return false;
}
try {
await Promise.all(
Array.from(tracker.persistedRunIds, (runId) =>
store.register(runId, persistedProgressRunFromTracker(tracker)),
),
);
return true;
} catch (error) {
logFailure(api, "reaction ownership write", error);
return false;
}
}
async function updateRunningReaction(api: ProgressApi, tracker: ProgressTracker) {
if (!tracker.reactionsEnabled) {
return true;
}
const nextEmoji =
tracker.activeRunIds.size > 0
? RUNNING_EMOJIS[Math.min(tracker.activeRunIds.size, RUNNING_EMOJIS.length) - 1]
: undefined;
if (nextEmoji === tracker.runningEmoji) {
if (!nextEmoji || tracker.runningEmojiConfirmed) {
return true;
}
if (!(await persistTrackerRunningEmoji(api, tracker))) {
return false;
}
tracker.runningEmojiConfirmed = await setReaction(api, tracker, nextEmoji);
return tracker.runningEmojiConfirmed;
}
if (!(await clearReaction(api, tracker, tracker.runningEmoji))) {
return false;
}
tracker.runningEmoji = undefined;
tracker.runningEmojiConfirmed = false;
if (nextEmoji) {
// Discord may apply the idempotent add before its response is lost. Keep
// attempted ownership so terminal cleanup still removes the possible glyph.
tracker.runningEmoji = nextEmoji;
if (!(await persistTrackerRunningEmoji(api, tracker))) {
tracker.runningEmoji = undefined;
return false;
}
tracker.runningEmojiConfirmed = await setReaction(api, tracker, nextEmoji);
return tracker.runningEmojiConfirmed;
}
await persistTrackerRunningEmoji(api, tracker);
return true;
}
async function disableTrackerReactionsOnCollision(
api: ProgressApi,
tracker: ProgressTracker,
ackReaction?: string,
) {
if (!tracker.reactionsEnabled || reactionsAreAvailable(api.config, ackReaction)) {
return true;
}
const reserved = reservedReactionEmojis(api.config, ackReaction);
if (tracker.runningEmoji && !reserved.has(tracker.runningEmoji)) {
if (!(await clearReaction(api, tracker, tracker.runningEmoji))) {
tracker.reactionsEnabled = false;
return false;
}
}
tracker.runningEmoji = undefined;
tracker.runningEmojiConfirmed = false;
tracker.reactionsEnabled = false;
await persistTrackerRunningEmoji(api, tracker);
return true;
}
async function sendTyping(api: ProgressApi, tracker: ProgressTracker) {
try {
await sendTypingDiscord(tracker.channelId, {
cfg: api.config,
accountId: tracker.accountId,
});
} catch (error) {
logFailure(api, "typing", error);
}
}
function startTyping(api: ProgressApi, tracker: ProgressTracker) {
tracker.typingExpiresAt = Date.now() + TYPING_TTL_MS;
void sendTyping(api, tracker);
if (tracker.typingTimer) {
return;
}
tracker.typingTimer = setInterval(() => {
if (tracker.activeRunIds.size === 0 || Date.now() >= tracker.typingExpiresAt) {
stopTyping(tracker);
return;
}
void sendTyping(api, tracker);
}, TYPING_INTERVAL_MS);
tracker.typingTimer.unref?.();
}
function stopTyping(tracker: ProgressTracker) {
if (tracker.typingTimer) {
clearInterval(tracker.typingTimer);
tracker.typingTimer = undefined;
}
}
async function handleStarted(
api: ProgressApi,
event: Extract<SubagentProgressEvent, { phase: "started" }>,
) {
const runId = event.runId.trim();
const target = resolveDiscordProgressTarget(event.requester);
if (!runId || !target || terminalOutcome(runId)) {
return;
}
const account = resolveDiscordAccount({ cfg: api.config, accountId: event.requester?.accountId });
const key = `${account.accountId}:${target.channelId}:${target.messageId}`;
if (!account.enabled || account.config.subagentProgress !== true) {
await runQueued(key, async () => {
const tracker = trackers.get(key);
if (!tracker) {
return;
}
stopTyping(tracker);
tracker.reactionsEnabled = false;
if (!account.enabled || !tracker.runningEmoji) {
return;
}
const reserved = reservedReactionEmojis(api.config, account.config.ackReaction);
if (
!reserved.has(tracker.runningEmoji) &&
(await clearReaction(api, tracker, tracker.runningEmoji))
) {
tracker.runningEmoji = undefined;
tracker.runningEmojiConfirmed = false;
await persistTrackerRunningEmoji(api, tracker);
}
});
return;
}
await runQueued(key, async () => {
let tracker = trackers.get(key);
let restoredCurrentRunWasTerminal = false;
if (!tracker) {
const reactionsEnabled = reactionsAreAvailable(api.config, account.config.ackReaction);
const restored = await listProgressStateForKey(api, key);
if (!restored.ok) {
return;
}
restoredCurrentRunWasTerminal = restored.cleanupRuns.some(
(cleanup) => cleanup.runId === runId,
);
tracker = {
accountId: account.accountId,
channelId: target.channelId,
messageId: target.messageId,
activeRunIds: new Set(restored.activeRunIds),
persistedRunIds: new Set(restored.activeRunIds),
runningEmojiConfirmed: false,
reactionsEnabled,
typingExpiresAt: 0,
};
// The process can stop between durable registration and either Discord
// reaction call. Rebuild from bot-owned glyphs instead of guessing which
// count made it to Discord.
if (restored.activeRunIds.length > 0 || restored.cleanupRuns.length > 0) {
const reserved = reservedReactionEmojis(api.config, account.config.ackReaction);
const cleanupEmojis = restored.ownedEmojis.filter((emoji) => !reserved.has(emoji));
const countsCleared = await clearRunningReactions(api, tracker, cleanupEmojis);
const failedCleanupRuns = restored.cleanupRuns.filter(
(cleanup) => cleanup.value.outcome !== "ok",
);
const failurePresented =
failedCleanupRuns.length === 0 ||
!reactionsEnabled ||
(countsCleared && (await setReaction(api, tracker, FAILURE_EMOJI)));
if (countsCleared && failurePresented) {
for (const cleanup of restored.cleanupRuns) {
markRunTerminal(cleanup.runId, cleanup.value.outcome);
}
await Promise.all(
restored.cleanupRuns.map((cleanup) => consumeProgressRun(api, cleanup.runId)),
);
} else {
tracker.reactionsEnabled = false;
for (const cleanup of restored.cleanupRuns) {
scheduleTerminalLookupRetry(
api,
{
phase: "ended",
runId: cleanup.runId,
outcome: cleanup.value.outcome,
},
cleanup.value,
);
}
}
}
trackers.set(key, tracker);
}
if (!(await disableTrackerReactionsOnCollision(api, tracker, account.config.ackReaction))) {
return;
}
if (restoredCurrentRunWasTerminal) {
if (tracker.activeRunIds.size > 0) {
await updateRunningReaction(api, tracker);
startTyping(api, tracker);
} else {
trackers.delete(key);
}
return;
}
if (tracker.activeRunIds.has(runId)) {
trackerKeyByRunId.set(runId, key);
await updateRunningReaction(api, tracker);
startTyping(api, tracker);
return;
}
let persistResult: PersistProgressResult = "error";
if (tracker.reactionsEnabled) {
persistResult = await persistProgressRun(api, runId, tracker);
if (persistResult === "terminal") {
markRunTerminal(runId, "unknown");
return;
}
if (persistResult === "conflict") {
api.logger.debug?.(`discord subagent progress ignored conflicting run id: ${runId}`);
return;
}
if (persistResult === "error") {
await clearReaction(api, tracker, tracker.runningEmoji);
tracker.runningEmoji = undefined;
tracker.runningEmojiConfirmed = false;
tracker.reactionsEnabled = false;
}
}
tracker.activeRunIds.add(runId);
trackerKeyByRunId.set(runId, key);
if (persistResult === "persisted") {
tracker.persistedRunIds.add(runId);
}
// A fast child can end between hook dispatch and durable presentation setup.
// The tombstone makes that ordering explicit and prevents a late start from sticking.
const endedOutcome = terminalOutcome(runId);
if (endedOutcome) {
const owned = persistedProgressRunFromTracker(tracker);
await markProgressRunForCleanup(api, runId, owned, endedOutcome);
const failurePresented =
endedOutcome === "ok" ||
!tracker.reactionsEnabled ||
(await setReaction(api, tracker, FAILURE_EMOJI));
if (failurePresented) {
await consumeProgressRun(api, runId);
} else {
scheduleTerminalLookupRetry(
api,
{ phase: "ended", runId, outcome: endedOutcome, requester: event.requester },
{ ...owned, status: "cleanup", outcome: endedOutcome },
);
}
tracker.activeRunIds.delete(runId);
tracker.persistedRunIds.delete(runId);
trackerKeyByRunId.delete(runId);
await updateRunningReaction(api, tracker);
if (tracker.activeRunIds.size === 0) {
stopTyping(tracker);
trackers.delete(key);
}
return;
}
await updateRunningReaction(api, tracker);
startTyping(api, tracker);
});
}
async function reconcilePersistedTracker(
api: ProgressApi,
persisted: PersistedProgressRun,
outcome: Extract<SubagentProgressEvent, { phase: "ended" }>["outcome"],
endingRunId: string,
): Promise<PersistedReconciliationResult> {
const store = getProgressStore(api);
let activeRunIds: string[] = [];
if (store) {
try {
const entries = await store.entries();
activeRunIds = entries
.filter(
(entry) =>
entry.key !== endingRunId &&
entry.value.key === persisted.key &&
entry.value.status === "active",
)
.map((entry) => entry.key);
} catch (error) {
logFailure(api, "state store list", error);
return { ok: false };
}
}
const tracker: ProgressTracker = {
accountId: persisted.accountId,
channelId: persisted.channelId,
messageId: persisted.messageId,
activeRunIds: new Set(activeRunIds),
persistedRunIds: new Set(activeRunIds),
runningEmojiConfirmed: false,
reactionsEnabled: true,
typingExpiresAt: 0,
};
const account = resolveDiscordAccount({ cfg: api.config, accountId: persisted.accountId });
const typingEnabled = account.enabled && account.config.subagentProgress === true;
const reserved = reservedReactionEmojis(api.config, account.config.ackReaction);
const cleanupEmojis =
persisted.runningEmoji && !reserved.has(persisted.runningEmoji) ? [persisted.runningEmoji] : [];
const reactionsEnabled =
typingEnabled && reactionsAreAvailable(api.config, account.config.ackReaction);
// Preserve newly reserved keycaps, but remove every unreserved glyph that
// this feature could have left behind under the previous configuration.
const reactionsCleared =
account.enabled && (await clearRunningReactions(api, tracker, cleanupEmojis));
const nextEmoji = RUNNING_EMOJIS[Math.min(activeRunIds.length, RUNNING_EMOJIS.length) - 1];
let countPresented = true;
if (reactionsEnabled && reactionsCleared && nextEmoji) {
tracker.runningEmoji = nextEmoji;
countPresented =
(await persistTrackerRunningEmoji(api, tracker)) &&
(await setReaction(api, tracker, nextEmoji));
tracker.runningEmojiConfirmed = countPresented;
}
const outcomePresented =
outcome === "ok" ||
!reactionsEnabled ||
(reactionsCleared && countPresented && (await setReaction(api, tracker, FAILURE_EMOJI)));
if (!reactionsCleared || !countPresented || !outcomePresented) {
return { ok: false };
}
return {
ok: true,
activeRunIds,
reactionsEnabled,
typingEnabled,
...(reactionsEnabled && nextEmoji ? { runningEmoji: nextEmoji } : {}),
};
}
function scheduleTerminalLookupRetry(
api: ProgressApi,
event: Extract<SubagentProgressEvent, { phase: "ended" }>,
owned?: PersistedProgressRun,
) {
const runId = event.runId.trim();
if (!runId || terminalRetryTimers.has(runId)) {
return;
}
if (!owned) {
const target = resolveDiscordProgressTarget(event.requester);
const account = resolveDiscordAccount({
cfg: api.config,
accountId: event.requester?.accountId,
});
if (!target || !account.enabled || account.config.subagentProgress !== true) {
return;
}
}
if (terminalRetryTimers.size >= MAX_TRACKED_RUNS) {
return;
}
const expiresAt = terminalRetryExpiresAt.get(runId) ?? Date.now() + PROGRESS_STORE_TTL_MS;
const attempts = terminalRetryAttempts.get(runId) ?? 0;
if (expiresAt <= Date.now() || attempts >= TERMINAL_RETRY_MAX_ATTEMPTS) {
clearTerminalRetry(runId);
return;
}
terminalRetryExpiresAt.set(runId, expiresAt);
terminalRetryAttempts.set(runId, attempts + 1);
const retryDelayMs = Math.min(
TERMINAL_LOOKUP_RETRY_MS * 2 ** Math.min(attempts, 12),
TERMINAL_RETRY_MAX_DELAY_MS,
);
const timer = setTimeout(() => {
terminalRetryTimers.delete(runId);
void handleEnded(api, event, owned);
}, retryDelayMs);
timer.unref?.();
terminalRetryTimers.set(runId, timer);
}
async function handleEnded(
api: ProgressApi,
event: Extract<SubagentProgressEvent, { phase: "ended" }>,
persistedHint?: PersistedProgressRun,
) {
const runId = event.runId.trim();
if (!runId) {
return;
}
markRunTerminal(runId, event.outcome);
const lookup = await lookupProgressRun(api, runId);
const persisted = lookup.status === "found" ? lookup.value : persistedHint;
const key = trackerKeyByRunId.get(runId) ?? persisted?.key;
if (!key) {
if (lookup.status === "error") {
scheduleTerminalLookupRetry(api, event);
} else {
clearTerminalRetry(runId);
}
return;
}
cancelTerminalRetryTimer(runId);
await runQueued(key, async () => {
const tracker = trackers.get(key);
const currentLookup = await lookupProgressRun(api, runId);
const currentPersisted =
currentLookup.status === "found"
? currentLookup.value
: currentLookup.status === "error"
? (persistedHint ?? persisted)
: (persistedHint ?? (tracker ? undefined : persisted));
const outcome =
persistedTerminalOutcome(currentPersisted) ??
persistedTerminalOutcome(persisted) ??
event.outcome;
const retryEvent = outcome === event.outcome ? event : { ...event, outcome };
trackerKeyByRunId.delete(runId);
const owned =
tracker?.persistedRunIds.has(runId) && currentPersisted?.status !== "cleanup"
? persistedProgressRunFromTracker(tracker)
: currentPersisted;
const cleanupMarked = owned
? owned.status === "cleanup" || (await markProgressRunForCleanup(api, runId, owned, outcome))
: true;
if (tracker) {
const currentAccount = resolveDiscordAccount({
cfg: api.config,
accountId: tracker.accountId,
});
if (!currentAccount.enabled || currentAccount.config.subagentProgress !== true) {
tracker.reactionsEnabled = false;
stopTyping(tracker);
} else {
await disableTrackerReactionsOnCollision(api, tracker, currentAccount.config.ackReaction);
}
}
if (!tracker) {
const reconciliation = owned
? await reconcilePersistedTracker(api, owned, outcome, runId)
: { ok: false as const };
if (reconciliation.ok && owned) {
const consumed = await consumeProgressRun(api, runId);
if (!consumed) {
scheduleTerminalLookupRetry(api, retryEvent, owned);
} else {
clearTerminalRetry(runId);
}
if (reconciliation.typingEnabled && reconciliation.activeRunIds.length > 0) {
const restoredTracker: ProgressTracker = {
accountId: owned.accountId,
channelId: owned.channelId,
messageId: owned.messageId,
activeRunIds: new Set(reconciliation.activeRunIds),
persistedRunIds: new Set(reconciliation.activeRunIds),
runningEmojiConfirmed: Boolean(reconciliation.runningEmoji),
reactionsEnabled: reconciliation.reactionsEnabled,
...(reconciliation.runningEmoji ? { runningEmoji: reconciliation.runningEmoji } : {}),
typingExpiresAt: 0,
};
trackers.set(key, restoredTracker);
for (const activeRunId of reconciliation.activeRunIds) {
trackerKeyByRunId.set(activeRunId, key);
}
startTyping(api, restoredTracker);
}
} else if (owned) {
scheduleTerminalLookupRetry(api, retryEvent, owned);
}
return;
}
tracker.activeRunIds.delete(runId);
tracker.persistedRunIds.delete(runId);
const countReconciled = tracker.reactionsEnabled
? await updateRunningReaction(api, tracker)
: owned
? (await reconcilePersistedTracker(api, owned, outcome, runId)).ok
: true;
const outcomePresented =
outcome === "ok" ||
!tracker.reactionsEnabled ||
(countReconciled && (await setReaction(api, tracker, FAILURE_EMOJI)));
const reconciled = countReconciled && outcomePresented;
if (reconciled && owned) {
const consumed = await consumeProgressRun(api, runId);
if (!consumed) {
scheduleTerminalLookupRetry(api, retryEvent, owned);
} else {
clearTerminalRetry(runId);
}
if (!consumed && !cleanupMarked) {
await markProgressRunForCleanup(api, runId, owned, outcome);
}
} else if (owned) {
scheduleTerminalLookupRetry(api, retryEvent, owned);
}
if (tracker.activeRunIds.size === 0) {
stopTyping(tracker);
trackers.delete(key);
}
});
}
async function handleDiscordSubagentProgressImpl(api: ProgressApi, event: SubagentProgressEvent) {
if (event.phase === "started") {
await handleStarted(api, event);
return;
}
await handleEnded(api, event);
}
function clearStartupRecoveryRetry(api: ProgressApi) {
const retry = startupRecoveryRetries.get(api);
if (retry?.timer) {
clearTimeout(retry.timer);
}
startupRecoveryRetries.delete(api);
}
function scheduleStartupRecoveryRetry(api: ProgressApi) {
const retry = startupRecoveryRetries.get(api) ?? { attempts: 0 };
if (retry.timer || retry.attempts >= STARTUP_RETRY_MAX_ATTEMPTS) {
return;
}
const delayMs = Math.min(
TERMINAL_LOOKUP_RETRY_MS * 2 ** retry.attempts,
TERMINAL_RETRY_MAX_DELAY_MS,
);
retry.attempts += 1;
retry.timer = setTimeout(() => {
retry.timer = undefined;
void recoverDiscordSubagentProgress(api);
}, delayMs);
retry.timer.unref?.();
startupRecoveryRetries.set(api, retry);
}
export async function recoverDiscordSubagentProgress(api: ProgressApi) {
const store = getProgressStore(api);
if (!store) {
if (api.runtime?.state) {
scheduleStartupRecoveryRetry(api);
}
return;
}
let persistedRuns: Array<{ key: string; value: PersistedProgressRun }>;
try {
persistedRuns = await store.entries();
} catch (error) {
logFailure(api, "startup recovery list", error);
scheduleStartupRecoveryRetry(api);
return;
}
clearStartupRecoveryRetry(api);
// Subagents share the gateway process, so no active run survives a cold
// start. Replaying every row repairs both interrupted and pending cleanup.
for (const entry of persistedRuns) {
await handleEnded(
api,
{
phase: "ended",
runId: entry.key,
outcome: persistedTerminalOutcome(entry.value) ?? "unknown",
},
entry.value,
);
}
}
function resetDiscordSubagentProgressForTest() {
for (const tracker of trackers.values()) {
stopTyping(tracker);
}
trackers.clear();
trackerKeyByRunId.clear();
for (const timer of terminalRetryTimers.values()) {
clearTimeout(timer);
}
terminalRetryTimers.clear();
terminalRetryExpiresAt.clear();
terminalRetryAttempts.clear();
for (const retry of startupRecoveryRetries.values()) {
if (retry.timer) {
clearTimeout(retry.timer);
}
}
startupRecoveryRetries.clear();
resetDiscordSubagentProgressStateForTest();
}
export const handleDiscordSubagentProgress = Object.assign(handleDiscordSubagentProgressImpl, {
resetForTest: resetDiscordSubagentProgressForTest,
});

View file

@ -5,10 +5,21 @@ import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
const loadDiscordSubagentHooksModule = createLazyRuntimeModule(
() => import("./src/subagent-hooks.js"),
);
const loadDiscordSubagentProgressModule = createLazyRuntimeModule(
() => import("./src/subagent-progress.js"),
);
// 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("gateway_start", async () => {
const { recoverDiscordSubagentProgress } = await loadDiscordSubagentProgressModule();
await recoverDiscordSubagentProgress(api);
});
api.on("subagent_progress", async (event) => {
const { handleDiscordSubagentProgress } = await loadDiscordSubagentProgressModule();
await handleDiscordSubagentProgress(api, event);
});
api.on("subagent_ended", async (event) => {
const { handleDiscordSubagentEnded } = await loadDiscordSubagentHooksModule();
handleDiscordSubagentEnded(event);

View file

@ -30,6 +30,7 @@ const fastModeEnv = vi.hoisted(() => {
const hookRunnerMocks = vi.hoisted(() => ({
runSubagentSpawning: vi.fn(async () => undefined),
runSubagentSpawned: vi.fn(async () => {}),
runSubagentProgress: vi.fn(async () => {}),
runSubagentEnded: vi.fn(async () => {}),
}));
@ -187,12 +188,16 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
resetSubagentRegistryForTests({ persist: false });
hookRunnerMocks.runSubagentSpawning.mockClear();
hookRunnerMocks.runSubagentSpawned.mockClear();
hookRunnerMocks.runSubagentProgress.mockClear();
hookRunnerMocks.runSubagentEnded.mockClear();
setSessionsSpawnHookRunnerOverride({
hasHooks: (hookName: string) =>
hookName === "subagent_spawned" || hookName === "subagent_ended",
hookName === "subagent_spawned" ||
hookName === "subagent_progress" ||
hookName === "subagent_ended",
runSubagentSpawning: hookRunnerMocks.runSubagentSpawning,
runSubagentSpawned: hookRunnerMocks.runSubagentSpawned,
runSubagentProgress: hookRunnerMocks.runSubagentProgress,
runSubagentEnded: hookRunnerMocks.runSubagentEnded,
});
callGatewayMock.mockClear();

View file

@ -623,6 +623,7 @@ export function createOpenClawTools(
currentMessagingTarget: options?.currentMessagingTarget,
currentChannelId: options?.currentChannelId,
currentThreadTs: options?.currentThreadTs,
currentMessageId: options?.currentMessageId,
agentGroupId: options?.agentGroupId,
agentGroupChannel: options?.agentGroupChannel,
agentGroupSpace: options?.agentGroupSpace,

View file

@ -28,6 +28,7 @@ const hoisted = vi.hoisted(() => ({
const hookRunnerMocks = vi.hoisted(() => ({
hasSubagentEndedHook: true,
runSubagentSpawned: vi.fn(async () => {}),
runSubagentProgress: vi.fn(async () => {}),
runSubagentEnded: vi.fn(async () => {}),
}));
@ -109,6 +110,9 @@ async function spawn(params?: {
agentAccountId?: string;
agentTo?: string;
agentThreadId?: string | number;
currentMessagingTarget?: string;
currentChannelId?: string;
currentMessageId?: string | number;
}) {
return await spawnSubagentDirect(
{
@ -128,6 +132,9 @@ async function spawn(params?: {
agentAccountId: params?.agentAccountId,
agentTo: params?.agentTo,
agentThreadId: params?.agentThreadId,
currentMessagingTarget: params?.currentMessagingTarget,
currentChannelId: params?.currentChannelId,
currentMessageId: params?.currentMessageId,
},
);
}
@ -204,8 +211,10 @@ beforeAll(async () => {
hookRunner: {
hasHooks: (hookName: string) =>
hookName === "subagent_spawned" ||
hookName === "subagent_progress" ||
(hookName === "subagent_ended" && hookRunnerMocks.hasSubagentEndedHook),
runSubagentSpawned: hookRunnerMocks.runSubagentSpawned,
runSubagentProgress: hookRunnerMocks.runSubagentProgress,
runSubagentEnded: hookRunnerMocks.runSubagentEnded,
},
getSessionBindingService: () => bindingMocks,
@ -221,6 +230,7 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
hoisted.updateSessionStoreMock.mockReset();
hookRunnerMocks.hasSubagentEndedHook = true;
hookRunnerMocks.runSubagentSpawned.mockClear();
hookRunnerMocks.runSubagentProgress.mockClear();
hookRunnerMocks.runSubagentEnded.mockClear();
bindingMocks.getCapabilities.mockClear();
bindingMocks.getCapabilities.mockReturnValue({
@ -291,6 +301,9 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
agentAccountId: "work",
agentTo: "channel:123",
agentThreadId: 456,
currentMessagingTarget: "channel:source",
currentChannelId: "source-native",
currentMessageId: "message-789",
context: "isolated",
});
@ -369,6 +382,27 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
},
"spawned context",
);
expect(hookRunnerMocks.runSubagentProgress).toHaveBeenCalledWith(
{
phase: "started",
runId: "run-1",
childSessionKey: event.childSessionKey,
requester: {
channel: "discord",
accountId: "work",
to: "channel:source",
threadId: 456,
channelId: "source-native",
messageId: "message-789",
},
},
ctx,
);
expect(
hookRunnerMocks.runSubagentProgress.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
).toBeLessThan(
hookRunnerMocks.runSubagentSpawned.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
);
});
it("emits subagent_spawned with threadRequested=false when not requested", async () => {

View file

@ -149,6 +149,40 @@ export function resolveLifecycleOutcomeFromRunOutcome(
return SUBAGENT_ENDED_OUTCOME_OK;
}
/** Emits the transient presentation event for a newly terminal child run. */
export async function emitSubagentProgressEndedHook(entry: SubagentRunRecord): Promise<void> {
const hookRunner = getGlobalHookRunner();
if (!hookRunner?.hasHooks("subagent_progress")) {
return;
}
const outcome =
entry.endedReason === SUBAGENT_ENDED_REASON_KILLED
? "killed"
: entry.outcome
? resolveLifecycleOutcomeFromRunOutcome(entry.outcome)
: "unknown";
try {
await hookRunner.runSubagentProgress(
{
phase: "ended",
runId: entry.runId,
childSessionKey: entry.childSessionKey,
outcome,
requester: entry.progressOrigin,
},
{
runId: entry.runId,
childSessionKey: entry.childSessionKey,
requesterSessionKey: entry.requesterSessionKey,
},
);
} catch (err) {
log.warn(
`failed to emit subagent progress for run ${entry.runId}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/** Emits the subagent_ended hook once per completed run. */
export async function emitSubagentEndedHookOnce(params: {
entry: SubagentRunRecord;

View file

@ -203,6 +203,7 @@ function createLifecycleController({
resolveSubagentTask: () => ({ lookup: "available" }),
shouldEmitEndedHookForRun: () => false,
emitSubagentEndedHookForRun: vi.fn(async () => {}),
emitSubagentProgressEndedForRun: vi.fn(async () => {}),
notifyContextEngineSubagentEnded: vi.fn(async () => {}),
retireSupersededRun: vi.fn(async () => {}),
resumeSubagentRun: vi.fn(),
@ -297,6 +298,25 @@ describe("subagent registry lifecycle hardening", () => {
bundleMcpRuntimeMocks.retireSessionMcpRuntimeForSessionKey.mockResolvedValue(true);
});
it("emits one progress end event at the canonical terminal transition", async () => {
const entry = createRunEntry({ expectsCompletionMessage: false });
const emitSubagentProgressEndedForRun = vi.fn(async () => {});
const controller = createLifecycleController({ entry, emitSubagentProgressEndedForRun });
const completion = {
runId: entry.runId,
endedAt: 4_000,
outcome: { status: "ok" as const },
reason: SUBAGENT_ENDED_REASON_COMPLETE,
triggerCleanup: false,
};
await controller.completeSubagentRun(completion);
await controller.completeSubagentRun(completion);
expect(emitSubagentProgressEndedForRun).toHaveBeenCalledTimes(1);
expect(emitSubagentProgressEndedForRun).toHaveBeenCalledWith(entry);
});
it("keeps task finalization, resource retirement, and announce cleanup root-admitted", async () => {
const entry = createRunEntry({ expectsCompletionMessage: true });
let releaseBrowserCleanup: (() => void) | undefined;

View file

@ -187,6 +187,7 @@ export function createSubagentRegistryLifecycleController(params: {
accountId?: string;
isCurrent?: () => boolean;
}): Promise<void>;
emitSubagentProgressEndedForRun(entry: SubagentRunRecord): Promise<void>;
notifyContextEngineSubagentEnded(args: {
childSessionKey: string;
reason: "completed" | "deleted";
@ -208,6 +209,9 @@ export function createSubagentRegistryLifecycleController(params: {
const terminalCompletionLocks = new Map<string, Promise<void>>();
const terminalGenerations = new WeakMap<SubagentRunRecord, number>();
const cleanupGenerations = new WeakMap<SubagentRunRecord, number>();
// Presentation is transient, so dedupe only this record's competing terminal callbacks.
// Persisted lifecycle truth stays limited to durable completion and delivery state.
const progressEndedEntries = new WeakSet<SubagentRunRecord>();
const newerGenerationOwnsSession = (entry: SubagentRunRecord): boolean =>
entry.killReconciliation?.supersededAt !== undefined ||
@ -2180,6 +2184,14 @@ export function createSubagentRegistryLifecycleController(params: {
parentSessionKey: entry.requesterSessionKey,
label: entry.label,
});
// The enclosing steer/session-effects guard admits only the real terminal generation.
if (!isProvisionalKill && !progressEndedEntries.has(entry)) {
progressEndedEntries.add(entry);
await params.emitSubagentProgressEndedForRun(entry);
if (!isTerminalCallbackCurrent(completeParams.runId, entry, terminalGeneration)) {
return;
}
}
}
const shouldEmitEndedHook =
!suppressedForSteerRestart &&

View file

@ -40,7 +40,7 @@ import {
resolveArchiveAfterMs,
safeRemoveAttachmentsDir,
} from "./subagent-registry-helpers.js";
import type { SubagentRunRecord } from "./subagent-registry.types.js";
import type { SubagentProgressOrigin, SubagentRunRecord } from "./subagent-registry.types.js";
import {
compareSubagentRunGeneration,
nextSubagentRunGeneration,
@ -172,6 +172,7 @@ export type RegisterSubagentRunParams = {
controllerSessionKey?: string;
requesterSessionKey: string;
requesterOrigin?: DeliveryContext;
progressOrigin?: SubagentProgressOrigin;
requesterDisplayKey: string;
task: string;
taskName?: string;
@ -758,6 +759,7 @@ export function createSubagentRunManager(params: {
controllerSessionKey,
requesterSessionKey,
requesterOrigin,
progressOrigin: registerParams.progressOrigin,
requesterDisplayKey: registerParams.requesterDisplayKey,
task: registerParams.task,
taskName: registerParams.taskName,

View file

@ -225,6 +225,36 @@ describe("subagent registry persistence", () => {
envSnapshot.restore();
});
it("round-trips the progress source locator through SQLite", async () => {
const progressOrigin = {
channel: "discord",
accountId: "work",
to: "channel:123",
threadId: "789",
channelId: "123",
messageId: "456",
};
const record: SubagentRunRecord = {
runId: "run-progress-origin",
childSessionKey: "agent:main:subagent:progress-origin",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
progressOrigin,
task: "persist progress source",
cleanup: "keep",
createdAt: 1,
};
await writePersistedRegistry(
{ runs: { [record.runId]: record } },
{ seedChildSessions: false },
);
expect(loadSubagentRegistryFromSqlite().get(record.runId)?.progressOrigin).toEqual(
progressOrigin,
);
});
it("persists completed subagent timing into the child session entry", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-subagent-"));
setTestEnvValue("OPENCLAW_STATE_DIR", tempStateDir);

View file

@ -57,6 +57,7 @@ import {
} from "./subagent-lifecycle-events.js";
import {
emitSubagentEndedHookOnce,
emitSubagentProgressEndedHook,
resolveLifecycleOutcomeFromRunOutcome,
} from "./subagent-registry-completion.js";
import {
@ -763,6 +764,7 @@ const subagentLifecycleController = createSubagentRegistryLifecycleController({
resolveSubagentTask: findSubagentTaskForRun,
shouldEmitEndedHookForRun,
emitSubagentEndedHookForRun,
emitSubagentProgressEndedForRun: emitSubagentProgressEndedHook,
notifyContextEngineSubagentEnded,
retireSupersededRun: retireSupersededSubagentRun,
resumeSubagentRun,

View file

@ -9,6 +9,15 @@ import type { SubagentRunOutcome } from "./subagent-announce-output.js";
import type { SubagentLifecycleEndedReason } from "./subagent-lifecycle-events.js";
import type { SpawnSubagentMode } from "./subagent-spawn.types.js";
export type SubagentProgressOrigin = {
channel?: string;
accountId?: string;
to?: string;
threadId?: string | number;
channelId?: string | number;
messageId?: string | number;
};
export type PendingFinalDeliveryPayload = {
requesterSessionKey: string;
requesterOrigin?: DeliveryContext;
@ -118,6 +127,8 @@ export type SubagentRunRecord = {
controllerSessionKey?: string;
requesterSessionKey: string;
requesterOrigin?: DeliveryContext;
/** Durable source locator for transport-neutral progress presentation. */
progressOrigin?: SubagentProgressOrigin;
requesterDisplayKey: string;
task: string;
taskName?: string;

View file

@ -16,7 +16,7 @@ type HookRunner = Pick<SubagentLifecycleHookRunner, "hasHooks"> &
Partial<
Pick<
SubagentLifecycleHookRunner,
"runSubagentSpawning" | "runSubagentSpawned" | "runSubagentEnded"
"runSubagentSpawning" | "runSubagentSpawned" | "runSubagentProgress" | "runSubagentEnded"
>
>;
type SubagentSpawnModuleForTest = Awaited<typeof import("./subagent-spawn.js")> & {

View file

@ -174,6 +174,9 @@ type SpawnSubagentContext = {
agentAccountId?: string;
agentTo?: string;
agentThreadId?: string | number;
currentMessagingTarget?: string;
currentChannelId?: string;
currentMessageId?: string | number;
agentGroupId?: string | null;
agentGroupChannel?: string | null;
agentGroupSpace?: string | null;
@ -1622,6 +1625,14 @@ export async function spawnSubagentDirect(
};
}
const progressOrigin = {
channel: requesterOrigin?.channel,
accountId: requesterOrigin?.accountId,
to: ctx.currentMessagingTarget ?? requesterOrigin?.to,
threadId: requesterOrigin?.threadId,
channelId: ctx.currentChannelId,
messageId: ctx.currentMessageId,
};
try {
registerSubagentRun({
runId: childRunId,
@ -1629,6 +1640,7 @@ export async function spawnSubagentDirect(
controllerSessionKey: ownership.controllerSessionKey,
requesterSessionKey: ownership.completionRequesterSessionKey,
requesterOrigin,
progressOrigin,
requesterDisplayKey: ownership.completionRequesterDisplayKey,
task,
taskName,
@ -1676,6 +1688,26 @@ export async function spawnSubagentDirect(
};
}
if (hookRunner?.hasHooks("subagent_progress")) {
try {
await hookRunner.runSubagentProgress(
{
phase: "started",
runId: childRunId,
childSessionKey,
requester: progressOrigin,
},
{
runId: childRunId,
childSessionKey,
requesterSessionKey: requesterInternalKey,
},
);
} catch {
// Progress presentation is best-effort and must not reject an accepted spawn.
}
}
if (hookRunner?.hasHooks("subagent_spawned")) {
try {
await hookRunner.runSubagentSpawned(

View file

@ -9,10 +9,12 @@ const hoisted = vi.hoisted(() => {
const spawnSubagentDirectMock = vi.fn();
const spawnAcpDirectMock = vi.fn();
const registerSubagentRunMock = vi.fn();
const runSubagentProgressMock = vi.fn(async () => {});
return {
spawnSubagentDirectMock,
spawnAcpDirectMock,
registerSubagentRunMock,
runSubagentProgressMock,
};
});
@ -33,6 +35,13 @@ vi.mock("../subagent-registry.js", () => ({
registerSubagentRun: (...args: unknown[]) => hoisted.registerSubagentRunMock(...args),
}));
vi.mock("../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: () => ({
hasHooks: (hookName: string) => hookName === "subagent_progress",
runSubagentProgress: hoisted.runSubagentProgressMock,
}),
}));
let createSessionsSpawnTool: typeof import("./sessions-spawn-tool.js").createSessionsSpawnTool;
let acpRuntimeRegistry: typeof import("../../acp/runtime/registry.js");
@ -55,6 +64,7 @@ describe("sessions_spawn tool", () => {
runId: "run-acp",
});
hoisted.registerSubagentRunMock.mockReset();
hoisted.runSubagentProgressMock.mockClear();
});
function registerAcpBackendForTest() {
@ -1015,6 +1025,9 @@ describe("sessions_spawn tool", () => {
agentAccountId: "default",
agentTo: "channel:123",
agentThreadId: "456",
currentMessagingTarget: "channel:source",
currentChannelId: "source-native",
currentMessageId: "message-789",
});
const result = await tool.execute("call-2", {
@ -1053,6 +1066,26 @@ describe("sessions_spawn tool", () => {
expect(registration.cleanup).toBe("keep");
expect(registration.spawnMode).toBe("session");
expect(registration.expectsCompletionMessage).toBe(true);
expect(hoisted.runSubagentProgressMock).toHaveBeenCalledWith(
{
phase: "started",
runId: "run-acp",
childSessionKey: "agent:codex:acp:1",
requester: {
channel: "quietchat",
accountId: "default",
to: "channel:source",
threadId: "456",
channelId: "source-native",
messageId: "message-789",
},
},
{
runId: "run-acp",
childSessionKey: "agent:codex:acp:1",
requesterSessionKey: "agent:main:main",
},
);
});
it("passes inherited tool denies to ACP spawns", async () => {

View file

@ -12,6 +12,7 @@ import {
import { getRuntimeConfig } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { resolveSnakeCaseParamKey } from "../../param-key.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
import { normalizeDeliveryContext } from "../../utils/delivery-context.shared.js";
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
@ -226,6 +227,7 @@ export function createSessionsSpawnTool(
currentMessagingTarget?: string;
currentChannelId?: string;
currentThreadTs?: string;
currentMessageId?: string | number;
sandboxed?: boolean;
config?: OpenClawConfig;
/** Explicit agent ID override for cron/hook sessions where session key parsing may not work. */
@ -418,6 +420,14 @@ export function createSessionsSpawnTool(
to: opts?.agentTo,
threadId: opts?.agentThreadId,
});
const progressOrigin = {
channel: requesterOrigin?.channel,
accountId: requesterOrigin?.accountId,
to: opts?.currentMessagingTarget ?? opts?.currentChannelId ?? requesterOrigin?.to,
threadId: requesterOrigin?.threadId,
channelId: opts?.currentChannelId,
messageId: opts?.currentMessageId,
};
const shouldExpectCompletionMessage = result.inlineDelivery
? false
: expectsCompletionMessage;
@ -428,6 +438,7 @@ export function createSessionsSpawnTool(
controllerSessionKey: ownership.controllerSessionKey,
requesterSessionKey: ownership.completionRequesterSessionKey,
requesterOrigin,
progressOrigin,
requesterDisplayKey: ownership.completionRequesterDisplayKey,
task,
taskName,
@ -438,6 +449,26 @@ export function createSessionsSpawnTool(
expectsCompletionMessage: shouldExpectCompletionMessage,
spawnMode: trackedSpawnMode,
});
try {
const hookRunner = getGlobalHookRunner();
if (hookRunner?.hasHooks("subagent_progress")) {
await hookRunner.runSubagentProgress(
{
phase: "started",
runId: childRunId,
childSessionKey,
requester: progressOrigin,
},
{
runId: childRunId,
childSessionKey,
requesterSessionKey: ownership.completionRequesterSessionKey,
},
);
}
} catch {
// ACP already started; presentation hooks are best-effort only.
}
} catch (err) {
// Best-effort only: the ACP turn was already started above, so deleting the
// child session record here does not guarantee the in-flight run was aborted.
@ -483,6 +514,9 @@ export function createSessionsSpawnTool(
agentAccountId: opts?.agentAccountId,
agentTo: opts?.agentTo,
agentThreadId: opts?.agentThreadId,
currentMessagingTarget: opts?.currentMessagingTarget ?? opts?.currentChannelId,
currentChannelId: opts?.currentChannelId,
currentMessageId: opts?.currentMessageId,
agentGroupId: opts?.agentGroupId,
agentGroupChannel: opts?.agentGroupChannel,
agentGroupSpace: opts?.agentGroupSpace,

File diff suppressed because one or more lines are too long

View file

@ -664,6 +664,13 @@ describe("config schema", () => {
).toBe(true);
});
it("accepts only the Discord subagent progress enable toggle", () => {
expect(DiscordConfigSchema.safeParse({ subagentProgress: true }).success).toBe(true);
expect(DiscordConfigSchema.safeParse({ subagentProgress: { enabled: true } }).success).toBe(
false,
);
});
it("keeps per-agent model overrides limited to model selection", () => {
const result = OpenClawSchema.safeParse({
agents: {

View file

@ -442,6 +442,8 @@ export type DiscordAccountConfig = {
slashCommand?: DiscordSlashCommandConfig;
/** Thread binding lifecycle settings (focus/subagent thread sessions). */
threadBindings?: DiscordThreadBindingsConfig;
/** Show subagent count reactions and typing on the source message. Default: false. */
subagentProgress?: boolean;
/** Privileged Gateway Intents (must also be enabled in Discord Developer Portal). */
intents?: DiscordIntentsConfig;
/** Voice channel conversation settings. */

View file

@ -722,6 +722,7 @@ const DiscordAccountSchema = z
})
.strict()
.optional(),
subagentProgress: z.boolean().optional(),
intents: z
.object({
presence: z.boolean().optional(),

View file

@ -31,7 +31,12 @@ const BUNDLED_TYPED_HOOK_REGISTRATION_GUARDS = {
"extensions/active-memory/index.ts": ["before_prompt_build"],
"extensions/codex/index.ts": ["after_compaction", "inbound_claim", "session_end"],
"extensions/diffs/src/plugin.ts": ["before_prompt_build"],
"extensions/discord/subagent-hooks-api.ts": ["subagent_delivery_target", "subagent_ended"],
"extensions/discord/subagent-hooks-api.ts": [
"gateway_start",
"subagent_delivery_target",
"subagent_ended",
"subagent_progress",
],
"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"],

View file

@ -109,6 +109,7 @@ export type PluginHookName =
| "subagent_spawning"
| "subagent_delivery_target"
| "subagent_spawned"
| "subagent_progress"
| "subagent_ended"
/** @deprecated Use gateway_stop. */
| "deactivate"
@ -153,6 +154,7 @@ const PLUGIN_HOOK_NAMES = [
"subagent_spawning",
"subagent_delivery_target",
"subagent_spawned",
"subagent_progress",
"subagent_ended",
"deactivate",
"gateway_start",
@ -747,17 +749,23 @@ export type PluginHookSubagentContext = {
type PluginHookSubagentTargetKind = "subagent" | "acp";
type PluginHookSubagentRequester = {
channel?: string;
accountId?: string;
to?: string;
threadId?: string | number;
/** Native source channel/conversation id, when distinct from the routable target. */
channelId?: string | number;
/** Native source message that initiated the parent run, when available. */
messageId?: string | number;
};
type PluginHookSubagentSpawnBase = {
childSessionKey: string;
agentId: string;
label?: string;
mode: "run" | "session";
requester?: {
channel?: string;
accountId?: string;
to?: string;
threadId?: string | number;
};
requester?: PluginHookSubagentRequester;
threadRequested: boolean;
};
@ -835,6 +843,22 @@ export type PluginHookSubagentSpawnedEvent = PluginHookSubagentSpawnBase & {
resolvedProvider?: string;
};
/** Portable channel presentation signal for one background child run. */
export type PluginHookSubagentProgressEvent =
| {
phase: "started";
runId: string;
childSessionKey: string;
requester?: PluginHookSubagentRequester;
}
| {
phase: "ended";
runId: string;
childSessionKey: string;
outcome: "ok" | "error" | "timeout" | "killed" | "unknown";
requester?: PluginHookSubagentRequester;
};
export type PluginHookSubagentEndedEvent = {
targetSessionKey: string;
targetKind: PluginHookSubagentTargetKind;
@ -1246,6 +1270,10 @@ export type PluginHookHandlerMap = {
event: PluginHookSubagentSpawnedEvent,
ctx: PluginHookSubagentContext,
) => Promise<void> | void;
subagent_progress: (
event: PluginHookSubagentProgressEvent,
ctx: PluginHookSubagentContext,
) => Promise<void> | void;
subagent_ended: (
event: PluginHookSubagentEndedEvent,
ctx: PluginHookSubagentContext,

View file

@ -80,6 +80,7 @@ import type {
PluginHookSubagentSpawningEvent,
PluginHookSubagentSpawningResult,
PluginHookSubagentEndedEvent,
PluginHookSubagentProgressEvent,
PluginHookSubagentSpawnedEvent,
PluginHookToolContext,
PluginHookToolResultPersistContext,
@ -1448,6 +1449,14 @@ export function createHookRunner(
return runVoidHook("subagent_spawned", event, ctx);
}
/** Run portable subagent progress presentation hooks. */
async function runSubagentProgress(
event: PluginHookSubagentProgressEvent,
ctx: PluginHookSubagentContext,
): Promise<void> {
return runVoidHook("subagent_progress", event, ctx);
}
/**
* Run subagent_ended hook.
* Runs in parallel (fire-and-forget).
@ -1621,6 +1630,7 @@ export function createHookRunner(
runSubagentSpawning,
runSubagentDeliveryTarget,
runSubagentSpawned,
runSubagentProgress,
runSubagentEnded,
// Gateway hooks
runGatewayStart,
@ -1641,6 +1651,10 @@ export type HookRunner = ReturnType<typeof createHookRunner>;
export type SubagentLifecycleHookRunner = Pick<
HookRunner,
"hasHooks" | "runSubagentSpawning" | "runSubagentSpawned" | "runSubagentEnded"
| "hasHooks"
| "runSubagentSpawning"
| "runSubagentSpawned"
| "runSubagentProgress"
| "runSubagentEnded"
>;
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */

View file

@ -1,5 +1,5 @@
/**
* Test: subagent_spawning, subagent_delivery_target, subagent_spawned & subagent_ended hook wiring
* Test: subagent spawning, routing, progress, and terminal hook wiring.
*/
import { describe, expect, it, vi } from "vitest";
import { addStaticTestHooks, createHookRunnerWithRegistry } from "./hooks.test-fixtures.js";
@ -22,6 +22,7 @@ describe("subagent hook runner methods", () => {
hookName:
| "subagent_spawning"
| "subagent_spawned"
| "subagent_progress"
| "subagent_delivery_target"
| "subagent_ended";
event: Record<string, unknown>;
@ -38,9 +39,11 @@ describe("subagent hook runner methods", () => {
? await runner.runSubagentSpawning(params.event as never, params.ctx as never)
: params.hookName === "subagent_spawned"
? await runner.runSubagentSpawned(params.event as never, params.ctx as never)
: params.hookName === "subagent_delivery_target"
? await runner.runSubagentDeliveryTarget(params.event as never, params.ctx as never)
: await runner.runSubagentEnded(params.event as never, params.ctx as never);
: params.hookName === "subagent_progress"
? await runner.runSubagentProgress(params.event as never, params.ctx as never)
: params.hookName === "subagent_delivery_target"
? await runner.runSubagentDeliveryTarget(params.event as never, params.ctx as never)
: await runner.runSubagentEnded(params.event as never, params.ctx as never);
expect(handler).toHaveBeenCalledWith(params.event, params.ctx);
return result;
@ -81,6 +84,18 @@ describe("subagent hook runner methods", () => {
},
ctx: baseSubagentCtx,
},
{
name: "runSubagentProgress invokes registered subagent_progress hooks",
hookName: "subagent_progress" as const,
methodName: "runSubagentProgress" as const,
event: {
phase: "started" as const,
runId: "run-1",
childSessionKey: "agent:main:subagent:child",
requester: { ...baseRequester, messageId: "message-1" },
},
ctx: baseSubagentCtx,
},
{
name: "runSubagentDeliveryTarget invokes registered subagent_delivery_target hooks",
hookName: "subagent_delivery_target" as const,
@ -160,6 +175,7 @@ describe("subagent hook runner methods", () => {
expect(runner.hasHooks("subagent_spawning")).toBe(true);
expect(runner.hasHooks("subagent_delivery_target")).toBe(true);
expect(runner.hasHooks("subagent_spawned")).toBe(false);
expect(runner.hasHooks("subagent_progress")).toBe(false);
expect(runner.hasHooks("subagent_ended")).toBe(false);
});