Files
windmill/ai_evals/adapters/frontend/runtime.ts
centdix b0ddcf31e4 ci: add path-gated AI agent + ai_evals smoke workflows (#9640)
* ci: add path-gated AI agent integration tests workflow

Runs integration_tests/ai_agent_tests against real LLM providers
(Anthropic/OpenAI/Google) only when AI-agent backend code or the tests
change, since runs make paid LLM calls. Adds a conftest fixture that
skips provider-parametrized cases whose API keys are absent, so CI
exercises only the providers it has secrets for.

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

* ci: add path-gated ai_evals global-mode smoke workflow

Runs the global AI chat eval (global-test1) across one cheap model per
provider (Anthropic/OpenAI/Google/DeepSeek) only when the eval harness or
copilot chat code change, since runs make paid LLM calls. Builds Windmill
CE from source as the AI proxy; global tools/drafts run in the Vitest
bridge. Gates on the deterministic draft pipeline (run succeeded +
produced a draft + used write_script), not the variable LLM judge score.

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

* ci: run AI smokes on PR ready-for-review instead of every push

Switch the pull_request trigger from `synchronize` (every commit) to
`ready_for_review`, with a job guard skipping draft PRs, so the paid LLM
runs only fire when a PR is marked ready to merge (plus push-to-main and
manual dispatch).

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

* fix(ai_evals): lazily load cli mode so non-cli evals skip the cli toolchain

The entrypoint eagerly imported modes/cli, which pulls the wmill CLI
guidance modules and their JSR deps (@cliffy/*). Global/flow/script/app
runs then crashed with "Cannot find module '@cliffy/ansi/colors'" when
the cli workspace deps were not installed. Import createCliModeRunner
dynamically inside runCliBenchmark instead.

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

* test(ai_agent): raise low max_completion_tokens to OpenAI's 16 minimum

OpenAI's /v1/responses rejects max_output_tokens < 16 with a 400, failing
test_low_max_tokens for openai. 16 still exercises a truncated response.

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

* ci: run ai_evals workflow on Node 22 for the frontend undici 8.x dep

The Vitest bridge loads frontend/node_modules/undici@8.x, which requires
Node >=22.19; Node 20 failed with "webidl.util.markAsUncloneable is not a
function" when loading vitest.config.ts.

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

* fix(ai_evals): run frontend evals autonomously + give global-test1 more turns

Frontend evals (flow/script/app/global) ran the production chat prompt, which
assumes an interactive human — so cheaper models burned their turn budget
asking for confirmation, waiting for approval, or presenting a plan, sometimes
hitting maxTurns without producing a draft. Append a shared autonomy note in
baseEvalRunner (the path all frontend modes share, mirroring cli mode): act
directly on clear requests; only ask on genuinely ambiguous ones (preserving
the askUserQuestion cases). Also raise global-test1's maxTurns 8 -> 10 so a
model that over-explores still converges.

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

* ci(ai_evals): watch draft/prompt deps outside copilot/

The global eval runs production frontend code in-process, so the smoke's
behavior depends on files outside frontend/src/lib/components/copilot/**:
the draft model (userDraft.svelte.ts, userDraftDbSyncer.svelte.ts), script
inference (infer.ts), and the chat system prompts ($system_prompts ->
system_prompts/auto-generated). Add them to both push and PR path filters so
a change there actually triggers the smoke that gates on draft production.

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

* fix: skip direct provider tests without credentials

* feat: add ai evals skip judge flag

* fix: simplify ai evals ci gate

* fix: simplify ai evals smoke gate

* fix: handle ai eval workflow triggers

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:41:31 +02:00

229 lines
6.4 KiB
TypeScript

import { spawn } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
formatFrontendBenchmarkProgressEvent,
parseFrontendBenchmarkProgressLine,
} from "./progress";
import type { BenchmarkRunResult } from "../../core/types";
const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
const FRONTEND_DIR = path.join(REPO_ROOT, "frontend");
const FRONTEND_BENCHMARK_TEST =
"../ai_evals/adapters/frontend/vitestAdapter.test.ts";
const FRONTEND_BENCHMARK_CONFIG =
"../ai_evals/adapters/frontend/vitest.config.ts";
export type FrontendMode = "flow" | "app" | "script" | "global";
export async function runFrontendBenchmarkAdapter(input: {
mode: FrontendMode;
caseIds: string[];
runs: number;
model?: string;
verbose?: boolean;
skipJudge?: boolean;
executionOnly?: boolean;
backendValidation?: string;
}): Promise<BenchmarkRunResult> {
const tempDir = await mkdtemp(
path.join(tmpdir(), "wmill-frontend-benchmark-"),
);
const outputPath = path.join(tempDir, "result.json");
const env: NodeJS.ProcessEnv = {
...process.env,
BROWSERSLIST_IGNORE_OLD_DATA: "1",
WMILL_FRONTEND_AI_EVAL_OUTPUT_PATH: outputPath,
WMILL_FRONTEND_AI_EVAL_MODE: input.mode,
WMILL_FRONTEND_AI_EVAL_CASE_IDS: JSON.stringify(input.caseIds),
WMILL_FRONTEND_AI_EVAL_RUNS: String(input.runs),
WMILL_FRONTEND_AI_EVAL_MODEL: input.model ?? "",
WMILL_FRONTEND_AI_EVAL_PROGRESS: "1",
WMILL_FRONTEND_AI_EVAL_VERBOSE: input.verbose ? "1" : "0",
WMILL_FRONTEND_AI_EVAL_SKIP_JUDGE:
input.skipJudge || input.executionOnly ? "1" : "0",
WMILL_FRONTEND_AI_EVAL_EXECUTION_ONLY: input.executionOnly ? "1" : "0",
WMILL_FRONTEND_AI_EVAL_BACKEND_VALIDATION: input.backendValidation ?? "",
};
try {
await runVitestBenchmark(
path.join(FRONTEND_DIR, "node_modules", ".bin", "vitest"),
[
"run",
FRONTEND_BENCHMARK_TEST,
"--project",
"server",
"--config",
FRONTEND_BENCHMARK_CONFIG,
],
{
cwd: FRONTEND_DIR,
env,
},
);
const raw = await readFile(outputPath, "utf8");
return JSON.parse(raw) as BenchmarkRunResult;
} catch (error) {
throw new Error(
`Frontend benchmark adapter failed:\n${toErrorMessage(error)}`,
);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
}
async function runVitestBenchmark(
command: string,
args: string[],
options: {
cwd: string;
env: NodeJS.ProcessEnv;
},
): Promise<void> {
const child = spawn(command, args, {
cwd: options.cwd,
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let stderrLineBuffer = "";
let assistantStreamOpen = false;
child.stdout?.setEncoding("utf8");
child.stdout?.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr?.setEncoding("utf8");
child.stderr?.on("data", (chunk: string) => {
stderrLineBuffer += chunk;
const { remainder, passthrough, nextAssistantStreamOpen } =
drainProgressLines(stderrLineBuffer, assistantStreamOpen);
stderrLineBuffer = remainder;
stderr += passthrough;
assistantStreamOpen = nextAssistantStreamOpen;
});
await new Promise<void>((resolve, reject) => {
child.on("error", reject);
child.on("close", (code) => {
if (stderrLineBuffer.length > 0) {
const { remainder, passthrough, nextAssistantStreamOpen } =
drainProgressLines(`${stderrLineBuffer}\n`, assistantStreamOpen);
stderrLineBuffer = remainder;
stderr += passthrough;
assistantStreamOpen = nextAssistantStreamOpen;
}
if (code === 0) {
if (assistantStreamOpen) {
process.stderr.write("\n");
}
resolve();
return;
}
const details = [`vitest exited with code ${code}`, stdout, stderr]
.filter(Boolean)
.join("\n");
reject(new Error(details));
});
});
}
function drainProgressLines(
buffer: string,
initialAssistantStreamOpen: boolean,
): {
remainder: string;
passthrough: string;
nextAssistantStreamOpen: boolean;
} {
let remainder = buffer;
let passthrough = "";
let assistantStreamOpen = initialAssistantStreamOpen;
while (true) {
const newlineIndex = remainder.indexOf("\n");
if (newlineIndex === -1) {
return {
remainder,
passthrough,
nextAssistantStreamOpen: assistantStreamOpen,
};
}
const line = remainder.slice(0, newlineIndex).replace(/\r$/, "");
remainder = remainder.slice(newlineIndex + 1);
const progressEvent = parseFrontendBenchmarkProgressLine(line);
if (progressEvent) {
if (progressEvent.type === "assistant-message-start") {
if (assistantStreamOpen) {
process.stderr.write("\n");
}
process.stderr.write(
`${formatCasePrefix(progressEvent.caseNumber, progressEvent.totalCases)} ${progressEvent.caseId} attempt ${progressEvent.attempt}/${progressEvent.runs} assistant:\n`,
);
assistantStreamOpen = true;
continue;
}
if (progressEvent.type === "assistant-chunk") {
process.stderr.write(progressEvent.chunk);
continue;
}
if (progressEvent.type === "assistant-message-end") {
if (assistantStreamOpen) {
process.stderr.write("\n");
}
assistantStreamOpen = false;
continue;
}
if (assistantStreamOpen) {
process.stderr.write("\n");
assistantStreamOpen = false;
}
process.stderr.write(
`${formatFrontendBenchmarkProgressEvent(progressEvent)}\n`,
);
continue;
}
if (shouldSuppressFrontendStderrLine(line)) {
continue;
}
passthrough += `${line}\n`;
process.stderr.write(`${line}\n`);
}
}
function formatCasePrefix(caseNumber: number, totalCases: number): string {
return `[${caseNumber}/${totalCases}]`;
}
function shouldSuppressFrontendStderrLine(line: string): boolean {
return (
line.startsWith("[baseline-browser-mapping] ") ||
line.startsWith("Browserslist: browsers data (caniuse-lite) is ") ||
line.includes("update-browserslist-db@latest") ||
line.includes("update-db#readme")
);
}
function toErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}