Files
windmill/ai_evals/core/validators.test.ts
centdix 4296a6ae1f feat(ai-chat): cap read_app_file + search_app grep tool to bound context in large raw apps (#9653)
* docs: add global AI chat context-optimization plan for raw apps

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ai-evals): add global raw-app debugging cases on a large fixture

Adds a ~20-file analytics_dashboard raw-app fixture (incl. a 5k-line data module
and a planted wrong-totals bug), two global cases (read-heavy debug + small-edit
baseline), app-seed support in the mock backend, directory-fixture loading, and a
decorateHelpers seam so read-dedupe is measurable. Records tokenUsage for before/
after comparison of the read-tool optimization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ai-chat): cap and dedupe read_app_file to bound context in large apps

read_app_file now defaults to a head slice (1500 lines / 50k chars) with offset/
limit to page further, and skips resending a file whose earlier read is still in
context (per-conversation ledger keyed off the originating tool-call id, so it
self-heals after compaction). Bounds the file-content portion of global-chat
context when working in large raw apps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ai-evals): add read-heavy raw-app debug case (large data module)

global-test31 induces the model to inspect the 5k-line seedData module, exercising
the read_app_file cap/offset path. Baseline ~262k tokens vs ~200k with the cap+dedupe
change (-24%).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: record A+B benchmark results and fixed-overhead finding

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ai-chat): clearer read_app_file past-EOF message + unit tests for cap/dedupe

Addresses local-review nits: out-of-range offset now reports 'offset N is past the
end of the file' instead of a backwards 'lines 11-10' label; adds unit coverage for
the slicing (line cap, offset/limit window, char budget, past-EOF) and re-read dedupe
(hit + miss-when-not-retained).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ai-chat): char-level paging + per-range dedupe for read_app_file

Adds char_offset/char_limit so minified/long-line files can be paged within a line
window, keys the re-read ledger by range (so reading different ranges no longer
collides), and dedupes on the full-file hash (a cached range stub is invalidated
when any byte of the file changes, not just the returned range). Tests updated for
the char-slice behavior plus single-line capping, char paging, and out-of-window
change detection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ai-chat): add read_app_file context micro-benchmark + re-read eval case

Adds a deterministic micro-benchmark (no LLM) that drives read_app_file through a
realistic big-project read pattern (large file, re-read, minified bundle, paging)
and asserts the cap+dedupe cut returned context >50% vs the old whole-file behavior
— isolating the feature's effect from model nondeterminism and guarding against
silent weakening. Adds global-test32, a cross-file consistency investigation that
revisits overlapping files so re-read dedupe is exercised in a real run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ai-evals): clarify test32 measures the read cap, not dedupe

Verified: sonnet and haiku both read each file once per conversation and retain
it, so test32 never triggers read_app_file re-read dedupe. Dedupe is measured
deterministically by the micro-benchmark instead. Comment corrected to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(ai-chat): drop read_app_file re-read dedupe, ship the cap only

Benchmarking showed the per-conversation re-read dedupe never fires in practice:
across sonnet/opus/gpt-5.5/haiku, every model reads each file once per conversation
and keeps it in context (0 within-conversation re-reads). It was a correct but unused
guard, so this removes the ledger, full-file hash, retention predicate, the
AIChatManager wiring, and the eval decorateHelpers seam — keeping the read cap +
offset/limit/char paging (A), which is the lever that actually bounds context. The
micro-benchmark is now cap-only; test32 is kept as a multi-file read-load case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ai-chat): add search_app grep tool for global raw-app chat (experimental)

Client-side grep over a raw app's frontend files and inline runnables (literal,
case-insensitive, optional file_glob/context_lines/max_matches, head-capped).
Completes the list -> search -> ranged-read triad. Includes the eval A/B gate
(WMILL_AI_EVAL_DISABLE_SEARCH_APP), unit tests + micro-benchmark, and a
find-all-usages eval case (global-test33).

Experimental: A/B benchmarking shows it is not an unconditional win — it helps
on find-all-usages but adds agentic iterations on navigable apps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ai-evals): accept search_app as a valid file-inspection tool in raw-app cases

