diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 4ba9b336127..bbe01a0b00d 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -80,6 +80,8 @@ export type SessionEntry = { spawnedBy?: string; /** Workspace inherited by spawned sessions and reused on later turns for the same child session. */ spawnedWorkspaceDir?: string; + /** Explicit parent session linkage for dashboard-created child sessions. */ + parentSessionKey?: string; /** True after a thread/topic session has been forked from its parent transcript once. */ forkedFromParent?: boolean; /** Subagent spawn depth (0 = main, 1 = sub-agent, 2 = sub-sub-agent). */ diff --git a/src/gateway/protocol/schema/sessions.ts b/src/gateway/protocol/schema/sessions.ts index 62bc895cf73..c318e890184 100644 --- a/src/gateway/protocol/schema/sessions.ts +++ b/src/gateway/protocol/schema/sessions.ts @@ -51,6 +51,8 @@ export const SessionsCreateParamsSchema = Type.Object( { agentId: Type.Optional(NonEmptyString), label: Type.Optional(SessionLabelString), + model: Type.Optional(NonEmptyString), + parentSessionKey: Type.Optional(NonEmptyString), task: Type.Optional(Type.String()), message: Type.Optional(Type.String()), }, diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index df4b31ec9b6..967fe633d57 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -403,19 +403,49 @@ export const sessionsHandlers: GatewayRequestHandlers = { const agentId = normalizeAgentId( typeof p.agentId === "string" && p.agentId.trim() ? p.agentId : resolveDefaultAgentId(cfg), ); + const parentSessionKey = + typeof p.parentSessionKey === "string" && p.parentSessionKey.trim() + ? p.parentSessionKey.trim() + : undefined; + let canonicalParentSessionKey: string | undefined; + if (parentSessionKey) { + const parent = loadSessionEntry(parentSessionKey); + if (!parent.entry?.sessionId) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unknown parent session: ${parentSessionKey}`), + ); + return; + } + canonicalParentSessionKey = parent.canonicalKey; + } const key = buildDashboardSessionKey(agentId); const target = resolveGatewaySessionStoreTarget({ cfg, key }); const created = await updateSessionStore(target.storePath, async (store) => { - return await applySessionsPatchToStore({ + const patched = await applySessionsPatchToStore({ cfg, store, storeKey: target.canonicalKey, patch: { key: target.canonicalKey, label: typeof p.label === "string" ? p.label.trim() : undefined, + model: typeof p.model === "string" ? p.model.trim() : undefined, }, loadGatewayModelCatalog: context.loadGatewayModelCatalog, }); + if (!patched.ok || !canonicalParentSessionKey) { + return patched; + } + const nextEntry: SessionEntry = { + ...patched.entry, + parentSessionKey: canonicalParentSessionKey, + }; + store[target.canonicalKey] = nextEntry; + return { + ...patched, + entry: nextEntry, + }; }); if (!created.ok) { respond(false, undefined, created.error); diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts index 300abffe1ff..48ff50231ea 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts @@ -234,34 +234,63 @@ describe("gateway server sessions", () => { browserSessionTabMocks.closeTrackedBrowserTabsForSessions.mockResolvedValue(0); }); - test("sessions.create creates a dashboard session entry and transcript", async () => { + test("sessions.create stores dashboard session model and parent linkage, and creates a transcript", async () => { const { dir, storePath } = await createSessionStoreDir(); + piSdkMock.enabled = true; + piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }]; + await writeSessionStore({ + entries: { + main: { + sessionId: "sess-parent", + updatedAt: Date.now(), + }, + }, + }); const { ws } = await openClient(); const created = await rpcReq<{ key?: string; sessionId?: string; - entry?: { label?: string }; + entry?: { + label?: string; + providerOverride?: string; + modelOverride?: string; + parentSessionKey?: string; + }; }>(ws, "sessions.create", { agentId: "ops", label: "Dashboard Chat", + model: "openai/gpt-test-a", + parentSessionKey: "main", }); expect(created.ok).toBe(true); expect(created.payload?.key).toMatch(/^agent:ops:dashboard:/); expect(created.payload?.entry?.label).toBe("Dashboard Chat"); + expect(created.payload?.entry?.providerOverride).toBe("openai"); + expect(created.payload?.entry?.modelOverride).toBe("gpt-test-a"); + expect(created.payload?.entry?.parentSessionKey).toBe("agent:main:main"); expect(created.payload?.sessionId).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, ); const rawStore = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record< string, - { sessionId?: string; label?: string } + { + sessionId?: string; + label?: string; + providerOverride?: string; + modelOverride?: string; + parentSessionKey?: string; + } >; const key = created.payload?.key as string; expect(rawStore[key]).toMatchObject({ sessionId: created.payload?.sessionId, label: "Dashboard Chat", + providerOverride: "openai", + modelOverride: "gpt-test-a", + parentSessionKey: "agent:main:main", }); const transcriptPath = path.join(dir, `${created.payload?.sessionId}.jsonl`); @@ -275,6 +304,23 @@ describe("gateway server sessions", () => { ws.close(); }); + test("sessions.create rejects unknown parentSessionKey", async () => { + await createSessionStoreDir(); + const { ws } = await openClient(); + + const created = await rpcReq(ws, "sessions.create", { + agentId: "ops", + parentSessionKey: "agent:main:missing", + }); + + expect(created.ok).toBe(false); + expect((created.error as { message?: string } | undefined)?.message ?? "").toContain( + "unknown parent session", + ); + + ws.close(); + }); + test("sessions.create can start the first agent turn from an initial task", async () => { const { ws } = await openClient(); const replySpy = vi.mocked(getReplyFromConfig); @@ -311,6 +357,96 @@ describe("gateway server sessions", () => { ws.close(); }); + test("sessions.list surfaces transcript usage fallbacks and parent child relationships", async () => { + const { dir } = await createSessionStoreDir(); + testState.agentConfig = { + models: { + "anthropic/claude-sonnet-4-6": { params: { context1m: true } }, + }, + }; + await fs.writeFile( + path.join(dir, "sess-parent.jsonl"), + `${JSON.stringify({ type: "session", version: 1, id: "sess-parent" })}\n`, + "utf-8", + ); + await fs.writeFile( + path.join(dir, "sess-child.jsonl"), + [ + JSON.stringify({ type: "session", version: 1, id: "sess-child" }), + JSON.stringify({ + message: { + role: "assistant", + provider: "anthropic", + model: "claude-sonnet-4-6", + usage: { + input: 2_000, + output: 500, + cacheRead: 1_000, + cost: { total: 0.0042 }, + }, + }, + }), + JSON.stringify({ + message: { + role: "assistant", + provider: "openclaw", + model: "delivery-mirror", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + }), + ].join("\n"), + "utf-8", + ); + await writeSessionStore({ + entries: { + main: { + sessionId: "sess-parent", + updatedAt: Date.now(), + }, + "dashboard:child": { + sessionId: "sess-child", + updatedAt: Date.now() - 1_000, + modelProvider: "anthropic", + model: "claude-sonnet-4-6", + parentSessionKey: "agent:main:main", + totalTokens: 0, + totalTokensFresh: false, + inputTokens: 0, + outputTokens: 0, + cacheRead: 0, + cacheWrite: 0, + }, + }, + }); + + const { ws } = await openClient(); + const listed = await rpcReq<{ + sessions: Array<{ + key: string; + parentSessionKey?: string; + childSessions?: string[]; + totalTokens?: number; + totalTokensFresh?: boolean; + contextTokens?: number; + estimatedCostUsd?: number; + }>; + }>(ws, "sessions.list", {}); + + expect(listed.ok).toBe(true); + const parent = listed.payload?.sessions.find((session) => session.key === "agent:main:main"); + const child = listed.payload?.sessions.find( + (session) => session.key === "agent:main:dashboard:child", + ); + expect(parent?.childSessions).toEqual(["agent:main:dashboard:child"]); + expect(child?.parentSessionKey).toBe("agent:main:main"); + expect(child?.totalTokens).toBe(3_000); + expect(child?.totalTokensFresh).toBe(true); + expect(child?.contextTokens).toBe(1_048_576); + expect(child?.estimatedCostUsd).toBe(0.0042); + + ws.close(); + }); + test("lists and patches session store via sessions.* RPC", async () => { const { dir, storePath } = await createSessionStoreDir(); const now = Date.now(); diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index 09ab7e2cda2..608e1ec42ec 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -7,6 +7,7 @@ import { archiveSessionTranscripts, readFirstUserMessageFromTranscript, readLastMessagePreviewFromTranscript, + readLatestSessionUsageFromTranscript, readSessionMessages, readSessionTitleFieldsFromTranscript, readSessionPreviewItemsFromTranscript, @@ -550,7 +551,9 @@ describe("readSessionMessages", () => { testCase.wrongStorePath, testCase.sessionFile, ); - expect(out).toEqual([testCase.message]); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject(testCase.message); + expect((out[0] as { __openclaw?: { seq?: number } }).__openclaw?.seq).toBe(1); } }); }); @@ -648,6 +651,66 @@ describe("readSessionPreviewItemsFromTranscript", () => { }); }); +describe("readLatestSessionUsageFromTranscript", () => { + let tmpDir: string; + let storePath: string; + + registerTempSessionStore("openclaw-session-usage-test-", (nextTmpDir, nextStorePath) => { + tmpDir = nextTmpDir; + storePath = nextStorePath; + }); + + test("returns the latest assistant usage snapshot and skips delivery mirrors", () => { + const sessionId = "usage-session"; + writeTranscript(tmpDir, sessionId, [ + { type: "session", version: 1, id: sessionId }, + { + message: { + role: "assistant", + provider: "openai", + model: "gpt-5.4", + usage: { + input: 1200, + output: 300, + cacheRead: 50, + cost: { total: 0.0042 }, + }, + }, + }, + { + message: { + role: "assistant", + provider: "openclaw", + model: "delivery-mirror", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + }, + ]); + + expect(readLatestSessionUsageFromTranscript(sessionId, storePath)).toEqual({ + modelProvider: "openai", + model: "gpt-5.4", + inputTokens: 1200, + outputTokens: 300, + cacheRead: 50, + totalTokens: 1250, + totalTokensFresh: true, + costUsd: 0.0042, + }); + }); + + test("returns null when the transcript has no assistant usage snapshot", () => { + const sessionId = "usage-empty"; + writeTranscript(tmpDir, sessionId, [ + { type: "session", version: 1, id: sessionId }, + { message: { role: "user", content: "hello" } }, + { message: { role: "assistant", content: "hi" } }, + ]); + + expect(readLatestSessionUsageFromTranscript(sessionId, storePath)).toBeNull(); + }); +}); + describe("resolveSessionTranscriptCandidates", () => { afterEach(() => { vi.unstubAllEnvs(); diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index 54f0843924a..cd9ac817252 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { deriveSessionTotalTokens, hasNonzeroUsage, normalizeUsage } from "../agents/usage.js"; import { formatSessionArchiveTimestamp, parseSessionArchiveTimestamp, @@ -556,6 +557,148 @@ export function readLastMessagePreviewFromTranscript( }); } +export type SessionTranscriptUsageSnapshot = { + modelProvider?: string; + model?: string; + inputTokens?: number; + outputTokens?: number; + cacheRead?: number; + cacheWrite?: number; + totalTokens?: number; + totalTokensFresh?: boolean; + costUsd?: number; +}; + +const USAGE_READ_SIZES = [16 * 1024, 64 * 1024, 256 * 1024, 1024 * 1024]; + +function extractTranscriptUsageCost(raw: unknown): number | undefined { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return undefined; + } + const cost = (raw as { cost?: unknown }).cost; + if (!cost || typeof cost !== "object" || Array.isArray(cost)) { + return undefined; + } + const total = (cost as { total?: unknown }).total; + return typeof total === "number" && Number.isFinite(total) && total >= 0 ? total : undefined; +} + +function readTailChunk(fd: number, size: number, maxBytes: number): string | null { + const readLen = Math.min(size, maxBytes); + if (readLen <= 0) { + return null; + } + const readStart = Math.max(0, size - readLen); + const buf = Buffer.alloc(readLen); + fs.readSync(fd, buf, 0, readLen, readStart); + return buf.toString("utf-8"); +} + +function extractLatestUsageFromTranscriptChunk( + chunk: string, +): SessionTranscriptUsageSnapshot | null { + const lines = chunk.split(/\r?\n/).filter((line) => line.trim().length > 0); + for (let i = lines.length - 1; i >= 0; i -= 1) { + const line = lines[i]; + try { + const parsed = JSON.parse(line) as Record; + const message = + parsed.message && typeof parsed.message === "object" && !Array.isArray(parsed.message) + ? (parsed.message as Record) + : undefined; + if (!message) { + continue; + } + const role = typeof message.role === "string" ? message.role : undefined; + if (role && role !== "assistant") { + continue; + } + const usageRaw = + message.usage && typeof message.usage === "object" && !Array.isArray(message.usage) + ? message.usage + : parsed.usage && typeof parsed.usage === "object" && !Array.isArray(parsed.usage) + ? parsed.usage + : undefined; + const usage = normalizeUsage(usageRaw); + const totalTokens = deriveSessionTotalTokens({ usage }); + const costUsd = extractTranscriptUsageCost(usageRaw); + const modelProvider = + typeof message.provider === "string" + ? message.provider.trim() + : typeof parsed.provider === "string" + ? parsed.provider.trim() + : undefined; + const model = + typeof message.model === "string" + ? message.model.trim() + : typeof parsed.model === "string" + ? parsed.model.trim() + : undefined; + const isDeliveryMirror = modelProvider === "openclaw" && model === "delivery-mirror"; + const hasMeaningfulUsage = + hasNonzeroUsage(usage) || + (typeof totalTokens === "number" && Number.isFinite(totalTokens) && totalTokens > 0) || + (typeof costUsd === "number" && Number.isFinite(costUsd) && costUsd > 0); + const hasModelIdentity = Boolean(modelProvider || model); + if (!hasMeaningfulUsage && !hasModelIdentity) { + continue; + } + if (isDeliveryMirror && !hasMeaningfulUsage) { + continue; + } + return { + ...(modelProvider ? { modelProvider } : {}), + ...(model ? { model } : {}), + ...(typeof usage?.input === "number" ? { inputTokens: usage.input } : {}), + ...(typeof usage?.output === "number" ? { outputTokens: usage.output } : {}), + ...(typeof usage?.cacheRead === "number" ? { cacheRead: usage.cacheRead } : {}), + ...(typeof usage?.cacheWrite === "number" ? { cacheWrite: usage.cacheWrite } : {}), + ...(typeof totalTokens === "number" && Number.isFinite(totalTokens) && totalTokens > 0 + ? { totalTokens, totalTokensFresh: true } + : {}), + ...(typeof costUsd === "number" && Number.isFinite(costUsd) ? { costUsd } : {}), + }; + } catch { + // skip malformed lines + } + } + return null; +} + +export function readLatestSessionUsageFromTranscript( + sessionId: string, + storePath: string | undefined, + sessionFile?: string, + agentId?: string, +): SessionTranscriptUsageSnapshot | null { + const filePath = findExistingTranscriptPath(sessionId, storePath, sessionFile, agentId); + if (!filePath) { + return null; + } + + return withOpenTranscriptFd(filePath, (fd) => { + const stat = fs.fstatSync(fd); + const size = stat.size; + if (size === 0) { + return null; + } + for (const maxBytes of USAGE_READ_SIZES) { + const chunk = readTailChunk(fd, size, maxBytes); + if (!chunk) { + continue; + } + const snapshot = extractLatestUsageFromTranscriptChunk(chunk); + if (snapshot) { + return snapshot; + } + if (maxBytes >= size) { + break; + } + } + return null; + }); +} + const PREVIEW_READ_SIZES = [64 * 1024, 256 * 1024, 1024 * 1024]; const PREVIEW_MAX_LINES = 200; diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 6960e6de838..66e21fea20e 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -876,6 +876,71 @@ describe("listSessionsFromStore search", () => { expect(result.sessions[0]?.estimatedCostUsd).toBeCloseTo(0.007725, 8); }); + + test("falls back to transcript usage for totalTokens and estimatedCostUsd, and derives contextTokens from the resolved model", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-utils-")); + const storePath = path.join(tmpDir, "sessions.json"); + const cfg = { + session: { mainKey: "main" }, + agents: { + list: [{ id: "main", default: true }], + defaults: { + models: { + "anthropic/claude-sonnet-4-6": { params: { context1m: true } }, + }, + }, + }, + } as unknown as OpenClawConfig; + fs.writeFileSync( + path.join(tmpDir, "sess-main.jsonl"), + [ + JSON.stringify({ type: "session", version: 1, id: "sess-main" }), + JSON.stringify({ + message: { + role: "assistant", + provider: "anthropic", + model: "claude-sonnet-4-6", + usage: { + input: 2_000, + output: 500, + cacheRead: 1_200, + cost: { total: 0.007725 }, + }, + }, + }), + ].join("\n"), + "utf-8", + ); + + try { + const result = listSessionsFromStore({ + cfg, + storePath, + store: { + "agent:main:main": { + sessionId: "sess-main", + updatedAt: Date.now(), + modelProvider: "anthropic", + model: "claude-sonnet-4-6", + totalTokens: 0, + totalTokensFresh: false, + inputTokens: 0, + outputTokens: 0, + cacheRead: 0, + cacheWrite: 0, + } as SessionEntry, + }, + opts: {}, + }); + + expect(result.sessions[0]?.totalTokens).toBe(3_200); + expect(result.sessions[0]?.totalTokensFresh).toBe(true); + expect(result.sessions[0]?.contextTokens).toBe(1_048_576); + expect(result.sessions[0]?.estimatedCostUsd).toBeCloseTo(0.007725, 8); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); describe("listSessionsFromStore subagent metadata", () => { @@ -982,6 +1047,34 @@ describe("listSessionsFromStore subagent metadata", () => { expect(failed?.runtimeMs).toBe(5_000); }); + test("includes explicit parentSessionKey relationships for dashboard child sessions", () => { + resetSubagentRegistryForTests({ persist: false }); + const now = Date.now(); + const store: Record = { + "agent:main:main": { + sessionId: "sess-main", + updatedAt: now, + } as SessionEntry, + "agent:main:dashboard:child": { + sessionId: "sess-child", + updatedAt: now - 1_000, + parentSessionKey: "agent:main:main", + } as SessionEntry, + }; + + const result = listSessionsFromStore({ + cfg, + storePath: "/tmp/sessions.json", + store, + opts: {}, + }); + + const main = result.sessions.find((session) => session.key === "agent:main:main"); + const child = result.sessions.find((session) => session.key === "agent:main:dashboard:child"); + expect(main?.childSessions).toEqual(["agent:main:dashboard:child"]); + expect(child?.parentSessionKey).toBe("agent:main:main"); + }); + test("maps timeout outcomes to timeout status and clamps negative runtime", () => { const now = Date.now(); const store: Record = { diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index cad8f3c8268..8aa5fd4152b 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { lookupContextTokens } from "../agents/context.js"; +import { lookupContextTokens, resolveContextTokensForModel } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { inferUniqueProviderFromConfiguredModels, @@ -45,7 +45,10 @@ import { } from "../shared/avatar-policy.js"; import { normalizeSessionDeliveryFields } from "../utils/delivery-context.js"; import { estimateUsageCost, resolveModelCostConfig } from "../utils/usage-format.js"; -import { readSessionTitleFieldsFromTranscript } from "./session-utils.fs.js"; +import { + readLatestSessionUsageFromTranscript, + readSessionTitleFieldsFromTranscript, +} from "./session-utils.fs.js"; import type { GatewayAgentRow, GatewaySessionRow, @@ -60,6 +63,7 @@ export { capArrayByJsonBytes, readFirstUserMessageFromTranscript, readLastMessagePreviewFromTranscript, + readLatestSessionUsageFromTranscript, readSessionTitleFieldsFromTranscript, readSessionPreviewItemsFromTranscript, readSessionMessages, @@ -218,12 +222,33 @@ function resolveSessionRuntimeMs( return Math.max(0, (run.endedAt ?? now) - run.startedAt); } +function resolvePositiveNumber(value: number | null | undefined): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; +} + function resolveEstimatedSessionCostUsd(params: { cfg: OpenClawConfig; provider?: string; model?: string; - entry?: SessionEntry; + entry?: Pick; + explicitCostUsd?: number; }): number | undefined { + const explicitCostUsd = resolvePositiveNumber(params.explicitCostUsd); + if (explicitCostUsd !== undefined) { + return explicitCostUsd; + } + const input = resolvePositiveNumber(params.entry?.inputTokens); + const output = resolvePositiveNumber(params.entry?.outputTokens); + const cacheRead = resolvePositiveNumber(params.entry?.cacheRead); + const cacheWrite = resolvePositiveNumber(params.entry?.cacheWrite); + if ( + input === undefined && + output === undefined && + cacheRead === undefined && + cacheWrite === undefined + ) { + return undefined; + } const cost = resolveModelCostConfig({ provider: params.provider, model: params.model, @@ -234,27 +259,92 @@ function resolveEstimatedSessionCostUsd(params: { } const estimated = estimateUsageCost({ usage: { - input: params.entry?.inputTokens, - output: params.entry?.outputTokens, - cacheRead: params.entry?.cacheRead, - cacheWrite: params.entry?.cacheWrite, + ...(input !== undefined ? { input } : {}), + ...(output !== undefined ? { output } : {}), + ...(cacheRead !== undefined ? { cacheRead } : {}), + ...(cacheWrite !== undefined ? { cacheWrite } : {}), }, cost, }); - return typeof estimated === "number" && Number.isFinite(estimated) ? estimated : undefined; + return resolvePositiveNumber(estimated); } -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), - ), +function resolveChildSessionKeys( + controllerSessionKey: string, + store: Record, +): string[] | undefined { + const childSessionKeys = new Set( + listSubagentRunsForController(controllerSessionKey) + .map((entry) => entry.childSessionKey) + .filter((value) => typeof value === "string" && value.trim().length > 0), ); + for (const [key, entry] of Object.entries(store)) { + if (!entry || key === controllerSessionKey) { + continue; + } + const spawnedBy = entry.spawnedBy?.trim(); + const parentSessionKey = entry.parentSessionKey?.trim(); + if (spawnedBy === controllerSessionKey || parentSessionKey === controllerSessionKey) { + childSessionKeys.add(key); + } + } + const childSessions = Array.from(childSessionKeys); return childSessions.length > 0 ? childSessions : undefined; } +function resolveTranscriptUsageFallback(params: { + cfg: OpenClawConfig; + key: string; + entry?: SessionEntry; + storePath: string; +}): { + estimatedCostUsd?: number; + totalTokens?: number; + totalTokensFresh?: boolean; + contextTokens?: number; +} | null { + const entry = params.entry; + if (!entry?.sessionId) { + return null; + } + const parsed = parseAgentSessionKey(params.key); + const agentId = parsed?.agentId + ? normalizeAgentId(parsed.agentId) + : resolveDefaultAgentId(params.cfg); + const snapshot = readLatestSessionUsageFromTranscript( + entry.sessionId, + params.storePath, + entry.sessionFile, + agentId, + ); + if (!snapshot) { + return null; + } + const contextTokens = resolveContextTokensForModel({ + cfg: params.cfg, + provider: snapshot.modelProvider, + model: snapshot.model, + }); + const estimatedCostUsd = resolveEstimatedSessionCostUsd({ + cfg: params.cfg, + provider: snapshot.modelProvider, + model: snapshot.model, + explicitCostUsd: snapshot.costUsd, + entry: { + inputTokens: snapshot.inputTokens, + outputTokens: snapshot.outputTokens, + cacheRead: snapshot.cacheRead, + cacheWrite: snapshot.cacheWrite, + }, + }); + return { + totalTokens: resolvePositiveNumber(snapshot.totalTokens), + totalTokensFresh: snapshot.totalTokensFresh === true, + contextTokens: resolvePositiveNumber(contextTokens), + estimatedCostUsd, + }; +} + export function loadSessionEntry(sessionKey: string) { const cfg = loadConfig(); const canonicalKey = resolveSessionStoreKey({ cfg, sessionKey }); @@ -958,9 +1048,6 @@ export function listSessionsFromStore(params: { }) .map(([key, entry]) => { const updatedAt = entry?.updatedAt ?? null; - const total = resolveFreshSessionTotalTokens(entry); - const totalTokensFresh = - typeof entry?.totalTokens === "number" ? entry?.totalTokensFresh !== false : false; const parsed = parseGroupKey(key); const channel = entry?.channel ?? parsed?.channel; const subject = entry?.subject; @@ -989,14 +1076,43 @@ export function listSessionsFromStore(params: { const resolvedModel = resolveSessionModelIdentityRef(cfg, entry, sessionAgentId); const modelProvider = resolvedModel.provider; const model = resolvedModel.model ?? DEFAULT_MODEL; + const transcriptUsage = + resolvePositiveNumber(resolveFreshSessionTotalTokens(entry)) === undefined || + resolvePositiveNumber(entry?.contextTokens) === undefined || + resolveEstimatedSessionCostUsd({ + cfg, + provider: modelProvider, + model, + entry, + }) === undefined + ? resolveTranscriptUsageFallback({ cfg, key, entry, storePath }) + : null; + const totalTokens = + resolvePositiveNumber(resolveFreshSessionTotalTokens(entry)) ?? + resolvePositiveNumber(transcriptUsage?.totalTokens); + const totalTokensFresh = + typeof totalTokens === "number" && Number.isFinite(totalTokens) && totalTokens > 0 + ? true + : transcriptUsage?.totalTokensFresh === true; const subagentRun = getSubagentRunByChildSessionKey(key); - const childSessions = resolveChildSessionKeys(key); - const estimatedCostUsd = resolveEstimatedSessionCostUsd({ - cfg, - provider: modelProvider, - model, - entry, - }); + const childSessions = resolveChildSessionKeys(key, store); + const estimatedCostUsd = + resolveEstimatedSessionCostUsd({ + cfg, + provider: modelProvider, + model, + entry, + }) ?? resolvePositiveNumber(transcriptUsage?.estimatedCostUsd); + const contextTokens = + resolvePositiveNumber(entry?.contextTokens) ?? + resolvePositiveNumber(transcriptUsage?.contextTokens) ?? + resolvePositiveNumber( + resolveContextTokensForModel({ + cfg, + provider: modelProvider, + model, + }), + ); return { key, spawnedBy: entry?.spawnedBy, @@ -1022,18 +1138,19 @@ export function listSessionsFromStore(params: { sendPolicy: entry?.sendPolicy, inputTokens: entry?.inputTokens, outputTokens: entry?.outputTokens, - totalTokens: total, + totalTokens, totalTokensFresh, estimatedCostUsd, status: resolveSessionRunStatus(subagentRun), startedAt: subagentRun?.startedAt, endedAt: subagentRun?.endedAt, runtimeMs: resolveSessionRuntimeMs(subagentRun, now), + parentSessionKey: entry?.parentSessionKey, childSessions, responseUsage: entry?.responseUsage, modelProvider, model, - contextTokens: entry?.contextTokens, + contextTokens, deliveryContext: deliveryFields.deliveryContext, lastChannel: deliveryFields.lastChannel ?? entry?.lastChannel, lastTo: deliveryFields.lastTo ?? entry?.lastTo, diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index 17d2bd334c1..3a92a94bd7b 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -48,6 +48,7 @@ export type GatewaySessionRow = { startedAt?: number; endedAt?: number; runtimeMs?: number; + parentSessionKey?: string; childSessions?: string[]; responseUsage?: "on" | "off" | "tokens" | "full"; modelProvider?: string;