Files
claude 18d5f5bfc0
CI / verify (push) Successful in 7m10s
CI / publish (push) Has been skipped
Make Piggy part of the product rather than a guest in it
Piggy arrived as a chat panel bolted onto a CRM and then grew a workspace
around it. The layout was already right — the audit found the approval card
to be the best-designed object in the repo, and the account page's empty
panels less finished than anything in the workspace. What was wrong was
vocabulary: nobody had written the small things down, so both halves kept
inventing them.

Piggy was drawn with five different marks — a pig in the dock, a sparkle in
the sidebar and again on the model picker, a speech bubble on the Ask
buttons, and a stock robot glyph on every assistant message, which is the
one people look at most. There is now one mark. The composer, which is the
first control in the product since sign-in lands on /piggy, was the only
un-adapted shadcn field left: 6px radius against a 12px Send button it sat
8px from. A stat tile had been reinvented six times at three numeral scales,
and the same uppercase micro-label existed in five variants, two of them one
tab apart in the same rail. There were 63 hand-written font sizes: not a
scale, sixty-three opinions.

Underneath that, the focus ring was invisible. The global rule used
ring-accent, which Tailwind deliberately aliases onto the hover tint, so the
ring measured 1.01:1 against the light canvas — no visible focus indicator
anywhere in the product, for any accent, in either theme. It is ring-brand
now and measures 17:1. The warning, positive and info tones were darkened
until each clears 4.5:1 on a card, on inset and on its own chip, and the
light canvas moved to 98% so a card lifts without leaning on its shadow.

The mobile work is the part worth reading. A landscape phone gave the
transcript 28% of the viewport and a keyboard-up phone 16%, against a 45%
floor — and the fixed tab bar painted over the composer, covering the safety
sentence and half the Send button, because two source comments asserted the
bar stood down on short viewports and it never had. Both fixed and measured
by hit-testing rather than by screenshot. The composer itself was 64px tall
for a blank second line nobody typed, because the auto-resize effect sizes
to scrollHeight and scrollHeight counts rows — a CSS height could not win
against an inline style, so the attribute was the honest lever.

Verified across both themes driven through the app's own control: no
horizontal overflow on 15 routes at four viewports, 672 stat values that fit,
297 labels at exactly 11px/500, Escape returning focus to its opener rather
than the body on every overlay, and a rejected write no longer reporting
"Succeeded" with a green check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 18:22:15 -07:00

