Put Piggy on Prime Agent, and let it write to the book
CI / verify (push) Successful in 7m6s
CI / publish (push) Has been skipped

Piggy was a hand-rolled OpenAI tool loop. It is now a Prime Agent session —
Prime Intellect's own harness, embedded as a Node library — answering from
PIG's tools and, for the first time, able to put information into the CRM
rather than only read it out.

The harness is a coding agent, so the first job was taking the coding agent
away from it. `noTools: 'all'` plus an explicit allowlist leaves the model
with PIG's ten `pig_*` tools and no bash, no filesystem, no IPython. That
holds under attack: a hostile extension, a skill and a settings file planted
in the agent's own directory, then `setActiveToolsByName` called with every
built-in, still leaves ten tools, all ours. Both lines are load-bearing —
`noTools` alone registers nothing, and the allowlist is what admits our own.

Writing is gated rather than assumed. A change is proposed, not made: the
tool returns a description, the transcript renders a diff card, and nothing
reaches the database until someone presses Apply. Contracts, commitments,
allocations and compliance always stop for a human whatever the mode. Every
write runs through `executeMutation` as the calling user, so their
capabilities and the audit trail apply exactly as they would to a human's.

Four things about the SDK are wrong in its own documentation and cost a
debugging cycle each: models.json does not resolve an env var name for
`apiKey`, it sends the literal string; there is no built-in prime-inference
provider in 0.84.1; a ResourceLoader you pass in is never reloaded for you;
and the stock system prompt is a coding-assistant prompt that must be
replaced — but replacing it also silently removes the tool list, because the
harness only renders that section when it owns the prompt. AGENTS.md records
all four.

The expensive one was thinking level. The harness defaults to `medium`, and
nemotron spent an entire 4,096-token budget reasoning and returned an empty
answer. `low` was worse; `off` omits the parameter so the endpoint's default
wins. An explicit `reasoning_effort: none` via `thinkingLevelMap` took a turn
from 6,195 output tokens to 149.

And a turn is now bounded. The harness loop is `while (true)` with no
iteration cap; a runaway on a frontier model would have eaten the credit it
is supposed to report on. Ceilings on model calls and tokens, enforced both
through the harness hook and independently from the event stream, plus a
per-user daily spend limit — and the ledger now records spend on turns that
fail, which it previously discarded.

