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>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* That a write Piggy made can be told from one a person typed.
|
||||
*
|
||||
* The product's safety argument is that nothing lands until a human presses
|
||||
* Apply. That argument is only checkable after the fact if the record surfaces
|
||||
* can say which rows came from the agent — and until this landed they could
|
||||
* not: an approved write read as hand-typed in the account timeline while the
|
||||
* seeded row beneath it said "· by piggy".
|
||||
*
|
||||
* Piggy signs a row in two places and the payload has to answer for both, so
|
||||
* both are asserted here, along with the two rows that must NOT be claimed:
|
||||
* a person's own entry, and a Slack sync that also carries an external id.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
PIGGY_EXTERNAL_ID_PREFIX,
|
||||
toActivityPayload,
|
||||
type ActivityRow,
|
||||
} from '../src/lib/activity-payload';
|
||||
import { runMode } from '../src/services/piggy-activity';
|
||||
|
||||
function activity(overrides: Partial<ActivityRow> = {}): ActivityRow {
|
||||
return {
|
||||
id: '60000000-0000-4000-8000-000000000001',
|
||||
type: 'call',
|
||||
subject: 'Call with DEMO — Northwind Robotics about extending the H200 block',
|
||||
body: null,
|
||||
accountId: '10000000-0000-4000-8000-00000000000a',
|
||||
contactId: null,
|
||||
demandDealId: null,
|
||||
supplyDealId: null,
|
||||
actorAgent: null,
|
||||
externalId: null,
|
||||
meta: null,
|
||||
occurredAt: new Date('2026-08-13T09:00:00.000Z'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('an activity Piggy logged is attributed to Piggy', () => {
|
||||
// `pig_log_activity`'s row is its own audit event, so the provenance rides on
|
||||
// the external id — which is also what stops a retried tool call logging the
|
||||
// same conversation twice.
|
||||
const payload = toActivityPayload(
|
||||
activity({ externalId: `${PIGGY_EXTERNAL_ID_PREFIX}d016db18-a6eb-4857-9cb5-cff3d58c78d0` }),
|
||||
);
|
||||
assert.equal(payload.actorAgent, 'piggy');
|
||||
// Still on the wire, because a record surface may want to draw the row Piggy
|
||||
// logged differently from the audit of a record Piggy changed.
|
||||
assert.ok(payload.externalId?.startsWith(PIGGY_EXTERNAL_ID_PREFIX));
|
||||
});
|
||||
|
||||
test('the audit of a record Piggy changed is attributed to Piggy', () => {
|
||||
// Every write tool other than `pig_log_activity` stamps the audit row's meta
|
||||
// instead, because the mutation convention writes that row, not the tool.
|
||||
const payload = toActivityPayload(activity({ meta: { actorAgent: 'piggy', piggyTool: 'pig_update_deal_stage' } }));
|
||||
assert.equal(payload.actorAgent, 'piggy');
|
||||
});
|
||||
|
||||
test('a person’s own entry claims no agent', () => {
|
||||
assert.equal(toActivityPayload(activity()).actorAgent, null);
|
||||
});
|
||||
|
||||
test('a synced entry is not mistaken for Piggy’s', () => {
|
||||
// Slack and Buzz carry external ids too. Attributing their rows to the agent
|
||||
// would put words in Piggy's mouth on the surface people audit it from.
|
||||
const payload = toActivityPayload(activity({ externalId: 'slack:C09QT/1755082800.123' }));
|
||||
assert.equal(payload.actorAgent, null);
|
||||
});
|
||||
|
||||
test('a stored agent stamp still wins', () => {
|
||||
// An API key really did authenticate as an agent; the derivation must not
|
||||
// overwrite what the column already recorded.
|
||||
assert.equal(toActivityPayload(activity({ actorAgent: 'agent' })).actorAgent, 'agent');
|
||||
});
|
||||
|
||||
test('meta that is not an object cannot break the timeline', () => {
|
||||
// `meta` is free-form JSON written by every mutation in the product.
|
||||
const hostile = { meta: ['piggy'] as unknown as Record<string, unknown> };
|
||||
assert.equal(toActivityPayload(activity(hostile)).actorAgent, null);
|
||||
});
|
||||
|
||||
test('the payload carries no internal blob', () => {
|
||||
const payload = toActivityPayload(activity({ meta: { slackPermalink: 'https://…' } }));
|
||||
assert.equal('meta' in payload, false);
|
||||
assert.equal(payload.occurredAt, '2026-08-13T09:00:00.000Z');
|
||||
});
|
||||
|
||||
test('a run reports the mode it was allowed to run in', () => {
|
||||
assert.equal(runMode({ surface: 'chat', mode: 'auto' }), 'auto');
|
||||
assert.equal(runMode({ surface: 'chat', mode: 'read_only' }), 'read_only');
|
||||
});
|
||||
|
||||
test('a run that recorded no mode reports none, rather than the safe one', () => {
|
||||
// A queued task has no mode, and neither do the chat turns written before the
|
||||
// relay stamped it. Defaulting those to `read_only` would put a claim in the
|
||||
// ledger that nobody made.
|
||||
assert.equal(runMode(null), null);
|
||||
assert.equal(runMode({ surface: 'chat' }), null);
|
||||
assert.equal(runMode({ mode: 'yolo' }), null);
|
||||
assert.equal(runMode({ mode: 42 }), null);
|
||||
});
|
||||
Reference in New Issue
Block a user