Add requiredToolsAnyOf alternatives-group to ToolValidationSpec and switch
global-test29..32 to it so a model that locates files via search_app instead
of read_app_file no longer false-fails the tool assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: remove stale ai-chat context-optimization planning doc

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(ai-chat): drop read_app_file char paging for a hard char cap

The char_offset/char_limit params guarded minified files (a single line over
the char budget) but were effectively unused in benchmarks. Remove them and the
in-window char paging; keep the hard 50k-char budget and, when a read hits it,
tell the model to narrow the line limit (or treat the file as unreadable if a
single line exceeds the budget). Proper long-line handling is left as a TODO.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(ai-chat): bake search_app context to 1 line, clarify query is literal

Drop the context_lines param (models varied it to little effect) for a fixed
SEARCH_APP_CONTEXT_LINES=1, and cap on matching lines instead of pushed rows so
max_matches stays accurate with context always on. Sharpen the query description
to state it is a literal (non-regex) substring and to suggest the call form
(e.g. formatCurrency() to hit call sites and skip formatCurrencyPrecise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(ai-chat): widen baked search_app context to 2 lines

Models that set the old context_lines param leaned to 2; match the lean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ai-chat): count every file with a match in search_app header

Move fileHadMatch ahead of the render cap so files whose matches fall past max_matches are still counted (with a regression test). Also swap the raw NUL globstar sentinel for a printable escape (the NUL bytes made core.ts read as binary to grep) and reword two comments to describe current constraints instead of drafting history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ai-chat): drop redundant input echoes from app tool results

read_app_file and search_app no longer prefix results with the tool name or echo back the caller's own inputs (file path, query, file_glob) — the model already has them from the call args, and the unbounded query echo could push the search result past its output budget. Keeps the useful signals (line range, match/file counts, truncation) and the actionable advice. Also reword max_matches to 'matching lines' since it caps lines (each expands to context rows). Unit tests updated to the new format.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:33:54 +00:00

987 lines
28 KiB
TypeScript

