diff --git a/src/agents/subagent-control.ts b/src/agents/subagent-control.ts index 785cf30ad7a..a1b83e8e5ac 100644 --- a/src/agents/subagent-control.ts +++ b/src/agents/subagent-control.ts @@ -11,35 +11,30 @@ import type { SessionEntry } from "../config/sessions.js"; import { loadSessionStore, resolveStorePath, updateSessionStore } from "../config/sessions.js"; import { callGateway } from "../gateway/call.js"; import { logVerbose } from "../globals.js"; -import { - isSubagentSessionKey, - parseAgentSessionKey, - type ParsedAgentSessionKey, -} from "../routing/session-key.js"; -import { - formatDurationCompact, - formatTokenUsageDisplay, - resolveTotalTokens, - truncateLine, -} from "../shared/subagents-format.js"; +import { isSubagentSessionKey, parseAgentSessionKey } from "../routing/session-key.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js"; import { AGENT_LANE_SUBAGENT } from "./lanes.js"; -import { resolveModelDisplayName, resolveModelDisplayRef } from "./model-selection-display.js"; import { abortEmbeddedPiRun } from "./pi-embedded.js"; import { readLatestAssistantReplySnapshot, waitForAgentRunAndReadUpdatedAssistantReply, } from "./run-wait.js"; import { resolveStoredSubagentCapabilities } from "./subagent-capabilities.js"; +import { + buildLatestSubagentRunIndex, + buildSubagentList, + createPendingDescendantCounter, + isActiveSubagentRun, + resolveSessionEntryForKey, + type BuiltSubagentList, + type SessionEntryResolution, + type SubagentListItem, +} from "./subagent-list.js"; import { subagentRuns } from "./subagent-registry-memory.js"; -import { countPendingDescendantRunsFromRuns } from "./subagent-registry-queries.js"; -import { getSubagentRunsSnapshotForRead } from "./subagent-registry-state.js"; import { clearSubagentRunSteerRestart, countPendingDescendantRuns, getLatestSubagentRunByChildSessionKey, - getSubagentSessionRuntimeMs, - getSubagentSessionStartedAt, listSubagentRunsForController, markSubagentRunTerminated, markSubagentRunForSteerRestart, @@ -67,71 +62,20 @@ let subagentControlDeps: { callGateway: GatewayCaller; } = defaultSubagentControlDeps; -export type SessionEntryResolution = { - storePath: string; - entry: SessionEntry | undefined; -}; - export type ResolvedSubagentController = { controllerSessionKey: string; callerSessionKey: string; callerIsSubagent: boolean; controlScope: "children" | "none"; }; - -export type SubagentListItem = { - index: number; - line: string; - runId: string; - sessionKey: string; - label: string; - task: string; - status: string; - pendingDescendants: number; - runtime: string; - runtimeMs: number; - childSessions?: string[]; - model?: string; - totalTokens?: number; - startedAt?: number; - endedAt?: number; +export type { BuiltSubagentList, SessionEntryResolution, SubagentListItem }; +export { + buildSubagentList, + createPendingDescendantCounter, + isActiveSubagentRun, + resolveSessionEntryForKey, }; -export type BuiltSubagentList = { - total: number; - active: SubagentListItem[]; - recent: SubagentListItem[]; - text: string; -}; - -function resolveStorePathForKey( - cfg: OpenClawConfig, - key: string, - parsed?: ParsedAgentSessionKey | null, -) { - return resolveStorePath(cfg.session?.store, { - agentId: parsed?.agentId, - }); -} - -export function resolveSessionEntryForKey(params: { - cfg: OpenClawConfig; - key: string; - cache: Map>; -}): SessionEntryResolution { - const parsed = parseAgentSessionKey(params.key); - const storePath = resolveStorePathForKey(params.cfg, params.key, parsed); - let store = params.cache.get(storePath); - if (!store) { - store = loadSessionStore(storePath); - params.cache.set(storePath, store); - } - return { - storePath, - entry: store[params.key], - }; -} - export function resolveSubagentController(params: { cfg: OpenClawConfig; agentSessionKey?: string; @@ -178,208 +122,6 @@ export function listControlledSubagentRuns(controllerSessionKey: string): Subage return sortSubagentRuns(filtered); } -function buildLatestSubagentRunIndex(runs: Map) { - const latestByChildSessionKey = new Map(); - for (const entry of runs.values()) { - const childSessionKey = entry.childSessionKey?.trim(); - if (!childSessionKey) { - continue; - } - const existing = latestByChildSessionKey.get(childSessionKey); - if (!existing || entry.createdAt > existing.createdAt) { - latestByChildSessionKey.set(childSessionKey, entry); - } - } - - const childSessionsByController = new Map(); - for (const [childSessionKey, entry] of latestByChildSessionKey.entries()) { - const controllerSessionKey = - entry.controllerSessionKey?.trim() || entry.requesterSessionKey?.trim(); - if (!controllerSessionKey) { - continue; - } - const existing = childSessionsByController.get(controllerSessionKey); - if (existing) { - existing.push(childSessionKey); - continue; - } - childSessionsByController.set(controllerSessionKey, [childSessionKey]); - } - for (const childSessions of childSessionsByController.values()) { - childSessions.sort(); - } - - return { - latestByChildSessionKey, - childSessionsByController, - }; -} - -export function createPendingDescendantCounter(runsSnapshot?: Map) { - const pendingDescendantCache = new Map(); - return (sessionKey: string) => { - if (pendingDescendantCache.has(sessionKey)) { - return pendingDescendantCache.get(sessionKey) ?? 0; - } - const pending = Math.max( - 0, - runsSnapshot - ? countPendingDescendantRunsFromRuns(runsSnapshot, sessionKey) - : countPendingDescendantRuns(sessionKey), - ); - pendingDescendantCache.set(sessionKey, pending); - return pending; - }; -} - -export function isActiveSubagentRun( - entry: SubagentRunRecord, - pendingDescendantCount: (sessionKey: string) => number, -) { - return !entry.endedAt || pendingDescendantCount(entry.childSessionKey) > 0; -} - -function resolveRunStatus(entry: SubagentRunRecord, options?: { pendingDescendants?: number }) { - const pendingDescendants = Math.max(0, options?.pendingDescendants ?? 0); - if (pendingDescendants > 0) { - const childLabel = pendingDescendants === 1 ? "child" : "children"; - return `active (waiting on ${pendingDescendants} ${childLabel})`; - } - if (!entry.endedAt) { - return "running"; - } - const status = entry.outcome?.status ?? "done"; - if (status === "ok") { - return "done"; - } - if (status === "error") { - return "failed"; - } - return status; -} - -function resolveModelRef(entry?: SessionEntry, fallbackModel?: string) { - return resolveModelDisplayRef({ - runtimeProvider: entry?.modelProvider, - runtimeModel: entry?.model, - overrideProvider: entry?.providerOverride, - overrideModel: entry?.modelOverride, - fallbackModel, - }); -} - -function resolveModelDisplay(entry?: SessionEntry, fallbackModel?: string) { - return resolveModelDisplayName({ - runtimeProvider: entry?.modelProvider, - runtimeModel: entry?.model, - overrideProvider: entry?.providerOverride, - overrideModel: entry?.modelOverride, - fallbackModel, - }); -} - -function buildListText(params: { - active: Array<{ line: string }>; - recent: Array<{ line: string }>; - recentMinutes: number; -}) { - const lines: string[] = []; - lines.push("active subagents:"); - if (params.active.length === 0) { - lines.push("(none)"); - } else { - lines.push(...params.active.map((entry) => entry.line)); - } - lines.push(""); - lines.push(`recent (last ${params.recentMinutes}m):`); - if (params.recent.length === 0) { - lines.push("(none)"); - } else { - lines.push(...params.recent.map((entry) => entry.line)); - } - return lines.join("\n"); -} - -export function buildSubagentList(params: { - cfg: OpenClawConfig; - runs: SubagentRunRecord[]; - recentMinutes: number; - taskMaxChars?: number; -}): BuiltSubagentList { - const now = Date.now(); - const recentCutoff = now - params.recentMinutes * 60_000; - const dedupedRuns: SubagentRunRecord[] = []; - const seenChildSessionKeys = new Set(); - for (const entry of sortSubagentRuns(params.runs)) { - if (seenChildSessionKeys.has(entry.childSessionKey)) { - continue; - } - seenChildSessionKeys.add(entry.childSessionKey); - dedupedRuns.push(entry); - } - const cache = new Map>(); - const snapshot = getSubagentRunsSnapshotForRead(subagentRuns); - const { childSessionsByController } = buildLatestSubagentRunIndex(snapshot); - const pendingDescendantCount = createPendingDescendantCounter(snapshot); - let index = 1; - const buildListEntry = (entry: SubagentRunRecord, runtimeMs: number) => { - const sessionEntry = resolveSessionEntryForKey({ - cfg: params.cfg, - key: entry.childSessionKey, - cache, - }).entry; - const totalTokens = resolveTotalTokens(sessionEntry); - const usageText = formatTokenUsageDisplay(sessionEntry); - const pendingDescendants = pendingDescendantCount(entry.childSessionKey); - const status = resolveRunStatus(entry, { - pendingDescendants, - }); - const childSessions = childSessionsByController.get(entry.childSessionKey) ?? []; - const runtime = formatDurationCompact(runtimeMs) ?? "n/a"; - const label = truncateLine(resolveSubagentLabel(entry), 48); - const task = truncateLine(entry.task.trim(), params.taskMaxChars ?? 72); - const line = `${index}. ${label} (${resolveModelDisplay(sessionEntry, entry.model)}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${task.toLowerCase() !== label.toLowerCase() ? ` - ${task}` : ""}`; - const view: SubagentListItem = { - index, - line, - runId: entry.runId, - sessionKey: entry.childSessionKey, - label, - task, - status, - pendingDescendants, - runtime, - runtimeMs, - ...(childSessions.length > 0 ? { childSessions } : {}), - model: resolveModelRef(sessionEntry, entry.model), - totalTokens, - startedAt: getSubagentSessionStartedAt(entry), - ...(entry.endedAt ? { endedAt: entry.endedAt } : {}), - }; - index += 1; - return view; - }; - const active = dedupedRuns - .filter((entry) => isActiveSubagentRun(entry, pendingDescendantCount)) - .map((entry) => buildListEntry(entry, getSubagentSessionRuntimeMs(entry, now) ?? 0)); - const recent = dedupedRuns - .filter( - (entry) => - !isActiveSubagentRun(entry, pendingDescendantCount) && - !!entry.endedAt && - (entry.endedAt ?? 0) >= recentCutoff, - ) - .map((entry) => - buildListEntry(entry, getSubagentSessionRuntimeMs(entry, entry.endedAt ?? now) ?? 0), - ); - return { - total: dedupedRuns.length, - active, - recent, - text: buildListText({ active, recent, recentMinutes: params.recentMinutes }), - }; -} - function ensureControllerOwnsRun(params: { controller: ResolvedSubagentController; entry: SubagentRunRecord; diff --git a/src/agents/subagent-list.test.ts b/src/agents/subagent-list.test.ts new file mode 100644 index 00000000000..02c1b3923c0 --- /dev/null +++ b/src/agents/subagent-list.test.ts @@ -0,0 +1,158 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { updateSessionStore } from "../config/sessions.js"; +import { buildSubagentList } from "./subagent-list.js"; +import { + addSubagentRunForTests, + resetSubagentRegistryForTests, +} from "./subagent-registry.test-helpers.js"; + +let testWorkspaceDir = os.tmpdir(); + +beforeAll(async () => { + testWorkspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-subagent-list-")); +}); + +afterAll(async () => { + await fs.rm(testWorkspaceDir, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 50, + }); +}); + +beforeEach(() => { + resetSubagentRegistryForTests(); +}); + +describe("buildSubagentList", () => { + it("returns empty active and recent sections when no runs exist", () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const list = buildSubagentList({ + cfg, + runs: [], + recentMinutes: 30, + taskMaxChars: 110, + }); + expect(list.active).toEqual([]); + expect(list.recent).toEqual([]); + expect(list.text).toContain("active subagents:"); + expect(list.text).toContain("recent (last 30m):"); + }); + + it("truncates long task text in list lines", () => { + const run = { + runId: "run-long-task", + childSessionKey: "agent:main:subagent:long-task", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "This is a deliberately long task description used to verify that subagent list output keeps the full task text instead of appending ellipsis after a short hard cutoff.", + cleanup: "keep", + createdAt: 1000, + startedAt: 1000, + }; + addSubagentRunForTests(run); + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const list = buildSubagentList({ + cfg, + runs: [run], + recentMinutes: 30, + taskMaxChars: 110, + }); + expect(list.active[0]?.line).toContain( + "This is a deliberately long task description used to verify that subagent list output keeps the full task text", + ); + expect(list.active[0]?.line).toContain("..."); + expect(list.active[0]?.line).not.toContain("after a short hard cutoff."); + }); + + it("keeps ended orchestrators active while descendants remain pending", () => { + const now = Date.now(); + const orchestratorRun = { + runId: "run-orchestrator-ended", + childSessionKey: "agent:main:subagent:orchestrator-ended", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "orchestrate child workers", + cleanup: "keep", + createdAt: now - 120_000, + startedAt: now - 120_000, + endedAt: now - 60_000, + outcome: { status: "ok" }, + }; + addSubagentRunForTests(orchestratorRun); + addSubagentRunForTests({ + runId: "run-orchestrator-child-active", + childSessionKey: "agent:main:subagent:orchestrator-ended:subagent:child", + requesterSessionKey: "agent:main:subagent:orchestrator-ended", + requesterDisplayKey: "subagent:orchestrator-ended", + task: "child worker still running", + cleanup: "keep", + createdAt: now - 30_000, + startedAt: now - 30_000, + }); + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const list = buildSubagentList({ + cfg, + runs: [orchestratorRun], + recentMinutes: 30, + taskMaxChars: 110, + }); + + expect(list.active[0]?.status).toBe("active (waiting on 1 child)"); + expect(list.recent).toEqual([]); + }); + + it("formats io and prompt/cache usage from session entries", async () => { + const run = { + runId: "run-usage", + childSessionKey: "agent:main:subagent:usage", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "do thing", + cleanup: "keep", + createdAt: 1000, + startedAt: 1000, + }; + addSubagentRunForTests(run); + const storePath = path.join(testWorkspaceDir, "sessions-subagent-list-usage.json"); + await updateSessionStore(storePath, (store) => { + store["agent:main:subagent:usage"] = { + sessionId: "child-session-usage", + updatedAt: Date.now(), + inputTokens: 12, + outputTokens: 1000, + totalTokens: 197000, + model: "opencode/claude-opus-4-6", + }; + }); + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: storePath }, + } as OpenClawConfig; + const list = buildSubagentList({ + cfg, + runs: [run], + recentMinutes: 30, + taskMaxChars: 110, + }); + + expect(list.active[0]?.line).toMatch(/tokens 1(\.0)?k \(in 12 \/ out 1(\.0)?k\)/); + expect(list.active[0]?.line).toContain("prompt/cache 197k"); + expect(list.active[0]?.line).not.toContain("1k io"); + }); +}); diff --git a/src/agents/subagent-list.ts b/src/agents/subagent-list.ts new file mode 100644 index 00000000000..279e5519f35 --- /dev/null +++ b/src/agents/subagent-list.ts @@ -0,0 +1,282 @@ +import { resolveSubagentLabel, sortSubagentRuns } from "../auto-reply/reply/subagents-utils.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveStorePath } from "../config/sessions/paths.js"; +import { loadSessionStore } from "../config/sessions/store-load.js"; +import type { SessionEntry } from "../config/sessions/types.js"; +import { parseAgentSessionKey, type ParsedAgentSessionKey } from "../routing/session-key.js"; +import { + formatDurationCompact, + formatTokenUsageDisplay, + resolveTotalTokens, + truncateLine, +} from "../shared/subagents-format.js"; +import { resolveModelDisplayName, resolveModelDisplayRef } from "./model-selection-display.js"; +import { subagentRuns } from "./subagent-registry-memory.js"; +import { countPendingDescendantRunsFromRuns } from "./subagent-registry-queries.js"; +import { getSubagentRunsSnapshotForRead } from "./subagent-registry-state.js"; +import { + countPendingDescendantRuns, + getSubagentSessionRuntimeMs, + getSubagentSessionStartedAt, + type SubagentRunRecord, +} from "./subagent-registry.js"; + +export type SubagentListItem = { + index: number; + line: string; + runId: string; + sessionKey: string; + label: string; + task: string; + status: string; + pendingDescendants: number; + runtime: string; + runtimeMs: number; + childSessions?: string[]; + model?: string; + totalTokens?: number; + startedAt?: number; + endedAt?: number; +}; + +export type BuiltSubagentList = { + total: number; + active: SubagentListItem[]; + recent: SubagentListItem[]; + text: string; +}; + +export type SessionEntryResolution = { + storePath: string; + entry: SessionEntry | undefined; +}; + +function resolveStorePathForKey( + cfg: OpenClawConfig, + key: string, + parsed?: ParsedAgentSessionKey | null, +) { + return resolveStorePath(cfg.session?.store, { + agentId: parsed?.agentId, + }); +} + +export function resolveSessionEntryForKey(params: { + cfg: OpenClawConfig; + key: string; + cache: Map>; +}): SessionEntryResolution { + const parsed = parseAgentSessionKey(params.key); + const storePath = resolveStorePathForKey(params.cfg, params.key, parsed); + let store = params.cache.get(storePath); + if (!store) { + store = loadSessionStore(storePath); + params.cache.set(storePath, store); + } + return { + storePath, + entry: store[params.key], + }; +} + +export function buildLatestSubagentRunIndex(runs: Map) { + const latestByChildSessionKey = new Map(); + for (const entry of runs.values()) { + const childSessionKey = entry.childSessionKey?.trim(); + if (!childSessionKey) { + continue; + } + const existing = latestByChildSessionKey.get(childSessionKey); + if (!existing || entry.createdAt > existing.createdAt) { + latestByChildSessionKey.set(childSessionKey, entry); + } + } + + const childSessionsByController = new Map(); + for (const [childSessionKey, entry] of latestByChildSessionKey.entries()) { + const controllerSessionKey = + entry.controllerSessionKey?.trim() || entry.requesterSessionKey?.trim(); + if (!controllerSessionKey) { + continue; + } + const existing = childSessionsByController.get(controllerSessionKey); + if (existing) { + existing.push(childSessionKey); + continue; + } + childSessionsByController.set(controllerSessionKey, [childSessionKey]); + } + for (const childSessions of childSessionsByController.values()) { + childSessions.sort(); + } + + return { + latestByChildSessionKey, + childSessionsByController, + }; +} + +export function createPendingDescendantCounter(runsSnapshot?: Map) { + const pendingDescendantCache = new Map(); + return (sessionKey: string) => { + if (pendingDescendantCache.has(sessionKey)) { + return pendingDescendantCache.get(sessionKey) ?? 0; + } + const pending = Math.max( + 0, + runsSnapshot + ? countPendingDescendantRunsFromRuns(runsSnapshot, sessionKey) + : countPendingDescendantRuns(sessionKey), + ); + pendingDescendantCache.set(sessionKey, pending); + return pending; + }; +} + +export function isActiveSubagentRun( + entry: SubagentRunRecord, + pendingDescendantCount: (sessionKey: string) => number, +) { + return !entry.endedAt || pendingDescendantCount(entry.childSessionKey) > 0; +} + +function resolveRunStatus(entry: SubagentRunRecord, options?: { pendingDescendants?: number }) { + const pendingDescendants = Math.max(0, options?.pendingDescendants ?? 0); + if (pendingDescendants > 0) { + const childLabel = pendingDescendants === 1 ? "child" : "children"; + return `active (waiting on ${pendingDescendants} ${childLabel})`; + } + if (!entry.endedAt) { + return "running"; + } + const status = entry.outcome?.status ?? "done"; + if (status === "ok") { + return "done"; + } + if (status === "error") { + return "failed"; + } + return status; +} + +function resolveModelRef(entry?: SessionEntry, fallbackModel?: string) { + return resolveModelDisplayRef({ + runtimeProvider: entry?.modelProvider, + runtimeModel: entry?.model, + overrideProvider: entry?.providerOverride, + overrideModel: entry?.modelOverride, + fallbackModel, + }); +} + +function resolveModelDisplay(entry?: SessionEntry, fallbackModel?: string) { + return resolveModelDisplayName({ + runtimeProvider: entry?.modelProvider, + runtimeModel: entry?.model, + overrideProvider: entry?.providerOverride, + overrideModel: entry?.modelOverride, + fallbackModel, + }); +} + +function buildListText(params: { + active: Array<{ line: string }>; + recent: Array<{ line: string }>; + recentMinutes: number; +}) { + const lines: string[] = []; + lines.push("active subagents:"); + if (params.active.length === 0) { + lines.push("(none)"); + } else { + lines.push(...params.active.map((entry) => entry.line)); + } + lines.push(""); + lines.push(`recent (last ${params.recentMinutes}m):`); + if (params.recent.length === 0) { + lines.push("(none)"); + } else { + lines.push(...params.recent.map((entry) => entry.line)); + } + return lines.join("\n"); +} + +export function buildSubagentList(params: { + cfg: OpenClawConfig; + runs: SubagentRunRecord[]; + recentMinutes: number; + taskMaxChars?: number; +}): BuiltSubagentList { + const now = Date.now(); + const recentCutoff = now - params.recentMinutes * 60_000; + const dedupedRuns: SubagentRunRecord[] = []; + const seenChildSessionKeys = new Set(); + for (const entry of sortSubagentRuns(params.runs)) { + if (seenChildSessionKeys.has(entry.childSessionKey)) { + continue; + } + seenChildSessionKeys.add(entry.childSessionKey); + dedupedRuns.push(entry); + } + const cache = new Map>(); + const snapshot = getSubagentRunsSnapshotForRead(subagentRuns); + const { childSessionsByController } = buildLatestSubagentRunIndex(snapshot); + const pendingDescendantCount = createPendingDescendantCounter(snapshot); + let index = 1; + const buildListEntry = (entry: SubagentRunRecord, runtimeMs: number) => { + const sessionEntry = resolveSessionEntryForKey({ + cfg: params.cfg, + key: entry.childSessionKey, + cache, + }).entry; + const totalTokens = resolveTotalTokens(sessionEntry); + const usageText = formatTokenUsageDisplay(sessionEntry); + const pendingDescendants = pendingDescendantCount(entry.childSessionKey); + const status = resolveRunStatus(entry, { + pendingDescendants, + }); + const childSessions = childSessionsByController.get(entry.childSessionKey) ?? []; + const runtime = formatDurationCompact(runtimeMs) ?? "n/a"; + const label = truncateLine(resolveSubagentLabel(entry), 48); + const task = truncateLine(entry.task.trim(), params.taskMaxChars ?? 72); + const line = `${index}. ${label} (${resolveModelDisplay(sessionEntry, entry.model)}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${task.toLowerCase() !== label.toLowerCase() ? ` - ${task}` : ""}`; + const view: SubagentListItem = { + index, + line, + runId: entry.runId, + sessionKey: entry.childSessionKey, + label, + task, + status, + pendingDescendants, + runtime, + runtimeMs, + ...(childSessions.length > 0 ? { childSessions } : {}), + model: resolveModelRef(sessionEntry, entry.model), + totalTokens, + startedAt: getSubagentSessionStartedAt(entry), + ...(entry.endedAt ? { endedAt: entry.endedAt } : {}), + }; + index += 1; + return view; + }; + const active = dedupedRuns + .filter((entry) => isActiveSubagentRun(entry, pendingDescendantCount)) + .map((entry) => buildListEntry(entry, getSubagentSessionRuntimeMs(entry, now) ?? 0)); + const recent = dedupedRuns + .filter( + (entry) => + !isActiveSubagentRun(entry, pendingDescendantCount) && + !!entry.endedAt && + (entry.endedAt ?? 0) >= recentCutoff, + ) + .map((entry) => + buildListEntry(entry, getSubagentSessionRuntimeMs(entry, entry.endedAt ?? now) ?? 0), + ); + return { + total: dedupedRuns.length, + active, + recent, + text: buildListText({ active, recent, recentMinutes: params.recentMinutes }), + }; +} diff --git a/src/agents/subagent-registry.test-helpers.ts b/src/agents/subagent-registry.test-helpers.ts new file mode 100644 index 00000000000..db7342e88a3 --- /dev/null +++ b/src/agents/subagent-registry.test-helpers.ts @@ -0,0 +1,20 @@ +import { resetAnnounceQueuesForTests } from "./subagent-announce-queue.js"; +import { subagentRuns } from "./subagent-registry-memory.js"; +import { listRunsForRequesterFromRuns } from "./subagent-registry-queries.js"; +import type { SubagentRunRecord } from "./subagent-registry.types.js"; + +export function resetSubagentRegistryForTests() { + subagentRuns.clear(); + resetAnnounceQueuesForTests(); +} + +export function addSubagentRunForTests(entry: SubagentRunRecord) { + subagentRuns.set(entry.runId, entry); +} + +export function listSubagentRunsForRequester( + requesterSessionKey: string, + options?: { requesterRunId?: string }, +) { + return listRunsForRequesterFromRuns(subagentRuns, requesterSessionKey, options); +} diff --git a/src/agents/tools/subagents-tool.ts b/src/agents/tools/subagents-tool.ts index a7eb53c5d46..d5b213a50a3 100644 --- a/src/agents/tools/subagents-tool.ts +++ b/src/agents/tools/subagents-tool.ts @@ -2,9 +2,7 @@ import { Type } from "@sinclair/typebox"; import { loadConfig } from "../../config/config.js"; import { optionalStringEnum } from "../schema/typebox.js"; import { - buildSubagentList, DEFAULT_RECENT_MINUTES, - isActiveSubagentRun, killAllControlledSubagentRuns, killControlledSubagentRun, listControlledSubagentRuns, @@ -13,8 +11,12 @@ import { resolveControlledSubagentTarget, resolveSubagentController, steerControlledSubagentRun, - createPendingDescendantCounter, } from "../subagent-control.js"; +import { + buildSubagentList, + createPendingDescendantCounter, + isActiveSubagentRun, +} from "../subagent-list.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, readNumberParam, readStringParam } from "./common.js"; diff --git a/src/auto-reply/reply/commands-subagents-info.test.ts b/src/auto-reply/reply/commands-subagents-info.test.ts new file mode 100644 index 00000000000..70d06f5898d --- /dev/null +++ b/src/auto-reply/reply/commands-subagents-info.test.ts @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + addSubagentRunForTests, + resetSubagentRegistryForTests, +} from "../../agents/subagent-registry.test-helpers.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { failTaskRunByRunId } from "../../tasks/task-executor.js"; +import { createTaskRecord, resetTaskRegistryForTests } from "../../tasks/task-registry.js"; +import type { ReplyPayload } from "../types.js"; +import { handleSubagentsInfoAction } from "./commands-subagents/action-info.js"; + +function buildInfoContext(params: { cfg: OpenClawConfig; runs: object[]; restTokens: string[] }) { + return { + params: { + cfg: params.cfg, + sessionKey: "agent:main:main", + }, + handledPrefix: "/subagents", + requesterKey: "agent:main:main", + runs: params.runs, + restTokens: params.restTokens, + } as Parameters[0]; +} + +function requireReplyText(reply: ReplyPayload | undefined): string { + expect(reply?.text).toBeDefined(); + return reply?.text as string; +} + +beforeEach(() => { + resetTaskRegistryForTests(); + resetSubagentRegistryForTests(); +}); + +describe("subagents info", () => { + it("returns usage for missing targets", () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const result = handleSubagentsInfoAction(buildInfoContext({ cfg, runs: [], restTokens: [] })); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("/subagents info "); + }); + + it("returns info for a subagent", () => { + const now = Date.now(); + const run = { + runId: "run-1", + childSessionKey: "agent:main:subagent:abc", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "do thing", + cleanup: "keep", + createdAt: now - 20_000, + startedAt: now - 20_000, + endedAt: now - 1_000, + outcome: { status: "ok" }, + }; + addSubagentRunForTests(run); + createTaskRecord({ + runtime: "subagent", + requesterSessionKey: "agent:main:main", + childSessionKey: "agent:main:subagent:abc", + runId: "run-1", + task: "do thing", + status: "succeeded", + terminalSummary: "Completed the requested task", + deliveryStatus: "delivered", + }); + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { mainKey: "main", scope: "per-sender" }, + } as OpenClawConfig; + const result = handleSubagentsInfoAction( + buildInfoContext({ cfg, runs: [run], restTokens: ["1"] }), + ); + const text = requireReplyText(result.reply); + expect(result.shouldContinue).toBe(false); + expect(text).toContain("Subagent info"); + expect(text).toContain("Run: run-1"); + expect(text).toContain("Status: done"); + expect(text).toContain("TaskStatus: succeeded"); + expect(text).toContain("Task summary: Completed the requested task"); + }); + + it("sanitizes leaked task details in /subagents info", () => { + const now = Date.now(); + const run = { + runId: "run-1", + childSessionKey: "agent:main:subagent:abc", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "Inspect the stuck run", + cleanup: "keep", + createdAt: now - 20_000, + startedAt: now - 20_000, + endedAt: now - 1_000, + outcome: { + status: "error", + error: [ + "OpenClaw runtime context (internal):", + "This context is runtime-generated, not user-authored. Keep internal details private.", + "", + "[Internal task completion event]", + "source: subagent", + ].join("\n"), + }, + }; + addSubagentRunForTests(run); + createTaskRecord({ + runtime: "subagent", + requesterSessionKey: "agent:main:main", + childSessionKey: "agent:main:subagent:abc", + runId: "run-1", + task: "Inspect the stuck run", + status: "running", + deliveryStatus: "delivered", + }); + failTaskRunByRunId({ + runId: "run-1", + endedAt: now - 1_000, + error: [ + "OpenClaw runtime context (internal):", + "This context is runtime-generated, not user-authored. Keep internal details private.", + "", + "[Internal task completion event]", + "source: subagent", + ].join("\n"), + terminalSummary: "Needs manual follow-up.", + }); + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { mainKey: "main", scope: "per-sender" }, + } as OpenClawConfig; + const result = handleSubagentsInfoAction( + buildInfoContext({ cfg, runs: [run], restTokens: ["1"] }), + ); + const text = requireReplyText(result.reply); + + expect(result.shouldContinue).toBe(false); + expect(text).toContain("Subagent info"); + expect(text).toContain("Outcome: error"); + expect(text).toContain("Task summary: Needs manual follow-up."); + expect(text).not.toContain("OpenClaw runtime context (internal):"); + expect(text).not.toContain("Internal task completion event"); + }); +}); diff --git a/src/auto-reply/reply/commands-subagents-status.test.ts b/src/auto-reply/reply/commands-subagents-status.test.ts new file mode 100644 index 00000000000..102078b8ab4 --- /dev/null +++ b/src/auto-reply/reply/commands-subagents-status.test.ts @@ -0,0 +1,130 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + addSubagentRunForTests, + resetSubagentRegistryForTests, +} from "../../agents/subagent-registry.test-helpers.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { ReplyPayload } from "../types.js"; +import { buildStatusReply } from "./commands-status.js"; +import type { CommandHandlerResult } from "./commands-types.js"; + +async function buildStatusReplyForTests(params: { + cfg: OpenClawConfig; + sessionKey?: string; + verbose?: boolean; +}): Promise { + const sessionKey = params.sessionKey ?? "agent:main:main"; + const reply = await buildStatusReply({ + cfg: params.cfg, + command: { + isAuthorizedSender: true, + channel: "whatsapp", + senderId: "owner", + } as never, + sessionEntry: { + sessionId: "status-session", + updatedAt: Date.now(), + }, + sessionKey, + parentSessionKey: sessionKey, + provider: "anthropic", + model: "claude-opus-4-6", + contextTokens: 0, + resolvedFastMode: false, + resolvedVerboseLevel: params.verbose ? "on" : "off", + resolvedReasoningLevel: "off", + resolvedElevatedLevel: "off", + resolveDefaultThinkingLevel: async () => undefined, + isGroup: false, + defaultGroupActivation: () => "mention", + }); + return { shouldContinue: false, reply }; +} + +function requireReplyText(reply: ReplyPayload | undefined): string { + expect(reply?.text).toBeDefined(); + return reply?.text as string; +} + +beforeEach(() => { + resetSubagentRegistryForTests(); +}); + +describe("subagents status", () => { + it.each([ + { + name: "omits subagent status line when none exist", + seedRuns: () => undefined, + verboseLevel: "on" as const, + expectedText: [] as string[], + unexpectedText: ["Subagents:"], + }, + { + name: "includes subagent count in /status when active", + seedRuns: () => { + addSubagentRunForTests({ + runId: "run-1", + childSessionKey: "agent:main:subagent:abc", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "do thing", + cleanup: "keep", + createdAt: 1000, + startedAt: 1000, + }); + }, + verboseLevel: "off" as const, + expectedText: ["๐Ÿค– Subagents: 1 active"], + unexpectedText: [] as string[], + }, + { + name: "includes subagent details in /status when verbose", + seedRuns: () => { + addSubagentRunForTests({ + runId: "run-1", + childSessionKey: "agent:main:subagent:abc", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "do thing", + cleanup: "keep", + createdAt: 1000, + startedAt: 1000, + }); + addSubagentRunForTests({ + runId: "run-2", + childSessionKey: "agent:main:subagent:def", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "finished task", + cleanup: "keep", + createdAt: 900, + startedAt: 900, + endedAt: 1200, + outcome: { status: "ok" }, + }); + }, + verboseLevel: "on" as const, + expectedText: ["๐Ÿค– Subagents: 1 active", "ยท 1 done"], + unexpectedText: [] as string[], + }, + ])("$name", async ({ seedRuns, verboseLevel, expectedText, unexpectedText }) => { + seedRuns(); + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { mainKey: "main", scope: "per-sender" }, + } as OpenClawConfig; + const result = await buildStatusReplyForTests({ + cfg, + verbose: verboseLevel === "on", + }); + expect(result.shouldContinue).toBe(false); + const text = requireReplyText(result.reply); + for (const expected of expectedText) { + expect(text).toContain(expected); + } + for (const blocked of unexpectedText) { + expect(text).not.toContain(blocked); + } + }); +}); diff --git a/src/auto-reply/reply/commands-subagents.ts b/src/auto-reply/reply/commands-subagents.ts index a4b6ab740af..17f81f92441 100644 --- a/src/auto-reply/reply/commands-subagents.ts +++ b/src/auto-reply/reply/commands-subagents.ts @@ -1,15 +1,5 @@ import { listControlledSubagentRuns } from "../../agents/subagent-control.js"; import { logVerbose } from "../../globals.js"; -import { handleSubagentsAgentsAction } from "./commands-subagents/action-agents.js"; -import { handleSubagentsFocusAction } from "./commands-subagents/action-focus.js"; -import { handleSubagentsHelpAction } from "./commands-subagents/action-help.js"; -import { handleSubagentsInfoAction } from "./commands-subagents/action-info.js"; -import { handleSubagentsKillAction } from "./commands-subagents/action-kill.js"; -import { handleSubagentsListAction } from "./commands-subagents/action-list.js"; -import { handleSubagentsLogAction } from "./commands-subagents/action-log.js"; -import { handleSubagentsSendAction } from "./commands-subagents/action-send.js"; -import { handleSubagentsSpawnAction } from "./commands-subagents/action-spawn.js"; -import { handleSubagentsUnfocusAction } from "./commands-subagents/action-unfocus.js"; import { type SubagentsCommandContext, resolveHandledPrefix, @@ -21,6 +11,71 @@ import type { CommandHandler } from "./commands-types.js"; export { extractMessageText } from "./commands-subagents-text.js"; +let actionAgentsPromise: Promise | null = + null; +let actionFocusPromise: Promise | null = + null; +let actionHelpPromise: Promise | null = null; +let actionInfoPromise: Promise | null = null; +let actionKillPromise: Promise | null = null; +let actionListPromise: Promise | null = null; +let actionLogPromise: Promise | null = null; +let actionSendPromise: Promise | null = null; +let actionSpawnPromise: Promise | null = + null; +let actionUnfocusPromise: Promise | null = + null; + +function loadAgentsAction() { + actionAgentsPromise ??= import("./commands-subagents/action-agents.js"); + return actionAgentsPromise; +} + +function loadFocusAction() { + actionFocusPromise ??= import("./commands-subagents/action-focus.js"); + return actionFocusPromise; +} + +function loadHelpAction() { + actionHelpPromise ??= import("./commands-subagents/action-help.js"); + return actionHelpPromise; +} + +function loadInfoAction() { + actionInfoPromise ??= import("./commands-subagents/action-info.js"); + return actionInfoPromise; +} + +function loadKillAction() { + actionKillPromise ??= import("./commands-subagents/action-kill.js"); + return actionKillPromise; +} + +function loadListAction() { + actionListPromise ??= import("./commands-subagents/action-list.js"); + return actionListPromise; +} + +function loadLogAction() { + actionLogPromise ??= import("./commands-subagents/action-log.js"); + return actionLogPromise; +} + +function loadSendAction() { + actionSendPromise ??= import("./commands-subagents/action-send.js"); + return actionSendPromise; +} + +function loadSpawnAction() { + actionSpawnPromise ??= import("./commands-subagents/action-spawn.js"); + return actionSpawnPromise; +} + +function loadUnfocusAction() { + actionUnfocusPromise ??= import("./commands-subagents/action-unfocus.js"); + return actionUnfocusPromise; +} + export const handleSubagentsCommand: CommandHandler = async (params, allowTextCommands) => { if (!allowTextCommands) { return null; @@ -66,28 +121,28 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo switch (action) { case "help": - return handleSubagentsHelpAction(); + return (await loadHelpAction()).handleSubagentsHelpAction(); case "agents": - return handleSubagentsAgentsAction(ctx); + return (await loadAgentsAction()).handleSubagentsAgentsAction(ctx); case "focus": - return await handleSubagentsFocusAction(ctx); + return await (await loadFocusAction()).handleSubagentsFocusAction(ctx); case "unfocus": - return await handleSubagentsUnfocusAction(ctx); + return await (await loadUnfocusAction()).handleSubagentsUnfocusAction(ctx); case "list": - return handleSubagentsListAction(ctx); + return (await loadListAction()).handleSubagentsListAction(ctx); case "kill": - return await handleSubagentsKillAction(ctx); + return await (await loadKillAction()).handleSubagentsKillAction(ctx); case "info": - return handleSubagentsInfoAction(ctx); + return (await loadInfoAction()).handleSubagentsInfoAction(ctx); case "log": - return await handleSubagentsLogAction(ctx); + return await (await loadLogAction()).handleSubagentsLogAction(ctx); case "send": - return await handleSubagentsSendAction(ctx, false); + return await (await loadSendAction()).handleSubagentsSendAction(ctx, false); case "steer": - return await handleSubagentsSendAction(ctx, true); + return await (await loadSendAction()).handleSubagentsSendAction(ctx, true); case "spawn": - return await handleSubagentsSpawnAction(ctx); + return await (await loadSpawnAction()).handleSubagentsSpawnAction(ctx); default: - return handleSubagentsHelpAction(); + return (await loadHelpAction()).handleSubagentsHelpAction(); } }; diff --git a/src/auto-reply/reply/commands-subagents/action-info.ts b/src/auto-reply/reply/commands-subagents/action-info.ts index 340e79818ca..2c71528d392 100644 --- a/src/auto-reply/reply/commands-subagents/action-info.ts +++ b/src/auto-reply/reply/commands-subagents/action-info.ts @@ -1,5 +1,6 @@ import { countPendingDescendantRuns } from "../../../agents/subagent-registry.js"; -import { loadSessionStore, resolveStorePath } from "../../../config/sessions.js"; +import { resolveStorePath } from "../../../config/sessions/paths.js"; +import { loadSessionStore } from "../../../config/sessions/store-load.js"; import { formatDurationCompact } from "../../../shared/subagents-format.js"; import { findTaskByRunIdForOwner } from "../../../tasks/task-owner-access.js"; import { sanitizeTaskStatusText } from "../../../tasks/task-status.js"; diff --git a/src/auto-reply/reply/commands-subagents/action-list.ts b/src/auto-reply/reply/commands-subagents/action-list.ts index e777c498d5f..ec8b36b0912 100644 --- a/src/auto-reply/reply/commands-subagents/action-list.ts +++ b/src/auto-reply/reply/commands-subagents/action-list.ts @@ -1,4 +1,4 @@ -import { buildSubagentList } from "../../../agents/subagent-control.js"; +import { buildSubagentList } from "../../../agents/subagent-list.js"; import type { CommandHandlerResult } from "../commands-types.js"; import { type SubagentsCommandContext, RECENT_WINDOW_MINUTES, stopWithText } from "./shared.js"; diff --git a/src/auto-reply/reply/commands-subagents/shared.ts b/src/auto-reply/reply/commands-subagents/shared.ts index f057150893b..50d581c5c5c 100644 --- a/src/auto-reply/reply/commands-subagents/shared.ts +++ b/src/auto-reply/reply/commands-subagents/shared.ts @@ -11,11 +11,9 @@ import { resolveMainSessionAlias, stripToolMessages, } from "../../../agents/tools/sessions-helpers.js"; -import type { - SessionEntry, - loadSessionStore as loadSessionStoreFn, - resolveStorePath as resolveStorePathFn, -} from "../../../config/sessions.js"; +import type { resolveStorePath as resolveStorePathFn } from "../../../config/sessions/paths.js"; +import type { loadSessionStore as loadSessionStoreFn } from "../../../config/sessions/store-load.js"; +import type { SessionEntry } from "../../../config/sessions/types.js"; import { callGateway } from "../../../gateway/call.js"; import { formatTimeAgo } from "../../../infra/format-time/format-relative.ts"; import { parseAgentSessionKey } from "../../../routing/session-key.js"; diff --git a/src/auto-reply/reply/commands.subagents.test.ts b/src/auto-reply/reply/commands.subagents.test.ts index d05b8413d47..2639506d570 100644 --- a/src/auto-reply/reply/commands.subagents.test.ts +++ b/src/auto-reply/reply/commands.subagents.test.ts @@ -3,6 +3,11 @@ import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { whatsappCommandPolicy } from "../../../test/helpers/channels/command-contract.js"; +import { + addSubagentRunForTests, + listSubagentRunsForRequester, + resetSubagentRegistryForTests, +} from "../../agents/subagent-registry.test-helpers.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { updateSessionStore } from "../../config/sessions.js"; @@ -21,17 +26,27 @@ vi.mock("../../gateway/call.js", () => ({ callGateway: callGatewayMock, })); -const { buildCommandTestParams } = await import("./commands.test-harness.js"); -const { buildStatusReply } = await import("./commands-status.js"); -const { handleSubagentsCommand } = await import("./commands-subagents.js"); -const { __testing: subagentControlTesting } = await import("../../agents/subagent-control.js"); -const { addSubagentRunForTests, listSubagentRunsForRequester, resetSubagentRegistryForTests } = - await import("../../agents/subagent-registry.js"); -const { createTaskRecord, resetTaskRegistryForTests } = - await import("../../tasks/task-registry.js"); -const { failTaskRunByRunId } = await import("../../tasks/task-executor.js"); - let testWorkspaceDir = os.tmpdir(); +let buildCommandTestParamsPromise: Promise | null = + null; +let handleSubagentsCommandPromise: Promise | null = null; +let subagentControlPromise: Promise | null = + null; + +function loadCommandTestHarness() { + buildCommandTestParamsPromise ??= import("./commands.test-harness.js"); + return buildCommandTestParamsPromise; +} + +function loadSubagentsModule() { + handleSubagentsCommandPromise ??= import("./commands-subagents.js"); + return handleSubagentsCommandPromise; +} + +function loadSubagentControlModule() { + subagentControlPromise ??= import("../../agents/subagent-control.js"); + return subagentControlPromise; +} const whatsappCommandTestPlugin: ChannelPlugin = { ...createChannelTestPluginBase({ @@ -69,49 +84,33 @@ function setChannelPluginRegistryForTests(): void { ); } -function buildParams(commandBody: string, cfg: OpenClawConfig) { +async function buildParams(commandBody: string, cfg: OpenClawConfig) { + const { buildCommandTestParams } = await loadCommandTestHarness(); return buildCommandTestParams(commandBody, cfg, undefined, { workspaceDir: testWorkspaceDir }); } -async function buildStatusReplyForTests(params: { - cfg: OpenClawConfig; - sessionKey?: string; - verbose?: boolean; -}): Promise { - const commandParams = buildCommandTestParams("/status", params.cfg, undefined, { - workspaceDir: testWorkspaceDir, - }); - const sessionKey = params.sessionKey ?? commandParams.sessionKey; - const reply = await buildStatusReply({ - cfg: params.cfg, - command: commandParams.command, - sessionEntry: commandParams.sessionEntry, - sessionKey, - parentSessionKey: sessionKey, - sessionScope: commandParams.sessionScope, - storePath: commandParams.storePath, - provider: "anthropic", - model: "claude-opus-4-6", - contextTokens: 0, - resolvedThinkLevel: commandParams.resolvedThinkLevel, - resolvedFastMode: false, - resolvedVerboseLevel: params.verbose ? "on" : commandParams.resolvedVerboseLevel, - resolvedReasoningLevel: commandParams.resolvedReasoningLevel, - resolvedElevatedLevel: commandParams.resolvedElevatedLevel, - resolveDefaultThinkingLevel: commandParams.resolveDefaultThinkingLevel, - isGroup: commandParams.isGroup, - defaultGroupActivation: commandParams.defaultGroupActivation, - }); - return { shouldContinue: false, reply }; -} - function requireCommandResult( - result: Awaited>, + result: Awaited> | null, ): CommandHandlerResult { expect(result).not.toBeNull(); return result as CommandHandlerResult; } +async function runSubagentsCommand(commandBody: string, cfg: OpenClawConfig) { + const params = await buildParams(commandBody, cfg); + const { handleSubagentsCommand } = await loadSubagentsModule(); + return handleSubagentsCommand(params, true); +} + +async function resetSubagentStateForTests() { + const { __testing: subagentControlTesting } = await loadSubagentControlModule(); + resetSubagentRegistryForTests(); + callGatewayMock.mockImplementation(async () => ({})); + subagentControlTesting.setDepsForTest({ + callGateway: (opts: unknown) => callGatewayMock(opts), + }); +} + function requireReplyText(reply: ReplyPayload | undefined): string { expect(reply?.text).toBeDefined(); return reply?.text as string; @@ -131,60 +130,13 @@ afterAll(async () => { }); }); -beforeEach(() => { +beforeEach(async () => { vi.clearAllMocks(); - resetTaskRegistryForTests(); - resetSubagentRegistryForTests(); + await resetSubagentStateForTests(); setChannelPluginRegistryForTests(); - callGatewayMock.mockImplementation(async () => ({})); - subagentControlTesting.setDepsForTest({ - callGateway: (opts: unknown) => callGatewayMock(opts), - }); }); describe("handleCommands subagents", () => { - it("lists subagents when none exist", async () => { - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildParams("/subagents list", cfg); - const result = requireCommandResult(await handleSubagentsCommand(params, true)); - const text = requireReplyText(result.reply); - expect(result.shouldContinue).toBe(false); - expect(text).toContain("active subagents:"); - expect(text).toContain("active subagents:\n-----\n"); - expect(text).toContain("recent subagents (last 30m):"); - expect(text).toContain("\n\nrecent subagents (last 30m):"); - expect(text).toContain("recent subagents (last 30m):\n-----\n"); - }); - - it("truncates long subagent task text in /subagents list", async () => { - addSubagentRunForTests({ - runId: "run-long-task", - childSessionKey: "agent:main:subagent:long-task", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", - task: "This is a deliberately long task description used to verify that subagent list output keeps the full task text instead of appending ellipsis after a short hard cutoff.", - cleanup: "keep", - createdAt: 1000, - startedAt: 1000, - }); - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildParams("/subagents list", cfg); - const result = requireCommandResult(await handleSubagentsCommand(params, true)); - const text = requireReplyText(result.reply); - expect(result.shouldContinue).toBe(false); - expect(text).toContain( - "This is a deliberately long task description used to verify that subagent list output keeps the full task text", - ); - expect(text).toContain("..."); - expect(text).not.toContain("after a short hard cutoff."); - }); - it("lists subagents for the command target session for native /subagents", async () => { addSubagentRunForTests({ runId: "run-target", @@ -210,6 +162,7 @@ describe("handleCommands subagents", () => { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; + const { buildCommandTestParams } = await loadCommandTestHarness(); const params = buildCommandTestParams( "/subagents list", cfg, @@ -220,6 +173,7 @@ describe("handleCommands subagents", () => { { workspaceDir: testWorkspaceDir }, ); params.sessionKey = "agent:main:slack:slash:u1"; + const { handleSubagentsCommand } = await loadSubagentsModule(); const result = requireCommandResult(await handleSubagentsCommand(params, true)); const text = requireReplyText(result.reply); expect(result.shouldContinue).toBe(false); @@ -228,157 +182,6 @@ describe("handleCommands subagents", () => { expect(text).not.toContain("slash run"); }); - it("keeps ended orchestrators in active list while descendants are pending", async () => { - const now = Date.now(); - addSubagentRunForTests({ - runId: "run-orchestrator-ended", - childSessionKey: "agent:main:subagent:orchestrator-ended", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", - task: "orchestrate child workers", - cleanup: "keep", - createdAt: now - 120_000, - startedAt: now - 120_000, - endedAt: now - 60_000, - outcome: { status: "ok" }, - }); - addSubagentRunForTests({ - runId: "run-orchestrator-child-active", - childSessionKey: "agent:main:subagent:orchestrator-ended:subagent:child", - requesterSessionKey: "agent:main:subagent:orchestrator-ended", - requesterDisplayKey: "subagent:orchestrator-ended", - task: "child worker still running", - cleanup: "keep", - createdAt: now - 30_000, - startedAt: now - 30_000, - }); - - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildParams("/subagents list", cfg); - const result = requireCommandResult(await handleSubagentsCommand(params, true)); - const text = requireReplyText(result.reply); - - expect(result.shouldContinue).toBe(false); - expect(text).toContain("active (waiting on 1 child)"); - expect(text).not.toContain("recent subagents (last 30m):\n-----\n1. orchestrate child workers"); - }); - - it("formats subagent usage with io and prompt/cache breakdown", async () => { - addSubagentRunForTests({ - runId: "run-usage", - childSessionKey: "agent:main:subagent:usage", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", - task: "do thing", - cleanup: "keep", - createdAt: 1000, - startedAt: 1000, - }); - const storePath = path.join(testWorkspaceDir, "sessions-subagents-usage.json"); - await updateSessionStore(storePath, (store) => { - store["agent:main:subagent:usage"] = { - sessionId: "child-session-usage", - updatedAt: Date.now(), - inputTokens: 12, - outputTokens: 1000, - totalTokens: 197000, - model: "opencode/claude-opus-4-6", - }; - }); - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: storePath }, - } as OpenClawConfig; - const params = buildParams("/subagents list", cfg); - const result = requireCommandResult(await handleSubagentsCommand(params, true)); - const text = requireReplyText(result.reply); - expect(result.shouldContinue).toBe(false); - expect(text).toMatch(/tokens 1(\.0)?k \(in 12 \/ out 1(\.0)?k\)/); - expect(text).toContain("prompt/cache 197k"); - expect(text).not.toContain("1k io"); - }); - - it.each([ - { - name: "omits subagent status line when none exist", - seedRuns: () => undefined, - verboseLevel: "on" as const, - expectedText: [] as string[], - unexpectedText: ["Subagents:"], - }, - { - name: "includes subagent count in /status when active", - seedRuns: () => { - addSubagentRunForTests({ - runId: "run-1", - childSessionKey: "agent:main:subagent:abc", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", - task: "do thing", - cleanup: "keep", - createdAt: 1000, - startedAt: 1000, - }); - }, - verboseLevel: "off" as const, - expectedText: ["๐Ÿค– Subagents: 1 active"], - unexpectedText: [] as string[], - }, - { - name: "includes subagent details in /status when verbose", - seedRuns: () => { - addSubagentRunForTests({ - runId: "run-1", - childSessionKey: "agent:main:subagent:abc", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", - task: "do thing", - cleanup: "keep", - createdAt: 1000, - startedAt: 1000, - }); - addSubagentRunForTests({ - runId: "run-2", - childSessionKey: "agent:main:subagent:def", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", - task: "finished task", - cleanup: "keep", - createdAt: 900, - startedAt: 900, - endedAt: 1200, - outcome: { status: "ok" }, - }); - }, - verboseLevel: "on" as const, - expectedText: ["๐Ÿค– Subagents: 1 active", "ยท 1 done"], - unexpectedText: [] as string[], - }, - ])("$name", async ({ seedRuns, verboseLevel, expectedText, unexpectedText }) => { - seedRuns(); - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { mainKey: "main", scope: "per-sender" }, - } as OpenClawConfig; - const result = await buildStatusReplyForTests({ - cfg, - verbose: verboseLevel === "on", - }); - expect(result.shouldContinue).toBe(false); - const text = requireReplyText(result.reply); - for (const expected of expectedText) { - expect(text).toContain(expected); - } - for (const blocked of unexpectedText) { - expect(text).not.toContain(blocked); - } - }); - it("returns help/usage for invalid or incomplete subagents commands", async () => { const cfg = { commands: { text: true }, @@ -389,115 +192,13 @@ describe("handleCommands subagents", () => { { commandBody: "/subagents info", expectedText: "/subagents info" }, ] as const; for (const testCase of cases) { - const params = buildParams(testCase.commandBody, cfg); - const result = requireCommandResult(await handleSubagentsCommand(params, true)); + const result = requireCommandResult(await runSubagentsCommand(testCase.commandBody, cfg)); const text = requireReplyText(result.reply); expect(result.shouldContinue).toBe(false); expect(text).toContain(testCase.expectedText); } }); - it("returns info for a subagent", async () => { - const now = Date.now(); - addSubagentRunForTests({ - runId: "run-1", - childSessionKey: "agent:main:subagent:abc", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", - task: "do thing", - cleanup: "keep", - createdAt: now - 20_000, - startedAt: now - 20_000, - endedAt: now - 1_000, - outcome: { status: "ok" }, - }); - createTaskRecord({ - runtime: "subagent", - requesterSessionKey: "agent:main:main", - childSessionKey: "agent:main:subagent:abc", - runId: "run-1", - task: "do thing", - status: "succeeded", - terminalSummary: "Completed the requested task", - deliveryStatus: "delivered", - }); - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { mainKey: "main", scope: "per-sender" }, - } as OpenClawConfig; - const params = buildParams("/subagents info 1", cfg); - const result = requireCommandResult(await handleSubagentsCommand(params, true)); - const text = requireReplyText(result.reply); - expect(result.shouldContinue).toBe(false); - expect(text).toContain("Subagent info"); - expect(text).toContain("Run: run-1"); - expect(text).toContain("Status: done"); - expect(text).toContain("TaskStatus: succeeded"); - expect(text).toContain("Task summary: Completed the requested task"); - }); - - it("sanitizes leaked task details in /subagents info", async () => { - const now = Date.now(); - addSubagentRunForTests({ - runId: "run-1", - childSessionKey: "agent:main:subagent:abc", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", - task: "Inspect the stuck run", - cleanup: "keep", - createdAt: now - 20_000, - startedAt: now - 20_000, - endedAt: now - 1_000, - outcome: { - status: "error", - error: [ - "OpenClaw runtime context (internal):", - "This context is runtime-generated, not user-authored. Keep internal details private.", - "", - "[Internal task completion event]", - "source: subagent", - ].join("\n"), - }, - }); - createTaskRecord({ - runtime: "subagent", - requesterSessionKey: "agent:main:main", - childSessionKey: "agent:main:subagent:abc", - runId: "run-1", - task: "Inspect the stuck run", - status: "running", - deliveryStatus: "delivered", - }); - failTaskRunByRunId({ - runId: "run-1", - endedAt: now - 1_000, - error: [ - "OpenClaw runtime context (internal):", - "This context is runtime-generated, not user-authored. Keep internal details private.", - "", - "[Internal task completion event]", - "source: subagent", - ].join("\n"), - terminalSummary: "Needs manual follow-up.", - }); - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { mainKey: "main", scope: "per-sender" }, - } as OpenClawConfig; - const params = buildParams("/subagents info 1", cfg); - const result = requireCommandResult(await handleSubagentsCommand(params, true)); - const text = requireReplyText(result.reply); - - expect(result.shouldContinue).toBe(false); - expect(text).toContain("Subagent info"); - expect(text).toContain("Outcome: error"); - expect(text).toContain("Task summary: Needs manual follow-up."); - expect(text).not.toContain("OpenClaw runtime context (internal):"); - expect(text).not.toContain("Internal task completion event"); - }); - it("kills subagents via /kill alias without a confirmation reply", async () => { addSubagentRunForTests({ runId: "run-1", @@ -513,8 +214,7 @@ describe("handleCommands subagents", () => { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; - const params = buildParams("/kill 1", cfg); - const result = requireCommandResult(await handleSubagentsCommand(params, true)); + const result = requireCommandResult(await runSubagentsCommand("/kill 1", cfg)); expect(result.shouldContinue).toBe(false); expect(result.reply).toBeUndefined(); }); @@ -547,8 +247,7 @@ describe("handleCommands subagents", () => { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; - const params = buildParams("/kill 1", cfg); - const result = requireCommandResult(await handleSubagentsCommand(params, true)); + const result = requireCommandResult(await runSubagentsCommand("/kill 1", cfg)); expect(result.shouldContinue).toBe(false); expect(result.reply).toBeUndefined(); }); @@ -584,8 +283,9 @@ describe("handleCommands subagents", () => { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; - const params = buildParams("/subagents send 1 continue with follow-up details", cfg); - const result = requireCommandResult(await handleSubagentsCommand(params, true)); + const result = requireCommandResult( + await runSubagentsCommand("/subagents send 1 continue with follow-up details", cfg), + ); const text = requireReplyText(result.reply); expect(result.shouldContinue).toBe(false); expect(text).toContain("โœ… Sent to"); @@ -648,9 +348,10 @@ describe("handleCommands subagents", () => { channels: { whatsapp: { allowFrom: ["*"] } }, session: { store: storePath }, } as OpenClawConfig; - const params = buildParams("/subagents send 1 continue with follow-up details", cfg); + const params = await buildParams("/subagents send 1 continue with follow-up details", cfg); params.sessionKey = leafKey; + const { handleSubagentsCommand } = await loadSubagentsModule(); const result = requireCommandResult(await handleSubagentsCommand(params, true)); const text = requireReplyText(result.reply); @@ -689,8 +390,9 @@ describe("handleCommands subagents", () => { channels: { whatsapp: { allowFrom: ["*"] } }, session: { store: storePath }, } as OpenClawConfig; - const params = buildParams("/steer 1 check timer.ts instead", cfg); - const result = requireCommandResult(await handleSubagentsCommand(params, true)); + const result = requireCommandResult( + await runSubagentsCommand("/steer 1 check timer.ts instead", cfg), + ); const text = requireReplyText(result.reply); expect(result.shouldContinue).toBe(false); expect(text).toContain("steered"); @@ -749,8 +451,9 @@ describe("handleCommands subagents", () => { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; - const params = buildParams("/steer 1 check timer.ts instead", cfg); - const result = requireCommandResult(await handleSubagentsCommand(params, true)); + const result = requireCommandResult( + await runSubagentsCommand("/steer 1 check timer.ts instead", cfg), + ); const text = requireReplyText(result.reply); expect(result.shouldContinue).toBe(false); expect(text).toContain("send failed: dispatch failed");