18d5f5bfc0
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>
463 lines
17 KiB
TypeScript
463 lines
17 KiB
TypeScript
/**
|
|
* What the chat server tells the user, and the ledger, about a retried turn.
|
|
*
|
|
* `inference-retry.test.ts` pins the retry itself against the real harness.
|
|
* This file pins the half of the same production failure that lived in PIG's
|
|
* own code, and it is the half that was doing the visible damage.
|
|
*
|
|
* Measured on 2026-08-14: the harness retries a rate-limited turn of its own
|
|
* accord and often succeeds, but `translateSessionEvent` latched
|
|
* `state.errorMessage` on the errored `turn_end` and never cleared it, so a turn
|
|
* that recovered and streamed a perfectly good answer was still closed as
|
|
* `inference_failed` with the 429 in `agent_runs.error`. The reader was told
|
|
* Piggy could not finish an answer they had just been given.
|
|
*
|
|
* Every session here is a double, for the same reason the stall guard's are: an
|
|
* endpoint cannot be asked to rate limit on demand, and the point of these tests
|
|
* is the server's reading of the events, not the transport underneath them.
|
|
*/
|
|
import assert from 'node:assert/strict';
|
|
import type { AddressInfo } from 'node:net';
|
|
import test from 'node:test';
|
|
import type { AgentSession, AgentSessionEvent, ToolDefinition } from '@earendil-works/pi-coding-agent';
|
|
import type { PiggyChatEvent, PiggyModelOption } from '@pig/core';
|
|
import type { Database } from '@pig/db';
|
|
import type { PiggySession } from '../src/agent/session';
|
|
import { startPiggyChatServer, type PiggyChatServerOptions } from '../src/chat-server';
|
|
import type { PiggyStallLimits } from '../src/config';
|
|
|
|
const TOKEN = 'test-internal-token-for-piggy-000000';
|
|
|
|
const MODELS: PiggyModelOption[] = [
|
|
{
|
|
id: 'nvidia/nemotron-3-super-120b-a12b',
|
|
label: 'Nemotron 3 Super',
|
|
costPerMTokIn: 0.3,
|
|
costPerMTokOut: 0.9,
|
|
contextWindow: 131_072,
|
|
reasoning: true,
|
|
isDefault: true,
|
|
},
|
|
];
|
|
|
|
/** The body Prime Inference really sends, verbatim from the production log. */
|
|
const RATE_LIMIT_ERROR =
|
|
'429: {"message":"Rate limit reached. Please retry shortly.","type":"rate_limit_exceeded","code":"rate_limited"}';
|
|
|
|
interface RecordedRun {
|
|
values: Record<string, unknown>;
|
|
closed?: Record<string, unknown>;
|
|
}
|
|
|
|
function fakeDatabase(runs: RecordedRun[]): Database {
|
|
return {
|
|
insert: () => ({
|
|
values: (values: Record<string, unknown>) => ({
|
|
returning: async () => {
|
|
runs.push({ values });
|
|
return [{ id: `run-${runs.length}` }];
|
|
},
|
|
}),
|
|
}),
|
|
update: () => ({
|
|
set: (closed: Record<string, unknown>) => ({
|
|
where: async () => {
|
|
const run = runs.at(-1);
|
|
if (run) run.closed = closed;
|
|
},
|
|
}),
|
|
}),
|
|
select: () => ({ from: () => ({ where: async () => [{ spent: '0' }] }) }),
|
|
} as unknown as Database;
|
|
}
|
|
|
|
type TurnScript = (
|
|
tools: readonly ToolDefinition[],
|
|
emit: (event: AgentSessionEvent) => void,
|
|
signal: AbortSignal,
|
|
) => Promise<void>;
|
|
|
|
interface SessionSpy {
|
|
aborted: number;
|
|
}
|
|
|
|
function sessions(script: TurnScript, watched: SessionSpy) {
|
|
return async (options: { tools: readonly ToolDefinition[]; modelId?: string }): Promise<PiggySession> => {
|
|
const listeners = new Set<(event: AgentSessionEvent) => void>();
|
|
const aborted = new AbortController();
|
|
const session = {
|
|
subscribe(listener: (event: AgentSessionEvent) => void) {
|
|
listeners.add(listener);
|
|
return () => listeners.delete(listener);
|
|
},
|
|
async prompt() {
|
|
await script(
|
|
options.tools,
|
|
(event) => {
|
|
for (const listener of [...listeners]) listener(event);
|
|
},
|
|
aborted.signal,
|
|
);
|
|
},
|
|
async abort() {
|
|
watched.aborted += 1;
|
|
aborted.abort();
|
|
},
|
|
dispose() {},
|
|
} as unknown as AgentSession;
|
|
|
|
return {
|
|
session,
|
|
modelId: options.modelId ?? MODELS[0]!.id,
|
|
systemPrompt: 'You are Piggy.',
|
|
dispose: () => aborted.abort(),
|
|
} satisfies PiggySession;
|
|
};
|
|
}
|
|
|
|
function textDelta(delta: string): AgentSessionEvent {
|
|
return {
|
|
type: 'message_update',
|
|
message: { role: 'assistant' },
|
|
assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta },
|
|
} as unknown as AgentSessionEvent;
|
|
}
|
|
|
|
function turnEnd(input: number, output: number, stopReason = 'stop'): AgentSessionEvent {
|
|
return {
|
|
type: 'turn_end',
|
|
message: { role: 'assistant', usage: { input, output }, stopReason },
|
|
toolResults: [],
|
|
} as unknown as AgentSessionEvent;
|
|
}
|
|
|
|
/** A model call the endpoint refused. This is what a 429 looks like from here. */
|
|
function failedTurn(errorMessage: string): AgentSessionEvent {
|
|
return {
|
|
type: 'turn_end',
|
|
message: { role: 'assistant', usage: { input: 0, output: 0 }, stopReason: 'error', errorMessage },
|
|
toolResults: [],
|
|
} as unknown as AgentSessionEvent;
|
|
}
|
|
|
|
/** The harness announcing that it is about to restart the turn. */
|
|
function retryStart(errorMessage: string, attempt = 1): AgentSessionEvent {
|
|
return {
|
|
type: 'auto_retry_start',
|
|
attempt,
|
|
maxAttempts: 1,
|
|
delayMs: 1_500,
|
|
errorMessage,
|
|
} as unknown as AgentSessionEvent;
|
|
}
|
|
|
|
/** Silence, until somebody tells the turn to stop. A harness that unwinds. */
|
|
const untilAborted: TurnScript = (_tools, _emit, signal) =>
|
|
new Promise<void>((resolve) => {
|
|
if (signal.aborted) {
|
|
resolve();
|
|
return;
|
|
}
|
|
signal.addEventListener('abort', () => resolve(), { once: true });
|
|
});
|
|
|
|
function stallLimits(overrides: Partial<PiggyStallLimits> = {}): PiggyStallLimits {
|
|
return { firstProgressMs: 5_000, idleMs: 5_000, ...overrides };
|
|
}
|
|
|
|
async function startForTest(
|
|
t: { after: (fn: () => void) => void },
|
|
runs: RecordedRun[],
|
|
options: Partial<PiggyChatServerOptions>,
|
|
): Promise<string> {
|
|
const server = startPiggyChatServer(fakeDatabase(runs), {
|
|
port: 0,
|
|
internalToken: TOKEN,
|
|
models: MODELS,
|
|
createReadTools: () => [],
|
|
createWriteTools: () => [],
|
|
limits: { maxModelCalls: 8, maxTurnTokens: 40_000, dailyLimitCents: 0 },
|
|
stallLimits: stallLimits(),
|
|
...options,
|
|
});
|
|
t.after(() => server.close());
|
|
await new Promise((resolve) => server.once('listening', resolve));
|
|
const { port } = server.address() as AddressInfo;
|
|
return `http://127.0.0.1:${port}`;
|
|
}
|
|
|
|
const PRINCIPAL = {
|
|
userId: '20000000-0000-4000-8000-000000000001',
|
|
email: 'ada@primeintellect.example',
|
|
name: 'Ada',
|
|
isPlatformAdmin: false,
|
|
teams: [{ team: 'supply', role: 'lead' }],
|
|
via: 'jwt',
|
|
scopes: ['read', 'write'],
|
|
};
|
|
|
|
const authorised = { authorization: `Bearer ${TOKEN}`, 'content-type': 'application/json' };
|
|
|
|
function chatBody(overrides: Record<string, unknown> = {}): string {
|
|
return JSON.stringify({
|
|
principal: PRINCIPAL,
|
|
message: 'What is idle costing us?',
|
|
mode: 'read_only',
|
|
conversationId: 'conv-retry',
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
async function turnFrames(base: string, body = chatBody()): Promise<PiggyChatEvent[]> {
|
|
const response = await fetch(`${base}/internal/chat`, { method: 'POST', headers: authorised, body });
|
|
return (await response.text())
|
|
.trim()
|
|
.split('\n')
|
|
.filter((line) => line.length > 0)
|
|
.map((line) => JSON.parse(line) as PiggyChatEvent);
|
|
}
|
|
|
|
function errorFrame(frames: PiggyChatEvent[]): { message: string; code?: string } | null {
|
|
const frame = frames.at(-1);
|
|
return frame?.type === 'error'
|
|
? { message: frame.message, ...(frame.code ? { code: frame.code } : {}) }
|
|
: null;
|
|
}
|
|
|
|
function answerText(frames: PiggyChatEvent[]): string {
|
|
return frames
|
|
.filter((frame): frame is Extract<PiggyChatEvent, { type: 'content_delta' }> => frame.type === 'content_delta')
|
|
.map((frame) => frame.delta)
|
|
.join('');
|
|
}
|
|
|
|
function inference(closed: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
|
|
return (closed?.result as { inference?: Record<string, unknown> } | undefined)?.inference;
|
|
}
|
|
|
|
// ------------------------------------------------- the turn that recovered anyway
|
|
|
|
test('a turn the harness retried and finished is reported as finished', async (t) => {
|
|
const runs: RecordedRun[] = [];
|
|
const watched: SessionSpy = { aborted: 0 };
|
|
const base = await startForTest(t, runs, {
|
|
createSession: sessions(async (_tools, emit) => {
|
|
// The 429 arrives before a byte of the answer, which is the ordinary
|
|
// shape of one: the endpoint refuses the request rather than dropping a
|
|
// response half way through.
|
|
emit(failedTurn(RATE_LIMIT_ERROR));
|
|
emit(retryStart(RATE_LIMIT_ERROR));
|
|
emit(textDelta('Idle is $12,000.'));
|
|
emit(turnEnd(1_240, 180));
|
|
}, watched),
|
|
});
|
|
|
|
const frames = await turnFrames(base);
|
|
|
|
// The whole of the visible bug: this used to end in an error frame with the
|
|
// 429 in the ledger, after the reader had already been given the answer.
|
|
assert.deepEqual(
|
|
frames.map((frame) => frame.type),
|
|
['meta', 'content_delta', 'done'],
|
|
);
|
|
assert.equal(answerText(frames), 'Idle is $12,000.');
|
|
assert.equal(watched.aborted, 0, 'a turn that was recovering was stopped');
|
|
|
|
const closed = runs[0]?.closed;
|
|
assert.equal(closed?.status, 'succeeded');
|
|
assert.equal(closed?.error, null);
|
|
// And an operator can still see that it cost two goes, which is the trend
|
|
// they are watching even when every turn eventually answers.
|
|
assert.equal(inference(closed)?.attempts, 2);
|
|
assert.match(String(inference(closed)?.retryReason), /Rate limit reached/);
|
|
});
|
|
|
|
test('a healthy turn records one attempt rather than none', async (t) => {
|
|
const runs: RecordedRun[] = [];
|
|
const base = await startForTest(t, runs, {
|
|
createSession: sessions(async (_tools, emit) => {
|
|
emit(textDelta('Idle is $12,000.'));
|
|
emit(turnEnd(1_240, 180));
|
|
}, { aborted: 0 }),
|
|
});
|
|
|
|
const frames = await turnFrames(base);
|
|
|
|
assert.equal(frames.at(-1)?.type, 'done');
|
|
// Written on every turn, not only the failed ones: a day where every turn
|
|
// needed two attempts and succeeded must not look like a day where none did.
|
|
assert.equal(inference(runs[0]?.closed)?.attempts, 1);
|
|
assert.equal(inference(runs[0]?.closed)?.retryReason, undefined);
|
|
});
|
|
|
|
// --------------------------------------------------- when the retries run out
|
|
|
|
test('an exhausted rate limit is its own code, and says what to do about it', async (t) => {
|
|
const runs: RecordedRun[] = [];
|
|
const base = await startForTest(t, runs, {
|
|
createSession: sessions(async (_tools, emit) => {
|
|
emit(failedTurn(RATE_LIMIT_ERROR));
|
|
emit(retryStart(RATE_LIMIT_ERROR));
|
|
emit(failedTurn(RATE_LIMIT_ERROR));
|
|
}, { aborted: 0 }),
|
|
});
|
|
|
|
const frames = await turnFrames(base);
|
|
|
|
// Distinct from `inference_failed`, because it wants a different response:
|
|
// waiting ten seconds genuinely fixes it, and it is not worth a pager.
|
|
assert.equal(errorFrame(frames)?.code, 'inference_rate_limited');
|
|
assert.match(String(errorFrame(frames)?.message), /rate limiting us/);
|
|
assert.match(String(errorFrame(frames)?.message), /2 times/);
|
|
assert.match(String(errorFrame(frames)?.message), /ask again/i);
|
|
assert.equal(answerText(frames), '');
|
|
|
|
const closed = runs[0]?.closed;
|
|
assert.equal(closed?.status, 'failed');
|
|
// The ledger keeps the upstream body; the browser is never shown it.
|
|
assert.match(String(closed?.error), /rate_limit_exceeded/);
|
|
assert.match(String(closed?.error), /2 attempts/);
|
|
assert.equal(inference(closed)?.attempts, 2);
|
|
});
|
|
|
|
test('a fault that is not a rate limit keeps the generic code', async (t) => {
|
|
const runs: RecordedRun[] = [];
|
|
const base = await startForTest(t, runs, {
|
|
createSession: sessions(async (_tools, emit) => {
|
|
emit(failedTurn('502: {"message":"upstream connect error"}'));
|
|
}, { aborted: 0 }),
|
|
});
|
|
|
|
const frames = await turnFrames(base);
|
|
|
|
// Somebody should look at this one, so it must not wear the name of the fault
|
|
// that fixes itself.
|
|
assert.equal(errorFrame(frames)?.code, 'inference_failed');
|
|
assert.equal(errorFrame(frames)?.message, 'Piggy could not finish this answer.');
|
|
assert.equal(inference(runs[0]?.closed)?.attempts, 1);
|
|
});
|
|
|
|
// ------------------------------------------------- what a retry may never replay
|
|
|
|
test('a retry that would repeat a delivered answer is refused', async (t) => {
|
|
const runs: RecordedRun[] = [];
|
|
const watched: SessionSpy = { aborted: 0 };
|
|
const base = await startForTest(t, runs, {
|
|
createSession: sessions(async (tools, emit, signal) => {
|
|
// Measured against a stubbed endpoint: the harness's session-level retry
|
|
// discards the errored assistant message and generates a replacement, so
|
|
// a turn that had streamed "Idle is " came back as
|
|
// "Idle is Idle is $12,000." in the transcript.
|
|
emit(textDelta('Idle is '));
|
|
emit(failedTurn(RATE_LIMIT_ERROR));
|
|
emit(retryStart(RATE_LIMIT_ERROR));
|
|
// And this script does not stop when it is told to, which is the nastier
|
|
// shape of the same fault and the one the stall guard already assumes: a
|
|
// harness that ignores the abort would stream the replacement answer over
|
|
// the top of the half the reader already has. Neither the abort nor the
|
|
// suppression is sufficient on its own.
|
|
await untilAborted(tools, emit, signal);
|
|
emit(textDelta('Idle is $12,000.'));
|
|
emit(turnEnd(1_240, 180));
|
|
}, watched),
|
|
});
|
|
|
|
const frames = await turnFrames(base);
|
|
|
|
assert.equal(answerText(frames), 'Idle is ', 'the reader was shown the answer twice');
|
|
assert.equal(watched.aborted, 1, 'the replay was allowed to proceed');
|
|
assert.equal(errorFrame(frames)?.code, 'inference_rate_limited');
|
|
assert.match(String(errorFrame(frames)?.message), /incomplete/);
|
|
assert.match(String(errorFrame(frames)?.message), /already been shown/);
|
|
assert.equal(
|
|
frames.some((frame) => frame.type === 'done'),
|
|
false,
|
|
'an incomplete answer must not also report itself finished',
|
|
);
|
|
|
|
const closed = runs[0]?.closed;
|
|
assert.equal(closed?.status, 'failed');
|
|
assert.equal(closed?.summary, 'Idle is');
|
|
assert.match(String(closed?.error), /retry refused/);
|
|
assert.equal(inference(closed)?.attempts, 2);
|
|
});
|
|
|
|
test('a retry before anything has been delivered is left alone', async (t) => {
|
|
const runs: RecordedRun[] = [];
|
|
const watched: SessionSpy = { aborted: 0 };
|
|
const base = await startForTest(t, runs, {
|
|
createSession: sessions(async (_tools, emit) => {
|
|
// A tool ran, so the turn is not untouched — but nothing has reached the
|
|
// reader's transcript, so there is nothing to say twice. Stopping here
|
|
// would throw away a recoverable turn for no gain.
|
|
emit({
|
|
type: 'tool_execution_start',
|
|
toolCallId: 'call_1',
|
|
toolName: 'pig_get_idle_capacity',
|
|
args: {},
|
|
} as unknown as AgentSessionEvent);
|
|
emit(failedTurn(RATE_LIMIT_ERROR));
|
|
emit(retryStart(RATE_LIMIT_ERROR));
|
|
emit(textDelta('Idle is $12,000.'));
|
|
emit(turnEnd(1_240, 180));
|
|
}, watched),
|
|
});
|
|
|
|
const frames = await turnFrames(base);
|
|
|
|
assert.equal(watched.aborted, 0, 'a safe retry was refused');
|
|
assert.equal(frames.at(-1)?.type, 'done');
|
|
assert.equal(answerText(frames), 'Idle is $12,000.');
|
|
assert.equal(runs[0]?.closed?.status, 'succeeded');
|
|
});
|
|
|
|
// -------------------------------------------- the guards that outrank the retry
|
|
|
|
test('the stall watchdog outranks a pending retry', async (t) => {
|
|
const runs: RecordedRun[] = [];
|
|
const watched: SessionSpy = { aborted: 0 };
|
|
const base = await startForTest(t, runs, {
|
|
stallLimits: stallLimits({ firstProgressMs: 30_000, idleMs: 120 }),
|
|
createSession: sessions(async (tools, emit, signal) => {
|
|
emit(failedTurn(RATE_LIMIT_ERROR));
|
|
emit(retryStart(RATE_LIMIT_ERROR));
|
|
// The retry was announced and then nothing ever happened, which is the
|
|
// shape of a backoff into an endpoint that has stopped answering
|
|
// altogether. A retry loop that could outlive the watchdog would hang the
|
|
// browser exactly the way the missing deadline used to.
|
|
await untilAborted(tools, emit, signal);
|
|
}, watched),
|
|
});
|
|
|
|
const frames = await turnFrames(base);
|
|
|
|
assert.equal(errorFrame(frames)?.code, 'inference_stalled');
|
|
assert.equal(watched.aborted, 1);
|
|
const closed = runs[0]?.closed;
|
|
assert.match(String(closed?.error), /idle deadline/);
|
|
// The attempt count is still recorded: the turn really did try twice before
|
|
// the silence, and that is what an operator is counting.
|
|
assert.equal(inference(closed)?.attempts, 2);
|
|
});
|
|
|
|
test('the turn ceiling outranks a pending retry', async (t) => {
|
|
const runs: RecordedRun[] = [];
|
|
const watched: SessionSpy = { aborted: 0 };
|
|
const base = await startForTest(t, runs, {
|
|
limits: { maxModelCalls: 2, maxTurnTokens: 40_000, dailyLimitCents: 0 },
|
|
createSession: sessions(async (tools, emit, signal) => {
|
|
emit(turnEnd(1_000, 100, 'toolUse'));
|
|
emit(failedTurn(RATE_LIMIT_ERROR));
|
|
emit(retryStart(RATE_LIMIT_ERROR));
|
|
await untilAborted(tools, emit, signal);
|
|
}, watched),
|
|
});
|
|
|
|
const frames = await turnFrames(base);
|
|
|
|
// A retry that resurrected a turn already stopped for cost would spend money
|
|
// the ceiling exists to refuse.
|
|
assert.equal(errorFrame(frames)?.code, 'turn_limit_exceeded');
|
|
assert.equal(runs[0]?.closed?.status, 'aborted');
|
|
});
|