mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-26 17:32:16 +00:00
Add dashboard session model and parent linkage support
This commit is contained in:
@@ -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). */
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -48,6 +48,7 @@ export type GatewaySessionRow = {
|
||||
startedAt?: number;
|
||||
endedAt?: number;
|
||||
runtimeMs?: number;
|
||||
parentSessionKey?: string;
|
||||
childSessions?: string[];
|
||||
responseUsage?: "on" | "off" | "tokens" | "full";
|
||||
modelProvider?: string;
|
||||
|
||||
Reference in New Issue
Block a user