4394 lines
208 KiB
JavaScript
4394 lines
208 KiB
JavaScript
/**
|
||
* Skill pipeline check — Owliver skills and Board (UI) skills.
|
||
*
|
||
* Runs the *real* module graph through Vite, so `import.meta.glob`, the `@/`
|
||
* alias and the Markdown loading all behave exactly as they do in the app. That
|
||
* matters: the reliability problem this script exists for was never in the
|
||
* Markdown, it was in what the pipeline did with a definition it could only
|
||
* partly read, and a mock of that pipeline would have reproduced none of it.
|
||
*
|
||
* node scripts/skill-check.mjs # against source, as the dev server sees it
|
||
* node scripts/skill-check.mjs --dist # also assert the built bundle carries every definition
|
||
*
|
||
* Exits non-zero on failure, so it can gate a build.
|
||
*/
|
||
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import { createServer } from 'vite';
|
||
import { BASELINE_PATH, captureBaseline } from './owliver-capture.mjs';
|
||
|
||
const ROOT = process.cwd();
|
||
const results = [];
|
||
const record = (name, pass, detail = '') => {
|
||
results.push({ name, pass, detail });
|
||
const mark = pass ? ' ok ' : ' FAIL ';
|
||
console.log(`[${mark}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||
};
|
||
|
||
const server = await createServer({
|
||
root: ROOT,
|
||
server: { middlewareMode: true },
|
||
appType: 'custom',
|
||
logLevel: 'error',
|
||
});
|
||
|
||
const reg = await server.ssrLoadModule('/src/lib/skills/registry.js');
|
||
const placement = await server.ssrLoadModule('/src/components/ai-assistant/placement.js');
|
||
|
||
const contextFor = (pageKey) => Object.entries(placement.PLACEMENT_ROUTES)
|
||
.find(([route]) => reg.pageKeyForRoute(route) === pageKey)?.[1] ?? null;
|
||
|
||
/* ── 1. Discovery ─────────────────────────────────────────────────────────── */
|
||
console.log('\n── Discovery ──');
|
||
|
||
const onDisk = readdirSync(join(ROOT, 'src/skills'))
|
||
.flatMap((dir) => readdirSync(join(ROOT, 'src/skills', dir)).map((f) => `${dir}/${f}`))
|
||
.filter((f) => f.endsWith('.md'));
|
||
|
||
record(
|
||
'every .md under src/skills registers',
|
||
reg.SKILLS.length === onDisk.length,
|
||
`${reg.SKILLS.length} registered / ${onDisk.length} files`
|
||
);
|
||
|
||
record(
|
||
'no duplicate skill ids',
|
||
new Set(reg.SKILLS.map((s) => s.id)).size === reg.SKILLS.length,
|
||
`${new Set(reg.SKILLS.map((s) => s.id)).size} unique ids`
|
||
);
|
||
|
||
record(
|
||
'every skill declares at least one page',
|
||
reg.SKILLS.every((s) => s.pages.length > 0)
|
||
);
|
||
|
||
/* Both managed lists are non-empty — the Workspace → Skills tabs. */
|
||
const uiFacet = reg.skillsWithFacet(reg.SKILLS, 'ui');
|
||
const owliverFacet = reg.skillsWithFacet(reg.SKILLS, 'owliver');
|
||
record('Owliver Skills list is populated', owliverFacet.length > 0, `${owliverFacet.length} skills`);
|
||
record(
|
||
'Board Skills list resolves without error',
|
||
Array.isArray(uiFacet),
|
||
`${uiFacet.length} skills declare a \`ui:\` block`
|
||
);
|
||
|
||
/* ── 2. Registration integrity: nothing half-loads in silence ─────────────── */
|
||
console.log('\n── Registration integrity ──');
|
||
|
||
const clean = reg.readSkillRegistry([]);
|
||
record(
|
||
'shipped registry reports no diagnostics',
|
||
clean.diagnostics.length === 0,
|
||
clean.diagnostics.map((d) => d.message).join(' | ') || 'none'
|
||
);
|
||
|
||
record(
|
||
'every shipped skill registered every capability it declared',
|
||
reg.SKILLS.every((s) => !(s.owliverErrors?.length || s.uiErrors?.length))
|
||
);
|
||
|
||
/* An unreadable stored definition must be REPORTED, not swallowed. */
|
||
const brokenYaml = `---\nid: broken\n name: bad indent\n---\n# Broken\n`;
|
||
const withBroken = reg.readSkillRegistry([{ path: 'custom/broken.md', raw: brokenYaml }]);
|
||
record(
|
||
'unreadable stored skill is reported, not silently dropped',
|
||
withBroken.diagnostics.some((d) => d.kind === 'unreadable'),
|
||
withBroken.diagnostics.find((d) => d.kind === 'unreadable')?.message ?? 'NO DIAGNOSTIC'
|
||
);
|
||
|
||
/* A definition with one bad capability keeps the good one AND says so. */
|
||
const partial = `---
|
||
id: partial-check
|
||
name: Partial Check
|
||
description: One resolvable capability and one that names no source.
|
||
pages:
|
||
- positions
|
||
status: active
|
||
owliver:
|
||
enabled: true
|
||
capabilities:
|
||
- summary
|
||
- flow
|
||
responses:
|
||
summary:
|
||
source: position.activity
|
||
periods:
|
||
- today
|
||
---
|
||
|
||
# Partial Check
|
||
`;
|
||
const withPartial = reg.readSkillRegistry([{ path: 'custom/partial.md', raw: partial }]);
|
||
const partialSkill = withPartial.skills.find((s) => s.id === 'partial-check');
|
||
record(
|
||
'partly-resolvable skill still registers its good capability',
|
||
partialSkill?.owliver.capabilities.includes('summary'),
|
||
`capabilities = ${JSON.stringify(partialSkill?.owliver.capabilities)}`
|
||
);
|
||
record(
|
||
'and the capability it LOST is reported',
|
||
withPartial.diagnostics.some((d) => d.kind === 'incomplete' && d.skillId === 'partial-check'),
|
||
withPartial.diagnostics.find((d) => d.kind === 'incomplete')?.message ?? 'NO DIAGNOSTIC'
|
||
);
|
||
|
||
/* Shadowing a built-in is allowed, but must be visible. */
|
||
const shadow = `---\nid: create-position\nname: Shadow\ndescription: d\npages:\n - positions\nstatus: active\n---\n\n# Shadow\n`;
|
||
const withShadow = reg.readSkillRegistry([{ path: 'custom/shadow.md', raw: shadow }]);
|
||
record(
|
||
'a custom skill overriding a built-in is reported',
|
||
withShadow.diagnostics.some((d) => d.kind === 'shadowed' && d.skillId === 'create-position'),
|
||
withShadow.diagnostics.find((d) => d.kind === 'shadowed')?.message ?? 'NO DIAGNOSTIC'
|
||
);
|
||
|
||
/* ── 3. Markdown parsing: no silent content loss ──────────────────────────── */
|
||
console.log('\n── Markdown parsing ──');
|
||
|
||
const cp = reg.SKILLS.find((s) => s.id === 'create-position');
|
||
record(
|
||
'wrapped bullets are read whole',
|
||
cp.capabilities.some((c) => c.endsWith('out of a single sentence.')),
|
||
cp.capabilities.find((c) => c.includes('single sentence')) ?? 'TRUNCATED'
|
||
);
|
||
|
||
record(
|
||
'conversation steps all parse',
|
||
cp.conversation.length === 7 && cp.conversation.every((s) => s.field && s.question),
|
||
`${cp.conversation.length} steps`
|
||
);
|
||
|
||
const numbered = reg.parseSkill(
|
||
`---\nid: numbered\nname: Numbered\ndescription: d\npages:\n - positions\nstatus: active\n---\n\n`
|
||
+ `## Capabilities\n\n1. First instruction.\n2. Second instruction.\n3. Third instruction.\n`,
|
||
{ custom: true }
|
||
);
|
||
record(
|
||
'numbered instructions are preserved',
|
||
numbered.capabilities.length === 3,
|
||
JSON.stringify(numbered.capabilities)
|
||
);
|
||
|
||
/* A section ends at the next `##`, and a heading that merely starts with the
|
||
same word is a different heading. Both halves matter: the first is what stops
|
||
`Capabilities` swallowing the rest of the file, the second is what stops a
|
||
near-miss heading being read as the real one. */
|
||
const bounded = reg.parseSkill(
|
||
`---\nid: bounded\nname: Bounded\ndescription: d\npages:\n - positions\nstatus: active\n---\n\n`
|
||
+ `## Capabilities\n\n- one\n- two\n\n## Capabilities (v2)\n\n- three\n\n## Actions\n\n- four\n`,
|
||
{ custom: true }
|
||
);
|
||
record(
|
||
'a section stops at the next heading',
|
||
bounded.capabilities.length === 2,
|
||
JSON.stringify(bounded.capabilities)
|
||
);
|
||
|
||
/* Sub-headings (`###`) belong to the section they sit under, and must not end
|
||
it — `\n##\s` would match `### ` if the space were not required. */
|
||
const nested = reg.parseSkill(
|
||
`---\nid: nested\nname: Nested\ndescription: d\npages:\n - positions\nstatus: active\n---\n\n`
|
||
+ `## Capabilities\n\n- one\n\n### Detail\n\n- two\n\n## Actions\n\n- three\n`,
|
||
{ custom: true }
|
||
);
|
||
record(
|
||
'a `###` sub-heading does not truncate its section',
|
||
nested.capabilities.length === 2,
|
||
JSON.stringify(nested.capabilities)
|
||
);
|
||
|
||
/* Frontmatter of every shipped file parses and carries its required fields. */
|
||
let frontmatterOk = true;
|
||
const frontmatterDetail = [];
|
||
for (const s of reg.SKILLS) {
|
||
if (!s.id || !s.name || !s.pages.length) {
|
||
frontmatterOk = false;
|
||
frontmatterDetail.push(s.path);
|
||
}
|
||
}
|
||
record('every shipped skill has id, name and pages', frontmatterOk, frontmatterDetail.join(', '));
|
||
|
||
/* ── 4. Trigger / matching ────────────────────────────────────────────────── */
|
||
console.log('\n── Trigger matching ──');
|
||
|
||
const positions = contextFor('positions');
|
||
record('positions context resolves', Boolean(positions), String(positions));
|
||
|
||
const MATRIX = [
|
||
['create a position', 'create-position'],
|
||
['i want to hire', 'create-position'],
|
||
['post a job', 'create-position'],
|
||
['add a client', 'create-position'],
|
||
['show hiring activity', 'hiring-activity-assistant'],
|
||
['hiring flow', 'hiring-activity-assistant'],
|
||
['applications over time', 'hiring-activity-assistant'],
|
||
];
|
||
for (const [question, expected] of MATRIX) {
|
||
const got = reg.matchSkill(question, positions)?.id ?? null;
|
||
record(`trigger "${question}" -> ${expected}`, got === expected, got ?? 'no match');
|
||
}
|
||
|
||
/* Every claimant is enumerable, so a contested phrase is not invisible. */
|
||
const contender = `---
|
||
id: zz-contender
|
||
name: ZZ Contender
|
||
description: Also claims a phrase create-position owns.
|
||
pages:
|
||
- positions
|
||
status: active
|
||
triggers:
|
||
- create a position
|
||
owliver:
|
||
enabled: true
|
||
capabilities:
|
||
- summary
|
||
responses:
|
||
summary:
|
||
source: position.activity
|
||
periods:
|
||
- today
|
||
---
|
||
|
||
# ZZ Contender
|
||
`;
|
||
const contested = [{ path: 'custom/zz.md', raw: contender }];
|
||
const claimants = reg.matchSkills('create a position', positions, [], contested);
|
||
record(
|
||
'all trigger claimants are enumerable',
|
||
claimants.length === 2,
|
||
claimants.map((s) => s.id).join(', ')
|
||
);
|
||
record(
|
||
'a contested trigger is reported as a collision',
|
||
reg.readSkillRegistry(contested).diagnostics.some((d) => d.kind === 'trigger-collision'),
|
||
reg.readSkillRegistry(contested).diagnostics.find((d) => d.kind === 'trigger-collision')?.message ?? 'NO DIAGNOSTIC'
|
||
);
|
||
|
||
/* ── 5. Owliver suggestions ───────────────────────────────────────────────── */
|
||
console.log('\n── Suggestions ──');
|
||
|
||
const resolver = await server.ssrLoadModule('/src/lib/skills/owliverResolver.js');
|
||
const declared = resolver.owliverSuggestions(positions, [], [], {});
|
||
|
||
record(
|
||
'every declared suggestion names a capability the skill offers',
|
||
declared.every((c) => !c.skillCapability
|
||
|| reg.SKILLS.find((s) => s.id === c.skillId)?.owliver.capabilities.includes(c.skillCapability))
|
||
);
|
||
|
||
const byIntent = declared.map((c) => `${c.skillId}:${c.skillCapability}`);
|
||
record(
|
||
'no two declared suggestions resolve to one capability',
|
||
new Set(byIntent).size === byIntent.length,
|
||
byIntent.join(' | ')
|
||
);
|
||
|
||
const dynamic = await server.ssrLoadModule('/src/components/ai-assistant/dynamic.js');
|
||
|
||
/* The generator is exercised at three different data shapes: the labels must
|
||
track the data, and must never appear when the count behind them is zero. */
|
||
const factsFor = (n) => ({
|
||
postings: [], starvedPositions: new Array(n).fill({}), unscreened: new Array(n).fill({}),
|
||
scored: [], stalled: [], ranked: [], activity: [], missingCredentials: [],
|
||
});
|
||
const shapes = [0, 1, 7, 12];
|
||
let labelsOk = true;
|
||
const offenders = [];
|
||
for (const n of shapes) {
|
||
for (const chip of dynamic.buildPrompts('admin.positions', factsFor(n), null)) {
|
||
/* A complete intent asks or instructs. A raw fragment — "4 ready for
|
||
interview" — does neither, and is what this check exists to catch. */
|
||
const isIntent = /\?$/.test(chip.label) || /^(show|summari[sz]e|create|continue|compare|find|explain|what|which|who)\b/i.test(chip.label);
|
||
if (!isIntent) { labelsOk = false; offenders.push(`n=${n}: "${chip.label}"`); }
|
||
if (n === 0 && /\b0\b/.test(chip.label)) { labelsOk = false; offenders.push(`n=0 leaked a zero count: "${chip.label}"`); }
|
||
}
|
||
}
|
||
record('every generated label is a complete intent', labelsOk, offenders.join(' ; ') || 'all labels are intents');
|
||
|
||
/* Counts must move with the data rather than being baked in. */
|
||
const at7 = dynamic.buildPrompts('admin.positions', factsFor(7), null).map((c) => c.label);
|
||
const at12 = dynamic.buildPrompts('admin.positions', factsFor(12), null).map((c) => c.label);
|
||
record(
|
||
'labels are dynamic, not hardcoded',
|
||
at7.some((l) => l.includes('7')) && at12.some((l) => l.includes('12')) && !at7.some((l) => l.includes('12')),
|
||
`7 -> ${at7.find((l) => l.includes('7'))} | 12 -> ${at12.find((l) => l.includes('12'))}`
|
||
);
|
||
|
||
const atZero = dynamic.buildPrompts('admin.positions', factsFor(0), null).map((c) => c.label);
|
||
record(
|
||
'no count-bearing suggestion when the count is zero',
|
||
!atZero.some((l) => /\d/.test(l)),
|
||
atZero.join(' | ')
|
||
);
|
||
|
||
/**
|
||
* The workforce branch — the one the fragments came from.
|
||
*
|
||
* "4 ready for interview" and "2 strong for Event Server" were generated here,
|
||
* so a suggestion check that passes `null` for workforce proves nothing about
|
||
* them. Applications are dated today so the "applied today" branch is live too.
|
||
*/
|
||
const workforceAt = (readyCount) => {
|
||
const position = { id: 'p1', title: 'Event Server – Fine Dining', status: 'active', headcount: 5 };
|
||
const today = new Date();
|
||
const applications = Array.from({ length: readyCount }, (_, i) => ({
|
||
id: `a${i}`,
|
||
job_posting_id: 'p1',
|
||
email: `c${i}@example.com`,
|
||
applicant_name: `Candidate ${i}`,
|
||
status: i % 2 ? 'ai_screened' : 'shortlisted',
|
||
ai_score: 90,
|
||
created_date: today.toISOString(),
|
||
updated_date: today.toISOString(),
|
||
}));
|
||
return {
|
||
positions: [position],
|
||
currentPositionId: null,
|
||
context: { applications, today, profiles: [], staff: [], assignments: [] },
|
||
};
|
||
};
|
||
|
||
for (const n of [1, 4, 9]) {
|
||
const labels = dynamic
|
||
.buildPrompts('admin.positions', factsFor(2), workforceAt(n))
|
||
.map((c) => c.label);
|
||
|
||
const ready = labels.find((l) => /ready for interview/i.test(l));
|
||
record(
|
||
`workforce branch at ${n}: "ready for interview" is a complete question`,
|
||
Boolean(ready) && /\?$/.test(ready) && ready.includes(String(n)) && /^Which\b/.test(ready),
|
||
ready ?? 'NOT GENERATED'
|
||
);
|
||
|
||
/* Singular/plural has to track the count, or the fix trades one awkward
|
||
label for another. */
|
||
if (n === 1) {
|
||
record(
|
||
'a single candidate reads as one candidate',
|
||
ready === 'Which 1 candidate is ready for interview?',
|
||
ready ?? 'NOT GENERATED'
|
||
);
|
||
}
|
||
|
||
const fragment = labels.find((l) => /^\d+\s/.test(l));
|
||
record(
|
||
`workforce branch at ${n}: no bare status fragments`,
|
||
!fragment,
|
||
fragment ?? 'none'
|
||
);
|
||
}
|
||
|
||
/* ── 6. Authoring: what the editors save, and what they refuse ────────────── */
|
||
console.log('\n── Authoring ──');
|
||
|
||
const fields = await server.ssrLoadModule('/src/lib/skills/skillFields.js');
|
||
const templates = await server.ssrLoadModule('/src/lib/skills/customSkills.js');
|
||
const surfaces = await server.ssrLoadModule('/src/lib/skills/surfaces.js');
|
||
|
||
/* A template must produce something that works before it is edited. The Board
|
||
template used to default to a source its own default placement cannot read. */
|
||
record(
|
||
'the Board template validates as written',
|
||
reg.validateSkillSource(templates.uiSkillTemplate({
|
||
id: 'template-check', name: 'Template Check', pages: ['positions'],
|
||
})) === null,
|
||
reg.validateSkillSource(templates.uiSkillTemplate({
|
||
id: 'template-check', name: 'Template Check', pages: ['positions'],
|
||
})) || 'valid'
|
||
);
|
||
|
||
/* An Owliver block that can answer nothing is refused rather than saved and
|
||
then reported as a skill that does not work. */
|
||
record(
|
||
'an Owliver skill with no capabilities is refused',
|
||
Boolean(reg.validateSkillSource(templates.owliverSkillTemplate({
|
||
id: 'empty-owliver', name: 'Empty Owliver', pages: ['positions'],
|
||
}))),
|
||
reg.validateSkillSource(templates.owliverSkillTemplate({
|
||
id: 'empty-owliver', name: 'Empty Owliver', pages: ['positions'],
|
||
})) || 'ACCEPTED — should have been refused'
|
||
);
|
||
|
||
/* The section-can-never-resolve check, in both directions. */
|
||
const boardOn = (page, placement) => `---
|
||
id: context-check
|
||
name: Context Check
|
||
description: Reads position activity.
|
||
pages:
|
||
- ${page}
|
||
status: active
|
||
ui:
|
||
type: flow
|
||
placement: ${placement}
|
||
source: position.activity
|
||
---
|
||
|
||
# Context Check
|
||
`;
|
||
|
||
record(
|
||
'a position source on a page with no position is refused',
|
||
Boolean(reg.validateSkillSource(boardOn('analytics', 'after-header'))),
|
||
reg.validateSkillSource(boardOn('analytics', 'after-header')) || 'ACCEPTED — should have been refused'
|
||
);
|
||
record(
|
||
'the same source is refused above the Positions grid',
|
||
Boolean(reg.validateSkillSource(boardOn('positions', 'after-position-list'))),
|
||
reg.validateSkillSource(boardOn('positions', 'after-position-list')) || 'ACCEPTED — should have been refused'
|
||
);
|
||
record(
|
||
'and accepted inside a position card',
|
||
reg.validateSkillSource(boardOn('positions', 'after-position-card')) === null,
|
||
reg.validateSkillSource(boardOn('positions', 'after-position-card')) || 'accepted'
|
||
);
|
||
|
||
/* Owliver responses are deliberately NOT subject to that rule: an unmet need is
|
||
a question back, not a dead card. `hiring-activity-assistant` depends on it. */
|
||
record(
|
||
'a shipped Owliver skill reading one position still validates',
|
||
reg.validateSkillSource(
|
||
reg.SKILLS.find((s) => s.id === 'hiring-activity-assistant').markdown
|
||
) === null
|
||
);
|
||
|
||
record(
|
||
'every shipped definition still validates',
|
||
reg.SKILLS.every((s) => reg.validateSkillSource(s.markdown) === null),
|
||
reg.SKILLS.filter((s) => reg.validateSkillSource(s.markdown)).map((s) => s.id).join(', ') || 'all valid'
|
||
);
|
||
|
||
/* Reading a definition into the editor fields — the upload path. */
|
||
for (const skill of reg.SKILLS.filter((s) => s.kind === 'assistant')) {
|
||
const read = fields.owliverFieldsFromSource(skill.markdown);
|
||
record(
|
||
`\`${skill.id}\` reads back into the Owliver fields`,
|
||
read.id === skill.id && read.name === skill.name
|
||
&& read.pages.join(',') === skill.pages.join(','),
|
||
`${read.name} / ${read.pages.join(', ')}`
|
||
);
|
||
}
|
||
|
||
record(
|
||
'an unparseable file leaves the fields empty rather than throwing',
|
||
fields.owliverFieldsFromSource('not a definition').id === ''
|
||
);
|
||
|
||
/* Writing a field back — the half that used to do nothing. */
|
||
const original = reg.SKILLS.find((s) => s.id === 'hiring-activity-assistant').markdown;
|
||
const renamed = fields.patchFrontmatter(original, { name: 'Renamed Assistant' });
|
||
|
||
record(
|
||
'patching a field changes what the registry reads',
|
||
reg.parseSkill(renamed, { custom: true }).name === 'Renamed Assistant',
|
||
reg.parseSkill(renamed, { custom: true }).name
|
||
);
|
||
record(
|
||
'patching a field leaves the body untouched',
|
||
reg.parseSkill(renamed, { custom: true }).body === reg.parseSkill(original, { custom: true }).body
|
||
);
|
||
record(
|
||
'patching a field preserves frontmatter comments',
|
||
renamed.includes('# One suggestion per capability.')
|
||
);
|
||
record(
|
||
'patching a field changes nothing else',
|
||
original.split('\n').filter((l) => !l.startsWith('name:')).join('\n')
|
||
=== renamed.split('\n').filter((l) => !l.startsWith('name:')).join('\n')
|
||
);
|
||
record(
|
||
'a patched definition still validates',
|
||
reg.validateSkillSource(renamed) === null,
|
||
reg.validateSkillSource(renamed) || 'valid'
|
||
);
|
||
|
||
const repaged = fields.patchFrontmatter(original, { pages: ['analytics', 'activity'] });
|
||
record(
|
||
'patching a list replaces the whole block',
|
||
reg.parseSkill(repaged, { custom: true }).pages.join(',') === 'analytics,activity',
|
||
reg.parseSkill(repaged, { custom: true }).pages.join(',')
|
||
);
|
||
|
||
const restatused = fields.patchFrontmatter(original, { status: 'inactive' });
|
||
record(
|
||
'patching a key the file never declared adds it',
|
||
reg.parseSkill(restatused, { custom: true }).status === 'inactive'
|
||
);
|
||
|
||
/* Both keys, because that is what the editor's own field handler writes: the
|
||
registry reads capabilities as the union of the declared list and the keys of
|
||
`responses:`, so patching one without the other changes nothing. */
|
||
const recapped = fields.patchFrontmatter(original, {
|
||
'owliver.capabilities': ['summary'],
|
||
'owliver.responses': { summary: { source: 'position.activity', periods: ['today'] } },
|
||
});
|
||
record(
|
||
'patching a nested block rewrites only that block',
|
||
reg.parseSkill(recapped, { custom: true }).owliver.capabilities.join(',') === 'summary',
|
||
reg.parseSkill(recapped, { custom: true }).owliver.capabilities.join(',')
|
||
);
|
||
/* Asserted on the text, not on the parse: with `flow` no longer offered, the
|
||
registry correctly drops the suggestion that names it — which is the rule
|
||
working, not the patch reaching a sibling key it should not have. */
|
||
record(
|
||
'and leaves its siblings in the same block alone',
|
||
recapped.includes('Summarize hiring activity for this position')
|
||
&& recapped.includes('Show hiring activity as a flow')
|
||
&& recapped.includes(' enabled: true')
|
||
);
|
||
|
||
/* The two `ui:` shapes, told apart — the per-page form must not be overwritten
|
||
from four single-valued fields. */
|
||
record(
|
||
'the shorthand `ui:` form is recognised',
|
||
fields.uiShape(boardOn('positions', 'after-position-card')) === 'shorthand'
|
||
);
|
||
record(
|
||
'a definition with no `ui:` block reports none',
|
||
fields.uiShape(original) === 'none'
|
||
);
|
||
|
||
/* The picker and the validator read the same table. */
|
||
record(
|
||
'a position card supplies a position',
|
||
surfaces.contextSuppliedBy(['positions'], 'after-position-card').includes('positionId')
|
||
);
|
||
record(
|
||
'the Positions list supplies nothing',
|
||
surfaces.contextSuppliedBy(['positions'], 'after-position-list').length === 0
|
||
);
|
||
record(
|
||
'Analytics supplies nothing',
|
||
surfaces.contextSuppliedBy(['analytics'], 'after-header').length === 0
|
||
);
|
||
record(
|
||
'every surface declares what its placements provide',
|
||
surfaces.SKILL_SURFACES.every((s) => s.provides && typeof s.provides === 'object'),
|
||
surfaces.SKILL_SURFACES.filter((s) => !s.provides).map((s) => s.id).join(', ') || 'all declared'
|
||
);
|
||
|
||
/* ── 7. Upload hydration: a file, into the fields ─────────────────────────── */
|
||
console.log('\n── Upload hydration ──');
|
||
|
||
/**
|
||
* The shapes people actually upload.
|
||
*
|
||
* Every case here is a definition that arrived from outside the editors, which
|
||
* is the only way most definitions arrive. What is being asserted is not that
|
||
* the parser is lenient — it is that the three identity fields, the pages and
|
||
* the section a reader can see in the file are the ones the form shows.
|
||
*/
|
||
const COMPLETE = `---
|
||
id: hiring-activity-assistant
|
||
name: Hiring Activity Assistant
|
||
description: Answer questions about recent hiring activity on a position.
|
||
type: board
|
||
ui:
|
||
- page: Positions
|
||
placement: grid-card
|
||
source: position.activity
|
||
---
|
||
|
||
# Hiring Activity Assistant
|
||
`;
|
||
|
||
const NO_ID = `---
|
||
name: Hiring Activity Assistant
|
||
description: Answer questions about recent hiring activity.
|
||
pages:
|
||
- positions
|
||
---
|
||
|
||
# Hiring Activity Assistant
|
||
`;
|
||
|
||
const MULTI_PAGE = `---
|
||
id: hiring-activity
|
||
name: Hiring Activity
|
||
description: Hiring activity across the workspace.
|
||
ui:
|
||
- page: Positions
|
||
placement: grid-card
|
||
source: candidates.activity
|
||
- page: Analytics
|
||
placement: panel
|
||
source: hires.performance
|
||
---
|
||
|
||
# Hiring Activity
|
||
`;
|
||
|
||
/* Case 1 — a complete definition hydrates every field it declares. */
|
||
{
|
||
const f = fields.boardFieldsFromSource(COMPLETE);
|
||
record('upload: name hydrates', f.name === 'Hiring Activity Assistant', f.name || 'EMPTY');
|
||
record('upload: id hydrates', f.id === 'hiring-activity-assistant', f.id || 'EMPTY');
|
||
record(
|
||
'upload: description hydrates',
|
||
f.description === 'Answer questions about recent hiring activity on a position.',
|
||
f.description || 'EMPTY'
|
||
);
|
||
record('upload: pages come from the `ui:` entries', f.pages.join(',') === 'positions', f.pages.join(',') || 'EMPTY');
|
||
record('upload: `grid-card` resolves to a real placement', f.placement === 'after-position-card', f.placement || 'EMPTY');
|
||
record('upload: source hydrates', f.source === 'position.activity', f.source || 'EMPTY');
|
||
record('upload: an undeclared type is inferred from the source', f.type === 'flow', f.type || 'EMPTY');
|
||
record('upload: the definition validates as written', reg.validateSkillSource(COMPLETE) === null,
|
||
reg.validateSkillSource(COMPLETE) || 'valid');
|
||
record('upload: it is classified as a Board skill',
|
||
fields.facetsFromSource(COMPLETE).join(',') === 'ui', fields.facetsFromSource(COMPLETE).join(','));
|
||
|
||
/* The same file read by the other editor's reader — one pipeline, two views. */
|
||
const o = fields.owliverFieldsFromSource(COMPLETE);
|
||
record('upload: the Owliver reader hydrates the same identity',
|
||
o.id === f.id && o.name === f.name && o.description === f.description);
|
||
}
|
||
|
||
/* Case 2 — no `id:`, so it is slugged from the name and never invented. */
|
||
{
|
||
const f = fields.boardFieldsFromSource(NO_ID);
|
||
record('upload: a missing id slugs the name', f.id === 'hiring-activity-assistant', f.id || 'EMPTY');
|
||
record('upload: a missing id does not become the placeholder path', f.id !== 'custom');
|
||
record('upload: an explicit id is never replaced by a generated one',
|
||
fields.boardFieldsFromSource(COMPLETE).id === 'hiring-activity-assistant');
|
||
record('upload: a file with no frontmatter hydrates nothing',
|
||
fields.boardFieldsFromSource('# Just a heading\n').id === '');
|
||
}
|
||
|
||
/* Case 3 — several pages, and every entry survives a field edit. */
|
||
{
|
||
const f = fields.boardFieldsFromSource(MULTI_PAGE);
|
||
record('upload: every page in the list is reported', f.pages.join(',') === 'positions,analytics', f.pages.join(','));
|
||
|
||
const parsed = reg.parseSkill(MULTI_PAGE, { custom: true });
|
||
record('upload: every entry becomes a section',
|
||
parsed.ui.positions.sections.length === 1 && parsed.ui.analytics.sections.length === 1);
|
||
record('upload: each entry keeps its own source',
|
||
parsed.ui.positions.sections[0].source === 'candidates.activity'
|
||
&& parsed.ui.analytics.sections[0].source === 'hires.performance');
|
||
record('upload: a multi-page definition validates', reg.validateSkillSource(MULTI_PAGE) === null,
|
||
reg.validateSkillSource(MULTI_PAGE) || 'valid');
|
||
|
||
/* The fields must not be able to flatten it. */
|
||
record('upload: the section fields are read-only against a list', !fields.uiIsEditableFromFields(MULTI_PAGE));
|
||
record('upload: a single-section definition stays field-editable',
|
||
fields.uiIsEditableFromFields(templates.uiSkillTemplate({ id: 'x', name: 'X', pages: ['positions'] })));
|
||
|
||
/* Renaming is identity, not structure: it must still work, and must not
|
||
touch either entry. */
|
||
const renamedMulti = fields.patchFrontmatter(MULTI_PAGE, { name: 'Renamed Multi' });
|
||
const after = reg.parseSkill(renamedMulti, { custom: true });
|
||
record('upload: renaming a multi-page definition keeps both entries',
|
||
after.ui.positions?.sections.length === 1 && after.ui.analytics?.sections.length === 1,
|
||
Object.keys(after.ui).join(','));
|
||
record('upload: ...and actually renames it', after.name === 'Renamed Multi', after.name);
|
||
record('upload: ...and leaves the `ui:` text byte-identical',
|
||
renamedMulti.slice(renamedMulti.indexOf('ui:')) === MULTI_PAGE.slice(MULTI_PAGE.indexOf('ui:')));
|
||
}
|
||
|
||
/* Files as they actually arrive: from Windows, from a download, from paste. */
|
||
{
|
||
const dirty = {
|
||
'a byte-order mark': `${COMPLETE}`,
|
||
'CRLF line endings': COMPLETE.replace(/\n/g, '\r\n'),
|
||
'a BOM and CRLF': `${COMPLETE.replace(/\n/g, '\r\n')}`,
|
||
'a blank line above the fence': `\n\n${COMPLETE}`,
|
||
'trailing spaces on the fence': COMPLETE.replace(/^---$/gm, '--- '),
|
||
};
|
||
for (const [what, md] of Object.entries(dirty)) {
|
||
const f = fields.boardFieldsFromSource(md);
|
||
record(
|
||
`upload: a file with ${what} still hydrates`,
|
||
f.name === 'Hiring Activity Assistant' && f.id === 'hiring-activity-assistant'
|
||
&& f.pages.join(',') === 'positions',
|
||
`${f.name || 'EMPTY'} / ${f.id || 'EMPTY'} / ${f.pages.join(',') || 'EMPTY'}`
|
||
);
|
||
record(
|
||
`upload: ...and the registry reads it the same way`,
|
||
reg.parseSkill(md, { custom: true }).name === 'Hiring Activity Assistant',
|
||
reg.parseSkill(md, { custom: true }).name
|
||
);
|
||
record(`upload: ...and it validates`, reg.validateSkillSource(md) === null,
|
||
reg.validateSkillSource(md) || 'valid');
|
||
}
|
||
|
||
/* Normalising on the way in is what keeps `patchFrontmatter` safe: an
|
||
unrecognised fence would have it write a second one above the first. */
|
||
const patchedDirty = fields.patchFrontmatter(fields.normalizeUpload(`${COMPLETE}`), { name: 'Clean' });
|
||
record('upload: patching a normalised file writes one frontmatter block',
|
||
(patchedDirty.match(/^---$/gm) || []).length === 2,
|
||
`${(patchedDirty.match(/^---$/gm) || []).length} fences`);
|
||
record('upload: ...and it still parses', reg.parseSkill(patchedDirty, { custom: true }).name === 'Clean');
|
||
|
||
/* A file with nothing to read must be refused, not read as a blank skill. */
|
||
record('upload: a file with no fence is not readable', !fields.isReadableDefinition('# Just prose\n'));
|
||
record('upload: a real definition is readable', fields.isReadableDefinition(COMPLETE));
|
||
}
|
||
|
||
/* Page names and placements as they are written in the product, not as the
|
||
vocabulary spells them internally. */
|
||
record('upload: `Positions` resolves to the positions surface', surfaces.canonicalPage('Positions') === 'positions');
|
||
record('upload: `Talent Pool` resolves to the talent-pool surface', surfaces.canonicalPage('Talent Pool') === 'talent-pool');
|
||
record('upload: `panel` on Analytics resolves to a real placement',
|
||
surfaces.placementFor('analytics', 'panel') === 'after-header');
|
||
record('upload: an alias never resolves onto a surface that lacks it',
|
||
surfaces.placementFor('analytics', 'grid-card') === null);
|
||
record('upload: a canonical placement still resolves to itself',
|
||
surfaces.placementFor('positions', 'after-position-card') === 'after-position-card');
|
||
|
||
/* Case 4 — an existing definition reopened for editing. */
|
||
for (const skill of reg.SKILLS) {
|
||
const f = skill.facets?.includes('ui')
|
||
? fields.boardFieldsFromSource(skill.markdown)
|
||
: fields.owliverFieldsFromSource(skill.markdown);
|
||
record(
|
||
`reopening \`${skill.id}\` loads its identity unchanged`,
|
||
f.id === skill.id && f.name === skill.name && f.description === skill.description
|
||
&& f.pages.join(',') === skill.pages.join(','),
|
||
`${f.id} / ${f.name} / ${f.pages.join(', ')}`
|
||
);
|
||
}
|
||
|
||
/* Case 5 — a manual edit is not reverted by later synchronisation. */
|
||
{
|
||
/* The editor's own handler, in miniature: hydrate from the file, edit one
|
||
field, then edit an unrelated one. The first edit must survive the second. */
|
||
let draft = fields.owliverFieldsFromSource(COMPLETE);
|
||
let src = COMPLETE;
|
||
const edit = (patch) => {
|
||
draft = { ...draft, ...patch };
|
||
src = fields.patchFrontmatter(src, {
|
||
id: draft.id || undefined,
|
||
name: draft.name || undefined,
|
||
description: draft.description || undefined,
|
||
pages: draft.pages?.length && !fields.pagesAreDerived(src) ? draft.pages : undefined,
|
||
});
|
||
};
|
||
|
||
edit({ name: 'My Own Name' });
|
||
record('edit: a manual name reaches the artefact',
|
||
reg.parseSkill(src, { custom: true }).name === 'My Own Name');
|
||
|
||
edit({ description: 'My own description.' });
|
||
record('edit: a later edit does not revert the earlier one',
|
||
reg.parseSkill(src, { custom: true }).name === 'My Own Name',
|
||
reg.parseSkill(src, { custom: true }).name);
|
||
record('edit: ...and applies itself',
|
||
reg.parseSkill(src, { custom: true }).description === 'My own description.');
|
||
record('edit: ...and the `ui:` block is untouched throughout',
|
||
src.slice(src.indexOf('ui:')) === COMPLETE.slice(COMPLETE.indexOf('ui:')));
|
||
|
||
/* A second upload replaces the draft outright — it is a new source. */
|
||
const rehydrated = fields.owliverFieldsFromSource(MULTI_PAGE);
|
||
record('edit: a derived `pages:` is never written back',
|
||
!src.includes('\npages:'), src.includes('\npages:') ? 'pages: was inserted' : 'not written');
|
||
|
||
record('edit: a declared `pages:` still patches normally',
|
||
reg.parseSkill(
|
||
fields.patchFrontmatter(NO_ID, { pages: ['analytics'] }), { custom: true }
|
||
).pages.join(',') === 'analytics');
|
||
|
||
record('edit: an inherited trigger is not written into the file',
|
||
!src.includes('triggers:'), src.includes('triggers:') ? 'triggers: was materialised' : 'not written');
|
||
record('edit: a declared trigger is still read into the fields',
|
||
fields.owliverFieldsFromSource(
|
||
reg.SKILLS.find((x) => x.id === 'hiring-activity-assistant').markdown
|
||
).triggers.includes('hiring activity'));
|
||
record('edit: a definition with no triggers reads none',
|
||
fields.owliverFieldsFromSource(COMPLETE).triggers.length === 0,
|
||
JSON.stringify(fields.owliverFieldsFromSource(COMPLETE).triggers));
|
||
|
||
record('edit: a second upload hydrates from the new file',
|
||
rehydrated.id === 'hiring-activity' && rehydrated.name === 'Hiring Activity',
|
||
`${rehydrated.id} / ${rehydrated.name}`);
|
||
}
|
||
|
||
/* ── 8. Markdown ↔ manual equivalence ─────────────────────────────────────
|
||
The two authoring paths, asserted to produce one artefact.
|
||
|
||
A form that cannot express what the format can is not a shortcut to it: the
|
||
manual Owliver editor carried a single `source` shared by every selected
|
||
capability, so a capability chosen before a source was picked composed no
|
||
`responses:` block at all — and a definition whose capabilities all drop
|
||
registers, shows as active on its page, and contributes no chip. These check
|
||
that both doors compose the same definition, and that the one that cannot be
|
||
configured is refused rather than saved empty. */
|
||
console.log('\n── Markdown ↔ manual equivalence ──');
|
||
|
||
const dataResolver = await server.ssrLoadModule('/src/lib/skills/dataResolver.js');
|
||
const positionsContext = Object.entries(placement.PLACEMENT_ROUTES)
|
||
.find(([route]) => reg.pageKeyForRoute(route) === 'positions')?.[1];
|
||
|
||
/* The canonical model is what `parseSkill` makes of a definition. Comparing the
|
||
whole record would compare its Markdown too, which is the one thing the two
|
||
paths are allowed to differ on — so the runtime configuration is compared,
|
||
which is exactly what every consumer reads. */
|
||
const canonical = (skill) => JSON.parse(JSON.stringify({
|
||
id: skill.id,
|
||
name: skill.name,
|
||
description: skill.description,
|
||
status: skill.status,
|
||
pages: skill.pages,
|
||
kind: skill.kind,
|
||
triggers: skill.triggers,
|
||
ui: skill.ui,
|
||
owliver: skill.owliver,
|
||
}));
|
||
|
||
/* TEST A / B — the Board card, written both ways. */
|
||
const BOARD_MD = `---
|
||
id: board
|
||
name: Board
|
||
description: Helps Owliver understand, analyze, and act on the current task board.
|
||
pages:
|
||
- positions
|
||
status: active
|
||
ui:
|
||
type: card
|
||
placement: after-position-list-summary
|
||
title: Board
|
||
source: candidates.activity
|
||
periods:
|
||
- today
|
||
- last-7-days
|
||
- previous-month
|
||
---
|
||
|
||
# Board
|
||
`;
|
||
|
||
const boardManual = templates.uiSkillTemplate({
|
||
id: 'board',
|
||
name: 'Board',
|
||
description: 'Helps Owliver understand, analyze, and act on the current task board.',
|
||
pages: ['positions'],
|
||
type: 'card',
|
||
placement: 'after-position-list-summary',
|
||
title: 'Board',
|
||
source: 'candidates.activity',
|
||
periods: ['today', 'last-7-days', 'previous-month'],
|
||
});
|
||
|
||
record('TEST A: Markdown Board saves', reg.validateSkillSource(BOARD_MD) === null,
|
||
reg.validateSkillSource(BOARD_MD) || 'accepted');
|
||
record('TEST B: manual Board saves', reg.validateSkillSource(boardManual) === null,
|
||
reg.validateSkillSource(boardManual) || 'accepted');
|
||
|
||
const boardFromMd = reg.parseSkill(BOARD_MD, { custom: true });
|
||
const boardFromForm = reg.parseSkill(boardManual, { custom: true });
|
||
const boardSection = (skill) => Object.values(skill.ui || {}).flatMap((pg) => pg.sections || [])[0];
|
||
|
||
record('TEST A: one section, above the position list',
|
||
Object.values(boardFromMd.ui).flatMap((pg) => pg.sections).length === 1
|
||
&& boardSection(boardFromMd).placement === 'after-position-list-summary',
|
||
`${boardSection(boardFromMd).type}@${boardSection(boardFromMd).placement}`);
|
||
record('TEST A: the Board card contributes no Owliver capability',
|
||
boardFromMd.owliver.enabled === false && boardFromMd.owliver.capabilities.length === 0);
|
||
record('TEST A: no unresolvable section', reg.unresolvableSections(boardFromMd).length === 0,
|
||
reg.unresolvableSections(boardFromMd)[0]?.message || 'none');
|
||
record('TEST B: manual Board renders the identical section',
|
||
JSON.stringify(boardSection(boardFromForm)) === JSON.stringify(boardSection(boardFromMd)),
|
||
JSON.stringify(boardSection(boardFromForm)));
|
||
record('TEST B: manual Board keeps its title', boardSection(boardFromForm).title === 'Board',
|
||
String(boardSection(boardFromForm).title));
|
||
|
||
/* TEST C / D — the conversational skill, written both ways. */
|
||
const OWLIVER_MD = `---
|
||
id: owliver-conversation-test
|
||
name: Hiring Activity Test
|
||
description: Shows hiring activity from the Positions page.
|
||
pages:
|
||
- positions
|
||
status: active
|
||
triggers:
|
||
- hiring activity test
|
||
owliver:
|
||
enabled: true
|
||
suggestions:
|
||
- Show hiring activity
|
||
- Summarize hiring activity
|
||
capabilities:
|
||
- summary
|
||
responses:
|
||
summary:
|
||
source: candidates.activity
|
||
periods:
|
||
- today
|
||
- last-7-days
|
||
- previous-month
|
||
---
|
||
|
||
# Hiring Activity Test
|
||
`;
|
||
|
||
const owliverManual = templates.owliverSkillTemplate({
|
||
id: 'owliver-conversation-test',
|
||
name: 'Hiring Activity Test',
|
||
description: 'Shows hiring activity from the Positions page.',
|
||
pages: ['positions'],
|
||
suggestions: ['Show hiring activity', 'Summarize hiring activity'],
|
||
capabilities: ['summary'],
|
||
responses: {
|
||
summary: { source: 'candidates.activity', periods: ['today', 'last-7-days', 'previous-month'] },
|
||
},
|
||
});
|
||
|
||
record('TEST C: Markdown Owliver skill saves', reg.validateSkillSource(OWLIVER_MD) === null,
|
||
reg.validateSkillSource(OWLIVER_MD) || 'accepted');
|
||
record('TEST D: manual Owliver skill saves', reg.validateSkillSource(owliverManual) === null,
|
||
reg.validateSkillSource(owliverManual) || 'accepted');
|
||
|
||
for (const [label, source] of [['TEST C', OWLIVER_MD], ['TEST D', owliverManual]]) {
|
||
const custom = [{ path: 'custom/owliver-conversation-test.md', raw: source }];
|
||
const skill = reg.parseSkill(source, { custom: true });
|
||
|
||
record(`${label}: the capability keeps its source`,
|
||
skill.owliver.responses.summary?.source === 'candidates.activity',
|
||
skill.owliver.responses.summary?.source || 'dropped');
|
||
record(`${label}: the capability keeps its periods`,
|
||
JSON.stringify(skill.owliver.responses.summary?.periods)
|
||
=== JSON.stringify(['today', 'last-7-days', 'previous-month']),
|
||
JSON.stringify(skill.owliver.responses.summary?.periods));
|
||
record(`${label}: it draws no page card`,
|
||
Object.keys(skill.ui).length === 0, `${Object.keys(skill.ui).length} pages`);
|
||
|
||
const chips = resolver.owliverSuggestions(positionsContext, [], custom, {});
|
||
const mine = chips.filter((c) => c.skillId === 'owliver-conversation-test');
|
||
record(`${label}: its suggestion reaches the panel`,
|
||
mine.some((c) => c.label === 'Show hiring activity'),
|
||
JSON.stringify(chips.map((c) => c.label)));
|
||
record(`${label}: the suggestion answers without asking for a record`,
|
||
mine.every((c) => !c.deferred));
|
||
|
||
const owlSkills = resolver.owliverSkillsForContext(positionsContext, [], custom);
|
||
const matched = resolver.matchOwliverSkill('Show hiring activity', owlSkills);
|
||
record(`${label}: clicking it routes to this skill's summary`,
|
||
matched?.skill.id === 'owliver-conversation-test' && matched?.capability === 'summary' && matched?.exact,
|
||
`${matched?.skill.id}:${matched?.capability} exact=${matched?.exact}`);
|
||
|
||
const answered = resolver.resolveOwliverResponse({
|
||
skill: matched.skill,
|
||
capability: matched.capability,
|
||
question: 'Show hiring activity',
|
||
context: {
|
||
positions: [{ id: 'p1', title: 'Line Cook', status: 'active' }],
|
||
applications: [{ id: 'a1', job_posting_id: 'p1', created_date: new Date().toISOString() }],
|
||
},
|
||
now: new Date(),
|
||
});
|
||
record(`${label}: it answers with the configured reading`,
|
||
answered?.missing === null && resolver.summaryLines(answered.data).length === 3,
|
||
JSON.stringify(resolver.summaryLines(answered?.data || {})));
|
||
}
|
||
|
||
/* TEST H — the same skill through both doors, normalized, compared. */
|
||
record('TEST H: Markdown and manual normalize identically (Board)',
|
||
JSON.stringify(canonical(boardFromMd)) === JSON.stringify(canonical(boardFromForm)),
|
||
JSON.stringify(canonical(boardFromForm)).slice(0, 120));
|
||
record('TEST H: Markdown and manual normalize identically (Owliver)',
|
||
JSON.stringify(canonical(reg.parseSkill(OWLIVER_MD, { custom: true })))
|
||
=== JSON.stringify(canonical(reg.parseSkill(owliverManual, { custom: true }))),
|
||
JSON.stringify(canonical(reg.parseSkill(owliverManual, { custom: true }))).slice(0, 120));
|
||
|
||
/* TEST E — a capability with no source is refused, never saved empty. */
|
||
const noSourceFields = {
|
||
id: 'no-source-test',
|
||
name: 'No Source Test',
|
||
description: 'A capability with nothing to read.',
|
||
pages: ['positions'],
|
||
suggestions: ['Show hiring activity'],
|
||
capabilities: ['summary'],
|
||
responses: {},
|
||
};
|
||
const noSource = templates.owliverSkillTemplate(noSourceFields);
|
||
record('TEST E: an unconfigured capability is named by the form',
|
||
JSON.stringify(fields.unconfiguredCapabilities(noSourceFields)) === '["summary"]',
|
||
JSON.stringify(fields.unconfiguredCapabilities(noSourceFields)));
|
||
record('TEST E: and the save is refused',
|
||
/owliver\.responses\.summary/.test(reg.validateSkillSource(noSource) || ''),
|
||
reg.validateSkillSource(noSource) || 'ACCEPTED — a skill with no capability would register');
|
||
record('TEST E: the refused definition would have had zero capabilities',
|
||
reg.parseSkill(noSource, { custom: true }).owliver.capabilities.length === 0,
|
||
'which is why it is refused rather than stored');
|
||
|
||
/* TEST F — two capabilities, two different sources, neither inheriting. */
|
||
const twoSources = templates.owliverSkillTemplate({
|
||
id: 'two-source-test',
|
||
name: 'Two Source Test',
|
||
description: 'Two capabilities reading two sources.',
|
||
pages: ['positions'],
|
||
suggestions: ['Summarize workspace applications', 'List the open roles'],
|
||
capabilities: ['summary', 'list'],
|
||
responses: {
|
||
summary: { source: 'candidates.activity', periods: ['today'] },
|
||
list: { source: 'positions.demand', limit: 5 },
|
||
},
|
||
});
|
||
const twoSkill = reg.parseSkill(twoSources, { custom: true });
|
||
record('TEST F: manual save accepted', reg.validateSkillSource(twoSources) === null,
|
||
reg.validateSkillSource(twoSources) || 'accepted');
|
||
record('TEST F: each capability keeps its own source',
|
||
twoSkill.owliver.responses.summary?.source === 'candidates.activity'
|
||
&& twoSkill.owliver.responses.list?.source === 'positions.demand',
|
||
`summary→${twoSkill.owliver.responses.summary?.source}, list→${twoSkill.owliver.responses.list?.source}`);
|
||
record('TEST F: neither inherits the other\'s options',
|
||
JSON.stringify(twoSkill.owliver.responses.summary.periods) === '["today"]'
|
||
&& twoSkill.owliver.responses.list.limit === 5
|
||
&& twoSkill.owliver.responses.list.periods.length === 0,
|
||
`summary periods=${JSON.stringify(twoSkill.owliver.responses.summary.periods)}, list limit=${twoSkill.owliver.responses.list.limit}`);
|
||
record('TEST F: both are offered as suggestions',
|
||
resolver.owliverSuggestions(positionsContext, [], [{ path: 'custom/two.md', raw: twoSources }], {})
|
||
.filter((c) => c.skillId === 'two-source-test').length === 2);
|
||
|
||
/* TEST G — editing one field leaves every other configuration alone. */
|
||
const edited = fields.patchFrontmatter(
|
||
twoSources,
|
||
fields.owliverPatch(
|
||
{ ...fields.owliverFieldsFromSource(twoSources), description: 'A new description.' },
|
||
{ existing: twoSources }
|
||
)
|
||
);
|
||
const editedSkill = reg.parseSkill(edited, { custom: true });
|
||
record('TEST G: the edit lands', editedSkill.description === 'A new description.', editedSkill.description);
|
||
record('TEST G: every capability source survives the edit',
|
||
JSON.stringify(canonical(editedSkill).owliver.responses)
|
||
=== JSON.stringify(canonical(twoSkill).owliver.responses),
|
||
`summary→${editedSkill.owliver.responses.summary?.source}, list→${editedSkill.owliver.responses.list?.source}`);
|
||
record('TEST G: suggestions survive the edit',
|
||
JSON.stringify(editedSkill.owliver.suggestions) === JSON.stringify(twoSkill.owliver.suggestions));
|
||
|
||
const boardEdited = fields.patchFrontmatter(
|
||
boardManual,
|
||
fields.boardPatch(
|
||
{ ...fields.boardFieldsFromSource(boardManual), description: 'A new description.' },
|
||
{ existing: boardManual }
|
||
)
|
||
);
|
||
record('TEST G: Board UI configuration survives the edit',
|
||
JSON.stringify(boardSection(reg.parseSkill(boardEdited, { custom: true })))
|
||
=== JSON.stringify(boardSection(boardFromForm)),
|
||
JSON.stringify(boardSection(reg.parseSkill(boardEdited, { custom: true }))));
|
||
|
||
/* TEST 6 — one source/shape resolver, shared by the pickers and the validator. */
|
||
record('shape compatibility: the resolver refuses what the normalizer refuses',
|
||
surfaces.sourcesForShape('list').every((src) => surfaces.sourceSupportsShape(src.id, 'list'))
|
||
&& !surfaces.sourcesForShape('list').some((src) => src.id === 'candidates.activity'),
|
||
`list sources: ${surfaces.sourcesForShape('list').map((s) => s.id).join(', ')}`);
|
||
record('shape compatibility: a list against candidates.activity is refused on save',
|
||
/cannot be shown as/.test(reg.validateSkillSource(templates.owliverSkillTemplate({
|
||
id: 'bad-shape-test',
|
||
name: 'Bad Shape Test',
|
||
description: 'A list of something with no list in it.',
|
||
pages: ['positions'],
|
||
capabilities: ['list'],
|
||
responses: { list: { source: 'candidates.activity' } },
|
||
})) || ''),
|
||
'the picker cannot offer it, and the save refuses it');
|
||
record('summary is prose, so every source suits it',
|
||
surfaces.shapeForCapability('summary') === null
|
||
&& surfaces.sourcesForShape(null).length === surfaces.DATA_SOURCES.length);
|
||
|
||
/* ── 9. The reported failure, reproduced end to end ───────────────────────── */
|
||
console.log('\n── Regression: manual Owliver skill on Positions ──');
|
||
|
||
/* Exactly the configuration from the report: Pages = Positions, one suggestion,
|
||
Summary, `candidates.activity`, three periods — composed the way the form
|
||
composes it, then read the way the panel reads it. */
|
||
const REGRESSION = templates.owliverSkillTemplate({
|
||
id: 'owliver-conversation-test',
|
||
name: 'Hiring Activity Test',
|
||
description: 'Shows hiring activity from the Positions page.',
|
||
pages: ['positions'],
|
||
suggestions: ['Show hiring activity'],
|
||
capabilities: ['summary'],
|
||
responses: {
|
||
summary: { source: 'candidates.activity', periods: ['today', 'last-7-days', 'previous-month'] },
|
||
},
|
||
});
|
||
const stored = [{ path: 'custom/owliver-conversation-test.md', raw: REGRESSION }];
|
||
|
||
record('the manual definition saves', reg.validateSkillSource(REGRESSION) === null,
|
||
reg.validateSkillSource(REGRESSION) || 'accepted');
|
||
record('it registers', reg.allSkills(stored).some((s) => s.id === 'owliver-conversation-test'));
|
||
record('it is attached to Positions',
|
||
reg.skillsForContext(positionsContext, [], stored).some((s) => s.id === 'owliver-conversation-test'));
|
||
record('it keeps a capability — the step that used to empty',
|
||
reg.parseSkill(REGRESSION, { custom: true }).owliver.capabilities.length === 1,
|
||
JSON.stringify(reg.parseSkill(REGRESSION, { custom: true }).owliver.capabilities));
|
||
record('it reaches owliverSkillsForContext',
|
||
resolver.owliverSkillsForContext(positionsContext, [], stored)
|
||
.some((s) => s.id === 'owliver-conversation-test'));
|
||
|
||
const regressionChips = resolver.owliverSuggestions(positionsContext, [], stored, {});
|
||
record('"Show hiring activity" is offered as a chip',
|
||
regressionChips.some((c) => c.label === 'Show hiring activity'),
|
||
JSON.stringify(regressionChips.map((c) => c.label)));
|
||
|
||
const clicked = resolver.matchOwliverSkill(
|
||
'Show hiring activity',
|
||
resolver.owliverSkillsForContext(positionsContext, [], stored)
|
||
);
|
||
record('clicking it invokes this skill\'s summary',
|
||
clicked?.skill.id === 'owliver-conversation-test' && clicked?.capability === 'summary',
|
||
`${clicked?.skill.id}:${clicked?.capability}`);
|
||
|
||
const now = new Date();
|
||
const answer = resolver.resolveOwliverResponse({
|
||
skill: clicked.skill,
|
||
capability: clicked.capability,
|
||
question: 'Show hiring activity',
|
||
context: {
|
||
positions: [{ id: 'p1', title: 'Line Cook', status: 'active' }],
|
||
applications: [
|
||
{ id: 'a1', job_posting_id: 'p1', created_date: now.toISOString() },
|
||
{ id: 'a2', job_posting_id: 'p1', created_date: new Date(now - 3 * 86400000).toISOString() },
|
||
],
|
||
},
|
||
now,
|
||
});
|
||
record('it answers with the configured source and periods',
|
||
answer?.missing === null && answer.section.source === 'candidates.activity'
|
||
&& answer.section.periods.length === 3,
|
||
`${answer?.section.source} over ${JSON.stringify(answer?.section.periods)}`);
|
||
record('the answer carries real figures',
|
||
resolver.summaryLines(answer.data).length === 3,
|
||
JSON.stringify(resolver.summaryLines(answer.data)));
|
||
record('and no `position.activity needs a position` is possible',
|
||
dataResolver.resolveSkillData(answer.section, { applications: [], positions: [] }, now).unavailable !== true,
|
||
'the source declares no context requirement');
|
||
|
||
/* Reload: what is stored is the Markdown, so the round trip through storage is
|
||
the round trip through the parser. */
|
||
const reloaded = reg.parseSkill(
|
||
fields.patchFrontmatter(REGRESSION, {}),
|
||
{ custom: true }
|
||
);
|
||
record('the source survives a reload',
|
||
reloaded.owliver.responses.summary?.source === 'candidates.activity',
|
||
reloaded.owliver.responses.summary?.source || 'lost');
|
||
record('the form reads it back unchanged',
|
||
JSON.stringify(fields.owliverFieldsFromSource(REGRESSION).responses)
|
||
=== JSON.stringify({ summary: { source: 'candidates.activity', periods: ['today', 'last-7-days', 'previous-month'], limit: null } }),
|
||
JSON.stringify(fields.owliverFieldsFromSource(REGRESSION).responses));
|
||
|
||
/* ── 10. Storage round trip ───────────────────────────────────────────────
|
||
The editors do not hand the registry a record; they write Markdown into
|
||
`preferences.customSkills`, which `base44Client` persists to localStorage and
|
||
reads back on the next load. "The source disappeared after reload" is a claim
|
||
about *that* path, so it is exercised here rather than assumed: the same
|
||
module the app runs, against a localStorage the same shape the browser's is. */
|
||
console.log('\n── Storage round trip ──');
|
||
|
||
const store = new Map();
|
||
globalThis.localStorage = {
|
||
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
||
setItem: (k, v) => store.set(k, String(v)),
|
||
removeItem: (k) => store.delete(k),
|
||
};
|
||
|
||
const { base44 } = await server.ssrLoadModule('/src/api/base44Client.js');
|
||
const custom = await server.ssrLoadModule('/src/lib/skills/customSkills.js');
|
||
|
||
/* Saving is what the editor's Save button does: compose, validate, upsert,
|
||
write the preference. */
|
||
const saved = custom.upsertCustomSkill(base44.auth.preferences().customSkills || [], REGRESSION);
|
||
const written = await base44.auth.updatePreferences({ customSkills: saved.next });
|
||
record('the definition is persisted', written.persisted, written.error?.message || 'written to storage');
|
||
|
||
/* Reload: a fresh read of the same key, exactly as a page load does. */
|
||
const rehydratedSkills = base44.auth.preferences().customSkills || [];
|
||
record('it survives the reload', rehydratedSkills.length === 1
|
||
&& String(rehydratedSkills[0].raw).includes('candidates.activity'),
|
||
`${rehydratedSkills.length} stored`);
|
||
|
||
const afterReload = reg.parseSkill(rehydratedSkills[0].raw, { custom: true });
|
||
record('its capability survives the reload',
|
||
afterReload.owliver.responses.summary?.source === 'candidates.activity',
|
||
afterReload.owliver.responses.summary?.source || 'lost');
|
||
record('and it still offers its chip after the reload',
|
||
resolver.owliverSuggestions(positionsContext, [], rehydratedSkills, {})
|
||
.some((c) => c.label === 'Show hiring activity'),
|
||
JSON.stringify(resolver.owliverSuggestions(positionsContext, [], rehydratedSkills, {}).map((c) => c.label)));
|
||
record('the editor reopens it with its source intact',
|
||
fields.owliverFieldsFromSource(rehydratedSkills[0].raw).responses.summary?.source === 'candidates.activity',
|
||
JSON.stringify(fields.owliverFieldsFromSource(rehydratedSkills[0].raw).responses));
|
||
|
||
/* The Board skill through the same path, stored beside it — the two subsystems
|
||
are independent, so both must survive one write. */
|
||
const bothStored = custom.upsertCustomSkill(rehydratedSkills, boardManual).next;
|
||
await base44.auth.updatePreferences({ customSkills: bothStored });
|
||
const finalStored = base44.auth.preferences().customSkills || [];
|
||
const finalRegistry = reg.readSkillRegistry(finalStored);
|
||
record('Board and Owliver skills coexist in storage', finalStored.length === 2,
|
||
`${finalStored.length} stored`);
|
||
record('neither produces a registry diagnostic',
|
||
!finalRegistry.diagnostics.some((d) => d.level === 'error'),
|
||
finalRegistry.diagnostics.filter((d) => d.level === 'error').map((d) => d.message).join(' | ') || 'clean');
|
||
record('the Board card draws once on Positions, and offers no chip',
|
||
reg.getSkillsForPage('positions', { customSources: finalStored })
|
||
.filter((sk) => sk.id === 'board')
|
||
.every((sk) => Object.values(sk.ui).flatMap((pg) => pg.sections).length === 1
|
||
&& sk.owliver.capabilities.length === 0));
|
||
record('the Owliver skill offers its chip, and draws no card',
|
||
resolver.owliverSuggestions(positionsContext, [], finalStored, {})
|
||
.some((c) => c.skillId === 'owliver-conversation-test')
|
||
&& Object.keys(reg.parseSkill(REGRESSION, { custom: true }).ui).length === 0);
|
||
|
||
/* ── 11. A fresh Add Owliver Skill screen ─────────────────────────────────
|
||
What the form starts as, asserted at the state that drives the checkbox
|
||
rather than at the checkbox. `checked` is
|
||
`draft.capabilities.includes(capability.id)` and the per-capability
|
||
configuration is rendered under `{checked && …}`, so the array below is the
|
||
whole of "what is ticked" and "what is shown". */
|
||
console.log('\n── Fresh Add Owliver Skill ──');
|
||
|
||
const CAPS = surfaces.OWLIVER_CAPABILITIES.map((c) => c.id);
|
||
|
||
/* 1. NEW skill. `emptyDraft` is `EMPTY_OWLIVER_FIELDS`, which is what the
|
||
editor initializes to when there is no `:id` and nothing handed over. */
|
||
record('new skill: initial capabilities are []',
|
||
Array.isArray(fields.EMPTY_OWLIVER_FIELDS.capabilities)
|
||
&& fields.EMPTY_OWLIVER_FIELDS.capabilities.length === 0,
|
||
JSON.stringify(fields.EMPTY_OWLIVER_FIELDS.capabilities));
|
||
record('new skill: no response is pre-configured either',
|
||
Object.keys(fields.EMPTY_OWLIVER_FIELDS.responses).length === 0,
|
||
JSON.stringify(fields.EMPTY_OWLIVER_FIELDS.responses));
|
||
record(`new skill: all ${CAPS.length} capability checkboxes start unchecked`,
|
||
CAPS.every((id) => !fields.EMPTY_OWLIVER_FIELDS.capabilities.includes(id)),
|
||
CAPS.filter((id) => fields.EMPTY_OWLIVER_FIELDS.capabilities.includes(id)).join(', ') || 'none ticked');
|
||
|
||
/* The template the fresh screen composes must not declare capabilities either:
|
||
an author who types a name before ticking anything patches *that* file, and a
|
||
`capabilities:` key in it would tick boxes nobody chose. */
|
||
const freshTemplate = templates.owliverSkillTemplate({});
|
||
record('new skill: the composed template declares no capabilities',
|
||
!/^\s*capabilities:/m.test(freshTemplate),
|
||
freshTemplate.split('\n').filter((l) => /capabilit/i.test(l)).join(' / ') || 'no capabilities key');
|
||
record('new skill: reading that template back still yields []',
|
||
fields.owliverFieldsFromSource(freshTemplate).capabilities.length === 0,
|
||
JSON.stringify(fields.owliverFieldsFromSource(freshTemplate).capabilities));
|
||
|
||
/* 2. One capability, chosen. The editor's `update({ capabilities })` is a plain
|
||
state merge, so the assertion is on what that array then means. */
|
||
const pickedSummary = { ...fields.EMPTY_OWLIVER_FIELDS, capabilities: ['summary'] };
|
||
record('selecting Summary ticks exactly Summary',
|
||
JSON.stringify(pickedSummary.capabilities) === '["summary"]');
|
||
record('and only Summary renders a configuration block',
|
||
CAPS.filter((id) => pickedSummary.capabilities.includes(id)).length === 1,
|
||
CAPS.filter((id) => pickedSummary.capabilities.includes(id)).join(', '));
|
||
record('an unsourced Summary is invalid, and only Summary is reported',
|
||
JSON.stringify(fields.unconfiguredCapabilities(pickedSummary)) === '["summary"]',
|
||
JSON.stringify(fields.unconfiguredCapabilities(pickedSummary)));
|
||
|
||
/* 3. Choosing its source clears the error, and the definition saves. */
|
||
const sourcedSummary = {
|
||
...pickedSummary,
|
||
id: 'fresh-summary-test',
|
||
name: 'Fresh Summary Test',
|
||
description: 'One capability, configured.',
|
||
pages: ['positions'],
|
||
suggestions: ['Show hiring activity'],
|
||
responses: { summary: { source: 'candidates.activity', periods: ['today'] } },
|
||
};
|
||
record('choosing a source clears the validation error',
|
||
fields.unconfiguredCapabilities(sourcedSummary).length === 0,
|
||
JSON.stringify(fields.unconfiguredCapabilities(sourcedSummary)));
|
||
const freshSaved = templates.owliverSkillTemplate(sourcedSummary);
|
||
record('and the save succeeds', reg.validateSkillSource(freshSaved) === null,
|
||
reg.validateSkillSource(freshSaved) || 'accepted');
|
||
record('the saved definition declares only the chosen capability',
|
||
JSON.stringify(reg.parseSkill(freshSaved, { custom: true }).owliver.capabilities) === '["summary"]',
|
||
JSON.stringify(reg.parseSkill(freshSaved, { custom: true }).owliver.capabilities));
|
||
record('and its suggestion reaches the Positions panel',
|
||
resolver.owliverSuggestions(positionsContext, [], [{ path: 'custom/fresh.md', raw: freshSaved }], {})
|
||
.some((c) => c.skillId === 'fresh-summary-test'));
|
||
|
||
/* 4. Several, chosen by hand — only those, each validated on its own. */
|
||
const pickedTwo = {
|
||
...fields.EMPTY_OWLIVER_FIELDS,
|
||
capabilities: ['summary', 'list'],
|
||
responses: { summary: { source: 'candidates.activity' } },
|
||
};
|
||
record('selecting two capabilities renders exactly those two',
|
||
CAPS.filter((id) => pickedTwo.capabilities.includes(id)).join(',') === 'summary,list');
|
||
record('and only the unconfigured one is reported',
|
||
JSON.stringify(fields.unconfiguredCapabilities(pickedTwo)) === '["list"]',
|
||
JSON.stringify(fields.unconfiguredCapabilities(pickedTwo)));
|
||
|
||
/* 5. An EXISTING skill hydrates from its own definition, and from nothing else.
|
||
Including the capability that did *not* resolve: it is the one the author
|
||
opened the editor to fix, and dropping it from the form would delete it
|
||
from the file on the next save. */
|
||
const DECLARED_ONE = `---
|
||
id: declared-one
|
||
name: Declared One
|
||
description: Declares a single capability.
|
||
pages:
|
||
- positions
|
||
status: active
|
||
owliver:
|
||
enabled: true
|
||
suggestions:
|
||
- Show hiring activity
|
||
capabilities:
|
||
- summary
|
||
responses:
|
||
summary:
|
||
source: candidates.activity
|
||
---
|
||
|
||
# Declared One
|
||
`;
|
||
record('existing skill: hydrates exactly its declared capability',
|
||
JSON.stringify(fields.owliverFieldsFromSource(DECLARED_ONE).capabilities) === '["summary"]',
|
||
JSON.stringify(fields.owliverFieldsFromSource(DECLARED_ONE).capabilities));
|
||
record('existing skill: no new-skill default is applied on top',
|
||
CAPS.filter((id) => fields.owliverFieldsFromSource(DECLARED_ONE).capabilities.includes(id)).length === 1);
|
||
|
||
const HALF_BROKEN = DECLARED_ONE
|
||
.replace(' - summary\n', ' - summary\n - list\n')
|
||
.replace('id: declared-one', 'id: half-broken');
|
||
const halfFields = fields.owliverFieldsFromSource(HALF_BROKEN);
|
||
record('existing skill: a declared capability that lost its source still shows',
|
||
JSON.stringify(halfFields.capabilities) === '["summary","list"]',
|
||
JSON.stringify(halfFields.capabilities));
|
||
record('...unsourced, so it stays invalid rather than silently dropped',
|
||
JSON.stringify(fields.unconfiguredCapabilities(halfFields)) === '["list"]',
|
||
JSON.stringify(fields.unconfiguredCapabilities(halfFields)));
|
||
record('...and the configured one keeps its source through the round trip',
|
||
halfFields.responses.summary.source === 'candidates.activity',
|
||
halfFields.responses.summary.source);
|
||
record('...while the runtime still registers only what can answer',
|
||
JSON.stringify(reg.parseSkill(HALF_BROKEN, { custom: true }).owliver.capabilities) === '["summary"]',
|
||
JSON.stringify(reg.parseSkill(HALF_BROKEN, { custom: true }).owliver.capabilities));
|
||
|
||
/* A definition inheriting its reading from a `ui:` section still hydrates the
|
||
capability it actually offers — the union is declaration-first, not
|
||
declaration-only. */
|
||
const INHERITED = `---
|
||
id: inherited-cap
|
||
name: Inherited Cap
|
||
description: A capability reading the page section.
|
||
pages:
|
||
- positions
|
||
status: active
|
||
ui:
|
||
type: card
|
||
placement: after-position-list-summary
|
||
source: candidates.activity
|
||
owliver:
|
||
enabled: true
|
||
capabilities:
|
||
- card
|
||
---
|
||
|
||
# Inherited Cap
|
||
`;
|
||
record('a capability inheriting from `ui:` still hydrates with its source',
|
||
fields.owliverFieldsFromSource(INHERITED).responses.card?.source === 'candidates.activity',
|
||
fields.owliverFieldsFromSource(INHERITED).responses.card?.source || 'lost');
|
||
|
||
/* ── 12. Attendance and overtime data ─────────────────────────────────────
|
||
*
|
||
* Attendance is the one collection whose dates are anchored to now rather than
|
||
* written as calendar dates, and the one whose foreign keys have to line up
|
||
* with a narrative written months earlier. Both are easy to get wrong in ways
|
||
* that look fine: a shift pointing at a staff member who was never hired reads
|
||
* as a working feature until someone asks whose shift it was.
|
||
*
|
||
* These checks are about the data being *true* — that it joins, that it is
|
||
* windowable by the existing period machinery, and that the analyses over it
|
||
* find what is there and stay quiet about what is not.
|
||
*/
|
||
console.log('\n── Attendance and overtime data ──');
|
||
|
||
const attendance = await server.ssrLoadModule('/src/lib/attendance.js');
|
||
const shiftSeed = await server.ssrLoadModule('/src/api/attendanceSeed.js');
|
||
const seedModule = await server.ssrLoadModule('/src/api/seed.js');
|
||
const resolverModule = await server.ssrLoadModule('/src/lib/skills/dataResolver.js');
|
||
|
||
const SHIFTS = shiftSeed.SHIFT_RECORDS;
|
||
const SHIFT_STATUSES = ['present', 'late', 'absent', 'no_show', 'excused'];
|
||
|
||
record('shift records are seeded', SHIFTS.length > 0, `${SHIFTS.length} shifts`);
|
||
record('the seed registers them as `ShiftRecord`',
|
||
Array.isArray(seedModule.seedData.ShiftRecord) && seedModule.seedData.ShiftRecord.length === SHIFTS.length,
|
||
`${seedModule.seedData.ShiftRecord?.length ?? 0} in seedData`);
|
||
|
||
record('every shift has a unique id',
|
||
new Set(SHIFTS.map((s) => s.id)).size === SHIFTS.length);
|
||
|
||
record('every shift status is one the product recognises',
|
||
SHIFTS.every((s) => SHIFT_STATUSES.includes(s.status)),
|
||
[...new Set(SHIFTS.map((s) => s.status))].join(', '));
|
||
|
||
record('every shift was scheduled for real hours',
|
||
SHIFTS.every((s) => s.scheduled_hours > 0));
|
||
|
||
record('overtime is never negative',
|
||
SHIFTS.every((s) => s.overtime_hours >= 0));
|
||
|
||
/* ── The joins ───────────────────────────────────────────────────────────── */
|
||
|
||
const staffIds = new Set(seedModule.seedData.Staff.map((s) => s.id));
|
||
const postingsById = new Map(seedModule.seedData.JobPosting.map((p) => [p.id, p]));
|
||
|
||
record('every shift belongs to someone who was actually hired',
|
||
SHIFTS.every((s) => staffIds.has(s.staff_id)),
|
||
[...new Set(SHIFTS.map((s) => s.staff_id).filter((id) => !staffIds.has(id)))].join(', ') || 'all resolve');
|
||
|
||
record('every shift belongs to a real position',
|
||
SHIFTS.every((s) => postingsById.has(s.job_posting_id)),
|
||
[...new Set(SHIFTS.map((s) => s.job_posting_id).filter((id) => !postingsById.has(id)))].join(', ') || 'all resolve');
|
||
|
||
/* "Department" has to mean the same thing here as it does on Hired History and
|
||
Analytics, both of which read it off the posting — see the join in
|
||
`lib/hiringRecords.js`. A shift carrying its own category is a denormalized
|
||
copy, and a copy that disagrees is worse than no copy. */
|
||
record('a shift\'s department matches its position\'s',
|
||
SHIFTS.every((s) => postingsById.get(s.job_posting_id)?.role_category === s.role_category),
|
||
SHIFTS.filter((s) => postingsById.get(s.job_posting_id)?.role_category !== s.role_category)
|
||
.map((s) => s.id).join(', ') || 'all agree');
|
||
|
||
/**
|
||
* `created_date` is the instant the shift was worked.
|
||
*
|
||
* Load-bearing rather than incidental: `inPeriod` windows every collection on
|
||
* `created_date`, so if these drifted apart every period reading of attendance
|
||
* would come back empty and nothing would say why.
|
||
*/
|
||
record('a shift\'s created date is the instant it was worked',
|
||
SHIFTS.every((s) => s.created_date === s.scheduled_start));
|
||
|
||
/* ── Hours add up ────────────────────────────────────────────────────────── */
|
||
|
||
record('a missed shift is zero hours worked, not a short one',
|
||
SHIFTS.filter((s) => s.status === 'absent' || s.status === 'no_show')
|
||
.every((s) => s.actual_hours === 0 && s.overtime_hours === 0 && s.actual_start === null));
|
||
|
||
record('a worked shift\'s hours are its schedule, less lateness, plus overtime',
|
||
SHIFTS.filter((s) => s.status === 'present' || s.status === 'late').every((s) => {
|
||
const expected = s.scheduled_hours - s.minutes_late / 60 + s.overtime_hours;
|
||
return Math.abs(s.actual_hours - expected) < 0.02;
|
||
}));
|
||
|
||
record('a late shift is one that was turned up for',
|
||
SHIFTS.filter((s) => s.status === 'late').every((s) => s.minutes_late > 0 && s.actual_start !== null));
|
||
|
||
/* ── Reachable the way a page reaches it ─────────────────────────────────── */
|
||
|
||
const client = await server.ssrLoadModule('/src/api/base44Client.js');
|
||
const listed = await client.base44.entities.ShiftRecord.list('-created_date', 500);
|
||
|
||
record('shifts are queryable through the entity API',
|
||
listed.length === SHIFTS.length, `${listed.length} returned`);
|
||
|
||
record('...newest first, like every other collection',
|
||
listed.every((s, i) => i === 0
|
||
|| new Date(listed[i - 1].created_date).getTime() >= new Date(s.created_date).getTime()));
|
||
|
||
const filtered = await client.base44.entities.ShiftRecord.filter({ status: 'absent' });
|
||
record('...and filterable by field',
|
||
filtered.length > 0 && filtered.every((s) => s.status === 'absent'),
|
||
`${filtered.length} absences`);
|
||
|
||
/* ── The sources future skills will declare ──────────────────────────────── */
|
||
|
||
for (const id of ['workforce.attendance', 'workforce.overtime']) {
|
||
const source = surfaces.dataSourceFor(id);
|
||
record(`\`${id}\` is a declarable source`, Boolean(source));
|
||
record(`\`${id}\` needs no record in context`, source?.context === null);
|
||
record(`\`${id}\` supports every shape it declares`,
|
||
source.shapes.every((shape) => surfaces.sourceSupportsShape(id, shape)),
|
||
source.shapes.join(', '));
|
||
record(`\`${id}\` accepts the options it declares`,
|
||
(source.options || []).every((option) => surfaces.sourceSupportsOption(id, option)),
|
||
(source.options || []).join(', ') || 'none');
|
||
|
||
/* Honest emptiness. A source with nothing to read must say so rather than
|
||
reporting zeros that look like findings. */
|
||
const empty = resolverModule.resolveSkillData({ source: id }, { shifts: [] });
|
||
record(`\`${id}\` reports honestly when there are no shifts`,
|
||
empty.empty === true && Boolean(empty.emptyNote),
|
||
empty.emptyNote || 'NO NOTE');
|
||
|
||
const full = resolverModule.resolveSkillData({ source: id }, { shifts: SHIFTS });
|
||
record(`\`${id}\` reads the seeded shifts`,
|
||
full.empty === false && full.steps.length > 0 && full.items.length > 0,
|
||
`${full.steps.length} figures, ${full.items.length} rows`);
|
||
|
||
const periodic = resolverModule.resolveSkillData(
|
||
{ source: id, periods: ['last-7-days', 'previous-month'] }, { shifts: SHIFTS }
|
||
);
|
||
record(`\`${id}\` windows by period`,
|
||
periodic.steps.length === 2 && periodic.steps.some((s) => s.records.length > 0),
|
||
periodic.steps.map((s) => `${s.label}=${s.records.length}`).join(', '));
|
||
}
|
||
|
||
/* ── The analyses ────────────────────────────────────────────────────────── */
|
||
|
||
const summary = attendance.attendanceSummary(SHIFTS);
|
||
record('attendance counts every scheduled shift exactly once',
|
||
summary.worked + summary.missed === summary.scheduled,
|
||
`${summary.worked} worked + ${summary.missed} missed = ${summary.scheduled}`);
|
||
record('attendance rate is a percentage',
|
||
summary.attendanceRate >= 0 && summary.attendanceRate <= 100, `${summary.attendanceRate}%`);
|
||
record('punctuality is never better than attendance',
|
||
summary.punctualityRate <= summary.attendanceRate,
|
||
`${summary.punctualityRate}% punctual / ${summary.attendanceRate}% present`);
|
||
|
||
const overtime = attendance.overtimeSummary(SHIFTS);
|
||
record('overtime is the gap between hours worked and hours scheduled',
|
||
overtime.hours > 0 && overtime.hoursWorked > overtime.hoursScheduled - summary.minutesLate / 60,
|
||
`${overtime.hours}h overtime`);
|
||
|
||
const workers = attendance.attendanceByWorker(SHIFTS);
|
||
record('every worker on the roster is compared',
|
||
workers.length === new Set(SHIFTS.map((s) => s.staff_id)).size, `${workers.length} workers`);
|
||
record('workers are ordered worst attendance first',
|
||
workers.every((w, i) => i === 0 || workers[i - 1].attendanceRate <= w.attendanceRate),
|
||
workers.map((w) => `${w.name.split(' ')[0]} ${w.attendanceRate}%`).join(', '));
|
||
|
||
const overtimeWorkers = attendance.overtimeByWorker(SHIFTS);
|
||
record('overtime comparison is ordered most hours first',
|
||
overtimeWorkers.every((w, i) => i === 0 || overtimeWorkers[i - 1].hours >= w.hours),
|
||
overtimeWorkers.map((w) => `${w.name.split(' ')[0]} ${w.hours}h`).join(', '));
|
||
|
||
const departments = attendance.attendanceByDepartment(SHIFTS);
|
||
record('departments are compared too',
|
||
departments.length > 1 && departments.every((d) => d.people > 0),
|
||
departments.map((d) => `${d.name} ${d.attendanceRate}%`).join(', '));
|
||
|
||
const trend = attendance.weeklyTrend(SHIFTS);
|
||
record('the weekly trend runs oldest to newest',
|
||
trend.length >= 4 && trend.every((w, i) => i === 0
|
||
|| new Date(trend[i - 1].weekStart).getTime() < new Date(w.weekStart).getTime()),
|
||
`${trend.length} weeks`);
|
||
record('a week nobody was rostered for is not reported as 0% attendance',
|
||
trend.every((w) => w.scheduled > 0));
|
||
|
||
/* ── Anomalies: what it finds, and what it stays quiet about ─────────────── */
|
||
|
||
const findings = attendance.attendanceAnomalies(SHIFTS);
|
||
record('the seeded attendance decline is found',
|
||
findings.some((f) => f.kind === 'attendance'),
|
||
findings.filter((f) => f.kind === 'attendance').map((f) => f.title).join(' | ') || 'NOT FOUND');
|
||
record('the seeded overtime climb is found',
|
||
findings.some((f) => f.kind === 'overtime'),
|
||
findings.find((f) => f.kind === 'overtime')?.detail || 'NOT FOUND');
|
||
record('every finding carries the figures behind it',
|
||
findings.every((f) => f.title && f.detail && f.severity && f.metric));
|
||
record('findings are ordered most severe first',
|
||
findings.every((f, i) => i === 0
|
||
|| ({ high: 0, medium: 1, low: 2 })[findings[i - 1].severity] <= ({ high: 0, medium: 1, low: 2 })[f.severity]),
|
||
findings.map((f) => f.severity).join(' → '));
|
||
|
||
/**
|
||
* The check that matters most, and the one a detector like this usually fails.
|
||
*
|
||
* A rule that flags everything is worse than no rule: it trains its reader to
|
||
* skim, and the one finding that mattered goes past unread. So a workforce with
|
||
* nothing wrong must produce *nothing at all* — not a low-severity note, not a
|
||
* "no issues" finding.
|
||
*/
|
||
const flawless = SHIFTS.map((s) => ({
|
||
...s, status: 'present', minutes_late: 0, overtime_hours: 0, actual_hours: s.scheduled_hours,
|
||
}));
|
||
record('a workforce with nothing wrong produces no findings',
|
||
attendance.attendanceAnomalies(flawless).length === 0,
|
||
`${attendance.attendanceAnomalies(flawless).length} findings`);
|
||
|
||
record('no shift records produces no findings either',
|
||
attendance.attendanceAnomalies([]).length === 0);
|
||
|
||
/* One bad week is a bad week; it takes a second to be a direction. */
|
||
const oneOffWeek = SHIFTS.map((s) => {
|
||
const daysBack = Math.round((Date.now() - new Date(s.created_date).getTime()) / 86400000);
|
||
return daysBack > 21 && daysBack <= 28 ? { ...s, status: 'absent', actual_hours: 0, overtime_hours: 0 } : { ...s, status: 'present', minutes_late: 0, overtime_hours: 0, actual_hours: s.scheduled_hours };
|
||
});
|
||
record('a single bad week months ago is not reported as a current problem',
|
||
!attendance.attendanceAnomalies(oneOffWeek).some((f) => f.id === 'missed-shifts-rising'),
|
||
attendance.attendanceAnomalies(oneOffWeek).map((f) => f.id).join(', ') || 'none');
|
||
|
||
/* ── 13. Cross-domain data sources ────────────────────────────────────────
|
||
*
|
||
* Eight readings over records the workspace already holds. The failure these
|
||
* guard against is a source that *looks* like it works: one that returns
|
||
* plausible zeros when it has nothing to read, or that quietly assumes a field
|
||
* the data does not carry. Either produces a skill that appears to answer and
|
||
* is answering about nothing.
|
||
*
|
||
* So every source is checked twice — against the seed, and against an empty
|
||
* workspace — and the empty case has to say so rather than report zeros.
|
||
*/
|
||
console.log('\n── Cross-domain data sources ──');
|
||
|
||
const signalsModule = await server.ssrLoadModule('/src/lib/activitySignals.js');
|
||
const insightsModule = await server.ssrLoadModule('/src/components/ai-assistant/insights.js');
|
||
|
||
const expectedBaseline = JSON.parse(readFileSync(BASELINE_PATH, 'utf8'));
|
||
const SEED = seedModule.seedData;
|
||
|
||
/* The workspace a declared reading is checked against — the same collections the
|
||
panel hands a resolver at runtime. Declared once here because both the
|
||
analysis-skill and agent sections read against it. */
|
||
const AGENT_SKILL_CONTEXT = {
|
||
positions: SEED.JobPosting,
|
||
applications: SEED.JobApplication,
|
||
interviews: SEED.AIInterview,
|
||
staff: SEED.Staff,
|
||
profiles: SEED.WorkerProfile,
|
||
workerProfiles: SEED.WorkerProfile,
|
||
activity: SEED.UserActivity,
|
||
assignments: SEED.Assignment,
|
||
courses: SEED.Course,
|
||
shifts: SHIFTS,
|
||
trainingPaths: [],
|
||
};
|
||
const FULL_CONTEXT = {
|
||
positions: SEED.JobPosting,
|
||
applications: SEED.JobApplication,
|
||
interviews: SEED.AIInterview,
|
||
staff: SEED.Staff,
|
||
profiles: SEED.WorkerProfile,
|
||
workerProfiles: SEED.WorkerProfile,
|
||
activity: SEED.UserActivity,
|
||
assignments: SEED.Assignment,
|
||
shifts: SHIFTS,
|
||
};
|
||
|
||
const NEW_SOURCES = [
|
||
'positions.risk',
|
||
'candidates.quality',
|
||
'talent.pool',
|
||
'workforce.coverage',
|
||
'activity.signals',
|
||
'activity.breakdown',
|
||
'operations.risk',
|
||
'workspace.summary',
|
||
];
|
||
|
||
for (const id of NEW_SOURCES) {
|
||
const decl = surfaces.dataSourceFor(id);
|
||
|
||
record(`\`${id}\` is a declarable source`, Boolean(decl));
|
||
if (!decl) continue;
|
||
|
||
/* Every one of these is a workspace-level reading. A source that needed a
|
||
record in context could not be answered from a page that has none, and
|
||
would validate then fail at read time. */
|
||
record(`\`${id}\` needs no record in context`, decl.context === null);
|
||
|
||
record(`\`${id}\` supports every shape it declares`,
|
||
decl.shapes.every((shape) => surfaces.sourceSupportsShape(id, shape)),
|
||
decl.shapes.join(', '));
|
||
|
||
record(`\`${id}\` accepts the options it declares`,
|
||
(decl.options || []).every((option) => surfaces.sourceSupportsOption(id, option)),
|
||
(decl.options || []).join(', ') || 'none');
|
||
|
||
/* Reads the workspace as it actually is. */
|
||
const readFull = resolverModule.resolveSkillData({ source: id }, FULL_CONTEXT);
|
||
record(`\`${id}\` reads the seeded workspace`,
|
||
readFull.empty === false && (readFull.steps?.length > 0),
|
||
`${readFull.steps?.length ?? 0} figures, ${readFull.items?.length ?? 0} rows`);
|
||
|
||
record(`\`${id}\` returns figures, never prose`,
|
||
(readFull.steps || []).every((step) => typeof step.value === 'number'),
|
||
(readFull.steps || []).map((s) => `${s.title}=${s.value}`).join(', '));
|
||
|
||
/**
|
||
* The check that separates a working source from one that only looks like it.
|
||
*
|
||
* An empty workspace must produce `empty: true` and a note saying why — not
|
||
* a row of zeros, which reads as a measured result rather than an absent one.
|
||
*/
|
||
const readEmpty = resolverModule.resolveSkillData({ source: id }, {});
|
||
record(`\`${id}\` reports honestly when there is nothing to read`,
|
||
readEmpty.empty === true && Boolean(readEmpty.emptyNote),
|
||
readEmpty.emptyNote || 'NO NOTE');
|
||
|
||
record(`\`${id}\` never throws on a half-empty workspace`,
|
||
(() => {
|
||
try {
|
||
const partialRead = resolverModule.resolveSkillData({ source: id }, { positions: SEED.JobPosting });
|
||
return partialRead && typeof partialRead.empty === 'boolean';
|
||
} catch {
|
||
return false;
|
||
}
|
||
})());
|
||
}
|
||
|
||
/* ── Period filtering, where it applies ──────────────────────────────────── */
|
||
|
||
for (const id of ['candidates.quality', 'activity.breakdown']) {
|
||
const readPeriodic = resolverModule.resolveSkillData(
|
||
{ source: id, periods: ['previous-month'] }, FULL_CONTEXT
|
||
);
|
||
const all = resolverModule.resolveSkillData({ source: id }, FULL_CONTEXT);
|
||
record(`\`${id}\` windows by period`,
|
||
readPeriodic.total <= all.total,
|
||
`${readPeriodic.total} in the previous month / ${all.total} in total`);
|
||
|
||
/* Overlapping windows must not count the same record twice — `today` sits
|
||
inside `last-7-days`, and a naive concatenation would double it. */
|
||
const overlapping = resolverModule.resolveSkillData(
|
||
{ source: id, periods: ['today', 'last-7-days'] }, FULL_CONTEXT
|
||
);
|
||
const sevenOnly = resolverModule.resolveSkillData(
|
||
{ source: id, periods: ['last-7-days'] }, FULL_CONTEXT
|
||
);
|
||
record(`\`${id}\` does not double-count overlapping periods`,
|
||
overlapping.total === sevenOnly.total,
|
||
`${overlapping.total} vs ${sevenOnly.total}`);
|
||
}
|
||
|
||
/* ── `activity.signals` reuses the assistant's own detection ─────────────── */
|
||
|
||
/**
|
||
* The reuse assertion.
|
||
*
|
||
* `activitySignals` moved out of `buildFacts` so a data source could read it
|
||
* without a library importing from the component tree. The whole value of that
|
||
* move is that there is still exactly one implementation — "two unusual
|
||
* patterns" has to mean the same two in the greeting and on the card.
|
||
*/
|
||
const factSheet = insightsModule.buildFacts({
|
||
applications: SEED.JobApplication,
|
||
postings: SEED.JobPosting,
|
||
interviews: SEED.AIInterview,
|
||
staff: SEED.Staff,
|
||
profiles: SEED.WorkerProfile,
|
||
activity: SEED.UserActivity,
|
||
courses: SEED.Course,
|
||
profile: null,
|
||
user: seedModule.DEMO_USER,
|
||
today: new Date('2026-08-20T09:00:00.000Z'),
|
||
});
|
||
const direct = signalsModule.activitySignals(SEED.UserActivity, new Date('2026-08-20T09:00:00.000Z'));
|
||
|
||
record('the fact sheet and the extracted detection agree exactly',
|
||
JSON.stringify(factSheet.activitySignals) === JSON.stringify(direct),
|
||
JSON.stringify(direct.flags));
|
||
|
||
record('`PRIVILEGED_EVENTS` is still importable from insights.js',
|
||
JSON.stringify(insightsModule.PRIVILEGED_EVENTS) === JSON.stringify(signalsModule.PRIVILEGED_EVENTS),
|
||
JSON.stringify(insightsModule.PRIVILEGED_EVENTS));
|
||
|
||
const signalsRead = resolverModule.resolveSkillData({ source: 'activity.signals' }, FULL_CONTEXT);
|
||
record('the activity.signals source reports the same flags the greeting counts',
|
||
signalsRead.items.length === factSheet.activitySignals.flags.length,
|
||
`${signalsRead.items.length} signals`);
|
||
|
||
record('every signal is explained rather than named',
|
||
signalsRead.items.every((i) => i.title && i.detail && i.title !== i.id));
|
||
|
||
/* A workspace with nothing out of pattern is a real answer, and a different
|
||
one from having no log at all. */
|
||
const quiet = resolverModule.resolveSkillData({ source: 'activity.signals' }, { activity: [] });
|
||
record('an empty log and a quiet log are different answers',
|
||
quiet.emptyNote !== signalsRead.emptyNote);
|
||
|
||
/* ── `workforce.coverage` refuses to invent a headcount ──────────────────── */
|
||
|
||
/**
|
||
* No seeded position declares a headcount, and `demandFor` reports that rather
|
||
* than defaulting to one person per role. This check exists because the obvious
|
||
* shortcut — assume 1, report a fill rate — produces a confident percentage
|
||
* that means nothing, and nothing on screen would say so.
|
||
*/
|
||
const coverage = resolverModule.resolveSkillData({ source: 'workforce.coverage' }, FULL_CONTEXT);
|
||
const declaredStep = coverage.steps.find((s) => s.id === 'declared');
|
||
record('coverage says out loud when no role states a headcount',
|
||
declaredStep.value === 0 && /does not|not state|No open role/i.test(declaredStep.detail),
|
||
declaredStep.detail);
|
||
record('coverage still reports who has actually been hired',
|
||
coverage.steps.find((s) => s.id === 'covered').value > 0,
|
||
`${coverage.steps.find((s) => s.id === 'covered').value} roles have someone hired`);
|
||
record('a role with no headcount reports hires, not a fill percentage',
|
||
coverage.items.every((i) => i.declared || /headcount not stated/.test(i.detail)));
|
||
|
||
/* ── `positions.risk` distinguishes "nothing wrong" from "nothing posted" ── */
|
||
|
||
const noRoles = resolverModule.resolveSkillData({ source: 'positions.risk' }, { positions: [], applications: [] });
|
||
const healthy = resolverModule.resolveSkillData({ source: 'positions.risk' }, {
|
||
positions: SEED.JobPosting.filter((p) => p.status === 'active').slice(0, 1),
|
||
applications: SEED.JobApplication.map((a) => ({
|
||
...a, job_posting_id: SEED.JobPosting.find((p) => p.status === 'active').id, ai_score: 88, status: 'hired',
|
||
})),
|
||
});
|
||
record('positions.risk separates "no roles open" from "no roles at risk"',
|
||
noRoles.emptyNote !== healthy.emptyNote,
|
||
`${noRoles.emptyNote} / ${healthy.emptyNote}`);
|
||
|
||
/* ── `operations.risk` stays quiet when the operation is running ─────────── */
|
||
|
||
record('operations.risk finds the real backlog',
|
||
resolverModule.resolveSkillData({ source: 'operations.risk' }, FULL_CONTEXT).findings.length > 0,
|
||
resolverModule.resolveSkillData({ source: 'operations.risk' }, FULL_CONTEXT)
|
||
.findings.map((f) => f.id).join(', '));
|
||
|
||
const readClean = resolverModule.resolveSkillData({ source: 'operations.risk' }, {
|
||
/* Everything screened and decided, every role has applicants, every shift worked. */
|
||
applications: SEED.JobApplication.map((a) => ({ ...a, status: 'hired', ai_score: 90 })),
|
||
positions: SEED.JobPosting.filter((p) => p.status === 'active').map((p) => ({ ...p })),
|
||
shifts: SHIFTS.map((s) => ({ ...s, status: 'present' })),
|
||
});
|
||
record('operations.risk reports nothing when nothing is wrong',
|
||
readClean.findings.length === 0 && readClean.empty === true,
|
||
readClean.findings.map((f) => f.id).join(', ') || 'no findings');
|
||
|
||
/* ── `talent.pool` does not average away the unscored ───────────────────── */
|
||
|
||
/**
|
||
* Four of the nine seeded profiles have no score. Averaging them in as zero
|
||
* would report a healthy pool as poor, in exact proportion to how much of it
|
||
* nobody has assessed yet — a figure that gets worse as the pool grows.
|
||
*/
|
||
const pool = resolverModule.resolveSkillData({ source: 'talent.pool' }, FULL_CONTEXT);
|
||
const scoredProfiles = SEED.WorkerProfile.filter((p) => (p.krow_score || 0) > 0);
|
||
const expectedAvg = Math.round(
|
||
scoredProfiles.reduce((sum, p) => sum + p.krow_score, 0) / scoredProfiles.length
|
||
);
|
||
record('talent.pool averages only the profiles that have been scored',
|
||
pool.steps.find((s) => s.id === 'quality').value === expectedAvg,
|
||
`${pool.steps.find((s) => s.id === 'quality').value} vs ${expectedAvg} expected`);
|
||
record('...and says how many are not yet assessed',
|
||
/not yet assessed/.test(pool.steps.find((s) => s.id === 'scored').detail),
|
||
pool.steps.find((s) => s.id === 'scored').detail);
|
||
record('an unscored person reads as unscored, not as a zero score',
|
||
pool.items.filter((i) => i.value === 0).every((i) => /Not yet scored/.test(i.detail)));
|
||
|
||
/* ── `candidates.quality` reports coverage beside quality ────────────────── */
|
||
|
||
const quality = resolverModule.resolveSkillData({ source: 'candidates.quality' }, FULL_CONTEXT);
|
||
record('candidate quality reports how much of the pool was actually scored',
|
||
quality.steps.find((s) => s.id === 'coverage').value < 100,
|
||
quality.steps.find((s) => s.id === 'coverage').detail);
|
||
record('score bands add up to the number scored',
|
||
quality.bands.reduce((n, b) => n + b.value, 0)
|
||
=== SEED.JobApplication.filter((a) => a.ai_score > 0).length,
|
||
quality.bands.map((b) => `${b.label}=${b.value}`).join(', '));
|
||
|
||
/* ── Nothing here reaches for a record it was not given ─────────────────── */
|
||
|
||
/**
|
||
* A resolver that throws takes the page down; one that invents a default is
|
||
* worse, because it reports a figure nobody can trace. Every source is called
|
||
* with each collection missing in turn.
|
||
*/
|
||
const COLLECTIONS = ['positions', 'applications', 'interviews', 'staff', 'profiles', 'activity', 'shifts', 'assignments'];
|
||
let survived = true;
|
||
let culprit = '';
|
||
for (const id of NEW_SOURCES) {
|
||
for (const drop of COLLECTIONS) {
|
||
const withoutOne = { ...FULL_CONTEXT };
|
||
delete withoutOne[drop];
|
||
try {
|
||
const out = resolverModule.resolveSkillData({ source: id }, withoutOne);
|
||
if (!out || typeof out.empty !== 'boolean') { survived = false; culprit = `${id} without ${drop}`; }
|
||
} catch (error) {
|
||
survived = false;
|
||
culprit = `${id} without ${drop}: ${error.message}`;
|
||
}
|
||
}
|
||
}
|
||
record('every source survives any single collection being absent', survived, culprit || `${NEW_SOURCES.length} sources × ${COLLECTIONS.length} collections`);
|
||
|
||
/* ── 14. Analysis skills ──────────────────────────────────────────────────
|
||
*
|
||
* Thirteen definitions written against the sources built in the two previous
|
||
* phases. The failure they guard against is a definition that registers, looks
|
||
* complete, and answers nothing — a capability bound to a source that cannot be
|
||
* read, or a trigger that quietly takes a question another definition was
|
||
* written to answer.
|
||
*
|
||
* Every one is therefore executed, not merely parsed: each declared capability
|
||
* is resolved against the seeded workspace and has to come back with figures.
|
||
*/
|
||
console.log('\n── Analysis skills ──');
|
||
|
||
const ANALYSIS_SKILLS = [
|
||
'staffing-risk', 'attendance-analysis', 'overtime-analysis', 'candidate-analysis',
|
||
'talent-pool-analysis', 'workforce-analytics', 'anomaly-detection', 'activity-analysis',
|
||
'operational-risk', 'executive-summary', 'hiring-history-analysis', 'learning-analysis',
|
||
'hiring-pulse-analysis',
|
||
];
|
||
|
||
record('every analysis skill registers',
|
||
ANALYSIS_SKILLS.every((id) => reg.SKILLS.some((s) => s.id === id)),
|
||
ANALYSIS_SKILLS.filter((id) => !reg.SKILLS.some((s) => s.id === id)).join(', ') || `${ANALYSIS_SKILLS.length} skills`);
|
||
|
||
for (const id of ANALYSIS_SKILLS) {
|
||
const skill = reg.SKILLS.find((s) => s.id === id);
|
||
if (!skill) continue;
|
||
|
||
/* Written in the one format, read by the one parser. */
|
||
record(`\`${id}\` is a valid definition`,
|
||
reg.validateSkillSource(skill.markdown) === null,
|
||
reg.validateSkillSource(skill.markdown) || 'ok');
|
||
|
||
record(`\`${id}\` is an Owliver skill on real pages`,
|
||
skill.facets.includes('owliver')
|
||
&& skill.pages.length > 0
|
||
&& skill.pages.every((p) => surfaces.SUPPORTED_SKILL_PAGES.includes(surfaces.canonicalPage(p) || p)),
|
||
JSON.stringify(skill.pages));
|
||
|
||
/* The sections the brief asks every definition to carry. `Purpose` and
|
||
`Capabilities` are parsed into fields; the rest are prose the authoring
|
||
flow will later read back, so they are checked for presence rather than
|
||
for shape. */
|
||
record(`\`${id}\` states its purpose and capabilities`,
|
||
skill.purpose.length > 0 && skill.capabilities.length > 0,
|
||
`${skill.purpose.length} purpose, ${skill.capabilities.length} capabilities`);
|
||
|
||
record(`\`${id}\` documents its data, analysis, output and limitations`,
|
||
['## Data', '## Analysis', '## Output', '## Limitations'].every((h) => skill.body.includes(h)),
|
||
['Data', 'Analysis', 'Output', 'Limitations'].filter((h) => !skill.body.includes(`## ${h}`)).join(', ') || 'all four');
|
||
|
||
/* Every capability resolves, and comes back with figures rather than an
|
||
apology. This is what separates a definition that works from one that
|
||
merely parses. */
|
||
const failures = [];
|
||
for (const capability of skill.owliver.capabilities) {
|
||
const response = skill.owliver.responses[capability];
|
||
if (!response) { failures.push(`${capability}: no response`); continue; }
|
||
|
||
const declared = surfaces.dataSourceFor(response.source);
|
||
if (!declared) { failures.push(`${capability}: unknown source ${response.source}`); continue; }
|
||
|
||
/* Shape has to be one the source actually supports, or the definition
|
||
promises a rendering the data cannot produce. */
|
||
const shape = surfaces.shapeForCapability(capability);
|
||
if (shape && !surfaces.sourceSupportsShape(response.source, shape)) {
|
||
failures.push(`${capability}: ${response.source} cannot draw ${shape}`);
|
||
continue;
|
||
}
|
||
|
||
if (declared.context) continue; /* needs a record; asked for at runtime */
|
||
|
||
const read = dataResolver.resolveSkillData(response, AGENT_SKILL_CONTEXT);
|
||
if (read?.unavailable) failures.push(`${capability}: unreadable`);
|
||
else if (read?.empty && !read.emptyNote) failures.push(`${capability}: empty with no explanation`);
|
||
}
|
||
record(`\`${id}\` answers every capability it declares`,
|
||
failures.length === 0,
|
||
failures.join(' | ') || `${skill.owliver.capabilities.length} capabilities`);
|
||
|
||
/* Every suggestion names the capability it asks for. Without this a chip
|
||
falls through to the first declared capability, and two chips silently
|
||
become one answer — the lesson recorded in hiring-activity-assistant.md. */
|
||
record(`\`${id}\` names a capability on every suggestion`,
|
||
skill.owliver.suggestions.every((s) => s.capability
|
||
&& skill.owliver.capabilities.includes(s.capability)),
|
||
skill.owliver.suggestions.map((s) => `${s.label} → ${s.capability}`).join(' | '));
|
||
|
||
/* Its own triggers, claimed rather than inherited from its name. */
|
||
record(`\`${id}\` claims its own triggers`,
|
||
skill.declaredTriggers && skill.triggers.length > 0,
|
||
JSON.stringify(skill.triggers));
|
||
}
|
||
|
||
/* ── Reachable from the pages they name ──────────────────────────────────── */
|
||
|
||
const PAGE_EXPECTATIONS = {
|
||
'admin.controlCenter': ['executive-summary', 'staffing-risk', 'operational-risk', 'anomaly-detection', 'attendance-analysis', 'overtime-analysis', 'hiring-pulse-analysis'],
|
||
'admin.positions': ['staffing-risk'],
|
||
'admin.candidatesList': ['candidate-analysis'],
|
||
'admin.hiredHistory': ['hiring-history-analysis'],
|
||
'admin.talentPool': ['talent-pool-analysis'],
|
||
'admin.forge': ['learning-analysis'],
|
||
'admin.analytics': ['workforce-analytics', 'attendance-analysis', 'overtime-analysis', 'hiring-pulse-analysis'],
|
||
'admin.activity': ['activity-analysis', 'anomaly-detection', 'operational-risk'],
|
||
};
|
||
|
||
for (const [contextId, expected] of Object.entries(PAGE_EXPECTATIONS)) {
|
||
const available = reg.skillsForContext(contextId, [], []).map((s) => s.id);
|
||
record(`${contextId.replace('admin.', '')} offers its analysis skills`,
|
||
expected.every((id) => available.includes(id)),
|
||
expected.filter((id) => !available.includes(id)).join(', ') || `${expected.length} available`);
|
||
}
|
||
|
||
/**
|
||
* Profile keeps none, and that is correct.
|
||
*
|
||
* There is no data source about an account, so a skill there would be a
|
||
* placeholder. The emptiness is pinned so a later phase cannot quietly fill it
|
||
* to make a list look complete.
|
||
*/
|
||
record('the Profile page still carries no analysis skill',
|
||
reg.skillsForContext('admin.profile', [], []).length === 0,
|
||
JSON.stringify(reg.skillsForContext('admin.profile', [], []).map((s) => s.id)));
|
||
|
||
/* ── Triggers reach their skill, and take nothing that was not theirs ────── */
|
||
|
||
const TRIGGER_CASES = [
|
||
['admin.positions', 'Which roles are at risk?', 'staffing-risk'],
|
||
['admin.analytics', 'How is attendance this month?', 'attendance-analysis'],
|
||
['admin.analytics', 'How much overtime are we running?', 'overtime-analysis'],
|
||
['admin.candidatesList', 'What is the candidate quality like?', 'candidate-analysis'],
|
||
['admin.talentPool', 'How healthy is the talent pool?', 'talent-pool-analysis'],
|
||
['admin.analytics', 'Show me workforce coverage', 'workforce-analytics'],
|
||
['admin.activity', 'Is there anything unusual?', 'anomaly-detection'],
|
||
['admin.activity', 'Give me an event breakdown', 'activity-analysis'],
|
||
['admin.controlCenter', 'What is the operational risk?', 'operational-risk'],
|
||
['admin.controlCenter', 'Give me an executive summary', 'executive-summary'],
|
||
['admin.hiredHistory', 'What is our time to hire?', 'hiring-history-analysis'],
|
||
['admin.forge', 'How is training progress?', 'learning-analysis'],
|
||
['admin.controlCenter', 'What is the hiring pulse?', 'hiring-pulse-analysis'],
|
||
];
|
||
|
||
for (const [contextId, question, expected] of TRIGGER_CASES) {
|
||
const matched = reg.matchSkill(question, contextId, [], []);
|
||
record(`"${question}" reaches \`${expected}\``,
|
||
matched?.id === expected, matched?.id || 'no match');
|
||
}
|
||
|
||
/**
|
||
* The check that protects everything already shipped.
|
||
*
|
||
* Thirteen new definitions across shared pages is the likeliest way to take a
|
||
* question that an existing definition — or a page's own reader — was answering.
|
||
* Every baseline question is replayed on every page: whatever answered it before
|
||
* must still answer it.
|
||
*/
|
||
const stolen = [];
|
||
for (const contextId of Object.keys(expectedBaseline.contexts)) {
|
||
const before = expectedBaseline.contexts[contextId];
|
||
for (const intent of before.intents) {
|
||
const now = reg.matchSkill(intent.question, contextId, [], [])?.id ?? null;
|
||
if (now !== intent.matchedSkill) {
|
||
stolen.push(`${contextId} "${intent.question}": ${intent.matchedSkill ?? 'page reader'} → ${now}`);
|
||
}
|
||
}
|
||
}
|
||
record('no new skill takes a question something else was answering',
|
||
stolen.length === 0, stolen.join(' | ') || `${Object.keys(expectedBaseline.contexts).length} contexts replayed`);
|
||
|
||
/* ── The format stays suitable for non-technical authoring ──────────────── */
|
||
|
||
/**
|
||
* Every analysis definition round-trips through the field writer.
|
||
*
|
||
* The Markdown is the canonical representation, and a future authoring flow
|
||
* will compose it rather than replace it. That only holds if a definition can
|
||
* be read into fields and written back without losing what it said — so the
|
||
* property is asserted now, while there are thirteen definitions to test it
|
||
* against, rather than discovered later.
|
||
*/
|
||
const lossy = [];
|
||
for (const id of ANALYSIS_SKILLS) {
|
||
const skill = reg.SKILLS.find((s) => s.id === id);
|
||
if (!skill) continue;
|
||
const rewritten = fields.patchFrontmatter(skill.markdown, { description: skill.description });
|
||
const reparsedSkill = reg.parseSkill(rewritten, { custom: true });
|
||
if (reparsedSkill.body !== skill.body) lossy.push(`${id}: body`);
|
||
if (JSON.stringify(reparsedSkill.owliver) !== JSON.stringify(skill.owliver)) lossy.push(`${id}: owliver`);
|
||
if (JSON.stringify(reparsedSkill.pages) !== JSON.stringify(skill.pages)) lossy.push(`${id}: pages`);
|
||
}
|
||
record('an analysis definition survives being written back through the field writer',
|
||
lossy.length === 0, lossy.join(', ') || `${ANALYSIS_SKILLS.length} definitions`);
|
||
|
||
record('every analysis skill declares a category for grouping',
|
||
ANALYSIS_SKILLS.every((id) => reg.SKILLS.find((s) => s.id === id)?.category),
|
||
[...new Set(ANALYSIS_SKILLS.map((id) => reg.SKILLS.find((s) => s.id === id)?.category))].join(', '));
|
||
|
||
/* ── 15. Agent registry ───────────────────────────────────────────────────
|
||
*
|
||
* Agents are the layer above skills, and they are read by the *same* parser —
|
||
* `parseAgent` imports `parseFrontmatter` and the section readers from the
|
||
* skill registry rather than reimplementing them. These checks exist to keep
|
||
* that true, and to keep an agent honest about what it carries: an agent that
|
||
* names a skill nobody provides is a capability promised and not delivered,
|
||
* and it fails silently.
|
||
*/
|
||
console.log('\n── Agent registry ──');
|
||
|
||
const agentReg = await server.ssrLoadModule('/src/lib/agents/registry.js');
|
||
const agentFields = await server.ssrLoadModule('/src/lib/agents/agentFields.js');
|
||
const customAgents = await server.ssrLoadModule('/src/lib/agents/customAgents.js');
|
||
const vocab = await server.ssrLoadModule('/src/lib/agents/vocabulary.js');
|
||
|
||
const agentFiles = readdirSync(join(ROOT, 'src/agents')).filter((f) => f.endsWith('.md'));
|
||
|
||
|
||
record('every .md under src/agents registers',
|
||
agentReg.AGENTS.length === agentFiles.length,
|
||
`${agentReg.AGENTS.length} registered / ${agentFiles.length} files`);
|
||
|
||
record('no two agents share an id',
|
||
new Set(agentReg.AGENTS.map((a) => a.id)).size === agentReg.AGENTS.length);
|
||
|
||
const shippedAgents = agentReg.readAgentRegistry([]);
|
||
|
||
record('shipped agent registry reports no diagnostics',
|
||
shippedAgents.diagnostics.length === 0,
|
||
shippedAgents.diagnostics.map((d) => d.message).join(' | ') || 'none');
|
||
|
||
record('every shipped agent registered every field it declared',
|
||
agentReg.AGENTS.every((a) => !a.errors?.length),
|
||
agentReg.AGENTS.flatMap((a) => a.errors || []).join(' | ') || 'none');
|
||
|
||
/* The nine the product ships: one per Krow page, plus the root. */
|
||
const EXPECTED_AGENTS = {
|
||
'krow-workforce-agent': null, // covers every page
|
||
'control-center-agent': 'control-center',
|
||
'positions-agent': 'positions',
|
||
'candidates-agent': 'candidates',
|
||
'hired-history-agent': 'hired-history',
|
||
'talent-pool-agent': 'talent-pool',
|
||
'krow-forge-agent': 'krow-forge',
|
||
'analytics-agent': 'analytics',
|
||
'activity-agent': 'activity',
|
||
};
|
||
|
||
for (const [id, page] of Object.entries(EXPECTED_AGENTS)) {
|
||
const agent = agentReg.getAgent(agentReg.AGENTS, id);
|
||
record(`\`${id}\` is registered and published`,
|
||
Boolean(agent) && agent.status === 'published',
|
||
agent ? agent.status : 'MISSING');
|
||
if (agent && page) {
|
||
record(`\`${id}\` covers \`${page}\``, agent.pages.includes(page),
|
||
JSON.stringify(agent.pages));
|
||
}
|
||
}
|
||
|
||
/* The root agent reaches every surface a skill may name, so it can stand in on
|
||
a page whose own agent carries nothing. */
|
||
const root = agentReg.getAgent(agentReg.AGENTS, 'krow-workforce-agent');
|
||
record('the root agent covers every supported page',
|
||
surfaces.SUPPORTED_SKILL_PAGES.every((p) => root.pages.includes(p)),
|
||
`${root.pages.length}/${surfaces.SUPPORTED_SKILL_PAGES.length}`);
|
||
record('the root agent carries the other eight as subagents',
|
||
root.subagents.length === 8
|
||
&& root.subagents.every((s) => s !== root.id && EXPECTED_AGENTS[s] !== undefined),
|
||
JSON.stringify(root.subagents));
|
||
|
||
/* Every address an agent states must resolve. */
|
||
const registeredSkillIds = new Set(reg.SKILLS.map((s) => s.id));
|
||
const registeredAgentIds = new Set(agentReg.AGENTS.map((a) => a.id));
|
||
|
||
record('every skill an agent names exists in the skill registry',
|
||
agentReg.AGENTS.every((a) => a.skills.every((s) => registeredSkillIds.has(s))),
|
||
agentReg.AGENTS.flatMap((a) => a.skills.filter((s) => !registeredSkillIds.has(s))).join(', ') || 'all resolve');
|
||
|
||
record('every subagent an agent names exists',
|
||
agentReg.AGENTS.every((a) => a.subagents.every((s) => registeredAgentIds.has(s))));
|
||
|
||
record('every page an agent names is a real surface',
|
||
agentReg.AGENTS.every((a) => a.pages.every((p) => surfaces.SUPPORTED_SKILL_PAGES.includes(p))),
|
||
agentReg.AGENTS.flatMap((a) => a.pages.filter((p) => !surfaces.SUPPORTED_SKILL_PAGES.includes(p))).join(', ') || 'all resolve');
|
||
|
||
/**
|
||
* No agent carries a placeholder skill.
|
||
*
|
||
* This replaces an earlier check that pinned four agents at zero skills, which
|
||
* was the honest assertion while no skill existed for their pages: the
|
||
* temptation when building an agent UI is to invent one so the list looks
|
||
* populated. Those skills now exist and are real, so pinning zero would be
|
||
* pinning the wrong thing — but the property worth protecting is unchanged, and
|
||
* this states it directly instead of by proxy.
|
||
*
|
||
* A skill is real when it declares a capability whose response binds to a
|
||
* registered data source, and that source resolves against the seeded workspace
|
||
* without reporting itself unavailable. A definition that names a source the
|
||
* product does not have, or one that cannot be read, is a promise the agent
|
||
* cannot keep.
|
||
*/
|
||
const skillById = new Map(reg.SKILLS.map((s) => [s.id, s]));
|
||
const placeholders = [];
|
||
const unreadable = [];
|
||
|
||
for (const agent of agentReg.AGENTS) {
|
||
for (const skillId of agent.skills) {
|
||
const skill = skillById.get(skillId);
|
||
if (!skill) continue; /* already reported as `unattached` above */
|
||
|
||
const responses = Object.values(skill.owliver?.responses || {});
|
||
const sources = responses.map((r) => r.source).filter(Boolean);
|
||
|
||
/**
|
||
* Two kinds of real skill, and only one of them reads data.
|
||
*
|
||
* An *analysis* skill binds capabilities to data sources. An *action* skill
|
||
* — `create-position`, `forge-skill-management` — declares actions or a
|
||
* guided conversation instead, and carries no responses at all. Both are
|
||
* real; a definition that does neither is the placeholder this looks for.
|
||
*/
|
||
const doesSomething = sources.length > 0
|
||
|| (skill.actions || []).length > 0
|
||
|| (skill.conversation || []).length > 0;
|
||
|
||
if (!doesSomething || !sources.every((src) => surfaces.dataSourceFor(src))) {
|
||
placeholders.push(`${agent.id}/${skillId}`);
|
||
continue;
|
||
}
|
||
|
||
for (const response of responses) {
|
||
const declared = surfaces.dataSourceFor(response.source);
|
||
/* A source that needs a position or a candidate in context is *correctly*
|
||
unavailable when it is handed neither — `resolveEntity` asks the reader
|
||
which one at runtime. Only workspace-level sources can be read cold,
|
||
so only those are asserted here. */
|
||
if (declared?.context) continue;
|
||
|
||
const read = dataResolver.resolveSkillData(response, AGENT_SKILL_CONTEXT);
|
||
/* `unavailable` means the resolver has no reading for that source at all.
|
||
`empty` is fine and honest — the source read successfully and found
|
||
nothing. */
|
||
if (read?.unavailable) unreadable.push(`${agent.id}/${skillId}:${response.source}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
record('no agent carries a skill without a real data source',
|
||
placeholders.length === 0, placeholders.join(', ') || 'every attached skill declares one');
|
||
|
||
record('every skill an agent carries reads a source the product can resolve',
|
||
unreadable.length === 0, unreadable.join(', ') || 'all resolve');
|
||
|
||
/* Every page agent now carries at least one skill of its own, except where the
|
||
product genuinely has none to give it. Stated as data rather than as a
|
||
number, so a future page with no source is visible rather than assumed. */
|
||
const withoutSkills = agentReg.AGENTS.filter((a) => a.skills.length === 0).map((a) => a.id);
|
||
record('every agent that has a skill available to it carries one',
|
||
withoutSkills.length === 0, withoutSkills.join(', ') || 'all nine agents carry skills');
|
||
|
||
/* ── Diagnostics: nothing half-loads in silence ──────────────────────────── */
|
||
|
||
const UNATTACHED = `---\nid: ghost-agent\nname: Ghost Agent\npages:\n - positions\nskills:\n - no-such-skill\n---\n\n# Ghost\n`;
|
||
const withUnattached = agentReg.readAgentRegistry([{ path: 'custom/ghost.md', raw: UNATTACHED }]);
|
||
record('an agent naming a missing skill is reported',
|
||
withUnattached.diagnostics.some((d) => d.kind === 'unattached' && d.agentId === 'ghost-agent'),
|
||
withUnattached.diagnostics.find((d) => d.kind === 'unattached')?.message ?? 'NO DIAGNOSTIC');
|
||
|
||
const BROKEN_AGENT = `---\nid: broken\n name: bad indent\n---\n# Broken\n`;
|
||
const withBrokenAgent = agentReg.readAgentRegistry([{ path: 'custom/broken.md', raw: BROKEN_AGENT }]);
|
||
record('an unreadable stored agent is reported, not silently dropped',
|
||
withBrokenAgent.diagnostics.some((d) => d.kind === 'unreadable'),
|
||
withBrokenAgent.diagnostics.find((d) => d.kind === 'unreadable')?.message ?? 'NO DIAGNOSTIC');
|
||
|
||
const SHADOW_AGENT = `---\nid: positions-agent\nname: My Positions Agent\npages:\n - positions\n---\n\n# Shadow\n`;
|
||
const withShadowAgent = agentReg.readAgentRegistry([{ path: 'custom/shadow.md', raw: SHADOW_AGENT }]);
|
||
record('a stored agent overriding a built-in is reported',
|
||
withShadowAgent.diagnostics.some((d) => d.kind === 'shadowed' && d.agentId === 'positions-agent'),
|
||
withShadowAgent.diagnostics.find((d) => d.kind === 'shadowed')?.message ?? 'NO DIAGNOSTIC');
|
||
|
||
/* A definition with one bad field keeps the rest AND says so. */
|
||
const PARTIAL_AGENT = `---\nid: partial-agent\nname: Partial Agent\npages:\n - positions\nreasoning: telepathy\n---\n\n# Partial\n`;
|
||
const withPartialAgent = agentReg.readAgentRegistry([{ path: 'custom/partial.md', raw: PARTIAL_AGENT }]);
|
||
const partialAgent = withPartialAgent.agents.find((a) => a.id === 'partial-agent');
|
||
record('an agent with one bad field still registers',
|
||
Boolean(partialAgent) && partialAgent.pages.includes('positions'));
|
||
record('...falls back to the documented default',
|
||
partialAgent?.reasoning === 'balanced', partialAgent?.reasoning);
|
||
record('...and the field it lost is reported',
|
||
withPartialAgent.diagnostics.some((d) => d.kind === 'incomplete' && d.agentId === 'partial-agent'),
|
||
withPartialAgent.diagnostics.find((d) => d.kind === 'incomplete')?.message ?? 'NO DIAGNOSTIC');
|
||
|
||
/* A cycle would make subagent resolution non-terminating. */
|
||
const SELF_SUB = `---\nid: loop-agent\nname: Loop Agent\npages:\n - positions\nsubagents:\n - loop-agent\n---\n\n# Loop\n`;
|
||
const looped = agentReg.parseAgent(SELF_SUB, { custom: true });
|
||
record('an agent cannot be its own subagent',
|
||
looped.subagents.length === 0 && looped.errors.length > 0,
|
||
looped.errors[0] || 'NOT REPORTED');
|
||
|
||
/* ── Validation: what the editor refuses ─────────────────────────────────── */
|
||
|
||
const REFUSALS = [
|
||
['an empty definition', ''],
|
||
['a malformed id', `---\nid: Not An Id\nname: Bad\npages:\n - positions\n---\n# x\n`],
|
||
['no name', `---\nid: no-name\npages:\n - positions\n---\n# x\n`],
|
||
['no pages', `---\nid: no-pages\nname: No Pages\n---\n# x\n`],
|
||
['an unsupported page', `---\nid: bad-page\nname: Bad Page\npages:\n - the-moon\n---\n# x\n`],
|
||
['an unsupported reasoning mode', `---\nid: bad-reason\nname: Bad Reason\npages:\n - positions\nreasoning: vibes\n---\n# x\n`],
|
||
['an unsupported permission role', `---\nid: bad-role\nname: Bad Role\npages:\n - positions\npermissions:\n people:\n - user: a@b.com\n role: emperor\n---\n# x\n`],
|
||
];
|
||
|
||
for (const [label, source] of REFUSALS) {
|
||
record(`validateAgentSource refuses ${label}`,
|
||
Boolean(agentReg.validateAgentSource(source)),
|
||
agentReg.validateAgentSource(source) || 'ACCEPTED');
|
||
}
|
||
|
||
/**
|
||
* An agent that omits `id:` derives one from its name.
|
||
*
|
||
* The same fallback `parseSkill` applies, and deliberately not a refusal: an
|
||
* explicit id is an address other definitions refer to, so writing one is
|
||
* encouraged, but leaving it out means "call it after its name" rather than
|
||
* "this file is broken".
|
||
*/
|
||
const DERIVED_ID = `---\nname: Derived Id Agent\npages:\n - positions\n---\n\n# D\n`;
|
||
record('an agent with no `id:` derives one from its name',
|
||
agentReg.parseAgent(DERIVED_ID, { custom: true }).id === 'derived-id-agent'
|
||
&& agentReg.validateAgentSource(DERIVED_ID) === null,
|
||
agentReg.parseAgent(DERIVED_ID, { custom: true }).id);
|
||
|
||
record('an agent with neither id nor name is refused',
|
||
Boolean(agentReg.validateAgentSource(`---\npages:\n - positions\n---\n\n# x\n`)));
|
||
|
||
record('validateAgentSource accepts a minimal agent',
|
||
agentReg.validateAgentSource(`---\nid: minimal\nname: Minimal\npages:\n - positions\n---\n\n# Minimal\n`) === null);
|
||
|
||
/**
|
||
* An agent carrying no skills is accepted.
|
||
*
|
||
* Deliberately not a refusal, unlike a skill that declares no capabilities.
|
||
* A skill with nothing to say cannot answer; an agent with no skills still has
|
||
* its page's own reader — which is exactly how Control Center, Hired History,
|
||
* Talent Pool and Activity answer today.
|
||
*/
|
||
record('validateAgentSource accepts an agent with no skills',
|
||
agentReg.validateAgentSource(`---\nid: skill-less\nname: Skill-less\npages:\n - control-center\n---\n\n# S\n`) === null);
|
||
|
||
/* ── Fields ⇄ definition, through the existing writer ────────────────────── */
|
||
|
||
const rootSource = root.markdown;
|
||
const patched = agentFields.applyAgentFields(rootSource, {
|
||
name: 'Renamed Agent',
|
||
permissions: {
|
||
owner: 'owner@krow.app',
|
||
access: 'specific',
|
||
people: [{ user: 'a@krow.app', role: 'editor' }, { user: 'b@krow.app', role: 'viewer' }],
|
||
},
|
||
knowledge: [{ id: 'policy', label: 'Overtime policy', kind: 'note', body: 'Beyond 20h needs sign-off.' }],
|
||
});
|
||
const reparsed = agentReg.parseAgent(patched, { custom: true });
|
||
|
||
/* The claim the whole editor rests on: `patchFrontmatter` already writes
|
||
nested block maps and `- key: value` sequences, so agents needed no second
|
||
writer. Asserted rather than assumed. */
|
||
record('a nested `permissions.people` list round-trips through the existing writer',
|
||
JSON.stringify(reparsed.permissions.people)
|
||
=== JSON.stringify([{ user: 'a@krow.app', role: 'editor' }, { user: 'b@krow.app', role: 'viewer' }]),
|
||
JSON.stringify(reparsed.permissions.people));
|
||
|
||
record('a nested `knowledge` entry round-trips too',
|
||
reparsed.knowledge.length === 1 && reparsed.knowledge[0].body === 'Beyond 20h needs sign-off.',
|
||
JSON.stringify(reparsed.knowledge));
|
||
|
||
record('patching one field leaves the body untouched',
|
||
reparsed.instructions === root.instructions);
|
||
|
||
record('patching one field leaves the others untouched',
|
||
JSON.stringify(reparsed.skills) === JSON.stringify(root.skills)
|
||
&& reparsed.subagents.length === root.subagents.length,
|
||
`${reparsed.skills.length} skills, ${reparsed.subagents.length} subagents`);
|
||
|
||
record('the patched definition still validates',
|
||
agentReg.validateAgentSource(patched) === null,
|
||
agentReg.validateAgentSource(patched) || 'ok');
|
||
|
||
record('fields read back out match what was written in',
|
||
agentFields.agentFieldsFromSource(patched).permissions.access === 'specific');
|
||
|
||
/* ── Storage round trip ──────────────────────────────────────────────────── */
|
||
|
||
const template = customAgents.agentTemplate({ id: 'my-agent', name: 'My Agent', pages: ['positions'] });
|
||
record('a fresh agent template validates',
|
||
agentReg.validateAgentSource(template) === null,
|
||
agentReg.validateAgentSource(template) || 'ok');
|
||
record('a fresh agent is a draft, never published',
|
||
agentReg.parseAgent(template, { custom: true }).status === 'draft',
|
||
agentReg.parseAgent(template, { custom: true }).status);
|
||
|
||
const storedAgent = customAgents.upsertCustomAgent([], template);
|
||
record('an authored agent stores as its own Markdown',
|
||
storedAgent.next.length === 1 && storedAgent.next[0].raw === template);
|
||
record('...and reads back with its id intact',
|
||
agentReg.parseAgent(storedAgent.next[0].raw, { custom: true }).id === 'my-agent');
|
||
|
||
const restored = customAgents.upsertCustomAgent(storedAgent.next, template);
|
||
record('re-saving an agent replaces its entry rather than duplicating it',
|
||
restored.next.length === 1, `${restored.next.length} stored`);
|
||
|
||
record('customAgentSource finds a stored definition',
|
||
customAgents.customAgentSource(storedAgent.next, 'my-agent') === template);
|
||
|
||
record('removeCustomAgent drops exactly one entry',
|
||
customAgents.removeCustomAgent(storedAgent.next, 'my-agent').length === 0);
|
||
|
||
/* An unparseable storedAgent entry must not take its neighbours with it. */
|
||
const mixed = [{ path: 'custom/bad.md', raw: BROKEN_AGENT }, ...storedAgent.next];
|
||
record('a broken stored agent does not remove the good ones',
|
||
customAgents.removeCustomAgent(mixed, 'nobody').length === 2);
|
||
|
||
/* ── Search ─────────────────────────────────────────────────────────────── */
|
||
|
||
record('an empty search returns every agent',
|
||
agentReg.searchAgents(agentReg.AGENTS, '').length === agentReg.AGENTS.length);
|
||
record('search matches on name',
|
||
agentReg.searchAgents(agentReg.AGENTS, 'analytics').some((a) => a.id === 'analytics-agent'));
|
||
record('search matches on what an agent is for',
|
||
agentReg.searchAgents(agentReg.AGENTS, 'audit trail').some((a) => a.id === 'activity-agent'));
|
||
record('search that matches nothing returns nothing',
|
||
agentReg.searchAgents(agentReg.AGENTS, 'zzzznope').length === 0);
|
||
|
||
/* ── Vocabulary is closed ───────────────────────────────────────────────── */
|
||
|
||
record('every agent names an icon the product has',
|
||
agentReg.AGENTS.every((a) => vocab.AGENT_ICONS.includes(a.icon)),
|
||
agentReg.AGENTS.map((a) => a.icon).join(', '));
|
||
record('every agent names a supported reasoning mode',
|
||
agentReg.AGENTS.every((a) => vocab.SUPPORTED_REASONING.includes(a.reasoning)));
|
||
record('every agent has a version of at least 1',
|
||
agentReg.AGENTS.every((a) => Number.isInteger(a.version) && a.version >= 1));
|
||
record('every agent states when to use it',
|
||
agentReg.AGENTS.every((a) => a.trigger.length > 0));
|
||
record('every agent states its instructions',
|
||
agentReg.AGENTS.every((a) => a.instructions.length > 0));
|
||
|
||
/* ── The parser is the skill parser ─────────────────────────────────────── */
|
||
|
||
/* A BOM, CRLF line endings, a blank line above the fence and trailing spaces
|
||
after it — the four things that used to take a skill definition down. An
|
||
agent gets the same tolerance for free, because it is the same function. */
|
||
const HOSTILE = `\r\n\r\n--- \r\nid: hostile-agent\r\nname: Hostile Agent\r\npages:\r\n - positions\r\n--- \r\n\r\n# Hostile\r\n\r\n## Instructions\r\n\r\nStill readable.\r\n`;
|
||
const hostile = agentReg.parseAgent(HOSTILE, { custom: true });
|
||
record('an agent survives a BOM, CRLF, a leading blank line and trailing spaces',
|
||
hostile.id === 'hostile-agent' && hostile.pages.includes('positions'),
|
||
`${hostile.id} / ${JSON.stringify(hostile.pages)}`);
|
||
record('...and its body still reads',
|
||
hostile.instructions.includes('Still readable.'),
|
||
hostile.instructions || 'LOST');
|
||
|
||
/* ── 16. Page boundary: runtime, knowledge and tools ──────────────────────
|
||
*
|
||
* The one property the whole design rests on: **an agent narrows a page and can
|
||
* never widen it.** Every layer added on top — data, knowledge, tools — has to
|
||
* inherit that, or selecting an agent becomes a way around the page boundary.
|
||
*
|
||
* It is asserted exhaustively rather than by example. Every agent is tried on
|
||
* every page, and the scoped result must be a subset of what the page offers
|
||
* with no agent at all. A subset cannot contain something the page did not have,
|
||
* so this is a proof rather than a spot-check.
|
||
*/
|
||
console.log('\n── Page boundary: runtime, knowledge and tools ──');
|
||
|
||
const runtime = await server.ssrLoadModule('/src/lib/agents/runtime.js');
|
||
const contexts = await server.ssrLoadModule('/src/components/ai-assistant/contexts.js');
|
||
const knowledge = await server.ssrLoadModule('/src/lib/agents/knowledge.js');
|
||
const tools = await server.ssrLoadModule('/src/lib/skills/tools.js');
|
||
const agentContext = await server.ssrLoadModule('/src/lib/agents/context.js');
|
||
const routingModule = await server.ssrLoadModule('/src/components/ai-assistant/routing.js');
|
||
const actions = await server.ssrLoadModule('/src/lib/skills/actions.js');
|
||
|
||
const ALL_AGENTS = agentReg.AGENTS;
|
||
const ALL_SKILLS = reg.SKILLS;
|
||
const PAGE_CONTEXTS = Object.keys(expectedBaseline.contexts);
|
||
|
||
/* ── The invariant, over every page × agent pair ─────────────────────────── */
|
||
|
||
const widened = [];
|
||
const scopedCounts = [];
|
||
|
||
for (const contextId of PAGE_CONTEXTS) {
|
||
const unscoped = reg.skillsForContext(contextId, [], []).map((s) => s.id);
|
||
|
||
for (const agent of ALL_AGENTS) {
|
||
const disabled = runtime.agentScopedDisabledWith(agent, ALL_AGENTS, ALL_SKILLS, []);
|
||
const scoped = reg.skillsForContext(contextId, disabled, []).map((s) => s.id);
|
||
|
||
const extra = scoped.filter((id) => !unscoped.includes(id));
|
||
if (extra.length) widened.push(`${contextId} + ${agent.id}: ${JSON.stringify(extra)}`);
|
||
scopedCounts.push(scoped.length);
|
||
}
|
||
}
|
||
|
||
record(`no agent widens any page (${PAGE_CONTEXTS.length} pages × ${ALL_AGENTS.length} agents)`,
|
||
widened.length === 0,
|
||
widened.slice(0, 3).join(' | ') || `${PAGE_CONTEXTS.length * ALL_AGENTS.length} combinations, every result a subset`);
|
||
|
||
/* With no agent at all, nothing is withheld — the pre-agent behaviour. */
|
||
record('no agent selected withholds nothing',
|
||
PAGE_CONTEXTS.every((contextId) =>
|
||
JSON.stringify(reg.skillsForContext(contextId, runtime.agentScopedDisabled(null, ALL_SKILLS, []), []).map((s) => s.id))
|
||
=== JSON.stringify(reg.skillsForContext(contextId, [], []).map((s) => s.id))));
|
||
|
||
record('`agentScopedDisabled(null, …)` returns its input untouched',
|
||
JSON.stringify(runtime.agentScopedDisabled(null, ALL_SKILLS, ['a', 'b'])) === JSON.stringify(['a', 'b']));
|
||
|
||
/* ── The three named cross-boundary cases ────────────────────────────────── */
|
||
|
||
const CROSS_CASES = [
|
||
['Positions page + Analytics Agent', 'admin.positions', 'analytics-agent'],
|
||
['Candidates page + Krow Workforce Agent', 'admin.candidatesList', 'krow-workforce-agent'],
|
||
['Analytics page + Positions Agent', 'admin.analytics', 'positions-agent'],
|
||
];
|
||
|
||
for (const [label, contextId, agentId] of CROSS_CASES) {
|
||
const agent = agentReg.getAgent(ALL_AGENTS, agentId);
|
||
const pageOnly = reg.skillsForContext(contextId, [], []).map((s) => s.id);
|
||
const disabled = runtime.agentScopedDisabledWith(agent, ALL_AGENTS, ALL_SKILLS, []);
|
||
const scoped = reg.skillsForContext(contextId, disabled, []).map((s) => s.id);
|
||
|
||
/* Data. */
|
||
record(`${label}: reads nothing the page does not already offer`,
|
||
scoped.every((id) => pageOnly.includes(id)),
|
||
`page offers ${JSON.stringify(pageOnly)}, agent sees ${JSON.stringify(scoped)}`);
|
||
|
||
/* Tools. */
|
||
const pageTools = tools.toolsForContext(contextId, [], []).map((t) => t.name);
|
||
const agentTools = tools.toolsForContext(contextId, disabled, []).map((t) => t.name);
|
||
record(`${label}: reaches no tool the page does not already offer`,
|
||
agentTools.every((name) => pageTools.includes(name)),
|
||
`page offers ${JSON.stringify(pageTools)}, agent reaches ${JSON.stringify(agentTools)}`);
|
||
|
||
/* Knowledge. */
|
||
const covers = runtime.agentCovers(agent, contextId);
|
||
const read = knowledge.retrieveKnowledge({
|
||
agent, contextId, question: 'What does the scope note say about pages?',
|
||
});
|
||
if (!covers) {
|
||
record(`${label}: retrieves no knowledge, because the agent is constrained here`,
|
||
read.available === false && read.passages.length === 0,
|
||
read.note || 'NO NOTE');
|
||
} else {
|
||
record(`${label}: knowledge stays inside the agent's own entries`,
|
||
read.passages.every((p) => (agent.knowledge || []).some((k) => k.id === p.documentId)),
|
||
`${read.passages.length} passage(s)`);
|
||
}
|
||
|
||
/* Routing. */
|
||
const intent = routingModule.resolveIntent({
|
||
question: 'What needs my attention?',
|
||
contextId,
|
||
disabledSkills: disabled,
|
||
agent,
|
||
agentCoversPage: covers,
|
||
agentSuggestion: runtime.defaultAgentForContext(ALL_AGENTS, contextId),
|
||
});
|
||
record(`${label}: ${covers ? 'answers from this page' : 'declines honestly instead of reaching'}`,
|
||
covers ? intent.kind !== 'constrained' : intent.kind === 'constrained',
|
||
`kind=${intent.kind}`);
|
||
}
|
||
|
||
/**
|
||
* A constrained agent must not answer *from the pages it does cover*.
|
||
*
|
||
* The subtlest way to break the boundary: Analytics Agent on Positions could
|
||
* plausibly answer an analytics question "because that is what it is for". It
|
||
* must not — the page is the boundary, and an agent is a lens on the page in
|
||
* front of the reader, never a route to a different one.
|
||
*/
|
||
const analyticsAgent = agentReg.getAgent(ALL_AGENTS, 'analytics-agent');
|
||
const leaked = routingModule.resolveIntent({
|
||
question: 'How much overtime are we running?',
|
||
contextId: 'admin.positions',
|
||
disabledSkills: runtime.agentScopedDisabledWith(analyticsAgent, ALL_AGENTS, ALL_SKILLS, []),
|
||
agent: analyticsAgent,
|
||
agentCoversPage: false,
|
||
agentSuggestion: agentReg.getAgent(ALL_AGENTS, 'positions-agent'),
|
||
});
|
||
record('a constrained agent does not answer from the pages it covers elsewhere',
|
||
leaked.kind === 'constrained' && !leaked.skill,
|
||
`kind=${leaked.kind}, skill=${leaked.skill?.id ?? 'none'}`);
|
||
|
||
record('...and says which agent belongs here instead',
|
||
JSON.stringify(leaked.doc).includes('Positions Agent'));
|
||
|
||
/* ── Tools ──────────────────────────────────────────────────────────────── */
|
||
|
||
record('every action a handler exists for is described',
|
||
tools.undescribedActions().length === 0,
|
||
tools.undescribedActions().join(', ') || `${tools.TOOL_NAMES.length} tools described`);
|
||
|
||
record('a tool that writes a record requires approval',
|
||
tools.toolRequiresApproval('create_position') === true);
|
||
|
||
record('a tool that only navigates does not',
|
||
['navigate_to_positions', 'navigate_to_analytics', 'open_related_page']
|
||
.every((name) => tools.toolRequiresApproval(name) === false));
|
||
|
||
record('every mutating tool requires approval',
|
||
tools.TOOLS.filter((t) => t.mutates).every((t) => t.requiresApproval),
|
||
tools.TOOLS.filter((t) => t.mutates).map((t) => t.name).join(', ') || 'none mutate');
|
||
|
||
record('every read-only tool is honest about mutating nothing',
|
||
tools.TOOLS.filter((t) => t.readOnly).every((t) => t.mutates === null));
|
||
|
||
/* A tool is reachable only through a skill on this page that declares it. */
|
||
const undeclaredTools = [];
|
||
for (const contextId of PAGE_CONTEXTS) {
|
||
const declared = new Set(
|
||
reg.skillsForContext(contextId, [], []).flatMap((s) => s.actions || [])
|
||
);
|
||
for (const tool of tools.toolsForContext(contextId, [], [])) {
|
||
if (!declared.has(tool.name)) undeclaredTools.push(`${contextId}: ${tool.name}`);
|
||
}
|
||
}
|
||
record('a tool is reachable only where a skill on that page declares it',
|
||
undeclaredTools.length === 0, undeclaredTools.join(', ') || 'every tool traced to a declaring skill');
|
||
|
||
/**
|
||
* The real action still refuses what a skill did not declare.
|
||
*
|
||
* `runAction` gated on `skill.actions` before any of this existed, and the tool
|
||
* layer describes actions rather than performing them — so the gate must be
|
||
* exactly where it was.
|
||
*/
|
||
const positionsSkill = reg.SKILLS.find((s) => s.id === 'staffing-risk');
|
||
record('runAction still refuses an action the skill did not declare',
|
||
actions.runAction('create_position', { skill: positionsSkill, draft: {}, status: 'draft' }) === null);
|
||
|
||
const creator = reg.SKILLS.find((s) => s.id === 'create-position');
|
||
record('...and still performs one it did',
|
||
actions.runAction('create_position', { skill: creator, draft: { title: 'X' }, status: 'draft' })?.type
|
||
=== 'create_position');
|
||
|
||
record('open_related_page resolves only addresses the product has',
|
||
actions.runAction('open_related_page', { skill: { actions: ['open_related_page'] }, page: 'positions' })?.route
|
||
=== '/admin/positions'
|
||
&& actions.runAction('open_related_page', { skill: { actions: ['open_related_page'] }, page: 'the-moon' }) === null);
|
||
|
||
/* ── Knowledge ──────────────────────────────────────────────────────────── */
|
||
|
||
record('an agent with no knowledge says so rather than returning nothing',
|
||
(() => {
|
||
const bare = agentReg.getAgent(ALL_AGENTS, 'activity-agent');
|
||
const read = knowledge.retrieveKnowledge({ agent: bare, contextId: 'admin.activity', question: 'policy' });
|
||
return read.available === false && /no knowledge attached/i.test(read.note || '');
|
||
})());
|
||
|
||
record('no agent is active means no knowledge, stated honestly',
|
||
(() => {
|
||
const read = knowledge.retrieveKnowledge({ agent: null, question: 'anything' });
|
||
return read.available === false && read.passages.length === 0 && Boolean(read.note);
|
||
})());
|
||
|
||
const rootAgent = agentReg.getAgent(ALL_AGENTS, 'krow-workforce-agent');
|
||
const found = knowledge.retrieveKnowledge({
|
||
agent: rootAgent, contextId: 'admin.positions', question: 'What can this agent see across pages?',
|
||
});
|
||
record('an agent with knowledge retrieves from its own entries',
|
||
found.available === true && found.passages.length > 0,
|
||
`${found.passages.length} passage(s)`);
|
||
|
||
record('every passage names the document it came from',
|
||
found.passages.every((p) => p.documentId && p.chunkId && p.text));
|
||
|
||
record('a question the knowledge does not cover returns nothing, and says so',
|
||
(() => {
|
||
const miss = knowledge.retrieveKnowledge({
|
||
agent: rootAgent, contextId: 'admin.positions', question: 'zzzz quantum bicycles',
|
||
});
|
||
return miss.available === true && miss.passages.length === 0 && Boolean(miss.note);
|
||
})());
|
||
|
||
record('no knowledge document is invented',
|
||
ALL_AGENTS.every((a) => knowledge.knowledgeDocuments(a)
|
||
.every((d) => (a.knowledge || []).some((k) => k.id === d.id))),
|
||
`${ALL_AGENTS.reduce((n, a) => n + knowledge.knowledgeDocuments(a).length, 0)} declared documents in total`);
|
||
|
||
/* ── Question classification ────────────────────────────────────────────── */
|
||
|
||
record('a question about records is structured, never retrieval',
|
||
runtime.classifyQuestion({ question: 'Which employees worked more than 20 overtime hours?' }) === 'structured');
|
||
|
||
record('a question about a document is knowledge',
|
||
runtime.classifyQuestion({ question: 'What does our overtime policy say?' }) === 'knowledge');
|
||
|
||
record('a question needing both is combined',
|
||
runtime.classifyQuestion({ question: 'Which employees exceeded the overtime policy this month?' }) === 'combined');
|
||
|
||
record('an ambiguous question stays structured rather than guessing at retrieval',
|
||
runtime.classifyQuestion({ question: 'How is attendance?' }) === 'structured');
|
||
|
||
/* ── Coverage and defaults ──────────────────────────────────────────────── */
|
||
|
||
const NATIVE = {
|
||
'admin.controlCenter': 'control-center-agent',
|
||
'admin.positions': 'positions-agent',
|
||
'admin.candidatesList': 'candidates-agent',
|
||
'admin.hiredHistory': 'hired-history-agent',
|
||
'admin.talentPool': 'talent-pool-agent',
|
||
'admin.forge': 'krow-forge-agent',
|
||
'admin.analytics': 'analytics-agent',
|
||
'admin.activity': 'activity-agent',
|
||
};
|
||
for (const [contextId, expected] of Object.entries(NATIVE)) {
|
||
record(`${contextId.replace('admin.', '')} opens on its own agent`,
|
||
runtime.defaultAgentForContext(ALL_AGENTS, contextId)?.id === expected,
|
||
runtime.defaultAgentForContext(ALL_AGENTS, contextId)?.id || 'none');
|
||
}
|
||
|
||
record('every page has an agent to open with',
|
||
PAGE_CONTEXTS.every((contextId) => runtime.defaultAgentForContext(ALL_AGENTS, contextId)),
|
||
PAGE_CONTEXTS.filter((c) => !runtime.defaultAgentForContext(ALL_AGENTS, c)).join(', ') || 'all covered');
|
||
|
||
record('a requested agent is never silently swapped for another',
|
||
(() => {
|
||
const turn = runtime.resolveAgentForTurn(ALL_AGENTS, 'analytics-agent', 'admin.positions');
|
||
return turn.agent.id === 'analytics-agent' && turn.covers === false && turn.suggestion?.id === 'positions-agent';
|
||
})());
|
||
|
||
/* ── Subagents ──────────────────────────────────────────────────────────── */
|
||
|
||
record('subagent skills are inherited one level deep',
|
||
runtime.agentSkillIds(rootAgent, ALL_AGENTS).length >= rootAgent.skills.length,
|
||
`${rootAgent.skills.length} own → ${runtime.agentSkillIds(rootAgent, ALL_AGENTS).length} with subagents`);
|
||
|
||
record('an unpublished subagent contributes nothing',
|
||
(() => {
|
||
const draftSub = { ...agentReg.getAgent(ALL_AGENTS, 'analytics-agent'), status: 'draft' };
|
||
const others = ALL_AGENTS.map((a) => (a.id === 'analytics-agent' ? draftSub : a));
|
||
const withDraft = runtime.agentSkillIds(rootAgent, others);
|
||
const withPublished = runtime.agentSkillIds(rootAgent, ALL_AGENTS);
|
||
return withDraft.length <= withPublished.length;
|
||
})());
|
||
|
||
record('a subagent cycle terminates',
|
||
(() => {
|
||
const a = { id: 'a', skills: ['s1'], subagents: ['b'], status: 'published' };
|
||
const b = { id: 'b', skills: ['s2'], subagents: ['a'], status: 'published' };
|
||
return runtime.agentSkillIds(a, [a, b]).length === 2;
|
||
})());
|
||
|
||
/* ── Starters ───────────────────────────────────────────────────────────── */
|
||
|
||
record('an agent offers no starters on a page it does not cover',
|
||
runtime.agentStarters(analyticsAgent, 'admin.positions').length === 0);
|
||
|
||
record('an agent offers its starters on a page it does cover',
|
||
runtime.agentStarters(analyticsAgent, 'admin.analytics').length > 0,
|
||
runtime.agentStarters(analyticsAgent, 'admin.analytics').map((s) => s.label).join(' | '));
|
||
|
||
record('a starter names no capability, so it cannot address an unoffered skill',
|
||
ALL_AGENTS.every((a) => runtime.agentStarters(a, null).every((s) => s.capability === null)));
|
||
|
||
/* ── Context envelope ───────────────────────────────────────────────────── */
|
||
|
||
const envelope = agentContext.buildOwliverContext({
|
||
context: { id: 'admin.positions', page: 'Positions' },
|
||
pathname: '/admin/positions',
|
||
pageContext: { position: { id: 'job_chef' } },
|
||
});
|
||
record('the envelope resolves its address from the placement table',
|
||
envelope.route === '/admin/positions' && envelope.pageKey === 'positions',
|
||
`${envelope.pageKey} @ ${envelope.route}`);
|
||
|
||
record('a page that publishes only a record still yields a valid envelope',
|
||
envelope.position?.id === 'job_chef'
|
||
&& Array.isArray(envelope.selectedItems) && envelope.selectedItems.length === 0
|
||
&& envelope.period === null,
|
||
JSON.stringify({ selected: envelope.selectedItems.length, period: envelope.period }));
|
||
|
||
record('a page that publishes nothing at all still yields a valid envelope',
|
||
(() => {
|
||
const bare = agentContext.buildOwliverContext({ context: { id: 'admin.activity', page: 'Activity' } });
|
||
return bare.pageKey === 'activity' && bare.position === null && Object.keys(bare.metrics).length === 0;
|
||
})());
|
||
|
||
record('the stored form of the envelope carries no records',
|
||
(() => {
|
||
const stored = agentContext.storableContext(envelope);
|
||
return !('selectedItems' in stored) && !('metrics' in stored) && !('position' in stored)
|
||
&& stored.pageKey === 'positions';
|
||
})());
|
||
|
||
/* ── Nothing above changed the unscoped product ─────────────────────────── */
|
||
|
||
/**
|
||
* The regression assertion for the whole phase.
|
||
*
|
||
* `resolveIntent` gained four parameters. With none of them supplied it must
|
||
* behave exactly as it did before — same branch, same skill, same document
|
||
* shape — or every existing caller has quietly changed.
|
||
*/
|
||
const UNSCOPED_CASES = [
|
||
['admin.positions', 'Which positions need attention?'],
|
||
['admin.candidatesList', 'Who is waiting on a decision?'],
|
||
['admin.analytics', 'What is the hiring trend?'],
|
||
['admin.activity', 'What happened recently?'],
|
||
['admin.profile', 'What are my permissions?'],
|
||
];
|
||
const drifted = [];
|
||
for (const [contextId, question] of UNSCOPED_CASES) {
|
||
const out = routingModule.resolveIntent({ question, contextId });
|
||
const before = expectedBaseline.contexts[contextId]?.intents.find((i) => i.question === question);
|
||
if (out.kind === 'constrained') drifted.push(`${contextId}: became constrained without an agent`);
|
||
if (before && out.kind !== before.kind) drifted.push(`${contextId} "${question}": ${before.kind} → ${out.kind}`);
|
||
}
|
||
record('resolveIntent with no agent behaves exactly as before',
|
||
drifted.length === 0, drifted.join(' | ') || `${UNSCOPED_CASES.length} cases unchanged`);
|
||
|
||
/* ── 17. Agent switcher wiring ────────────────────────────────────────────
|
||
*
|
||
* The switcher is a React component and there is no component harness here, so
|
||
* what is checked is the logic behind it: which agent a page opens on, what the
|
||
* list offers, and — the part that matters — that none of it can move the page.
|
||
*
|
||
* The rendering itself is verified by driving the real application; see the
|
||
* Phase 6 report.
|
||
*/
|
||
console.log('\n── Agent switcher wiring ──');
|
||
|
||
const icons = await server.ssrLoadModule('/src/components/agents/icons.js');
|
||
|
||
/* Every icon an agent names resolves, or falls back to the avatar on purpose. */
|
||
record('every agent icon resolves to a component or to the avatar',
|
||
ALL_AGENTS.every((a) => a.icon === 'owliver' || icons.agentIconFor(a.icon)),
|
||
ALL_AGENTS.map((a) => `${a.icon}${icons.agentIconFor(a.icon) ? '' : '(avatar)'}`).join(', '));
|
||
|
||
record('the primary agent draws the Owliver avatar rather than a glyph',
|
||
icons.agentIconFor('owliver') === null);
|
||
|
||
record('an unknown icon falls back to the avatar rather than crashing',
|
||
icons.agentIconFor('not-an-icon') === null);
|
||
|
||
/* ── The list the switcher renders ──────────────────────────────────────── */
|
||
|
||
/**
|
||
* Agents that work here sort above those that do not.
|
||
*
|
||
* On a page where two of nine apply, the seven that cannot must not sit above
|
||
* them. Order is otherwise the registry's own, so the list does not reshuffle
|
||
* as the reader types.
|
||
*/
|
||
for (const contextId of ['admin.positions', 'admin.analytics', 'admin.activity']) {
|
||
const ordered = [...agentReg.searchAgents(ALL_AGENTS, '')]
|
||
.sort((a, b) => Number(runtime.agentCovers(b, contextId)) - Number(runtime.agentCovers(a, contextId)));
|
||
const firstConstrained = ordered.findIndex((a) => !runtime.agentCovers(a, contextId));
|
||
const lastCovering = ordered.map((a) => runtime.agentCovers(a, contextId)).lastIndexOf(true);
|
||
record(`${contextId.replace('admin.', '')}: agents that work here are listed first`,
|
||
firstConstrained === -1 || lastCovering < firstConstrained,
|
||
ordered.slice(0, 3).map((a) => `${a.name}${runtime.agentCovers(a, contextId) ? '' : ' (constrained)'}`).join(', '));
|
||
}
|
||
|
||
record('search reaches every agent by name',
|
||
ALL_AGENTS.every((a) => agentReg.searchAgents(ALL_AGENTS, a.name).some((m) => m.id === a.id)));
|
||
|
||
record('a constrained agent is still listed rather than hidden',
|
||
agentReg.searchAgents(ALL_AGENTS, 'Analytics').some((a) => a.id === 'analytics-agent'),
|
||
'listed on every page, marked where it does not apply');
|
||
|
||
/* ── Native agent per page ──────────────────────────────────────────────── */
|
||
|
||
/**
|
||
* A page opens on the agent written for it, and that state is behaviourally
|
||
* identical to no agent at all.
|
||
*
|
||
* The second half is what makes automatic selection safe: a page agent carries
|
||
* every skill its own page offers, so selecting it withholds nothing. If that
|
||
* ever stopped being true, opening a page would silently lose a capability.
|
||
*/
|
||
for (const [contextId, expected] of Object.entries(NATIVE)) {
|
||
const native = runtime.defaultAgentForContext(ALL_AGENTS, contextId);
|
||
const unscoped = reg.skillsForContext(contextId, [], []).map((s) => s.id);
|
||
const withNative = reg.skillsForContext(
|
||
contextId, runtime.agentScopedDisabledWith(native, ALL_AGENTS, ALL_SKILLS, []), []
|
||
).map((s) => s.id);
|
||
|
||
record(`${contextId.replace('admin.', '')}: opening on its native agent withholds nothing`,
|
||
JSON.stringify(unscoped) === JSON.stringify(withNative),
|
||
`${expected}: ${withNative.length}/${unscoped.length} skills`);
|
||
}
|
||
|
||
/* ── Switching agent cannot move the page ───────────────────────────────── */
|
||
|
||
/**
|
||
* The protected contract, at the UI seam.
|
||
*
|
||
* `AgentProvider` is handed the context id and only reads it. There is no
|
||
* setter, no navigation and no write-back, so selecting an agent cannot change
|
||
* which page the reader is on. Asserted structurally: the page's context id and
|
||
* the skills it offers are identical whichever agent is active.
|
||
*/
|
||
const movedPage = [];
|
||
for (const contextId of PAGE_CONTEXTS) {
|
||
const pageOnly = reg.skillsForContext(contextId, [], []).map((s) => s.id);
|
||
for (const candidate of ALL_AGENTS) {
|
||
const turn = runtime.resolveAgentForTurn(ALL_AGENTS, candidate.id, contextId);
|
||
/* The context the panel resolves is unchanged by the choice. */
|
||
if (turn.agent?.id !== candidate.id) movedPage.push(`${contextId}: ${candidate.id} was swapped`);
|
||
/* And the page still offers exactly what it offered. */
|
||
const stillOffers = reg.skillsForContext(contextId, [], []).map((s) => s.id);
|
||
if (JSON.stringify(stillOffers) !== JSON.stringify(pageOnly)) {
|
||
movedPage.push(`${contextId}: page changed under ${candidate.id}`);
|
||
}
|
||
}
|
||
}
|
||
record('choosing an agent never changes the page or what it offers',
|
||
movedPage.length === 0,
|
||
movedPage.slice(0, 3).join(' | ') || `${PAGE_CONTEXTS.length * ALL_AGENTS.length} selections, page unchanged in every one`);
|
||
|
||
/* ── The constrained state ──────────────────────────────────────────────── */
|
||
|
||
const constrainedPairs = [];
|
||
for (const contextId of PAGE_CONTEXTS) {
|
||
for (const candidate of ALL_AGENTS) {
|
||
if (runtime.agentCovers(candidate, contextId)) continue;
|
||
constrainedPairs.push([contextId, candidate]);
|
||
}
|
||
}
|
||
|
||
record('a constrained agent offers no starters',
|
||
constrainedPairs.every(([contextId, candidate]) => runtime.agentStarters(candidate, contextId).length === 0),
|
||
`${constrainedPairs.length} constrained pairs`);
|
||
|
||
/**
|
||
* A constrained agent does not answer — which is not the same as carrying no
|
||
* skills here.
|
||
*
|
||
* An earlier version of this check asserted the empty list and was wrong.
|
||
* Control Center Agent is constrained on Positions, yet it carries
|
||
* `staffing-risk`, and `staffing-risk` genuinely *is* a Positions skill. The
|
||
* list is non-empty and entirely within the page's boundary — the subset proof
|
||
* above already covers that.
|
||
*
|
||
* What actually protects the reader is that the constrained branch fires before
|
||
* any skill is consulted, so the agent declines rather than answering under a
|
||
* name that does not belong to this page. That is what is asserted here, over
|
||
* every constrained pair rather than a sample.
|
||
*/
|
||
const answeredWhileConstrained = [];
|
||
for (const [contextId, candidate] of constrainedPairs) {
|
||
const intent = routingModule.resolveIntent({
|
||
question: 'What needs my attention?',
|
||
contextId,
|
||
disabledSkills: runtime.agentScopedDisabledWith(candidate, ALL_AGENTS, ALL_SKILLS, []),
|
||
agent: candidate,
|
||
agentCoversPage: false,
|
||
agentSuggestion: runtime.defaultAgentForContext(ALL_AGENTS, contextId),
|
||
});
|
||
if (intent.kind !== 'constrained') {
|
||
answeredWhileConstrained.push(`${contextId} + ${candidate.id}: ${intent.kind}`);
|
||
}
|
||
}
|
||
record('a constrained agent declines rather than answering, on every page',
|
||
answeredWhileConstrained.length === 0,
|
||
answeredWhileConstrained.slice(0, 3).join(' | ') || `${constrainedPairs.length} constrained pairs all decline`);
|
||
|
||
/* And whatever it does carry is still inside the page's own boundary. */
|
||
record('a constrained agent still cannot exceed the page it is constrained on',
|
||
constrainedPairs.every(([contextId, candidate]) => {
|
||
const pageOnly = reg.skillsForContext(contextId, [], []).map((x) => x.id);
|
||
return reg.skillsForContext(contextId, runtime.agentScopedDisabledWith(candidate, ALL_AGENTS, ALL_SKILLS, []), [])
|
||
.every((x) => pageOnly.includes(x.id));
|
||
}));
|
||
|
||
record('every constrained pair has a native agent to point at instead',
|
||
constrainedPairs.every(([contextId]) => runtime.defaultAgentForContext(ALL_AGENTS, contextId)));
|
||
|
||
/* ── Starters merge without duplicating a suggestion ────────────────────── */
|
||
|
||
/**
|
||
* A starter worded like a skill's suggestion must yield one chip, not two.
|
||
*
|
||
* The panel de-duplicates on what a chip *resolves to* as well as its label, and
|
||
* an agent starter deliberately carries no capability — so it can never address
|
||
* a skill the page has not offered, and a duplicate wording drops out.
|
||
*/
|
||
const dupes = [];
|
||
for (const [contextId] of Object.entries(NATIVE)) {
|
||
const native = runtime.defaultAgentForContext(ALL_AGENTS, contextId);
|
||
const disabled = runtime.agentScopedDisabledWith(native, ALL_AGENTS, ALL_SKILLS, []);
|
||
const starters = runtime.agentStarters(native, contextId);
|
||
const suggestions = resolver.owliverSuggestions(contextId, disabled, [], {});
|
||
|
||
const labels = [...starters, ...suggestions].map((c) => String(c.label).trim().toLowerCase());
|
||
const unique = new Set(labels);
|
||
if (labels.length !== unique.size) {
|
||
/* Not a failure in itself — the panel drops the repeat — but it is worth
|
||
knowing which wordings collide. */
|
||
dupes.push(`${contextId}: ${labels.length - unique.size}`);
|
||
}
|
||
}
|
||
record('agent starters carry no capability, so they cannot address an unoffered skill',
|
||
ALL_AGENTS.every((a) => runtime.agentStarters(a, null).every((s) => s.capability === null)),
|
||
dupes.length ? `overlapping wordings de-duplicated on: ${dupes.join(', ')}` : 'no overlapping wordings');
|
||
|
||
/**
|
||
* Every destination the switcher offers must exist.
|
||
*
|
||
* This check exists because it did not, and a visual review found two footer
|
||
* actions navigating to routes the router had no entry for. Tests covered what
|
||
* the switcher *computed* and nothing about where it *sent* the reader, which
|
||
* is exactly the gap a 404 lives in.
|
||
*
|
||
* Read out of `App.jsx` rather than asserted against a written list, so a route
|
||
* that is renamed or removed fails here rather than in someone's browser.
|
||
*/
|
||
const appSource = readFileSync(join(ROOT, 'src/App.jsx'), 'utf8');
|
||
const switcherSource = readFileSync(join(ROOT, 'src/components/ai-assistant/AgentSwitcher.jsx'), 'utf8');
|
||
|
||
/* Only live navigations count: a disabled control goes nowhere by design. */
|
||
const navigated = [...switcherSource.matchAll(/navigate\('([^']+)'\)/g)].map((m) => m[1]);
|
||
|
||
const routed = new Set(
|
||
[...appSource.matchAll(/<Route\s+path="([^"]+)"/g)]
|
||
.map((m) => m[1])
|
||
.filter((path) => path !== '*')
|
||
.map((path) => (path.startsWith('/') ? path : `/admin/${path}`))
|
||
);
|
||
|
||
const dead = navigated.filter((to) => !routed.has(to));
|
||
record('every route the agent switcher navigates to exists',
|
||
dead.length === 0,
|
||
dead.length ? `DEAD: ${dead.join(', ')}` : `${navigated.length} destination(s): ${navigated.join(', ')}`);
|
||
|
||
/**
|
||
* Authoring is reachable from the switcher, and its screen exists.
|
||
*
|
||
* An earlier version of this asserted the opposite — that the control was
|
||
* disabled — which was correct while the management screens did not exist and
|
||
* a visual review had found it navigating to a 404. Now that it is built, the
|
||
* durable property is the one above: whatever the switcher offers must resolve.
|
||
* This states the specific case so the pair cannot silently invert again.
|
||
*/
|
||
record('creating an agent is reachable from the switcher',
|
||
/navigate\('\/admin\/workspace\/agents\/new'\)/.test(switcherSource)
|
||
&& routed.has('/admin/workspace/agents/new'),
|
||
'offered, and the screen exists');
|
||
|
||
record('every published agent offers at least one starter where it applies',
|
||
ALL_AGENTS.filter((a) => a.status === 'published')
|
||
.every((a) => a.pages.length === 0 || runtime.agentStarters(a, null).length > 0),
|
||
ALL_AGENTS.map((a) => `${a.id}:${a.starters.length}`).join(' '));
|
||
|
||
/* ── 18. Conversation records and insights ───────────────────────────────
|
||
*
|
||
* Conversations gained fields: which agent answered, where, what it used, and
|
||
* how it was rated. The risk in changing a stored shape is not that the new
|
||
* records are wrong — it is that the old ones quietly stop reading, and a
|
||
* reader's history disappears without anything saying so.
|
||
*
|
||
* So the migration is asserted first, and the figures built on top are asserted
|
||
* to describe only records that exist.
|
||
*/
|
||
console.log('\n── Conversation records and insights ──');
|
||
|
||
const insightsSelectors = await server.ssrLoadModule('/src/lib/agents/conversationInsights.js');
|
||
const historyModule = await server.ssrLoadModule('/src/components/ai-assistant/history.js');
|
||
|
||
/* `history.js` writes to localStorage, which does not exist under SSR. A
|
||
minimal in-memory stand-in lets the real module be exercised rather than a
|
||
reimplementation of it — the migration is the thing under test, and a mock of
|
||
it would reproduce none of the failures this section exists for. */
|
||
const historyStore = new Map();
|
||
globalThis.localStorage = {
|
||
getItem: (k) => (historyStore.has(k) ? historyStore.get(k) : null),
|
||
setItem: (k, v) => historyStore.set(k, String(v)),
|
||
removeItem: (k) => historyStore.delete(k),
|
||
clear: () => historyStore.clear(),
|
||
};
|
||
|
||
const HISTORY_KEY = 'krow_assistant:history';
|
||
const seedHistory = (records) => historyStore.set(HISTORY_KEY, JSON.stringify(records));
|
||
|
||
/* ── A conversation held before any of this existed ──────────────────────── */
|
||
|
||
const V1_RECORD = {
|
||
id: 'c_old',
|
||
contextId: 'admin.positions',
|
||
page: 'Positions',
|
||
title: 'Which positions need attention?',
|
||
turns: 2,
|
||
updatedAt: new Date().toISOString(),
|
||
messages: [
|
||
{ role: 'user', text: 'Which positions need attention?' },
|
||
{ role: 'assistant', blocks: [{ type: 'text', text: 'Three roles need attention.' }] },
|
||
],
|
||
};
|
||
|
||
seedHistory([V1_RECORD]);
|
||
const migrated = historyModule.readHistory();
|
||
|
||
record('a conversation stored before agents existed still reads',
|
||
migrated.length === 1 && migrated[0].id === 'c_old',
|
||
`${migrated.length} record(s)`);
|
||
|
||
record('...and its messages are returned byte-identical',
|
||
JSON.stringify(migrated[0].messages) === JSON.stringify(V1_RECORD.messages));
|
||
|
||
record('...with the new fields present and honestly empty',
|
||
migrated[0].schema === 2
|
||
&& migrated[0].agentId === null
|
||
&& migrated[0].feedback === null
|
||
&& Array.isArray(migrated[0].skillsUsed) && migrated[0].skillsUsed.length === 0,
|
||
JSON.stringify({
|
||
schema: migrated[0].schema, agentId: migrated[0].agentId, skills: migrated[0].skillsUsed,
|
||
}));
|
||
|
||
record('migration does not rewrite the stored copy',
|
||
JSON.parse(historyStore.get(HISTORY_KEY))[0].schema === undefined,
|
||
'read-time migration, so nothing can fail half-written');
|
||
|
||
/* ── A conversation held now ─────────────────────────────────────────────── */
|
||
|
||
historyStore.clear();
|
||
historyModule.saveConversation({
|
||
id: 'c_new',
|
||
contextId: 'admin.positions',
|
||
page: 'Positions',
|
||
agentId: 'positions-agent',
|
||
pageContext: { page: 'Positions', pageKey: 'positions', route: '/admin/positions', period: null },
|
||
skillsUsed: ['staffing-risk', 'staffing-risk'],
|
||
toolsUsed: ['create_position'],
|
||
knowledgeUsed: [],
|
||
messages: [
|
||
{ role: 'user', text: 'Which roles are at risk?' },
|
||
{ role: 'assistant', blocks: [{ type: 'text', text: 'Three.' }] },
|
||
],
|
||
});
|
||
|
||
const [savedConversation] = historyModule.readHistory();
|
||
|
||
record('a conversation records which agent answered',
|
||
savedConversation.agentId === 'positions-agent', savedConversation.agentId);
|
||
|
||
record('...what it used, deduplicated',
|
||
JSON.stringify(savedConversation.skillsUsed) === JSON.stringify(['staffing-risk'])
|
||
&& JSON.stringify(savedConversation.toolsUsed) === JSON.stringify(['create_position']),
|
||
`${JSON.stringify(savedConversation.skillsUsed)} / ${JSON.stringify(savedConversation.toolsUsed)}`);
|
||
|
||
/**
|
||
* The stored context is the *reduced* envelope.
|
||
*
|
||
* Selections and computed figures are records; writing them per turn would put
|
||
* the dataset into localStorage a message at a time. What a reviewer needs
|
||
* later is where the question was asked, not a copy of what was on screen.
|
||
*/
|
||
record('the stored page context carries no records',
|
||
!('selectedItems' in savedConversation.pageContext) && !('metrics' in savedConversation.pageContext)
|
||
&& !('position' in savedConversation.pageContext) && savedConversation.pageContext.pageKey === 'positions',
|
||
Object.keys(savedConversation.pageContext).join(', '));
|
||
|
||
/* ── Feedback ───────────────────────────────────────────────────────────── */
|
||
|
||
historyModule.recordFeedback('c_new', { rating: 'up' });
|
||
record('a conversation can be rated', historyModule.readHistory()[0].feedback?.rating === 'up');
|
||
|
||
historyModule.recordFeedback('c_new', { rating: 'down', note: 'Missed the chef role' });
|
||
const rerated = historyModule.readHistory();
|
||
record('re-rating corrects rather than appends',
|
||
rerated.length === 1 && rerated[0].feedback.rating === 'down' && rerated[0].feedback.note === 'Missed the chef role',
|
||
`${rerated.length} record(s), rating=${rerated[0].feedback.rating}`);
|
||
|
||
historyModule.recordFeedback('c_new', null);
|
||
record('clearing a rating leaves none behind, not a neutral one',
|
||
historyModule.readHistory()[0].feedback === null);
|
||
|
||
record('rating a conversation that does not exist changes nothing',
|
||
historyModule.recordFeedback('c_nope', { rating: 'up' }).length === 1);
|
||
|
||
/* A rating survives the thread growing. */
|
||
historyModule.recordFeedback('c_new', { rating: 'up' });
|
||
historyModule.saveConversation({
|
||
id: 'c_new',
|
||
contextId: 'admin.positions',
|
||
page: 'Positions',
|
||
agentId: 'positions-agent',
|
||
messages: [
|
||
{ role: 'user', text: 'Which roles are at risk?' },
|
||
{ role: 'assistant', blocks: [{ type: 'text', text: 'Three.' }] },
|
||
{ role: 'user', text: 'And the chef role?' },
|
||
],
|
||
});
|
||
record('a rating survives the conversation continuing',
|
||
historyModule.readHistory()[0].feedback?.rating === 'up'
|
||
&& historyModule.readHistory()[0].turns === 2,
|
||
`rating kept across ${historyModule.readHistory()[0].turns} turns`);
|
||
|
||
/* ── Insight selectors ──────────────────────────────────────────────────── */
|
||
|
||
const nowMs = Date.now();
|
||
const day = (n) => new Date(nowMs - n * 86400000).toISOString();
|
||
|
||
const FIXTURE = [
|
||
{ id: 'a1', agentId: 'positions-agent', contextId: 'admin.positions', page: 'Positions', turns: 3, skillsUsed: ['staffing-risk'], toolsUsed: [], feedback: { rating: 'up' }, updatedAt: day(0), messages: [{ role: 'user', text: 'x' }] },
|
||
{ id: 'a2', agentId: 'positions-agent', contextId: 'admin.positions', page: 'Positions', turns: 1, skillsUsed: ['staffing-risk', 'create-position'], toolsUsed: ['create_position'], feedback: { rating: 'down' }, updatedAt: day(1), messages: [{ role: 'user', text: 'x' }] },
|
||
{ id: 'a3', agentId: 'analytics-agent', contextId: 'admin.analytics', page: 'Analytics', turns: 2, skillsUsed: ['overtime-analysis'], toolsUsed: [], feedback: null, updatedAt: day(1), messages: [{ role: 'user', text: 'x' }] },
|
||
];
|
||
|
||
/**
|
||
* Nothing recorded is a different answer from nothing happening.
|
||
*
|
||
* A row of zeros reads as "the agent was asked and did nothing". The empty flag
|
||
* is what lets a view say "not asked yet" instead — which is the whole reason
|
||
* Insights can be honest before any conversation exists.
|
||
*/
|
||
const none = insightsSelectors.conversationStats([]);
|
||
record('no conversations reports empty rather than zeros',
|
||
none.empty === true && none.total === 0 && none.feedback.score === null,
|
||
`score=${none.feedback.score} (null, not 0)`);
|
||
|
||
const stats = insightsSelectors.conversationStats(FIXTURE);
|
||
record('conversation counts match the records', stats.total === 3 && stats.turns === 6,
|
||
`${stats.total} conversations, ${stats.turns} turns`);
|
||
|
||
record('pages counted are the distinct contexts', stats.pages === 2, `${stats.pages} pages`);
|
||
|
||
record('feedback counts every rating and every absence',
|
||
stats.feedback.up === 1 && stats.feedback.down === 1 && stats.feedback.unrated === 1
|
||
&& stats.feedback.score === 50,
|
||
JSON.stringify(stats.feedback));
|
||
|
||
record('skills are tallied across conversations, most used first',
|
||
JSON.stringify(stats.bySkill) === JSON.stringify([
|
||
{ id: 'staffing-risk', count: 2 }, { id: 'create-position', count: 1 }, { id: 'overtime-analysis', count: 1 },
|
||
]),
|
||
JSON.stringify(stats.bySkill));
|
||
|
||
record('tools are tallied too',
|
||
JSON.stringify(stats.byTool) === JSON.stringify([{ id: 'create_position', count: 1 }]));
|
||
|
||
record('conversations are grouped per agent',
|
||
JSON.stringify(stats.byAgent) === JSON.stringify([
|
||
{ id: 'positions-agent', count: 2 }, { id: 'analytics-agent', count: 1 },
|
||
]));
|
||
|
||
record('a day nothing was asked is not charted as a zero',
|
||
stats.byDay.length === stats.activeDays && stats.byDay.every((d) => d.count > 0),
|
||
`${stats.byDay.length} active day(s)`);
|
||
|
||
record('stats can be scoped to one agent',
|
||
insightsSelectors.conversationStats(FIXTURE, { agentId: 'analytics-agent' }).total === 1);
|
||
|
||
record('an agent with no conversations reports empty, not zero',
|
||
insightsSelectors.conversationStats(FIXTURE, { agentId: 'activity-agent' }).empty === true);
|
||
|
||
record('stats can be windowed by date',
|
||
insightsSelectors.conversationStats(FIXTURE, { since: day(0.5) }).total === 1,
|
||
`${insightsSelectors.conversationStats(FIXTURE, { since: day(0.5) }).total} in the last 12 hours`);
|
||
|
||
record('conversationsForAgent narrows without mutating the input',
|
||
insightsSelectors.conversationsForAgent(FIXTURE, 'positions-agent').length === 2
|
||
&& FIXTURE.length === 3);
|
||
|
||
record('unrated conversations are the review queue',
|
||
insightsSelectors.unratedConversations(FIXTURE).map((r) => r.id).join(',') === 'a3');
|
||
|
||
/**
|
||
* A review row carries no thread.
|
||
*
|
||
* A list renders forty of these and one is ever opened; including the messages
|
||
* would load every conversation to draw a table.
|
||
*/
|
||
const row = insightsSelectors.reviewRow(FIXTURE[0]);
|
||
record('a review row omits the thread itself',
|
||
!('messages' in row) && row.id === 'a1' && row.page === 'Positions',
|
||
Object.keys(row).join(', '));
|
||
|
||
/* ── Nothing here invents a figure ──────────────────────────────────────── */
|
||
|
||
const invented = [];
|
||
for (const key of ['total', 'turns', 'pages']) {
|
||
if (insightsSelectors.conversationStats([])[key] !== 0) invented.push(key);
|
||
}
|
||
for (const entry of [...stats.bySkill, ...stats.byTool, ...stats.byAgent]) {
|
||
const real = FIXTURE.some((r) => [...r.skillsUsed, ...r.toolsUsed, r.agentId].includes(entry.id));
|
||
if (!real) invented.push(entry.id);
|
||
}
|
||
record('every figure traces to a record that exists',
|
||
invented.length === 0, invented.join(', ') || 'nothing invented');
|
||
|
||
delete globalThis.localStorage;
|
||
|
||
/* ── 19. Agent management ─────────────────────────────────────────────────
|
||
*
|
||
* The management screens let someone who has never seen a Markdown file
|
||
* create, configure and publish an agent. What makes that safe is that they are
|
||
* not a second agent system: every screen writes fields, `agentPatch` turns
|
||
* those into frontmatter, and the existing parser reads them back.
|
||
*
|
||
* So what is checked here is the round trip — a form edit must survive being
|
||
* written and re-read — and the lifecycle rules that protect a published agent.
|
||
*/
|
||
console.log('\n── Agent management ──');
|
||
|
||
const lifecycle = await server.ssrLoadModule('/src/lib/agents/agentLifecycle.js');
|
||
|
||
const shippedSource = agentReg.getAgent(ALL_AGENTS, 'positions-agent').markdown;
|
||
|
||
/* ── Fields survive the round trip ──────────────────────────────────────── */
|
||
|
||
/**
|
||
* The property the whole management UI rests on.
|
||
*
|
||
* A form holds fields; storage holds Markdown. If a field could not survive
|
||
* being written and read back, configuring an agent would silently lose part of
|
||
* it — and the loss would only show up later, in an answer that did not happen.
|
||
*/
|
||
const EDITS = {
|
||
name: 'Renamed Positions Agent',
|
||
description: 'A different description.',
|
||
trigger: 'Use when roles are not filling.',
|
||
instructions: 'Answer about open roles only.\n\nAsk which role when none is open.',
|
||
icon: 'briefcase',
|
||
reasoning: 'deep',
|
||
webSearch: true,
|
||
pages: ['positions', 'control-center'],
|
||
skills: ['staffing-risk', 'create-position'],
|
||
subagents: ['analytics-agent'],
|
||
starters: [{ label: 'Which roles are at risk?', prompt: 'Which roles are at risk?' }],
|
||
knowledge: [{ id: 'policy', label: 'Fill policy', kind: 'note', body: 'A role open 30 days is escalated.', url: '' }],
|
||
permissions: { owner: 'demo@krow.app', access: 'specific', people: [{ user: 'a@krow.app', role: 'editor' }] },
|
||
};
|
||
|
||
const composed = agentFields.applyAgentFields(shippedSource, EDITS);
|
||
const readBack = agentFields.agentFieldsFromSource(composed);
|
||
|
||
const lost = [];
|
||
for (const [key, value] of Object.entries(EDITS)) {
|
||
if (JSON.stringify(readBack[key]) !== JSON.stringify(value)) {
|
||
lost.push(`${key}: wrote ${JSON.stringify(value)}, read ${JSON.stringify(readBack[key])}`);
|
||
}
|
||
}
|
||
record('every configurable field survives being written and read back',
|
||
lost.length === 0, lost.slice(0, 2).join(' | ') || `${Object.keys(EDITS).length} fields`);
|
||
|
||
record('the composed definition is valid',
|
||
agentReg.validateAgentSource(composed) === null,
|
||
agentReg.validateAgentSource(composed) || 'ok');
|
||
|
||
record('configuring an agent never has to touch Markdown',
|
||
/^---/.test(composed) && agentReg.parseAgent(composed, { custom: true }).name === EDITS.name,
|
||
'fields in, frontmatter out, parsed by the one parser');
|
||
|
||
/* Editing one field leaves the rest of the file alone, including its prose. */
|
||
const oneField = agentFields.applyAgentFields(shippedSource, { description: 'Just this.' });
|
||
const before8 = agentReg.parseAgent(shippedSource, { custom: true });
|
||
const after8 = agentReg.parseAgent(oneField, { custom: true });
|
||
record('editing one field leaves the others untouched',
|
||
after8.description === 'Just this.'
|
||
&& JSON.stringify(after8.skills) === JSON.stringify(before8.skills)
|
||
&& after8.instructions === before8.instructions,
|
||
`${after8.skills.length} skills and the instructions kept`);
|
||
|
||
/* ── Lifecycle ──────────────────────────────────────────────────────────── */
|
||
|
||
record('a duplicate is always a draft at v1',
|
||
(() => {
|
||
const copy = agentReg.parseAgent(
|
||
lifecycle.duplicateAgent(shippedSource, { existingIds: ALL_AGENTS.map((a) => a.id) }),
|
||
{ custom: true }
|
||
);
|
||
return copy.status === 'draft' && copy.version === 1 && copy.id !== 'positions-agent';
|
||
})(),
|
||
'a copy of a published agent must not enter the switcher unreviewed');
|
||
|
||
record('a duplicate keeps what the original carried',
|
||
(() => {
|
||
const copy = agentReg.parseAgent(
|
||
lifecycle.duplicateAgent(shippedSource, { existingIds: [] }), { custom: true }
|
||
);
|
||
return JSON.stringify(copy.skills) === JSON.stringify(before8.skills)
|
||
&& copy.instructions === before8.instructions;
|
||
})());
|
||
|
||
record('a duplicate never collides with an existing id',
|
||
(() => {
|
||
const taken = ALL_AGENTS.map((a) => a.id);
|
||
const first = agentReg.parseAgent(lifecycle.duplicateAgent(shippedSource, { existingIds: taken }), { custom: true });
|
||
const second = agentReg.parseAgent(
|
||
lifecycle.duplicateAgent(shippedSource, { existingIds: [...taken, first.id] }), { custom: true }
|
||
);
|
||
return first.id !== second.id;
|
||
})());
|
||
|
||
record('archiving takes an agent out of service without altering it',
|
||
(() => {
|
||
const archived = agentReg.parseAgent(lifecycle.archiveAgent(shippedSource), { custom: true });
|
||
return archived.status === 'archived'
|
||
&& JSON.stringify(archived.skills) === JSON.stringify(before8.skills);
|
||
})());
|
||
|
||
record('restoring brings it back as a draft, not straight back into service',
|
||
agentReg.parseAgent(lifecycle.restoreAgent(lifecycle.archiveAgent(shippedSource)), { custom: true })
|
||
.status === 'draft');
|
||
|
||
/**
|
||
* Publishing must never discard a version somebody else published.
|
||
*
|
||
* The failure it prevents is silent: a draft taken from v1 published over a v2
|
||
* looks exactly like the v2 change never having been made.
|
||
*/
|
||
record('publishing a draft moves it into service',
|
||
(() => {
|
||
const draft = lifecycle.restoreAgent(shippedSource);
|
||
const out = lifecycle.publishAgent(draft);
|
||
return !out.conflict && agentReg.parseAgent(out.source, { custom: true }).status === 'published';
|
||
})());
|
||
|
||
record('republishing a published agent moves its version on',
|
||
(() => {
|
||
const out = lifecycle.publishAgent(shippedSource);
|
||
return agentReg.parseAgent(out.source, { custom: true }).version === before8.version + 1;
|
||
})(),
|
||
'so "what is live" is always a specific version');
|
||
|
||
record('publishing over a newer version is refused, not silently applied',
|
||
(() => {
|
||
const out = lifecycle.publishAgent(lifecycle.restoreAgent(shippedSource), { publishedVersion: 5 });
|
||
return Boolean(out.conflict) && !out.source;
|
||
})(),
|
||
'a conflict the screen can explain, rather than a lost change');
|
||
|
||
/* ── An agent created from nothing ──────────────────────────────────────── */
|
||
|
||
/**
|
||
* The path a non-technical author actually takes: a blank template, filled in
|
||
* through the form, saved. It has to produce a definition the runtime accepts.
|
||
*/
|
||
const fresh = agentFields.applyAgentFields(customAgents.agentTemplate(), {
|
||
id: 'hr-helper',
|
||
name: 'HR Helper',
|
||
description: 'Answers hiring questions for the HR team.',
|
||
trigger: 'Use on Candidates for pipeline questions.',
|
||
instructions: 'Answer from candidate records on this page.',
|
||
icon: 'users',
|
||
reasoning: 'balanced',
|
||
pages: ['candidates'],
|
||
skills: ['candidate-analysis'],
|
||
starters: [{ label: 'How strong is the pool?', prompt: 'How strong is the pool?' }],
|
||
});
|
||
|
||
record('an agent created entirely through the form is valid',
|
||
agentReg.validateAgentSource(fresh) === null,
|
||
agentReg.validateAgentSource(fresh) || 'ok');
|
||
|
||
const freshAgent = agentReg.parseAgent(fresh, { custom: true });
|
||
record('...and starts as a draft rather than live',
|
||
freshAgent.status === 'draft', freshAgent.status);
|
||
|
||
record('...and registers alongside the shipped ones',
|
||
(() => {
|
||
const { agents, diagnostics } = agentReg.readAgentRegistry([{ path: 'custom/hr-helper.md', raw: fresh }]);
|
||
return agents.some((a) => a.id === 'hr-helper') && diagnostics.length === 0;
|
||
})(),
|
||
'no diagnostics, so nothing it declared was dropped');
|
||
|
||
record('...and is bounded by the page exactly like a shipped agent',
|
||
(() => {
|
||
const disabled = runtime.agentScopedDisabledWith(freshAgent, ALL_AGENTS, ALL_SKILLS, []);
|
||
const pageOnly = reg.skillsForContext('admin.candidatesList', [], []).map((s) => s.id);
|
||
const scoped = reg.skillsForContext('admin.candidatesList', disabled, []).map((s) => s.id);
|
||
/* And it reaches nothing at all on a page it does not cover. */
|
||
const elsewhere = reg.skillsForContext('admin.analytics', disabled, []).map((s) => s.id);
|
||
return scoped.every((x) => pageOnly.includes(x)) && elsewhere.length === 0;
|
||
})(),
|
||
'a user-created agent gets the same boundary, not a weaker one');
|
||
|
||
/* ── Editing a shipped agent overrides rather than mutates ───────────────── */
|
||
|
||
record('editing a shipped agent is reported as an override',
|
||
(() => {
|
||
const { diagnostics } = agentReg.readAgentRegistry([{ path: 'custom/positions-agent.md', raw: composed }]);
|
||
return diagnostics.some((d) => d.kind === 'shadowed' && d.agentId === 'positions-agent');
|
||
})(),
|
||
'the shipped definition is never altered on disk');
|
||
|
||
record('the shipped definition is still intact after an override',
|
||
agentReg.AGENTS.find((a) => a.id === 'positions-agent').name === 'Positions Agent');
|
||
|
||
/* ── The Add Skills modal reads the one registry ────────────────────────── */
|
||
|
||
/**
|
||
* Asserted against the registry rather than the component, because the failure
|
||
* worth preventing is architectural: a separate list for agents would drift
|
||
* from the one Owliver runs, and an agent would offer a skill the runtime does
|
||
* not have.
|
||
*/
|
||
const attachable = reg.skillsWithFacet(reg.allSkills([]), 'owliver').filter((s) => s.status === 'active');
|
||
record('every attachable skill comes from the shared registry',
|
||
attachable.every((s) => reg.SKILLS.some((r) => r.id === s.id)),
|
||
`${attachable.length} attachable`);
|
||
|
||
record('workforce training paths are not offered as agent skills',
|
||
attachable.every((s) => s.kind !== 'workforce'),
|
||
'a training path is something a person learns, not something an agent does');
|
||
|
||
record('every category offered by the picker matches at least one skill',
|
||
(() => {
|
||
const categories = [...new Set(attachable.map((s) => s.category).filter(Boolean))];
|
||
return categories.every((c) => attachable.some((s) => s.category === c));
|
||
})(),
|
||
[...new Set(attachable.map((s) => s.category).filter(Boolean))].join(', '));
|
||
|
||
/* ── Every management destination exists ────────────────────────────────── */
|
||
|
||
const managementRoutes = ['/admin/workspace/agents', '/admin/workspace/agents/new'];
|
||
const appRoutes = new Set(
|
||
[...readFileSync(join(ROOT, 'src/App.jsx'), 'utf8').matchAll(/<Route\s+path="([^"]+)"/g)]
|
||
.map((m) => m[1])
|
||
.map((path) => (path.startsWith('/') ? path : `/admin/${path}`))
|
||
);
|
||
record('every agent management route is registered',
|
||
managementRoutes.every((r) => appRoutes.has(r)),
|
||
managementRoutes.filter((r) => !appRoutes.has(r)).join(', ') || managementRoutes.join(', '));
|
||
|
||
record('the dynamic agent route is registered after the static one',
|
||
(() => {
|
||
const source = readFileSync(join(ROOT, 'src/App.jsx'), 'utf8');
|
||
return source.indexOf('workspace/agents/new') < source.indexOf('workspace/agents/:id');
|
||
})(),
|
||
'so `agents/new` cannot be read as an agent whose id is "new"');
|
||
|
||
/* ── 20. Owliver on the agent configuration screen ────────────────────────
|
||
*
|
||
* Configure is a workspace page, not an operational one, and it is now a host
|
||
* for the *existing* Owliver rather than a second chat. Two things have to hold
|
||
* and they pull in opposite directions:
|
||
*
|
||
* - Owliver must actually mount there, at both addresses.
|
||
* - Standing there must not become standing on an operational page — most
|
||
* sharply when the agent being *edited* is an operational agent.
|
||
*
|
||
* The second is the one worth testing hardest: configuring the Analytics Agent
|
||
* must not put a reader on Analytics.
|
||
*/
|
||
console.log('\n── Owliver on Agent Configure ──');
|
||
|
||
const CONFIGURE_CONTEXT = 'admin.agentConfigure';
|
||
|
||
/* ── It mounts, at both addresses ────────────────────────────────────────── */
|
||
|
||
for (const route of ['/admin/workspace/agents/new', '/admin/workspace/agents/analytics-agent']) {
|
||
const resolved = placement.resolveAssistantContext('admin', route);
|
||
record(`Owliver mounts on \`${route}\``,
|
||
resolved?.id === CONFIGURE_CONTEXT, resolved?.id || 'no panel');
|
||
}
|
||
|
||
record('the configure context is the one Owliver already uses, not a new panel',
|
||
Boolean(contexts.ASSISTANT_CONTEXTS[CONFIGURE_CONTEXT]?.respond)
|
||
&& Boolean(contexts.ASSISTANT_CONTEXTS[CONFIGURE_CONTEXT]?.capabilities?.length),
|
||
'a context in the existing table, resolved by the existing placement');
|
||
|
||
/**
|
||
* The rest of the workspace hosts Owliver too — under its *own* context.
|
||
*
|
||
* These two checks used to assert the opposite: that the agents list and the
|
||
* other workspace routes carried no panel at all. That was right while Agent
|
||
* Configure was the only workspace host, and wrong as a general rule — it left
|
||
* Owliver dead on every configuration surface in the product, on the reasoning
|
||
* that a page with no specialist agent is a page with no assistant. It is not.
|
||
*
|
||
* What still matters, and is what these now assert, is that each one resolves
|
||
* to *its own* context rather than borrowing Agent Configure's: the page a
|
||
* reader is standing on is never something another page's context describes.
|
||
*/
|
||
const WORKSPACE_HOSTS = {
|
||
'/admin/settings': 'admin.settings',
|
||
'/admin/workspace': 'admin.workspace',
|
||
'/admin/workspace/agents': 'admin.workspaceAgents',
|
||
'/admin/workspace/skills': 'admin.workspaceSkills',
|
||
'/admin/workspace/skill-development': 'admin.skillDevelopment',
|
||
'/admin/workspace/skills/new': 'admin.skillConfigure',
|
||
'/admin/workspace/skills/owliver/new': 'admin.skillConfigure',
|
||
'/admin/workspace/skills/my-skill': 'admin.skillConfigure',
|
||
'/admin/workspace/skills/owliver/my-skill': 'admin.skillConfigure',
|
||
};
|
||
const misplaced = Object.entries(WORKSPACE_HOSTS)
|
||
.filter(([route, id]) => placement.resolveAssistantContext('admin', route)?.id !== id)
|
||
.map(([route, id]) => `${route}: expected ${id}, got ${placement.resolveAssistantContext('admin', route)?.id ?? 'no panel'}`);
|
||
record('every workspace surface hosts Owliver under its own context',
|
||
misplaced.length === 0,
|
||
misplaced.join(' | ') || `${Object.keys(WORKSPACE_HOSTS).length} routes`);
|
||
|
||
record('the agents list does not borrow the configure context',
|
||
placement.resolveAssistantContext('admin', '/admin/workspace/agents')?.id === 'admin.workspaceAgents');
|
||
|
||
/**
|
||
* The pattern must not swallow addresses beneath it.
|
||
*
|
||
* One segment after `agents/`, and no deeper. A nested route added later would
|
||
* otherwise silently inherit this panel.
|
||
*/
|
||
record('the dynamic pattern matches one segment only',
|
||
placement.resolveAssistantContext('admin', '/admin/workspace/agents/x/y') === null
|
||
&& placement.resolveAssistantContext('admin', '/admin/workspace/agents/x') !== null);
|
||
|
||
/* ── The eight operational pages are untouched ───────────────────────────── */
|
||
|
||
/**
|
||
* Exact matching still happens first, so none of the eight ever reaches the
|
||
* pattern table. Asserted rather than assumed, because "I added a fallback" is
|
||
* exactly the change that quietly re-routes something.
|
||
*/
|
||
const OPERATIONAL = {
|
||
'/admin': 'admin.controlCenter',
|
||
'/admin/positions': 'admin.positions',
|
||
'/admin/candidates': 'admin.candidatesList',
|
||
'/admin/hired': 'admin.hiredHistory',
|
||
'/admin/talent-pool': 'admin.talentPool',
|
||
'/admin/university': 'admin.forge',
|
||
'/admin/analytics': 'admin.analytics',
|
||
'/admin/activity': 'admin.activity',
|
||
};
|
||
const rerouted = Object.entries(OPERATIONAL)
|
||
.filter(([route, expectedId]) => placement.resolveAssistantContext('admin', route)?.id !== expectedId);
|
||
record('all eight operational pages resolve exactly as before',
|
||
rerouted.length === 0, rerouted.map(([r]) => r).join(', ') || '8 pages unchanged');
|
||
|
||
/* ── The critical boundary ──────────────────────────────────────────────── */
|
||
|
||
/**
|
||
* Editing the Analytics Agent does not put the reader on Analytics.
|
||
*
|
||
* The edited agent is a record being changed, not the page anyone is standing
|
||
* on. Two independent reasons this holds, both asserted: no skill declares the
|
||
* configure page, and the Analytics Agent does not cover it.
|
||
*/
|
||
record('no skill is available on the configure page at all',
|
||
reg.skillsForContext(CONFIGURE_CONTEXT, [], []).length === 0,
|
||
JSON.stringify(reg.skillsForContext(CONFIGURE_CONTEXT, [], []).map((s) => s.id)));
|
||
|
||
const analyticsAgentCfg = agentReg.getAgent(ALL_AGENTS, 'analytics-agent');
|
||
record('the Analytics Agent does not cover the configure page',
|
||
runtime.agentCovers(analyticsAgentCfg, CONFIGURE_CONTEXT) === false);
|
||
|
||
record('editing the Analytics Agent exposes no analytics skill',
|
||
reg.skillsForContext(
|
||
CONFIGURE_CONTEXT,
|
||
runtime.agentScopedDisabledWith(analyticsAgentCfg, ALL_AGENTS, ALL_SKILLS, []),
|
||
[]
|
||
).length === 0,
|
||
'the edited agent is metadata, not the current page');
|
||
|
||
/* The Analytics page keeps everything it had. A subset check, not equality:
|
||
the snapshot predates the analysis skills added since, and the baseline
|
||
section above already governs additions. What matters here is that mounting
|
||
Owliver on a workspace page took nothing away from an operational one. */
|
||
const analyticsNow = reg.skillsForContext('admin.analytics', [], []).map((s) => s.id);
|
||
const analyticsLost = expectedBaseline.contexts['admin.analytics'].skills
|
||
.filter((id) => !analyticsNow.includes(id));
|
||
record('...and the Analytics page kept every skill it had',
|
||
analyticsLost.length === 0,
|
||
analyticsLost.length ? `LOST ${JSON.stringify(analyticsLost)}` : `${analyticsNow.length} skills, none lost`);
|
||
|
||
/* Every agent, on the configure page: none reaches an operational skill. */
|
||
const leakedOnConfigure = ALL_AGENTS.filter((a) =>
|
||
reg.skillsForContext(
|
||
CONFIGURE_CONTEXT, runtime.agentScopedDisabledWith(a, ALL_AGENTS, ALL_SKILLS, []), []
|
||
).length > 0);
|
||
record('no agent reaches an operational skill from the configure page',
|
||
leakedOnConfigure.length === 0,
|
||
leakedOnConfigure.map((a) => a.id).join(', ') || `${ALL_AGENTS.length} agents, none`);
|
||
|
||
/* ── The page answers honestly ──────────────────────────────────────────── */
|
||
|
||
const configureContext = contexts.ASSISTANT_CONTEXTS[CONFIGURE_CONTEXT];
|
||
|
||
record('the configure page has a native agent to answer with',
|
||
runtime.defaultAgentForContext(ALL_AGENTS, CONFIGURE_CONTEXT)?.id === 'krow-workforce-agent',
|
||
runtime.defaultAgentForContext(ALL_AGENTS, CONFIGURE_CONTEXT)?.name || 'none');
|
||
|
||
record('it answers questions about agents and skills',
|
||
['What agents can I configure?', 'Where do skills come from?', 'What does reasoning do?']
|
||
.every((q) => routingModule.resolveIntent({ question: q, contextId: CONFIGURE_CONTEXT }).kind === 'answer'),
|
||
'agent-management questions are in scope');
|
||
|
||
/**
|
||
* A workforce question asked here is declined, not answered.
|
||
*
|
||
* This is the honest half of the boundary: the page has no operational records,
|
||
* so it must say so and point at the page that does — rather than answering
|
||
* from whatever the configuration screen happens to know.
|
||
*/
|
||
const workforceHere = ['How many candidates applied this week?', 'What is our attendance rate?']
|
||
.map((q) => routingModule.resolveIntent({ question: q, contextId: CONFIGURE_CONTEXT }));
|
||
record('a workforce question on the configure page is declined or routed away',
|
||
workforceHere.every((i) => i.kind === 'outOfScope' || i.kind === 'navigate'),
|
||
workforceHere.map((i) => i.kind).join(', '));
|
||
|
||
record('...and never answered from the configure page itself',
|
||
workforceHere.every((i) => i.kind !== 'answer' && !i.skill));
|
||
|
||
/* ── One Owliver, not two ───────────────────────────────────────────────── */
|
||
|
||
/**
|
||
* Asserted structurally: the configure screen must not import or define a chat.
|
||
* The panel it gets is the one the Admin shell already mounts.
|
||
*/
|
||
const detailSource = readFileSync(join(ROOT, 'src/pages/admin/AgentDetail.jsx'), 'utf8');
|
||
const configureSource = readFileSync(join(ROOT, 'src/components/agents/AgentConfigure.jsx'), 'utf8');
|
||
|
||
record('the configure screen defines no chat of its own',
|
||
!/KrowAssistant|AssistantPanel|useConversation|createAssistantProvider/.test(detailSource + configureSource),
|
||
'no second panel, provider or conversation');
|
||
|
||
record('the panel is mounted once, by the shell',
|
||
/* `<AssistantPanel` alone also matches `<AssistantPanelProvider`, which is a
|
||
different component and legitimately present. */
|
||
(readFileSync(join(ROOT, 'src/layouts/AdminLayout.jsx'), 'utf8').match(/<AssistantPanel\b(?!Provider)/g) || []).length === 1);
|
||
|
||
record('the switcher is untouched by this change',
|
||
!/agentConfigure/.test(readFileSync(join(ROOT, 'src/components/ai-assistant/AgentSwitcher.jsx'), 'utf8')),
|
||
'no configure-specific branch in the switcher');
|
||
|
||
record('PageContext is untouched',
|
||
!/agentConfigure/.test(readFileSync(join(ROOT, 'src/components/ai-assistant/PageContext.jsx'), 'utf8')));
|
||
|
||
/* ── The configure surface declares no placements ───────────────────────── */
|
||
|
||
/**
|
||
* Nothing renders skill cards on this screen, so the surface offers no
|
||
* placement. A `ui:` skill that tried to attach is refused at validation rather
|
||
* than validating and then drawing nothing.
|
||
*/
|
||
const configureSurface = surfaces.surfaceFor('workspace-agent-configure');
|
||
record('the configure surface exists and offers no placements',
|
||
Boolean(configureSurface) && configureSurface.placements.length === 0,
|
||
`${configureSurface?.placements.length ?? '—'} placements`);
|
||
|
||
record('no skill declares the configure page',
|
||
reg.SKILLS.every((s) => !s.pages.includes('workspace-agent-configure')),
|
||
'so there is nothing operational to reach here');
|
||
|
||
/* ── The configure workspace ─────────────────────────────────────────────
|
||
*
|
||
* The screen is an authoring surface and the only one in the console that is,
|
||
* so it carries a visual treatment the eight operational pages do not. What
|
||
* follows guards the two things that treatment must not cost.
|
||
*
|
||
* **Every control still exists.** A redesign that quietly drops a field looks
|
||
* exactly like a redesign that kept it — until someone cannot set an icon. So
|
||
* each closed vocabulary is asserted to be *rendered from its own table*, not
|
||
* merely present as a word: the icon picker over `AGENT_ICONS`, the page chips
|
||
* over `SUPPORTED_SKILL_PAGES`, reasoning over `REASONING_MODES`, and the
|
||
* knowledge kinds over `KNOWLEDGE_KINDS`. A hand-written subset would pass a
|
||
* grep and still be wrong the day a value is added.
|
||
*
|
||
* **It stays Krow.** The palette is the tokens, and nothing else.
|
||
*/
|
||
|
||
const canvasSource = readFileSync(join(ROOT, 'src/components/agents/AgentCanvas.jsx'), 'utf8');
|
||
const agentUi = detailSource + configureSource + canvasSource;
|
||
|
||
/* One surface with sections inside it, not four cards side by side. */
|
||
record('the configure screen composes one workspace surface',
|
||
/<Workspace\b/.test(configureSource)
|
||
&& (configureSource.match(/<DocSection\b/g) || []).length === 4
|
||
&& !/<Surface\b/.test(configureSource),
|
||
'Workspace + 4 DocSection, no per-section card');
|
||
|
||
/* The rail is navigation over the document, so every id it offers must exist
|
||
as something the document actually renders. A leaf pointing at nothing is a
|
||
dead link the eye cannot see. */
|
||
const railSections = [...configureSource.matchAll(/\{\s*id:\s*'([a-z-]+)',\s*icon:/g)].map((m) => m[1]);
|
||
const railLeaves = [...configureSource.matchAll(/\{\s*id:\s*'([a-z-]+)',\s*label:/g)].map((m) => m[1]);
|
||
const railIds = [...railSections, ...railLeaves];
|
||
const missingTargets = railIds.filter((id) => !new RegExp(`id="${id}"`).test(configureSource));
|
||
record('every rail entry addresses something the document renders',
|
||
railSections.length === 4 && railLeaves.length === 12 && missingTargets.length === 0,
|
||
missingTargets.length
|
||
? `no target for ${missingTargets.join(', ')}`
|
||
: `${railSections.length} sections, ${railLeaves.length} leaves, all addressable`);
|
||
|
||
/* Each closed vocabulary is rendered from its own table. */
|
||
record('the icon picker offers every icon an agent may name',
|
||
/icons=\{AGENT_ICONS\}/.test(configureSource) && vocab.AGENT_ICONS.length === 10,
|
||
`${vocab.AGENT_ICONS.length} icons, from AGENT_ICONS`);
|
||
|
||
record('the page chips offer every supported page',
|
||
/SUPPORTED_SKILL_PAGES\.map/.test(configureSource),
|
||
`${surfaces.SUPPORTED_SKILL_PAGES.length} pages, from SUPPORTED_SKILL_PAGES`);
|
||
|
||
record('reasoning offers every mode',
|
||
/REASONING_MODES\.map/.test(configureSource),
|
||
vocab.REASONING_MODES.map((m) => m.label).join(' / '));
|
||
|
||
record('knowledge offers every kind',
|
||
/KNOWLEDGE_KINDS\.map/.test(configureSource),
|
||
vocab.KNOWLEDGE_KINDS.join(', '));
|
||
|
||
/* The controls that are not list-driven, one by one. */
|
||
const CONTROLS = {
|
||
name: /value=\{fields\.name\}/,
|
||
description: /value=\{fields\.description\}/,
|
||
'when to use': /value=\{fields\.trigger\}/,
|
||
instructions: /value=\{fields\.instructions\}/,
|
||
'add skills': /setAddingSkills\(true\)/,
|
||
'remove skill': /skills: fields\.skills\.filter/,
|
||
'add knowledge': /knowledge: \[\s*\n?\s*\.\.\.fields\.knowledge/,
|
||
'remove knowledge': /knowledge: fields\.knowledge\.filter/,
|
||
'add starter': /starters: \[\.\.\.fields\.starters/,
|
||
'remove starter': /starters: fields\.starters\.filter/,
|
||
'web search': /onCheckedChange=\{\(webSearch\) => set\(\{ webSearch \}\)\}/,
|
||
'add subagent': /subagents: \[\.\.\.fields\.subagents/,
|
||
'remove subagent': /subagents: fields\.subagents\.filter/,
|
||
};
|
||
const missingControls = Object.entries(CONTROLS)
|
||
.filter(([, re]) => !re.test(configureSource)).map(([k]) => k);
|
||
record('every field the editor had is still editable',
|
||
missingControls.length === 0,
|
||
missingControls.length ? `MISSING ${missingControls.join(', ')}` : `${Object.keys(CONTROLS).length} controls`);
|
||
|
||
/* Lifecycle state stays legible: version, status, and the customized marker. */
|
||
record('the header still states version, status and customization',
|
||
/v\{agent\.version\}/.test(detailSource)
|
||
&& /\{agent\.status\}/.test(detailSource)
|
||
&& /isOverridden\(id\) && <Badge variant="info">Customized/.test(detailSource)
|
||
&& /Publish update/.test(detailSource),
|
||
'v · status · Customized · Publish update');
|
||
|
||
record('save state is shown and Save is offered only when there is something to save',
|
||
/dirty \? 'Unsaved changes' : 'Saved'/.test(detailSource)
|
||
&& /onClick=\{persist\} disabled=\{!dirty\}/.test(detailSource));
|
||
|
||
/**
|
||
* Closed sections stay reachable by the rail, which means they stay mounted —
|
||
* and a mounted control nobody can see must not be in the tab order.
|
||
*/
|
||
record('collapsed content is inert rather than merely hidden',
|
||
/inert=\{open \? undefined : true\}/.test(canvasSource)
|
||
&& !/aria-hidden=\{open/.test(canvasSource),
|
||
'not tabbable while closed');
|
||
|
||
/**
|
||
* Motion is CSS transitions only, so the global `prefers-reduced-motion` rule
|
||
* in index.css governs it. A JavaScript animation would need its own guard and
|
||
* would eventually be written without one.
|
||
*/
|
||
record('the workspace animates in CSS, so reduced motion is inherited',
|
||
!/framer-motion/.test(configureSource + canvasSource)
|
||
&& /transition-\[grid-template-rows\]/.test(canvasSource),
|
||
'no JS animation on this screen');
|
||
|
||
/**
|
||
* The palette is the design system's. Not "no hex anywhere" — the check is
|
||
* that this screen introduced none of its own.
|
||
*/
|
||
const strayColour = [...agentUi.matchAll(/(?:bg|text|border|from|via|to|ring)-(?:\[#[0-9a-fA-F]{3,8}\]|purple|violet|fuchsia|pink|indigo|cyan|teal|lime|orange)-?\d*/g)]
|
||
.map((m) => m[0]);
|
||
record('the configure screen introduces no colour outside the Krow palette',
|
||
strayColour.length === 0,
|
||
strayColour.length ? `STRAY ${[...new Set(strayColour)].join(', ')}` : 'tokens only');
|
||
|
||
/* The redesign is scoped to this screen. Nothing else may import its parts. */
|
||
const canvasImporters = [
|
||
...readdirSync(join(ROOT, 'src/pages/admin')).map((f) => ['src/pages/admin', f]),
|
||
...readdirSync(join(ROOT, 'src/components/agents')).map((f) => ['src/components/agents', f]),
|
||
]
|
||
.filter(([, f]) => /\.jsx?$/.test(f))
|
||
.filter(([, f]) => !/^(AgentConfigure|AgentDetail|AgentCanvas)\./.test(f))
|
||
.filter(([dir, f]) => /agents\/AgentCanvas|from '\.\/AgentCanvas'/
|
||
.test(readFileSync(join(ROOT, dir, f), 'utf8')))
|
||
.map(([dir, f]) => `${dir}/${f}`);
|
||
record('the workspace treatment is used by the configure screen alone',
|
||
canvasImporters.length === 0,
|
||
canvasImporters.join(', ') || 'no other page imports it');
|
||
|
||
/* ── 20b. Native agent pages vs agent-less pages ──────────────────────────
|
||
*
|
||
* The correction this section pins down, in one sentence: **a page with no
|
||
* agent of its own is not a page without Owliver.**
|
||
*
|
||
* Two runtime modes, and only the first existed as a deliberate design:
|
||
*
|
||
* 1. **Native.** The eight operational pages open on the agent written for
|
||
* them, with that page's skills and that page's records. Unchanged, and
|
||
* most of what follows exists to prove it stayed unchanged.
|
||
* 2. **Fallback.** Settings, the workspace surfaces, Agent Configure and
|
||
* Profile have no specialist and need none. They open on the general Krow
|
||
* Workforce Agent, and Owliver works there — chat, chips, history,
|
||
* everything — while still declining any question about records the page
|
||
* does not hold.
|
||
*
|
||
* The failure this replaces was reading "no native agent" as "constrained", and
|
||
* a constrained panel is a dead one: no starters, no answers, a decline to every
|
||
* question. That is the right behaviour for an agent the reader *chose* which
|
||
* does not cover the page, and the wrong behaviour for a page nobody wrote an
|
||
* agent for. Both are asserted below, because the whole correction is the
|
||
* distinction between them.
|
||
*/
|
||
console.log('\n── Native agent pages vs agent-less pages ──');
|
||
|
||
/** Pages with an agent of their own. `NATIVE` above is the same eight. */
|
||
const NATIVE_CONTEXTS = Object.keys(NATIVE);
|
||
|
||
/**
|
||
* Pages with none.
|
||
*
|
||
* Profile is on this list and always was — it had no specialist before any of
|
||
* this and resolved to the general agent, which is exactly the behaviour the
|
||
* six new surfaces now share. Listing it here is what proves the fallback is
|
||
* one rule rather than a special case for the pages added last.
|
||
*/
|
||
const AGENTLESS_CONTEXTS = [
|
||
'admin.settings',
|
||
'admin.workspace',
|
||
'admin.workspaceAgents',
|
||
'admin.workspaceSkills',
|
||
'admin.skillConfigure',
|
||
'admin.skillDevelopment',
|
||
'admin.agentConfigure',
|
||
'admin.profile',
|
||
];
|
||
|
||
const GENERAL = 'krow-workforce-agent';
|
||
|
||
/* ── 1–2. Resolution, both modes ────────────────────────────────────────── */
|
||
|
||
for (const contextId of NATIVE_CONTEXTS) {
|
||
const native = runtime.nativeAgentForContext(ALL_AGENTS, contextId);
|
||
const resolved = runtime.resolveDefaultAgent(ALL_AGENTS, contextId);
|
||
record(`${contextId.replace('admin.', '')}: resolves its own agent`,
|
||
native?.id === NATIVE[contextId] && resolved?.id === NATIVE[contextId],
|
||
resolved?.id || 'none');
|
||
}
|
||
|
||
for (const contextId of AGENTLESS_CONTEXTS) {
|
||
const page = contextId.replace('admin.', '');
|
||
record(`${page}: has no agent of its own, and resolves the general one`,
|
||
runtime.nativeAgentForContext(ALL_AGENTS, contextId) === null
|
||
&& runtime.resolveDefaultAgent(ALL_AGENTS, contextId)?.id === GENERAL,
|
||
runtime.resolveDefaultAgent(ALL_AGENTS, contextId)?.id || 'NONE');
|
||
}
|
||
|
||
record('the general agent covers every agent-less page',
|
||
AGENTLESS_CONTEXTS.every((c) => runtime.agentCovers(agentReg.getAgent(ALL_AGENTS, GENERAL), c)),
|
||
`${AGENTLESS_CONTEXTS.length} pages`);
|
||
|
||
/* Nothing above changed which agent the eight operational pages open on. */
|
||
record('the eight native pages still open on exactly the agents they did',
|
||
Object.entries(NATIVE).every(([c, id]) => runtime.defaultAgentForContext(ALL_AGENTS, c)?.id === id),
|
||
Object.entries(NATIVE).filter(([c, id]) => runtime.defaultAgentForContext(ALL_AGENTS, c)?.id !== id)
|
||
.map(([c]) => c).join(', ') || '8 pages unchanged');
|
||
|
||
/* ── 3–5. An agent-less page does not open constrained ──────────────────── */
|
||
|
||
/**
|
||
* The heart of it.
|
||
*
|
||
* With nothing chosen, the panel resolves an agent that covers the page — so
|
||
* `covers` is true, no constrained document is produced, and the reader gets an
|
||
* assistant rather than an apology.
|
||
*/
|
||
const openedConstrained = [];
|
||
for (const contextId of AGENTLESS_CONTEXTS) {
|
||
const resolved = runtime.resolveDefaultAgent(ALL_AGENTS, contextId);
|
||
const turn = runtime.resolveAgentForTurn(ALL_AGENTS, null, contextId);
|
||
const intent = routingModule.resolveIntent({
|
||
question: 'What can I do here?',
|
||
contextId,
|
||
agent: resolved,
|
||
agentCoversPage: runtime.agentCovers(resolved, contextId),
|
||
agentSuggestion: resolved,
|
||
});
|
||
if (!turn.covers || intent.kind === 'constrained') openedConstrained.push(contextId);
|
||
}
|
||
record('no agent-less page opens in the constrained state',
|
||
openedConstrained.length === 0,
|
||
openedConstrained.join(', ') || `${AGENTLESS_CONTEXTS.length} pages open with a working agent`);
|
||
|
||
record('Settings opens on the general agent, not constrained',
|
||
runtime.resolveDefaultAgent(ALL_AGENTS, 'admin.settings')?.id === GENERAL
|
||
&& runtime.resolveAgentForTurn(ALL_AGENTS, null, 'admin.settings').covers === true);
|
||
|
||
record('Agent Configure opens on the general agent, not constrained',
|
||
runtime.resolveDefaultAgent(ALL_AGENTS, 'admin.agentConfigure')?.id === GENERAL
|
||
&& runtime.resolveAgentForTurn(ALL_AGENTS, null, 'admin.agentConfigure').covers === true);
|
||
|
||
/* ── 6–7. The edited agent is metadata, never the runtime ───────────────── */
|
||
|
||
/**
|
||
* Configuring an agent does not become standing on the page it covers.
|
||
*
|
||
* Section 20 proves the *data* half — no skill is reachable there. This is the
|
||
* *identity* half: whichever agent is being edited, the agent answering on the
|
||
* configure page is the general one, because nothing about the record on the
|
||
* form is an input to agent resolution.
|
||
*/
|
||
for (const edited of ['analytics-agent', 'positions-agent', 'activity-agent']) {
|
||
record(`editing \`${edited}\` does not make it the runtime agent`,
|
||
runtime.resolveDefaultAgent(ALL_AGENTS, 'admin.agentConfigure')?.id === GENERAL
|
||
&& runtime.agentCovers(agentReg.getAgent(ALL_AGENTS, edited), 'admin.agentConfigure') === false,
|
||
'the edited agent is configuration, not the active runtime identity');
|
||
}
|
||
|
||
/* ── 8–13. Stale selection ──────────────────────────────────────────────── */
|
||
|
||
/**
|
||
* A selection made on one page must not decide another.
|
||
*
|
||
* `resolveSelection` is a pure function of (agents, selection, context), so the
|
||
* whole rule is provable here rather than through a React tree. Three outcomes,
|
||
* and every case below is one of them: applied because it covers, applied
|
||
* because this is where it was chosen, or retired.
|
||
*/
|
||
const LEAKS = [
|
||
['positions-agent', 'admin.positions', 'admin.settings'],
|
||
['analytics-agent', 'admin.analytics', 'admin.settings'],
|
||
['activity-agent', 'admin.activity', 'admin.settings'],
|
||
['positions-agent', 'admin.positions', 'admin.agentConfigure'],
|
||
['control-center-agent', 'admin.controlCenter', 'admin.workspaceSkills'],
|
||
['talent-pool-agent', 'admin.talentPool', 'admin.skillDevelopment'],
|
||
];
|
||
const leakedSelections = [];
|
||
for (const [id, chosenOn, arrivingAt] of LEAKS) {
|
||
const applied = runtime.resolveSelection(ALL_AGENTS, { id, contextId: chosenOn }, arrivingAt);
|
||
const answering = runtime.resolveAgentForTurn(ALL_AGENTS, applied.id, arrivingAt);
|
||
if (applied.id !== null || !applied.retire || answering.agent?.id !== GENERAL || !answering.covers) {
|
||
leakedSelections.push(`${id} (${chosenOn}) → ${arrivingAt}: ${answering.agent?.id}`);
|
||
}
|
||
}
|
||
record('a selection made on another page never follows onto an agent-less page',
|
||
leakedSelections.length === 0,
|
||
leakedSelections.join(' | ') || `${LEAKS.length} navigations, every one resolved to the general agent`);
|
||
|
||
record('a retired selection is retired, not merely ignored',
|
||
runtime.resolveSelection(ALL_AGENTS, { id: 'positions-agent', contextId: 'admin.positions' }, 'admin.settings').retire === true,
|
||
'so returning to that page later does not resurrect it');
|
||
|
||
/* Arriving on a page the selection *does* cover keeps it — the choice is only
|
||
dropped where it could not answer. */
|
||
record('a selection that covers the page it arrives on is kept',
|
||
(() => {
|
||
const applied = runtime.resolveSelection(
|
||
ALL_AGENTS, { id: 'analytics-agent', contextId: 'admin.settings' }, 'admin.analytics'
|
||
);
|
||
return applied.id === 'analytics-agent' && applied.covers === true && applied.retire === false;
|
||
})(),
|
||
'Settings → Analytics keeps a deliberate choice');
|
||
|
||
record('Agent Configure → Analytics resolves Analytics normally',
|
||
(() => {
|
||
const carried = runtime.resolveSelection(ALL_AGENTS, { id: null, contextId: 'admin.agentConfigure' }, 'admin.analytics');
|
||
return carried.id === null
|
||
&& runtime.resolveDefaultAgent(ALL_AGENTS, 'admin.analytics')?.id === 'analytics-agent';
|
||
})());
|
||
|
||
record('Settings → Analytics resolves Analytics normally',
|
||
runtime.resolveDefaultAgent(ALL_AGENTS, 'admin.analytics')?.id === 'analytics-agent');
|
||
|
||
/* An account-level default is a choice made nowhere, and is read by the same
|
||
rule: it applies where it covers, and never constrains a page it does not. */
|
||
record('an account default that does not cover a page does not constrain it',
|
||
runtime.resolveSelection(ALL_AGENTS, 'analytics-agent', 'admin.settings').id === null
|
||
&& runtime.resolveSelection(ALL_AGENTS, 'analytics-agent', 'admin.analytics').id === 'analytics-agent');
|
||
|
||
/* A stored id for an agent that no longer exists resolves to nothing rather
|
||
than leaving the panel pointed at a ghost. */
|
||
record('a selection naming an agent that no longer exists is retired',
|
||
runtime.resolveSelection(ALL_AGENTS, { id: 'deleted-agent', contextId: 'admin.settings' }, 'admin.settings').retire === true);
|
||
|
||
/* ── 18. An explicit incompatible choice still constrains ───────────────── */
|
||
|
||
/**
|
||
* The other half, and the reason "stale" had to be defined rather than just
|
||
* cleared: choosing a specialist *here*, on a page it does not cover, is a
|
||
* deliberate act. It is honoured, shown as constrained, and it declines — the
|
||
* honest answer, and the same one every constrained pair gives above.
|
||
*/
|
||
const explicit = runtime.resolveSelection(
|
||
ALL_AGENTS, { id: 'analytics-agent', contextId: 'admin.settings' }, 'admin.settings'
|
||
);
|
||
const explicitTurn = runtime.resolveAgentForTurn(ALL_AGENTS, explicit.id, 'admin.settings');
|
||
record('choosing an incompatible agent on this page is honoured, and constrained',
|
||
explicit.id === 'analytics-agent' && explicit.retire === false
|
||
&& explicitTurn.agent?.id === 'analytics-agent' && explicitTurn.covers === false,
|
||
'the reader chose it here, so it is not swapped out from under them');
|
||
|
||
record('...and it declines rather than answering',
|
||
routingModule.resolveIntent({
|
||
question: 'What can I configure here?',
|
||
contextId: 'admin.settings',
|
||
agent: explicitTurn.agent,
|
||
agentCoversPage: false,
|
||
agentSuggestion: runtime.resolveDefaultAgent(ALL_AGENTS, 'admin.settings'),
|
||
}).kind === 'constrained');
|
||
|
||
record('...and the way out of it is named on a page with no specialist',
|
||
Boolean(runtime.resolveDefaultAgent(ALL_AGENTS, 'admin.settings')),
|
||
'the general agent is what a constrained answer points at');
|
||
|
||
/* ── 14–17. Owliver actually works there ────────────────────────────────── */
|
||
|
||
/* Every one of these pages mounts the panel. A resolution rule is worth nothing
|
||
if there is no panel to resolve for. */
|
||
record('every agent-less context is a real placement with a panel',
|
||
AGENTLESS_CONTEXTS.every((id) => Object.values(placement.PLACEMENT_ROUTES).includes(id)
|
||
|| Object.values(placement.PLACEMENT_PATTERN_ROUTES).includes(id)),
|
||
AGENTLESS_CONTEXTS.filter((id) => !Object.values(placement.PLACEMENT_ROUTES).includes(id)).join(', ') || 'all mounted');
|
||
|
||
/* Each one names itself, so the header reads the page the reader is on. */
|
||
record('every agent-less page states its own name, key and route',
|
||
AGENTLESS_CONTEXTS.every((id) => {
|
||
const context = contexts.ASSISTANT_CONTEXTS[id];
|
||
const pageKey = reg.pageKeyForContext(id);
|
||
return Boolean(context?.page) && Boolean(pageKey) && Boolean(reg.routeForPageKey(pageKey));
|
||
}),
|
||
AGENTLESS_CONTEXTS.map((id) => `${contexts.ASSISTANT_CONTEXTS[id].page}/${reg.pageKeyForContext(id)}`).join(', '));
|
||
|
||
/**
|
||
* The chips are real.
|
||
*
|
||
* Every suggestion offered on these pages is answered by the page it is offered
|
||
* on — a chip is a promise, and one that declines is worse than no chip. The
|
||
* count is asserted too: suggestions disappearing on an agent-less page was one
|
||
* of the reported symptoms.
|
||
*/
|
||
const emptyChips = [];
|
||
const brokenChips = [];
|
||
for (const contextId of AGENTLESS_CONTEXTS) {
|
||
const prompts = dynamic.buildPrompts(contextId, factSheet, null) || [];
|
||
if (!prompts.length) emptyChips.push(contextId);
|
||
for (const prompt of prompts) {
|
||
const intent = routingModule.resolveIntent({ question: prompt.prompt, contextId });
|
||
if (intent.kind !== 'answer') brokenChips.push(`${contextId}: "${prompt.label}" → ${intent.kind}`);
|
||
}
|
||
}
|
||
record('every agent-less page offers suggestions',
|
||
emptyChips.length === 0,
|
||
emptyChips.join(', ') || AGENTLESS_CONTEXTS
|
||
.map((c) => `${c.replace('admin.', '')}:${(dynamic.buildPrompts(c, factSheet, null) || []).length}`).join(' '));
|
||
|
||
record('every suggestion an agent-less page offers is one it can answer',
|
||
brokenChips.length === 0,
|
||
brokenChips.slice(0, 3).join(' | ') || 'every chip resolves to an answer');
|
||
|
||
/* And the landing screen is furnished — a title, something to type into. */
|
||
record('every agent-less page has a landing screen rather than an empty panel',
|
||
AGENTLESS_CONTEXTS.every((contextId) => {
|
||
const intro = dynamic.buildIntro(contextId, factSheet, 'Test');
|
||
return Boolean(intro.title) && Boolean(intro.description) && intro.placeholders.length > 0;
|
||
}));
|
||
|
||
/**
|
||
* No fake skills.
|
||
*
|
||
* The lazy way to make a configuration page answer is to invent a skill for it.
|
||
* That would show up in exactly two places, and both are checked: the page would
|
||
* carry skills, and its chips would resolve to one. Neither is true — the
|
||
* answers come from the page responder, which is the mechanism every context in
|
||
* the table already used.
|
||
*/
|
||
record('no agent-less page carries any skill',
|
||
AGENTLESS_CONTEXTS.every((c) => reg.skillsForContext(c, [], []).length === 0),
|
||
AGENTLESS_CONTEXTS.filter((c) => reg.skillsForContext(c, [], []).length).join(', ') || 'no skills, on any of them');
|
||
|
||
/* A chip may name one of the page's own capabilities — Profile's always have —
|
||
and that is a context responder, not a skill. What must not exist is a skill
|
||
behind any of them. */
|
||
record('no chip on an agent-less page is backed by a skill',
|
||
AGENTLESS_CONTEXTS.every((contextId) =>
|
||
(dynamic.buildPrompts(contextId, factSheet, null) || []).every((p) =>
|
||
!reg.matchSkill(p.prompt, contextId, [], []))),
|
||
'the fallback is architectural, not a skill layer');
|
||
|
||
record('no agent reaches a skill from an agent-less page',
|
||
AGENTLESS_CONTEXTS.every((contextId) => ALL_AGENTS.every((a) =>
|
||
reg.skillsForContext(contextId, runtime.agentScopedDisabledWith(a, ALL_AGENTS, ALL_SKILLS, []), []).length === 0)),
|
||
`${AGENTLESS_CONTEXTS.length} pages × ${ALL_AGENTS.length} agents`);
|
||
|
||
/**
|
||
* Honest about what it cannot answer.
|
||
*
|
||
* An operational question asked on a configuration screen must never be
|
||
* answered from that screen. It may route to the page that holds the records —
|
||
* which is the useful answer — or decline. What it may not do is produce a
|
||
* figure.
|
||
*/
|
||
const OPERATIONAL_QUESTIONS = [
|
||
'What are the open positions today?',
|
||
'What is our attendance rate?',
|
||
'Which candidates need review?',
|
||
'How many people did we hire last month?',
|
||
];
|
||
const fabricated = [];
|
||
for (const contextId of AGENTLESS_CONTEXTS) {
|
||
for (const question of OPERATIONAL_QUESTIONS) {
|
||
const intent = routingModule.resolveIntent({ question, contextId });
|
||
if (intent.kind === 'answer' || intent.skill) fabricated.push(`${contextId}: "${question}" → ${intent.kind}`);
|
||
}
|
||
}
|
||
record('an operational question on an agent-less page is declined or routed, never answered',
|
||
fabricated.length === 0,
|
||
fabricated.slice(0, 3).join(' | ')
|
||
|| `${AGENTLESS_CONTEXTS.length} pages × ${OPERATIONAL_QUESTIONS.length} questions`);
|
||
|
||
/* The decline names what the page *can* do, so it is an offer rather than a
|
||
dead end. */
|
||
record('the decline offers what the page can answer instead',
|
||
AGENTLESS_CONTEXTS.every((contextId) => {
|
||
const intent = routingModule.resolveIntent({ question: 'What is our attendance rate?', contextId });
|
||
if (intent.kind !== 'outOfScope') return true;
|
||
return (intent.doc?.blocks || []).some((b) => b.type === 'list' && (b.items || []).length > 0);
|
||
}));
|
||
|
||
/* ── The switcher still works there ─────────────────────────────────────── */
|
||
|
||
/**
|
||
* An agent-less page is not a page with no switcher.
|
||
*
|
||
* The list is the whole registry, ordered so the agents that work here come
|
||
* first — which on these pages is the general agent, because it is the one that
|
||
* covers them.
|
||
*/
|
||
record('the switcher lists every agent on an agent-less page',
|
||
AGENTLESS_CONTEXTS.every((contextId) =>
|
||
agentReg.searchAgents(ALL_AGENTS, '').length === ALL_AGENTS.length),
|
||
`${ALL_AGENTS.length} agents, on every page`);
|
||
|
||
record('the general agent sorts to the top of the list on an agent-less page',
|
||
AGENTLESS_CONTEXTS.every((contextId) => {
|
||
const ordered = [...agentReg.searchAgents(ALL_AGENTS, '')]
|
||
.sort((a, b) => Number(runtime.agentCovers(b, contextId)) - Number(runtime.agentCovers(a, contextId)));
|
||
return ordered[0]?.id === GENERAL;
|
||
}),
|
||
'the one that can answer here is offered first');
|
||
|
||
record('choosing an agent on an agent-less page still cannot move the page',
|
||
AGENTLESS_CONTEXTS.every((contextId) => ALL_AGENTS.every((candidate) =>
|
||
runtime.resolveAgentForTurn(ALL_AGENTS, candidate.id, contextId).agent?.id === candidate.id)));
|
||
|
||
/* ── 21. Owliver behaviour baseline ───────────────────────────────────────
|
||
*
|
||
* The Agent layer is additive, which is a claim rather than a guarantee: every
|
||
* page's skills, suggestions, prompts and intent routing must survive it
|
||
* unchanged. That is far more than fits in a reviewer's head, and all of it
|
||
* fails silently — a page quietly answering with one skill fewer looks exactly
|
||
* like a page that never had it.
|
||
*
|
||
* So today's behaviour was recorded before any of it was built
|
||
* (`scripts/__baseline__/owliver-baseline.json`) and is compared here, per
|
||
* context and per question. `captureBaseline` is the same function that wrote
|
||
* the file, so there is one definition of what is measured.
|
||
*
|
||
* A failure here is a regression in the existing product, not a stale
|
||
* expectation. The file is regenerated deliberately, never to turn a check
|
||
* green.
|
||
*/
|
||
console.log('\n── Owliver behaviour baseline ──');
|
||
|
||
if (!existsSync(BASELINE_PATH)) {
|
||
record('baseline snapshot exists', false, 'run `node scripts/owliver-baseline.mjs --write`');
|
||
} else {
|
||
const expected = JSON.parse(readFileSync(BASELINE_PATH, 'utf8'));
|
||
const actual = await captureBaseline(server);
|
||
|
||
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||
|
||
record('baseline schema matches', expected.schema === actual.schema,
|
||
`expected ${expected.schema}, got ${actual.schema}`);
|
||
|
||
/**
|
||
* Additive is allowed; losing something is not.
|
||
*
|
||
* The snapshot records the product as it was before the agent layer existed,
|
||
* and later phases legitimately add skills — that is what they are for. So a
|
||
* strict equality here would fail on every intended change and force the
|
||
* snapshot to be regenerated, which is exactly how a baseline stops catching
|
||
* anything.
|
||
*
|
||
* The contract is therefore a *subset*: every skill that existed then must
|
||
* still exist now. A skill disappearing is a regression and still fails.
|
||
* What must not move at all — routes, page keys, suggested prompts and intent
|
||
* routing — is asserted strictly below, and those are the checks that catch a
|
||
* new definition quietly taking over an existing question.
|
||
*/
|
||
const lostSkills = expected.skillIds.filter((id) => !actual.skillIds.includes(id));
|
||
const gainedSkills = actual.skillIds.filter((id) => !expected.skillIds.includes(id));
|
||
record('every skill that existed before the agent layer still registers',
|
||
lostSkills.length === 0,
|
||
lostSkills.length
|
||
? `LOST ${JSON.stringify(lostSkills)}`
|
||
: `${expected.skillIds.length} kept, ${gainedSkills.length} added since`);
|
||
|
||
record('registry still reports no diagnostics', actual.diagnostics.length === 0,
|
||
actual.diagnostics.map((d) => d.message).join(' | ') || 'none');
|
||
|
||
/**
|
||
* Route → context: every address that resolved before must resolve the same
|
||
* way now.
|
||
*
|
||
* Additive, for the same reason page skills are. A later phase can mount
|
||
* Owliver somewhere new — the agent configuration screen is the first — and a
|
||
* strict equality would fail on that intended addition and force the snapshot
|
||
* to be regenerated, which is how a baseline stops catching anything.
|
||
*
|
||
* What must never happen is an *existing* route resolving somewhere else, or
|
||
* ceasing to resolve at all: that is one of the eight operational pages
|
||
* quietly changing which assistant it carries. Both are failures here.
|
||
*/
|
||
const movedRoutes = Object.entries(expected.routes)
|
||
.filter(([route, contextId]) => actual.routes[route] !== contextId)
|
||
.map(([route, contextId]) => `${route}: ${contextId} → ${actual.routes[route] ?? 'nothing'}`);
|
||
const addedRoutes = Object.keys(actual.routes).filter((route) => !(route in expected.routes));
|
||
|
||
record('every route that resolved before resolves the same way',
|
||
movedRoutes.length === 0,
|
||
movedRoutes.join(' | ')
|
||
|| `${Object.keys(expected.routes).length} unchanged${addedRoutes.length ? `, added ${JSON.stringify(addedRoutes)}` : ''}`);
|
||
|
||
/* Per context, so a failure names the page it broke rather than reporting
|
||
that "something" moved. */
|
||
for (const contextId of Object.keys(expected.contexts)) {
|
||
const want = expected.contexts[contextId];
|
||
const got = actual.contexts[contextId];
|
||
const page = contextId.replace(/^admin\./, '');
|
||
|
||
if (!got) {
|
||
record(`${page}: context still exists`, false, 'context missing from the registry');
|
||
continue;
|
||
}
|
||
|
||
record(`${page}: page key and route unchanged`,
|
||
want.pageKey === got.pageKey && want.route === got.route,
|
||
`${got.pageKey} @ ${got.route}`);
|
||
|
||
/* The page boundary itself. A page may gain skills as later phases add
|
||
them; it may never lose one, because that is a capability the page had
|
||
and silently stopped offering. */
|
||
const lost = want.skills.filter((id) => !got.skills.includes(id));
|
||
const gained = got.skills.filter((id) => !want.skills.includes(id));
|
||
record(`${page}: keeps every skill it had`, lost.length === 0,
|
||
lost.length ? `LOST ${JSON.stringify(lost)}`
|
||
: gained.length ? `${want.skills.length} kept, gained ${JSON.stringify(gained)}`
|
||
: `${got.skills.length} skill(s), unchanged`);
|
||
|
||
const lostSuggestions = want.suggestions.filter((label) => !got.suggestions.includes(label));
|
||
record(`${page}: keeps every suggestion it offered`, lostSuggestions.length === 0,
|
||
lostSuggestions.length ? `LOST ${JSON.stringify(lostSuggestions)}`
|
||
: `${want.suggestions.length} kept, ${got.suggestions.length - want.suggestions.length} added`);
|
||
|
||
record(`${page}: suggested prompts unchanged`, same(want.prompts, got.prompts),
|
||
same(want.prompts, got.prompts)
|
||
? `${got.prompts.length} prompt(s)`
|
||
: `expected ${JSON.stringify(want.prompts)}, got ${JSON.stringify(got.prompts)}`);
|
||
|
||
/* Intent routing, question by question — skill matching, the branch taken,
|
||
and the shape of the answer. */
|
||
const drifted = want.intents.filter((w, i) => !same(w, got.intents[i]));
|
||
record(`${page}: intent routing unchanged`, drifted.length === 0,
|
||
drifted.length === 0
|
||
? `${want.intents.length} question(s)`
|
||
: drifted.map((d) => `"${d.question}"`).join(', '));
|
||
}
|
||
}
|
||
|
||
await server.close();
|
||
|
||
/* ── 8. Production bundle ─────────────────────────────────────────────────── */
|
||
if (process.argv.includes('--dist')) {
|
||
console.log('\n── Production bundle ──');
|
||
const dir = join(ROOT, 'dist/assets');
|
||
if (!existsSync(dir)) {
|
||
record('dist/assets exists', false, 'run `npm run build` first');
|
||
} else {
|
||
const bundle = readdirSync(dir)
|
||
.filter((f) => f.endsWith('.js'))
|
||
.map((f) => readFileSync(join(dir, f), 'utf8'))
|
||
.join('\n');
|
||
|
||
for (const s of reg.SKILLS) {
|
||
record(`bundle carries \`${s.id}\``, bundle.includes(s.id));
|
||
}
|
||
/* Body content, not just the id — a manifest naming a skill whose Markdown
|
||
did not survive is the failure this is looking for. */
|
||
record(
|
||
'bundle carries skill body text',
|
||
bundle.includes('Which client is this role for'),
|
||
'create-position conversation step found in bundle'
|
||
);
|
||
}
|
||
}
|
||
|
||
/* ── Summary ──────────────────────────────────────────────────────────────── */
|
||
const failed = results.filter((r) => !r.pass);
|
||
console.log(`\n${results.length - failed.length}/${results.length} checks passed`);
|
||
if (failed.length) {
|
||
console.log('\nFailed:');
|
||
failed.forEach((r) => console.log(` - ${r.name}${r.detail ? ` (${r.detail})` : ''}`));
|
||
process.exit(1);
|
||
}
|