mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-14 10:41:23 +00:00
perf(test): trim subagent command imports
This commit is contained in:
@@ -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<string, Record<string, SessionEntry>>;
|
||||
}): 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<string, SubagentRunRecord>) {
|
||||
const latestByChildSessionKey = new Map<string, SubagentRunRecord>();
|
||||
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<string, string[]>();
|
||||
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<string, SubagentRunRecord>) {
|
||||
const pendingDescendantCache = new Map<string, number>();
|
||||
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<string>();
|
||||
for (const entry of sortSubagentRuns(params.runs)) {
|
||||
if (seenChildSessionKeys.has(entry.childSessionKey)) {
|
||||
continue;
|
||||
}
|
||||
seenChildSessionKeys.add(entry.childSessionKey);
|
||||
dedupedRuns.push(entry);
|
||||
}
|
||||
const cache = new Map<string, Record<string, SessionEntry>>();
|
||||
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;
|
||||
|
||||
158
src/agents/subagent-list.test.ts
Normal file
158
src/agents/subagent-list.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
282
src/agents/subagent-list.ts
Normal file
282
src/agents/subagent-list.ts
Normal file
@@ -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<string, Record<string, SessionEntry>>;
|
||||
}): 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<string, SubagentRunRecord>) {
|
||||
const latestByChildSessionKey = new Map<string, SubagentRunRecord>();
|
||||
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<string, string[]>();
|
||||
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<string, SubagentRunRecord>) {
|
||||
const pendingDescendantCache = new Map<string, number>();
|
||||
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<string>();
|
||||
for (const entry of sortSubagentRuns(params.runs)) {
|
||||
if (seenChildSessionKeys.has(entry.childSessionKey)) {
|
||||
continue;
|
||||
}
|
||||
seenChildSessionKeys.add(entry.childSessionKey);
|
||||
dedupedRuns.push(entry);
|
||||
}
|
||||
const cache = new Map<string, Record<string, SessionEntry>>();
|
||||
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 }),
|
||||
};
|
||||
}
|
||||
20
src/agents/subagent-registry.test-helpers.ts
Normal file
20
src/agents/subagent-registry.test-helpers.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
150
src/auto-reply/reply/commands-subagents-info.test.ts
Normal file
150
src/auto-reply/reply/commands-subagents-info.test.ts
Normal file
@@ -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<typeof handleSubagentsInfoAction>[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 <id|#>");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
130
src/auto-reply/reply/commands-subagents-status.test.ts
Normal file
130
src/auto-reply/reply/commands-subagents-status.test.ts
Normal file
@@ -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<CommandHandlerResult> {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import("./commands-subagents/action-agents.js")> | null =
|
||||
null;
|
||||
let actionFocusPromise: Promise<typeof import("./commands-subagents/action-focus.js")> | null =
|
||||
null;
|
||||
let actionHelpPromise: Promise<typeof import("./commands-subagents/action-help.js")> | null = null;
|
||||
let actionInfoPromise: Promise<typeof import("./commands-subagents/action-info.js")> | null = null;
|
||||
let actionKillPromise: Promise<typeof import("./commands-subagents/action-kill.js")> | null = null;
|
||||
let actionListPromise: Promise<typeof import("./commands-subagents/action-list.js")> | null = null;
|
||||
let actionLogPromise: Promise<typeof import("./commands-subagents/action-log.js")> | null = null;
|
||||
let actionSendPromise: Promise<typeof import("./commands-subagents/action-send.js")> | null = null;
|
||||
let actionSpawnPromise: Promise<typeof import("./commands-subagents/action-spawn.js")> | null =
|
||||
null;
|
||||
let actionUnfocusPromise: Promise<typeof import("./commands-subagents/action-unfocus.js")> | 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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<typeof import("./commands.test-harness.js")> | null =
|
||||
null;
|
||||
let handleSubagentsCommandPromise: Promise<typeof import("./commands-subagents.js")> | null = null;
|
||||
let subagentControlPromise: Promise<typeof import("../../agents/subagent-control.js")> | 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<CommandHandlerResult> {
|
||||
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<ReturnType<typeof handleSubagentsCommand>>,
|
||||
result: Awaited<ReturnType<typeof runSubagentsCommand>> | 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");
|
||||
|
||||
Reference in New Issue
Block a user