fix: keep active-descendant subagents visible in reply status

This commit is contained in:
Tak Hoffman
2026-03-24 15:50:59 -05:00
parent 0d2315ed15
commit ebe18c0379
4 changed files with 177 additions and 2 deletions

View File

@@ -0,0 +1,73 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
addSubagentRunForTests,
resetSubagentRegistryForTests,
} from "../../agents/subagent-registry.js";
import type { OpenClawConfig } from "../../config/config.js";
import { buildStatusReply } from "./commands-status.js";
import { buildCommandTestParams } from "./commands.test-harness.js";
describe("buildStatusReply subagent summary", () => {
beforeEach(() => {
resetSubagentRegistryForTests();
});
afterEach(() => {
resetSubagentRegistryForTests();
});
it("counts ended orchestrators with active descendants as active", async () => {
const parentKey = "agent:main:subagent:status-ended-parent";
addSubagentRunForTests({
runId: "run-status-ended-parent",
childSessionKey: parentKey,
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "status orchestrator",
cleanup: "keep",
createdAt: Date.now() - 120_000,
startedAt: Date.now() - 120_000,
endedAt: Date.now() - 110_000,
outcome: { status: "ok" },
});
addSubagentRunForTests({
runId: "run-status-active-child",
childSessionKey: "agent:main:subagent:status-ended-parent:subagent:child",
requesterSessionKey: parentKey,
requesterDisplayKey: "subagent:status-ended-parent",
task: "status child still running",
cleanup: "keep",
createdAt: Date.now() - 60_000,
startedAt: Date.now() - 60_000,
});
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { mainKey: "main", scope: "per-sender" },
} as OpenClawConfig;
const params = buildCommandTestParams("/status", cfg);
const reply = await buildStatusReply({
cfg,
command: params.command,
sessionEntry: params.sessionEntry,
sessionKey: params.sessionKey,
parentSessionKey: params.sessionKey,
sessionScope: params.sessionScope,
storePath: params.storePath,
provider: "anthropic",
model: "claude-opus-4-5",
contextTokens: 0,
resolvedThinkLevel: params.resolvedThinkLevel,
resolvedFastMode: false,
resolvedVerboseLevel: params.resolvedVerboseLevel,
resolvedReasoningLevel: params.resolvedReasoningLevel,
resolvedElevatedLevel: params.resolvedElevatedLevel,
resolveDefaultThinkingLevel: params.resolveDefaultThinkingLevel,
isGroup: params.isGroup,
defaultGroupActivation: params.defaultGroupActivation,
});
expect(reply?.text).toContain("🤖 Subagents: 1 active");
});
});

View File

@@ -5,7 +5,10 @@ import {
} from "../../agents/agent-scope.js";
import { resolveFastModeState } from "../../agents/fast-mode.js";
import { resolveModelAuthLabel } from "../../agents/model-auth-label.js";
import { listSubagentRunsForRequester } from "../../agents/subagent-registry.js";
import {
countPendingDescendantRuns,
listSubagentRunsForRequester,
} from "../../agents/subagent-registry.js";
import {
resolveInternalSessionKey,
resolveMainSessionAlias,
@@ -188,7 +191,9 @@ export async function buildStatusReply(params: {
const runs = listSubagentRunsForRequester(requesterKey);
const verboseEnabled = resolvedVerboseLevel && resolvedVerboseLevel !== "off";
if (runs.length > 0) {
const active = runs.filter((entry) => !entry.endedAt);
const active = runs.filter(
(entry) => !entry.endedAt || countPendingDescendantRuns(entry.childSessionKey) > 0,
);
const done = runs.length - active.length;
if (verboseEnabled) {
const labels = active

View File

@@ -1,3 +1,4 @@
import { countPendingDescendantRuns } from "../../../agents/subagent-registry.js";
import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js";
import type { CommandHandlerResult } from "../commands-types.js";
import { formatRunLabel, sortSubagentRuns } from "../subagents-utils.js";
@@ -49,6 +50,9 @@ export function handleSubagentsAgentsAction(ctx: SubagentsCommandContext): Comma
if (!entry.endedAt) {
return true;
}
if (countPendingDescendantRuns(entry.childSessionKey) > 0) {
return true;
}
return resolveSessionBindings(entry.childSessionKey).length > 0;
});

View File

@@ -2243,6 +2243,99 @@ describe("handleCommands subagents", () => {
expect(trackedRuns[0].runId).toBe("run-steer-ended-parent");
});
it("lists ended orchestrators that are still waiting on active descendants in /agents", async () => {
const parentKey = "agent:main:subagent:agents-ended-parent";
const childKey = "agent:main:subagent:agents-ended-parent:subagent:child";
const storePath = path.join(testWorkspaceDir, "sessions-subagents-agents-ended-parent.json");
await updateSessionStore(storePath, (store) => {
store[parentKey] = {
sessionId: "agents-ended-parent-session",
updatedAt: Date.now(),
};
store[childKey] = {
sessionId: "agents-active-child-session",
updatedAt: Date.now(),
};
});
addSubagentRunForTests({
runId: "run-agents-ended-parent",
childSessionKey: parentKey,
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "orchestrate child workers",
cleanup: "keep",
createdAt: Date.now() - 120_000,
startedAt: Date.now() - 120_000,
endedAt: Date.now() - 110_000,
outcome: { status: "ok" },
});
addSubagentRunForTests({
runId: "run-agents-active-child",
childSessionKey: childKey,
requesterSessionKey: parentKey,
requesterDisplayKey: "subagent:agents-ended-parent",
task: "child worker still running",
cleanup: "keep",
createdAt: Date.now() - 60_000,
startedAt: Date.now() - 60_000,
});
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
} as OpenClawConfig;
const params = buildParams("/agents", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("agents:");
expect(result.reply?.text).toContain("orchestrate child workers");
});
it("dedupes stale rows for the same child session in /agents", async () => {
const childKey = "agent:main:subagent:agents-dedupe";
const storePath = path.join(testWorkspaceDir, "sessions-subagents-agents-dedupe.json");
await updateSessionStore(storePath, (store) => {
store[childKey] = {
sessionId: "agents-dedupe-session",
updatedAt: Date.now(),
};
});
addSubagentRunForTests({
runId: "run-agents-dedupe-new",
childSessionKey: childKey,
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "current worker label",
cleanup: "keep",
createdAt: Date.now() - 10_000,
startedAt: Date.now() - 10_000,
});
addSubagentRunForTests({
runId: "run-agents-dedupe-old",
childSessionKey: childKey,
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "stale worker label",
cleanup: "keep",
createdAt: Date.now() - 20_000,
startedAt: Date.now() - 20_000,
endedAt: Date.now() - 15_000,
outcome: { status: "ok" },
});
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
} as OpenClawConfig;
const params = buildParams("/agents", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("current worker label");
expect(result.reply?.text).not.toContain("stale worker label");
});
it("restores announce behavior when /steer replacement dispatch fails", async () => {
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string };