Skip to content
Open
7 changes: 6 additions & 1 deletion .claude/commands/run-ai-assistant-eval.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,14 @@ evidence, whether each holds. Then score:
number of criteria), rounded to a whole number. A criterion that is half met
(the field exists but is not shown, the edit landed but broke another view)
counts as failed.
- **0**: the verdict is not `pass`, or no card of the asked-for kind exists
- **0**: the verdict is `model-failure`, or no card of the asked-for kind exists
in the test workspace. A pass with the wrong card (a placeholder, an
unrelated card, only the pre-copied card untouched) is also 0.
- **Not scored**: the verdict is `host-failure`, `bot-failure` or
`runner-failure`. The run says nothing about the model, so the result card
leaves it out of the scores (tier `unscored`) whatever score is recorded.
Record `--score 0` with an analysis that names the failure, and judge what
the model did up to that point in the analysis.

Write the analysis as markdown: a checklist of the criteria with ✓ or ✗ and
one line of evidence each; then, for anything that failed or looked rough,
Expand Down
7 changes: 4 additions & 3 deletions .claude/skills/ai-assistant-evals/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ Knobs (env vars):
| `EVAL_HOST_URL` / `EVAL_MATRIX_URL` | `https://localhost:4200` / `http://localhost:8008` | stack endpoints |
| `EVAL_BOT_USER` | `@aibot:localhost` | the ai-bot's matrix id |
| `EVAL_WORKERS` / `EVAL_STAGGER_SECONDS` | 5 / 40 | parallel workers, and the gap between their starts |
| `EVAL_SKILL_FEATURES` | none | comma-separated skill features to switch on in each room before the first prompt (`boxel-debug:feature:enable:<name>`). A skill section between `<!-- feature:<name> -->` markers is left out of the prompt otherwise, so an evaluation of `catalog-reuse` needs `EVAL_SKILL_FEATURES=catalog-reuse` |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] This row says an evaluation of catalog-reuse needs EVAL_SKILL_FEATURES=catalog-reuse. But both new cards enable it with their own skillFeatures, and the runner merges the two lists. Someone who follows this row sets a variable that does nothing. And nothing tells a new evaluation author that the card field exists: the README (where it lists initialCards / initialFiles) and this skill's "Changing the runner" section do not name skillFeatures. Please say that the variable is for a bare pnpm eval:models run, or to add a feature on top of the card's own. Then add skillFeatures next to initialCards / initialFiles in both places.

Regression (docs), non-blocking.


Models run in parallel, `EVAL_WORKERS` at a time (default 5), starting
`EVAL_STAGGER_SECONDS` apart (default 40) so the dev server's first page
Expand Down Expand Up @@ -143,8 +144,8 @@ When one appears:
is also printed at the end of the run.

Columns: result grade, model id used, effort the room carried, verdict, turns,
tool calls by name with counts, run-realm-code writes, cost, wall time, whether a card
rendered and where (`stack` or `preview`), notes with the reasons, the missed
tool calls by name with counts, realm writes (run-realm-code writes plus other
writing tool calls), cost, wall time, whether a card rendered and where (`stack` or `preview`), notes with the reasons, the missed
benchmarks, and the room id.

The grade is the one-word answer per model:
Expand Down Expand Up @@ -199,7 +200,7 @@ After every run, two checks on the money, whatever the grade:
Verdicts:

