diff --git a/scripts/repro/limit-edge-case-live-proof.mjs b/scripts/repro/limit-edge-case-live-proof.mjs index 717277b7c14..1d2bda930f4 100644 --- a/scripts/repro/limit-edge-case-live-proof.mjs +++ b/scripts/repro/limit-edge-case-live-proof.mjs @@ -6,6 +6,7 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { testing as voiceCallCliTesting } from "../../extensions/voice-call/src/cli.ts"; import { loadSessionLogs, loadSessionUsageTimeSeries } from "../../src/infra/session-cost-usage.ts"; import { @@ -14,6 +15,15 @@ import { resetDiagnosticPhasesForTest, } from "../../src/logging/diagnostic-phase.ts"; +export async function withProofTempRoot(callback) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-proof-")); + try { + return await callback(root); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +} + async function main() { resetDiagnosticPhasesForTest(); recordDiagnosticPhase({ @@ -40,77 +50,80 @@ async function main() { assert.equal(zeroPhases.length, 0); console.log("getRecentDiagnosticPhases(0).length =", zeroPhases.length); - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-proof-")); - const sessionFile = path.join(root, "s.jsonl"); - fs.writeFileSync( - sessionFile, - [ - JSON.stringify({ - type: "message", - timestamp: "2026-01-01T00:00:00.000Z", - message: { role: "user", content: "a" }, - }), - JSON.stringify({ - type: "message", - timestamp: "2026-01-01T00:01:00.000Z", - message: { - role: "assistant", - content: "b", - provider: "openai", - model: "gpt-5.5", - usage: { - input: 1, - output: 2, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 3, - cost: { total: 0.001 }, + await withProofTempRoot(async (root) => { + const sessionFile = path.join(root, "s.jsonl"); + fs.writeFileSync( + sessionFile, + [ + JSON.stringify({ + type: "message", + timestamp: "2026-01-01T00:00:00.000Z", + message: { role: "user", content: "a" }, + }), + JSON.stringify({ + type: "message", + timestamp: "2026-01-01T00:01:00.000Z", + message: { + role: "assistant", + content: "b", + provider: "openai", + model: "gpt-5.5", + usage: { + input: 1, + output: 2, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 3, + cost: { total: 0.001 }, + }, }, - }, - }), - JSON.stringify({ - type: "message", - timestamp: "2026-01-01T00:02:00.000Z", - message: { - role: "assistant", - content: "c", - provider: "openai", - model: "gpt-5.5", - usage: { - input: 3, - output: 4, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 7, - cost: { total: 0.002 }, + }), + JSON.stringify({ + type: "message", + timestamp: "2026-01-01T00:02:00.000Z", + message: { + role: "assistant", + content: "c", + provider: "openai", + model: "gpt-5.5", + usage: { + input: 3, + output: 4, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 7, + cost: { total: 0.002 }, + }, }, - }, - }), - ].join("\n"), - ); + }), + ].join("\n"), + ); - const logs = await loadSessionLogs({ sessionFile, limit: 0 }); - const series = await loadSessionUsageTimeSeries({ sessionFile, maxPoints: 0 }); - const positiveLogs = await loadSessionLogs({ sessionFile, limit: 10 }); - const positiveSeries = await loadSessionUsageTimeSeries({ sessionFile, maxPoints: 10 }); - assert.equal(logs?.length, 0); - assert.equal(series.points.length, 0); - assert.equal(positiveLogs?.length, 3); - assert.equal(positiveSeries.points.length, 2); - console.log("loadSessionLogs({ limit: 0 }).length =", logs?.length); - console.log( - "loadSessionUsageTimeSeries({ maxPoints: 0 }).points.length =", - series?.points.length, - ); + const logs = await loadSessionLogs({ sessionFile, limit: 0 }); + const series = await loadSessionUsageTimeSeries({ sessionFile, maxPoints: 0 }); + const positiveLogs = await loadSessionLogs({ sessionFile, limit: 10 }); + const positiveSeries = await loadSessionUsageTimeSeries({ sessionFile, maxPoints: 10 }); + assert.equal(logs?.length, 0); + assert.equal(series.points.length, 0); + assert.equal(positiveLogs?.length, 3); + assert.equal(positiveSeries.points.length, 2); + console.log("loadSessionLogs({ limit: 0 }).length =", logs?.length); + console.log( + "loadSessionUsageTimeSeries({ maxPoints: 0 }).points.length =", + series?.points.length, + ); - try { - voiceCallCliTesting.parseVoiceCallIntOption("nope", "--port", { min: 1 }); - assert.fail("expected invalid voicecall --port value to throw"); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - assert.equal(message, "Invalid numeric value for --port: nope"); - console.log("parseVoiceCallIntOption('nope', '--port') error:", message); - } + try { + voiceCallCliTesting.parseVoiceCallIntOption("nope", "--port", { min: 1 }); + assert.fail("expected invalid voicecall --port value to throw"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + assert.equal(message, "Invalid numeric value for --port: nope"); + console.log("parseVoiceCallIntOption('nope', '--port') error:", message); + } + }); } -await main(); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/src/infra/session-cost-usage.test.ts b/src/infra/session-cost-usage.test.ts index 0b004920965..9dc1bdea466 100644 --- a/src/infra/session-cost-usage.test.ts +++ b/src/infra/session-cost-usage.test.ts @@ -2523,20 +2523,26 @@ example "utf-8", ); - await expect(loadSessionUsageTimeSeries({ sessionFile, maxPoints: 0 })).resolves.toEqual({ - sessionId: undefined, - points: [], - }); - await expect(loadSessionUsageTimeSeries({ sessionFile, maxPoints: -1 })).resolves.toEqual({ - sessionId: undefined, - points: [], - }); - await expect( - loadSessionUsageTimeSeries({ sessionFile, maxPoints: Number.NaN }), - ).resolves.toEqual({ sessionId: undefined, points: [] }); - await expect( - loadSessionUsageTimeSeries({ sessionFile, maxPoints: Number.POSITIVE_INFINITY }), - ).resolves.toEqual({ sessionId: undefined, points: [] }); + const createReadStream = vi.spyOn(nodeFs, "createReadStream"); + try { + await expect(loadSessionUsageTimeSeries({ sessionFile, maxPoints: 0 })).resolves.toEqual({ + sessionId: undefined, + points: [], + }); + await expect(loadSessionUsageTimeSeries({ sessionFile, maxPoints: -1 })).resolves.toEqual({ + sessionId: undefined, + points: [], + }); + await expect( + loadSessionUsageTimeSeries({ sessionFile, maxPoints: Number.NaN }), + ).resolves.toEqual({ sessionId: undefined, points: [] }); + await expect( + loadSessionUsageTimeSeries({ sessionFile, maxPoints: Number.POSITIVE_INFINITY }), + ).resolves.toEqual({ sessionId: undefined, points: [] }); + expect(createReadStream).not.toHaveBeenCalled(); + } finally { + createReadStream.mockRestore(); + } }); it("returns empty logs for zero, negative, and non-finite limits", async () => { diff --git a/src/infra/session-cost-usage.ts b/src/infra/session-cost-usage.ts index 86f502e5524..34351fd2c1f 100644 --- a/src/infra/session-cost-usage.ts +++ b/src/infra/session-cost-usage.ts @@ -2300,6 +2300,12 @@ export async function loadSessionUsageTimeSeries(params: { return null; } + if (params.maxPoints !== undefined && params.maxPoints !== null) { + if (!Number.isFinite(params.maxPoints) || params.maxPoints <= 0) { + return { sessionId: params.sessionId, points: [] }; + } + } + const points: SessionUsageTimePoint[] = []; let cumulativeTokens = 0; let cumulativeCost = 0; @@ -2341,11 +2347,6 @@ export async function loadSessionUsageTimeSeries(params: { const sortedPoints = points.toSorted((a, b) => a.timestamp - b.timestamp); // Optionally downsample if too many points - if (params.maxPoints !== undefined && params.maxPoints !== null) { - if (!Number.isFinite(params.maxPoints) || params.maxPoints <= 0) { - return { sessionId: params.sessionId, points: [] }; - } - } const maxPoints = params.maxPoints ?? 100; if (sortedPoints.length > maxPoints) { const step = Math.ceil(sortedPoints.length / maxPoints); diff --git a/test/scripts/limit-edge-case-live-proof.test.ts b/test/scripts/limit-edge-case-live-proof.test.ts new file mode 100644 index 00000000000..bf4296bbb29 --- /dev/null +++ b/test/scripts/limit-edge-case-live-proof.test.ts @@ -0,0 +1,37 @@ +import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { withProofTempRoot } from "../../scripts/repro/limit-edge-case-live-proof.mjs"; + +describe("limit-edge-case live proof", () => { + it("cleans the generated session-log temp root", async () => { + const tempRoot = mkdtempSync(path.join(tmpdir(), "openclaw-limit-proof-test-")); + try { + const originalTmpdir = process.env.TMPDIR; + process.env.TMPDIR = tempRoot; + let proofRoot = ""; + try { + await withProofTempRoot(async (root) => { + proofRoot = root; + writeFileSync(path.join(root, "s.jsonl"), "{}\n"); + expect(existsSync(root)).toBe(true); + }); + } finally { + if (originalTmpdir === undefined) { + delete process.env.TMPDIR; + } else { + process.env.TMPDIR = originalTmpdir; + } + } + + expect(proofRoot).not.toBe(""); + expect(existsSync(proofRoot)).toBe(false); + expect(readdirSync(tempRoot).filter((entry) => entry.startsWith("openclaw-proof-"))).toEqual( + [], + ); + } finally { + rmSync(tempRoot, { force: true, recursive: true }); + } + }); +});