diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index 477544bdd3d..896b03e98d0 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -1468,6 +1468,24 @@ export function listDescendantRunsForRequester(rootSessionKey: string): Subagent ); } +export function getSubagentRunByChildSessionKey(childSessionKey: string): SubagentRunRecord | null { + const runIds = findRunIdsByChildSessionKeyFromRuns(subagentRuns, childSessionKey); + if (runIds.length === 0) { + return null; + } + let latest: SubagentRunRecord | null = null; + for (const runId of runIds) { + const entry = subagentRuns.get(runId); + if (!entry) { + continue; + } + if (!latest || entry.createdAt > latest.createdAt) { + latest = entry; + } + } + return latest; +} + export function initSubagentRegistry() { restoreSubagentRunsOnce(); } diff --git a/src/agents/tools/sessions-helpers.ts b/src/agents/tools/sessions-helpers.ts index e638438758c..50132efb8b2 100644 --- a/src/agents/tools/sessions-helpers.ts +++ b/src/agents/tools/sessions-helpers.ts @@ -44,6 +44,8 @@ export type SessionListDeliveryContext = { accountId?: string; }; +export type SessionRunStatus = "running" | "done" | "failed" | "killed"; + export type SessionListRow = { key: string; kind: SessionKind; @@ -56,6 +58,11 @@ export type SessionListRow = { model?: string; contextTokens?: number | null; totalTokens?: number | null; + status?: SessionRunStatus; + startedAt?: number; + endedAt?: number; + runtimeMs?: number; + childSessions?: string[]; thinkingLevel?: string; verboseLevel?: string; systemSent?: boolean; diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 3c69ce1bcd7..1e5f700e01b 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -1,7 +1,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test } from "vitest"; +import { + addSubagentRunForTests, + resetSubagentRegistryForTests, +} from "../agents/subagent-registry.js"; import { clearConfigCache, writeConfigFile } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js"; import type { SessionEntry } from "../config/sessions.js"; @@ -82,6 +86,10 @@ function createLegacyRuntimeStore(model: string): Record { } describe("gateway session utils", () => { + afterEach(() => { + resetSubagentRegistryForTests({ persist: false }); + }); + test("capArrayByJsonBytes trims from the front", () => { const res = capArrayByJsonBytes(["a", "b", "c"], 10); expect(res.items).toEqual(["b", "c"]); @@ -830,6 +838,111 @@ describe("listSessionsFromStore search", () => { }); }); +describe("listSessionsFromStore subagent metadata", () => { + const cfg = { + session: { mainKey: "main" }, + agents: { list: [{ id: "main", default: true }] }, + } as OpenClawConfig; + + test("includes subagent status timing and direct child session keys", () => { + const now = Date.now(); + const store: Record = { + "agent:main:main": { + sessionId: "sess-main", + updatedAt: now, + } as SessionEntry, + "agent:main:subagent:parent": { + sessionId: "sess-parent", + updatedAt: now - 2_000, + spawnedBy: "agent:main:main", + } as SessionEntry, + "agent:main:subagent:child": { + sessionId: "sess-child", + updatedAt: now - 1_000, + spawnedBy: "agent:main:subagent:parent", + } as SessionEntry, + "agent:main:subagent:failed": { + sessionId: "sess-failed", + updatedAt: now - 500, + spawnedBy: "agent:main:main", + } as SessionEntry, + }; + + addSubagentRunForTests({ + runId: "run-parent", + childSessionKey: "agent:main:subagent:parent", + controllerSessionKey: "agent:main:main", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "parent task", + cleanup: "keep", + createdAt: now - 10_000, + startedAt: now - 9_000, + model: "openai/gpt-5.4", + }); + addSubagentRunForTests({ + runId: "run-child", + childSessionKey: "agent:main:subagent:child", + controllerSessionKey: "agent:main:subagent:parent", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "child task", + cleanup: "keep", + createdAt: now - 8_000, + startedAt: now - 7_500, + endedAt: now - 2_500, + outcome: { status: "ok" }, + model: "openai/gpt-5.4", + }); + addSubagentRunForTests({ + runId: "run-failed", + childSessionKey: "agent:main:subagent:failed", + controllerSessionKey: "agent:main:main", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "failed task", + cleanup: "keep", + createdAt: now - 6_000, + startedAt: now - 5_500, + endedAt: now - 500, + outcome: { status: "error", error: "boom" }, + model: "openai/gpt-5.4", + }); + + const result = listSessionsFromStore({ + cfg, + storePath: "/tmp/sessions.json", + store, + opts: {}, + }); + + const main = result.sessions.find((session) => session.key === "agent:main:main"); + expect(main?.childSessions).toEqual([ + "agent:main:subagent:parent", + "agent:main:subagent:failed", + ]); + expect(main?.status).toBeUndefined(); + + const parent = result.sessions.find((session) => session.key === "agent:main:subagent:parent"); + expect(parent?.status).toBe("running"); + expect(parent?.startedAt).toBe(now - 9_000); + expect(parent?.endedAt).toBeUndefined(); + expect(parent?.runtimeMs).toBeGreaterThanOrEqual(9_000); + expect(parent?.childSessions).toEqual(["agent:main:subagent:child"]); + + const child = result.sessions.find((session) => session.key === "agent:main:subagent:child"); + expect(child?.status).toBe("done"); + expect(child?.startedAt).toBe(now - 7_500); + expect(child?.endedAt).toBe(now - 2_500); + expect(child?.runtimeMs).toBe(5_000); + expect(child?.childSessions).toBeUndefined(); + + const failed = result.sessions.find((session) => session.key === "agent:main:subagent:failed"); + expect(failed?.status).toBe("failed"); + expect(failed?.runtimeMs).toBe(5_000); + }); +}); + describe("loadCombinedSessionStoreForGateway includes disk-only agents (#32804)", () => { test("ACP agent sessions are visible even when agents.list is configured", async () => { await withStateDirEnv("openclaw-acp-vis-", async ({ stateDir }) => { diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 591799879b9..3a7ef894ef0 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -9,6 +9,10 @@ import { resolveConfiguredModelRef, resolveDefaultModelForAgent, } from "../agents/model-selection.js"; +import { + getSubagentRunByChildSessionKey, + listSubagentRunsForController, +} from "../agents/subagent-registry.js"; import { type OpenClawConfig, loadConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import { @@ -177,6 +181,49 @@ export function deriveSessionTitle( return undefined; } +function resolveSessionRunStatus( + run: { + endedAt?: number; + outcome?: { status?: string }; + } | null, +): "running" | "done" | "failed" | "killed" | undefined { + if (!run) { + return undefined; + } + if (!run.endedAt) { + return "running"; + } + const status = run.outcome?.status; + if (status === "error") { + return "failed"; + } + if (status === "killed") { + return "killed"; + } + return "done"; +} + +function resolveSessionRuntimeMs( + run: { startedAt?: number; endedAt?: number } | null, + now: number, +) { + if (!run?.startedAt) { + return undefined; + } + return Math.max(0, (run.endedAt ?? now) - run.startedAt); +} + +function resolveChildSessionKeys(controllerSessionKey: string): string[] | undefined { + const childSessions = Array.from( + new Set( + listSubagentRunsForController(controllerSessionKey) + .map((entry) => entry.childSessionKey) + .filter((value) => typeof value === "string" && value.trim().length > 0), + ), + ); + return childSessions.length > 0 ? childSessions : undefined; +} + export function loadSessionEntry(sessionKey: string) { const cfg = loadConfig(); const canonicalKey = resolveSessionStoreKey({ cfg, sessionKey }); @@ -911,6 +958,8 @@ export function listSessionsFromStore(params: { const resolvedModel = resolveSessionModelIdentityRef(cfg, entry, sessionAgentId); const modelProvider = resolvedModel.provider; const model = resolvedModel.model ?? DEFAULT_MODEL; + const subagentRun = getSubagentRunByChildSessionKey(key); + const childSessions = resolveChildSessionKeys(key); return { key, spawnedBy: entry?.spawnedBy, @@ -938,6 +987,11 @@ export function listSessionsFromStore(params: { outputTokens: entry?.outputTokens, totalTokens: total, totalTokensFresh, + status: resolveSessionRunStatus(subagentRun), + startedAt: subagentRun?.startedAt, + endedAt: subagentRun?.endedAt, + runtimeMs: resolveSessionRuntimeMs(subagentRun, now), + childSessions, responseUsage: entry?.responseUsage, modelProvider, model, diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index 200df4459e9..bee9c3da320 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -13,6 +13,8 @@ export type GatewaySessionsDefaults = { contextTokens: number | null; }; +export type SessionRunStatus = "running" | "done" | "failed" | "killed"; + export type GatewaySessionRow = { key: string; spawnedBy?: string; @@ -41,6 +43,11 @@ export type GatewaySessionRow = { outputTokens?: number; totalTokens?: number; totalTokensFresh?: boolean; + status?: SessionRunStatus; + startedAt?: number; + endedAt?: number; + runtimeMs?: number; + childSessions?: string[]; responseUsage?: "on" | "off" | "tokens" | "full"; modelProvider?: string; model?: string; diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 17ff4293afa..a7b1e42b165 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -364,6 +364,8 @@ export type AgentsFilesSetResult = { file: AgentFileEntry; }; +export type SessionRunStatus = "running" | "done" | "failed" | "killed"; + export type GatewaySessionRow = { key: string; spawnedBy?: string; @@ -386,6 +388,11 @@ export type GatewaySessionRow = { inputTokens?: number; outputTokens?: number; totalTokens?: number; + status?: SessionRunStatus; + startedAt?: number; + endedAt?: number; + runtimeMs?: number; + childSessions?: string[]; model?: string; modelProvider?: string; contextTokens?: number;