Chunk B (owner-designed, model-slots wave) on the merged engine surface (main 1d66a17): AGENT SECTION — SLOTS ONLY (owner ruling) - The six per-role model rows are GONE; three slot rows instead: Strong (Prober + Teacher), Cheap (five workhorse roles), Vision (visual maker + PDF reader), roster + resolved-default in every hint. - Slot dropdowns share the app's menu language over the FEDERATED catalog (/api/models = keyed providers only, fresh per call — saving a key in Keys immediately widens the picker); the vision slot filters to image-capable models (Model.input); the menu's highlight now matches provider+id (ids can collide across providers). - Slots write the new models section (PUT /api/settings/models) through the dialog's write seam; per-role overrides stay schema-only. - ModelMenuList rows read 'provider · id' — federation makes the provider bookkeeping-relevant. Usage stamps render provider-qualified (prettyModel(qualified): 'Glm 5.3 Flash · openrouter') in the stats bar's turn lines. SEARCH ENGINE (search wave: no default, owner) - A row in Tools: Tavily/Brave via the shared-menu look, Automatic = the pin removed (auto-activation). The live reason line comes from /api/search; no engine key at all grays the whole row + disables the trigger (a keyless choice would only block auto-activation). FIRST-RUN SETUP FLOW (owner design A: composer CTA + send interception) - Chats cannot start until all three slots are picked — no defaults for 33 providers (slotsConfigured: all three slot models required). - The graph view's new-session dock (home) shows a 'Set up your models' CTA while unconfigured; submitting a topic opens the SetupDialog WITH THE DRAFT PRESERVED; Save stores the models section and the queued message sends. Same interception on /chat/new sends. - STAGED BY DEPENDENCY (owner: keys first, models after): no keyed provider → stage 1 deep-links the settings dialog on Keys (open now takes a section); a saved key reopens at stage 2 on the next submit. - Stage 2: Strong → Cheap → Vision pickers (federated, vision-filtered, all three required). Closable without configuring — browsing stays possible; the gate is the send, not the dialog. Layered Esc: an open slot menu eats Esc before the dialog. - Two closure bugs caught live: onDone→start/send captured the stale pre-save 'configured' (the flow now forces the replayed send); menu-Esc used to close the whole dialog. Settings-dialog provider: open(section?) lands on a section (stage 1 deep-link). Tests: composeSlotModel/composeSearchEngine pure, slot SSR replacing role rows, slotsConfigured/setupStage first-run gates, provider-qualified prettyModel. Gates: typecheck, lint, 792/792, build.
87 lines
4.1 KiB
TypeScript
87 lines
4.1 KiB
TypeScript
import { renderToStaticMarkup } from "react-dom/server";
|
||
import { describe, it } from "node:test";
|
||
import assert from "node:assert/strict";
|
||
import { StatsBar } from "../components/stats-bar.tsx";
|
||
import { prettyModel } from "../components/usage-format.ts";
|
||
import type { ChatSettings, TurnUsage } from "../src/core/contracts.ts";
|
||
|
||
// The stats bar: folds only (no cost math in the component), the "—"
|
||
// doctrine for absent data, honest cost (never a fake $0), and the model +
|
||
// thinking control (components/model-picker.tsx — pi-ai catalog backed).
|
||
|
||
function step(inputTokens: number, outputTokens: number, over: Record<string, number | undefined> = {}) {
|
||
return { inputTokens, outputTokens, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined, ...over };
|
||
}
|
||
|
||
function turn(over: Partial<TurnUsage> = {}): TurnUsage {
|
||
return {
|
||
id: "t1",
|
||
messageId: "m1",
|
||
at: "2026-09-09T12:00:00Z",
|
||
model: "openrouter:z-ai/glm-5.3-flash",
|
||
steps: [step(10_000, 1_000)],
|
||
...over,
|
||
};
|
||
}
|
||
|
||
const SETTINGS_STUB = (_s: ChatSettings) => {};
|
||
|
||
describe("stats bar", () => {
|
||
it("renders — everywhere when the record has no turns yet (doctrine)", () => {
|
||
const html = renderToStaticMarkup(
|
||
<StatsBar turns={[]} chatId="chat_x" onSettingsSaved={SETTINGS_STUB} />,
|
||
);
|
||
assert.match(html, /data-testid="stats-bar"/);
|
||
assert.match(html, /data-testid="stats-context">—</);
|
||
assert.match(html, /data-testid="stats-cost">—</);
|
||
// no turns, no settings, catalog not loaded → both control labels
|
||
// "—" (the thinking button shows just the level, like the model
|
||
// button shows just the model name — no prefix, no dot separator)
|
||
assert.match(html, /data-testid="stats-model"[^>]*>\s*<span[^>]*>—<\/span>/);
|
||
assert.match(html, /data-testid="stats-thinking"[^>]*>\s*<span[^>]*>—<\/span>/);
|
||
assert.ok(!html.includes("aria-hidden>·"), "no dot between the controls");
|
||
// the control reads as a button: pill border + dropdown chevron
|
||
assert.match(html, /data-testid="stats-model"[^>]*rounded-full[^>]*>/);
|
||
assert.match(html, /▾/);
|
||
});
|
||
|
||
it("folds context + cost from the turns log (core/prices, no local math)", () => {
|
||
const html = renderToStaticMarkup(
|
||
<StatsBar turns={[turn(), turn()]} chatId="chat_x" onSettingsSaved={SETTINGS_STUB} />,
|
||
);
|
||
// last turn's last call: 10k in + 1k out = 11k; pi catalog limit
|
||
// 1_048_576 → "1M" (tables regenerated from pi-ai's catalog)
|
||
assert.match(html, /data-testid="stats-context">11k \/ 1M</);
|
||
// pi catalog price 0.075/0.25 per 1M: each turn 10k×0.075 + 1k×0.25
|
||
// = $0.001 → two = $0.0020
|
||
assert.match(html, /data-testid="stats-cost">\$0\.0020</);
|
||
// model button label falls back to the last turn's actual model
|
||
// (the catalog + settings are what drive it once loaded)
|
||
assert.match(html, /data-testid="stats-model"[^>]*>\s*<span[^>]*>Glm 5\.3 Flash<\/span>/);
|
||
});
|
||
|
||
it("shows — for cost when every turn is unpriceable, never a fake $0", () => {
|
||
const html = renderToStaticMarkup(
|
||
<StatsBar turns={[turn({ model: "nope/unknown" })]} chatId="chat_x" onSettingsSaved={SETTINGS_STUB} />,
|
||
);
|
||
assert.match(html, /data-testid="stats-cost">—</);
|
||
// unknown model also kills the context limit
|
||
assert.match(html, /data-testid="stats-context">—</);
|
||
});
|
||
|
||
it("prettyModel strips vendor + date suffix, capitalizes", () => {
|
||
assert.equal(prettyModel("z-ai/glm-5.3-flash"), "Glm 5.3 Flash");
|
||
assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "Qwen3.8 Max");
|
||
});
|
||
|
||
it("provider-qualified stamps (usage contract, model-slots wave)", () => {
|
||
// qualified: the provider rides as a quiet suffix — federation makes
|
||
// the same model via two providers different bookkeeping
|
||
assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash", true), "Glm 5.3 Flash · openrouter");
|
||
assert.equal(prettyModel("openai:gpt-5.2", true), "Gpt 5.2 · openai");
|
||
// unqualified on a qualified stamp: provider silently dropped (old shape)
|
||
assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash"), "Glm 5.3 Flash");
|
||
// qualified on a bare id: no suffix invented
|
||
assert.equal(prettyModel("z-ai/glm-5.3-flash", true), "Glm 5.3 Flash");
|
||
});
|
||
}); |