Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions apps/cli/ai/eval-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,113 @@ import path from 'node:path';
import { SessionManager } from '@earendil-works/pi-coding-agent';
import { DEFAULT_MODEL, isAiModelId, type AiModelId } from '@studio/common/ai/models';
import { findLastAssistant } from '@studio/common/ai/session-events';
import {
addConnectedWpcomSite,
removeConnectedWpcomSite,
} from '@studio/common/lib/connected-sites';
import { snapshotSchema } from '@studio/common/types/snapshot';
import { syncSiteSchema, type SyncSite } from '@studio/common/types/sync';
import { z } from 'zod';
import {
resolveAiEnvironment,
resolveInitialAiProvider,
resolveUnavailableAiProvider,
} from 'cli/ai/auth';
import { runStudioAgentTurn } from 'cli/ai/runtimes/pi';
import {
lockCliConfig,
readCliConfig,
saveCliConfig,
unlockCliConfig,
} from 'cli/lib/cli-config/core';
import { deleteSnapshotFromConfig } from 'cli/lib/cli-config/snapshots';
import { STUDIO_SITES_ROOT } from 'cli/lib/site-paths';
import type { AgentSessionEvent } from '@earendil-works/pi-coding-agent';
import type { AiProviderId } from 'cli/ai/providers';

// Optional fixtures a test can pre-seed before the agent turn, so flows that
// depend on connected remote sites and/or preview sites can be exercised
// deterministically and offline (no real WordPress.com connection/preview).
const evalSeedSchema = z.object( {
localSite: z
.object( {
id: z.string(),
name: z.string(),
path: z.string(),
port: z.number().default( 8881 ),
url: z.string().optional(),
phpVersion: z.string().default( '8.2' ),
} )
.optional(),
connectedWpcomSites: z.array( syncSiteSchema ).optional(),
snapshots: z.array( snapshotSchema ).optional(),
} );
type EvalSeed = z.infer< typeof evalSeedSchema >;

interface EvalRunnerInput {
prompt: string;
timeoutMs?: number;
model?: AiModelId;
seed?: EvalSeed;
}

/**
* Writes the requested fixtures into cli.json (local site + snapshots) and
* shared.json (connected WordPress.com sites). Returns a cleanup function that
* removes exactly what was added, so reruns start from a clean slate.
*/
async function seedFixtures( seed: EvalSeed ): Promise< () => Promise< void > > {
const { localSite, connectedWpcomSites = [], snapshots = [] } = seed;

if ( localSite || snapshots.length > 0 ) {
try {
await lockCliConfig();
const config = await readCliConfig();
if ( localSite && ! config.sites.some( ( s ) => s.id === localSite.id ) ) {
config.sites.push( {
id: localSite.id,
name: localSite.name,
path: localSite.path,
port: localSite.port,
url: localSite.url ?? `http://localhost:${ localSite.port }`,
phpVersion: localSite.phpVersion,
} );
}
for ( const snapshot of snapshots ) {
config.snapshots.push( snapshot );
}
await saveCliConfig( config );
} finally {
await unlockCliConfig();
}
}

const seededConnections: SyncSite[] = [];
for ( const site of connectedWpcomSites ) {
await addConnectedWpcomSite( site.localSiteId, site );
seededConnections.push( site );
}

return async () => {
for ( const site of seededConnections ) {
await removeConnectedWpcomSite( site.localSiteId, site.id ).catch( () => undefined );
}
for ( const snapshot of snapshots ) {
await deleteSnapshotFromConfig( snapshot.url ).catch( () => undefined );
}
if ( localSite ) {
try {
await lockCliConfig();
const config = await readCliConfig();
config.sites = config.sites.filter( ( s ) => s.id !== localSite.id );
await saveCliConfig( config );
} catch {
// best-effort cleanup
} finally {
await unlockCliConfig();
}
}
};
}

