diff --git a/src/cli/commands/inspiredesign.ts b/src/cli/commands/inspiredesign.ts index be62ff1..12f6f53 100644 --- a/src/cli/commands/inspiredesign.ts +++ b/src/cli/commands/inspiredesign.ts @@ -6,11 +6,16 @@ import { parseBooleanFlag, parseNumberFlag, parseOptionalStringFlag, - parseRepeatedStringFlag + parseRepeatedStringFlag, + readInlineFlagValue } from "../utils/parse"; import { buildWorkflowCompletionMessage } from "../utils/workflow-message"; import { isChallengeAutomationMode, type ChallengeAutomationMode } from "../../challenges/types"; -import { validateProviderUrlSiteRecipeCompatibility } from "../../guidance/recipes/site-recipe-validation"; +import { + requiresProviderUrlSiteRecipeCompatibility, + validateProviderScopedUrlCanonicality, + validateProviderUrlSiteRecipeCompatibility +} from "../../guidance/recipes/site-recipe-validation"; import { resolveInspiredesignCaptureMode } from "../../inspiredesign/capture-mode"; import type { InspiredesignVisualEvidenceMode } from "../../inspiredesign/visual-evidence"; import type { WorkflowBrowserMode } from "../../providers/types"; @@ -101,7 +106,7 @@ const parseInspiredesignArgs = (rawArgs: string[]): InspiredesignCommandArgs => continue; } if (arg?.startsWith("--capture-mode=")) { - const value = (arg.split("=", 2)[1] ?? "").toLowerCase(); + const value = (readInlineFlagValue(arg, "--capture-mode") ?? "").toLowerCase(); if (!CAPTURE_MODE_VALUES.has(value)) { throw createUsageError(`Invalid --capture-mode: ${value}`); } @@ -114,7 +119,10 @@ const parseInspiredesignArgs = (rawArgs: string[]): InspiredesignCommandArgs => continue; } if (arg?.startsWith("--include-prototype-guidance=")) { - parsed.includePrototypeGuidance = parseBooleanFlag(arg.split("=", 2)[1] ?? "", "--include-prototype-guidance"); + parsed.includePrototypeGuidance = parseBooleanFlag( + readInlineFlagValue(arg, "--include-prototype-guidance") ?? "", + "--include-prototype-guidance" + ); continue; } @@ -128,7 +136,7 @@ const parseInspiredesignArgs = (rawArgs: string[]): InspiredesignCommandArgs => continue; } if (arg?.startsWith("--mode=")) { - const value = (arg.split("=", 2)[1] ?? "").toLowerCase(); + const value = (readInlineFlagValue(arg, "--mode") ?? "").toLowerCase(); if (!MODE_VALUES.has(value)) { throw createUsageError(`Invalid --mode: ${value}`); } @@ -145,10 +153,14 @@ const parseInspiredesignArgs = (rawArgs: string[]): InspiredesignCommandArgs => continue; } if (arg?.startsWith("--max-references=")) { - parsed.maxReferences = parseNumberFlag(arg.split("=", 2)[1] ?? "", "--max-references", { - min: 1, - max: MAX_REFERENCES_LIMIT - }); + parsed.maxReferences = parseNumberFlag( + readInlineFlagValue(arg, "--max-references") ?? "", + "--max-references", + { + min: 1, + max: MAX_REFERENCES_LIMIT + } + ); continue; } @@ -162,7 +174,7 @@ const parseInspiredesignArgs = (rawArgs: string[]): InspiredesignCommandArgs => continue; } if (arg?.startsWith("--visual-evidence=")) { - const value = (arg.split("=", 2)[1] ?? "").toLowerCase(); + const value = (readInlineFlagValue(arg, "--visual-evidence") ?? "").toLowerCase(); if (!VISUAL_EVIDENCE_VALUES.has(value)) { throw createUsageError(`Invalid --visual-evidence: ${value}`); } @@ -176,7 +188,11 @@ const parseInspiredesignArgs = (rawArgs: string[]): InspiredesignCommandArgs => continue; } if (arg?.startsWith("--timeout-ms=")) { - parsed.timeoutMs = parseNumberFlag(arg.split("=", 2)[1] ?? "", "--timeout-ms", { min: 1 }); + parsed.timeoutMs = parseNumberFlag( + readInlineFlagValue(arg, "--timeout-ms") ?? "", + "--timeout-ms", + { min: 1 } + ); continue; } @@ -186,7 +202,7 @@ const parseInspiredesignArgs = (rawArgs: string[]): InspiredesignCommandArgs => continue; } if (arg?.startsWith("--output-dir=")) { - parsed.outputDir = resolveWorkflowOutputDirFlag(arg.split("=", 2)[1]); + parsed.outputDir = resolveWorkflowOutputDirFlag(readInlineFlagValue(arg, "--output-dir")); continue; } @@ -196,7 +212,11 @@ const parseInspiredesignArgs = (rawArgs: string[]): InspiredesignCommandArgs => continue; } if (arg?.startsWith("--ttl-hours=")) { - parsed.ttlHours = parseNumberFlag(arg.split("=", 2)[1] ?? "", "--ttl-hours", { min: 1, max: 168 }); + parsed.ttlHours = parseNumberFlag( + readInlineFlagValue(arg, "--ttl-hours") ?? "", + "--ttl-hours", + { min: 1, max: 168 } + ); continue; } @@ -210,7 +230,7 @@ const parseInspiredesignArgs = (rawArgs: string[]): InspiredesignCommandArgs => continue; } if (arg?.startsWith("--browser-mode=")) { - const value = (arg.split("=", 2)[1] ?? "").toLowerCase(); + const value = (readInlineFlagValue(arg, "--browser-mode") ?? "").toLowerCase(); if (!BROWSER_MODE_VALUES.has(value)) { throw createUsageError(`Invalid --browser-mode: ${value}`); } @@ -223,7 +243,7 @@ const parseInspiredesignArgs = (rawArgs: string[]): InspiredesignCommandArgs => continue; } if (arg?.startsWith("--use-cookies=")) { - parsed.useCookies = parseBooleanFlag(arg.split("=", 2)[1] ?? "", "--use-cookies"); + parsed.useCookies = parseBooleanFlag(readInlineFlagValue(arg, "--use-cookies") ?? "", "--use-cookies"); continue; } @@ -237,7 +257,7 @@ const parseInspiredesignArgs = (rawArgs: string[]): InspiredesignCommandArgs => continue; } if (arg?.startsWith("--challenge-automation-mode=")) { - const value = arg.split("=", 2)[1] ?? ""; + const value = readInlineFlagValue(arg, "--challenge-automation-mode") ?? ""; if (!isChallengeAutomationMode(value)) { throw createUsageError(`Invalid --challenge-automation-mode: ${value}`); } @@ -255,7 +275,8 @@ const parseInspiredesignArgs = (rawArgs: string[]): InspiredesignCommandArgs => continue; } if (arg?.startsWith("--cookie-policy-override=") || arg?.startsWith("--cookie-policy=")) { - const value = (arg.split("=", 2)[1] ?? "").toLowerCase(); + const flag = arg.startsWith("--cookie-policy-override=") ? "--cookie-policy-override" : "--cookie-policy"; + const value = (readInlineFlagValue(arg, flag) ?? "").toLowerCase(); if (!COOKIE_POLICY_VALUES.has(value)) { throw createUsageError(`Invalid --cookie-policy-override: ${value}`); } @@ -280,13 +301,23 @@ export async function runInspiredesignCommand(args: ParsedArgs) { throw createUsageError("--query is only supported by inspiredesign harvest"); } const isHarvest = subcommand === "harvest"; - if (parsed.providers && parsed.providers.length > 0 && !parsed.query) { + const providers = parsed.providers ?? []; + const urls = parsed.urls ?? []; + const canonicality = validateProviderScopedUrlCanonicality({ providers, urls }); + if (!canonicality.ok) { + throw createUsageError(canonicality.message); + } + if (requiresProviderUrlSiteRecipeCompatibility({ + providers, + urls, + query: parsed.query + })) { if (!isHarvest) { throw createUsageError("--provider requires --query or compatible harvest --url recovery"); } const compatibility = validateProviderUrlSiteRecipeCompatibility({ - providers: parsed.providers, - urls: parsed.urls ?? [] + providers, + urls }); if (!compatibility.ok) { throw createUsageError(compatibility.message); diff --git a/src/cli/utils/parse.ts b/src/cli/utils/parse.ts index 78629f8..02090a1 100644 --- a/src/cli/utils/parse.ts +++ b/src/cli/utils/parse.ts @@ -23,6 +23,11 @@ function decimalPattern(options: NumberFlagOptions): RegExp { return signed ? SIGNED_DECIMAL_PATTERN : UNSIGNED_DECIMAL_PATTERN; } +export const readInlineFlagValue = (arg: string, flag: string): string | undefined => { + const prefix = `${flag}=`; + return arg.startsWith(prefix) ? arg.slice(prefix.length) : undefined; +}; + export function parseNumberFlag(value: string, flag: string, options: NumberFlagOptions = {}): number { if (value.trim() === "" || value !== value.trim() || !decimalPattern(options).test(value)) { throw createUsageError(`Invalid ${flag}: ${value}`); @@ -66,7 +71,7 @@ export function parseOptionalStringFlag(rawArgs: string[], flag: string): string return value; } if (arg?.startsWith(`${flag}=`)) { - const value = arg.split("=", 2)[1]; + const value = readInlineFlagValue(arg, flag); if (!value) { throw createUsageError(`Missing value for ${flag}`); } @@ -102,7 +107,7 @@ export function parseRepeatedStringFlag(rawArgs: string[], flag: string): string continue; } if (arg?.startsWith(`${flag}=`)) { - const value = arg.split("=", 2)[1]; + const value = readInlineFlagValue(arg, flag); if (!value) { throw createUsageError(`Missing value for ${flag}`); } diff --git a/src/guidance/recipes/generic.ts b/src/guidance/recipes/generic.ts index 7924f71..4d7ae5d 100644 --- a/src/guidance/recipes/generic.ts +++ b/src/guidance/recipes/generic.ts @@ -1,5 +1,5 @@ import { buildCanvasPlanSetParamsExample, buildCanvasRepairGuidance } from "../../canvas/repair-examples"; -import { resolveSiteRecipeForUrl } from "./site-registry"; +import { normalizePinterestReferenceUrl } from "./pinterest"; import type { CanvasGenerationPlanIssue } from "../../canvas/types"; import type { GuidanceCommandExample, @@ -18,6 +18,7 @@ const DEFAULT_INSPIREDESIGN_BRIEF = "Digital photography studio landing page"; const DEFAULT_INSPIREDESIGN_QUERY = "cinematic photography studio landing page inspiration"; const DEFAULT_RESEARCH_TOPIC = "browser automation provider recovery"; const DEFAULT_PROVIDER = "provider diagnostics"; +const DEFAULT_PINTEREST_REFERENCE_URL = "https://www.pinterest.com/pin/27654985208435505/"; const isCanvasGenerationPlanIssue = (value: unknown): value is CanvasGenerationPlanIssue => { if (!value || typeof value !== "object" || Array.isArray(value)) return false; @@ -65,9 +66,18 @@ const inspiredesignCookieFlags = (context: GuidanceContext): string => { const pinterestRecoveryUrls = (context: GuidanceContext): string[] => { if (!isPinterestScopedRecovery(context)) return []; const urls = context.referenceUrls ?? []; - return [...new Set(urls.filter((url) => resolveSiteRecipeForUrl(url)?.id === "social/pinterest"))]; + const canonicalUrls = urls + .map(normalizePinterestReferenceUrl) + .filter((url): url is string => url !== null); + return [...new Set(canonicalUrls)]; }; +const inspiredesignRecoveryExampleUrls = (context: GuidanceContext): string[] => ( + selectedProviderIsPinterest(context) + ? [DEFAULT_PINTEREST_REFERENCE_URL] + : ["https://example.com/usable-reference"] +); + const inspiredesignHarvestCommand = (context: GuidanceContext): GuidanceCommandExample => { const brief = typeof context.details?.brief === "string" ? context.details.brief : DEFAULT_INSPIREDESIGN_BRIEF; const query = context.query ?? DEFAULT_INSPIREDESIGN_QUERY; @@ -444,10 +454,10 @@ const buildEvidenceRecoveryGuidance = ( commands: [inspiredesignHarvestCommand(context)], paramsExamples: [{ id: "explicit-reference-url-recovery", - label: "Use explicit high-quality visual references when provider discovery is blocked", - params: { + label: "Use explicit high-quality visual references when provider discovery is blocked", + params: { brief: typeof context.details?.brief === "string" ? context.details.brief : DEFAULT_INSPIREDESIGN_BRIEF, - urls: ["https://example.com/usable-reference"], + urls: inspiredesignRecoveryExampleUrls(context), visualEvidence: "required", browserMode: inspiredesignBrowserMode(context), ...(selectedProviderIsPinterest(context) ? { useCookies: true, cookiePolicy: "required" } : {}) diff --git a/src/guidance/recipes/pinterest.ts b/src/guidance/recipes/pinterest.ts index d41e6ed..17b686a 100644 --- a/src/guidance/recipes/pinterest.ts +++ b/src/guidance/recipes/pinterest.ts @@ -53,10 +53,15 @@ const pinterestGuidance: NextStepGuidance = { const PINTEREST_PIN_ID_PATTERN = /^\d+$/; const RESERVED_PINTEREST_BOARD_PATHS = new Set([ "about", + "ads", "board", "business", + "careers", + "contact", "create", + "developers", "explore", + "help", "ideas", "login", "messages", @@ -65,11 +70,13 @@ const RESERVED_PINTEREST_BOARD_PATHS = new Set([ "search", "settings", "shopping", + "terms", "today" ]); const RESERVED_PINTEREST_IDEA_PATHS = new Set(["create", "edit", "search"]); const RESERVED_PINTEREST_PROFILE_TABS = new Set([ "activity", + "boards", "comments", "created", "followers", diff --git a/src/guidance/recipes/site-recipe-validation.ts b/src/guidance/recipes/site-recipe-validation.ts index e842a19..6ac2801 100644 --- a/src/guidance/recipes/site-recipe-validation.ts +++ b/src/guidance/recipes/site-recipe-validation.ts @@ -1,15 +1,24 @@ import { resolveSiteRecipeForProvider, resolveSiteRecipeForUrl } from "./site-registry"; +import { normalizePinterestReferenceUrl } from "./pinterest"; import type { SiteRecipe } from "../types"; export type ProviderUrlSiteRecipeCompatibilityResult = | { ok: true; recipeId: string } | { ok: false; message: string }; +export type ProviderScopedUrlCanonicalityResult = + | { ok: true } + | { ok: false; message: string }; + type ProviderUrlSiteRecipeCompatibilityInput = { providers: string[]; urls: string[]; }; +type ProviderUrlSiteRecipeCompatibilityGateInput = ProviderUrlSiteRecipeCompatibilityInput & { + query?: string; +}; + const normalizeNonBlank = (values: string[]): string[] => values .map((value) => value.trim()) .filter((value) => value.length > 0); @@ -20,6 +29,63 @@ const supportsBrowserNativeRecovery = (recipe: SiteRecipe | undefined): recipe i typeof recipe?.browserNativeDiscovery?.buildSearchUrl === "function" ); +const isCanonicalRecipeReferenceUrl = (recipe: SiteRecipe, url: string): boolean => { + if (recipe.id === "social/pinterest") { + return normalizePinterestReferenceUrl(url) !== null; + } + return true; +}; + +const isPinterestLikeHostname = (value: string): boolean => { + const hostname = value.toLowerCase(); + return [ + hostname === "pinterest.com", + hostname.endsWith(".pinterest.com"), + hostname.startsWith("pinterest."), + hostname.includes(".pinterest.") + ].some(Boolean); +}; + +export const isPinterestLikeUrl = (value: string): boolean => { + try { + return isPinterestLikeHostname(new URL(value).hostname); + } catch { + return false; + } +}; + +export const isNonCanonicalPinterestLikeUrl = (url: string): boolean => ( + (resolveSiteRecipeForUrl(url)?.id === "social/pinterest" || isPinterestLikeUrl(url)) + && normalizePinterestReferenceUrl(url) === null +); + +export const requiresProviderUrlSiteRecipeCompatibility = ({ + providers, + urls, + query +}: ProviderUrlSiteRecipeCompatibilityGateInput): boolean => { + const providerIds = normalizeNonBlank(providers); + if (providerIds.length === 0) return false; + if (!query?.trim()) return true; + return false; +}; + +export const validateProviderScopedUrlCanonicality = ({ + providers, + urls +}: ProviderUrlSiteRecipeCompatibilityInput): ProviderScopedUrlCanonicalityResult => { + const providerHasPinterest = normalizeNonBlank(providers) + .some((providerId) => resolveSiteRecipeForProvider(providerId)?.id === "social/pinterest"); + if (!providerHasPinterest) return { ok: true }; + + const nonCanonicalPinterestUrl = normalizeNonBlank(urls) + .find((url) => normalizePinterestReferenceUrl(url) === null); + if (!nonCanonicalPinterestUrl) return { ok: true }; + return incompatible( + `URL ${nonCanonicalPinterestUrl} is not a canonical social/pinterest reference URL for provider-scoped recovery.` + ); +}; + export const validateProviderUrlSiteRecipeCompatibility = ({ providers, urls @@ -72,5 +138,12 @@ export const validateProviderUrlSiteRecipeCompatibility = ({ if (!recipeId) { return incompatible("Provider-scoped URL recovery could not resolve a site recipe."); } + const recipe = providerRecipes[0]?.recipe; + const nonReferenceUrl = recipe + ? urlRecipes.find((entry) => !isCanonicalRecipeReferenceUrl(recipe, entry.url)) + : undefined; + if (nonReferenceUrl) { + return incompatible(`URL ${nonReferenceUrl.url} is not a canonical ${recipeId} reference URL for provider-scoped recovery.`); + } return { ok: true, recipeId }; }; diff --git a/src/providers/renderer.ts b/src/providers/renderer.ts index 25011b3..4eb343f 100644 --- a/src/providers/renderer.ts +++ b/src/providers/renderer.ts @@ -10,13 +10,21 @@ import { import type { InspiredesignReferencePatternBoard } from "../inspiredesign/reference-pattern-board"; import type { CanvasDesignGovernance, CanvasGenerationPlan } from "../canvas/types"; import { - INSPIREDESIGN_HANDOFF_FILES + INSPIREDESIGN_HANDOFF_FILES, + type InspiredesignArtifactGuide, + type InspiredesignContractSectionGuide } from "../inspiredesign/handoff"; import { buildInspiredesignSuccessHandoff } from "./workflow-handoff"; import type { NextStepGuidance } from "../guidance/types"; export type RenderMode = "compact" | "json" | "md" | "context" | "path"; +type RenderedInspiredesignArtifactGuide = Partial; +type RenderedInspiredesignFollowthrough = Omit & { + artifactGuide: RenderedInspiredesignArtifactGuide; +}; +type InspiredesignGuideEntry = InspiredesignContractSectionGuide[string]; + export interface ShoppingOffer { offer_id: string; product_id: string; @@ -182,30 +190,102 @@ const researchFailureMessage = (content: string | undefined): string => ( ); const CANVAS_CONTINUATION_BLOCKED_COMMAND = "Unavailable until nextStepGuidance.readiness is ready."; +const CANVAS_PLAN_OMITTED_GUIDANCE = "Canvas plan request omitted until nextStepGuidance.readiness is ready."; const canContinueInspiredesignInCanvas = (guidance: NextStepGuidance | undefined): boolean => ( guidance?.readiness === "ready" ); -const blockInspiredesignCanvasArtifactGuide = ( - handoff: InspiredesignFollowthrough, - recoverySummary: string -): InspiredesignFollowthrough["artifactGuide"] => ({ - ...handoff.artifactGuide, - [INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest]: { - ...handoff.artifactGuide[INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest], - purpose: "Diagnostic Canvas request preview only until nextStepGuidance.readiness is ready.", - howToUse: [ - recoverySummary, - "Do not submit this payload to Canvas until usable ranked references pass the readiness checks." - ], - mustNot: [ - "Do not submit canvas.plan.set while nextStepGuidance.readiness is not ready.", - "Do not treat rejected or not-ready references as Canvas design direction." - ] - } +const scrubCanvasPlanReference = (value: string): string => ( + value.includes(INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest) + ? CANVAS_PLAN_OMITTED_GUIDANCE + : value +); + +const scrubGuideEntryCanvasReferences = (entry: InspiredesignGuideEntry): InspiredesignGuideEntry => ({ + ...entry, + expectedContents: entry.expectedContents.map(scrubCanvasPlanReference), + howToUse: entry.howToUse.map(scrubCanvasPlanReference), + mustNot: entry.mustNot.map(scrubCanvasPlanReference) }); +const scrubArtifactGuideCanvasReferences = ( + guide: RenderedInspiredesignArtifactGuide +): RenderedInspiredesignArtifactGuide => { + const scrubbed: RenderedInspiredesignArtifactGuide = {}; + for (const [key, entry] of Object.entries(guide)) { + if (entry) { + scrubbed[key as keyof InspiredesignArtifactGuide] = scrubGuideEntryCanvasReferences(entry); + } + } + return scrubbed; +}; + +const scrubContractSectionGuideCanvasReferences = ( + guide: InspiredesignContractSectionGuide +): InspiredesignContractSectionGuide => { + const scrubbed: InspiredesignContractSectionGuide = {}; + for (const [key, entry] of Object.entries(guide)) { + scrubbed[key] = scrubGuideEntryCanvasReferences(entry); + } + return scrubbed; +}; + +const blockInspiredesignCanvasArtifactGuide = ( + handoff: InspiredesignFollowthrough +): RenderedInspiredesignArtifactGuide => { + const artifactGuide: RenderedInspiredesignArtifactGuide = { ...handoff.artifactGuide }; + delete artifactGuide[INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest]; + return artifactGuide; +}; + +const blockInspiredesignNotReadyArtifacts = ( + handoff: RenderedInspiredesignFollowthrough +): RenderedInspiredesignFollowthrough => { + const referenceSynthesis = plainObject(handoff.implementationContext.referenceSynthesis); + const blockedArtifacts: ReadonlySet = new Set([ + INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest, + INSPIREDESIGN_HANDOFF_FILES.prototypeGuidance + ]); + const requiredArtifacts = isStringArray(referenceSynthesis.requiredArtifacts) + ? referenceSynthesis.requiredArtifacts.filter((artifact) => !blockedArtifacts.has(artifact)) + : []; + const artifactGuide: RenderedInspiredesignArtifactGuide = { ...handoff.artifactGuide }; + delete artifactGuide[INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest]; + delete artifactGuide[INSPIREDESIGN_HANDOFF_FILES.prototypeGuidance]; + return { + ...handoff, + artifactGuide: scrubArtifactGuideCanvasReferences(artifactGuide), + contractSectionGuide: scrubContractSectionGuideCanvasReferences(handoff.contractSectionGuide), + implementationContext: { + ...handoff.implementationContext, + referenceSynthesis: { + ...referenceSynthesis, + requiredArtifacts + } + } + }; +}; + +const blockPrototypeGuidanceInDesignMarkdown = ( + markdown: string, + prototypeGuidanceMarkdown: string | null +): string => { + const withoutCanvasDeliverable = markdown.replace( + /\n- Diagnostic `canvasPlanRequest` preview; do not submit to Canvas until next-step guidance is ready/g, + `\n- ${CANVAS_PLAN_OMITTED_GUIDANCE}` + ); + const withoutPrototypeDeliverable = withoutCanvasDeliverable.replace( + /\n- Prototype guidance Markdown for the first HTML pass/g, + "" + ); + if (!prototypeGuidanceMarkdown) return withoutPrototypeDeliverable; + return withoutPrototypeDeliverable.replace( + prototypeGuidanceMarkdown, + "# 6. Optional Prototype Plan\n\n- Prototype guidance omitted because next-step guidance is not ready." + ); +}; + const buildMissingInspiredesignGuidanceHandoff = (): { followthroughSummary: string; suggestedNextAction: string; @@ -793,6 +873,9 @@ export const renderInspiredesign = (args: { const followthroughSummary = prependPrimaryConstraint(args.designAgentHandoff.summary, args.meta); const canContinueInCanvas = canContinueInspiredesignInCanvas(args.nextStepGuidance); const prototypeGuidanceMarkdown = canContinueInCanvas ? args.prototypeGuidanceMarkdown : null; + const designMarkdown = canContinueInCanvas + ? args.designMarkdown + : blockPrototypeGuidanceInDesignMarkdown(args.designMarkdown, args.prototypeGuidanceMarkdown); const commandExamples = { ...args.designAgentHandoff.commandExamples, continueInCanvas: canContinueInCanvas @@ -807,10 +890,10 @@ export const renderInspiredesign = (args: { ...(args.nextStepGuidance ? { nextStepGuidance: args.nextStepGuidance } : {}) }); const handoff = args.nextStepGuidance ? renderedWorkflowHandoff : buildMissingInspiredesignGuidanceHandoff(); - const blockedCanvasArtifactGuide = canContinueInCanvas + const blockedCanvasArtifactGuide: RenderedInspiredesignArtifactGuide = canContinueInCanvas ? args.designAgentHandoff.artifactGuide - : blockInspiredesignCanvasArtifactGuide(args.designAgentHandoff, handoff.suggestedNextAction); - const renderedDesignAgentHandoff = { + : blockInspiredesignCanvasArtifactGuide(args.designAgentHandoff); + const renderedDesignAgentHandoffBase: RenderedInspiredesignFollowthrough = { ...args.designAgentHandoff, ...handoff, summary: handoff.followthroughSummary, @@ -818,17 +901,20 @@ export const renderInspiredesign = (args: { artifactGuide: blockedCanvasArtifactGuide, commandExamples }; + const renderedDesignAgentHandoff = canContinueInCanvas + ? renderedDesignAgentHandoffBase + : blockInspiredesignNotReadyArtifacts(renderedDesignAgentHandoffBase); const contextPayload = { brief: args.brief, advancedBriefMarkdown: args.advancedBriefMarkdown, urls: args.urls, designContract: args.designContract, - canvasPlanRequest: args.canvasPlanRequest, + ...(canContinueInCanvas ? { canvasPlanRequest: args.canvasPlanRequest } : {}), designAgentHandoff: renderedDesignAgentHandoff, ...(args.nextStepGuidance ? { nextStepGuidance: args.nextStepGuidance } : {}), generationPlan: args.generationPlan, implementationPlan: args.implementationPlan, - designMarkdown: args.designMarkdown, + designMarkdown, implementationPlanMarkdown: args.implementationPlanMarkdown, prototypeGuidanceMarkdown, evidence: args.evidence, @@ -839,10 +925,9 @@ export const renderInspiredesign = (args: { meta: args.meta }; const files: Array<{ path: string; content: string | Record }> = [ - { path: INSPIREDESIGN_HANDOFF_FILES.designMarkdown, content: args.designMarkdown }, + { path: INSPIREDESIGN_HANDOFF_FILES.designMarkdown, content: designMarkdown }, { path: INSPIREDESIGN_HANDOFF_FILES.advancedBrief, content: args.advancedBriefMarkdown }, { path: INSPIREDESIGN_HANDOFF_FILES.designContract, content: args.designContract }, - { path: INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest, content: args.canvasPlanRequest }, { path: INSPIREDESIGN_HANDOFF_FILES.designAgentHandoff, content: renderedDesignAgentHandoff @@ -859,6 +944,9 @@ export const renderInspiredesign = (args: { if (prototypeGuidanceMarkdown) { files.push({ path: INSPIREDESIGN_HANDOFF_FILES.prototypeGuidance, content: prototypeGuidanceMarkdown }); } + if (canContinueInCanvas) { + files.push({ path: INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest, content: args.canvasPlanRequest }); + } const captureAttemptFields = { ...(captureAttemptSummary ? { captureAttemptSummary } : {}), ...(captureAttemptReport ? { captureAttemptReport } : {}) @@ -883,7 +971,7 @@ export const renderInspiredesign = (args: { brief: args.brief, advancedBriefMarkdown: args.advancedBriefMarkdown, urls: args.urls, - canvasPlanRequest: args.canvasPlanRequest, + ...(canContinueInCanvas ? { canvasPlanRequest: args.canvasPlanRequest } : {}), designAgentHandoff: renderedDesignAgentHandoff, ...(args.nextStepGuidance ? { nextStepGuidance: args.nextStepGuidance } : {}), designContract: args.designContract, @@ -906,7 +994,7 @@ export const renderInspiredesign = (args: { return { response: { mode: args.mode, - markdown: args.designMarkdown, + markdown: designMarkdown, implementationPlanMarkdown: args.implementationPlanMarkdown, prototypeGuidanceMarkdown, ...handoff, diff --git a/src/providers/workflows.ts b/src/providers/workflows.ts index c47190f..1b21b9e 100644 --- a/src/providers/workflows.ts +++ b/src/providers/workflows.ts @@ -44,7 +44,12 @@ import { type NextStepGuidance, type SiteRecipe } from "../guidance"; -import { validateProviderUrlSiteRecipeCompatibility } from "../guidance/recipes/site-recipe-validation"; +import { + isNonCanonicalPinterestLikeUrl, + requiresProviderUrlSiteRecipeCompatibility, + validateProviderScopedUrlCanonicality, + validateProviderUrlSiteRecipeCompatibility +} from "../guidance/recipes/site-recipe-validation"; import { resolveSiteRecipeForProvider, resolveSiteRecipeForUrl } from "../guidance/recipes/site-registry"; import { mergeInspiredesignReferenceUrls, @@ -1711,7 +1716,11 @@ const normalizeInspiredesignInput = (input: InspiredesignRunInput): Inspiredesig if (query && input.harvest !== true) { throw new Error("Inspiredesign workflow query is only supported when harvest is true."); } - if (providers.length > 0 && !query) { + const canonicality = validateProviderScopedUrlCanonicality({ providers, urls }); + if (!canonicality.ok) { + throw new Error(`Inspiredesign workflow ${canonicality.message}`); + } + if (requiresProviderUrlSiteRecipeCompatibility({ providers, urls, query })) { if (input.harvest !== true) { throw new Error("Inspiredesign workflow providers require query unless harvest uses compatible URL recovery."); } @@ -2013,6 +2022,32 @@ const capMixedInspiredesignDiscovery = ( }; }; +const filterStandardDiscoveryForSiteRecipe = ( + siteRecipe: SiteRecipe, + discovery: InspiredesignDiscoveryResult +): InspiredesignDiscoveryResult => { + if (siteRecipe.id !== "social/pinterest") return discovery; + + const accepted: InspiredesignDiscoveryResult["accepted"] = []; + const rejected: InspiredesignDiscoveryResult["rejected"] = [...discovery.rejected]; + discovery.accepted.forEach((candidate) => { + if (!isNonCanonicalPinterestLikeUrl(candidate.url)) { + accepted.push(candidate); + return; + } + rejected.push({ + status: "rejected", + reason: "invalid_url", + rawUrl: candidate.url, + ...(candidate.title ? { title: candidate.title } : {}), + source: candidate.source, + provider: candidate.provider, + rank: candidate.rank + }); + }); + return { accepted, rejected }; +}; + const discoverInspiredesignReferences = async ( runtime: ReferenceRetrievalPort, workflowInput: InspiredesignResolvedInput, @@ -2066,7 +2101,10 @@ const discoverInspiredesignReferences = async ( const searchFailures = searchResult.failures.length > 0 ? searchResult.failures : failureFromInspiredesignDiscoveryError({ ...workflowInput, providers: standardProviderIds }, searchResult.error); - const discovery = normalizeInspiredesignDiscoveryRecords(searchResult.records); + const discovery = filterStandardDiscoveryForSiteRecipe( + siteRecipe, + normalizeInspiredesignDiscoveryRecords(searchResult.records) + ); const siteResult = await runSiteRecipeDiscovery(); const siteDiscovery = normalizeInspiredesignDiscoveryRecords(siteResult.records); const combinedDiscovery = capMixedInspiredesignDiscovery( diff --git a/src/tools/inspiredesign_run.ts b/src/tools/inspiredesign_run.ts index 61cc198..b77d032 100644 --- a/src/tools/inspiredesign_run.ts +++ b/src/tools/inspiredesign_run.ts @@ -6,7 +6,11 @@ import { resolveProviderRuntime } from "./workflow-runtime"; import { resolveWorkflowToolOutputDir } from "./workflow-output"; import { CHALLENGE_AUTOMATION_MODES } from "../challenges/types"; import { DEFAULT_WORKFLOW_TRANSPORT_TIMEOUT_MS } from "../cli/transport-timeouts"; -import { validateProviderUrlSiteRecipeCompatibility } from "../guidance/recipes/site-recipe-validation"; +import { + requiresProviderUrlSiteRecipeCompatibility, + validateProviderScopedUrlCanonicality, + validateProviderUrlSiteRecipeCompatibility +} from "../guidance/recipes/site-recipe-validation"; import { captureInspiredesignReferenceFromManager } from "../inspiredesign/capture"; import { resolveInspiredesignCaptureMode } from "../inspiredesign/capture-mode"; @@ -51,13 +55,23 @@ export function createInspiredesignRunTool(deps: ToolDeps): ToolDefinition { throw new Error("query is only supported when harvest is true."); } const isHarvest = args.harvest === true; - if (args.providers && args.providers.length > 0 && !args.query) { + const providers = args.providers ?? []; + const urls = args.urls ?? []; + const canonicality = validateProviderScopedUrlCanonicality({ providers, urls }); + if (!canonicality.ok) { + throw new Error(canonicality.message); + } + if (requiresProviderUrlSiteRecipeCompatibility({ + providers, + urls, + query: args.query + })) { if (!isHarvest) { throw new Error("providers require query unless harvest uses compatible URL recovery."); } const compatibility = validateProviderUrlSiteRecipeCompatibility({ - providers: args.providers, - urls: args.urls ?? [] + providers, + urls }); if (!compatibility.ok) { throw new Error(compatibility.message); diff --git a/tests/cli-workflows.test.ts b/tests/cli-workflows.test.ts index 8228dcc..110b4be 100644 --- a/tests/cli-workflows.test.ts +++ b/tests/cli-workflows.test.ts @@ -610,6 +610,78 @@ describe("workflow CLI commands", () => { })); }); + it("rejects Pinterest provider query runs with non-canonical explicit URLs", async () => { + await expect(runInspiredesignCommand(makeArgs("inspiredesign", [ + "harvest", + "--brief=Build a docs landing page contract", + "--query=studio references", + "--provider=social/pinterest", + "--url=https://www.pinterest.com/search/pins/?q=studio" + ]))).rejects.toThrow( + "URL https://www.pinterest.com/search/pins/?q=studio is not a canonical social/pinterest reference URL for provider-scoped recovery." + ); + + await expect(runInspiredesignCommand(makeArgs("inspiredesign", [ + "harvest", + "--brief=Build a docs landing page contract", + "--query=studio references", + "--provider=social/pinterest", + "--url=https://www.pinterest.com/create/pin/" + ]))).rejects.toThrow( + "URL https://www.pinterest.com/create/pin/ is not a canonical social/pinterest reference URL for provider-scoped recovery." + ); + + await expect(runInspiredesignCommand(makeArgs("inspiredesign", [ + "harvest", + "--brief=Build a docs landing page contract", + "--query=studio references", + "--provider=pinterest", + "--url=https://www.pinterest.com/search/pins/?q=studio" + ]))).rejects.toThrow( + "URL https://www.pinterest.com/search/pins/?q=studio is not a canonical social/pinterest reference URL for provider-scoped recovery." + ); + + await expect(runInspiredesignCommand(makeArgs("inspiredesign", [ + "harvest", + "--brief=Build a docs landing page contract", + "--query=studio references", + "--provider=social/pinterest", + "--provider=web/default", + "--url=https://www.pinterest.com/search/pins/?q=studio" + ]))).rejects.toThrow( + "URL https://www.pinterest.com/search/pins/?q=studio is not a canonical social/pinterest reference URL for provider-scoped recovery." + ); + + await expect(runInspiredesignCommand(makeArgs("inspiredesign", [ + "harvest", + "--brief=Build a docs landing page contract", + "--query=studio references", + "--provider=social/pinterest", + "--url=https://example.com/pin/27654985208435505/" + ]))).rejects.toThrow( + "URL https://example.com/pin/27654985208435505/ is not a canonical social/pinterest reference URL for provider-scoped recovery." + ); + }); + + it("accepts Pinterest provider query runs with canonical explicit URLs", async () => { + callDaemon.mockResolvedValue({ ok: true }); + + await runInspiredesignCommand(makeArgs("inspiredesign", [ + "harvest", + "--brief=Build a docs landing page contract", + "--query=studio references", + "--provider=social/pinterest", + "--url=https://www.pinterest.com/pin/27654985208435505/" + ])); + + expect(callDaemon).toHaveBeenCalledWith("inspiredesign.run", expect.objectContaining({ + harvest: true, + providers: ["social/pinterest"], + query: "studio references", + urls: ["https://www.pinterest.com/pin/27654985208435505/"] + })); + }); + it("rejects inspiredesign providers without query or compatible URLs", async () => { await expect(runInspiredesignCommand(makeArgs("inspiredesign", [ "harvest", @@ -625,6 +697,21 @@ describe("workflow CLI commands", () => { ]))).rejects.toThrow("Provider web/default does not support URL-only site recipe recovery"); }); + it("preserves inline output directory values containing equals signs", async () => { + callDaemon.mockResolvedValue({ ok: true }); + + await runInspiredesignCommand(makeArgs("inspiredesign", [ + "harvest", + "--brief=Build a docs landing page contract", + "--query=studio references", + "--output-dir=/tmp/odb-output=a" + ])); + + expect(callDaemon).toHaveBeenCalledWith("inspiredesign.run", expect.objectContaining({ + outputDir: "/tmp/odb-output=a" + })); + }); + it("surfaces inspiredesign readiness in completion messages", async () => { for (const readiness of ["diagnostic_only", "ready"]) { callDaemon.mockResolvedValueOnce({ diff --git a/tests/guidance-router.test.ts b/tests/guidance-router.test.ts index 6a8eb7f..aa6a415 100644 --- a/tests/guidance-router.test.ts +++ b/tests/guidance-router.test.ts @@ -27,6 +27,12 @@ describe("GuidanceRouter", () => { expect(guidance.readiness).toBe("blocked"); expect(guidance.primaryAction.summary).toContain("Pinterest browser-native recipe"); expect(guidance.doNotProceedIf).toContain("reference_count is 0"); + expect(guidance.paramsExamples[0]?.params).toMatchObject({ + urls: ["https://www.pinterest.com/pin/27654985208435505/"], + useCookies: true, + cookiePolicy: "required" + }); + expect(JSON.stringify(guidance.paramsExamples)).not.toContain("example.com"); expectRunnableCommands(guidance); }); @@ -177,6 +183,52 @@ describe("GuidanceRouter", () => { expect(guidance.commands[0]?.command).toContain("--browser-mode extension --use-cookies --cookie-policy required"); }); + it("omits non-canonical Pinterest chrome URLs from URL recovery commands", () => { + const guidance = routeNextStepGuidance({ + workflow: "inspiredesign", + reasonCode: "zero_ranked_references", + requestedProviders: ["social/pinterest"], + siteRecipeId: "social/pinterest", + referenceUrls: [ + "https://www.pinterest.com/search/pins/?q=studio", + "https://www.pinterest.com/pin/61572719900827789/?utm_source=test", + "/pin/61572719900827790/?utm_source=relative", + "https://www.pinterest.com/studio/pins/", + "https://www.pinterest.com/ideas/web-design-parallax-scrolling/896364491640/#comments" + ], + evidence: { referenceCount: 4, rankedReferenceCount: 0, rejectedReferenceCount: 4 } + }); + + const command = guidance.commands[0]?.command ?? ""; + expect(command).toContain("--provider social/pinterest --url"); + expect(command).toContain("--url \"https://www.pinterest.com/pin/61572719900827789/\""); + expect(command).toContain("--url \"https://www.pinterest.com/pin/61572719900827790/\""); + expect(command).toContain("--url \"https://www.pinterest.com/ideas/web-design-parallax-scrolling/896364491640/\""); + expect(command).not.toContain("/search/pins"); + expect(command).not.toContain("/studio/pins"); + expect(command).not.toContain("--query"); + }); + + it("falls back to Pinterest query recovery when no canonical reference URLs remain", () => { + const guidance = routeNextStepGuidance({ + workflow: "inspiredesign", + reasonCode: "zero_ranked_references", + requestedProviders: ["social/pinterest"], + siteRecipeId: "social/pinterest", + referenceUrls: [ + "https://www.pinterest.com/search/pins/?q=studio", + "https://www.pinterest.com/studio/pins/" + ], + query: "fashion studio landing page", + evidence: { referenceCount: 2, rankedReferenceCount: 0, rejectedReferenceCount: 2 } + }); + + const command = guidance.commands[0]?.command ?? ""; + expect(command).toContain("--query \"fashion studio landing page\""); + expect(command).toContain("--provider social/pinterest"); + expect(command).not.toContain("--url"); + }); + it("does not route all-rejected generic evidence to Canvas handoff", () => { const guidance = routeNextStepGuidance({ workflow: "inspiredesign", diff --git a/tests/guidance-site-recipe-validation.test.ts b/tests/guidance-site-recipe-validation.test.ts index d0763b0..1182811 100644 --- a/tests/guidance-site-recipe-validation.test.ts +++ b/tests/guidance-site-recipe-validation.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it, vi } from "vitest"; -import { validateProviderUrlSiteRecipeCompatibility } from "../src/guidance/recipes/site-recipe-validation"; +import { + isNonCanonicalPinterestLikeUrl, + isPinterestLikeUrl, + requiresProviderUrlSiteRecipeCompatibility, + validateProviderScopedUrlCanonicality, + validateProviderUrlSiteRecipeCompatibility +} from "../src/guidance/recipes/site-recipe-validation"; const PIN_URL = "https://www.pinterest.com/pin/27654985208435505/"; const browserNativeDiscovery = { @@ -7,11 +13,27 @@ const browserNativeDiscovery = { }; describe("site recipe URL compatibility validation", () => { - it("accepts canonical Pinterest provider and Pinterest URLs", () => { - expect(validateProviderUrlSiteRecipeCompatibility({ - providers: ["social/pinterest"], - urls: [PIN_URL] - })).toEqual({ ok: true, recipeId: "social/pinterest" }); + it("classifies malformed and non-canonical Pinterest-like URLs", () => { + expect(isPinterestLikeUrl("not a url")).toBe(false); + expect(isPinterestLikeUrl("https://www.pinterest.com/search/pins/?q=studio")).toBe(true); + expect(isNonCanonicalPinterestLikeUrl("https://www.pinterest.com/search/pins/?q=studio")).toBe(true); + expect(isNonCanonicalPinterestLikeUrl(PIN_URL)).toBe(false); + expect(isNonCanonicalPinterestLikeUrl("https://example.com/reference")).toBe(false); + }); + + it("accepts canonical Pinterest reference URL variants", () => { + for (const url of [ + PIN_URL, + "http://www.pinterest.com/pin/27654985208435505/?utm_source=test#comments", + "https://uk.pinterest.com/pin/27654985208435505/", + "https://www.pinterest.com/ideas/web-design-parallax-scrolling/896364491640/", + "https://www.pinterest.com/studioeditorial/fashion-campaigns/" + ]) { + expect(validateProviderUrlSiteRecipeCompatibility({ + providers: ["social/pinterest"], + urls: [url] + })).toEqual({ ok: true, recipeId: "social/pinterest" }); + } }); it("accepts Pinterest provider alias and Pinterest URLs", () => { @@ -21,6 +43,84 @@ describe("site recipe URL compatibility validation", () => { })).toEqual({ ok: true, recipeId: "social/pinterest" }); }); + it("does not use URL-only compatibility validation when a query is present", () => { + expect(requiresProviderUrlSiteRecipeCompatibility({ + providers: ["pinterest"], + urls: ["https://www.pinterest.com/search/pins/?q=studio"], + query: "studio references" + })).toBe(false); + expect(validateProviderScopedUrlCanonicality({ + providers: ["pinterest"], + urls: ["https://www.pinterest.com/search/pins/?q=studio"] + })).toEqual({ + ok: false, + message: "URL https://www.pinterest.com/search/pins/?q=studio is not a canonical social/pinterest reference URL for provider-scoped recovery." + }); + }); + + it("rejects Pinterest chrome URLs that are not canonical references", () => { + for (const url of [ + "https://www.pinterest.com/", + "https://www.pinterest.com/search/pins/?q=fashion%20studio", + "https://www.pinterest.com/pin/create/", + "https://www.pinterest.com/login/", + "https://www.pinterest.com/help/article/", + "https://www.pinterest.com/ads/overview/", + "https://www.pinterest.com/studio/", + "https://www.pinterest.com/studio/pins/", + "https://www.pinterest.com/studio/boards/" + ]) { + expect(validateProviderUrlSiteRecipeCompatibility({ + providers: ["social/pinterest"], + urls: [url] + })).toEqual({ + ok: false, + message: `URL ${url} is not a canonical social/pinterest reference URL for provider-scoped recovery.` + }); + } + }); + + it("rejects Pinterest chrome URLs in mixed-provider query runs", () => { + expect(validateProviderScopedUrlCanonicality({ + providers: ["social/pinterest", "web/default"], + urls: ["https://www.pinterest.com/search/pins/?q=studio"] + })).toEqual({ + ok: false, + message: "URL https://www.pinterest.com/search/pins/?q=studio is not a canonical social/pinterest reference URL for provider-scoped recovery." + }); + expect(validateProviderScopedUrlCanonicality({ + providers: ["social/pinterest", "web/default"], + urls: [PIN_URL] + })).toEqual({ ok: true }); + for (const url of [ + "https://pinterest.example.com/pin/27654985208435505/", + "https://www.pinterest.co.uk/pin/27654985208435505/" + ]) { + expect(validateProviderScopedUrlCanonicality({ + providers: ["social/pinterest", "web/default"], + urls: [url] + })).toEqual({ + ok: false, + message: `URL ${url} is not a canonical social/pinterest reference URL for provider-scoped recovery.` + }); + } + }); + + it("rejects non-Pinterest hosts even when paths look like Pinterest references", () => { + for (const url of [ + "https://evilpinterest.com/pin/27654985208435505/", + "https://pinterest.example.com/pin/27654985208435505/" + ]) { + expect(validateProviderUrlSiteRecipeCompatibility({ + providers: ["social/pinterest"], + urls: [url] + })).toEqual({ + ok: false, + message: `URL ${url} does not match a browser-native site recipe for provider-scoped recovery.` + }); + } + }); + it("rejects Pinterest providers paired with non-Pinterest URLs", () => { expect(validateProviderUrlSiteRecipeCompatibility({ providers: ["social/pinterest"], @@ -29,6 +129,13 @@ describe("site recipe URL compatibility validation", () => { ok: false, message: "URL https://example.com/reference does not match a browser-native site recipe for provider-scoped recovery." }); + expect(validateProviderScopedUrlCanonicality({ + providers: ["social/pinterest"], + urls: ["https://example.com/reference"] + })).toEqual({ + ok: false, + message: "URL https://example.com/reference is not a canonical social/pinterest reference URL for provider-scoped recovery." + }); }); it("rejects generic providers paired with URLs", () => { @@ -114,6 +221,49 @@ describe("site recipe URL compatibility validation", () => { vi.resetModules(); }); + it("rejects URL recipes without browser-native discovery support", async () => { + vi.resetModules(); + vi.doMock("../src/guidance/recipes/site-registry", () => ({ + resolveSiteRecipeForProvider: vi.fn(() => ({ id: "social/example", browserNativeDiscovery })), + resolveSiteRecipeForUrl: vi.fn(() => ({ id: "social/example" })) + })); + + const { validateProviderUrlSiteRecipeCompatibility: validateWithNonNativeUrlRecipe } = await import( + "../src/guidance/recipes/site-recipe-validation" + ); + + expect(validateWithNonNativeUrlRecipe({ + providers: ["social/example"], + urls: ["https://example.com/reference"] + })).toEqual({ + ok: false, + message: "URL https://example.com/reference does not match a browser-native site recipe for provider-scoped recovery." + }); + + vi.doUnmock("../src/guidance/recipes/site-registry"); + vi.resetModules(); + }); + + it("accepts browser-native non-Pinterest recipes without Pinterest canonicality checks", async () => { + vi.resetModules(); + vi.doMock("../src/guidance/recipes/site-registry", () => ({ + resolveSiteRecipeForProvider: vi.fn(() => ({ id: "social/example", browserNativeDiscovery })), + resolveSiteRecipeForUrl: vi.fn(() => ({ id: "social/example", browserNativeDiscovery })) + })); + + const { validateProviderUrlSiteRecipeCompatibility: validateNonPinterestRecipe } = await import( + "../src/guidance/recipes/site-recipe-validation" + ); + + expect(validateNonPinterestRecipe({ + providers: ["social/example"], + urls: ["https://example.com/reference"] + })).toEqual({ ok: true, recipeId: "social/example" }); + + vi.doUnmock("../src/guidance/recipes/site-registry"); + vi.resetModules(); + }); + it("rejects unresolved recipe ids from malformed registry responses", async () => { vi.resetModules(); vi.doMock("../src/guidance/recipes/site-registry", () => ({ diff --git a/tests/providers-inspiredesign-contract.test.ts b/tests/providers-inspiredesign-contract.test.ts index aa251ff..649378b 100644 --- a/tests/providers-inspiredesign-contract.test.ts +++ b/tests/providers-inspiredesign-contract.test.ts @@ -3366,15 +3366,12 @@ describe("inspiredesign packet + renderer", () => { expect(handoffFile?.commandExamples.continueInCanvas).toBe(responseHandoff.commandExamples.continueInCanvas); expect(responseHandoff.summary).toBe(response.followthroughSummary); expect(responseHandoff.summary).not.toContain("continue in OpenDevBrowser Canvas"); - expect(responseHandoff.artifactGuide[INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest]?.purpose).toContain( - "Diagnostic Canvas request preview" - ); - expect(responseHandoff.artifactGuide[INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest]?.mustNot).toContain( - "Do not submit canvas.plan.set while nextStepGuidance.readiness is not ready." - ); - expect(handoffFile?.artifactGuide[INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest]?.purpose).toBe( - responseHandoff.artifactGuide[INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest]?.purpose - ); + expect(response).not.toHaveProperty("canvasPlanRequest"); + expect(rendered.files.some((file) => file.path === INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest)).toBe(false); + expect(responseHandoff.artifactGuide[INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest]).toBeUndefined(); + expect(handoffFile?.artifactGuide[INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest]).toBeUndefined(); + expect(JSON.stringify(responseHandoff)).not.toContain(INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest); + expect(JSON.stringify(handoffFile)).not.toContain(INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest); expect(response.suggestedNextAction).toBe("Collect usable reference evidence before Canvas."); expect(response.followthroughSummary).toBe( "Primary constraint: Pinterest requires a user-authorized signed-in browser session. Collect usable reference evidence before Canvas." @@ -3410,6 +3407,8 @@ describe("inspiredesign packet + renderer", () => { expect(responseWithoutGuidance.suggestedNextAction).toBe( "Canvas continuation unavailable until nextStepGuidance.readiness is ready." ); + expect(responseWithoutGuidance).not.toHaveProperty("canvasPlanRequest"); + expect(renderedWithoutGuidance.files.some((file) => file.path === INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest)).toBe(false); expect(JSON.stringify(responseWithoutGuidance.designAgentHandoff)).not.toContain("continue in OpenDevBrowser Canvas"); }); @@ -3468,8 +3467,171 @@ describe("inspiredesign packet + renderer", () => { }); const response = rendered.response as Record; - const responseHandoff = response.designAgentHandoff as { commandExamples: { continueInCanvas: string } }; + const responseHandoff = response.designAgentHandoff as { + commandExamples: { continueInCanvas: string }; + implementationContext: { referenceSynthesis: { requiredArtifacts: string[] } }; + }; expect(responseHandoff.commandExamples.continueInCanvas).toBe("Unavailable until nextStepGuidance.readiness is ready."); + expect(responseHandoff.implementationContext.referenceSynthesis.requiredArtifacts).not.toContain( + INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest + ); + expect(response).not.toHaveProperty("canvasPlanRequest"); + expect(rendered.files.some((file) => file.path === INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest)).toBe(false); + }); + + it.each(["compact", "json", "md", "context", "path"] as const)( + "omits prototype artifact references from not-ready %s responses", + (mode) => { + const packet = buildInspiredesignPacket({ + brief: "Create a photography studio landing page", + briefExpansion: makeBriefExpansion(), + urls: ["https://example.com/blocked"], + references: [], + includePrototypeGuidance: true + }); + const nextStepGuidance = { + id: "inspiredesign.harvest.zero_ranked_references", + recipeType: "evidence_recovery", + workflow: "inspiredesign", + severity: "warning", + readiness: "needs_recovery", + reasonCode: "zero_ranked_references", + primaryAction: { + id: "recover_reference_evidence", + label: "Recover reference evidence", + summary: "Collect usable reference evidence before Canvas." + }, + commands: [{ + id: "rerun", + label: "Rerun harvest", + command: "npx opendevbrowser inspiredesign harvest --brief \"Create a photography studio landing page\" --query \"cinematic studio references\"" + }], + paramsExamples: [], + fieldExamples: [], + artifactInputs: [], + validationChecks: [], + fallbackPolicy: { allowed: false, requiresUserConfirmation: true, reason: "Do not continue yet." }, + doNotProceedIf: ["reference_count is 0"] + } satisfies NextStepGuidance; + + const rendered = renderInspiredesign({ + mode, + brief: "Create a photography studio landing page", + advancedBriefMarkdown: packet.advancedBriefMarkdown, + urls: ["https://example.com/blocked"], + designContract: packet.designContract, + canvasPlanRequest: packet.canvasPlanRequest, + designAgentHandoff: packet.followthrough, + generationPlan: packet.generationPlan, + implementationPlan: packet.implementationPlan, + designMarkdown: packet.designMarkdown, + implementationPlanMarkdown: packet.implementationPlanMarkdown, + prototypeGuidanceMarkdown: packet.prototypeGuidanceMarkdown, + evidence: packet.evidence, + nextStepGuidance, + meta: {} + }); + + const serialized = JSON.stringify({ + response: rendered.response, + files: rendered.files + }); + const renderedHandoff = rendered.files.find( + (file) => file.path === INSPIREDESIGN_HANDOFF_FILES.designAgentHandoff + )?.content as { + artifactGuide: Record; + implementationContext: { referenceSynthesis: { requiredArtifacts: string[] } }; + } | undefined; + expect(serialized).not.toContain(INSPIREDESIGN_HANDOFF_FILES.prototypeGuidance); + expect(serialized).not.toContain("Prototype guidance Markdown for the first HTML pass"); + expect(serialized).not.toContain("Use only for the first prototype pass"); + expect(renderedHandoff?.artifactGuide[INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest]).toBeUndefined(); + expect(renderedHandoff?.implementationContext.referenceSynthesis.requiredArtifacts).not.toContain( + INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest + ); + expect(JSON.stringify(renderedHandoff)).not.toContain(INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest); + expect(rendered.files.some((file) => file.path === INSPIREDESIGN_HANDOFF_FILES.prototypeGuidance)).toBe(false); + expect(rendered.files.some((file) => file.path === INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest)).toBe(false); + if (mode === "json") { + expect(rendered.response).not.toHaveProperty("canvasPlanRequest"); + } + if (mode === "context") { + expect((rendered.response as { context?: Record }).context).not.toHaveProperty("canvasPlanRequest"); + } + } + ); + + it("scrubs not-ready handoffs even when optional guide entries or required artifacts are malformed", () => { + const packet = buildInspiredesignPacket({ + brief: "Create a photography studio landing page", + briefExpansion: makeBriefExpansion(), + urls: ["https://example.com/blocked"], + references: [], + includePrototypeGuidance: true + }); + const designAgentHandoff = { + ...packet.followthrough, + artifactGuide: { + ...packet.followthrough.artifactGuide, + "diagnostic-extra.json": undefined as never + }, + implementationContext: { + ...packet.followthrough.implementationContext, + referenceSynthesis: { + ...packet.followthrough.implementationContext.referenceSynthesis, + requiredArtifacts: "canvas-plan.request.json" as never + } + } + }; + const nextStepGuidance = { + id: "inspiredesign.harvest.zero_ranked_references", + recipeType: "evidence_recovery", + workflow: "inspiredesign", + severity: "warning", + readiness: "needs_recovery", + reasonCode: "zero_ranked_references", + primaryAction: { + id: "recover_reference_evidence", + label: "Recover reference evidence", + summary: "Collect usable reference evidence before Canvas." + }, + commands: [], + paramsExamples: [], + fieldExamples: [], + artifactInputs: [], + validationChecks: [], + fallbackPolicy: { allowed: false, requiresUserConfirmation: true, reason: "Do not continue yet." }, + doNotProceedIf: ["reference_count is 0"] + } satisfies NextStepGuidance; + + const rendered = renderInspiredesign({ + mode: "json", + brief: "Create a photography studio landing page", + advancedBriefMarkdown: packet.advancedBriefMarkdown, + urls: ["https://example.com/blocked"], + designContract: packet.designContract, + canvasPlanRequest: packet.canvasPlanRequest, + designAgentHandoff, + generationPlan: packet.generationPlan, + implementationPlan: packet.implementationPlan, + designMarkdown: packet.designMarkdown, + implementationPlanMarkdown: packet.implementationPlanMarkdown, + prototypeGuidanceMarkdown: packet.prototypeGuidanceMarkdown, + evidence: packet.evidence, + nextStepGuidance, + meta: {} + }); + + const response = rendered.response as { + designAgentHandoff: { + artifactGuide: Record; + implementationContext: { referenceSynthesis: { requiredArtifacts: string[] } }; + }; + }; + expect(response.designAgentHandoff.artifactGuide).not.toHaveProperty("diagnostic-extra.json"); + expect(response.designAgentHandoff.implementationContext.referenceSynthesis.requiredArtifacts).toEqual([]); + expect(JSON.stringify(response.designAgentHandoff)).not.toContain(INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest); + expect(rendered.files.some((file) => file.path === INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest)).toBe(false); }); it("does not label Canvas request artifacts as ready when attempted references are diagnostic-only", () => { diff --git a/tests/providers-inspiredesign-workflow.test.ts b/tests/providers-inspiredesign-workflow.test.ts index b54c03a..d452812 100644 --- a/tests/providers-inspiredesign-workflow.test.ts +++ b/tests/providers-inspiredesign-workflow.test.ts @@ -422,7 +422,10 @@ describe("inspiredesign workflow", () => { const artifactPath = String(output.artifact_path); expectArtifactPath(artifactPath, join(workspaceDir, ".opendevbrowser"), "inspiredesign"); - expect(readFileSync(join(artifactPath, "canvas-plan.request.json"), "utf8")).toContain("canvasSessionId"); + expect(existsSync(join(artifactPath, "canvas-plan.request.json"))).toBe(false); + expect(readFileSync(join(artifactPath, "design-agent-handoff.json"), "utf8")).toContain( + "Unavailable until nextStepGuidance.readiness is ready." + ); }); it("defaults direct harvest workflow callers to path output and required visual evidence", async () => { @@ -943,6 +946,24 @@ describe("inspiredesign workflow", () => { providers: ["web/default"], urls: ["https://www.pinterest.com/pin/27654985208435505/"] })).rejects.toThrow("Provider web/default does not support URL-only site recipe recovery"); + await expect(runInspiredesignWorkflow(runtime, { + brief: "Design a visual harvest landing page", + harvest: true, + query: "premium references", + providers: ["social/pinterest"], + urls: ["https://www.pinterest.com/search/pins/?q=studio"] + })).rejects.toThrow( + "URL https://www.pinterest.com/search/pins?q=studio is not a canonical social/pinterest reference URL for provider-scoped recovery." + ); + await expect(runInspiredesignWorkflow(runtime, { + brief: "Design a visual harvest landing page", + harvest: true, + query: "premium references", + providers: ["social/pinterest", "web/default"], + urls: ["https://pinterest.example.com/pin/27654985208435505/"] + })).rejects.toThrow( + "URL https://pinterest.example.com/pin/27654985208435505 is not a canonical social/pinterest reference URL for provider-scoped recovery." + ); await expect(runInspiredesignWorkflow(runtime, { brief: "Design a visual harvest landing page", harvest: true, @@ -1634,6 +1655,75 @@ describe("inspiredesign workflow", () => { })); }); + it("rejects Pinterest search-shell URLs returned by the standard lane in mixed provider harvests", async () => { + const search = vi.fn(async () => makeAggregate({ + records: [ + normalizeRecord("web/default", "web", { + url: "https://www.pinterest.com/search/pins/?q=premium+photography+studio", + title: "Pinterest search shell from web", + content: "Pinterest search chrome with no canonical reference." + }), + normalizeRecord("web/default", "web", { + url: "https://example.com/studio-reference", + title: "Photography studio landing page", + content: "A premium photography studio landing page with cinematic portrait imagery, parallax sections, booking CTA, and editorial motion cues." + }) + ] + })); + const fetch = vi.fn(async (input: { url: string }) => { + if (input.url.includes("/search/pins/")) { + return makeAggregate({ + records: [ + normalizeRecord("social/pinterest", "social", { + url: input.url, + title: "Pinterest mixed provider results", + content: 'Studio pin' + }) + ] + }); + } + return makeAggregate({ + records: [ + normalizeRecord("web/default", "web", { + url: input.url, + title: "Fetched studio reference", + content: "A premium photography studio landing page with cinematic portrait imagery, parallax sections, booking CTA, and editorial motion cues." + }) + ] + }); + }); + + const output = await runInspiredesignWorkflow(toRuntime({ fetch, search }), { + brief: "Design a cinematic photography studio landing page", + harvest: true, + query: "premium photography studio landing page", + providers: ["web/default", "social/pinterest"], + visualEvidence: "off", + mode: "json" + }, { + captureReference: async (url: string) => makeCapture(`Captured ${url}`) + }); + + const meta = output.meta as InspiredesignWorkflowMeta; + expect(meta.discovery?.acceptedUrls).toEqual([ + "https://www.pinterest.com/pin/61572719900827789/", + "https://example.com/studio-reference" + ]); + expect(meta.discovery?.acceptedUrls).not.toContain( + "https://www.pinterest.com/search/pins/?q=premium+photography+studio" + ); + expect(meta.discovery?.rejected).toEqual(expect.arrayContaining([ + expect.objectContaining({ + rawUrl: "https://www.pinterest.com/search/pins/?q=premium+photography+studio", + reason: "invalid_url" + }) + ])); + expect(fetch).not.toHaveBeenCalledWith( + { url: "https://www.pinterest.com/search/pins/?q=premium+photography+studio" }, + expect.objectContaining({ providerIds: ["web/default"] }) + ); + }); + it("blocks Canvas continuation when a mixed Pinterest lane has a hard auth failure", async () => { const search = vi.fn(async () => makeAggregate({ records: [ @@ -2206,9 +2296,7 @@ describe("inspiredesign workflow", () => { ]) }); expect(screenshotIndex.screenshots).toHaveLength(5); - expect(designMarkdown).toContain( - "Diagnostic `canvasPlanRequest` preview; do not submit to Canvas until next-step guidance is ready" - ); + expect(designMarkdown).toContain("Canvas plan request omitted until nextStepGuidance.readiness is ready."); expect(designMarkdown).not.toContain("Ready-to-fill `canvasPlanRequest` JSON for `canvas.plan.set`"); expect(handoff.commandExamples.continueInCanvas).toBe("Unavailable until nextStepGuidance.readiness is ready."); expect(handoff.nextStepGuidance.readiness).toBe("diagnostic_only"); @@ -2302,34 +2390,8 @@ describe("inspiredesign workflow", () => { })); }); - it("keeps Pinterest browser-native auth failures blocking when only unrelated explicit references succeed", async () => { - const fetch = vi.fn(async (input: { url: string }) => { - if (input.url.includes("/search/pins/")) { - return makeAggregate({ - ok: false, - records: [], - failures: [ - makeFailure("social/pinterest", "social", { - code: "auth", - message: "Pinterest requires login.", - reasonCode: "auth_required" - }) - ] - }); - } - return makeAggregate({ - records: [ - normalizeRecord("web/default", "web", { - url: input.url, - title: "Explicit unrelated reference", - content: "Premium ceramic coffee roaster landing page with warm product photography, editorial hero rhythm, and conversion CTA." - }) - ] - }); - }); - const captureReference = vi.fn(async (url: string) => makeCapture(`Captured ${url}`)); - - const output = await runInspiredesignWorkflow(toRuntime({ fetch }), { + it("rejects Pinterest provider query harvests with unrelated explicit references", async () => { + await expect(runInspiredesignWorkflow(toRuntime({}), { brief: "Design a coffee roaster landing page", harvest: true, query: "premium ceramic coffee roaster landing page design", @@ -2340,24 +2402,9 @@ describe("inspiredesign workflow", () => { cookiePolicyOverride: "required", visualEvidence: "off", mode: "json" - }, { - captureReference - }); - - const meta = output.meta as InspiredesignWorkflowMeta; - expect(meta.metrics).toEqual(expect.objectContaining({ - reference_count: 1, - fetched_references: 1, - captured_references: 1 - })); - expect(meta.metrics.reasonCodeDistribution).toEqual(expect.objectContaining({ - auth_required: 1 - })); - expect(meta.nextStepGuidance).toEqual(expect.objectContaining({ - readiness: "blocked", - reasonCode: "pinterest_browser_native_recovery" - })); - expect(meta.nextStepGuidance?.commands.map((command) => command.command).join("\n")).not.toContain("canvas.plan.set"); + })).rejects.toThrow( + "URL https://example.com/coffee-roaster-reference is not a canonical social/pinterest reference URL" + ); }); it("does not cap explicit non-harvest URLs when visual evidence is enabled", async () => { diff --git a/tests/tools-workflows.test.ts b/tests/tools-workflows.test.ts index 151d1f3..bd4e624 100644 --- a/tests/tools-workflows.test.ts +++ b/tests/tools-workflows.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { mkdtemp, rm } from "fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "fs/promises"; import { tmpdir } from "os"; import { basename, dirname, join } from "path"; import { ConfigStore, resolveConfig } from "../src/config"; @@ -609,6 +609,120 @@ describe("workflow tools", () => { expect(deps.providerRuntime.fetch).toHaveBeenCalled(); }); + it("keeps direct-tool diagnostic Pinterest harvest out of Canvas continuation", async () => { + const artifactRoot = await makeTempDir("odb-direct-inspiredesign-diagnostic-"); + const deps = makeDeps(); + deps.providerRuntime.fetch.mockResolvedValue({ + ok: false, + records: [], + trace: { requestId: "req", ts: "2026-02-16T00:00:00.000Z" }, + partial: false, + failures: [], + metrics: { attempted: 1, succeeded: 0, failed: 1, retries: 0, latencyMs: 1 }, + sourceSelection: "web", + providerOrder: ["social/pinterest"], + error: { + code: "provider_unavailable", + message: "Provider circuit is open", + provider: "social/pinterest", + source: "social" + } + }); + deps.manager.snapshot.mockResolvedValue({ + content: "Skip to content Your profile Pin card Home Updates Messages Accounts Settings", + refCount: 8, + warnings: [] + }); + deps.manager.clonePage.mockResolvedValue({ + component: "Skip to content Your profile Home Updates Messages Accounts Settings", + css: "", + warnings: [] + }); + deps.manager.clonePageHtmlWithOptions.mockResolvedValue({ html: "" }); + deps.manager.screenshot.mockImplementation(async (_sessionId: string, options?: { path?: string }) => { + if (options?.path) { + await writeFile(options.path, Buffer.from("png")); + } + return { path: options?.path, warnings: [] }; + }); + const { createInspiredesignRunTool } = await import("../src/tools/inspiredesign_run"); + const tool = createInspiredesignRunTool(deps as never); + + const response = parse(await tool.execute({ + brief: "Design a premium fashion studio landing page", + harvest: true, + providers: ["social/pinterest"], + urls: ["https://www.pinterest.com/pin/27654985208435505/"], + visualEvidence: "auto", + includePrototypeGuidance: true, + mode: "path", + outputDir: artifactRoot + } as never)); + + expect(response.ok).toBe(true); + expect(response.suggestedNextAction).toContain("Pinterest browser-native"); + expect(response.artifact_path).toEqual(expect.stringContaining(join(artifactRoot, "inspiredesign"))); + expect(response.meta).toEqual(expect.objectContaining({ + nextStepGuidance: expect.objectContaining({ + readiness: "diagnostic_only", + reasonCode: "pinterest_browser_native_recovery", + primaryAction: expect.objectContaining({ + id: "recover_reference_evidence", + summary: expect.stringContaining("Pinterest browser-native recipe") + }), + commands: expect.arrayContaining([ + expect.objectContaining({ + id: "inspiredesign-harvest-url-recovery", + command: expect.stringContaining("--provider social/pinterest --url") + }) + ]), + doNotProceedIf: expect.arrayContaining([ + "rankedReferences is empty", + "top ranked reference is diagnostic-only or off brief" + ]) + }) + })); + const artifactPath = response.artifact_path as string; + const rankedReferences = JSON.parse(await readFile(join(artifactPath, "ranked-references.json"), "utf8")) as { + references: unknown[]; + rejectedReferences: Array<{ diagnosticReasons?: string[]; capturedButRejectedReason?: string }>; + }; + expect(rankedReferences.references).toEqual([]); + expect(rankedReferences.rejectedReferences).toEqual([ + expect.objectContaining({ + diagnosticReasons: expect.arrayContaining(["interface_chrome_shell"]), + capturedButRejectedReason: expect.stringContaining("interface_chrome_shell") + }) + ]); + const handoff = JSON.parse(await readFile(join(artifactPath, "design-agent-handoff.json"), "utf8")) as { + artifactGuide: Record; + commandExamples: { continueInCanvas: string }; + implementationContext: { referenceSynthesis: { requiredArtifacts: string[] } }; + nextStepGuidance: { readiness: string; commands: Array<{ command: string }> }; + }; + expect(handoff.commandExamples.continueInCanvas).toBe("Unavailable until nextStepGuidance.readiness is ready."); + expect(handoff.artifactGuide).not.toHaveProperty("canvas-plan.request.json"); + expect(handoff.artifactGuide).not.toHaveProperty("prototype-guidance.md"); + expect(handoff.implementationContext.referenceSynthesis.requiredArtifacts).not.toContain("canvas-plan.request.json"); + expect(handoff.implementationContext.referenceSynthesis.requiredArtifacts).not.toContain("prototype-guidance.md"); + expect(JSON.stringify(handoff)).not.toContain("canvas-plan.request.json"); + expect(handoff.nextStepGuidance.readiness).toBe("diagnostic_only"); + expect(handoff.nextStepGuidance.commands[0]?.command).toContain("--provider social/pinterest --url"); + expect(handoff.nextStepGuidance.commands[0]?.command).not.toContain("--query"); + const designMarkdown = await readFile(join(artifactPath, "design.md"), "utf8"); + expect(designMarkdown).toContain("Prototype guidance omitted because next-step guidance is not ready."); + expect(designMarkdown).not.toContain("Prototype guidance Markdown for the first HTML pass"); + expect(designMarkdown).not.toContain("## 6.1 Reference Anchors"); + const manifest = JSON.parse(await readFile(join(artifactPath, "bundle-manifest.json"), "utf8")) as { files: string[] }; + expect(manifest.files).toEqual(expect.arrayContaining([ + "ranked-references.json", + "design-agent-handoff.json", + "bundle-manifest.json" + ])); + expect(manifest.files).not.toContain("canvas-plan.request.json"); + expect(manifest.files).not.toContain("prototype-guidance.md"); + }); + it("rejects inspiredesign tool providers without a query or compatible URL", async () => { const deps = makeDeps(); const { createInspiredesignRunTool } = await import("../src/tools/inspiredesign_run"); @@ -641,6 +755,52 @@ describe("workflow tools", () => { }); }); + it("rejects direct-tool Pinterest query harvests with non-canonical explicit URLs", async () => { + const deps = makeDeps(); + const { createInspiredesignRunTool } = await import("../src/tools/inspiredesign_run"); + const tool = createInspiredesignRunTool(deps as never); + + const searchUrlResponse = parse(await tool.execute({ + brief: "Design a premium docs website", + harvest: true, + query: "studio references", + providers: ["social/pinterest"], + urls: ["https://www.pinterest.com/search/pins/?q=studio"] + } as never)); + + expect(searchUrlResponse.ok).toBe(false); + expect(searchUrlResponse.error).toEqual({ + code: "inspiredesign_run_failed", + message: "URL https://www.pinterest.com/search/pins/?q=studio is not a canonical social/pinterest reference URL for provider-scoped recovery." + }); + expect(deps.providerRuntime.search).not.toHaveBeenCalled(); + + const aliasResponse = parse(await tool.execute({ + brief: "Design a premium docs website", + harvest: true, + query: "studio references", + providers: ["pinterest"], + urls: ["https://www.pinterest.com/search/pins/?q=studio"] + } as never)); + + expect(aliasResponse.ok).toBe(false); + expect(aliasResponse.error).toEqual(searchUrlResponse.error); + + const unrelatedReferenceResponse = parse(await tool.execute({ + brief: "Design a premium docs website", + harvest: true, + query: "studio references", + providers: ["social/pinterest"], + urls: ["https://example.com/pin/27654985208435505/"] + } as never)); + + expect(unrelatedReferenceResponse.ok).toBe(false); + expect(unrelatedReferenceResponse.error).toEqual({ + code: "inspiredesign_run_failed", + message: "URL https://example.com/pin/27654985208435505/ is not a canonical social/pinterest reference URL for provider-scoped recovery." + }); + }); + it("rejects inspiredesign tool harvest without query or URLs", async () => { const deps = makeDeps(); const { createInspiredesignRunTool } = await import("../src/tools/inspiredesign_run");