Add Motion: the go-to-market operating system on top of the ledger
The ledger answers which contracted capacity is sold, to whom, at what margin. It says nothing about the motion — the repeatable practice that turns a customer conversation into a scoped deployment, and turns that deployment into something the next one reuses. Motion is deliberately not a parallel entity tree. DEMAND_STAGES already is the motion, so Motion binds reusable artefacts to the stages of a demand deal that already exists: an engagement hangs off one deal, cascade deleted, one per deal by unique constraint. Nine closed kinds, each declaring which stages it serves, and a starter library of twelve templates covering all eight open stages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,9 @@ export const PIGGY_PAGE_TOOL_NAMES = [
|
||||
'pig_get_idle_capacity',
|
||||
'pig_get_pipeline',
|
||||
'pig_get_calendar_ahead',
|
||||
'pig_get_motion_summary',
|
||||
'pig_search_motion_library',
|
||||
'pig_get_engagement',
|
||||
] as const;
|
||||
|
||||
export type PiggyPageToolName = (typeof PIGGY_PAGE_TOOL_NAMES)[number];
|
||||
@@ -97,6 +100,24 @@ const GUIDES: Partial<Record<PiggyPageRoute, PiggyPageGuide>> = {
|
||||
label: 'the contracts list — Piggy reads its dates here, not its terms',
|
||||
tool: 'pig_get_calendar_ahead',
|
||||
},
|
||||
'/motion': {
|
||||
label: 'the Motion home — stage coverage, the shared library, and recent promotions',
|
||||
tool: 'pig_get_motion_summary',
|
||||
},
|
||||
/*
|
||||
* The label says "shared" because the tool reads nothing else, and the model
|
||||
* is otherwise free to conclude that a template it cannot find is missing
|
||||
* rather than private. A user asking "where is my draft?" should be told
|
||||
* Piggy cannot see private drafts, not that no such template exists.
|
||||
*/
|
||||
'/motion/library': {
|
||||
label: 'the motion library — Piggy reads the shared templates here, never a private draft',
|
||||
tool: 'pig_search_motion_library',
|
||||
},
|
||||
'/motion/engagements': {
|
||||
label: 'the engagement list — the demand deals with a motion running against them',
|
||||
tool: 'pig_get_engagement',
|
||||
},
|
||||
'/imports': { label: 'the CSV import page', tool: 'pig_get_workspace_summary' },
|
||||
'/team': { label: 'the team and permissions page', tool: 'pig_get_workspace_summary' },
|
||||
'/facts': { label: 'the fact review queue', tool: 'pig_get_workspace_summary' },
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
import {
|
||||
CONSUMING_ALLOCATION_STATUSES,
|
||||
DEMAND_OPEN_STAGES,
|
||||
DEMAND_STAGES,
|
||||
MOTION_KINDS,
|
||||
RESERVING_ALLOCATION_STATUSES,
|
||||
SUPPLY_OPEN_STAGES,
|
||||
aggregateMargin,
|
||||
@@ -37,19 +39,27 @@ import {
|
||||
type AllocationInput,
|
||||
type CalendarEvent,
|
||||
type CalendarEventKind,
|
||||
type DemandStage,
|
||||
type MarginResult,
|
||||
type MotionKind,
|
||||
type PiggyPageRoute,
|
||||
} from '@pig/core';
|
||||
import {
|
||||
accounts,
|
||||
allocations,
|
||||
capacityCommitments,
|
||||
demandDeals,
|
||||
engagementArtifacts,
|
||||
engagements,
|
||||
motionTemplates,
|
||||
qualificationScores,
|
||||
supplyDeals,
|
||||
type Database,
|
||||
} from '@pig/db';
|
||||
import { CalendarService } from '@pig/api/src/services/calendar';
|
||||
import { and, gte, inArray, isNull } from 'drizzle-orm';
|
||||
import { and, desc, eq, gte, ilike, inArray, isNotNull, isNull, or, type SQL } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { likeFragment } from './chat-tools';
|
||||
import { piggyPageGuide, type PiggyPageToolName } from './page-routes';
|
||||
import { defineTool, type AgentTool } from './provider';
|
||||
|
||||
@@ -161,6 +171,72 @@ function pageTool(db: Database, name: PiggyPageToolName): AgentTool {
|
||||
.strict(),
|
||||
execute: async ({ withinDays }) => readCalendarAhead(db, withinDays ?? 30),
|
||||
});
|
||||
case 'pig_get_motion_summary':
|
||||
return defineTool({
|
||||
name,
|
||||
description:
|
||||
'Read whether the go-to-market motion is repeating: which demand stages the shared ' +
|
||||
'library covers and which it does not, how many live engagements sit at each stage, ' +
|
||||
'the shared library by kind, and the artifacts most recently promoted back into it. ' +
|
||||
'Private drafts are not visible to this tool.',
|
||||
inputSchema: noInput,
|
||||
execute: async () => readMotionSummary(db),
|
||||
});
|
||||
case 'pig_search_motion_library':
|
||||
return defineTool({
|
||||
name,
|
||||
description:
|
||||
'Search the shared motion library — discovery guides, qualification frameworks, POC ' +
|
||||
'structures, proposal blocks, pricing inputs, reference architectures, case studies, ' +
|
||||
'technical narratives and deployment playbooks. Returns the newest version of each ' +
|
||||
'template. Private drafts are never searched, whoever is asking.',
|
||||
inputSchema: z
|
||||
.object({
|
||||
kind: z
|
||||
.enum(MOTION_KINDS)
|
||||
.describe('Restrict to one kind of template. null for every kind.')
|
||||
.nullish(),
|
||||
stage: z
|
||||
.enum(DEMAND_STAGES)
|
||||
.describe('Restrict to templates serving one demand stage. null for every stage.')
|
||||
.nullish(),
|
||||
query: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2)
|
||||
.max(64)
|
||||
.describe(
|
||||
'Word or phrase matched against the title, summary and slug. null returns the ' +
|
||||
'whole shared library.',
|
||||
)
|
||||
.nullish(),
|
||||
})
|
||||
.strict(),
|
||||
execute: async (filter) => readMotionLibrary(db, filter),
|
||||
});
|
||||
case 'pig_get_engagement':
|
||||
return defineTool({
|
||||
name,
|
||||
description:
|
||||
'Read the engagements running against demand deals: which stage the deal sits at, ' +
|
||||
'how many artifacts have been produced and how many are final, and the latest ' +
|
||||
'qualification score with its band.',
|
||||
inputSchema: z
|
||||
.object({
|
||||
query: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2)
|
||||
.max(64)
|
||||
.describe(
|
||||
'Word or phrase matched against the deal name and the engagement summary. ' +
|
||||
'null returns the most recently opened engagements.',
|
||||
)
|
||||
.nullish(),
|
||||
})
|
||||
.strict(),
|
||||
execute: async ({ query }) => readEngagements(db, query ?? null),
|
||||
});
|
||||
case 'pig_get_workspace_summary':
|
||||
return defineTool({
|
||||
name,
|
||||
@@ -585,6 +661,325 @@ function countByState(events: readonly CalendarEvent[]): Record<string, number>
|
||||
return counts;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The motion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MotionLibraryFilter {
|
||||
kind?: MotionKind | null;
|
||||
stage?: DemandStage | null;
|
||||
query?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared templates only, unconditionally — not "unless the asker owns it".
|
||||
*
|
||||
* Motion is the one place in PIG with a row-level access rule: a `private`
|
||||
* template is readable by its owner and by a platform admin, and by nobody
|
||||
* else. Piggy has no reliable notion of who is asking. The dock publishes a
|
||||
* route, the relay checks a capability, and the tool then runs as the process;
|
||||
* nothing reaches this query that identifies a person strongly enough to widen
|
||||
* it on. So it is not widened. A model that can be argued into reading a
|
||||
* colleague's private draft is a leak with the extra step of asking politely,
|
||||
* and the argument would arrive as ordinary conversation the guard never sees.
|
||||
*
|
||||
* This is the only clause here that must not become a parameter. If private
|
||||
* drafts ever need an answer, the identity has to arrive with the request and
|
||||
* be enforced in `services/motion.ts` where the API already enforces it — not
|
||||
* by relaxing this. `motion-tools.test.ts` fails if it is.
|
||||
*/
|
||||
export function motionLibraryWhere(filter: MotionLibraryFilter): SQL {
|
||||
const conditions: SQL[] = [
|
||||
eq(motionTemplates.visibility, 'shared'),
|
||||
isNull(motionTemplates.archivedAt),
|
||||
];
|
||||
if (filter.kind) conditions.push(eq(motionTemplates.kind, filter.kind));
|
||||
if (filter.stage) conditions.push(eq(motionTemplates.stage, filter.stage));
|
||||
if (filter.query) {
|
||||
const fragment = likeFragment(filter.query);
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(motionTemplates.title, fragment),
|
||||
ilike(motionTemplates.summary, fragment),
|
||||
ilike(motionTemplates.slug, fragment),
|
||||
)!,
|
||||
);
|
||||
}
|
||||
return and(...conditions)!;
|
||||
}
|
||||
|
||||
interface LineageRow {
|
||||
slug: string;
|
||||
kind: MotionKind;
|
||||
stage: DemandStage;
|
||||
version: number;
|
||||
title: string;
|
||||
summary: string;
|
||||
usageCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per lineage, newest version winning.
|
||||
*
|
||||
* The library counts lineages rather than rows because a template promoted
|
||||
* three times is one piece of practice with a history, and counting its
|
||||
* versions reports a library four times the size of the one anybody can choose
|
||||
* from. The slug is the identity of the lineage — see the schema header.
|
||||
*/
|
||||
function newestPerSlug<Row extends { slug: string; version: number }>(rows: readonly Row[]): Row[] {
|
||||
const newest = new Map<string, Row>();
|
||||
for (const row of rows) {
|
||||
const held = newest.get(row.slug);
|
||||
if (!held || row.version > held.version) newest.set(row.slug, row);
|
||||
}
|
||||
return [...newest.values()];
|
||||
}
|
||||
|
||||
function countBy<Row>(rows: readonly Row[], key: (row: Row) => string): Record<string, number> {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const row of rows) counts[key(row)] = (counts[key(row)] ?? 0) + 1;
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* The question the /motion page exists to answer: is the motion repeating?
|
||||
*
|
||||
* Coverage is reported as the stages the shared library does NOT reach, not
|
||||
* only as a count, because "6 of 8 stages covered" is a figure nobody acts on
|
||||
* and "nothing covers procurement or deployment" is a piece of work. The two
|
||||
* closed stages are excluded throughout — a won deal has left the motion.
|
||||
*/
|
||||
async function readMotionSummary(db: Database): Promise<unknown> {
|
||||
const [libraryRead, engagementRead, promotions] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
slug: motionTemplates.slug,
|
||||
kind: motionTemplates.kind,
|
||||
stage: motionTemplates.stage,
|
||||
version: motionTemplates.version,
|
||||
title: motionTemplates.title,
|
||||
summary: motionTemplates.summary,
|
||||
usageCount: motionTemplates.usageCount,
|
||||
})
|
||||
.from(motionTemplates)
|
||||
.where(motionLibraryWhere({}))
|
||||
.limit(SCAN_LIMIT + 1),
|
||||
db
|
||||
.select({ stage: demandDeals.stage, dealName: demandDeals.name })
|
||||
.from(engagements)
|
||||
.innerJoin(demandDeals, eq(engagements.demandDealId, demandDeals.id))
|
||||
.where(eq(engagements.status, 'open'))
|
||||
.limit(SCAN_LIMIT + 1),
|
||||
db
|
||||
.select({
|
||||
title: motionTemplates.title,
|
||||
kind: motionTemplates.kind,
|
||||
version: motionTemplates.version,
|
||||
createdAt: motionTemplates.createdAt,
|
||||
})
|
||||
.from(motionTemplates)
|
||||
.where(and(motionLibraryWhere({}), isNotNull(motionTemplates.originArtifactId)))
|
||||
.orderBy(desc(motionTemplates.createdAt))
|
||||
.limit(EXEMPLARS),
|
||||
]);
|
||||
const { rows: templateRows, truncated: libraryTruncated } = bounded(libraryRead);
|
||||
const { rows: openEngagements, truncated: engagementsTruncated } = bounded(engagementRead);
|
||||
const truncated = libraryTruncated || engagementsTruncated;
|
||||
|
||||
const lineages = newestPerSlug(templateRows);
|
||||
const engagementsByStage = countBy(openEngagements, (row) => row.stage);
|
||||
const uncovered = DEMAND_OPEN_STAGES.filter(
|
||||
(stage) => !lineages.some((template) => template.stage === stage),
|
||||
);
|
||||
|
||||
return {
|
||||
headline:
|
||||
`${atLeast(lineages.length, libraryTruncated)} shared template(s) across ` +
|
||||
`${Object.keys(countBy(lineages, (row) => row.kind)).length} of ${MOTION_KINDS.length} ` +
|
||||
`kind(s), and ${atLeast(openEngagements.length, engagementsTruncated)} open engagement(s). ` +
|
||||
(uncovered.length === 0
|
||||
? 'Every live demand stage has at least one shared template.'
|
||||
: `No shared template covers ${uncovered.join(', ')}.`) +
|
||||
` ${promotions.length} artifact(s) promoted back into the library recently.` +
|
||||
(truncated ? ` ${TRUNCATION_NOTE}` : ''),
|
||||
truncated,
|
||||
sharedTemplates: lineages.length,
|
||||
openEngagements: openEngagements.length,
|
||||
uncoveredStages: uncovered,
|
||||
stages: DEMAND_OPEN_STAGES.map((stage) => ({
|
||||
stage,
|
||||
openEngagements: engagementsByStage[stage] ?? 0,
|
||||
sharedTemplates: lineages.filter((template) => template.stage === stage).length,
|
||||
})),
|
||||
libraryByKind: countBy(lineages, (row) => row.kind),
|
||||
// The loop made visible: what the last few engagements gave back.
|
||||
recentPromotions: promotions.map((row) => ({
|
||||
title: row.title,
|
||||
kind: row.kind,
|
||||
version: row.version,
|
||||
promotedAt: row.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function readMotionLibrary(db: Database, filter: MotionLibraryFilter): Promise<unknown> {
|
||||
const { rows, truncated } = bounded(
|
||||
await db
|
||||
.select({
|
||||
slug: motionTemplates.slug,
|
||||
kind: motionTemplates.kind,
|
||||
stage: motionTemplates.stage,
|
||||
version: motionTemplates.version,
|
||||
title: motionTemplates.title,
|
||||
summary: motionTemplates.summary,
|
||||
usageCount: motionTemplates.usageCount,
|
||||
})
|
||||
.from(motionTemplates)
|
||||
.where(motionLibraryWhere(filter))
|
||||
.orderBy(desc(motionTemplates.updatedAt))
|
||||
.limit(SCAN_LIMIT + 1),
|
||||
);
|
||||
|
||||
const lineages = newestPerSlug(rows as LineageRow[]);
|
||||
const described = [
|
||||
filter.kind ? `kind ${filter.kind}` : null,
|
||||
filter.stage ? `stage ${filter.stage}` : null,
|
||||
filter.query ? `"${filter.query}"` : null,
|
||||
].filter((part): part is string => part !== null);
|
||||
const scope = described.length ? ` matching ${described.join(', ')}` : '';
|
||||
|
||||
return {
|
||||
headline:
|
||||
(lineages.length === 0
|
||||
? `No shared template${scope}. Private drafts are not searched, so a template may ` +
|
||||
'exist and not be visible here.'
|
||||
: `${atLeast(lineages.length, truncated)} shared template(s)${scope}, newest version ` +
|
||||
'of each.') + (truncated ? ` ${TRUNCATION_NOTE}` : ''),
|
||||
truncated,
|
||||
count: lineages.length,
|
||||
byKind: countBy(lineages, (row) => row.kind),
|
||||
// Most recently updated first: the practice people are actually amending.
|
||||
templates: lineages.slice(0, EXEMPLARS).map((template) => ({
|
||||
slug: template.slug,
|
||||
kind: template.kind,
|
||||
stage: template.stage,
|
||||
version: template.version,
|
||||
title: template.title,
|
||||
summary: template.summary,
|
||||
// How many engagements have instantiated it — the only evidence here of
|
||||
// whether a template is practice or an unread document.
|
||||
usageCount: template.usageCount,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Engagements, with the two figures anyone asks for: how much has been produced
|
||||
* and where qualification landed.
|
||||
*
|
||||
* Artifacts and scores are read only for the exemplars, so the second and third
|
||||
* queries stay small however wide the book is. The engagement's playbook
|
||||
* template is deliberately not joined: it may be a private draft, and naming it
|
||||
* would walk round the library rule by another door.
|
||||
*/
|
||||
async function readEngagements(db: Database, query: string | null): Promise<unknown> {
|
||||
const fragment = query ? likeFragment(query) : null;
|
||||
const { rows, truncated } = bounded(
|
||||
await db
|
||||
.select({
|
||||
id: engagements.id,
|
||||
status: engagements.status,
|
||||
summary: engagements.summary,
|
||||
openedAt: engagements.openedAt,
|
||||
stage: demandDeals.stage,
|
||||
dealName: demandDeals.name,
|
||||
accountName: accounts.name,
|
||||
})
|
||||
.from(engagements)
|
||||
.innerJoin(demandDeals, eq(engagements.demandDealId, demandDeals.id))
|
||||
.leftJoin(accounts, eq(demandDeals.accountId, accounts.id))
|
||||
.where(
|
||||
fragment
|
||||
? or(ilike(demandDeals.name, fragment), ilike(engagements.summary, fragment))
|
||||
: undefined,
|
||||
)
|
||||
.orderBy(desc(engagements.openedAt))
|
||||
.limit(SCAN_LIMIT + 1),
|
||||
);
|
||||
|
||||
const exemplars = rows.slice(0, EXEMPLARS);
|
||||
const ids = exemplars.map((row) => row.id);
|
||||
const [artifacts, scores] = ids.length
|
||||
? await Promise.all([
|
||||
db
|
||||
.select({ engagementId: engagementArtifacts.engagementId, status: engagementArtifacts.status })
|
||||
.from(engagementArtifacts)
|
||||
.where(
|
||||
and(
|
||||
inArray(engagementArtifacts.engagementId, ids),
|
||||
isNull(engagementArtifacts.archivedAt),
|
||||
),
|
||||
)
|
||||
.limit(SCAN_LIMIT),
|
||||
db
|
||||
.select({
|
||||
engagementId: qualificationScores.engagementId,
|
||||
basisPoints: qualificationScores.basisPoints,
|
||||
band: qualificationScores.band,
|
||||
scoredAt: qualificationScores.scoredAt,
|
||||
})
|
||||
.from(qualificationScores)
|
||||
.where(inArray(qualificationScores.engagementId, ids))
|
||||
.orderBy(desc(qualificationScores.scoredAt))
|
||||
.limit(SCAN_LIMIT),
|
||||
])
|
||||
: [[], []];
|
||||
|
||||
// Newest first out of the query, so the first score seen for an engagement is
|
||||
// its latest; a later one must not overwrite it.
|
||||
const latestScore = new Map<string, (typeof scores)[number]>();
|
||||
for (const score of scores) if (!latestScore.has(score.engagementId)) latestScore.set(score.engagementId, score);
|
||||
|
||||
return {
|
||||
headline:
|
||||
(rows.length === 0
|
||||
? query
|
||||
? `No engagement matches "${query}".`
|
||||
: 'No demand deal has an engagement running against it yet.'
|
||||
: `${atLeast(rows.length, truncated)} engagement(s)${query ? ` matching "${query}"` : ''}, ` +
|
||||
`of which ${rows.filter((row) => row.status === 'open').length} open.`) +
|
||||
(truncated ? ` ${TRUNCATION_NOTE}` : ''),
|
||||
truncated,
|
||||
count: rows.length,
|
||||
byStatus: countBy(rows, (row) => row.status),
|
||||
byStage: countBy(rows, (row) => row.stage),
|
||||
engagements: exemplars.map((row) => {
|
||||
const mine = artifacts.filter((artifact) => artifact.engagementId === row.id);
|
||||
const score = latestScore.get(row.id);
|
||||
return {
|
||||
dealName: row.dealName,
|
||||
accountName: row.accountName,
|
||||
stage: row.stage,
|
||||
status: row.status,
|
||||
summary: row.summary,
|
||||
openedAt: row.openedAt.toISOString(),
|
||||
artifacts: mine.length,
|
||||
finalArtifacts: mine.filter((artifact) => artifact.status === 'final').length,
|
||||
latestScore: score
|
||||
? {
|
||||
// Basis points of the maximum, so a tenth of a per cent — the
|
||||
// band is the part a seller acts on.
|
||||
basisPoints: score.basisPoints,
|
||||
percent: `${(score.basisPoints / 100).toFixed(1)}%`,
|
||||
band: score.band,
|
||||
scoredAt: score.scoredAt.toISOString(),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The fallback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* The motion page tools, and the one rule none of them may relax.
|
||||
*
|
||||
* Motion is the first feature in PIG with a row-level access rule: a `private`
|
||||
* template belongs to its owner and to a platform admin, and to nobody else.
|
||||
* Every other read in this process is book-wide, so the habit of the codebase
|
||||
* is against this clause rather than for it — which is exactly why it is pinned
|
||||
* here rather than left to a review.
|
||||
*
|
||||
* Piggy cannot enforce ownership because it does not reliably know who is
|
||||
* asking: the dock publishes a route and the relay checks a capability, and
|
||||
* neither reaches the query. So the query is closed instead. The tests below
|
||||
* assert the closed form under every filter combination a model can send,
|
||||
* because the plausible-but-wrong version of this code is one where the clause
|
||||
* is present in the unfiltered read and lost in a branch.
|
||||
*
|
||||
* The unit suite runs in CI BEFORE the migration step, against a database with
|
||||
* no tables, so nothing here may execute a query. The WHERE clause is rendered
|
||||
* with `PgDialect` rather than run.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { MOTION_KINDS } from '@pig/core';
|
||||
import type { Database } from '@pig/db';
|
||||
import { PgDialect } from 'drizzle-orm/pg-core';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { assertPigToolBoundary } from '../src/chat';
|
||||
import { createPagePigTools, motionLibraryWhere } from '../src/page-tools';
|
||||
|
||||
/** Schema and SQL-shape checks only: no query is executed. */
|
||||
const db = {} as Database;
|
||||
const dialect = new PgDialect();
|
||||
|
||||
function renderedWhere(filter: Parameters<typeof motionLibraryWhere>[0]): {
|
||||
text: string;
|
||||
params: unknown[];
|
||||
} {
|
||||
const query = dialect.sqlToQuery(motionLibraryWhere(filter));
|
||||
return { text: query.sql, params: query.params };
|
||||
}
|
||||
|
||||
function tool(route: '/motion' | '/motion/library' | '/motion/engagements') {
|
||||
const [only, ...rest] = createPagePigTools(db, route);
|
||||
assert.ok(only, `${route} has a tool`);
|
||||
// One tool per page: a second is a second thing to choose wrongly, and a
|
||||
// wrong choice costs one of four turns.
|
||||
assert.equal(rest.length, 0);
|
||||
return only;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The visibility invariant. Do not relax this to "unless the user owns it".
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('the library query filters to shared templates UNCONDITIONALLY, under every filter', () => {
|
||||
const filters: Parameters<typeof motionLibraryWhere>[0][] = [
|
||||
{},
|
||||
{ kind: 'proposal' },
|
||||
{ stage: 'poc' },
|
||||
{ query: 'sovereign' },
|
||||
{ kind: 'playbook', stage: 'deployment', query: 'inference' },
|
||||
// The nulls a schema-abiding model sends for "no filter" must not read as
|
||||
// "no visibility filter either".
|
||||
{ kind: null, stage: null, query: null },
|
||||
];
|
||||
|
||||
for (const filter of filters) {
|
||||
const { params } = renderedWhere(filter);
|
||||
assert.ok(
|
||||
params.includes('shared'),
|
||||
`visibility = 'shared' is missing for ${JSON.stringify(filter)}`,
|
||||
);
|
||||
// Bound to the column, not merely present somewhere in the statement.
|
||||
assert.match(renderedWhere(filter).text, /"visibility" = \$\d+/);
|
||||
}
|
||||
});
|
||||
|
||||
test('no filter a model can send widens the library beyond shared — private is not a parameter', () => {
|
||||
const library = tool('/motion/library');
|
||||
const accepts = (input: unknown) => library.inputSchema.safeParse(input).success;
|
||||
|
||||
// `.strict()`, so every one of these is refused rather than ignored. A tool
|
||||
// that silently drops an unknown key teaches a model to keep trying.
|
||||
assert.equal(accepts({ visibility: 'private' }), false);
|
||||
assert.equal(accepts({ ownerUserId: '20000000-0000-4000-8000-000000000002' }), false);
|
||||
assert.equal(accepts({ includePrivate: true }), false);
|
||||
assert.equal(accepts({ all: '1' }), false);
|
||||
assert.equal(accepts({ kind: 'proposal' }), true);
|
||||
});
|
||||
|
||||
test('a private template is invisible even when its title is the search term', () => {
|
||||
// The query filter is an AND alongside the visibility clause, never an OR
|
||||
// beside it: an `or(...)` at the top level would make any matching title
|
||||
// satisfy the whole WHERE and return the private row.
|
||||
const { text, params } = renderedWhere({ query: 'Halcyon' });
|
||||
const [visibility] = text.split('and');
|
||||
assert.ok(visibility?.includes('"visibility"'), text);
|
||||
assert.ok(text.startsWith('('), text);
|
||||
assert.match(text, /^\("[a-z_]+"\."visibility" = \$1 and /);
|
||||
assert.equal(params[0], 'shared');
|
||||
});
|
||||
|
||||
test('archived templates are excluded from every library read', () => {
|
||||
// Archiving is how deletion works here, so a query that ignores it hands the
|
||||
// model practice somebody deliberately withdrew.
|
||||
assert.match(renderedWhere({}).text, /"archived_at" is null/);
|
||||
assert.match(renderedWhere({ kind: 'case_study' }).text, /"archived_at" is null/);
|
||||
});
|
||||
|
||||
test('LIKE wildcards in the model-supplied query are escaped, not honoured', () => {
|
||||
// Unescaped, `%` matches every shared template and the model is handed the
|
||||
// first eight as though they answered the question.
|
||||
assert.deepEqual(renderedWhere({ query: '%' }).params, ['shared', '%\\%%', '%\\%%', '%\\%%']);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The boundary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('every motion page tool sits inside the PIG tool boundary', () => {
|
||||
const tools = [tool('/motion'), tool('/motion/library'), tool('/motion/engagements')];
|
||||
assert.deepEqual(tools.map((entry) => entry.name), [
|
||||
'pig_get_motion_summary',
|
||||
'pig_search_motion_library',
|
||||
'pig_get_engagement',
|
||||
]);
|
||||
// The assertion the chat provider runs on every request: a name that fails it
|
||||
// takes the whole conversation down rather than one tool.
|
||||
assert.doesNotThrow(() => assertPigToolBoundary(tools));
|
||||
for (const entry of tools) {
|
||||
assert.ok(entry.description.length > 40, `${entry.name} has a usable description`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the library and engagement tools say out loud that private drafts are not read', () => {
|
||||
// The description is the only place the model learns the limit, and "I found
|
||||
// nothing" is a materially different answer from "I cannot see private
|
||||
// drafts" to someone looking at their own.
|
||||
assert.match(tool('/motion/library').description, /[Pp]rivate drafts are never searched/);
|
||||
assert.match(tool('/motion').description, /[Pp]rivate drafts are not visible/);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The input bounds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('the motion summary takes no input at all', () => {
|
||||
const summary = tool('/motion');
|
||||
assert.equal(summary.inputSchema.safeParse({}).success, true);
|
||||
assert.equal(summary.inputSchema.safeParse({ stage: 'poc' }).success, false);
|
||||
});
|
||||
|
||||
test('the library filters accept only ontology values, and a bounded query', () => {
|
||||
const library = tool('/motion/library');
|
||||
const accepts = (input: unknown) => library.inputSchema.safeParse(input).success;
|
||||
|
||||
for (const kind of MOTION_KINDS) assert.equal(accepts({ kind }), true);
|
||||
// A kind the model invented reaches the database as a cast error rather than
|
||||
// a miss, so it is refused at the schema.
|
||||
assert.equal(accepts({ kind: 'battlecard' }), false);
|
||||
assert.equal(accepts({ stage: 'closed_won' }), true);
|
||||
assert.equal(accepts({ stage: 'negotiation' }), false);
|
||||
// Trimmed before the length check, so trailing whitespace cannot smuggle a
|
||||
// one-character query past the floor and match the whole library.
|
||||
assert.equal(accepts({ query: ' a ' }), false);
|
||||
assert.equal(accepts({ query: 'x'.repeat(64) }), true);
|
||||
assert.equal(accepts({ query: 'x'.repeat(65) }), false);
|
||||
assert.equal(accepts({ query: 'x'.repeat(4000) }), false);
|
||||
});
|
||||
|
||||
test('the engagement query is bounded and refuses anything it does not name', () => {
|
||||
const engagement = tool('/motion/engagements');
|
||||
const accepts = (input: unknown) => engagement.inputSchema.safeParse(input).success;
|
||||
|
||||
assert.equal(accepts({}), true);
|
||||
assert.equal(accepts({ query: 'Halcyon' }), true);
|
||||
assert.equal(accepts({ query: 'a' }), false);
|
||||
assert.equal(accepts({ query: 'x'.repeat(65) }), false);
|
||||
assert.equal(accepts({ engagementId: '20000000-0000-4000-8000-000000000002' }), false);
|
||||
assert.equal(accepts({ limit: 500 }), false);
|
||||
});
|
||||
|
||||
/**
|
||||
* What the model is actually sent, rather than what the zod reads like.
|
||||
*
|
||||
* `zodToJsonSchema(..., { target: 'openAi' })` — the exact call both inference
|
||||
* paths make — emits an optional field as REQUIRED and nullable, so a
|
||||
* schema-abiding model sends `null` for every filter it does not want and
|
||||
* `.optional()` would reject the call. A `.describe()` applied after the
|
||||
* wrapper is dropped from the emitted schema entirely.
|
||||
*/
|
||||
test('an omitted motion filter arrives as the null the emitted schema asks for', () => {
|
||||
const library = tool('/motion/library');
|
||||
assert.equal(
|
||||
library.inputSchema.safeParse({ kind: null, stage: null, query: null }).success,
|
||||
true,
|
||||
);
|
||||
assert.equal(tool('/motion/engagements').inputSchema.safeParse({ query: null }).success, true);
|
||||
|
||||
const emitted = zodToJsonSchema(library.inputSchema, {
|
||||
$refStrategy: 'none',
|
||||
target: 'openAi',
|
||||
}) as { properties?: Record<string, { description?: string }>; required?: string[] };
|
||||
assert.deepEqual(emitted.required, ['kind', 'stage', 'query']);
|
||||
for (const [parameter, shape] of Object.entries(emitted.properties ?? {})) {
|
||||
assert.ok(
|
||||
shape.description && shape.description.length > 10,
|
||||
`${parameter} reaches the model with no description`,
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user