2026-09-11 03:12:32 +03:00
|
|
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
|
|
|
|
import { describe, it } from "node:test";
|
|
|
|
|
|
import assert from "node:assert/strict";
|
2026-09-29 01:04:40 +03:00
|
|
|
|
import { ActivitySection, formatElapsed, toBlobs, type ActivityFeed, type FeedExtraction } from "../components/activity-dock.tsx";
|
2026-09-11 03:15:55 +03:00
|
|
|
|
import { formatTokens, formatUsd } from "../components/usage-format.ts";
|
2026-09-11 03:12:32 +03:00
|
|
|
|
|
2026-09-14 11:56:22 +03:00
|
|
|
|
// The section's pure layer: folds, formatting, and blob order — then the SSR
|
|
|
|
|
|
// render (blobs, "—" doctrine, the abort affordance, the always-present
|
|
|
|
|
|
// header — the sidebar's background-agents strip).
|
2026-09-11 03:12:32 +03:00
|
|
|
|
|
|
|
|
|
|
const NOW = Date.parse("2026-09-09T12:00:00Z");
|
|
|
|
|
|
|
|
|
|
|
|
/** A full CallUsage (its fields are undefined-valued, not optional). */
|
|
|
|
|
|
function step(inputTokens: number, outputTokens: number) {
|
|
|
|
|
|
return { inputTokens, outputTokens, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function job(over: Partial<ActivityFeed["jobs"][number]> = {}): ActivityFeed["jobs"][number] {
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: "job-1",
|
|
|
|
|
|
kind: "research",
|
|
|
|
|
|
chatId: "c1",
|
|
|
|
|
|
toolCallId: "t1",
|
|
|
|
|
|
status: "running",
|
|
|
|
|
|
activity: 'web_search: "sigma.js v3"',
|
|
|
|
|
|
createdAt: new Date(NOW - 45_000).toISOString(),
|
|
|
|
|
|
updatedAt: new Date(NOW - 1000).toISOString(),
|
|
|
|
|
|
title: "Limits lesson",
|
2026-09-24 02:10:06 +03:00
|
|
|
|
contextLimit: null,
|
2026-09-11 03:12:32 +03:00
|
|
|
|
...over,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
describe("activity dock pure layer", () => {
|
|
|
|
|
|
it("merges jobs + extractions oldest-first (the stable blob order)", () => {
|
|
|
|
|
|
const feed: ActivityFeed = {
|
|
|
|
|
|
jobs: [job({ id: "new", createdAt: new Date(NOW - 10_000).toISOString() })],
|
2026-09-29 01:04:40 +03:00
|
|
|
|
extractions: [{ id: "cx", chatId: "cx", title: "Old lesson", phase: "auditing", startedAt: NOW - 100_000 }],
|
2026-09-11 03:12:32 +03:00
|
|
|
|
};
|
|
|
|
|
|
const blobs = toBlobs(feed);
|
|
|
|
|
|
assert.equal(blobs.length, 2);
|
|
|
|
|
|
const first = blobs[0]!;
|
|
|
|
|
|
assert.equal(first.id, "cx"); // extraction started first
|
|
|
|
|
|
assert.equal(first.process, "extraction");
|
|
|
|
|
|
assert.equal(first.phase, "auditing");
|
|
|
|
|
|
assert.equal(blobs[1]!.id, "new");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-29 01:04:40 +03:00
|
|
|
|
it("per-chunk jobs (owner, 2026-09-28): one source, N rows — unique ids (React keys), one chatId, part-suffixed titles", () => {
|
|
|
|
|
|
const rows = [
|
|
|
|
|
|
{ id: "src_a#1", chatId: "src_a", part: { i: 1, n: 2 }, title: "notes.md · 1/2", phase: "extracting", startedAt: NOW - 1000 },
|
|
|
|
|
|
{ id: "src_a#2", chatId: "src_a", part: { i: 2, n: 2 }, title: "notes.md · 2/2", phase: "queued", startedAt: NOW - 500 },
|
|
|
|
|
|
] as FeedExtraction[];
|
|
|
|
|
|
const blobs = toBlobs({ jobs: [], extractions: rows });
|
|
|
|
|
|
assert.equal(blobs.length, 2, "one blob per chunk job");
|
|
|
|
|
|
assert.deepEqual(blobs.map((b) => b.id), ["src_a#1", "src_a#2"], "unique row ids — no duplicate React keys");
|
|
|
|
|
|
assert.ok(blobs.every((b) => b.chatId === "src_a"), "abort/link identity stays source-scoped");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-11 03:12:32 +03:00
|
|
|
|
it("folds cost with jobCost; absent usage is null, never zero", () => {
|
|
|
|
|
|
const priced = job({
|
|
|
|
|
|
usage: {
|
2026-09-22 00:14:56 +03:00
|
|
|
|
model: "openrouter:z-ai/glm-5.3-flash",
|
2026-09-11 03:12:32 +03:00
|
|
|
|
inputTokens: 10_000,
|
|
|
|
|
|
outputTokens: 1000,
|
|
|
|
|
|
cacheReadTokens: 5000,
|
|
|
|
|
|
cacheWriteTokens: 0,
|
|
|
|
|
|
reasoningTokens: 0,
|
|
|
|
|
|
steps: 2,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
const blob = toBlobs({ jobs: [priced], extractions: [] })[0]!;
|
2026-10-04 19:50:20 +03:00
|
|
|
|
// (10000-5000)/1e6*0.15 + 5000/1e6*0.03 + 1000/1e6*0.5 = 0.0014
|
|
|
|
|
|
// (pi-ai catalog prices — glm-5.3-flash cacheRead corrected
|
|
|
|
|
|
// 0.05 → 0.03 in the pi 1.0.2 catalog refresh)
|
|
|
|
|
|
assert.ok(blob.cost !== null && blob.cost > 0.0013 && blob.cost < 0.0015);
|
2026-09-11 03:12:32 +03:00
|
|
|
|
const absent = toBlobs({ jobs: [job()], extractions: [] })[0]!;
|
|
|
|
|
|
assert.equal(absent.cost, null);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-24 23:20:59 +03:00
|
|
|
|
it("a key-refused job reads as info — 'needs a provider key' (zero-keys wave, 66dbea9)", () => {
|
|
|
|
|
|
const refused = job({
|
|
|
|
|
|
status: "error",
|
|
|
|
|
|
activity: "failed",
|
|
|
|
|
|
// the runner composes the envelope's error verbatim (invariant tail)
|
|
|
|
|
|
error: "No OpenRouter key is set — add one in Settings → Keys",
|
|
|
|
|
|
});
|
|
|
|
|
|
const blob = toBlobs({ jobs: [refused], extractions: [] })[0]!;
|
|
|
|
|
|
assert.equal(blob.activity, "needs a provider key");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-22 18:12:57 +03:00
|
|
|
|
it("a models-refused job reads as info — 'needs model setup', not 'failed' (BYOK, 2026-09-22)", () => {
|
|
|
|
|
|
const refused = job({
|
|
|
|
|
|
status: "error",
|
|
|
|
|
|
activity: "failed",
|
|
|
|
|
|
error: "No model configured — set up your models first (missing: vision)",
|
|
|
|
|
|
});
|
|
|
|
|
|
const blob = toBlobs({ jobs: [refused], extractions: [] })[0]!;
|
|
|
|
|
|
assert.equal(blob.activity, "needs model setup");
|
|
|
|
|
|
// A real failure keeps the runner's honest wording.
|
|
|
|
|
|
const crashed = job({ status: "error", activity: "failed", error: "FAILED: headless crashed" });
|
|
|
|
|
|
const real = toBlobs({ jobs: [crashed], extractions: [] })[0]!;
|
|
|
|
|
|
assert.equal(real.activity, "failed");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-11 03:12:32 +03:00
|
|
|
|
it("context line = LAST step's tokens vs the model limit, never a cumulative sum", () => {
|
|
|
|
|
|
const base = {
|
2026-09-22 00:14:56 +03:00
|
|
|
|
model: "openrouter:z-ai/glm-5.3-flash",
|
2026-09-11 03:12:32 +03:00
|
|
|
|
inputTokens: 400_000, // running total (much bigger — must NOT feed the line)
|
|
|
|
|
|
outputTokens: 8_000,
|
|
|
|
|
|
cacheReadTokens: 0,
|
|
|
|
|
|
cacheWriteTokens: 0,
|
|
|
|
|
|
reasoningTokens: 0,
|
|
|
|
|
|
steps: 5,
|
|
|
|
|
|
};
|
|
|
|
|
|
const withLast = job({
|
|
|
|
|
|
usage: { ...base, lastStep: step(90_000, 2_000) },
|
2026-09-24 02:10:06 +03:00
|
|
|
|
// The amendment (owner 2026-09-23): the limit is the server's
|
|
|
|
|
|
// live-catalog join riding the feed — the dock never resolves it.
|
|
|
|
|
|
contextLimit: 131_072,
|
2026-09-11 03:12:32 +03:00
|
|
|
|
});
|
|
|
|
|
|
const blob = toBlobs({ jobs: [withLast], extractions: [] })[0]!;
|
|
|
|
|
|
assert.equal(blob.context?.used, 92_000);
|
2026-09-24 02:10:06 +03:00
|
|
|
|
assert.equal(blob.context?.limit, 131_072);
|
2026-09-11 03:12:32 +03:00
|
|
|
|
// No lastStep yet (capture pending) → "—", even though usage exists.
|
|
|
|
|
|
const stripped = toBlobs({ jobs: [job({ usage: { ...base } })], extractions: [] })[0]!;
|
|
|
|
|
|
assert.equal(stripped.context, null);
|
2026-09-24 02:10:06 +03:00
|
|
|
|
// Model missing from the live catalog (historical session,
|
|
|
|
|
|
// package update) → the feed carries null → no context line.
|
|
|
|
|
|
const uncatalogued = toBlobs({
|
|
|
|
|
|
jobs: [job({ usage: { ...base, model: "nope/unknown", lastStep: step(5, 5), } })],
|
2026-09-11 03:12:32 +03:00
|
|
|
|
extractions: [],
|
|
|
|
|
|
})[0]!;
|
2026-09-24 02:10:06 +03:00
|
|
|
|
assert.equal(uncatalogued.context, null);
|
2026-09-24 02:15:23 +03:00
|
|
|
|
// The numerator is the same cache-inclusive fold the session meter
|
|
|
|
|
|
// uses (context-meter audit, owner 2026-09-23): provider "input"
|
|
|
|
|
|
// EXCLUDES cacheRead/cacheWrite, so input+output alone reads wildly
|
|
|
|
|
|
// low on cache-heavy jobs — 10k+0.5k+120k+30k = 160.5k, not 10.5k.
|
|
|
|
|
|
const cached = toBlobs({
|
|
|
|
|
|
jobs: [
|
|
|
|
|
|
job({
|
|
|
|
|
|
usage: {
|
|
|
|
|
|
...base,
|
|
|
|
|
|
lastStep: {
|
|
|
|
|
|
inputTokens: 10_000,
|
|
|
|
|
|
outputTokens: 500,
|
|
|
|
|
|
cacheReadTokens: 120_000,
|
|
|
|
|
|
cacheWriteTokens: 30_000,
|
|
|
|
|
|
reasoningTokens: undefined,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
contextLimit: 200_000,
|
|
|
|
|
|
}),
|
|
|
|
|
|
],
|
|
|
|
|
|
extractions: [],
|
|
|
|
|
|
})[0]!;
|
|
|
|
|
|
assert.equal(cached.context?.used, 160_500);
|
|
|
|
|
|
assert.equal(cached.context?.limit, 200_000);
|
2026-09-11 03:12:32 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("extraction blobs carry no money/context (—, the doctrine)", () => {
|
|
|
|
|
|
const blob = toBlobs({
|
|
|
|
|
|
jobs: [],
|
2026-09-29 01:04:40 +03:00
|
|
|
|
extractions: [{ id: "cx", chatId: "cx", title: "T", phase: "extracting", startedAt: NOW }],
|
2026-09-11 03:12:32 +03:00
|
|
|
|
})[0]!;
|
|
|
|
|
|
assert.equal(blob.cost, null);
|
|
|
|
|
|
assert.equal(blob.context, null);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("formatElapsed ticks compactly", () => {
|
|
|
|
|
|
assert.equal(formatElapsed(45_000), "45s");
|
|
|
|
|
|
assert.equal(formatElapsed(184_000), "3m 04s");
|
|
|
|
|
|
assert.equal(formatElapsed(4_320_000), "1h 12m");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("formatUsd keeps four decimals under $1, two above", () => {
|
|
|
|
|
|
assert.equal(formatUsd(0.0014), "$0.0014");
|
|
|
|
|
|
assert.equal(formatUsd(1.5), "$1.50");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("formatTokens trims (never 38.0k)", () => {
|
|
|
|
|
|
assert.equal(formatTokens(38_000), "38k");
|
|
|
|
|
|
assert.equal(formatTokens(1_234_567), "1.2M");
|
|
|
|
|
|
assert.equal(formatTokens(950), "950");
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-14 11:56:22 +03:00
|
|
|
|
describe("activity section render", () => {
|
|
|
|
|
|
it("renders the ALWAYS-PRESENT header when idle — empty means empty, nothing hides", () => {
|
|
|
|
|
|
const html = renderToStaticMarkup(<ActivitySection feed={{ jobs: [], extractions: [] }} />);
|
|
|
|
|
|
assert.match(html, /data-testid="activity-section"/);
|
|
|
|
|
|
assert.match(html, /Background agents/);
|
|
|
|
|
|
assert.doesNotMatch(html, /activity-blob-(job|extraction)/, "no rows while idle");
|
|
|
|
|
|
assert.doesNotMatch(html, /activity-total-cost/, "the cost span is population, not decoration");
|
2026-09-11 03:12:32 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("renders one blob per running process with cost, elapsed, and the abort affordance", () => {
|
|
|
|
|
|
const html = renderToStaticMarkup(
|
2026-09-14 11:56:22 +03:00
|
|
|
|
<ActivitySection
|
2026-09-11 03:12:32 +03:00
|
|
|
|
feed={{
|
|
|
|
|
|
jobs: [job({ id: "j1", kind: "demo", activity: "building sandbox…", title: "Chain-rule demo" })],
|
2026-09-29 01:04:40 +03:00
|
|
|
|
extractions: [{ id: "cx", chatId: "cx", title: "Old lesson", phase: "merging", startedAt: NOW - 60_000 }],
|
2026-09-11 03:12:32 +03:00
|
|
|
|
}}
|
|
|
|
|
|
/>,
|
|
|
|
|
|
);
|
2026-09-14 11:56:22 +03:00
|
|
|
|
assert.match(html, /data-testid="activity-section"/);
|
2026-09-11 03:12:32 +03:00
|
|
|
|
assert.match(html, /data-testid="activity-blob-job"/);
|
|
|
|
|
|
assert.match(html, /data-testid="activity-blob-extraction"/);
|
|
|
|
|
|
assert.match(html, /Chain-rule demo/);
|
|
|
|
|
|
assert.match(html, /building sandbox…/);
|
|
|
|
|
|
assert.match(html, /Extraction — merging/);
|
|
|
|
|
|
// Unpriced job → "—", never $0.0000 (doctrine); both blobs show it.
|
|
|
|
|
|
assert.match(html, /activity-blob-cost[^>]*>—</);
|
|
|
|
|
|
assert.match(html, /data-testid="activity-abort"/, "END on the blob face, popover-independent");
|
|
|
|
|
|
assert.match(html, /\bEnd\b/, "the explicit END button");
|
2026-09-14 11:56:22 +03:00
|
|
|
|
assert.match(html, /Background agents/);
|
2026-09-11 03:12:32 +03:00
|
|
|
|
// Total cost: nothing priced → "—" in the header.
|
|
|
|
|
|
assert.match(html, /activity-total-cost[^>]*>—</);
|
2026-09-25 01:52:20 +03:00
|
|
|
|
// Truncation-reveals KEEP native title (tooltip wave mechanism rule):
|
|
|
|
|
|
// the truncated activity + title spans carry title= — a styled
|
|
|
|
|
|
// tooltip adds nothing over an ellipsis reveal.
|
|
|
|
|
|
assert.match(html, /title="building sandbox…"/);
|
2026-09-11 03:12:32 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-15 07:54:50 +03:00
|
|
|
|
|
2026-09-11 03:12:32 +03:00
|
|
|
|
it("shows total running cost when jobs are priced", () => {
|
|
|
|
|
|
const usage = {
|
2026-09-22 00:14:56 +03:00
|
|
|
|
model: "openrouter:z-ai/glm-5.3-flash",
|
2026-09-11 03:12:32 +03:00
|
|
|
|
inputTokens: 10_000,
|
|
|
|
|
|
outputTokens: 1000,
|
|
|
|
|
|
cacheReadTokens: 0,
|
|
|
|
|
|
cacheWriteTokens: 0,
|
|
|
|
|
|
reasoningTokens: 0,
|
|
|
|
|
|
steps: 1,
|
|
|
|
|
|
};
|
|
|
|
|
|
const html = renderToStaticMarkup(
|
2026-09-14 11:56:22 +03:00
|
|
|
|
<ActivitySection
|
2026-09-11 03:12:32 +03:00
|
|
|
|
feed={{
|
|
|
|
|
|
jobs: [
|
|
|
|
|
|
job({ id: "a", usage }),
|
|
|
|
|
|
job({ id: "b", usage, createdAt: new Date(NOW - 20_000).toISOString() }),
|
|
|
|
|
|
],
|
|
|
|
|
|
extractions: [],
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>,
|
|
|
|
|
|
);
|
2026-09-29 04:30:15 +03:00
|
|
|
|
// two identical priced jobs → 2 × $0.002 = $0.0040 total (pi prices,
|
|
|
|
|
|
// glm-5.3-flash drifted in pi-ai 0.87.1)
|
|
|
|
|
|
assert.match(html, /activity-total-cost[^>]*>\$0\.0040</);
|
2026-09-11 03:12:32 +03:00
|
|
|
|
});
|
|
|
|
|
|
});
|