f0173440e4
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>
1139 lines
40 KiB
TypeScript
1139 lines
40 KiB
TypeScript
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 { PiggyApprovalDecision, PiggyChatEvent, PiggyModelOption } from '@pig/core';
|
|
import type { Database } from '@pig/db';
|
|
import type { CreatePiggySessionOptions, PiggySession } from '../src/agent/session';
|
|
import {
|
|
startPiggyChatServer,
|
|
type PiggyChatServerOptions,
|
|
type PiggySessionFactory,
|
|
} from '../src/chat-server';
|
|
import type { PigWriteToolDeps } from '../src/write-tools';
|
|
|
|
const TOKEN = 'test-internal-token-for-piggy-000000';
|
|
|
|
const MODELS: PiggyModelOption[] = [
|
|
{
|
|
id: 'nvidia/nemotron-3-nano-30b-a3b',
|
|
label: 'Nemotron 3 Nano',
|
|
costPerMTokIn: 0.05,
|
|
costPerMTokOut: 0.2,
|
|
contextWindow: 131_072,
|
|
reasoning: true,
|
|
isDefault: true,
|
|
},
|
|
{
|
|
id: 'anthropic/claude-opus-5',
|
|
label: 'Claude Opus 5',
|
|
costPerMTokIn: 5,
|
|
costPerMTokOut: 25,
|
|
contextWindow: 200_000,
|
|
reasoning: true,
|
|
},
|
|
];
|
|
|
|
/**
|
|
* The chat server writes exactly two statements per turn — one insert, one
|
|
* update — so a fake that records them is enough to assert the whole ledger.
|
|
*/
|
|
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;
|
|
},
|
|
}),
|
|
}),
|
|
// The daily spend, read once before every turn. Zero here, so nothing in
|
|
// this file is refused for cost; `turn-limits.test.ts` owns that ceiling.
|
|
select: () => ({ from: () => ({ where: async () => [{ spent: '0' }] }) }),
|
|
} as unknown as Database;
|
|
}
|
|
|
|
/**
|
|
* A turn, written the way the harness would perform it: the script is handed
|
|
* the tool set the server assembled and an emitter, and drives both. Every
|
|
* event it emits has a real `AgentSessionEvent` shape; the casts are there only
|
|
* because an `AssistantMessage` carries thirty fields the translation never
|
|
* reads, and building all of them would test the fake rather than the server.
|
|
*
|
|
* The third argument is the signal a disposed session aborts. The real harness
|
|
* passes one to every tool `execute` and fires it on `session.abort()`, which is
|
|
* how a tool parked on an approval learns that the reader has gone; a fake that
|
|
* did not would deadlock the moment a turn was abandoned mid-approval.
|
|
*/
|
|
type TurnScript = (
|
|
tools: readonly ToolDefinition[],
|
|
emit: (event: AgentSessionEvent) => void,
|
|
signal: AbortSignal,
|
|
) => Promise<void>;
|
|
|
|
interface SessionSpy {
|
|
options?: CreatePiggySessionOptions;
|
|
disposed: number;
|
|
}
|
|
|
|
function spy(): SessionSpy {
|
|
return { disposed: 0 };
|
|
}
|
|
|
|
function fakeSessions(script: TurnScript, watched: SessionSpy): PiggySessionFactory {
|
|
return async (options) => {
|
|
watched.options = options;
|
|
const listeners = new Set<(event: AgentSessionEvent) => void>();
|
|
const aborted = new AbortController();
|
|
let disposed = false;
|
|
const session = {
|
|
subscribe(listener: (event: AgentSessionEvent) => void) {
|
|
listeners.add(listener);
|
|
return () => listeners.delete(listener);
|
|
},
|
|
async prompt() {
|
|
await script(
|
|
options.tools,
|
|
(event) => {
|
|
// `createPiggySession`'s dispose aborts the session before dropping
|
|
// its listeners, so a disposed session stops generating rather than
|
|
// streaming into a socket nobody is reading.
|
|
if (disposed) throw new Error('The session was aborted.');
|
|
for (const listener of [...listeners]) listener(event);
|
|
},
|
|
aborted.signal,
|
|
);
|
|
},
|
|
async abort() {},
|
|
dispose() {},
|
|
} as unknown as AgentSession;
|
|
|
|
return {
|
|
session,
|
|
modelId: options.modelId ?? MODELS[0]!.id,
|
|
systemPrompt: 'You are Piggy.',
|
|
dispose: () => {
|
|
disposed = true;
|
|
aborted.abort();
|
|
watched.disposed += 1;
|
|
},
|
|
} 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',
|
|
errorMessage?: string,
|
|
): AgentSessionEvent {
|
|
return {
|
|
type: 'turn_end',
|
|
message: {
|
|
role: 'assistant',
|
|
usage: { input, output },
|
|
stopReason,
|
|
...(errorMessage ? { errorMessage } : {}),
|
|
},
|
|
toolResults: [],
|
|
} as unknown as AgentSessionEvent;
|
|
}
|
|
|
|
function toolStart(id: string, name: string, args: unknown): AgentSessionEvent {
|
|
return {
|
|
type: 'tool_execution_start',
|
|
toolCallId: id,
|
|
toolName: name,
|
|
args,
|
|
} as unknown as AgentSessionEvent;
|
|
}
|
|
|
|
function toolEnd(
|
|
id: string,
|
|
name: string,
|
|
result: { content: { type: 'text'; text: string }[]; details?: unknown },
|
|
isError = false,
|
|
): AgentSessionEvent {
|
|
return {
|
|
type: 'tool_execution_end',
|
|
toolCallId: id,
|
|
toolName: name,
|
|
result,
|
|
isError,
|
|
} as unknown as AgentSessionEvent;
|
|
}
|
|
|
|
/** An event PIG does not render, which must never reach the wire. */
|
|
const noiseEvent = { type: 'queue_update', steering: [], followUp: [] } as unknown as AgentSessionEvent;
|
|
|
|
/** A tool needs only a name to be assembled, allowlisted and handed over. */
|
|
function namedTool(name: string): ToolDefinition {
|
|
return { name } as unknown as ToolDefinition;
|
|
}
|
|
|
|
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: () => [],
|
|
...options,
|
|
});
|
|
t.after(() => server.close());
|
|
// Port 0 is only resolved once the socket is bound.
|
|
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'],
|
|
};
|
|
|
|
function chatBody(overrides: Record<string, unknown> = {}): string {
|
|
return JSON.stringify({
|
|
principal: PRINCIPAL,
|
|
message: 'What is idle costing us?',
|
|
context: { type: 'page', route: '/capacity' },
|
|
mode: 'read_only',
|
|
conversationId: 'conv-1',
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
const authorised = { authorization: `Bearer ${TOKEN}`, 'content-type': 'application/json' };
|
|
|
|
function parseFrames(body: string): PiggyChatEvent[] {
|
|
return body
|
|
.trim()
|
|
.split('\n')
|
|
.filter((line) => line.length > 0)
|
|
.map((line) => JSON.parse(line) as PiggyChatEvent);
|
|
}
|
|
|
|
test('health answers without a token, and nothing else does', async (t) => {
|
|
const runs: RecordedRun[] = [];
|
|
const base = await startForTest(t, runs, { createSession: fakeSessions(async () => {}, spy()) });
|
|
|
|
const health = await fetch(`${base}/internal/health`);
|
|
assert.equal(health.status, 200);
|
|
assert.deepEqual(await health.json(), {
|
|
ok: true,
|
|
service: 'piggy-chat',
|
|
model: 'nvidia/nemotron-3-nano-30b-a3b',
|
|
});
|
|
|
|
assert.equal((await fetch(`${base}/internal/anything`)).status, 404);
|
|
assert.equal((await fetch(`${base}/internal/models`)).status, 401);
|
|
assert.equal(
|
|
(await fetch(`${base}/internal/chat`, { method: 'POST', body: chatBody() })).status,
|
|
401,
|
|
);
|
|
assert.equal(
|
|
(await fetch(`${base}/internal/approve`, { method: 'POST', body: '{}' })).status,
|
|
401,
|
|
);
|
|
});
|
|
|
|
test('the catalogue is served to the relay, so no client hard-codes a model list', async (t) => {
|
|
const runs: RecordedRun[] = [];
|
|
const base = await startForTest(t, runs, { createSession: fakeSessions(async () => {}, spy()) });
|
|
|
|
const response = await fetch(`${base}/internal/models`, { headers: authorised });
|
|
assert.equal(response.status, 200);
|
|
assert.deepEqual(await response.json(), MODELS);
|
|
});
|
|
|
|
test('a turn is translated into PIG events, and the harness noise is dropped', async (t) => {
|
|
const runs: RecordedRun[] = [];
|
|
const watched = spy();
|
|
const base = await startForTest(t, runs, {
|
|
createSession: fakeSessions(async (_tools, emit) => {
|
|
emit(noiseEvent);
|
|
emit(toolStart('call_1', 'pig_get_idle_capacity', { since: '2026-01-01' }));
|
|
emit(
|
|
toolEnd('call_1', 'pig_get_idle_capacity', {
|
|
content: [{ type: 'text', text: '{"totalIdleCostCents":1200000}' }],
|
|
details: { tool: 'pig_get_idle_capacity', result: { totalIdleCostCents: 1_200_000 } },
|
|
}),
|
|
);
|
|
emit(textDelta('Idle is '));
|
|
emit(textDelta('$12,000.'));
|
|
emit(turnEnd(1_240, 180));
|
|
}, watched),
|
|
});
|
|
|
|
const response = await fetch(`${base}/internal/chat`, {
|
|
method: 'POST',
|
|
headers: authorised,
|
|
body: chatBody(),
|
|
});
|
|
assert.equal(response.status, 200);
|
|
const frames = parseFrames(await response.text());
|
|
|
|
assert.deepEqual(frames[0], {
|
|
type: 'meta',
|
|
model: 'nvidia/nemotron-3-nano-30b-a3b',
|
|
mode: 'read_only',
|
|
conversationId: 'conv-1',
|
|
});
|
|
assert.deepEqual(frames[1], {
|
|
type: 'tool_call',
|
|
id: 'call_1',
|
|
name: 'pig_get_idle_capacity',
|
|
arguments: { since: '2026-01-01' },
|
|
});
|
|
// The bridge already structured the answer in `details`; the panel is handed
|
|
// that rather than the JSON string the model was shown.
|
|
assert.deepEqual(frames[2], {
|
|
type: 'tool_result',
|
|
id: 'call_1',
|
|
name: 'pig_get_idle_capacity',
|
|
ok: true,
|
|
result: { totalIdleCostCents: 1_200_000 },
|
|
});
|
|
assert.deepEqual(frames.slice(3, 5), [
|
|
{ type: 'content_delta', delta: 'Idle is ' },
|
|
{ type: 'content_delta', delta: '$12,000.' },
|
|
]);
|
|
assert.deepEqual(frames.at(-1), {
|
|
type: 'done',
|
|
inputTokens: 1_240,
|
|
outputTokens: 180,
|
|
// 1240 x 5 + 180 x 20 micro-cents, at $0.05/$0.20 per million tokens.
|
|
costMicroCents: 9_800,
|
|
});
|
|
assert.equal(frames.length, 6);
|
|
|
|
const run = runs[0];
|
|
assert.equal(run?.values.model, 'nvidia/nemotron-3-nano-30b-a3b');
|
|
assert.equal(run?.values.principalUserId, PRINCIPAL.userId);
|
|
assert.equal(run?.closed?.status, 'succeeded');
|
|
assert.equal(run?.closed?.summary, 'Idle is $12,000.');
|
|
assert.equal(run?.closed?.inputTokens, 1_240);
|
|
assert.equal(run?.closed?.costMicroCents, 9_800);
|
|
assert.ok(run?.closed?.finishedAt instanceof Date);
|
|
// The session is unwound on the happy path too; a leaked one holds an
|
|
// inference connection open and billing.
|
|
assert.equal(watched.disposed, 1);
|
|
});
|
|
|
|
// ------------------------------------------------- the harness's vocabulary
|
|
|
|
/**
|
|
* The four harness events PIG renders something for.
|
|
*
|
|
* Everything else is dropped on the server, deliberately: PIG's own event
|
|
* vocabulary is what the browser speaks, so a harness upgrade is a server change
|
|
* and never a client one.
|
|
*/
|
|
const MAPPED_EVENTS = [
|
|
'message_update',
|
|
'tool_execution_start',
|
|
'tool_execution_end',
|
|
'turn_end',
|
|
] as const;
|
|
|
|
/**
|
|
* Everything the harness can emit that PIG deliberately drops.
|
|
*
|
|
* Written out in full rather than implied by a `default:` arm, because the
|
|
* failure this catches is an upgrade that ADDS an event type — a harness that
|
|
* starts announcing, say, a delegated sub-agent, or a permission request, would
|
|
* otherwise fall into `default` and be dropped in silence for as long as it took
|
|
* somebody to notice the product had lost a feature it never knew it had.
|
|
*
|
|
* A few of these are worth knowing by name. `bash_execution_update` exists
|
|
* because the harness can run a shell; Piggy cannot, and if this event ever
|
|
* arrives on a Piggy session something is very wrong. `compaction_start` and
|
|
* `compaction_end` are the harness rewriting its own transcript, which PIG
|
|
* neither triggers nor persists — conversations are stored as PIG messages in
|
|
* Postgres. `auto_retry_start` is a retry PIG does not surface because the user
|
|
* is watching a spinner either way.
|
|
*/
|
|
const DROPPED_EVENTS = [
|
|
'agent_start',
|
|
'agent_end',
|
|
'agent_settled',
|
|
'turn_start',
|
|
'message_start',
|
|
'message_end',
|
|
'tool_execution_update',
|
|
'queue_update',
|
|
'compaction_start',
|
|
'compaction_end',
|
|
'entry_appended',
|
|
'session_info_changed',
|
|
'thinking_level_changed',
|
|
'auto_retry_start',
|
|
'auto_retry_end',
|
|
'summarization_retry_scheduled',
|
|
'summarization_retry_attempt_start',
|
|
'summarization_retry_finished',
|
|
'bash_execution_update',
|
|
] as const;
|
|
|
|
/**
|
|
* Both directions, at compile time.
|
|
*
|
|
* `[A] extends [B]` and back again is mutual assignability rather than
|
|
* assignability one way: a harness event missing from the lists fails, and a
|
|
* name in the lists the harness no longer emits fails too. Either way the
|
|
* failure is `tsc`, before a single test runs.
|
|
*/
|
|
type MutuallyAssignable<A, B> = [A] extends [B] ? ([B] extends [A] ? true : never) : never;
|
|
type HarnessEventType = AgentSessionEvent['type'];
|
|
type AccountedFor = (typeof MAPPED_EVENTS)[number] | (typeof DROPPED_EVENTS)[number];
|
|
const EVENT_VOCABULARY_IS_ACCOUNTED_FOR: MutuallyAssignable<HarnessEventType, AccountedFor> = true;
|
|
|
|
test('every harness event is either rendered or deliberately dropped', async (t) => {
|
|
// The type above is the real assertion; this keeps it from being deleted as
|
|
// an unused declaration, and states in words what it is for.
|
|
assert.equal(EVENT_VOCABULARY_IS_ACCOUNTED_FOR, true);
|
|
assert.equal(
|
|
new Set([...MAPPED_EVENTS, ...DROPPED_EVENTS]).size,
|
|
MAPPED_EVENTS.length + DROPPED_EVENTS.length,
|
|
'an event cannot be both rendered and dropped',
|
|
);
|
|
|
|
const runs: RecordedRun[] = [];
|
|
const base = await startForTest(t, runs, {
|
|
createSession: fakeSessions(async (_tools, emit) => {
|
|
// Every dropped type, in one turn. None of them may reach the wire.
|
|
for (const type of DROPPED_EVENTS) emit({ type } as unknown as AgentSessionEvent);
|
|
emit(turnEnd(10, 5));
|
|
}, spy()),
|
|
});
|
|
|
|
const frames = parseFrames(
|
|
await fetch(`${base}/internal/chat`, { method: 'POST', headers: authorised, body: chatBody() })
|
|
.then((response) => response.text()),
|
|
);
|
|
|
|
assert.deepEqual(
|
|
frames.map((frame) => frame.type),
|
|
['meta', 'done'],
|
|
'a harness event PIG does not render must not reach the browser at all',
|
|
);
|
|
// And the turn still completed: dropping an event is not the same as being
|
|
// confused by one.
|
|
assert.equal(runs[0]?.closed?.status, 'succeeded');
|
|
});
|
|
|
|
test('a failed tool is reported as failed rather than as an empty answer', async (t) => {
|
|
const runs: RecordedRun[] = [];
|
|
const base = await startForTest(t, runs, {
|
|
createSession: fakeSessions(async (_tools, emit) => {
|
|
emit(toolStart('call_1', 'pig_get_record', {}));
|
|
emit(
|
|
toolEnd(
|
|
'call_1',
|
|
'pig_get_record',
|
|
{ content: [{ type: 'text', text: 'Account 9c1 not found.' }] },
|
|
true,
|
|
),
|
|
);
|
|
emit(turnEnd(40, 10));
|
|
}, spy()),
|
|
});
|
|
|
|
const frames = parseFrames(
|
|
await fetch(`${base}/internal/chat`, { method: 'POST', headers: authorised, body: chatBody() })
|
|
.then((response) => response.text()),
|
|
);
|
|
assert.deepEqual(frames[2], {
|
|
type: 'tool_result',
|
|
id: 'call_1',
|
|
name: 'pig_get_record',
|
|
ok: false,
|
|
error: 'Account 9c1 not found.',
|
|
});
|
|
});
|
|
|
|
test('the chosen model is the one that answers, and is priced as itself', async (t) => {
|
|
const runs: RecordedRun[] = [];
|
|
const watched = spy();
|
|
const base = await startForTest(t, runs, {
|
|
createSession: fakeSessions(async (_tools, emit) => {
|
|
emit(textDelta('Considered.'));
|
|
emit(turnEnd(1_000_000, 1_000_000));
|
|
}, watched),
|
|
});
|
|
|
|
const frames = parseFrames(
|
|
await fetch(`${base}/internal/chat`, {
|
|
method: 'POST',
|
|
headers: authorised,
|
|
body: chatBody({ modelId: 'anthropic/claude-opus-5' }),
|
|
}).then((response) => response.text()),
|
|
);
|
|
|
|
assert.equal(watched.options?.modelId, 'anthropic/claude-opus-5');
|
|
assert.equal(frames[0]?.type === 'meta' ? frames[0].model : null, 'anthropic/claude-opus-5');
|
|
const done = frames.at(-1);
|
|
// A million tokens each way at $5/$25 per million: 500 + 2500 cents, in
|
|
// micro-cents. Billing that at the nano's price would understate it a
|
|
// hundredfold, which is why the catalogue is the only price list.
|
|
assert.equal(done?.type === 'done' ? done.costMicroCents : null, 3_000_000_000);
|
|
|
|
const unknown = await fetch(`${base}/internal/chat`, {
|
|
method: 'POST',
|
|
headers: authorised,
|
|
body: chatBody({ modelId: 'openai/o-something' }),
|
|
});
|
|
assert.equal(unknown.status, 400);
|
|
assert.deepEqual(await unknown.json(), { error: 'Unknown Piggy model.' });
|
|
});
|
|
|
|
test('read_only withholds the write tools; confirm hands them over', async (t) => {
|
|
const runs: RecordedRun[] = [];
|
|
const watched = spy();
|
|
const seen: PigWriteToolDeps[] = [];
|
|
const base = await startForTest(t, runs, {
|
|
createReadTools: () => [namedTool('pig_get_idle_capacity')],
|
|
createWriteTools: (deps) => {
|
|
seen.push(deps);
|
|
return [namedTool('pig_log_activity')];
|
|
},
|
|
createSession: fakeSessions(async (_tools, emit) => emit(turnEnd(10, 10)), watched),
|
|
});
|
|
|
|
await fetch(`${base}/internal/chat`, { method: 'POST', headers: authorised, body: chatBody() })
|
|
.then((response) => response.text());
|
|
// A tool the model is never shown is a tool it cannot be talked into calling.
|
|
assert.deepEqual(watched.options?.tools.map((tool) => tool.name), ['pig_get_idle_capacity']);
|
|
assert.equal(seen.length, 0);
|
|
|
|
await fetch(`${base}/internal/chat`, {
|
|
method: 'POST',
|
|
headers: authorised,
|
|
body: chatBody({ mode: 'confirm' }),
|
|
}).then((response) => response.text());
|
|
assert.deepEqual(watched.options?.tools.map((tool) => tool.name), [
|
|
'pig_get_idle_capacity',
|
|
'pig_log_activity',
|
|
]);
|
|
assert.equal(watched.options?.mode, 'confirm');
|
|
// Built as the caller, never as an elevated or synthetic principal: this is
|
|
// the identity `executeMutation` will check capabilities against.
|
|
assert.deepEqual(seen[0]?.principal, PRINCIPAL);
|
|
assert.equal(seen[0]?.mode, 'confirm');
|
|
});
|
|
|
|
test('a tool outside the PIG boundary never reaches the harness', async (t) => {
|
|
const runs: RecordedRun[] = [];
|
|
const watched = spy();
|
|
const base = await startForTest(t, runs, {
|
|
createReadTools: () => [namedTool('bash')],
|
|
createSession: fakeSessions(async () => {}, watched),
|
|
});
|
|
|
|
const frames = parseFrames(
|
|
await fetch(`${base}/internal/chat`, { method: 'POST', headers: authorised, body: chatBody() })
|
|
.then((response) => response.text()),
|
|
);
|
|
|
|
assert.deepEqual(frames.at(-1), {
|
|
type: 'error',
|
|
message: 'Piggy chat failed.',
|
|
code: 'agent_failed',
|
|
});
|
|
assert.equal(watched.options, undefined);
|
|
assert.equal(runs[0]?.closed?.status, 'failed');
|
|
assert.match(String(runs[0]?.closed?.error), /outside the PIG tool boundary/);
|
|
});
|
|
|
|
test('a malformed request is the only thing called an invalid request', async (t) => {
|
|
const runs: RecordedRun[] = [];
|
|
const base = await startForTest(t, runs, { createSession: fakeSessions(async () => {}, spy()) });
|
|
|
|
for (const body of [
|
|
JSON.stringify({ principal: PRINCIPAL, message: '', mode: 'read_only', conversationId: 'c' }),
|
|
// The old bare `principalUserId`: a relay that has not been updated must
|
|
// fail here rather than write as nobody in particular.
|
|
JSON.stringify({
|
|
principalUserId: PRINCIPAL.userId,
|
|
message: 'Hello',
|
|
mode: 'read_only',
|
|
conversationId: 'c',
|
|
}),
|
|
// A principal the relay shaped differently is a relay that has drifted.
|
|
chatBody({ principal: { ...PRINCIPAL, extra: true } }),
|
|
chatBody({ principal: { ...PRINCIPAL, teams: [{ team: 'legal', role: 'lead' }] } }),
|
|
chatBody({ mode: 'god_mode' }),
|
|
chatBody({ conversationId: '' }),
|
|
]) {
|
|
const response = await fetch(`${base}/internal/chat`, {
|
|
method: 'POST',
|
|
headers: authorised,
|
|
body,
|
|
});
|
|
assert.equal(response.status, 400);
|
|
assert.deepEqual(await response.json(), { error: 'Invalid Piggy chat request.' });
|
|
}
|
|
// No inference was attempted, so no run should have been opened for any.
|
|
assert.equal(runs.length, 0);
|
|
});
|
|
|
|
test('a fault raised mid-turn is not blamed on the user, and closes its run', async (t) => {
|
|
const runs: RecordedRun[] = [];
|
|
const base = await startForTest(t, runs, {
|
|
createSession: fakeSessions(async (_tools, emit) => {
|
|
emit(textDelta('Idle is '));
|
|
throw new Error('inference stream stalled for 30000ms');
|
|
}, spy()),
|
|
});
|
|
|
|
const response = await fetch(`${base}/internal/chat`, {
|
|
method: 'POST',
|
|
headers: authorised,
|
|
body: chatBody(),
|
|
});
|
|
|
|
// The stream had already begun, so the turn ends as an error frame on a 200.
|
|
assert.equal(response.status, 200);
|
|
const frames = parseFrames(await response.text());
|
|
assert.deepEqual(frames.at(-1), {
|
|
type: 'error',
|
|
message: 'Piggy chat failed.',
|
|
code: 'agent_failed',
|
|
});
|
|
assert.equal(runs[0]?.closed?.status, 'failed');
|
|
assert.equal(runs[0]?.closed?.summary, 'Idle is');
|
|
});
|
|
|
|
test('a model that stops on its own error does not report a finished answer', async (t) => {
|
|
const runs: RecordedRun[] = [];
|
|
const base = await startForTest(t, runs, {
|
|
createSession: fakeSessions(async (_tools, emit) => {
|
|
emit(textDelta('Idle is '));
|
|
emit(turnEnd(120, 4, 'error', 'upstream returned 502'));
|
|
}, spy()),
|
|
});
|
|
|
|
const frames = parseFrames(
|
|
await fetch(`${base}/internal/chat`, { method: 'POST', headers: authorised, body: chatBody() })
|
|
.then((response) => response.text()),
|
|
);
|
|
|
|
assert.deepEqual(frames.at(-1), {
|
|
type: 'error',
|
|
message: 'Piggy could not finish this answer.',
|
|
code: 'inference_failed',
|
|
});
|
|
assert.ok(!frames.some((frame) => frame.type === 'done'));
|
|
// The upstream body is not ours to relay to the browser, but it belongs in
|
|
// the ledger, where an operator can read it.
|
|
assert.equal(runs[0]?.closed?.error, 'upstream returned 502');
|
|
});
|
|
|
|
test('a turn that ends badly still bills what it actually spent', async (t) => {
|
|
// The measurement this pins: a real turn made three tool calls, was billed
|
|
// for every model call behind them, and then the endpoint answered 429. The
|
|
// run closed as `failed` with inputTokens, outputTokens and costMicroCents
|
|
// all null, because the ledger was fed only from the `done` frame — which a
|
|
// failed turn never emits. The spend panel therefore under-reported, and in
|
|
// the reassuring direction, which is the worst way for a money figure to be
|
|
// wrong. Two model calls land before the fault here, so a ledger that reads
|
|
// only the last one would be wrong as well as short.
|
|
const runs: RecordedRun[] = [];
|
|
const base = await startForTest(t, runs, {
|
|
createSession: fakeSessions(async (_tools, emit) => {
|
|
emit(turnEnd(4_000, 100));
|
|
emit(turnEnd(6_000, 300));
|
|
throw new Error('429: rate limit reached');
|
|
}, spy()),
|
|
});
|
|
|
|
await fetch(`${base}/internal/chat`, { method: 'POST', headers: authorised, body: chatBody() })
|
|
.then((response) => response.text());
|
|
|
|
const closed = runs[0]?.closed;
|
|
assert.equal(closed?.status, 'failed');
|
|
assert.equal(closed?.inputTokens, 10_000);
|
|
assert.equal(closed?.outputTokens, 400);
|
|
// 10,000 x $0.05/Mtok + 400 x $0.20/Mtok, in micro-cents.
|
|
assert.equal(closed?.costMicroCents, 10_000 * 0.05 * 100 + 400 * 0.2 * 100);
|
|
});
|
|
|
|
test('an abandoned turn bills what it generated before the reader left', async (t) => {
|
|
const runs: RecordedRun[] = [];
|
|
const base = await startForTest(t, runs, {
|
|
createSession: fakeSessions(async (_tools, emit) => {
|
|
emit(turnEnd(2_000, 50));
|
|
for (let index = 0; index < 40; index += 1) {
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
emit(textDelta(`chunk ${index} `));
|
|
}
|
|
}, spy()),
|
|
});
|
|
|
|
const abort = new AbortController();
|
|
setTimeout(() => abort.abort(), 120);
|
|
await assert.rejects(
|
|
fetch(`${base}/internal/chat`, {
|
|
method: 'POST',
|
|
headers: authorised,
|
|
body: chatBody(),
|
|
signal: abort.signal,
|
|
}).then((response) => response.text()),
|
|
);
|
|
|
|
await waitFor(() => runs[0]?.closed !== undefined);
|
|
assert.equal(runs[0]?.closed?.status, 'aborted');
|
|
// Generated, therefore billed, therefore in the ledger — a closed tab is not
|
|
// a refund.
|
|
assert.equal(runs[0]?.closed?.inputTokens, 2_000);
|
|
assert.equal(runs[0]?.closed?.outputTokens, 50);
|
|
assert.equal(runs[0]?.closed?.costMicroCents, 2_000 * 0.05 * 100 + 50 * 0.2 * 100);
|
|
});
|
|
|
|
test('an answer cut short by the token budget says so', async (t) => {
|
|
const runs: RecordedRun[] = [];
|
|
const base = await startForTest(t, runs, {
|
|
createSession: fakeSessions(async (_tools, emit) => {
|
|
emit(textDelta('The first half of a table'));
|
|
emit(turnEnd(900, 2_048, 'length'));
|
|
}, spy()),
|
|
});
|
|
|
|
const frames = parseFrames(
|
|
await fetch(`${base}/internal/chat`, { method: 'POST', headers: authorised, body: chatBody() })
|
|
.then((response) => response.text()),
|
|
);
|
|
const done = frames.at(-1);
|
|
assert.equal(done?.type === 'done' ? done.finishReason : null, 'length');
|
|
});
|
|
|
|
test('a reader who leaves mid-answer closes the run as abandoned, not as running', async (t) => {
|
|
const runs: RecordedRun[] = [];
|
|
const watched = spy();
|
|
const base = await startForTest(t, runs, {
|
|
createSession: fakeSessions(async (_tools, emit) => {
|
|
for (let index = 0; index < 20; index += 1) {
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
emit(textDelta(`chunk ${index} `));
|
|
}
|
|
}, watched),
|
|
});
|
|
|
|
const abort = new AbortController();
|
|
setTimeout(() => abort.abort(), 80);
|
|
await assert.rejects(
|
|
fetch(`${base}/internal/chat`, {
|
|
method: 'POST',
|
|
headers: authorised,
|
|
body: chatBody(),
|
|
signal: abort.signal,
|
|
}).then((response) => response.text()),
|
|
);
|
|
|
|
await waitFor(() => runs[0]?.closed !== undefined);
|
|
// Without the finally this row stayed `running` for ever, and no later query
|
|
// could tell it from a turn still in flight.
|
|
assert.equal(runs[0]?.closed?.status, 'aborted');
|
|
// And the session is disposed rather than left generating tokens nobody will
|
|
// ever read.
|
|
assert.ok(watched.disposed >= 1);
|
|
});
|
|
|
|
// ------------------------------------------------------------- the approvals
|
|
|
|
/**
|
|
* A write tool in the shape `createPigWriteTools` builds: it proposes, waits,
|
|
* and reports what really happened as its own tool result — so a declined
|
|
* change cannot be summarised to the reader as a saved one.
|
|
*/
|
|
function proposingWriteTools(applied: string[]): (deps: PigWriteToolDeps) => ToolDefinition[] {
|
|
return ({ propose }) => [
|
|
{
|
|
name: 'pig_log_activity',
|
|
async execute() {
|
|
const decision = await propose({
|
|
tool: 'pig_log_activity',
|
|
kind: 'activity',
|
|
summary: 'Log a call on Northwind Robotics',
|
|
fields: [{ label: 'Subject', value: 'Capacity review' }],
|
|
});
|
|
if (decision === 'apply') applied.push('applied');
|
|
return {
|
|
content: [{ type: 'text', text: `The change was ${decision === 'apply' ? 'applied' : 'declined'}.` }],
|
|
details: {
|
|
tool: 'pig_log_activity',
|
|
kind: 'activity',
|
|
status: decision === 'apply' ? 'applied' : 'declined',
|
|
},
|
|
};
|
|
},
|
|
} as unknown as ToolDefinition,
|
|
];
|
|
}
|
|
|
|
/** Drives the write tool the way the harness would, around a real approval. */
|
|
const writingScript: TurnScript = async (tools, emit, signal) => {
|
|
const tool = tools.find((candidate) => candidate.name === 'pig_log_activity');
|
|
assert.ok(tool, 'the write tool should have been handed over');
|
|
emit(toolStart('call_1', 'pig_log_activity', { subject: 'Capacity review' }));
|
|
// The signal is forwarded because the harness forwards it: it is the only
|
|
// thing that tells a tool parked on an approval that the turn has been
|
|
// abandoned underneath it.
|
|
const result = await tool.execute('call_1', {}, signal, undefined, undefined as never);
|
|
emit(
|
|
toolEnd('call_1', 'pig_log_activity', {
|
|
content: result.content.filter(
|
|
(part): part is { type: 'text'; text: string } => part.type === 'text',
|
|
),
|
|
details: result.details,
|
|
}),
|
|
);
|
|
emit(textDelta('Logged.'));
|
|
emit(turnEnd(200, 20));
|
|
};
|
|
|
|
/**
|
|
* Reads the stream up to the approval card, then hands back a reader for the
|
|
* rest — because the decision has to be posted while the turn is still open,
|
|
* which is the entire point of the rendezvous.
|
|
*/
|
|
async function readUntilApproval(
|
|
response: Response,
|
|
): Promise<{ frames: PiggyChatEvent[]; rest: () => Promise<PiggyChatEvent[]> }> {
|
|
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);
|
|
}
|
|
|
|
const rest = async (): Promise<PiggyChatEvent[]> => {
|
|
const tail: PiggyChatEvent[] = [];
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
drain(value, tail);
|
|
}
|
|
return tail;
|
|
};
|
|
return { frames, rest };
|
|
}
|
|
|
|
test('a proposed write waits for the user, then applies once and only once', async (t) => {
|
|
const runs: RecordedRun[] = [];
|
|
const applied: string[] = [];
|
|
const base = await startForTest(t, runs, {
|
|
createWriteTools: proposingWriteTools(applied),
|
|
createSession: fakeSessions(writingScript, spy()),
|
|
});
|
|
|
|
const response = await fetch(`${base}/internal/chat`, {
|
|
method: 'POST',
|
|
headers: authorised,
|
|
body: chatBody({ mode: 'confirm', message: 'Log a call on Northwind.' }),
|
|
});
|
|
const { frames, rest } = await readUntilApproval(response);
|
|
|
|
const asked = frames.find((frame) => frame.type === 'approval_required');
|
|
assert.ok(asked && asked.type === 'approval_required');
|
|
assert.equal(asked.change.summary, 'Log a call on Northwind Robotics');
|
|
assert.ok(asked.change.id.length > 0);
|
|
// The turn is still open, and nothing has been written yet.
|
|
assert.equal(applied.length, 0);
|
|
|
|
const decision = await fetch(`${base}/internal/approve`, {
|
|
method: 'POST',
|
|
headers: authorised,
|
|
body: JSON.stringify({ conversationId: 'conv-1', changeId: asked.change.id, decision: 'apply' }),
|
|
});
|
|
assert.equal(decision.status, 202);
|
|
|
|
const tail = await rest();
|
|
assert.deepEqual(tail[0], {
|
|
type: 'approval_resolved',
|
|
changeId: asked.change.id,
|
|
decision: 'apply',
|
|
ok: true,
|
|
});
|
|
// The tool result carries the truth, so the model cannot claim a save it did
|
|
// not make.
|
|
const result = tail.find((frame) => frame.type === 'tool_result');
|
|
assert.deepEqual(result?.type === 'tool_result' ? result.result : null, {
|
|
tool: 'pig_log_activity',
|
|
kind: 'activity',
|
|
status: 'applied',
|
|
});
|
|
assert.equal(applied.length, 1);
|
|
|
|
// A replayed decision must not apply the change a second time.
|
|
const replay = await fetch(`${base}/internal/approve`, {
|
|
method: 'POST',
|
|
headers: authorised,
|
|
body: JSON.stringify({ conversationId: 'conv-1', changeId: asked.change.id, decision: 'apply' }),
|
|
});
|
|
assert.equal(replay.status, 404);
|
|
assert.equal(applied.length, 1);
|
|
assert.equal(runs[0]?.closed?.status, 'succeeded');
|
|
// One model call, and one that waited five minutes' worth of human time
|
|
// without that counting against anything: the ceilings measure model work.
|
|
assert.deepEqual(runs[0]?.closed?.result, {
|
|
toolCalls: 1,
|
|
approvalsRequested: 1,
|
|
approvalsApplied: 1,
|
|
modelCalls: 1,
|
|
});
|
|
});
|
|
|
|
test("a decision on another conversation's id settles nothing", async (t) => {
|
|
const runs: RecordedRun[] = [];
|
|
const applied: string[] = [];
|
|
const base = await startForTest(t, runs, {
|
|
approvalTimeoutMs: 200,
|
|
createWriteTools: proposingWriteTools(applied),
|
|
createSession: fakeSessions(writingScript, spy()),
|
|
});
|
|
|
|
const response = await fetch(`${base}/internal/chat`, {
|
|
method: 'POST',
|
|
headers: authorised,
|
|
body: chatBody({ mode: 'confirm' }),
|
|
});
|
|
const { frames, rest } = await readUntilApproval(response);
|
|
const asked = frames.find((frame) => frame.type === 'approval_required');
|
|
assert.ok(asked && asked.type === 'approval_required');
|
|
|
|
const wrong = await fetch(`${base}/internal/approve`, {
|
|
method: 'POST',
|
|
headers: authorised,
|
|
body: JSON.stringify({
|
|
conversationId: 'conv-someone-else',
|
|
changeId: asked.change.id,
|
|
decision: 'apply',
|
|
}),
|
|
});
|
|
assert.equal(wrong.status, 404);
|
|
await rest();
|
|
assert.equal(applied.length, 0);
|
|
});
|
|
|
|
test('a declined write is reported to the model as declined', async (t) => {
|
|
const runs: RecordedRun[] = [];
|
|
const applied: string[] = [];
|
|
const base = await startForTest(t, runs, {
|
|
createWriteTools: proposingWriteTools(applied),
|
|
createSession: fakeSessions(writingScript, spy()),
|
|
});
|
|
|
|
const response = await fetch(`${base}/internal/chat`, {
|
|
method: 'POST',
|
|
headers: authorised,
|
|
body: chatBody({ mode: 'confirm' }),
|
|
});
|
|
const { frames, rest } = await readUntilApproval(response);
|
|
const asked = frames.find((frame) => frame.type === 'approval_required');
|
|
assert.ok(asked && asked.type === 'approval_required');
|
|
|
|
await fetch(`${base}/internal/approve`, {
|
|
method: 'POST',
|
|
headers: authorised,
|
|
body: JSON.stringify({ conversationId: 'conv-1', changeId: asked.change.id, decision: 'reject' }),
|
|
});
|
|
|
|
const tail = await rest();
|
|
assert.equal(applied.length, 0);
|
|
const settled = tail.find((frame) => frame.type === 'approval_resolved');
|
|
assert.equal(settled?.type === 'approval_resolved' ? settled.ok : null, true);
|
|
const result = tail.find((frame) => frame.type === 'tool_result');
|
|
assert.deepEqual(result?.type === 'tool_result' ? result.result : null, {
|
|
tool: 'pig_log_activity',
|
|
kind: 'activity',
|
|
status: 'declined',
|
|
});
|
|
assert.deepEqual(runs[0]?.closed?.result, {
|
|
toolCalls: 1,
|
|
approvalsRequested: 1,
|
|
approvalsApplied: 0,
|
|
modelCalls: 1,
|
|
});
|
|
});
|
|
|
|
test('an unanswered approval times out as a rejection rather than holding the turn open', async (t) => {
|
|
const runs: RecordedRun[] = [];
|
|
const applied: string[] = [];
|
|
const base = await startForTest(t, runs, {
|
|
approvalTimeoutMs: 60,
|
|
createWriteTools: proposingWriteTools(applied),
|
|
createSession: fakeSessions(writingScript, spy()),
|
|
});
|
|
|
|
const frames = parseFrames(
|
|
await fetch(`${base}/internal/chat`, {
|
|
method: 'POST',
|
|
headers: authorised,
|
|
body: chatBody({ mode: 'auto' }),
|
|
}).then((response) => response.text()),
|
|
);
|
|
|
|
const settled = frames.find((frame) => frame.type === 'approval_resolved');
|
|
assert.ok(settled && settled.type === 'approval_resolved');
|
|
assert.equal(settled.decision, 'reject');
|
|
assert.equal(settled.ok, false);
|
|
assert.match(String(settled.error), /within five minutes/);
|
|
// The turn finished rather than hanging on a reader who never answered, which
|
|
// is what was holding the inference connection open.
|
|
assert.equal(frames.at(-1)?.type, 'done');
|
|
assert.equal(applied.length, 0);
|
|
});
|
|
|
|
/**
|
|
* A write tool that behaves the way the real ones do when a turn is abandoned.
|
|
*
|
|
* `awaitDecision` in `write-tools.ts` races the pending decision against the
|
|
* abort signal the harness passes to `execute`, precisely so a reader who closes
|
|
* the tab does not leave a tool call — and the billed inference connection
|
|
* behind it — parked for ever. This double mirrors that, and records which
|
|
* decision it actually observed.
|
|
*/
|
|
function abandonableWriteTools(
|
|
seen: PiggyApprovalDecision[],
|
|
): (deps: PigWriteToolDeps) => ToolDefinition[] {
|
|
return ({ propose }) => [
|
|
{
|
|
name: 'pig_log_activity',
|
|
async execute(_id: string, _params: unknown, signal?: AbortSignal) {
|
|
const decision = await Promise.race<PiggyApprovalDecision>([
|
|
propose({
|
|
tool: 'pig_log_activity',
|
|
kind: 'activity',
|
|
summary: 'Log a call on Northwind Robotics',
|
|
fields: [{ label: 'Subject', value: 'Capacity review' }],
|
|
}),
|
|
new Promise<PiggyApprovalDecision>((resolve) => {
|
|
signal?.addEventListener('abort', () => resolve('reject'), { once: true });
|
|
}),
|
|
]);
|
|
seen.push(decision);
|
|
return {
|
|
content: [{ type: 'text', text: `The change was ${decision}ed.` }],
|
|
details: { tool: 'pig_log_activity', kind: 'activity', status: 'declined' },
|
|
};
|
|
},
|
|
} as unknown as ToolDefinition,
|
|
];
|
|
}
|
|
|
|
test('an abandoned turn rejects the approval it left open', async (t) => {
|
|
const runs: RecordedRun[] = [];
|
|
const seen: PiggyApprovalDecision[] = [];
|
|
const base = await startForTest(t, runs, {
|
|
// Long enough that the deadline cannot be what settles this: the abandoned
|
|
// turn has to do it, or the assertion below is measuring the timeout.
|
|
approvalTimeoutMs: 60_000,
|
|
createWriteTools: abandonableWriteTools(seen),
|
|
createSession: fakeSessions(writingScript, spy()),
|
|
});
|
|
|
|
const abort = new AbortController();
|
|
const response = await fetch(`${base}/internal/chat`, {
|
|
method: 'POST',
|
|
headers: authorised,
|
|
body: chatBody({ mode: 'confirm' }),
|
|
signal: abort.signal,
|
|
});
|
|
const { frames } = await readUntilApproval(response);
|
|
const asked = frames.find((frame) => frame.type === 'approval_required');
|
|
assert.ok(asked && asked.type === 'approval_required');
|
|
|
|
// The user closes the tab with the card still on screen.
|
|
abort.abort();
|
|
await waitFor(() => runs[0]?.closed !== undefined);
|
|
|
|
assert.equal(runs[0]?.closed?.status, 'aborted');
|
|
assert.deepEqual(seen, ['reject'], 'the tool was told the change was not approved');
|
|
// And the card is gone from the registry rather than sitting there waiting
|
|
// out its five minutes: an answer arriving now settles nothing.
|
|
const late = await fetch(`${base}/internal/approve`, {
|
|
method: 'POST',
|
|
headers: authorised,
|
|
body: JSON.stringify({ conversationId: 'conv-1', changeId: asked.change.id, decision: 'apply' }),
|
|
});
|
|
assert.equal(late.status, 404);
|
|
assert.deepEqual(seen, ['reject'], 'a late approval cannot revive an abandoned change');
|
|
});
|
|
|
|
test('a decision for an unknown change settles nothing, and says so', async (t) => {
|
|
const runs: RecordedRun[] = [];
|
|
const base = await startForTest(t, runs, { createSession: fakeSessions(async () => {}, spy()) });
|
|
|
|
const unknown = await fetch(`${base}/internal/approve`, {
|
|
method: 'POST',
|
|
headers: authorised,
|
|
body: JSON.stringify({
|
|
conversationId: 'conv-1',
|
|
changeId: '30000000-0000-4000-8000-000000000009',
|
|
decision: 'apply',
|
|
}),
|
|
});
|
|
assert.equal(unknown.status, 404);
|
|
|
|
const malformed = await fetch(`${base}/internal/approve`, {
|
|
method: 'POST',
|
|
headers: authorised,
|
|
body: JSON.stringify({ conversationId: 'conv-1', changeId: 'nope', decision: 'apply' }),
|
|
});
|
|
assert.equal(malformed.status, 400);
|
|
assert.deepEqual(await malformed.json(), { error: 'Invalid Piggy approval decision.' });
|
|
});
|
|
|
|
async function waitFor(condition: () => boolean): Promise<void> {
|
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
if (condition()) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
assert.fail('the run was never closed');
|
|
}
|