- `pass` — a card from the new workspace rendered with no error boundary, and at
least one run-realm-code write was applied.
least one applied run-realm-code or other writing tool call.
- `model-failure` — the model did not get there: no write, or no card. Reasons are listed.
- `host-failure` — a tool pill stayed in "applying" past the host's own
two-minute tool timeout, or a tool call never got a result. The model may be
Expand Down
5 changes: 3 additions & 2 deletions packages/ai-assistant-evals/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,9 @@ A run also prints the table at the end.
4. Looks for a rendered card in the stack that is not the workspace index and
has no error state; screenshots the page.
5. Reads the room's Matrix events: turns, tokens, cost, tool calls and their
outcomes, run-realm-code writes, files written.
6. Classifies: `pass` (card rendered), `model` failure (never wrote, wrong
outcomes, run-realm-code writes and other writing tool calls, files written.
6. Classifies: `pass` (card rendered, and at least one applied run-realm-code
or other writing tool call), `model` failure (never wrote, wrong
tool, gave up), or `host` failure (a pill stuck applying, a
tool with no result, the tab left the workspace). Keeps the room id so the
room can be inspected afterwards.
Expand Down
121 changes: 115 additions & 6 deletions packages/ai-assistant-evals/assistant-eval.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,16 @@ const MODELS = (process.env.EVAL_MODELS ?? 'Claude Sonnet 4.6')
.map((m) => m.trim())
.filter(Boolean);

// Skill sections behind a feature flag (`<!-- feature:<name> -->`) are left
// out of the prompt unless the room enabled them. Each name here is enabled
// in the room with `boxel-debug:feature:enable:<name>` before the first
// prompt, in addition to the evaluation's own `skillFeatures`, so a run can
// measure the assistant with a feature on that the evaluation leaves off.
const SKILL_FEATURES = (process.env.EVAL_SKILL_FEATURES ?? '')
.split(',')
.map((f) => f.trim())
.filter(Boolean);

function slugify(s: string) {
return s
.toLowerCase()
Expand Down Expand Up @@ -463,12 +473,16 @@ async function readActivityOnce(page: Page): Promise<Activity> {
}
// A loop: the same tool call, with the same arguments, three times. The
// bot's own checkCorrectness is left out: it repeats with the same
// arguments on every repair round by design.
// arguments on every repair round by design. So is a call rejected as
// invalid: it never ran, its pill shows no arguments, so several of them
// in one turn look identical, and the failed-turn count above already
// covers them.
let toolElements = Array.from(
document.querySelectorAll<HTMLElement>('[data-tool-name]'),
);
let calls = toolElements
.filter((el) => el.dataset.toolName !== 'checkCorrectness')
.filter((el) => !el.querySelector('[data-test-apply-state="invalid"]'))
.map(
(el) =>
`${el.dataset.toolName}|${el.dataset.toolArgumentsLength}|${(
Expand Down Expand Up @@ -529,18 +543,57 @@ async function stopGeneration(page: Page) {
// expensive runs are not stopped; they are graded afterwards.
// `botMessagesBefore` is how many finished bot turns the room already had
// when the prompt went out; a follow-up prompt waits for a new one.
// Some tools open a room of their own and move the assistant panel to it:
// `listing-remix`, for one, starts a "Remixing …" session. The evaluation's
// conversation carries on in its own room, but the panel no longer shows it,
// so everything read from the page would describe the wrong room. When the
// panel is on another room, reopen the evaluation's room from past sessions.
// Returns true when it switched back. It runs on every poll, so reading the
// current room never waits, and a switch that fails (the past-sessions button
// disabled, the host reloading) returns false for the next poll to retry
// rather than failing the run.
async function returnToRoom(page: Page, roomId: string): Promise<boolean> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Efficiency: returnToRoom adds a second page.evaluate round-trip to every 2s poll, just to read [data-test-room]. readActivityOnce already evaluates in the page each poll. Returning the current room id from there and calling returnToRoom only on a mismatch saves one CDP round-trip per poll. It also gets the same lost-context retry handling.


Generated by Claude Code

let current = await page
.evaluate(
() =>
document
.querySelector('[data-test-room]')
?.getAttribute('data-test-room') ?? null,
)
.catch(() => null);
if (!current || current === roomId) {
return false;
}
try {
await page.locator('[data-test-past-sessions-button]').click();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] returnToRoom can block the poll loop and read the wrong room. The clicks rely on the 30s actionTimeout, then two 60s waits, so one failed attempt holds a poll for up to about 150s. During that time the stuck, stalled and deadline checks in waitForIdle don't run. A failed attempt can also leave the past-sessions popover open, so the next poll's click closes it again. If the eval room never shows in the list (for example, many rooms on one shared account from parallel workers), every poll fails silently. readActivity then keeps reading the remix room's DOM, and idle or no-reply is decided on the wrong conversation. Use short explicit timeouts. After N failed attempts, record it as an irregularity or runner failure instead of carrying on.


Generated by Claude Code

await page.locator(`[data-test-enter-room="${roomId}"]`).click();
await page
.locator(`[data-test-room="${roomId}"]`)
.waitFor({ timeout: 60_000 });
await page.locator('[data-test-room-settled]').waitFor({ timeout: 60_000 });
return true;
} catch {
return false;
}
}

async function waitForIdle(
page: Page,
deadline: number,
botMessagesBefore = 0,
onActivity?: (activity: Activity) => void,
roomId?: string,
roomReturns: { count: number } = { count: 0 },
): Promise<{ stoppedBy: RunResult['stoppedBy']; irregularities: string[] }> {
let idleSince: number | undefined;
let applyingSince: number | undefined;
let flatSince: number | undefined;
let lastTextLength = -1;
let startedAt = Date.now();
for (;;) {
if (roomId && (await returnToRoom(page, roomId))) {
roomReturns.count++;
}
let now = Date.now();
let activity = await readActivity(page);
onActivity?.(activity);
Expand Down Expand Up @@ -745,8 +798,11 @@ function classify(
);
return { verdict: 'host-failure', reasons };
}
if (analysis.realmCodeWrites === 0) {
reasons.push('no file was written (no applied run-realm-code write)');
let writes = analysis.realmCodeWrites + analysis.toolWrites;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Altitude: the pass gate now depends on a hand-maintained tool-name allowlist. WRITE_TOOL in room-analysis.ts has 28 hard-coded prefixes, kept in sync by hand with host tools and catalog commands. When a new writing tool is added or one is renamed, a real write reads as "no file was written" and the run becomes a model-failure. The runner already captures the workspace (captureWorkspace) and knows initialCards/initialFiles. Deriving "did the model write anything" from the workspace diff is general and can't drift.


Generated by Claude Code

if (writes === 0) {
reasons.push(
'no file was written (no applied run-realm-code or other writing tool call)',
);
}
if (!cardId) {
reasons.push(...cardReasons);
Expand All @@ -761,7 +817,7 @@ function classify(
if (
cardId &&
cardReasons.length === 0 &&
analysis.realmCodeWrites > 0 &&
writes > 0 &&
promptsSent === promptsTotal
) {
return { verdict: 'pass', reasons };
Expand Down Expand Up @@ -813,6 +869,8 @@ async function runModel(
initialCards: [],
initialFiles: [],
skillsUsed: [],
skillFeatures: [],
roomReturns: 0,
username,
stoppedBy: 'error',
irregularities: [],
Expand Down Expand Up @@ -871,14 +929,46 @@ async function runModel(
await ensureActMode(page, requestedModel);
setStep('confirm the tab is inside the new workspace');
await ensureInsideWorkspace(page, result.realmUrl, result.initialCards);
// The evaluation's own features, plus any EVAL_SKILL_FEATURES adds.
let skillFeatures = [
...new Set([...(evaluation?.skillFeatures ?? []), ...SKILL_FEATURES]),
Comment on lines +933 to +934

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] The PR describes EVAL_SKILL_FEATURES as the way to do an A/B run. Two gaps prevent a clean A/B over these cards. (a) The variable can only add features. Both cards include catalog-reuse in their own skillFeatures, so to get the feature-off arm someone must edit the card. (b) result.skillFeatures gets to session.json but createResultCard in run-eval.ts does not write it to the EvaluationResultCard. So the result cards and report cards of the two arms look the same in the evaluations workspace. A small fix: an opt-out (for example EVAL_SKILL_FEATURES=-catalog-reuse, or a separate EVAL_SKILL_FEATURES_OFF), plus a skillFeatures note or field on the result card.

Follow-up, not this PR. Non-blocking.

];
for (let [index, feature] of skillFeatures.entries()) {
setStep(`enable skill feature ${feature}`);
await sendPrompt(
page,
result.roomId,
`boxel-debug:feature:enable:${feature}`,
index + 1,
);
// The bot answers the command itself, without the model; its reply
// carries no usage, so it is not counted as a turn. A name the bot
// does not know gets "There is no feature named …" instead, which ends
// the run straight away rather than at the timeout.
let enabled = page.getByText(`${feature} is now enabled`).first();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] The enabled check can match an earlier reply. getByText does a substring match across the whole page. A feature whose name is a suffix of one enabled earlier (for example reuse after catalog-reuse) matches the earlier "catalog-reuse is now enabled" reply, so the loop moves on before the bot has confirmed this feature. Scope the locator to the newest bot message, or use exact/a regex anchored on the code element.


Generated by Claude Code

let unknown = page.getByText('There is no feature named').first();
await expect(enabled.or(unknown)).toBeVisible({ timeout: NO_REPLY_MS });
if (await unknown.isVisible()) {
throw new Error(
`The ai-bot has no skill feature named "${feature}"; check the evaluation's skillFeatures and EVAL_SKILL_FEATURES.`,
);
}
}
result.skillFeatures = skillFeatures;
// One safety clock for the whole run, follow-ups included.
let deadline = Date.now() + MAX_MINUTES * 60_000;
let botMessagesBefore = 0;
let roomReturns = { count: 0 };
for (let [index, prompt] of prompts.entries()) {
setStep(
index === 0 ? 'send prompt' : `send follow-up prompt ${index + 1}`,
);
await sendPrompt(page, result.roomId, prompt, index + 1);
await sendPrompt(
page,
result.roomId,
prompt,
skillFeatures.length + index + 1,
);
result.promptsSent = index + 1;
setStep(
index === 0
Expand All @@ -904,6 +994,8 @@ async function runModel(
});
}
},
result.roomId,
roomReturns,
);
result.stoppedBy = waited.stoppedBy;
result.irregularities = waited.irregularities;
Expand All @@ -912,6 +1004,10 @@ async function runModel(
}
botMessagesBefore = (await readActivity(page)).botMessages;
}
if (result.roomId && (await returnToRoom(page, result.roomId))) {
roomReturns.count++;
}
result.roomReturns = roomReturns.count;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] roomReturns is recorded but never surfaced. The table in afterAll, the result card notes in run-eval.ts and judge-result.ts never read it, so the count only shows up in the raw JSON. Add it to the notes, for example returned to the eval room N time(s), or drop the field and the counter object threaded through waitForIdle.


