diff --git a/src/agents/subagent-list.test.ts b/src/agents/subagent-list.test.ts index 99fbde4c37e0..2ecc678697b2 100644 --- a/src/agents/subagent-list.test.ts +++ b/src/agents/subagent-list.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { replaceSessionEntry } from "../config/sessions/session-accessor.js"; +import { SUBAGENT_ENDED_REASON_KILLED } from "./subagent-lifecycle-events.js"; import { buildSubagentList } from "./subagent-list.js"; import { addSubagentRunForTests, @@ -108,6 +109,64 @@ describe("buildSubagentList", () => { expect(list.active[0]?.line).toContain("review_subagents: Review worker"); }); + it.each([ + { + name: "a killed run with a provider failure", + endedReason: SUBAGENT_ENDED_REASON_KILLED, + outcome: { status: "error", error: "agent run aborted" } as const, + expectedStatus: "killed", + }, + { + name: "a killed run with an earlier successful provider outcome", + endedReason: SUBAGENT_ENDED_REASON_KILLED, + outcome: { status: "ok" } as const, + expectedStatus: "killed", + }, + { + name: "a failed run", + outcome: { status: "error", error: "provider rejected the request" } as const, + expectedStatus: "failed", + }, + { + name: "a timed-out run", + outcome: { status: "timeout" } as const, + expectedStatus: "timeout", + }, + { + name: "a completed run", + outcome: { status: "ok" } as const, + expectedStatus: "done", + }, + ])( + "projects the canonical terminal status for $name", + ({ endedReason, outcome, expectedStatus }) => { + const now = Date.now(); + const run = { + runId: `run-status-${expectedStatus}`, + childSessionKey: `agent:main:subagent:status-${expectedStatus}`, + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "report the actual child outcome", + cleanup: "keep", + createdAt: now - 2_000, + startedAt: now - 2_000, + endedAt: now - 1_000, + ...(endedReason ? { endedReason } : {}), + outcome, + } satisfies SubagentRunRecord; + addSubagentRunForTests(run); + + const list = buildSubagentList({ + cfg: {} as OpenClawConfig, + runs: [run], + recentMinutes: 30, + }); + + expect(list.recent[0]?.status).toBe(expectedStatus); + expect(list.recent[0]?.line).toContain(` ${expectedStatus}`); + }, + ); + it("keeps ended orchestrators active while descendants remain pending", () => { // Parent orchestrators can finish their own turn before child workers do; // list output should keep them active until descendants settle. diff --git a/src/agents/subagent-list.ts b/src/agents/subagent-list.ts index 8f47f6411dfe..be06a2e72a4a 100644 --- a/src/agents/subagent-list.ts +++ b/src/agents/subagent-list.ts @@ -26,10 +26,10 @@ import { import { getSubagentRunsSnapshotForRead } from "./subagent-registry-state.js"; import type { SubagentRunRecord } from "./subagent-registry.types.js"; import { - hasSubagentRunEnded, isLiveUnendedSubagentRun, shouldKeepSubagentRunChildLink, } from "./subagent-run-liveness.js"; +import { resolveSubagentDisplayStatus } from "./subagent-session-metrics.js"; type SubagentListItem = { index: number; @@ -144,25 +144,6 @@ function isActiveSubagentRun( return isLiveUnendedSubagentRun(entry) || 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 (!hasSubagentRunEnded(entry)) { - 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, @@ -240,9 +221,7 @@ export function buildSubagentList(params: { const totalTokens = resolveTotalTokens(sessionEntry); const usageText = formatTokenUsageDisplay(sessionEntry); const pendingDescendants = pendingDescendantCount(entry.childSessionKey); - const status = resolveRunStatus(entry, { - pendingDescendants, - }); + const status = resolveSubagentDisplayStatus(entry, pendingDescendants); const childSessions = childSessionsByController.get(entry.childSessionKey) ?? []; const runtime = formatDurationCompact(runtimeMs) ?? "n/a"; const label = truncateLine(resolveSubagentLabel(entry), 48); diff --git a/src/agents/subagent-session-metrics.ts b/src/agents/subagent-session-metrics.ts index e7c1cf9b768f..7ff700fa64ae 100644 --- a/src/agents/subagent-session-metrics.ts +++ b/src/agents/subagent-session-metrics.ts @@ -76,3 +76,16 @@ export function resolveSubagentSessionStatus( } return "done"; } + +/** Formats the authoritative run status while preserving unfinished descendants. */ +export function resolveSubagentDisplayStatus( + entry: Pick, + pendingDescendants = 0, +): string { + const pending = Math.max(0, pendingDescendants); + if (pending > 0) { + const childLabel = pending === 1 ? "child" : "children"; + return `active (waiting on ${pending} ${childLabel})`; + } + return resolveSubagentSessionStatus(entry) ?? "done"; +} diff --git a/src/agents/tools/subagents-tool.test.ts b/src/agents/tools/subagents-tool.test.ts index 1ede25caa30a..449a733e168b 100644 --- a/src/agents/tools/subagents-tool.test.ts +++ b/src/agents/tools/subagents-tool.test.ts @@ -2,6 +2,12 @@ import { describe, expect, it, vi } from "vitest"; import type { TaskRecord, TaskRuntime, TaskStatus } from "../../tasks/task-registry.types.js"; import { TASK_STATUS_DETAIL_MAX_CHARS } from "../../tasks/task-status.js"; +import { SUBAGENT_ENDED_REASON_KILLED } from "../subagent-lifecycle-events.js"; +import { + addSubagentRunForTests, + resetSubagentRegistryForTests, +} from "../subagent-registry.test-helpers.js"; +import type { SubagentRunRecord } from "../subagent-registry.types.js"; import { createSubagentsTool } from "./subagents-tool.js"; function task(params: { @@ -47,6 +53,44 @@ describe("subagents tool", () => { ); }); + it("reports a killed subagent truthfully through the actual list tool", async () => { + resetSubagentRegistryForTests(); + const now = Date.now(); + const run = { + runId: "run-tool-killed", + childSessionKey: "agent:main:subagent:tool-killed", + controllerSessionKey: "agent:main:main", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "report the killed child", + cleanup: "keep", + createdAt: now - 2_000, + startedAt: now - 2_000, + endedAt: now - 1_000, + endedReason: SUBAGENT_ENDED_REASON_KILLED, + outcome: { status: "error", error: "agent run aborted" }, + } satisfies SubagentRunRecord; + addSubagentRunForTests(run); + + try { + const tool = createSubagentsTool({ + agentSessionKey: "agent:main:main", + config: {}, + listTasks: () => [], + }); + + const result = await tool.execute("list-killed", { action: "list" }); + + expect(result.details).toMatchObject({ + status: "ok", + recent: [expect.objectContaining({ runId: run.runId, status: "killed" })], + }); + expect((result.details as { text: string }).text).toContain(" killed"); + } finally { + resetSubagentRegistryForTests(); + } + }); + it("lists cross-runtime tasks in the caller session tree", async () => { const tasks = [ task({ diff --git a/src/auto-reply/reply/commands-subagents.test.ts b/src/auto-reply/reply/commands-subagents.test.ts index 458d8d02c897..e414a00c6b04 100644 --- a/src/auto-reply/reply/commands-subagents.test.ts +++ b/src/auto-reply/reply/commands-subagents.test.ts @@ -7,6 +7,7 @@ import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SUBAGENT_ENDED_REASON_KILLED } from "../../agents/subagent-lifecycle-events.js"; import { subagentRuns } from "../../agents/subagent-registry-memory.js"; import { countPendingDescendantRunsFromRuns, @@ -26,6 +27,7 @@ import type { ReplyPayload } from "../types.js"; import { buildSubagentsStatusLine } from "./commands-status-subagents.js"; import { extractMessageText } from "./commands-subagents-text.js"; import { handleSubagentsInfoAction } from "./commands-subagents/action-info.js"; +import { handleSubagentsListAction } from "./commands-subagents/action-list.js"; import { handleSubagentsLogAction } from "./commands-subagents/action-log.js"; import { resolveFocusTargetSession } from "./commands-subagents/shared.js"; import { @@ -233,6 +235,62 @@ describe("subagents info", () => { expect(text).toContain("Task summary: Completed the requested task"); }); + it.each([ + { + name: "a killed run despite its provider error", + endedReason: SUBAGENT_ENDED_REASON_KILLED, + outcome: { status: "error", error: "agent run aborted" } as const, + expectedStatus: "killed", + }, + { + name: "a killed run despite its earlier successful result", + endedReason: SUBAGENT_ENDED_REASON_KILLED, + outcome: { status: "ok" } as const, + expectedStatus: "killed", + }, + { + name: "a failed run", + outcome: { status: "error", error: "provider rejected the request" } as const, + expectedStatus: "failed", + }, + { + name: "a timed-out run", + outcome: { status: "timeout" } as const, + expectedStatus: "timeout", + }, + ])( + "keeps /subagents info and list aligned for $name", + ({ endedReason, outcome, expectedStatus }) => { + const now = Date.now(); + const run = { + runId: `commands-subagents-status-${expectedStatus}`, + childSessionKey: `agent:main:subagent:commands-status-${expectedStatus}`, + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "report the actual child outcome", + cleanup: "keep", + createdAt: now - 2_000, + startedAt: now - 2_000, + endedAt: now - 1_000, + ...(endedReason ? { endedReason } : {}), + outcome, + } satisfies SubagentRunRecord; + addSubagentRunForTests(run); + const context = buildInfoContext({ + cfg: buildCommandTestConfig(), + runs: [run], + restTokens: ["1"], + }); + + expect(requireReplyText(handleSubagentsInfoAction(context).reply)).toContain( + `Status: ${expectedStatus}`, + ); + expect(requireReplyText(handleSubagentsListAction(context).reply)).toContain( + ` ${expectedStatus}`, + ); + }, + ); + it("omits Date-invalid subagent timestamps", () => { const runId = "commands-subagents-info-invalid-date-run"; const childSessionKey = "agent:main:subagent:commands-info-invalid-date"; diff --git a/src/auto-reply/reply/commands-subagents/action-info.ts b/src/auto-reply/reply/commands-subagents/action-info.ts index 5790e66bcb74..24fbe017c961 100644 --- a/src/auto-reply/reply/commands-subagents/action-info.ts +++ b/src/auto-reply/reply/commands-subagents/action-info.ts @@ -3,6 +3,7 @@ import { timestampMsToIsoString } from "@openclaw/normalization-core/number-coer import { subagentRuns } from "../../../agents/subagent-registry-memory.js"; import { countPendingDescendantRunsFromRuns } from "../../../agents/subagent-registry-queries.js"; import { getSubagentRunsSnapshotForRead } from "../../../agents/subagent-registry-state.js"; +import { resolveSubagentDisplayStatus } from "../../../agents/subagent-session-metrics.js"; import { resolveStorePath } from "../../../config/sessions/paths.js"; import { loadSessionEntryReadOnly } from "../../../config/sessions/session-accessor.js"; import { formatTimeAgo } from "../../../infra/format-time/format-relative.ts"; @@ -11,7 +12,7 @@ import { formatDurationCompact } from "../../../shared/subagents-format.js"; import { findTaskByRunIdForOwner } from "../../../tasks/task-owner-access.js"; import { sanitizeTaskStatusText } from "../../../tasks/task-status.js"; import type { CommandHandlerResult } from "../commands-types.js"; -import { formatRunLabel, formatRunStatus } from "../subagents-utils.js"; +import { formatRunLabel } from "../subagents-utils.js"; import { resolveSubagentEntryForToken, stopWithText, @@ -29,19 +30,6 @@ function formatTimestampWithAge(valueMs?: number) { return `${timestamp} (${formatTimeAgo(Date.now() - valueMs, { fallback: "n/a" })})`; } -function resolveDisplayStatus( - entry: SubagentsCommandContext["runs"][number], - 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})`; - } - const status = formatRunStatus(entry); - return status === "error" ? "failed" : status; -} - function loadSubagentSessionEntry(params: SubagentsCommandContext["params"], childKey: string) { const parsed = parseAgentSessionKey(childKey); const storePath = resolveStorePath(params.cfg.session?.store, { @@ -91,12 +79,13 @@ export function handleSubagentsInfoAction(ctx: SubagentsCommandContext): Command const lines = [ "ℹ️ Subagent info", - `Status: ${resolveDisplayStatus(run, { - pendingDescendants: countPendingDescendantRunsFromRuns( + `Status: ${resolveSubagentDisplayStatus( + run, + countPendingDescendantRunsFromRuns( getSubagentRunsSnapshotForRead(subagentRuns), run.childSessionKey, ), - })}`, + )}`, `Label: ${formatRunLabel(run)}`, `Task: ${taskText}`, `Run: ${run.runId}`, diff --git a/src/auto-reply/reply/reply-plumbing.test.ts b/src/auto-reply/reply/reply-plumbing.test.ts index 617290893c77..225c7f930d23 100644 --- a/src/auto-reply/reply/reply-plumbing.test.ts +++ b/src/auto-reply/reply/reply-plumbing.test.ts @@ -14,12 +14,7 @@ import { import type { TemplateContext } from "../templating.js"; import { buildThreadingToolContext } from "./agent-runner-utils.js"; import { applyReplyThreading } from "./reply-payloads.js"; -import { - formatRunLabel, - formatRunStatus, - resolveSubagentLabel, - sortSubagentRuns, -} from "./subagents-utils.js"; +import { formatRunLabel, resolveSubagentLabel, sortSubagentRuns } from "./subagents-utils.js"; function createSlackThreadingPlugin(): ChannelPlugin { return { @@ -484,14 +479,6 @@ describe("subagents utils", () => { expect(sorted.map((run) => run.runId)).toEqual(["run-2", "run-1", "run-3"]); }); - it("formats run status from outcome and timestamps", () => { - expect(formatRunStatus({ ...baseRun })).toBe("running"); - expect(formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "ok" } })).toBe("done"); - expect(formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "timeout" } })).toBe( - "timeout", - ); - }); - it("formats duration compact for seconds and minutes", () => { expect(formatDurationCompact(45_000)).toBe("45s"); expect(formatDurationCompact(65_000)).toBe("1m5s"); diff --git a/src/auto-reply/reply/subagents-utils.ts b/src/auto-reply/reply/subagents-utils.ts index 55669bf07964..598a5524882f 100644 --- a/src/auto-reply/reply/subagents-utils.ts +++ b/src/auto-reply/reply/subagents-utils.ts @@ -21,14 +21,6 @@ export function formatRunLabel(entry: SubagentRunRecord, options?: { maxLength?: return raw.length > maxLength ? `${truncateUtf16Safe(raw, maxLength).trimEnd()}…` : raw; } -export function formatRunStatus(entry: SubagentRunRecord) { - if (!entry.endedAt) { - return "running"; - } - const status = entry.outcome?.status ?? "done"; - return status === "ok" ? "done" : status; -} - export function sortSubagentRuns(runs: SubagentRunRecord[]) { return [...runs].toSorted((a, b) => { const aTime = a.startedAt ?? a.createdAt ?? 0;