Signing in lands on /piggy, which is a workspace: conversations down one
side, the agent in the middle, what it did and what it cost beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude
2026-08-14 05:26:28 -07:00
parent 99d165b5e5
commit f0173440e4
77 changed files with 28108 additions and 1672 deletions
+338
View File
@@ -0,0 +1,338 @@
/**
* The approval rendezvous, end to end, against a real database.
*
* `test/chat-server.test.ts` proves the choreography — card raised, decision
* posted, single-use, deadlined, cancelled on abandonment — with a write tool
* that only pretends to write. `test/write-tools.test.ts` proves the write tools
* never open a transaction for a change nobody agreed to. Neither can prove the
* sentence the whole feature rests on, which is what a user reads on the card:
*
* "Decline this and nothing changes."
*
* That is a claim about Postgres, made across two HTTP requests and a promise
* parked in the middle of a turn. So this suite wires the real chat server to the
* real `createPigWriteTools` against a real database, declines a real proposal
* over `/internal/approve`, and then goes and looks at the rows. The applied case
* runs the identical call to the same endpoint so that "untouched" means
* something: the same request, answered the other way, does move the deal.
*
* No inference is involved and no key is needed — the harness is a fake that
* drives the tool the way Prime Agent drives it, signal and all. What is real is
* everything PIG owns.
*
* docker exec pig-ux-db psql -U pig -d postgres -c "CREATE DATABASE pig_c3_scratch"
* DATABASE_URL=postgres://pig:pig@localhost:54330/pig_c3_scratch pnpm -F @pig/db run migrate
* PIGGY_WRITE_DATABASE_URL=postgres://pig:pig@localhost:54330/pig_c3_scratch \
* pnpm -F @pig/piggy run test:e2e
*/
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import type { AddressInfo } from 'node:net';
import test, { after, before } from 'node:test';
import type { AgentSession, AgentSessionEvent, ToolDefinition } from '@earendil-works/pi-coding-agent';
import type { PiggyChatEvent } from '@pig/core';
import {
accounts,
activities,
agentRuns,
createDatabase,
demandDeals,
users,
type Database,
} from '@pig/db';
import { and, eq } from 'drizzle-orm';
import { startPiggyChatServer, type PiggySessionFactory } from '../src/chat-server';
const databaseUrl = process.env.PIGGY_WRITE_DATABASE_URL;
if (!databaseUrl) {
test.skip('the approval rendezvous E2E needs PIGGY_WRITE_DATABASE_URL pointing at a scratch database');
}
if (databaseUrl?.includes('pig_combined')) {
throw new Error('The approval rendezvous E2E must never run against the development book.');
}
const TOKEN = 'test-internal-token-for-piggy-0000000';
const db: Database = createDatabase({ url: databaseUrl ?? 'postgres://unused', max: 2 });
const marker = `PIGGY-C3-${randomUUID()}`;
const fixture = { userId: '', accountId: '', dealId: '' };
let base = '';
function principal(): Record<string, unknown> {
return {
userId: fixture.userId,
email: `${marker}@example.test`,
name: 'Dana Okonjo',
isPlatformAdmin: false,
teams: [{ team: 'demand', role: 'member' }],
via: 'jwt',
scopes: ['read', 'write'],
};
}
/**
* The harness, reduced to what it does around a tool call.
*
* It hands the tool the abort signal — which is what lets a tool parked on an
* approval discover that the reader has gone — and turns its result into the two
* events the chat server translates.
*/
function fakeSessions(toolName: string, params: Record<string, unknown>): PiggySessionFactory {
return async (options) => {
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() {
const emit = (event: AgentSessionEvent): void => {
for (const listener of [...listeners]) listener(event);
};
const tool = options.tools.find((candidate) => candidate.name === toolName);
assert.ok(tool, `${toolName} was not handed to the session`);
emit({ type: 'tool_execution_start', toolCallId: 'call_1', toolName, args: params } as
unknown as AgentSessionEvent);
const result = await tool.execute(
'call_1',
params,
aborted.signal,
undefined,
undefined as never,
);
emit({
type: 'tool_execution_end',
toolCallId: 'call_1',
toolName,
result,
isError: false,
} as unknown as AgentSessionEvent);
emit({
type: 'turn_end',
message: { role: 'assistant', usage: { input: 120, output: 30 }, stopReason: 'stop' },
toolResults: [],
} as unknown as AgentSessionEvent);
},
async abort() {},
dispose() {},
} as unknown as AgentSession;
return {
session,
modelId: options.modelId ?? 'nvidia/nemotron-3-nano-30b-a3b',
systemPrompt: 'You are Piggy.',
dispose: () => aborted.abort(),
};
};
}
interface StreamReader {
frames: PiggyChatEvent[];
rest(): Promise<PiggyChatEvent[]>;
}
/** Reads up to the approval card, then hands back a reader for the remainder. */
async function readUntilApproval(response: Response): Promise<StreamReader> {
const body = response.body;
assert.ok(body, 'the turn should have streamed a body');
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const drain = (chunk: Uint8Array | undefined, into: PiggyChatEvent[]): void => {
buffer += decoder.decode(chunk, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) if (line) into.push(JSON.parse(line) as PiggyChatEvent);
};
const frames: PiggyChatEvent[] = [];
while (!frames.some((frame) => frame.type === 'approval_required')) {
const { done, value } = await reader.read();
if (done) break;
drain(value, frames);
}
return {
frames,
rest: async () => {
const tail: PiggyChatEvent[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
drain(value, tail);
}
return tail;
},
};
}
/** One turn, up to the card. The decision is posted while it is still open. */
async function proposeStageChange(stage: string): Promise<StreamReader> {
const response = await fetch(`${base}/internal/chat`, {
method: 'POST',
headers: { authorization: `Bearer ${TOKEN}`, 'content-type': 'application/json' },
body: JSON.stringify({
principal: principal(),
message: `Move the deal to ${stage}.`,
mode: 'confirm',
conversationId: `conv-${stage}`,
}),
});
assert.equal(response.status, 200);
return readUntilApproval(response);
}
async function decide(
conversationId: string,
changeId: string,
decision: 'apply' | 'reject',
): Promise<number> {
const response = await fetch(`${base}/internal/approve`, {
method: 'POST',
headers: { authorization: `Bearer ${TOKEN}`, 'content-type': 'application/json' },
body: JSON.stringify({ conversationId, changeId, decision }),
});
return response.status;
}
function askedChangeId(reader: StreamReader): string {
const asked = reader.frames.find((frame) => frame.type === 'approval_required');
assert.ok(asked && asked.type === 'approval_required', 'no approval card was raised');
// The card a person reads must name the record and the movement, or approving
// it is a click on a uuid.
assert.match(asked.change.summary, /Northwind/);
return asked.change.id;
}
let server: ReturnType<typeof startPiggyChatServer> | undefined;
before(async () => {
if (!databaseUrl) return;
const [user] = await db
.insert(users)
.values({ email: `${marker}@example.test`, name: 'Dana Okonjo', authSubject: randomUUID() })
.returning({ id: users.id });
assert.ok(user);
fixture.userId = user.id;
const [account] = await db
.insert(accounts)
.values({ name: `${marker} Northwind Robotics`, side: 'demand' })
.returning({ id: accounts.id });
assert.ok(account);
fixture.accountId = account.id;
const [deal] = await db
.insert(demandDeals)
.values({ accountId: account.id, name: `${marker} Northwind H200`, stage: 'proposal' })
.returning({ id: demandDeals.id });
assert.ok(deal);
fixture.dealId = deal.id;
});
after(async () => {
server?.close();
if (!databaseUrl) return;
// The run rows only null their user out on delete, so they are cleared by
// hand; everything else cascades from the account.
if (fixture.userId) await db.delete(agentRuns).where(eq(agentRuns.principalUserId, fixture.userId));
if (fixture.accountId) await db.delete(accounts).where(eq(accounts.id, fixture.accountId));
if (fixture.userId) await db.delete(users).where(eq(users.id, fixture.userId));
await db.$client.end({ timeout: 5 });
});
function start(stage: string): void {
server?.close();
server = startPiggyChatServer(db, {
port: 0,
internalToken: TOKEN,
// The real write tools, against the real database, as the real caller.
createReadTools: () => [] as ToolDefinition[],
createSession: fakeSessions('pig_update_deal_stage', {
dealType: 'demand',
dealId: fixture.dealId,
stage,
reason: 'Legal cleared the MSA this morning.',
}),
});
}
async function listen(): Promise<void> {
assert.ok(server);
await new Promise((resolve) => server?.once('listening', resolve));
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
}
test('a declined proposal leaves the book exactly as it was', { skip: !databaseUrl }, async () => {
start('procurement');
await listen();
const [before] = await db.select().from(demandDeals).where(eq(demandDeals.id, fixture.dealId));
const auditBefore = await db
.select()
.from(activities)
.where(eq(activities.demandDealId, fixture.dealId));
const reader = await proposeStageChange('procurement');
const changeId = askedChangeId(reader);
// Still nothing written: the turn is parked on a promise, mid-tool-call.
const [during] = await db.select().from(demandDeals).where(eq(demandDeals.id, fixture.dealId));
assert.equal(during?.stage, before?.stage, 'the deal moved while the card was still on screen');
assert.equal(await decide('conv-procurement', changeId, 'reject'), 202);
const tail = await reader.rest();
const [after] = await db.select().from(demandDeals).where(eq(demandDeals.id, fixture.dealId));
assert.equal(after?.stage, 'proposal', 'a declined change moved the deal anyway');
assert.equal(after?.updatedAt?.getTime(), before?.updatedAt?.getTime(), 'the row was touched');
const auditAfter = await db
.select()
.from(activities)
.where(eq(activities.demandDealId, fixture.dealId));
assert.equal(auditAfter.length, auditBefore.length, 'a declined change wrote an audit row');
// And the model is told the truth, in the tool result it will summarise from.
const result = tail.find((frame) => frame.type === 'tool_result');
assert.ok(result && result.type === 'tool_result');
assert.equal(result.ok, true, 'a decline is an answer, not a tool failure');
assert.deepEqual(result.result, {
tool: 'pig_update_deal_stage',
kind: 'deal',
status: 'declined',
reason: 'declined by the user',
});
const settled = tail.find((frame) => frame.type === 'approval_resolved');
assert.equal(settled?.type === 'approval_resolved' ? settled.decision : null, 'reject');
});
test('the same call, approved, does move the deal', { skip: !databaseUrl }, async () => {
start('deployment');
await listen();
const reader = await proposeStageChange('deployment');
const changeId = askedChangeId(reader);
assert.equal(await decide('conv-deployment', changeId, 'apply'), 202);
const tail = await reader.rest();
const [after] = await db.select().from(demandDeals).where(eq(demandDeals.id, fixture.dealId));
assert.equal(after?.stage, 'deployment');
const [audit] = await db
.select()
.from(activities)
.where(and(eq(activities.demandDealId, fixture.dealId), eq(activities.type, 'stage_change')));
assert.ok(audit, 'the applied write left the audit row the mutation convention writes');
assert.equal(audit.actorUserId, fixture.userId, 'written as the caller, never as Piggy itself');
assert.equal(audit.meta?.actorAgent, 'piggy');
const result = tail.find((frame) => frame.type === 'tool_result');
assert.equal(
result?.type === 'tool_result' && (result.result as { status?: string }).status,
'applied',
);
// Answering again cannot apply it twice: the id was consumed when it settled.
assert.equal(await decide('conv-deployment', changeId, 'apply'), 404);
const [unchanged] = await db.select().from(demandDeals).where(eq(demandDeals.id, fixture.dealId));
assert.equal(unchanged?.stage, 'deployment');
});
+143
View File
@@ -0,0 +1,143 @@
/**
* One real turn against Prime Inference, to pin the thing money bought.
*
* Everything in `test/` runs offline, and everything in `test/` would have
* passed on the day Piggy answered every question with an empty string: the
* harness defaulted `thinkingLevel` to `medium`, the default model spent 6,195
* output tokens reasoning, hit `finish_reason: length`, and returned nothing.
* The configuration was valid, the tools were correct, the types checked. The
* only way to see it is to ask a model a question and count the tokens.
*
* So this suite does exactly that, once, on the cheapest model in the
* catalogue, and asserts the three properties that failure violated:
*
* - the answer is not empty, and was not cut off by the budget;
* - the reasoning did not eat the turn (149 output tokens was the measurement
* after the fix, against 6,195 before it);
* - the tool was actually called, rather than the figures being invented.
*
* It is opt-in twice over — a key AND `PIGGY_E2E_LIVE=1` — because a suite that
* spends money whenever the environment happens to be loaded is a suite that
* spends money by accident. A turn costs about $0.0003.
*
* PIGGY_E2E_LIVE=1 PRIME_API_KEY=... pnpm -F @pig/piggy run test:e2e
*/
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test, { after, before } from 'node:test';
import { defineTool, type AgentSessionEvent } from '@earendil-works/pi-coding-agent';
import { Type } from 'typebox';
const live = process.env.PIGGY_E2E_LIVE === '1' && Boolean(process.env.PRIME_API_KEY);
if (!live) {
test.skip('the live Prime Agent E2E needs PIGGY_E2E_LIVE=1 and PRIME_API_KEY; it spends credit');
}
const agentDir = mkdtempSync(join(tmpdir(), 'piggy-live-e2e-'));
before(() => {
// The session only needs the key; these two are required by the config schema
// and are never read on this path.
process.env.DATABASE_URL ??= 'postgres://pig:pig@localhost:54330/pig';
process.env.PIGGY_INTERNAL_TOKEN ??= 'test-internal-token-for-piggy-000000';
process.env.PIGGY_AGENT_DIR = agentDir;
});
after(() => {
rmSync(agentDir, { recursive: true, force: true });
});
/**
* The figures are the two that were misread in production.
*
* 189 has to be spoken as $1.89 and 112 as $1.12 — the units rule in the system
* prompt exists because a small model says "$189 per GPU-hour" and "112 cents"
* otherwise, and both readings are confidently, catastrophically wrong.
*/
const SUMMARY = {
headline: 'Northwind Robotics H100 block, 38% sold',
committedGpuHours: 52_000,
allocatedGpuHours: 19_760,
utilisation: 0.38,
costPerGpuHourCents: 189,
breakEvenPriceCents: 112,
idleCostCents: 1_200_000,
};
/** Usage off a `turn_end` message, without widening anything to `any`. */
function outputTokens(event: AgentSessionEvent): number {
if (event.type !== 'turn_end') return 0;
const message: unknown = event.message;
if (typeof message !== 'object' || message === null) return 0;
const usage = (message as { usage?: { output?: unknown } }).usage;
return typeof usage?.output === 'number' ? usage.output : 0;
}
function stopReason(event: AgentSessionEvent): string | undefined {
if (event.type !== 'turn_end') return undefined;
const message: unknown = event.message;
if (typeof message !== 'object' || message === null) return undefined;
const reason = (message as { stopReason?: unknown }).stopReason;
return typeof reason === 'string' ? reason : undefined;
}
test('a real turn answers, calls its tool, and does not think itself out of a reply', { skip: !live }, async () => {
const { createPiggySession } = await import('../src/agent/session');
let toolCalls = 0;
const tool = defineTool({
name: 'pig_get_workspace_summary',
label: 'Workspace summary',
description: 'Returns the workspace-wide capacity aggregates, already computed.',
promptSnippet: 'Workspace-wide capacity aggregates, already computed',
parameters: Type.Object({}),
async execute() {
toolCalls += 1;
return {
content: [{ type: 'text' as const, text: JSON.stringify(SUMMARY) }],
details: {},
};
},
});
const piggy = await createPiggySession({ mode: 'read_only', tools: [tool] });
let answer = '';
let spent = 0;
let finish: string | undefined;
const unsubscribe = piggy.session.subscribe((event) => {
if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') {
answer += event.assistantMessageEvent.delta;
}
spent += outputTokens(event);
finish = stopReason(event) ?? finish;
});
try {
await piggy.session.prompt(
'What is the break-even price per GPU-hour on this block, and how much has the idle ' +
'capacity already cost? Use the tool.',
);
await piggy.session.waitForIdle();
} finally {
unsubscribe();
piggy.dispose();
}
assert.equal(toolCalls > 0, true, 'the model answered without calling the tool');
assert.ok(answer.trim().length > 0, 'the model returned an empty answer');
// `length` is the signature of the failure: the budget was spent before a
// single token of the answer was written.
assert.notEqual(finish, 'length');
// 149 output tokens after the fix; 6,195 before it. The bound is generous
// enough that ordinary variation cannot trip it and tight enough that a
// reasoning regression cannot hide under it.
assert.ok(spent > 0 && spent < 1_500, `the turn spent ${spent} output tokens`);
// Not a check on the model's prose: a check that the units rule survived. A
// cents-denominated money figure is the one output that is arithmetically
// correct and commercially useless.
assert.doesNotMatch(answer, /\b112\s*(cents|c)\b/i);
});
+236
View File
@@ -0,0 +1,236 @@
/**
* The write tools, taken all the way through a real transaction.
*
* `test/write-tools.test.ts` proves the negative — that a change nobody agreed
* to never opens a transaction — against a fake handle. It cannot prove the
* positive, because the interesting part of an applied write is what the
* database ends up holding: whether the row is really there, and whether the
* audit trail says Piggy wrote it. That needs Postgres.
*
* It needs its own Postgres, too. These cases INSERT, and the development
* database is a book people are looking at — an activity that appears in
* somebody's feed because a test ran is exactly the kind of thing a CRM must
* never do. So the URL is supplied separately and `pig_combined` is refused by
* name.
*
* docker exec pig-ux-db psql -U pig -d postgres -c "CREATE DATABASE pig_a2_scratch"
* DATABASE_URL=postgres://pig:pig@localhost:54330/pig_a2_scratch pnpm -F @pig/db run migrate
* PIGGY_WRITE_DATABASE_URL=postgres://pig:pig@localhost:54330/pig_a2_scratch \
* pnpm -F @pig/piggy run test:e2e
*/
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import test, { after, before } from 'node:test';
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
import type { Principal } from '@pig/api/src/lib/auth';
import {
accounts,
activities,
createDatabase,
demandDeals,
users,
type Database,
} from '@pig/db';
import { and, eq, like } from 'drizzle-orm';
import { createPigWriteTools, type PigWriteDetails } from '../src/write-tools';
const databaseUrl = process.env.PIGGY_WRITE_DATABASE_URL;
// A skipped suite that says why beats one that silently passes: these are the
// only cases in the repo that watch a write land.
if (!databaseUrl) {
test.skip('the write-tool E2E needs PIGGY_WRITE_DATABASE_URL pointing at a scratch database');
}
if (databaseUrl?.includes('pig_combined')) {
throw new Error('The write-tool E2E must never run against the development book.');
}
const db: Database = createDatabase({ url: databaseUrl ?? 'postgres://unused', max: 2 });
const ctx = {} as ExtensionContext;
const marker = `PIGGY-A2-${randomUUID()}`;
const fixture = { userId: '', accountId: '', dealId: '' };
function seller(): Principal {
return {
userId: fixture.userId,
email: `${marker}@example.test`,
name: 'Dana Okonjo',
isPlatformAdmin: false,
teams: [
{ team: 'demand', role: 'member' },
{ team: 'supply', role: 'member' },
],
via: 'jwt',
scopes: ['read', 'write'],
};
}
function tools(mode: 'confirm' | 'auto', decision: 'apply' | 'reject') {
return createPigWriteTools({
db,
principal: seller(),
mode,
propose: async () => decision,
});
}
function named(list: ReturnType<typeof tools>, name: string) {
const found = list.find((candidate) => candidate.name === name);
assert.ok(found, `${name} is missing`);
return found;
}
function detailsOf(result: { details: unknown }): PigWriteDetails {
return result.details as PigWriteDetails;
}
before(async () => {
if (!databaseUrl) return;
const [user] = await db
.insert(users)
.values({ email: `${marker}@example.test`, name: 'Dana Okonjo', authSubject: randomUUID() })
.returning({ id: users.id });
assert.ok(user);
fixture.userId = user.id;
const [account] = await db
.insert(accounts)
.values({ name: `${marker} Northwind Robotics`, side: 'demand' })
.returning({ id: accounts.id });
assert.ok(account);
fixture.accountId = account.id;
const [deal] = await db
.insert(demandDeals)
.values({ accountId: account.id, name: `${marker} H200 reserved`, stage: 'proposal' })
.returning({ id: demandDeals.id });
assert.ok(deal);
fixture.dealId = deal.id;
});
after(async () => {
if (!databaseUrl) return;
// Activities and deals cascade from the account; the user does not.
if (fixture.accountId) await db.delete(accounts).where(eq(accounts.id, fixture.accountId));
if (fixture.userId) await db.delete(users).where(eq(users.id, fixture.userId));
// Closed explicitly: an open pool keeps the event loop alive, and a suite
// that passes but never exits looks exactly like one that hangs.
await db.$client.end({ timeout: 5 });
});
test('an approved activity is written, and marked as Piggys', { skip: !databaseUrl }, async () => {
const result = await named(tools('confirm', 'apply'), 'pig_log_activity').execute(
'call-1',
{
type: 'call',
subject: 'Pricing call with procurement',
body: 'They want H200 pricing before the board meets.',
accountId: fixture.accountId,
},
undefined,
undefined,
ctx,
);
assert.equal(detailsOf(result).status, 'applied');
const written = await db
.select()
.from(activities)
.where(eq(activities.accountId, fixture.accountId));
assert.equal(written.length, 1);
const [row] = written;
assert.ok(row);
assert.equal(row.subject, 'Pricing call with procurement');
assert.equal(row.actorUserId, fixture.userId, 'the write is attributed to the caller');
// The row IS its own audit event, so the provenance rides on the external id.
assert.match(row.externalId ?? '', /^piggy:/);
const piggyRows = await db
.select()
.from(activities)
.where(and(eq(activities.accountId, fixture.accountId), like(activities.externalId, 'piggy:%')));
assert.equal(piggyRows.length, 1, 'every write Piggy made is selectable by that prefix');
});
test('a rejected change leaves the book exactly as it was', { skip: !databaseUrl }, async () => {
const before = await db.select().from(demandDeals).where(eq(demandDeals.id, fixture.dealId));
const activitiesBefore = await db
.select()
.from(activities)
.where(eq(activities.demandDealId, fixture.dealId));
const result = await named(tools('confirm', 'reject'), 'pig_update_deal_stage').execute(
'call-2',
{
dealType: 'demand',
dealId: fixture.dealId,
stage: 'procurement',
reason: 'Legal cleared the MSA this morning.',
},
undefined,
undefined,
ctx,
);
assert.equal(detailsOf(result).status, 'declined');
const after = await db.select().from(demandDeals).where(eq(demandDeals.id, fixture.dealId));
assert.equal(after[0]?.stage, before[0]?.stage, 'the stage did not move');
const activitiesAfter = await db
.select()
.from(activities)
.where(eq(activities.demandDealId, fixture.dealId));
assert.equal(activitiesAfter.length, activitiesBefore.length, 'no audit row was written');
});
test('an approved stage change carries Piggy in its audit row', { skip: !databaseUrl }, async () => {
const result = await named(tools('confirm', 'apply'), 'pig_update_deal_stage').execute(
'call-3',
{
dealType: 'demand',
dealId: fixture.dealId,
stage: 'procurement',
reason: 'Legal cleared the MSA this morning.',
},
undefined,
undefined,
ctx,
);
assert.equal(detailsOf(result).status, 'applied');
const [deal] = await db.select().from(demandDeals).where(eq(demandDeals.id, fixture.dealId));
assert.equal(deal?.stage, 'procurement');
const [audit] = await db
.select()
.from(activities)
.where(and(eq(activities.demandDealId, fixture.dealId), eq(activities.type, 'stage_change')));
assert.ok(audit, 'the mutation convention wrote its audit row');
assert.equal(audit.subject, 'proposal → procurement');
assert.equal(audit.actorUserId, fixture.userId, 'still the caller, never an elevated principal');
// `actorAgent` on the column stays null because the request really did
// authenticate as a person; the provenance goes where the caller legitimately
// controls the content.
assert.equal(audit.meta?.actorAgent, 'piggy');
assert.equal(audit.meta?.piggyTool, 'pig_update_deal_stage');
assert.equal(audit.meta?.piggyReason, 'Legal cleared the MSA this morning.');
assert.match(audit.body ?? '', /Recorded by Piggy \(pig_update_deal_stage\) on behalf of Dana/);
});
test('a task becomes a calendar entry the user owns', { skip: !databaseUrl }, async () => {
const result = await named(tools('auto', 'apply'), 'pig_create_task').execute(
'call-4',
{
title: 'Send the H200 quote',
startsAt: '2026-09-01',
accountId: fixture.accountId,
},
undefined,
undefined,
ctx,
);
const details = detailsOf(result);
assert.equal(details.status, 'applied');
assert.ok(details.recordId);
});