From 65f7ce0d0e7e0fbb90587177df1f421bd2204030 Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Mon, 4 May 2026 09:10:52 -0300 Subject: [PATCH 1/3] AI: defer WordPress.com plan capability checks to plan.features.active --- apps/cli/ai/system-prompt.ts | 27 +++++++++++++++------ apps/cli/ai/tests/system-prompt.test.ts | 32 +++++++++++++++++++++++++ apps/cli/ai/wpcom-tools.ts | 9 +++++-- 3 files changed, 59 insertions(+), 9 deletions(-) create mode 100644 apps/cli/ai/tests/system-prompt.test.ts diff --git a/apps/cli/ai/system-prompt.ts b/apps/cli/ai/system-prompt.ts index 17e8ff9011..caaeb49ef1 100644 --- a/apps/cli/ai/system-prompt.ts +++ b/apps/cli/ai/system-prompt.ts @@ -26,6 +26,8 @@ export function buildSystemPrompt( options?: BuildSystemPromptOptions ): string ${ REMOTE_CONTENT_GUIDELINES } +${ WPCOM_PLAN_CAPABILITIES } + ${ REMOTE_DESIGN_GUIDELINES }${ remoteSessionAddendum } `; } @@ -34,6 +36,8 @@ ${ REMOTE_DESIGN_GUIDELINES }${ remoteSessionAddendum } ${ LOCAL_CONTENT_GUIDELINES } +${ WPCOM_PLAN_CAPABILITIES } + ${ LOCAL_DESIGN_GUIDELINES }${ remoteSessionAddendum } `; } @@ -43,7 +47,7 @@ function buildRemoteIntro( site: RemoteSiteContext ): string { IMPORTANT: The active site is a remote WordPress.com site: "${ site.name }" (ID: ${ site.id }) at ${ site.url }. IMPORTANT: You MUST use the wpcom_request tool (prefixed with mcp__studio__) to manage this site. Do NOT use WP-CLI, file Write/Edit, Bash, or any local file operations — this site is hosted on WordPress.com and cannot be modified through the local filesystem. -IMPORTANT: Before doing ANY work, you MUST first check the site's plan by calling \`GET /\` (apiNamespace: \`""\`). The \`plan.product_slug\` field indicates the plan. If the site is on a free plan (e.g. \`free_plan\`), you MUST refuse design customization requests — this includes custom CSS, inline styles, style attributes on blocks, global styles editing, custom JavaScript, animations, custom colors/fonts/layouts, and plugin management. Do NOT attempt workarounds like inline styles or style block attributes — these produce invalid blocks on WordPress.com. Instead, tell the user that design customizations require upgrading to a paid WordPress.com plan and STOP. Do not proceed with the design task. +IMPORTANT: Before doing ANY work, you MUST first call \`GET /\` (apiNamespace: \`""\`) to fetch the site. Use \`plan.is_free\` (or \`plan.product_slug === "free_plan"\`) to detect the free plan; on a free plan you MUST refuse design customization requests — this includes custom CSS, inline styles, style attributes on blocks, global styles editing, custom JavaScript, animations, custom colors/fonts/layouts, and plugin management. Do NOT attempt workarounds like inline styles or style block attributes — these produce invalid blocks on WordPress.com. Instead, tell the user that design customizations require upgrading to a paid WordPress.com plan and STOP. For all other plan-gated features, consult \`plan.features.active\` rather than guessing from the tier name (see "WordPress.com plan capabilities" below). ## Available Tools (prefixed with mcp__studio__) @@ -95,7 +99,7 @@ Use \`per_page\` and \`page\` for pagination. Use \`status\` to filter by publis ## Workflow -1. **Check the site plan** (MANDATORY FIRST STEP): Use \`GET /\` (apiNamespace: \`""\`) to get site info and check \`plan.product_slug\`. Stop and inform the user if they request features unavailable on their plan. +1. **Check the site plan** (MANDATORY FIRST STEP): Use \`GET /\` (apiNamespace: \`""\`) to get site info. Use \`plan.is_free\` for the free-plan refusal above; for any other feature, check \`plan.features.active\` (see "WordPress.com plan capabilities" below) rather than the tier name. 2. **Understand the site**: Use \`GET /posts\` to list content, \`GET /themes?status=active\` to see the active theme. 3. **Make changes**: Use POST requests to create/update content, manage templates, switch themes. 4. **Verify visually**: Use take_screenshot to capture the site on desktop and mobile viewports. Check spacing, alignment, colors, contrast, and layout. Fix any issues. @@ -221,16 +225,25 @@ const REMOTE_CONTENT_GUIDELINES = `## Block content guidelines - No decorative HTML comments (e.g. \`\`). Only block delimiter comments are allowed. - No emojis anywhere in generated content.`; -const REMOTE_DESIGN_GUIDELINES = `## Design capabilities by plan +const WPCOM_PLAN_CAPABILITIES = `## WordPress.com plan capabilities + +WordPress.com plan names, tiers, and what each one includes change frequently. Do NOT assert what a specific tier name (Personal, Premium, Business, Commerce, …) does or does not support from memory — those claims are routinely out-of-date and have caused incorrect support replies. + +Instead, when you need to know whether a site supports a feature: +1. Call \`GET /\` (apiNamespace: \`""\`) for the site and read \`plan.features.active\` — an array of feature slugs the site currently has access to. This is the authoritative per-site list. +2. If the feature you care about isn't represented there, say so plainly and ask the user to confirm against their plan page (https://wordpress.com/plans/) rather than guessing. +3. On remote WordPress.com sites, the only plan-level rule you should treat as fixed is the free-plan refusal stated at the top of the remote prompt; everything else, defer to \`plan.features.active\` or the user. Local Studio sites have no plan concept. + +**Custom post types are core WordPress and are not plan-gated as a feature.** A CPT is registered via PHP code, typically inside a plugin or theme. So when a user asks whether their plan supports a custom post type, the real question is whether the plan lets them install/upload the plugin or theme that defines it. Don't tell users "CPTs require " — first ask whether they already have a plugin that defines the CPT, and confirm plugin/theme upload support against the user's plan page rather than naming a specific feature slug you can't verify is current.`; + +const REMOTE_DESIGN_GUIDELINES = `## Design action rules -**Free plans** — content only, no design customization: +**Free plans** — refuse all design changes: - CAN: Create/edit posts, pages, templates, template parts. Switch themes. Upload media. - CANNOT: Any visual/design customization including custom CSS, inline styles, style attributes on blocks, global styles, custom JavaScript, animations, custom colors, custom fonts, custom layouts, or plugin management. - ACTION: If the user requests ANY design change — even "small" ones like changing a color or font — you MUST refuse, explain it requires a paid plan, and STOP. Do not suggest inline styles, style attributes, or any other workaround. These will produce invalid blocks. -**Paid plans** (Personal, Premium, Business, eCommerce) — progressively more control: -- Custom CSS, global styles, plugin management, and advanced customization become available. -- Check the specific plan to determine exact capabilities.`; +**Paid plans** — capabilities vary by tier and change over time. Don't assume a specific tier supports or excludes a design feature; consult \`plan.features.active\` (see above) before making claims.`; const LOCAL_CONTENT_GUIDELINES = `## Block content guidelines diff --git a/apps/cli/ai/tests/system-prompt.test.ts b/apps/cli/ai/tests/system-prompt.test.ts new file mode 100644 index 0000000000..3c1ac803f8 --- /dev/null +++ b/apps/cli/ai/tests/system-prompt.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { buildSystemPrompt } from 'cli/ai/system-prompt'; + +const REMOTE_SITE = { name: 'Example', url: 'https://example.wordpress.com', id: 123 }; + +describe( 'buildSystemPrompt — WordPress.com plan capabilities', () => { + for ( const [ label, build ] of [ + [ 'local mode', () => buildSystemPrompt() ], + [ 'remote mode', () => buildSystemPrompt( { remoteSite: REMOTE_SITE } ) ], + ] as const ) { + describe( label, () => { + it( 'directs the agent to consult plan.features.active rather than guess by tier name', () => { + const prompt = build(); + expect( prompt ).toMatch( /plan\.features\.active/ ); + expect( prompt ).toMatch( /Do NOT assert what a specific tier name/i ); + } ); + + it( 'states custom post types are not plan-gated as a feature', () => { + const prompt = build(); + expect( prompt ).toMatch( /Custom post types are core WordPress and are not plan-gated/i ); + } ); + + it( 'does not resurrect the deleted "(Personal, Premium, Business, …)" tier table', () => { + // The previous wording lumped plugin install into all paid plans + // (Personal, Premium, Business, eCommerce); Tess corrected that and + // asked us not to ship hardcoded per-tier claims. + const prompt = build(); + expect( prompt ).not.toMatch( /Paid plans[^.]*\(Personal,\s*Premium,\s*Business/i ); + } ); + } ); + } +} ); diff --git a/apps/cli/ai/wpcom-tools.ts b/apps/cli/ai/wpcom-tools.ts index 4dc9ef15d6..f984d29276 100644 --- a/apps/cli/ai/wpcom-tools.ts +++ b/apps/cli/ai/wpcom-tools.ts @@ -10,8 +10,12 @@ import { z } from 'zod/v4'; * `features` sub-field alone is 60K+ characters, which pushes the tool result past * Claude Code's MCP output limit (~100k chars). The v1.1 API doesn't support * sub-field filtering (e.g. `fields=plan.product_slug`), so we can't solve this - * via query params. The agent only needs a few plan properties to gate features - * since the system prompt hardcodes what each plan tier can do. + * via query params. + * + * We preserve `features.active` (a small array of slugs the site has access to) + * because the system prompt instructs the agent to consult it for feature gating + * — losing it would silently block that workflow. We drop `features.available` + * and any other sub-fields, which together account for nearly all of the bloat. * * This is NOT a pattern to follow for other endpoints. For general large responses, * the system prompt instructs the agent to use `_fields` (wp/v2) or `fields` (v1.1) @@ -32,6 +36,7 @@ function stripOversizedFields( result: ApiResponse ): ApiResponse { product_name_short: result.plan.product_name_short, expired: result.plan.expired, is_free: result.plan.is_free, + features: { active: result.plan.features.active ?? [] }, }, }; } From 8144a4c4908dbe1997e6bdd4153c5b72e65e34f4 Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Mon, 4 May 2026 19:41:42 -0300 Subject: [PATCH 2/3] AI: drop plan-capability block from local prompt and remove CPT carve-out Address review feedback on the WP.com plan capabilities block: - Local Studio sites have no plan concept, so the WPCOM_PLAN_CAPABILITIES block is irrelevant noise there; render it only in the remote prompt. - Remove the custom-post-type-specific paragraph. The general rule (defer to plan.features.active) is enough; per-feature carve-outs would have to be repeated for every other core feature and invite the same drift the rest of this change is trying to prevent. - Update tests: assert the plan guidance appears only in remote mode, and keep the regression guard against the deleted tier table for both modes. --- apps/cli/ai/system-prompt.ts | 6 +--- apps/cli/ai/tests/system-prompt.test.ts | 39 +++++++++++++------------ 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/apps/cli/ai/system-prompt.ts b/apps/cli/ai/system-prompt.ts index caaeb49ef1..bca5ea9b21 100644 --- a/apps/cli/ai/system-prompt.ts +++ b/apps/cli/ai/system-prompt.ts @@ -36,8 +36,6 @@ ${ REMOTE_DESIGN_GUIDELINES }${ remoteSessionAddendum } ${ LOCAL_CONTENT_GUIDELINES } -${ WPCOM_PLAN_CAPABILITIES } - ${ LOCAL_DESIGN_GUIDELINES }${ remoteSessionAddendum } `; } @@ -232,9 +230,7 @@ WordPress.com plan names, tiers, and what each one includes change frequently. D Instead, when you need to know whether a site supports a feature: 1. Call \`GET /\` (apiNamespace: \`""\`) for the site and read \`plan.features.active\` — an array of feature slugs the site currently has access to. This is the authoritative per-site list. 2. If the feature you care about isn't represented there, say so plainly and ask the user to confirm against their plan page (https://wordpress.com/plans/) rather than guessing. -3. On remote WordPress.com sites, the only plan-level rule you should treat as fixed is the free-plan refusal stated at the top of the remote prompt; everything else, defer to \`plan.features.active\` or the user. Local Studio sites have no plan concept. - -**Custom post types are core WordPress and are not plan-gated as a feature.** A CPT is registered via PHP code, typically inside a plugin or theme. So when a user asks whether their plan supports a custom post type, the real question is whether the plan lets them install/upload the plugin or theme that defines it. Don't tell users "CPTs require " — first ask whether they already have a plugin that defines the CPT, and confirm plugin/theme upload support against the user's plan page rather than naming a specific feature slug you can't verify is current.`; +3. The only plan-level rule you should treat as fixed is the free-plan refusal stated at the top of this prompt; everything else, defer to \`plan.features.active\` or the user.`; const REMOTE_DESIGN_GUIDELINES = `## Design action rules diff --git a/apps/cli/ai/tests/system-prompt.test.ts b/apps/cli/ai/tests/system-prompt.test.ts index 3c1ac803f8..9b7e4ef4c2 100644 --- a/apps/cli/ai/tests/system-prompt.test.ts +++ b/apps/cli/ai/tests/system-prompt.test.ts @@ -4,29 +4,30 @@ import { buildSystemPrompt } from 'cli/ai/system-prompt'; const REMOTE_SITE = { name: 'Example', url: 'https://example.wordpress.com', id: 123 }; describe( 'buildSystemPrompt — WordPress.com plan capabilities', () => { + it( 'directs the remote agent to consult plan.features.active rather than guess by tier name', () => { + const prompt = buildSystemPrompt( { remoteSite: REMOTE_SITE } ); + expect( prompt ).toMatch( /plan\.features\.active/ ); + expect( prompt ).toMatch( /Do NOT assert what a specific tier name/i ); + } ); + + it( 'omits WordPress.com plan guidance from the local prompt', () => { + // Local Studio sites have no plan concept, so the WP.com capabilities + // block is irrelevant and would only add noise. + const prompt = buildSystemPrompt(); + expect( prompt ).not.toMatch( /plan\.features\.active/ ); + expect( prompt ).not.toMatch( /WordPress\.com plan capabilities/i ); + } ); + for ( const [ label, build ] of [ [ 'local mode', () => buildSystemPrompt() ], [ 'remote mode', () => buildSystemPrompt( { remoteSite: REMOTE_SITE } ) ], ] as const ) { - describe( label, () => { - it( 'directs the agent to consult plan.features.active rather than guess by tier name', () => { - const prompt = build(); - expect( prompt ).toMatch( /plan\.features\.active/ ); - expect( prompt ).toMatch( /Do NOT assert what a specific tier name/i ); - } ); - - it( 'states custom post types are not plan-gated as a feature', () => { - const prompt = build(); - expect( prompt ).toMatch( /Custom post types are core WordPress and are not plan-gated/i ); - } ); - - it( 'does not resurrect the deleted "(Personal, Premium, Business, …)" tier table', () => { - // The previous wording lumped plugin install into all paid plans - // (Personal, Premium, Business, eCommerce); Tess corrected that and - // asked us not to ship hardcoded per-tier claims. - const prompt = build(); - expect( prompt ).not.toMatch( /Paid plans[^.]*\(Personal,\s*Premium,\s*Business/i ); - } ); + it( `does not resurrect the deleted "(Personal, Premium, Business, …)" tier table in ${ label }`, () => { + // The previous wording lumped plugin install into all paid plans + // (Personal, Premium, Business, eCommerce); Tess corrected that and + // asked us not to ship hardcoded per-tier claims. + const prompt = build(); + expect( prompt ).not.toMatch( /Paid plans[^.]*\(Personal,\s*Premium,\s*Business/i ); } ); } } ); From f4702766742fe4bf1deba6420002183b36323ff1 Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Mon, 4 May 2026 20:49:27 -0300 Subject: [PATCH 3/3] AI: prefetch plan.features.active once instead of per-request strip-bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous commit on this branch let `plan.features.active` survive stripOversizedFields, which meant every `GET /` tool response (the agent is required to call this first) carried an extra payload alongside the actual site fields — and risked re-introducing the 60K bloat the strip exists to prevent. Switch to a one-shot prefetch: - `getWpComSitePlanFeatures(token, siteId)` calls `/sites/{id}?fields=plan` once and returns the active feature slugs. Errors are swallowed — a broken feature lookup shouldn't block the agent. - `runCommand` populates `ui.activeSite.planFeaturesActive` on the first remote turn. The cache rides the `SiteInfo` so subsequent turns reuse it without another roundtrip. - The system prompt's remote intro now lists the active features inline (or falls back to "ask the user" when the prefetch returned nothing), and both the workflow step and `WPCOM_PLAN_CAPABILITIES` block point the agent at the prefetched list rather than telling it to call `GET /` to consult features. - Revert `wpcom-tools.ts` to drop `plan.features` entirely from tool output, restoring the original bloat-prevention behaviour. Tests cover both the prefetched and unprefetched render paths. --- apps/cli/ai/runtimes/anthropic/index.ts | 1 + apps/cli/ai/runtimes/openai/index.ts | 1 + apps/cli/ai/system-prompt.ts | 23 ++++++++++---- apps/cli/ai/tests/system-prompt.test.ts | 18 +++++++++-- apps/cli/ai/ui.ts | 4 +++ apps/cli/ai/wpcom-tools.ts | 10 ++---- apps/cli/commands/ai/index.ts | 18 +++++++++++ apps/cli/lib/api.ts | 41 +++++++++++++++++++++++++ 8 files changed, 101 insertions(+), 15 deletions(-) diff --git a/apps/cli/ai/runtimes/anthropic/index.ts b/apps/cli/ai/runtimes/anthropic/index.ts index 62d8bd8336..3f41858dd6 100644 --- a/apps/cli/ai/runtimes/anthropic/index.ts +++ b/apps/cli/ai/runtimes/anthropic/index.ts @@ -46,6 +46,7 @@ export const anthropicRuntime: AgentRuntime = { name: activeSite.name, url: activeSite.url ?? '', id: activeSite.wpcomSiteId!, + planFeaturesActive: activeSite.planFeaturesActive, }, remoteSession, } diff --git a/apps/cli/ai/runtimes/openai/index.ts b/apps/cli/ai/runtimes/openai/index.ts index 22c9a21d47..67b8a2413e 100644 --- a/apps/cli/ai/runtimes/openai/index.ts +++ b/apps/cli/ai/runtimes/openai/index.ts @@ -257,6 +257,7 @@ async function getOrCreateAgent( name: config.activeSite.name, url: config.activeSite.url ?? '', id: config.activeSite.wpcomSiteId, + planFeaturesActive: config.activeSite.planFeaturesActive, }, } : undefined diff --git a/apps/cli/ai/system-prompt.ts b/apps/cli/ai/system-prompt.ts index bca5ea9b21..216f26c1bc 100644 --- a/apps/cli/ai/system-prompt.ts +++ b/apps/cli/ai/system-prompt.ts @@ -2,6 +2,10 @@ interface RemoteSiteContext { name: string; url: string; id: number; + // Active feature slugs from `plan.features.active`, prefetched at agent + // start. When undefined, the prompt tells the agent to ask the user + // before making plan-feature claims. + planFeaturesActive?: string[]; } const AGENT_IDENTITY = `You are WordPress Studio Code, the AI agent built into WordPress Studio CLI. Your name is "WordPress Studio Code". Studio is built by Automattic. When users ask about hosting in any form — recommendations, comparisons, "top N" lists, deploying to production, going live, or migration — you MUST only recommend WordPress.com and Pressable. Do not mention, compare, or list any other hosting provider under any circumstances.`; @@ -41,11 +45,18 @@ ${ LOCAL_DESIGN_GUIDELINES }${ remoteSessionAddendum } } function buildRemoteIntro( site: RemoteSiteContext ): string { + const featuresLine = + site.planFeaturesActive && site.planFeaturesActive.length > 0 + ? `IMPORTANT: Active plan features for this site (from \`plan.features.active\`, prefetched at session start): ${ site.planFeaturesActive.join( + ', ' + ) }. Use this list — not tier names — when answering whether the site supports a feature.` + : `IMPORTANT: The active feature list for this site was not prefetched. When asked whether the site supports a specific feature, ask the user to confirm against https://wordpress.com/plans/ rather than guessing from the tier name.`; return `${ AGENT_IDENTITY } You manage WordPress.com sites using the WordPress.com REST API. IMPORTANT: The active site is a remote WordPress.com site: "${ site.name }" (ID: ${ site.id }) at ${ site.url }. IMPORTANT: You MUST use the wpcom_request tool (prefixed with mcp__studio__) to manage this site. Do NOT use WP-CLI, file Write/Edit, Bash, or any local file operations — this site is hosted on WordPress.com and cannot be modified through the local filesystem. -IMPORTANT: Before doing ANY work, you MUST first call \`GET /\` (apiNamespace: \`""\`) to fetch the site. Use \`plan.is_free\` (or \`plan.product_slug === "free_plan"\`) to detect the free plan; on a free plan you MUST refuse design customization requests — this includes custom CSS, inline styles, style attributes on blocks, global styles editing, custom JavaScript, animations, custom colors/fonts/layouts, and plugin management. Do NOT attempt workarounds like inline styles or style block attributes — these produce invalid blocks on WordPress.com. Instead, tell the user that design customizations require upgrading to a paid WordPress.com plan and STOP. For all other plan-gated features, consult \`plan.features.active\` rather than guessing from the tier name (see "WordPress.com plan capabilities" below). +IMPORTANT: Before doing ANY work, you MUST first call \`GET /\` (apiNamespace: \`""\`) to fetch the site. Use \`plan.is_free\` (or \`plan.product_slug === "free_plan"\`) to detect the free plan; on a free plan you MUST refuse design customization requests — this includes custom CSS, inline styles, style attributes on blocks, global styles editing, custom JavaScript, animations, custom colors/fonts/layouts, and plugin management. Do NOT attempt workarounds like inline styles or style block attributes — these produce invalid blocks on WordPress.com. Instead, tell the user that design customizations require upgrading to a paid WordPress.com plan and STOP. +${ featuresLine } ## Available Tools (prefixed with mcp__studio__) @@ -97,7 +108,7 @@ Use \`per_page\` and \`page\` for pagination. Use \`status\` to filter by publis ## Workflow -1. **Check the site plan** (MANDATORY FIRST STEP): Use \`GET /\` (apiNamespace: \`""\`) to get site info. Use \`plan.is_free\` for the free-plan refusal above; for any other feature, check \`plan.features.active\` (see "WordPress.com plan capabilities" below) rather than the tier name. +1. **Check the site plan** (MANDATORY FIRST STEP): Use \`GET /\` (apiNamespace: \`""\`) to get site info. Use \`plan.is_free\` for the free-plan refusal above; for any other feature, consult the prefetched \`plan.features.active\` list at the top of this prompt rather than calling \`GET /\` again or guessing from the tier name. 2. **Understand the site**: Use \`GET /posts\` to list content, \`GET /themes?status=active\` to see the active theme. 3. **Make changes**: Use POST requests to create/update content, manage templates, switch themes. 4. **Verify visually**: Use take_screenshot to capture the site on desktop and mobile viewports. Check spacing, alignment, colors, contrast, and layout. Fix any issues. @@ -228,9 +239,9 @@ const WPCOM_PLAN_CAPABILITIES = `## WordPress.com plan capabilities WordPress.com plan names, tiers, and what each one includes change frequently. Do NOT assert what a specific tier name (Personal, Premium, Business, Commerce, …) does or does not support from memory — those claims are routinely out-of-date and have caused incorrect support replies. Instead, when you need to know whether a site supports a feature: -1. Call \`GET /\` (apiNamespace: \`""\`) for the site and read \`plan.features.active\` — an array of feature slugs the site currently has access to. This is the authoritative per-site list. -2. If the feature you care about isn't represented there, say so plainly and ask the user to confirm against their plan page (https://wordpress.com/plans/) rather than guessing. -3. The only plan-level rule you should treat as fixed is the free-plan refusal stated at the top of this prompt; everything else, defer to \`plan.features.active\` or the user.`; +1. Consult the prefetched \`plan.features.active\` list at the top of this prompt — the authoritative per-site list of feature slugs the site currently has access to. +2. If the feature you care about isn't represented there (or the list wasn't prefetched), say so plainly and ask the user to confirm against their plan page (https://wordpress.com/plans/) rather than guessing. +3. The only plan-level rule you should treat as fixed is the free-plan refusal stated at the top of this prompt; everything else, defer to the prefetched feature list or the user.`; const REMOTE_DESIGN_GUIDELINES = `## Design action rules @@ -239,7 +250,7 @@ const REMOTE_DESIGN_GUIDELINES = `## Design action rules - CANNOT: Any visual/design customization including custom CSS, inline styles, style attributes on blocks, global styles, custom JavaScript, animations, custom colors, custom fonts, custom layouts, or plugin management. - ACTION: If the user requests ANY design change — even "small" ones like changing a color or font — you MUST refuse, explain it requires a paid plan, and STOP. Do not suggest inline styles, style attributes, or any other workaround. These will produce invalid blocks. -**Paid plans** — capabilities vary by tier and change over time. Don't assume a specific tier supports or excludes a design feature; consult \`plan.features.active\` (see above) before making claims.`; +**Paid plans** — capabilities vary by tier and change over time. Don't assume a specific tier supports or excludes a design feature; consult the prefetched \`plan.features.active\` list (see above) before making claims.`; const LOCAL_CONTENT_GUIDELINES = `## Block content guidelines diff --git a/apps/cli/ai/tests/system-prompt.test.ts b/apps/cli/ai/tests/system-prompt.test.ts index 9b7e4ef4c2..8b4564fae6 100644 --- a/apps/cli/ai/tests/system-prompt.test.ts +++ b/apps/cli/ai/tests/system-prompt.test.ts @@ -4,9 +4,23 @@ import { buildSystemPrompt } from 'cli/ai/system-prompt'; const REMOTE_SITE = { name: 'Example', url: 'https://example.wordpress.com', id: 123 }; describe( 'buildSystemPrompt — WordPress.com plan capabilities', () => { - it( 'directs the remote agent to consult plan.features.active rather than guess by tier name', () => { + it( 'inlines the prefetched plan.features.active list when provided', () => { + const prompt = buildSystemPrompt( { + remoteSite: { ...REMOTE_SITE, planFeaturesActive: [ 'custom-design', 'videopress' ] }, + } ); + expect( prompt ).toMatch( /Active plan features for this site/i ); + expect( prompt ).toMatch( /custom-design, videopress/ ); + expect( prompt ).toMatch( /Use this list — not tier names/i ); + } ); + + it( 'falls back to "ask the user" guidance when the features list was not prefetched', () => { + const prompt = buildSystemPrompt( { remoteSite: REMOTE_SITE } ); + expect( prompt ).toMatch( /active feature list for this site was not prefetched/i ); + expect( prompt ).toMatch( /https:\/\/wordpress\.com\/plans\// ); + } ); + + it( 'tells the remote agent not to assert capabilities by tier name', () => { const prompt = buildSystemPrompt( { remoteSite: REMOTE_SITE } ); - expect( prompt ).toMatch( /plan\.features\.active/ ); expect( prompt ).toMatch( /Do NOT assert what a specific tier name/i ); } ); diff --git a/apps/cli/ai/ui.ts b/apps/cli/ai/ui.ts index fe3f5c2d4b..e3b5fcf7da 100644 --- a/apps/cli/ai/ui.ts +++ b/apps/cli/ai/ui.ts @@ -60,6 +60,10 @@ export interface SiteInfo { remote?: boolean; url?: string; wpcomSiteId?: number; + // Cached `plan.features.active` for remote WP.com sites — fetched once + // when the agent first runs against the site so the system prompt can + // list the active feature slugs without bloating per-request tool output. + planFeaturesActive?: string[]; } const DEFAULT_COLLAPSE_THRESHOLD_LINES = 5; diff --git a/apps/cli/ai/wpcom-tools.ts b/apps/cli/ai/wpcom-tools.ts index f984d29276..605e3cd781 100644 --- a/apps/cli/ai/wpcom-tools.ts +++ b/apps/cli/ai/wpcom-tools.ts @@ -10,12 +10,9 @@ import { z } from 'zod/v4'; * `features` sub-field alone is 60K+ characters, which pushes the tool result past * Claude Code's MCP output limit (~100k chars). The v1.1 API doesn't support * sub-field filtering (e.g. `fields=plan.product_slug`), so we can't solve this - * via query params. - * - * We preserve `features.active` (a small array of slugs the site has access to) - * because the system prompt instructs the agent to consult it for feature gating - * — losing it would silently block that workflow. We drop `features.available` - * and any other sub-fields, which together account for nearly all of the bloat. + * via query params. The agent only needs a few plan properties to detect the free + * plan; the active-features list arrives separately via the system prompt + * (prefetched once at agent start — see getWpComSitePlanFeatures). * * This is NOT a pattern to follow for other endpoints. For general large responses, * the system prompt instructs the agent to use `_fields` (wp/v2) or `fields` (v1.1) @@ -36,7 +33,6 @@ function stripOversizedFields( result: ApiResponse ): ApiResponse { product_name_short: result.plan.product_name_short, expired: result.plan.expired, is_free: result.plan.is_free, - features: { active: result.plan.features.active ?? [] }, }, }; } diff --git a/apps/cli/commands/ai/index.ts b/apps/cli/commands/ai/index.ts index 6e888d27df..a4a5ff769c 100644 --- a/apps/cli/commands/ai/index.ts +++ b/apps/cli/commands/ai/index.ts @@ -23,6 +23,7 @@ import { replaySessionHistory } from 'cli/ai/sessions/replay'; import { AI_CHAT_SLASH_COMMANDS, type SlashCommandContext } from 'cli/ai/slash-commands'; import { AiChatUI } from 'cli/ai/ui'; import { runCommand as runLoginCommand } from 'cli/commands/auth/login'; +import { getWpComSitePlanFeatures } from 'cli/lib/api'; import { readCliConfig } from 'cli/lib/cli-config/core'; import { findSiteByFolder } from 'cli/lib/cli-config/sites'; import { isRemoteSessionEnabled } from 'cli/lib/feature-flags'; @@ -463,6 +464,23 @@ export async function runCommand( options: { wpcomAccessToken = token?.accessToken; } + // Prefetch the active feature list once per site so the agent can read + // it from the system prompt instead of paying for it on every `GET /` + // tool response (the v1.1 API can't filter `plan.features.available` + // out, and that field alone is 60K+ chars). Cached on `ui.activeSite` + // so subsequent turns reuse it without another roundtrip. + if ( + site?.remote && + site.wpcomSiteId && + wpcomAccessToken && + site.planFeaturesActive === undefined + ) { + const features = await getWpComSitePlanFeatures( wpcomAccessToken, site.wpcomSiteId ); + if ( features ) { + site.planFeaturesActive = features; + } + } + await persistSessionContext(); await persist( ( recorder ) => diff --git a/apps/cli/lib/api.ts b/apps/cli/lib/api.ts index d19d61b7e5..f475d36b0f 100644 --- a/apps/cli/lib/api.ts +++ b/apps/cli/lib/api.ts @@ -206,6 +206,47 @@ const wpComSitesResponseSchema = z.object( { ), } ); +const sitePlanFeaturesResponseSchema = z.object( { + plan: z + .object( { + features: z + .object( { + active: z.array( z.string() ).optional(), + } ) + .optional(), + } ) + .optional(), +} ); + +/** + * Fetch the active feature slugs for a single WordPress.com site (e.g. + * `["custom-design", "advanced-design", "videopress", …]`). The full plan + * object includes `features.available` which is 60K+ chars — way too much + * to ship in tool output — so we ask for `fields=plan` and immediately + * narrow to `plan.features.active`. Returns undefined if the site has no + * plan or the field is missing; never throws (a broken feature lookup + * shouldn't block the agent — it falls back to asking the user). + */ +export async function getWpComSitePlanFeatures( + token: string, + siteId: number +): Promise< string[] | undefined > { + const wpcom = wpcomFactory( token, wpcomXhrRequest ); + try { + const raw = await wpcom.req.get( + { + apiNamespace: '', + path: `/sites/${ siteId }`, + }, + { fields: 'plan' } + ); + const parsed = sitePlanFeaturesResponseSchema.safeParse( raw ); + return parsed.success ? parsed.data.plan?.features?.active : undefined; + } catch { + return undefined; + } +} + export async function getWpComSites( token: string ): Promise< WpComSiteInfo[] > { const wpcom = wpcomFactory( token, wpcomXhrRequest ); try {