The relational-databases incident, diagnosed and fixed: TRIGGER — pi's estimator law, adopted exactly: anchor on the last assistant message's provider-reported usage (totalTokens || the field sum) and chars/4-estimate ONLY what was appended after it. The anchor scan skips aborted/zero-usage assistants and STOPS at the newest compaction marker (pre-cut usage described context the next request will not carry — pi's distrust rule). The old estimator (JSON.stringify of every message /4) tripled the true context (measured: 84,412 'tokens' estimated vs 38,799 provider-reported on the incident record) — scaffolding, escaped LaTeX, thinking blocks and app-side details all counted. estimateMessageTokens now counts wire text only (text + thinking + toolCall name/args; content text for user/toolResult/custom). The unanchored fallback runs the plain heuristic over the CUT-HONORED projection plus the additive prompt term; the anchored path adds nothing (usage already billed prompt and tools). OBSERVABILITY — compaction is a visible session-wide state (owner spec): the transcript-bottom placeholder register gains COMPACTING CONTEXT… (BrailleSpinner + Shimmer, the THINKING placeholder's twin, mutually exclusive by construction — the gate precedes beginTurn), and the sidebar row shows 'Compacting context · <ago>' with the spinner (sessionMeta outranks busy; compacting-changed joins turn-started/turn-settled as refresh triggers). Liveness rides the API like turn-busy: ChatListItem.compacting + compactionInFlight on both GET /api/chats and GET /api/chats/[id] (initiallyCompacting covers the navigate-back-mid-compaction mount — the event already fired), exported through the server barrel. Also: the relational-databases record rolled back to pre-compaction (marker, post-marker turns, tree reset; backup in /tmp) for a clean retest. The summarizer-usage 'gap' was a misread — steps[] was populated all along.
146 lines
6.5 KiB
TypeScript
146 lines
6.5 KiB
TypeScript
/**
|
|
* Session-row render tests — the picker's status language (owner,
|
|
* minimal-color round): ONE indicator, the braille spinner, present ONLY
|
|
* while a teach turn is in flight; every other state (idle, question
|
|
* pending) renders nothing — phases only advance, so phase-as-signal was
|
|
* decoration, and question-pending is the dominant RESTING state (the
|
|
* meta line's text carries it). Pure function of the list item, so SSR of
|
|
* <SessionRow> pins every visible state. Same tsx classic-JSX shim as
|
|
* render-cards.test.tsx.
|
|
*/
|
|
import React from "react";
|
|
(globalThis as Record<string, unknown>).React = React;
|
|
|
|
import assert from "node:assert/strict";
|
|
import { describe, it } from "node:test";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import { SessionRow, timeAgo } from "../components/sidebar.tsx";
|
|
import type { ChatListItem } from "../src/core/index.ts";
|
|
|
|
function item(overrides: Partial<ChatListItem>): ChatListItem {
|
|
return {
|
|
id: "c1",
|
|
title: "Test session",
|
|
kind: "teach",
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-02T00:00:00.000Z",
|
|
messageCount: 4,
|
|
phase: "probe",
|
|
pendingQuestion: false,
|
|
pendingProblem: false,
|
|
busy: false,
|
|
compacting: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function render(chat: ChatListItem, active = false) {
|
|
return renderToStaticMarkup(<SessionRow chat={chat} active={active} onOpen={() => {}} />);
|
|
}
|
|
|
|
describe("SessionRow", () => {
|
|
it("no phase strips, no phase colors — the retired decoration", () => {
|
|
const html = render(item({ phase: "teach" }));
|
|
assert.ok(!html.includes("phase-strip"), "strip removed by design (too busy)");
|
|
assert.ok(!html.includes("bg-phase-"), "phase fills are retired app-wide");
|
|
assert.ok(!html.includes("border-phase-"), "phase rings are retired app-wide");
|
|
});
|
|
|
|
it("idle session: NO indicator — absence is the state", () => {
|
|
for (const phase of ["probe", "plan", "teach"] as const) {
|
|
const html = render(item({ phase }));
|
|
assert.ok(!html.includes("session-dot"), "no dots — the status language is spinner-only");
|
|
assert.ok(!html.includes("braille-spinner"), "idle never shows the spinner");
|
|
}
|
|
});
|
|
|
|
it("a pending question renders NO indicator — resting state, carried by the meta text", () => {
|
|
const html = render(item({ phase: "teach", pendingQuestion: true }));
|
|
assert.ok(!html.includes("session-dot"), "no dots");
|
|
assert.ok(!html.includes("braille-spinner"), "motion is reserved for working");
|
|
assert.ok(html.includes("Waiting for your answer"), "the meta line's text");
|
|
});
|
|
|
|
it("a working session (turn in flight) renders the braille spinner — the ONLY indicator", () => {
|
|
const html = render(item({ phase: "plan", busy: true }));
|
|
assert.ok(html.includes('data-testid="braille-spinner"'), "the spinner");
|
|
assert.ok(html.includes("braille-frame"), "CSS-animated frames");
|
|
assert.ok((html.match(/class="braille-frame"/g) ?? []).length === 10, "all ten frames");
|
|
assert.ok(html.includes('data-frame="0"'), "frame 0 leads (static fallback)");
|
|
assert.ok(html.includes("Teacher is working"), "accessible label");
|
|
assert.ok(!html.includes("bg-attention"), "no colored state fill — monochrome by design");
|
|
});
|
|
|
|
it("busy does not double-signal background agents (that is the sidebar section's channel)", () => {
|
|
// The narrowing lives in the API route (busy = isTurnRunning only);
|
|
// the row itself just renders busy faithfully. Pinned here so the
|
|
// contract can't silently re-widen: an extraction-only "working"
|
|
// row would be a double signal.
|
|
const html = render(item({ busy: true, pendingQuestion: false }));
|
|
assert.ok(html.includes("Teacher is working"), "the spinner's label names the turn, not agents");
|
|
});
|
|
|
|
it("the meta line: state replaces the count when the state is the useful fact", () => {
|
|
const awaiting = render(item({ pendingQuestion: true, updatedAt: "2026-01-02T01:00:00.000Z" }));
|
|
assert.ok(awaiting.includes("Waiting for your answer · "), "awaiting meta");
|
|
assert.ok(!awaiting.includes("messages"), "count yields to the state");
|
|
assert.ok(!awaiting.includes("braille-spinner"), "…and the spinner stays off");
|
|
|
|
const working = render(item({ busy: true }));
|
|
assert.ok(working.includes("Teacher is working…"), "working meta");
|
|
|
|
// Owner, typography round 5: the message count is dropped — it
|
|
// informed no decision. Idle meta is just how long ago.
|
|
const idle = render(item({ title: "Partial derivatives", messageCount: 7 }));
|
|
assert.ok(idle.includes("Partial derivatives"));
|
|
assert.ok(!idle.includes("messages"), "the count is gone (flip-flop: one line in sessionMeta)");
|
|
assert.match(idle, /\d{4}-\d{2}-\d{2}/, "idle shows the ISO date");
|
|
});
|
|
|
|
it("active row is visually marked by the background alone (owner, round 5: no weight change)", () => {
|
|
const html = render(item({}), true);
|
|
assert.ok(html.includes("bg-accent"), "active background");
|
|
assert.ok(!html.includes("font-medium"), "weight is not a second channel doing the same job");
|
|
});
|
|
});
|
|
|
|
describe("timeAgo", () => {
|
|
const now = new Date("2026-01-02T12:00:00.000Z");
|
|
|
|
it("just now / minutes / hours / days, then the ISO date (owner, round 5)", () => {
|
|
assert.equal(timeAgo("2026-01-02T11:59:30.000Z", now), "just now");
|
|
assert.equal(timeAgo("2026-01-02T11:55:00.000Z", now), "5m ago");
|
|
assert.equal(timeAgo("2026-01-02T09:00:00.000Z", now), "3h ago");
|
|
assert.equal(timeAgo("2025-12-31T12:00:00.000Z", now), "2d ago");
|
|
assert.equal(timeAgo("2025-06-02T12:00:00.000Z", now), "2025-06-02", "beyond a week: ISO — unambiguous, sortable");
|
|
});
|
|
|
|
it("negative drift (clock skew) reads as just now, never a future date", () => {
|
|
assert.equal(timeAgo("2026-01-02T12:01:00.000Z", now), "just now");
|
|
});
|
|
});
|
|
|
|
describe("SessionRow practice badge (practice sessions, 2026-10-04)", () => {
|
|
it("a practice row carries the dumbbell badge and the Practicing meta while busy", () => {
|
|
const html = render(item({ kind: "practice", busy: true }));
|
|
assert.match(html, /Practicing…/);
|
|
assert.match(html, /aria-label="Practice session"/);
|
|
});
|
|
|
|
it("a teaching row carries neither", () => {
|
|
const html = render(item({}));
|
|
assert.doesNotMatch(html, /Practice session/);
|
|
assert.doesNotMatch(html, /Practicing…/);
|
|
});
|
|
|
|
it("busy teaching rows still say the teacher is working", () => {
|
|
const html = render(item({ busy: true }));
|
|
assert.match(html, /Teacher is working…/);
|
|
});
|
|
|
|
it("a practice row with a posed problem says the problem is waiting", () => {
|
|
const html = render(item({ kind: "practice", pendingProblem: true }));
|
|
assert.match(html, /Problem waiting ·/);
|
|
});
|
|
});
|