diff --git a/src/infra/session-cost-usage.test.ts b/src/infra/session-cost-usage.test.ts index ba9e10b1f4a..bacb2ead581 100644 --- a/src/infra/session-cost-usage.test.ts +++ b/src/infra/session-cost-usage.test.ts @@ -231,6 +231,92 @@ describe("session cost usage", () => { }); }); + it("includes reset and deleted transcript files in aggregate usage summaries", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cost-archived-")); + const sessionsDir = path.join(root, "agents", "main", "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + const day = new Date("2026-03-13T10:00:00.000Z"); + + const files = [ + "sess-active.jsonl", + "sess-reset.jsonl.reset.2026-03-13T10-05-00.000Z", + "sess-deleted.jsonl.deleted.2026-03-13T10-10-00.000Z", + ]; + + for (const [i, name] of files.entries()) { + const filePath = path.join(sessionsDir, name); + await fs.writeFile( + filePath, + JSON.stringify({ + type: "message", + timestamp: day.toISOString(), + message: { + role: "assistant", + provider: "openai", + model: "gpt-5.2", + usage: { + input: 10 + i, + output: 20 + i, + totalTokens: 30 + i * 2, + cost: { total: 0.01 * (i + 1) }, + }, + }, + }), + "utf-8", + ); + } + + await withStateDir(root, async () => { + const summary = await loadCostUsageSummary({ + startMs: new Date("2026-03-13T00:00:00.000Z").getTime(), + endMs: new Date("2026-03-13T23:59:59.999Z").getTime(), + }); + expect(summary.daily).toHaveLength(1); + expect(summary.totals.totalTokens).toBe(96); + expect(summary.totals.totalCost).toBeCloseTo(0.06, 8); + }); + }); + + it("discovers archived reset/deleted transcripts and preserves base session ids", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-discover-archived-")); + const sessionsDir = path.join(root, "agents", "main", "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + + const files = [ + ["sess-active.jsonl", "active hello"], + ["sess-reset.jsonl.reset.2026-03-13T10-05-00.000Z", "reset hello"], + ["sess-deleted.jsonl.deleted.2026-03-13T10-10-00.000Z", "deleted hello"], + ] as const; + + for (const [name, text] of files) { + const filePath = path.join(sessionsDir, name); + await fs.writeFile( + filePath, + JSON.stringify({ + type: "message", + timestamp: "2026-03-13T10:00:00.000Z", + message: { + role: "user", + content: text, + }, + }), + "utf-8", + ); + } + + await withStateDir(root, async () => { + const sessions = await discoverAllSessions({ + startMs: new Date("2026-03-13T00:00:00.000Z").getTime(), + }); + const ids = sessions.map((session) => session.sessionId).sort(); + expect(ids).toEqual(["sess-active", "sess-deleted", "sess-reset"]); + const reset = sessions.find((session) => session.sessionId === "sess-reset"); + const deleted = sessions.find((session) => session.sessionId === "sess-deleted"); + expect(reset?.firstUserMessage).toContain("reset hello"); + expect(deleted?.firstUserMessage).toContain("deleted hello"); + }); + }); + it("resolves non-main absolute sessionFile using explicit agentId for cost summary", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cost-agent-")); const workerSessionsDir = path.join(root, "agents", "worker1", "sessions"); diff --git a/src/infra/session-cost-usage.ts b/src/infra/session-cost-usage.ts index 230ebd60c2e..e4b9b4af466 100644 --- a/src/infra/session-cost-usage.ts +++ b/src/infra/session-cost-usage.ts @@ -53,6 +53,16 @@ export type { SessionUsageTimeSeries, } from "./session-cost-usage.types.js"; +const SESSION_TRANSCRIPT_FILE_RE = /\.jsonl(?:\.(?:reset|deleted)\..+)?$/; + +const isSessionTranscriptFilename = (name: string): boolean => + SESSION_TRANSCRIPT_FILE_RE.test(name); + +const deriveSessionIdFromFilename = (name: string): string => { + const match = name.match(/^(.*?\.jsonl)(?:\.(?:reset|deleted)\..+)?$/); + return match ? match[1].slice(0, -6) : name.replace(/\.jsonl$/, ""); +}; + const emptyTotals = (): CostUsageTotals => ({ input: 0, output: 0, @@ -318,7 +328,7 @@ export async function loadCostUsageSummary(params?: { const files = ( await Promise.all( entries - .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")) + .filter((entry) => entry.isFile() && isSessionTranscriptFilename(entry.name)) .map(async (entry) => { const filePath = path.join(sessionsDir, entry.name); const stats = await fs.promises.stat(filePath).catch(() => null); @@ -393,7 +403,7 @@ export async function discoverAllSessions(params?: { const discovered: DiscoveredSession[] = []; for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith(".jsonl")) { + if (!entry.isFile() || !isSessionTranscriptFilename(entry.name)) { continue; } @@ -409,8 +419,8 @@ export async function discoverAllSessions(params?: { } // Do not exclude by endMs: a session can have activity in range even if it continued later. - // Extract session ID from filename (remove .jsonl) - const sessionId = entry.name.slice(0, -6); + // Extract session ID from filename, preserving base session id for archived reset/deleted transcripts. + const sessionId = deriveSessionIdFromFilename(entry.name); // Try to read first user message for label extraction let firstUserMessage: string | undefined;