function extractToolCalls( event: AgentSessionEvent ) {
Expand Down Expand Up @@ -119,10 +212,16 @@ function readInput(): EvalRunnerInput {
const rawModel = varModel || envModel;
const model = rawModel && isAiModelId( rawModel ) ? rawModel : undefined;

let seed: EvalSeed | undefined;
if ( vars.seed ) {
seed = evalSeedSchema.parse( vars.seed );
}

return {
prompt: ( vars.prompt as string ) ?? prompt,
timeoutMs: typeof vars.timeoutMs === 'number' ? vars.timeoutMs : undefined,
model,
seed,
};
}

Expand Down Expand Up @@ -173,6 +272,11 @@ async function runEval( input: EvalRunnerInput ) {
let error: string | null = null;
let timedOut = false;

let cleanupSeed: ( () => Promise< void > ) | null = null;
if ( input.seed ) {
cleanupSeed = await seedFixtures( input.seed );
}

phaseStartedAt = Date.now();
const sessionDirEnv = process.env.STUDIO_EVAL_SESSION_DIR?.trim();
let session: SessionManager;
Expand Down Expand Up @@ -271,6 +375,9 @@ async function runEval( input: EvalRunnerInput ) {
error = caught instanceof Error ? caught.message : String( caught );
} finally {
clearTimeout( timeout );
if ( cleanupSeed ) {
await cleanupSeed().catch( () => undefined );
}
}
phaseTimingsMs.total_eval_ms = elapsed();

Expand Down
10 changes: 5 additions & 5 deletions apps/cli/ai/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,18 +141,18 @@ For long CSS or page-content files (>~200 lines), load the \`block-content\` ski
- site_start: Start a stopped site
- site_stop: Stop a running site
- site_delete: Delete a site from Studio and optionally move its files to trash
- preview_create: Create a hosted WordPress.com preview for a local site; this can take a few minutes, so tell the user to wait
- preview_list: List hosted WordPress.com previews for a local site
- preview_update: Update an existing hosted WordPress.com preview from a local site; this can take a few minutes, so tell the user to wait
- preview_delete: Delete a hosted WordPress.com preview by hostname
- preview_create: Create a preview site (a temporary, expiring hosted preview) for a local site; when a local site is selected, preview that site instead of creating a new local site; requires WordPress.com authentication and can take a few minutes, so tell the user to wait
- preview_list: List preview sites (temporary, expiring hosted previews) for a local site. These are NOT connected WordPress.com remote sites.
- preview_update: Update an existing preview site from a local site; this can take a few minutes, so tell the user to wait
- preview_delete: Delete a preview site by hostname
- wp_cli: Run WP-CLI commands on a running site
- scaffold_theme: Scaffold a minimal block theme (style.css, theme.json, functions.php with frontend + editor enqueue, default templates and parts, empty assets/fonts and patterns dirs) into a site and activate it. Use as the first step when starting a new custom theme; the agent fills design-specific content afterwards. Block themes only.
- validate_blocks: Validate block content in two stages and return a combined report. First a static core/html policy check; if it finds invalid core/html blocks it returns only those (rewrite them as editable core or plugin blocks and call again) and skips the editor. Once it passes, validates in the running site's real block editor: with filePath, applies safe editor fixes directly to the file and returns a CSS-review diff; with inline content, returns exact fixed block content plus the diff. Requires a site name or path. Call after every file write/edit that contains block content.
- take_screenshot: Take a full-page screenshot of a URL (supports desktop, mobile, or \`viewport: "all"\` for both). Use this to visually check the site after building it.
- inspect_design: Inspect the rendered DOM and computed styles of a page by CSS selector to root-cause visual issues. Pair with take_screenshot when verifying or polishing a design.
- need_for_speed: Measure frontend performance metrics (TTFB, FCP, LCP, CLS, page weight, DOM size, JS/CSS/image/font asset breakdown) for a running site. Use this to identify performance bottlenecks and guide optimization.
- rank_me_up: Run an on-page SEO audit (title/meta tags, headings, image alt text, OpenGraph/Twitter cards, JSON-LD structured data, robots.txt and sitemap.xml availability) for a running site. Use this to identify on-page SEO issues and guide fixes.
- site_connected_remote_sites: List the WordPress.com sites already attached to a local site. Call this before site_push to decide how to ask the user which remote site to target.
- site_connected_remote_sites: List the durable WordPress.com remote sites (production/staging) already attached to a local site for syncing. These are distinct from temporary preview sites (preview_list). Call this before site_push to decide how to ask the user which remote site to target.
- site_push: Push a local site to a WordPress.com site. Requires authentication (studio auth login). Specify the remote site URL or ID and sync options (all, sqls, uploads, plugins, themes, contents).
- site_pull: Pull a WordPress.com site to a local site. Requires authentication. Specify the remote site URL or ID and sync options.
- site_import: Import a backup file (.zip, .tar.gz, .sql, .wpress) into a local site.
Expand Down
80 changes: 76 additions & 4 deletions apps/cli/ai/tests/tools.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'fs/promises';
import os from 'os';
import path from 'path';
import { getConnectedWpcomSitesForLocalSite } from '@studio/common/lib/connected-sites';
import { SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime';
import { vi } from 'vitest';
import { validateBlocks } from 'cli/ai/block-validator';
Expand All @@ -25,6 +26,7 @@ import {
resolveStudioToolDefinitions,
studioToolDefinitions,
} from '../tools';
import { enrichPreviewListOutput } from '../tools/list-previews';

vi.mock( 'cli/ai/block-validator', () => ( {
validateBlocks: vi.fn(),
Expand Down Expand Up @@ -105,6 +107,10 @@ vi.mock( 'cli/lib/wordpress-server-manager', () => ( {
isServerRunning: vi.fn(),
} ) );

vi.mock( '@studio/common/lib/connected-sites', () => ( {
getConnectedWpcomSitesForLocalSite: vi.fn(),
} ) );

describe( 'Studio AI MCP tools', () => {
const previousScratchpadWidgetType = 'sd-' + 'artefact';
const mockSite = {
Expand Down Expand Up @@ -790,14 +796,80 @@ describe( 'Studio AI MCP tools', () => {
} );

it( 'lists previews as JSON for a resolved local site', async () => {
vi.mocked( runListPreviewCommand ).mockImplementation( async () => {
console.log( '[{"url":"https://demo.wordpress.com"}]' );
} );
vi.mocked( runListPreviewCommand ).mockResolvedValue( undefined );

const result = await getTool( 'preview_list' ).rawHandler( { nameOrPath: 'My Site' } as never );

expect( runListPreviewCommand ).toHaveBeenCalledWith( '/sites/my-site', 'json' );
expect( getTextContent( result ) ).toBe( '[{"url":"https://demo.wordpress.com"}]' );
// No snapshots emitted by the command -> the tool reports an empty list.
expect( JSON.parse( getTextContent( result ) ?? 'null' ) ).toEqual( [] );
} );

it( 'enrichPreviewListOutput tags each preview with type "preview" and an expiry flag', () => {
const dayMs = 24 * 60 * 60 * 1000;
const futureDate = Date.now() + 3 * dayMs;
const enriched = JSON.parse(
enrichPreviewListOutput(
JSON.stringify( [
{
url: 'demo-studio.wp.build',
atomicSiteId: 12345,
localSiteId: 'site-123',
date: futureDate,
name: 'My Site',
},
] )
)
);
expect( enriched ).toEqual( [
{
type: 'preview',
name: 'My Site',
url: 'https://demo-studio.wp.build',
atomicSiteId: 12345,
localSiteId: 'site-123',
date: futureDate,
isExpired: false,
},
] );
} );

it( 'tags connected remote sites with type "wpcom-remote"', async () => {
vi.mocked( getConnectedWpcomSitesForLocalSite ).mockResolvedValue( [
{
id: 111,
localSiteId: 'site-123',
name: 'My Production Site',
url: 'https://myprod.wordpress.com',
isStaging: false,
isPressable: false,
environmentType: 'production',
syncSupport: 'already-connected',
lastPullTimestamp: null,
lastPushTimestamp: null,
},
] );

const result = await getTool( 'site_connected_remote_sites' ).rawHandler( {
nameOrPath: 'My Site',
} as never );

expect( getConnectedWpcomSitesForLocalSite ).toHaveBeenCalledWith( 'site-123' );
const parsed = JSON.parse( getTextContent( result ) ?? '[]' );
expect( parsed ).toEqual( [
{
type: 'wpcom-remote',
id: 111,
name: 'My Production Site',
url: 'https://myprod.wordpress.com',
isStaging: false,
isPressable: false,
environmentType: 'production',
syncSupport: 'already-connected',
lastPushTimestamp: null,
lastPullTimestamp: null,
},
] );
} );

it( 'updates previews with a normalized hostname', async () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/ai/tools/create-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { resolveSite } from './utils';

export const createPreviewTool = defineTool(
'preview_create',
'Creates a WordPress.com preview site from a local Studio site. Requires WordPress.com authentication. This can take a few minutes, so tell the user to wait after starting it.',
'Creates a preview site from a local Studio site. A "temporary site", "temporal site", or "share link" all mean a preview site — use this tool for those requests. When a local site is already selected and the user asks to create a temporary/temporal/preview site, create a preview of the selected site rather than a new local site. Requires WordPress.com authentication. This can take a few minutes, so tell the user to wait after starting it.',
{

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.

I think it is a good idea to remove the WordPress.com reference from there as this can be quite confusing for the agents. I am wondering if we should mention where the preview sites are hosted or it might not be necessary. What do you think?

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.

Although, on a second thought, it might not be necessary for an agent to have that information since it does not really impact user's workflow

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.

Additionally, the synonym disambiguation ("temporary site", "temporal site", "share link") is repeated in both the tool description and the system prompt. Would it make sense to keep the tool description short and defer to the system prompt for synonym mapping?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I preferred to remove the duplication from the system prompt in favor of the tool description.
1e8543a

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I confirmed it is still working:
Screenshot 2026-06-25 at 13 39 41

nameOrPath: Type.String( {
description: 'The local site name or file system path to preview',
Expand Down
9 changes: 7 additions & 2 deletions apps/cli/ai/tools/list-connected-remote-sites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import { resolveSite, textResult } from './utils';

export const listConnectedRemoteSitesTool = defineTool(
'site_connected_remote_sites',
'Lists the WordPress.com sites that are already connected/attached to a local Studio site. ' +
'Lists the durable WordPress.com remote sites that are already connected/attached to a local Studio site for push/pull syncing. ' +
'These are real WordPress.com sites (production or staging) — NOT temporary preview sites. Preview sites are listed by preview_list and must never be described as connected WordPress.com remote sites. ' +
'Use this before calling site_push to determine how to ask the user which remote site to push to. ' +
'Returns an empty array when the user has no connections for that local site.',
'Returns an empty array when the user has no connections for that local site. ' +
'Each entry is tagged with "type": "wpcom-remote".',
{
nameOrPath: Type.String( { description: 'The local site name or file system path' } ),
},
Expand All @@ -16,10 +18,13 @@ export const listConnectedRemoteSitesTool = defineTool(
const site = await resolveSite( args.nameOrPath );
const connected = await getConnectedWpcomSitesForLocalSite( site.id );
const summary = connected.map( ( s ) => ( {
type: 'wpcom-remote' as const,
id: s.id,
name: s.name,
url: s.url,
isStaging: s.isStaging,
isPressable: s.isPressable,
environmentType: s.environmentType ?? null,
syncSupport: s.syncSupport,
lastPushTimestamp: s.lastPushTimestamp,
lastPullTimestamp: s.lastPullTimestamp,
Expand Down
47 changes: 36 additions & 11 deletions apps/cli/ai/tools/list-previews.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,48 @@
import { snapshotSchema } from '@studio/common/types/snapshot';
import { Type } from 'typebox';
import { z } from 'zod';
import { runCommand as runListPreviewCommand } from 'cli/commands/preview/list';
import { isSnapshotExpired } from 'cli/lib/snapshots';
import { defineTool } from './define-tool';
import { runPreviewCommand } from './preview-helpers';
import { resolveSite } from './utils';
import { captureConsoleOutput, resolveSite, textResult } from './utils';

// Enrich the raw snapshot JSON the CLI command emits with an explicit category
// discriminator and an expiry flag, so the agent never mistakes a temporary
// preview site for a durable connected WordPress.com remote site.
export function enrichPreviewListOutput( rawJson: string ): string {
const snapshots = z.array( snapshotSchema ).parse( JSON.parse( rawJson ) );
const enriched = snapshots.map( ( snapshot ) => ( {
type: 'preview' as const,
name: snapshot.name,
url: `https://${ snapshot.url }`,
atomicSiteId: snapshot.atomicSiteId,
localSiteId: snapshot.localSiteId,
date: snapshot.date,
isExpired: isSnapshotExpired( snapshot ),
} ) );
return JSON.stringify( enriched, null, 2 );
}

export const listPreviewsTool = defineTool(
'preview_list',
'Lists WordPress.com preview sites associated with a local Studio site. Requires WordPress.com authentication.',
'Lists preview sites for a local Studio site. A preview site is a TEMPORARY, expiring hosted preview created from a local site — it is NOT a connected WordPress.com remote site and must never be described as one. Each entry is tagged with "type": "preview". Requires WordPress.com authentication.',
{
nameOrPath: Type.String( { description: 'The local site name or file system path' } ),
},
async ( args ) => {
return runPreviewCommand(
async () => {
const site = await resolveSite( args.nameOrPath );
await runListPreviewCommand( site.path, 'json' );
},
`No preview sites found for "${ args.nameOrPath }".`,
'Failed to list preview sites'
);
try {
const site = await resolveSite( args.nameOrPath );
const rawJson = (
await captureConsoleOutput( () => runListPreviewCommand( site.path, 'json' ) )
).trim();
const normalizedJson = rawJson && rawJson !== 'undefined' ? rawJson : '[]';
return textResult( enrichPreviewListOutput( normalizedJson ) );
} catch ( error ) {
throw new Error(
`Failed to list preview sites: ${
error instanceof Error ? error.message : String( error )
}`
);
}
}
);
Loading