Add dashboard session model and parent linkage support

This commit is contained in:
Tyler Yust
2026-03-12 12:45:25 -07:00
parent d8de86870c
commit 545f015f3b
9 changed files with 619 additions and 32 deletions

View File

@@ -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). */

View File

@@ -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()),
},

View File

@@ -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);

View File

@@ -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();

View File

@@ -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();

View File

@@ -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<string, unknown>;
const message =
parsed.message && typeof parsed.message === "object" && !Array.isArray(parsed.message)
? (parsed.message as Record<string, unknown>)
: 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;

View File

@@ -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<string, SessionEntry> = {
"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<string, SessionEntry> = {

View File

@@ -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<SessionEntry, "inputTokens" | "outputTokens" | "cacheRead" | "cacheWrite">;
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, SessionEntry>,
): 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,

View File

@@ -48,6 +48,7 @@ export type GatewaySessionRow = {
startedAt?: number;
endedAt?: number;
runtimeMs?: number;
parentSessionKey?: string;
childSessions?: string[];
responseUsage?: "on" | "off" | "tokens" | "full";
modelProvider?: string;