Make a conversation a registry, and add the second one
Some checks failed
CI / check (push) Failing after 4m57s
Some checks failed
CI / check (push) Failing after 4m57s
`positionFlow.js` was five per-domain concerns in one file — a field table, an
`@`-token resolver, a sentence extractor, a commit vocabulary and a set of
outcome renderers — and only the control flow between them was general.
Everything else knew it was creating a job posting. Adding a second
conversation meant a second copy of all of it.
So the control flow is now `conversationFlow.js` and each kind of record is a
REGISTRY. Which conversation a skill runs is the skill's own `flow:` line,
resolved through a map: `routing.js` used to say `if (skill.id ===
'create-position')`, which made a second conversational skill a change to the
router rather than a file on disk — the `if agent_key == ...` shape the
platform rules out one level up. The panel's write callback is likewise a map
keyed by flow id instead of an `onCreatePosition` prop, and the outcome wording
comes off the registry, so nothing in the panel names a kind of record any
more.
`create-employee-role` is the second registry. The worker is asked for and
never assumed: a conversation that names nobody re-asks rather than falling
back to the session, because an operator records this on somebody's behalf. Its
`extract` is deliberately narrower than the posting's — "bartender, weekends,
$30/hr" settles three fields and leaves the subject alone, since guessing WHO a
record is about from a fragment is how a role gets filed against the wrong
person.
THE CONFIRMATION STEP ACCEPTED "create position" AND SILENTLY REJECTED "create
positions" — the plural the Positions page itself uses. An anchored regex missed
it, and the reader got the summary back with no indication of what was wrong
with what they said, which is indistinguishable from the screen not having
updated. Matching is now exact membership against a normalized reply, so a
vocabulary is a list of phrases somebody can read rather than an expression
somebody has to parse.
Two bugs in `extractRole`, both of which fabricated a value nobody typed on the
one field a position cannot be created without:
- The phrase pattern marks "new" as the role by the same grammar that marks
"sous chef", so "create new position" opened the conversation titled "New".
The scaffolding is a PHRASE at least as often as a single word, so a
per-word test still produced "Brand New" and "One More". Scaffolding words
are now stripped to DECIDE whether the phrase named anything, and the
ORIGINAL phrase is returned when it did — strip to test, never to rewrite,
or "second chef" becomes "Chef" and the cure is worse than the bug.
- `(?:a|an)?\s*` has no word boundary, so it matched the leading "a" of
"another" and the capture began mid-word. That mangled scaffolding into
"Nother New" and, worse, corrupted every role introduced with "an":
"create an open kitchen lead position" titled the position "N Open Kitchen
Lead". A real role, typed correctly, silently wrong. Found by mutation
testing the first fix.
`@companies` and `@workers` resolve from data the panel already holds — postings
and profiles the API has already scoped to the caller — so neither widens
anybody's view and neither costs a request. The company list is deliberately
unsorted: `useJobPostings` asks for `-created_date`, so the clients staffed for
most recently come first, and the panel does no ranking of its own. That last
part is a rule the suite enforces structurally, and it is the right rule — a
second opinion formed in the panel outranking the server's is exactly the kind
of thing that decays quietly.
`npm test` now refuses a conversation step whose field its registry does not
define. `stepsOf` drops unknown fields, so a typo means the flow asks fewer
questions than the file lists — and a skill whose steps are ALL unknown asks
none, jumps to the summary, and offers to write an empty record. Nothing errors
and the Markdown still reads correctly. 970 checks, up from 924; the new ones
walk both conversations end to end, because a wrong answer at the confirmation
step re-renders the same summary a right answer does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
This commit is contained in:
@@ -159,6 +159,58 @@ record(
|
||||
`${cp.conversation.length} steps`
|
||||
);
|
||||
|
||||
/* ── Every conversation step resolves in the registry it is bound to ─────── */
|
||||
|
||||
/*
|
||||
* The check that catches the failure nobody else would.
|
||||
*
|
||||
* `stepsOf` filters a skill's steps down to the fields its registry defines.
|
||||
* A typo in a field name — or a step added to the Markdown before the field
|
||||
* exists in code — is therefore DROPPED SILENTLY: the flow asks fewer questions
|
||||
* than the file lists, and a skill whose steps are all unknown asks none at all,
|
||||
* jumps straight to the summary, and offers to write an empty record. Nothing
|
||||
* errors, nothing logs, and the Markdown still reads correctly.
|
||||
*
|
||||
* It is also the failure a second conversation makes easy: `flow: employee-role`
|
||||
* against position field names parses, validates, and produces a flow that asks
|
||||
* nothing.
|
||||
*/
|
||||
const flowsModule = await server.ssrLoadModule('/src/lib/skills/flows/index.js');
|
||||
|
||||
const conversational = reg.SKILLS.filter((s) => (s.conversation || []).length);
|
||||
record(
|
||||
'every conversational skill declares a flow that exists',
|
||||
conversational.every((s) => flowsModule.flowFor(s)),
|
||||
conversational.filter((s) => !flowsModule.flowFor(s))
|
||||
.map((s) => `${s.id} → ${s.flow ?? 'none'}`).join(', ')
|
||||
|| `${conversational.length} skill(s): ${conversational.map((s) => `${s.id}→${s.flow}`).join(', ')}`
|
||||
);
|
||||
|
||||
const unresolvedSteps = conversational.flatMap((skill) => {
|
||||
const registry = flowsModule.flowFor(skill);
|
||||
if (!registry) return [];
|
||||
return (skill.conversation || [])
|
||||
.filter((step) => !registry.fields[step.field])
|
||||
.map((step) => `${skill.id}:${step.field}`);
|
||||
});
|
||||
record(
|
||||
'...and every one of its steps resolves to a field that registry defines',
|
||||
unresolvedSteps.length === 0,
|
||||
unresolvedSteps.join(', ')
|
||||
|| `${conversational.reduce((n, s) => n + s.conversation.length, 0)} steps all resolve`
|
||||
);
|
||||
|
||||
/* A registry's commit vocabulary has to accept the chip it renders, or the
|
||||
summary offers a button that falls through to "I did not catch that". */
|
||||
const unspokenVerbs = Object.values(flowsModule.FLOWS).flatMap((registry) => registry.verbs
|
||||
.filter((verb) => !verb.says.has(verb.chip.toLowerCase()))
|
||||
.map((verb) => `${registry.id}:${verb.chip}`));
|
||||
record(
|
||||
'every commit chip is a phrase its own flow accepts',
|
||||
unspokenVerbs.length === 0,
|
||||
unspokenVerbs.join(', ') || 'all verbs answer their own chips'
|
||||
);
|
||||
|
||||
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`,
|
||||
@@ -2940,6 +2992,221 @@ record('open_related_page resolves only addresses the product has',
|
||||
=== '/admin/positions'
|
||||
&& actions.runAction('open_related_page', { skill: { actions: ['open_related_page'] }, page: 'the-moon' }) === null);
|
||||
|
||||
/* ── Both conversations, end to end ─────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* A whole conversation, driven the way the panel drives it.
|
||||
*
|
||||
* The flow engine is the one place a wrong answer produces no error at all: an
|
||||
* unrecognised reply re-renders the summary, which is exactly what a CORRECT
|
||||
* reply does on the turn before. So the only way to know the confirmation step
|
||||
* works is to walk one to the end and check that a record came out.
|
||||
*/
|
||||
const flowEngine = await server.ssrLoadModule('/src/lib/skills/conversationFlow.js');
|
||||
|
||||
const ROLES = ['Bartender', 'Server', 'Line Cook'];
|
||||
const WORKERS = [
|
||||
{ id: 'wp-1', name: 'Asha Menon', email: 'asha@example.test' },
|
||||
{ id: 'wp-2', name: 'Ravi Kumar', email: 'ravi@example.test' },
|
||||
];
|
||||
const COMPANIES = ['Fairmont San Jose', 'Taj Coromandel'];
|
||||
|
||||
/** Answer a flow's questions in order, and return the last turn. */
|
||||
function walk(registry, skill, opening, answers, ctx) {
|
||||
let turn = flowEngine.beginFlow({ registry, question: opening, skill, ctx });
|
||||
for (const answer of answers) {
|
||||
if (!turn.flow) break;
|
||||
turn = flowEngine.advanceFlow({ registry, flow: turn.flow, answer, skill, ctx });
|
||||
}
|
||||
return turn;
|
||||
}
|
||||
|
||||
const positionSkill = reg.SKILLS.find((s) => s.id === 'create-position');
|
||||
const positionFlow = flowsModule.flowFor(positionSkill);
|
||||
const positionCtx = { roles: ROLES, companies: COMPANIES };
|
||||
|
||||
const posted = walk(positionFlow, positionSkill,
|
||||
'Create a bartender position in Chennai paying $30-$40/hr',
|
||||
['Fairmont San Jose', 'No minimum', 'Skip', 'None', 'Publish Job Posting'],
|
||||
positionCtx);
|
||||
|
||||
record('the position conversation reaches a record',
|
||||
Boolean(posted.create),
|
||||
posted.create ? `status=${posted.create.status}` : `stage=${posted.flow?.stage}, step=${posted.flow?.step}`);
|
||||
|
||||
record('...carrying every answer the conversation collected',
|
||||
posted.create?.draft.company === 'Fairmont San Jose'
|
||||
&& posted.create?.draft.title === 'Bartender'
|
||||
&& posted.create?.draft.location === 'Chennai'
|
||||
&& posted.create?.draft.pay_range_min === '30',
|
||||
JSON.stringify(posted.create?.draft ?? {}));
|
||||
|
||||
record('...and the payload the API receives is unchanged by the split',
|
||||
JSON.stringify(actions.runAction('create_position',
|
||||
{ skill: positionSkill, draft: posted.create.draft, status: posted.create.status })?.data.status) === '"active"'
|
||||
&& actions.runAction('create_position',
|
||||
{ skill: positionSkill, draft: posted.create.draft, status: posted.create.status })?.data.pay_range_min === 30);
|
||||
|
||||
/*
|
||||
* The confirmation bug, pinned.
|
||||
*
|
||||
* "Create position" committed and "create positions" — the plural the Positions
|
||||
* page itself uses — silently did not: the anchored regex missed it, and the
|
||||
* reader got the summary back with no clue what was wrong. All four phrasings
|
||||
* are now the same answer.
|
||||
*/
|
||||
const atReview = walk(positionFlow, positionSkill,
|
||||
'Create a bartender position in Chennai paying $30-$40/hr',
|
||||
['Fairmont San Jose', 'No minimum', 'Skip', 'None'],
|
||||
positionCtx);
|
||||
|
||||
for (const [phrase, shouldCommit] of [
|
||||
['Create position', true], ['create positions', true], ['Create a position', true],
|
||||
['create it', true], ['Publish', true], ['Save as draft', true],
|
||||
['banana', false], ['no', false],
|
||||
]) {
|
||||
const turn = flowEngine.advanceFlow({
|
||||
registry: positionFlow, flow: atReview.flow, answer: phrase, skill: positionSkill, ctx: positionCtx,
|
||||
});
|
||||
record(`confirmation: "${phrase}" ${shouldCommit ? 'commits' : 'does not commit'}`,
|
||||
Boolean(turn.create) === shouldCommit,
|
||||
`create=${Boolean(turn.create)}`);
|
||||
}
|
||||
|
||||
record('...and "no" opens the change menu rather than refusing',
|
||||
flowEngine.advanceFlow({
|
||||
registry: positionFlow, flow: atReview.flow, answer: 'no', skill: positionSkill, ctx: positionCtx,
|
||||
}).flow?.stage === 'change');
|
||||
|
||||
record('...while "Save as draft" commits the draft status, not the published one',
|
||||
flowEngine.advanceFlow({
|
||||
registry: positionFlow, flow: atReview.flow, answer: 'Save as draft', skill: positionSkill, ctx: positionCtx,
|
||||
}).create?.status === 'draft');
|
||||
|
||||
/*
|
||||
* A request that names no role must settle no title.
|
||||
*
|
||||
* "Create new position" used to arrive with the title already set to "New" — a
|
||||
* value nobody typed, on the one field a position cannot be created without.
|
||||
* The scaffolding is a PHRASE at least as often as it is a single word, and the
|
||||
* first fix for this only handled the single word: "create another new position"
|
||||
* still produced "Nother New", the regex having eaten the "a" of "another".
|
||||
*
|
||||
* Both halves are pinned below, because both failed silently — a fabricated
|
||||
* title reads like an answer, so nothing about it looks wrong until somebody
|
||||
* notices the position is called "One More".
|
||||
*/
|
||||
for (const question of [
|
||||
'Create position', 'Create a position', 'create positions',
|
||||
'Create new position', 'create a new position', 'create another new position',
|
||||
'create a brand new position', 'create one more position', 'create the position',
|
||||
'open a new role', 'add a new position', 'post a new job',
|
||||
]) {
|
||||
const draft = flowEngine.beginFlow({
|
||||
registry: positionFlow, question, skill: positionSkill, ctx: positionCtx,
|
||||
}).flow.draft;
|
||||
record(`"${question}" settles no title`, !draft.title, JSON.stringify(draft));
|
||||
}
|
||||
|
||||
/*
|
||||
* ...and the fix must not rename a role that legitimately uses one of those
|
||||
* words. The scaffolding list is stripped to DECIDE whether a phrase named
|
||||
* anything, never to rewrite what it named — otherwise "second chef" becomes
|
||||
* "Chef" and the cure is worse than the bug.
|
||||
*/
|
||||
for (const [question, want] of [
|
||||
['create a bartender position', 'Bartender'],
|
||||
['create a sous chef position', 'Sous Chef'],
|
||||
['create a second chef position', 'Second Chef'],
|
||||
['create an open kitchen lead position', 'Open Kitchen Lead'],
|
||||
['create a fresh produce buyer position', 'Fresh Produce Buyer'],
|
||||
['create an extra hands supervisor position', 'Extra Hands Supervisor'],
|
||||
['position for a third cook', 'Third Cook'],
|
||||
]) {
|
||||
const draft = flowEngine.beginFlow({
|
||||
registry: positionFlow, question, skill: positionSkill, ctx: positionCtx,
|
||||
}).flow.draft;
|
||||
record(`"${question}" keeps its own words`, draft.title === want, draft.title ?? 'null');
|
||||
}
|
||||
|
||||
/* The employee role: the other conversation, over the same engine. */
|
||||
const roleSkill = reg.SKILLS.find((s) => s.id === 'create-employee-role');
|
||||
const roleFlow = flowsModule.flowFor(roleSkill);
|
||||
const roleCtx = { roles: ROLES, workers: WORKERS };
|
||||
|
||||
const recorded = walk(roleFlow, roleSkill, 'Create an employee role',
|
||||
['Asha Menon', 'Bartender', '3 years', 'Skip', 'None', '$25-$35/hr', 'Weekends', 'Skip',
|
||||
'Create employee role'],
|
||||
roleCtx);
|
||||
|
||||
record('the employee-role conversation reaches a record',
|
||||
Boolean(recorded.create),
|
||||
recorded.create ? `status=${recorded.create.status}` : `stage=${recorded.flow?.stage}, step=${recorded.flow?.step}`);
|
||||
|
||||
record('...against the worker that was chosen, resolved to a real profile',
|
||||
recorded.create?.draft.worker_email === 'asha@example.test'
|
||||
&& recorded.create?.draft.worker_profile_id === 'wp-1'
|
||||
&& recorded.create?.draft.role_category === 'Bartender',
|
||||
JSON.stringify(recorded.create?.draft ?? {}));
|
||||
|
||||
/* The subject is never assumed. An operator records this on somebody's behalf,
|
||||
so a conversation that names nobody must not invent one. */
|
||||
record('...and a worker it cannot resolve is re-asked, never guessed',
|
||||
flowEngine.advanceFlow({
|
||||
registry: roleFlow,
|
||||
flow: flowEngine.beginFlow({ registry: roleFlow, question: 'Create an employee role', skill: roleSkill, ctx: roleCtx }).flow,
|
||||
answer: 'somebody', skill: roleSkill, ctx: roleCtx,
|
||||
}).flow.step === 'worker');
|
||||
|
||||
/* An email with no profile behind it is still an answer: a role can be declared
|
||||
before the profile exists. */
|
||||
record('...while a bare email address is accepted without one',
|
||||
flowEngine.advanceFlow({
|
||||
registry: roleFlow,
|
||||
flow: flowEngine.beginFlow({ registry: roleFlow, question: 'Create an employee role', skill: roleSkill, ctx: roleCtx }).flow,
|
||||
answer: 'newcomer@example.test', skill: roleSkill, ctx: roleCtx,
|
||||
}).flow.draft.worker_email === 'newcomer@example.test');
|
||||
|
||||
/* The `@` tokens resolve from data the caller already holds. */
|
||||
const firstChips = (registry, skill, ctx) => flowEngine.beginFlow({
|
||||
registry, question: registry.id === 'position' ? 'Create a position' : 'Create an employee role', skill, ctx,
|
||||
}).followUp.map((c) => c.label);
|
||||
|
||||
record('@companies offers the clients this organization already staffs for',
|
||||
COMPANIES.every((c) => firstChips(positionFlow, positionSkill, positionCtx).includes(c)),
|
||||
firstChips(positionFlow, positionSkill, positionCtx).join(', '));
|
||||
|
||||
record('@workers offers the profiles the panel already holds',
|
||||
WORKERS.every((w) => firstChips(roleFlow, roleSkill, roleCtx).includes(w.name)),
|
||||
firstChips(roleFlow, roleSkill, roleCtx).join(', '));
|
||||
|
||||
/*
|
||||
* The chip the server suggests has to be a phrase a skill answers to.
|
||||
*
|
||||
* No page context declares `capabilities`, so every server suggestion is
|
||||
* dispatched as its own TEXT — which means the catalogue's wording and the
|
||||
* skills' triggers are one coupling with nothing else holding it together. A
|
||||
* renamed chip would produce a suggestion that opens nothing, silently.
|
||||
*/
|
||||
for (const [chip, wantSkill] of [
|
||||
['Create a company position', 'create-position'],
|
||||
['Create an employee role', 'create-employee-role'],
|
||||
]) {
|
||||
const matched = reg.matchSkill(chip, 'admin.positions', [], []);
|
||||
record(`the "${chip}" chip is answered by ${wantSkill}`,
|
||||
matched?.id === wantSkill,
|
||||
matched?.id ?? `no skill on Positions answers "${chip}"`);
|
||||
}
|
||||
|
||||
/* And the wording actually distinguishes them: the two chips must not both
|
||||
route to the same conversation, which is the whole reason "company" and
|
||||
"employee" are in the text the reader clicks. */
|
||||
record('...and the two create chips route to different conversations',
|
||||
reg.matchSkill('Create a company position', 'admin.positions', [], [])?.flow
|
||||
!== reg.matchSkill('Create an employee role', 'admin.positions', [], [])?.flow,
|
||||
`${reg.matchSkill('Create a company position', 'admin.positions', [], [])?.flow} vs `
|
||||
+ `${reg.matchSkill('Create an employee role', 'admin.positions', [], [])?.flow}`);
|
||||
|
||||
/* ── Knowledge ──────────────────────────────────────────────────────────── */
|
||||
|
||||
record('an agent with no knowledge says so rather than returning nothing',
|
||||
|
||||
Reference in New Issue
Block a user