Generated by Claude Code

setStep('check render');

let rendered = await findRenderedCard(
Expand Down Expand Up @@ -941,6 +1037,19 @@ async function runModel(
);
result.verdict = verdict.verdict;
result.reasons = verdict.reasons;
// An enabled feature whose skill the model never opened means the run
// did not exercise it, whatever the verdict says. This assumes each
// feature's skill lives in a skills/<feature name>/SKILL.md directory.
for (let feature of result.skillFeatures) {
let read = result.analysis.filesRead.some((url) =>
url.endsWith(`/skills/${feature}/SKILL.md`),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] The skill-read check assumes skills/<feature>/SKILL.md, which may not exist. Per the ai-bot README, the catalog-reuse markers sit in the skills index ("turns on the mandatory catalog search in the skills index"), not necessarily in a skills/catalog-reuse/ directory. If the feature's text lives elsewhere, this note fires on every run. If the model gets the gated text from the always-loaded index, the note fires even though the feature was exercised. Consider mapping each feature to the file(s) that carry its markers rather than deriving a path from the name.


Generated by Claude Code

);
if (!read) {
result.reasons.push(
`skill feature "${feature}" was enabled but skills/${feature}/SKILL.md was never read`,
);
}
}
} catch (error) {
result.error = error instanceof Error ? error.message : String(error);
result.reasons.push(
Expand Down Expand Up @@ -1110,7 +1219,7 @@ test.afterAll(async () => {
)
.join(', ')
: '';
let writes = a ? `${a.realmCodeWrites}` : '';
let writes = a ? `${a.realmCodeWrites + a.toolWrites}` : '';
return `| ${graded.grade} | ${r.modelId ?? r.requestedModel} | ${r.reasoningEffort ?? '–'} | ${r.verdict} | ${
a?.turns ?? '–'
} | ${tools} | ${writes} | ${a ? `$${a.costUsd.toFixed(3)}` : '–'} | ${
Expand Down
7 changes: 7 additions & 0 deletions packages/ai-assistant-evals/eval-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface Evaluation {
// Absolute URLs, as the realm serializes them.
initialCards: string[];
initialFiles: string[];
// Skill features to enable in the room before the first prompt.
skillFeatures: string[];
adoptsFrom: { module: string; name: string };
}

Expand Down Expand Up @@ -87,6 +89,11 @@ export async function loadEvaluation(
successCriteria: attributes.successCriteria ?? '',
initialCards: linkedIds(doc, 'initialCards', id),
initialFiles: linkedIds(doc, 'initialFiles', id),
skillFeatures: Array.isArray(attributes.skillFeatures)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Feature names are filtered on trim() but kept untrimmed. An entry like " catalog-reuse" passes the filter. The runner then sends boxel-debug:feature:enable: catalog-reuse, which fails the bot's FEATURE_COMMAND_RE ([\\w-]+ right after the colon). The bot replies with only the generic status, so neither the is now enabled locator nor the There is no feature named locator matches. The run waits the full 120s NO_REPLY_MS and ends as a confusing runner-failure. Map to f.trim(), and consider validating against [\\w-]+ here.


Generated by Claude Code

? attributes.skillFeatures.filter(
(f: unknown): f is string => typeof f === 'string' && f.trim() !== '',
)
: [],
adoptsFrom: doc.data.meta.adoptsFrom,
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"data": {
"type": "card",
"attributes": {
"assistantPrompt": "I want to keep track of our wine collection \u2014 each bottle with its producer, vintage and what we paid for it, and a cellar page where we can see them all. Create one example instance and show it on screen.",
"followUpPrompts": [
"We'd like our own version of this now \u2014 something we can restyle and add fields to, that won't shift under us when someone else changes theirs."
],
"successCriteria": "1. The assistant searched the catalog before writing any `.gts`.\n2. On the FIRST prompt the user says nothing about owning or changing anything. The assistant must NOT install or remix a Listing there \u2014 a Listing hands over a copy, and nothing was asked to be owned.\n3. On the first prompt it reaches the catalog's wine definitions by module URL \u2014 `WineCellar` and `WineBottle` under `@cardstack/catalog/4b6602-wine-cellar-card-definition/` \u2014 linking, extending or instantiating them.\n4. It does not author its own cellar or bottle card while those catalog definitions exist.\n5. After the first prompt at least one instance exists, is shown on screen, and renders with no error boundary.\n6. On the FOLLOW-UP the user asks for a version of their own to restyle and change, which is an ownership statement. The assistant now installs or remixes the Wine Cellar Listing rather than continuing to reference it.\n7. It takes that copy through the install or remix tool (`listing-install` / `listing-remix`), never by copying the catalog source by hand.\n8. It does not treat the follow-up as a reason to rebuild anything from scratch \u2014 the copy comes from the catalog.\n9. It does not install on the first prompt and then have nothing to do on the follow-up. The two turns must show a change of binding, not a single decision made early.\n10. The follow-up reply says in one line what the user now holds: their own copy, which will not receive later catalog changes.",
"cardInfo": {
"name": "Reuse: reference first, own on request",
"summary": "The first prompt asks for a wine collection with bottles and a cellar page, which the catalog's Wine Cellar Listing already answers; the second asks for a version of their own to restyle. This eval tests whether the selected models reference the catalog definitions first and install or remix the Listing only once ownership is asked for.",
"notes": "This card exists because reference-and-install look equally successful from the outside: both leave a working cellar on screen. The difference is what the user is left holding \u2014 definitions that stay wired to the catalog, or a copy that will not receive upstream fixes. Criterion 2 is the one under test; criterion 6 guards the over-correction, where the assistant refuses to copy even when asked.\n\nTiming targets do not apply: this is a two-turn build and will report ROUGH on turns and seconds as a matter of course.\n\nThe Wine Cellar Listing (`4b6602-wine-cellar-card-definition`) was chosen because it installs and remixes cleanly: every module it needs has a Spec, so the follow-up is completable. Its shape matches the case under test \u2014 a collection card (`WineCellar.bottles`, linksToMany) over item cards (`WineBottle`) \u2014 and the domain appears in no skill example, keeping eval vocabulary disjoint from the skill's own. Before changing the domain, check that the new Listing remixes into a fresh realm with no unresolved imports.",
"cardThumbnailURL": null
},
"skillFeatures": ["catalog-reuse"]
},
"relationships": {
"cardInfo.theme": {
"links": {
"self": null
}
}
},
"meta": {
"adoptsFrom": {
"module": "../evaluation",
"name": "EvaluationCard"
}
}
}
}
Loading
Loading