import { describe, expect, it } from "bun:test";
import {
validateAppState,
validateCliWorkspace,
validateGlobalState,
validateScriptState,
validateToolExpectations,
} from "./validators";
describe("validateScriptState", () => {
it("accepts semantically equivalent script implementations", () => {
const checks = validateScriptState({
actual: {
path: "f/evals/greet_user.ts",
lang: "bun",
code: "export async function main(name: string): Promise<string> {\n return `Hello, ${name}!`;\n}\n",
},
expected: {
path: "f/evals/greet_user.ts",
lang: "bun",
code: "export async function main(name: string) {\n\treturn `Hello, ${name}!`\n}\n",
},
});
expect(checks.every((check) => check.passed)).toBe(true);
});
it("still requires an exported main entrypoint", () => {
const checks = validateScriptState({
actual: {
path: "f/evals/greet_user.ts",
lang: "bun",
code: "async function main(name: string) {\n return `Hello, ${name}!`;\n}\n",
},
});
expect(checks).toContainEqual({
name: "script exports entrypoint",
passed: false,
});
});
});
describe("validateToolExpectations", () => {
it("accepts Windmill-prefixed schedule paths", () => {
const checks = validateToolExpectations({
run: {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["create_schedule"],
toolCallDetails: [
{
name: "create_schedule",
arguments: {
path: "f/evals/greet_user_daily",
},
},
],
skillsInvoked: [],
},
toolExpect: {
requiredToolsUsed: ["create_schedule"],
toolCallArgs: [
{
tool: "create_schedule",
field: "path",
stringStartsWithAnyOf: ["f/", "u/"],
stringMustNotStartWithAnyOf: ["schedules/"],
},
],
},
});
expect(checks.every((check) => check.passed)).toBe(true);
});
it("rejects schedule-prefixed tool paths", () => {
const checks = validateToolExpectations({
run: {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["create_schedule"],
toolCallDetails: [
{
name: "create_schedule",
arguments: {
path: "schedules/greet_user_daily",
},
},
],
skillsInvoked: [],
},
toolExpect: {
requiredToolsUsed: ["create_schedule"],
toolCallArgs: [
{
tool: "create_schedule",
field: "path",
stringStartsWithAnyOf: ["f/", "u/"],
stringMustNotStartWithAnyOf: ["schedules/"],
},
],
},
});
expect(checks).toContainEqual({
name: "create_schedule.path uses an accepted prefix",
passed: false,
details: 'accepted prefixes: f/, u/; values: "schedules/greet_user_daily"',
});
expect(checks).toContainEqual({
name: "create_schedule.path avoids rejected prefixes",
passed: false,
details: 'rejected prefixes: schedules/; values: "schedules/greet_user_daily"',
});
});
it("rejects forbidden tool usage", () => {
const checks = validateToolExpectations({
run: {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["write_script", "deploy_workspace_item"],
skillsInvoked: [],
},
toolExpect: {
forbiddenToolsUsed: ["deploy_workspace_item"],
},
});
expect(checks).toContainEqual({
name: "does not use deploy_workspace_item",
passed: false,
details: "tools used: write_script, deploy_workspace_item",
});
});
it("accepts a stringIncludesAnyOf substring regardless of case or position", () => {
const checks = validateToolExpectations({
run: {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["exec_datatable_sql"],
toolCallDetails: [
{
name: "exec_datatable_sql",
arguments: {
sql: "WITH recent AS (SELECT * FROM orders) SELECT count(*) FROM recent",
},
},
],
skillsInvoked: [],
},
toolExpect: {
requiredToolsUsed: ["exec_datatable_sql"],
toolCallArgs: [
{
tool: "exec_datatable_sql",
field: "sql",
stringIncludesAnyOf: ["select"],
},
],
},
});
expect(checks.every((check) => check.passed)).toBe(true);
});
it("accepts stringIncludesAnyOf when only one of several calls matches", () => {
// Existential: a mutation mixed with verification SELECTs still passes.
const checks = validateToolExpectations({
run: {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 2,
toolsUsed: ["exec_datatable_sql"],
toolCallDetails: [
{
name: "exec_datatable_sql",
arguments: { sql: "UPDATE orders SET status = 'shipped' WHERE id = 2" },
},
{
name: "exec_datatable_sql",
arguments: { sql: "SELECT * FROM orders WHERE id = 2" },
},
],
skillsInvoked: [],
},
toolExpect: {
toolCallArgs: [
{
tool: "exec_datatable_sql",
field: "sql",
stringIncludesAnyOf: ["insert into", "update"],
},
],
},
});
expect(checks.every((check) => check.passed)).toBe(true);
});
it("rejects stringIncludesAnyOf when no call matches any substring", () => {
const checks = validateToolExpectations({
run: {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["exec_datatable_sql"],
toolCallDetails: [
{
name: "exec_datatable_sql",
arguments: {
sql: "DROP TABLE orders",
},
},
],
skillsInvoked: [],
},
toolExpect: {
toolCallArgs: [
{
tool: "exec_datatable_sql",
field: "sql",
stringIncludesAnyOf: ["insert into", "update"],
},
],
},
});
expect(checks).toContainEqual({
name: "exec_datatable_sql.sql includes a required substring",
passed: false,
details:
'accepted substrings: insert into, update; values: "DROP TABLE orders"',
});
});
it("passes requiredToolsAnyOf when any alternative in the group is used", () => {
const checks = validateToolExpectations({
run: {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["search_app", "patch_app_file"],
skillsInvoked: [],
},
toolExpect: {
requiredToolsAnyOf: [["read_app_file", "search_app"]],
},
});
expect(checks).toContainEqual({
name: "uses one of read_app_file, search_app",
passed: true,
});
});
it("fails requiredToolsAnyOf when no alternative in the group is used", () => {
const checks = validateToolExpectations({
run: {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["patch_app_file"],
skillsInvoked: [],
},
toolExpect: {
requiredToolsAnyOf: [["read_app_file", "search_app"]],
},
});
expect(checks).toContainEqual({
name: "uses one of read_app_file, search_app",
passed: false,
details: "tools used: patch_app_file",
});
});
});
describe("validateGlobalState", () => {
it("accepts a required script draft", () => {
const checks = validateGlobalState({
actual: {
drafts: [
{
type: "script",
path: "f/evals/global/greet_user",
language: "bun",
value:
"export async function main(name: string) {\n return `Hello, ${name}!`\n}\n",
isDraft: true,
},
],
},
validate: {
draftCountExactly: 1,
requiredDrafts: [
{
type: "script",
path: "f/evals/global/greet_user",
language: "bun",
valueIncludes: ["Hello"],
},
],
},
});
expect(checks.every((check) => check.passed)).toBe(true);
});
it("fails when a required draft is missing", () => {
const checks = validateGlobalState({
actual: {
drafts: [],
},
validate: {
requiredDrafts: [
{
type: "script",
path: "f/evals/global/greet_user",
},
],
},
});
expect(checks).toContainEqual({
name: "global includes script draft f/evals/global/greet_user",
passed: false,
details: "drafts: none",
});
});
it("accepts a required script draft without an exact path", () => {
const checks = validateGlobalState({
actual: {
drafts: [
{
type: "script",
path: "f/team_tools/friendly_greeting",
language: "bun",
summary: "Friendly greeting helper",
value:
"export async function main(name: string) {\n return `Hello, ${name}!`\n}\n",
isDraft: true,
},
],
},
validate: {
draftCountExactly: 1,
requiredDrafts: [
{
type: "script",
pathIncludes: ["greeting"],
language: "bun",
summaryIncludes: ["Friendly"],
valueIncludes: ["Hello"],
},
],
},
});
expect(checks.every((check) => check.passed)).toBe(true);
});
it("reports flexible global draft path filters when no draft matches", () => {
const checks = validateGlobalState({
actual: {
drafts: [
{
type: "script",
path: "f/team_tools/friendly_greeting",
language: "bun",
value:
"export async function main(name: string) {\n return `Hello, ${name}!`\n}\n",
isDraft: true,
},
],
},
validate: {
requiredDrafts: [
{
type: "script",
pathIncludes: ["invoice"],
},
],
},
});
expect(checks).toContainEqual({
name: "global includes script draft (path includes invoice)",
passed: false,
details: "drafts: script:f/team_tools/friendly_greeting",
});
});
it("does not require a TypeScript entrypoint for non-TypeScript script drafts", () => {
const checks = validateGlobalState({
actual: {
drafts: [
{
type: "script",
path: "f/evals/global/greet_python",
language: "python3",
value: "def main(name: str):\n return f'Hello, {name}!'\n",
isDraft: true,
},
],
},
});
expect(checks.some((check) => check.name.includes("exports entrypoint"))).toBe(
false
);
expect(checks.every((check) => check.passed)).toBe(true);
});
it("allows read-only global cases without draft expectations", () => {
const checks = validateGlobalState({
actual: {
drafts: [],
},
});
expect(
checks.some(
(check) => check.name === "global produced at least one draft"
)
).toBe(false);
expect(checks.every((check) => check.passed)).toBe(true);
});
it("matches expected global draft fixtures", () => {
const checks = validateGlobalState({
actual: {
drafts: [
{
type: "script",
path: "f/evals/global/greet_user",
language: "bun",
value:
"export async function main(name: string) {\r\n return `Hello, ${name}!`\r\n}\r\n",
isDraft: true,
},
],
},
expected: {
drafts: [
{
type: "script",
path: "f/evals/global/greet_user",
language: "bun",
value:
"export async function main(name: string) {\n return `Hello, ${name}!`\n}\n",
isDraft: true,
},
],
},
});
expect(checks).toContainEqual({
name: "global drafts match expected",
passed: true,
});
});
it("fails when expected global draft fixtures differ", () => {
const checks = validateGlobalState({
actual: {
drafts: [
{
type: "script",
path: "f/evals/global/greet_user",
language: "bun",
value:
"export async function main(name: string) {\n return `Hello, ${name}!`\n}\n",
isDraft: true,
},
],
},
expected: {
drafts: [
{
type: "script",
path: "f/evals/global/greet_user",
language: "bun",
value:
"export async function main(name: string) {\n return `Bonjour, ${name}!`\n}\n",
isDraft: true,
},
],
},
});
const expectedMatchCheck = checks.find(
(check) => check.name === "global drafts match expected"
);
expect(expectedMatchCheck?.passed).toBe(false);
expect(expectedMatchCheck?.details).toContain(
"script:f/evals/global/greet_user value differs"
);
expect(expectedMatchCheck?.details).toContain("Hello");
expect(expectedMatchCheck?.details).toContain("Bonjour");
});
it("explains expected global draft metadata mismatches", () => {
const checks = validateGlobalState({
actual: {
drafts: [
{
type: "script",
path: "f/evals/global/greet_user",
language: "bun",
value:
"export async function main(name: string) {\n return `Hello, ${name}!`\n}\n",
isDraft: true,
},
],
},
expected: {
drafts: [
{
type: "script",
path: "f/evals/global/greet_user",
language: "python3",
value:
"export async function main(name: string) {\n return `Hello, ${name}!`\n}\n",
isDraft: true,
},
],
},
});
const expectedMatchCheck = checks.find(
(check) => check.name === "global drafts match expected"
);
expect(expectedMatchCheck?.passed).toBe(false);
expect(expectedMatchCheck?.details).toContain(
"script:f/evals/global/greet_user language differs"
);
expect(expectedMatchCheck?.details).toContain('actual="bun"');
expect(expectedMatchCheck?.details).toContain('expected="python3"');
});
});
describe("validateAppState", () => {
it("accepts app persistence requirements when a datatable table is registered", () => {
const checks = validateAppState({
actual: {
frontend: {
"/index.tsx": "import { backend } from 'wmill'\nexport default function App() { return <div /> }\n",
},
backend: {
listRecipes: {
name: "List recipes",
type: "inline",
inlineScript: {
language: "bun",
content:
"import * as wmill from 'windmill-client'\nexport async function main() { const sql = wmill.datatable(); return await sql`select * from recipes`.fetch() }\n",
},
},
},
datatables: [
{
datatable_name: "main",
schemas: {
public: {
recipes: {},
},
},
},
],
},
validate: {
datatableTableCountAtLeast: 1,
},
});
expect(checks.every((check) => check.passed)).toBe(true);
});
it("fails app persistence requirements when no datatable table exists", () => {
const checks = validateAppState({
actual: {
frontend: {
"/index.tsx": "export default function App() { return <div /> }\n",
},
backend: {},
datatables: [],
},
validate: {
datatableTableCountAtLeast: 1,
},
});
expect(checks).toContainEqual({
name: "app includes at least 1 datatable table",
passed: false,
details: "expected at least 1, got 0",
});
});
it("requires a specific datatable table when requested", () => {
const checks = validateAppState({
actual: {
frontend: {
"/index.tsx": "export default function App() { return <div /> }\n",
},
backend: {},
datatables: [
{
datatable_name: "main",
schemas: {
public: {
recipes: {},
},
},
},
],
},
validate: {
requiredDatatables: [
{
datatableName: "main",
schema: "public",
table: "recipes",
},
],
},
});
expect(checks.every((check) => check.passed)).toBe(true);
});
it("can require an exact datatable table count", () => {
const checks = validateAppState({
actual: {
frontend: {
"/index.tsx": "export default function App() { return <div /> }\n",
},
backend: {},
datatables: [
{
datatable_name: "main",
schemas: {
public: {
notes: {},
extra_notes: {},
},
},
},
],
},
validate: {
datatableTableCountExactly: 1,
},
});
expect(checks).toContainEqual({
name: "app includes exactly 1 datatable table",
passed: false,
details: "expected exactly 1, got 2",
});
});
it("validates app datatable code, tool usage, and forbidden storage", () => {
const checks = validateAppState({
actual: {
frontend: {
"/index.tsx":
"import { backend } from './wmill'\nexport default function App() { void backend.listNotes(); return <div /> }\n",
},
backend: {
listNotes: {
name: "List notes",
type: "inline",
inlineScript: {
language: "bun",
content:
"import * as wmill from 'windmill-client'\nexport async function main() { const sql = wmill.datatable(); return await sql`SELECT * FROM notes`.fetch() }\n",
},
},
},
datatables: [
{
datatable_name: "main",
schemas: {
public: {
notes: {},
},
},
},
],
},
toolsUsed: ["list_datatables", "get_datatable_table_schema"],
validate: {
requiredFrontendFileContent: [
{
path: "/index.tsx",
includes: ["backend.listNotes"],
},
],
requiredBackendRunnableContent: [
{
key: "listNotes",
includes: ["wmill.datatable", "select", "notes"],
},
],
requiredToolsUsed: ["list_datatables", "get_datatable_table_schema"],
forbiddenAppContent: ["localStorage", "sessionStorage"],
},
});
expect(checks.every((check) => check.passed)).toBe(true);
});
it("fails app datatable code validation when required code or tools are missing", () => {
const checks = validateAppState({
actual: {
frontend: {
"/index.tsx": "export default function App() { localStorage.setItem('x', 'y'); return <div /> }\n",
},
backend: {
listNotes: {
name: "List notes",
type: "inline",
inlineScript: {
language: "bun",
content: "export async function main() { return [] }\n",
},
},
},
datatables: [],
},
toolsUsed: ["list_files"],
validate: {
requiredBackendRunnableContent: [
{
key: "listNotes",
includes: ["wmill.datatable", "notes"],
},
],
requiredToolsUsed: ["list_datatables"],
forbiddenAppContent: ["localStorage"],
},
});
expect(checks).toContainEqual({
name: "listNotes backend runnable includes required content",
passed: false,
details: "missing snippets: wmill.datatable, notes",
});
expect(checks).toContainEqual({
name: "tool list_datatables was used",
passed: false,
details: "tools used: list_files",
});
expect(checks).toContainEqual({
name: "app does not include forbidden content 'localStorage'",
passed: false,
details: "forbidden snippet: localStorage",
});
});
it("fails validation when frontend references a missing backend runnable", () => {
const checks = validateAppState({
actual: {
frontend: {
"/index.tsx":
"import { backend } from 'wmill'\nexport default function App() { void backend.deleteRecipe({ id: 1 }); return <div /> }\n",
},
backend: {
listRecipes: {
name: "List recipes",
type: "inline",
inlineScript: {
language: "bun",
content: "export async function main() { return [] }\n",
},
},
},
datatables: [],
},
});
expect(checks).toContainEqual({
name: "frontend backend references resolve",
passed: false,
details: expect.stringContaining("deleteRecipe"),
});
});
});
describe("validateCliWorkspace", () => {
it("accepts required CLI skills and proposed commands without execution", () => {
const checks = validateCliWorkspace({
actualFiles: {
"f/evals/hello.ts": "export async function main(name: string) { return { greeting: `Hello, ${name}!` } }\n",
},
expectedFiles: {
"f/evals/hello.ts": "export async function main(name: string)\nreturn { greeting: `Hello, ${name}!` }",
},
assistantOutput:
"Created the script. Next run `wmill generate-metadata --yes` and then `wmill sync push`.",
trace: {
toolsUsed: [
{ tool: "Skill", input: { skill: "write-script-bun" }, timestamp: 1 },
{ tool: "Write", input: { file_path: "f/evals/hello.ts" }, timestamp: 2 },
],
skillsInvoked: ["write-script-bun"],
assistantMessageCount: 1,
bashCommands: [],
proposedCommands: ["wmill generate-metadata --yes", "wmill sync push"],
executedWmillCommands: [],
wmillInvocations: [],
firstMutationToolIndex: 1,
},
cliExpect: {
requiredSkills: ["write-script-bun"],
requiredSkillsBeforeFirstMutation: ["write-script-bun"],
orderedAssistantMentions: ["wmill generate-metadata", "wmill sync push"],
orderedProposedCommands: ["wmill generate-metadata", "wmill sync push"],
forbiddenExecutedCommands: ["^wmill generate-metadata", "^wmill sync push"],
},
});
expect(checks.every((check) => check.passed)).toBe(true);
});
it("fails when a forbidden wmill command is executed", () => {
const checks = validateCliWorkspace({
actualFiles: {},
assistantOutput: "Run `wmill sync push` when ready.",
trace: {
toolsUsed: [{ tool: "Bash", input: { command: "wmill sync push" }, timestamp: 1 }],
skillsInvoked: [],
assistantMessageCount: 1,
bashCommands: ["wmill sync push"],
proposedCommands: ["wmill sync push"],
executedWmillCommands: ["wmill sync push"],
wmillInvocations: [
{
argv: ["sync", "push"],
cwd: "/tmp/workspace",
timestamp: "2026-04-21T12:00:00+00:00",
},
],
firstMutationToolIndex: 0,
},
cliExpect: {
forbiddenExecutedCommands: ["^wmill sync push"],
},
});
expect(checks).toContainEqual({
name: "does not execute ^wmill sync push",
passed: false,
details: "executed=wmill sync push",
});
});
it("supports read-only guidance cases that must keep the workspace unchanged", () => {
const checks = validateCliWorkspace({
actualFiles: {},
assistantOutput:
"Use `wmill job get 123`, then `wmill job logs 123`, then `wmill job result 123`.",
trace: {
toolsUsed: [{ tool: "Skill", input: { skill: "cli-commands" }, timestamp: 1 }],
skillsInvoked: ["cli-commands"],
assistantMessageCount: 1,
bashCommands: [],
proposedCommands: ["wmill job get 123", "wmill job logs 123", "wmill job result 123"],
executedWmillCommands: [],
wmillInvocations: [],
firstMutationToolIndex: null,
},
cliExpect: {
requiredSkills: ["cli-commands"],
workspaceUnchanged: true,
orderedProposedCommands: [
"wmill job get 123",
"wmill job logs 123",
"wmill job result 123",
],
forbiddenProposedCommands: ["wmill sync push"],
},
});
expect(checks.every((check) => check.passed)).toBe(true);
});
it("matches skills by exact name instead of substring", () => {
const checks = validateCliWorkspace({
actualFiles: {},
assistantOutput: "No workspace changes needed.",
trace: {
toolsUsed: [{ tool: "Skill", input: { skill: "write-flow-helper" }, timestamp: 1 }],
skillsInvoked: ["write-flow-helper"],
assistantMessageCount: 1,
bashCommands: [],
proposedCommands: [],
executedWmillCommands: [],
wmillInvocations: [],
firstMutationToolIndex: null,
},
cliExpect: {
requiredSkills: ["write-flow"],
forbiddenSkills: ["write-flow"],
},
});
expect(checks).toContainEqual({
name: "invokes skill write-flow",
passed: false,
details: "skills=write-flow-helper",
});
expect(checks).toContainEqual({
name: "does not invoke skill write-flow",
passed: true,
});
});
it("accepts ordered proposed commands when they appear in one concatenated entry", () => {
const checks = validateCliWorkspace({
actualFiles: {},
assistantOutput: "Run wmill generate-metadata and then wmill sync push.",
trace: {
toolsUsed: [{ tool: "Skill", input: { skill: "cli-commands" }, timestamp: 1 }],
skillsInvoked: ["cli-commands"],
assistantMessageCount: 1,
bashCommands: [],
proposedCommands: ["wmill generate-metadata and then wmill sync push"],
executedWmillCommands: [],
wmillInvocations: [],
firstMutationToolIndex: null,
},
cliExpect: {
orderedProposedCommands: ["wmill generate-metadata", "wmill sync push"],
},
});
expect(checks).toContainEqual({
name: "assistant proposes expected commands in order",
passed: true,
});
});
it("fails skill-before-mutation checks cleanly when no mutation happened", () => {
const checks = validateCliWorkspace({
actualFiles: {},
assistantOutput: "Run `wmill sync pull` first.",
trace: {
toolsUsed: [{ tool: "Skill", input: { skill: "cli-commands" }, timestamp: 1 }],
skillsInvoked: ["cli-commands"],
assistantMessageCount: 1,
bashCommands: [],
proposedCommands: ["wmill sync pull"],
executedWmillCommands: [],
wmillInvocations: [],
firstMutationToolIndex: null,
},
cliExpect: {
requiredSkillsBeforeFirstMutation: ["cli-commands"],
},
});
expect(checks).toContainEqual({
name: "invokes skill cli-commands before first mutation",
passed: false,
details: "firstSkillIndex=0; firstMutationIndex=none",
});
});
});