127 lines
5.2 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { loadPiggyConfig, loadPiggyStallLimits, loadPiggyTurnLimits } from '../src/config';
const minimum = {
DATABASE_URL: 'postgres://pig:pig@localhost:54330/pig',
PIGGY_INFERENCE_API_KEY: 'test-key',
PIGGY_INTERNAL_TOKEN: 'test-internal-token-for-piggy-000000',
};
test('the chat budget is separate from the worker budget, and larger', () => {
const config = loadPiggyConfig(minimum);
// The worker extracts; the chat has to quote aggregates back. Sharing one
// budget meant tuning either one moved both.
assert.equal(config.PIGGY_MAX_TOKENS, 1_024);
assert.equal(config.PIGGY_CHAT_MAX_TOKENS, 2_048);
assert.equal(config.PIGGY_MAX_TURNS, 4);
});
test('a turn has a ceiling on both axes, generous against the measured turn', () => {
const config = loadPiggyConfig(minimum);
// Measured on the live stack against the default model: a one-tool turn is
// 2 model calls and 4,922 tokens, a two-tool turn is 3 and 12,265. The
// ceilings are roughly three times the busiest of those, which leaves a real
// multi-step question room to breathe and still stops a `while (true)` in
// seconds rather than in dollars.
assert.equal(config.PIGGY_CHAT_MAX_MODEL_CALLS, 8);
assert.equal(config.PIGGY_CHAT_MAX_TURN_TOKENS, 40_000);
assert.equal(config.PIGGY_CHAT_DAILY_LIMIT_CENTS, 200);
// PIGGY_MAX_TURNS is the queue worker's own budget and reaches nothing in the
// chat path. Keeping them distinct is the point: raising one used to look
// like it raised the other, which is how the chat came to have no ceiling at
// all.
assert.notEqual(config.PIGGY_MAX_TURNS, config.PIGGY_CHAT_MAX_MODEL_CALLS);
});
test('the ceilings can be read without the rest of the environment', () => {
// The chat server is handed a socket and a token and builds the rest from
// defaults; it must not start demanding a DATABASE_URL it never uses.
assert.deepEqual(loadPiggyTurnLimits({}), {
maxModelCalls: 8,
maxTurnTokens: 40_000,
dailyLimitCents: 200,
});
assert.deepEqual(
loadPiggyTurnLimits({
PIGGY_CHAT_MAX_MODEL_CALLS: '3',
PIGGY_CHAT_MAX_TURN_TOKENS: '9000',
PIGGY_CHAT_DAILY_LIMIT_CENTS: '0',
}),
{ maxModelCalls: 3, maxTurnTokens: 9_000, dailyLimitCents: 0 },
);
// A ceiling of zero model calls would answer nothing at all, so it is a
// configuration error rather than a very strict deployment.
assert.throws(
() => loadPiggyTurnLimits({ PIGGY_CHAT_MAX_MODEL_CALLS: '0' }),
/PIGGY_CHAT_MAX_MODEL_CALLS/,
);
assert.throws(
() => loadPiggyTurnLimits({ PIGGY_CHAT_MAX_TURN_TOKENS: 'plenty' }),
/PIGGY_CHAT_MAX_TURN_TOKENS/,
);
});
test('a turn has two deadlines for silence, and they are not one flat deadline', () => {
const config = loadPiggyConfig(minimum);
assert.equal(config.PIGGY_CHAT_FIRST_PROGRESS_TIMEOUT_MS, 60_000);
assert.equal(config.PIGGY_CHAT_IDLE_TIMEOUT_MS, 45_000);
// Read on their own too: the chat server is handed a socket and a token.
assert.deepEqual(loadPiggyStallLimits({}), { firstProgressMs: 60_000, idleMs: 45_000 });
assert.deepEqual(
loadPiggyStallLimits({
PIGGY_CHAT_FIRST_PROGRESS_TIMEOUT_MS: '1500',
PIGGY_CHAT_IDLE_TIMEOUT_MS: '900',
}),
{ firstProgressMs: 1_500, idleMs: 900 },
);
// The idle window is the shorter of the two on purpose. Getting started
// covers connecting, the endpoint's queue and a slow model's first token;
// once a turn is under way the gaps are milliseconds, so a long silence
// mid-answer is a dead socket rather than a thoughtful one. Neither bounds
// the turn's total duration, which is the whole design: the idle clock
// restarts on every event.
assert.ok(
loadPiggyStallLimits({}).idleMs < loadPiggyStallLimits({}).firstProgressMs,
'the idle window should not need to be as generous as getting started',
);
// A deadline of zero would stall every turn before it began, so it is a
// configuration error rather than a very impatient deployment.
assert.throws(
() => loadPiggyStallLimits({ PIGGY_CHAT_IDLE_TIMEOUT_MS: '0' }),
/PIGGY_CHAT_IDLE_TIMEOUT_MS/,
);
assert.throws(
() => loadPiggyStallLimits({ PIGGY_CHAT_FIRST_PROGRESS_TIMEOUT_MS: 'patience' }),
/PIGGY_CHAT_FIRST_PROGRESS_TIMEOUT_MS/,
);
});
test('reasoning stays off by default', () => {
// Reasoning tokens are billed like any other and nemotron-nano's are
// verbose. The knob exists for debugging, not for the default deployment.
assert.equal(loadPiggyConfig(minimum).PIGGY_REASONING_EFFORT, 'none');
assert.equal(
loadPiggyConfig({ ...minimum, PIGGY_REASONING_EFFORT: 'low' }).PIGGY_REASONING_EFFORT,
'low',
);
assert.throws(
() => loadPiggyConfig({ ...minimum, PIGGY_REASONING_EFFORT: 'maximum' }),
/PIGGY_REASONING_EFFORT/,
);
});
test('the default token prices are the published price of the default model', () => {
const config = loadPiggyConfig(minimum);
// $0.05/$0.20 per million tokens, carried as cents per million so that
// tokens x price is already micro-cents.
assert.equal(config.PIGGY_PRICE_INPUT_CENTS_PER_MTOK, 5);
assert.equal(config.PIGGY_PRICE_OUTPUT_CENTS_PER_MTOK, 20);
assert.equal(config.PIGGY_MODEL, 'nvidia/nemotron-3-nano-30b-a3b');
});