From ca4e36adc00e7e82188bf0a77622fd35be611410 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 11 Jun 2026 16:25:26 -0400 Subject: [PATCH] fix(config): parse markdown prompt file blocks as prompts (#12412) --- packages/config-yaml/src/load/unroll.test.ts | 129 ++++++++++++++++++ packages/config-yaml/src/load/unroll.ts | 29 +++- packages/config-yaml/src/markdown/index.ts | 1 + .../src/markdown/parseMarkdownPrompt.ts | 37 +++++ 4 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 packages/config-yaml/src/markdown/parseMarkdownPrompt.ts diff --git a/packages/config-yaml/src/load/unroll.test.ts b/packages/config-yaml/src/load/unroll.test.ts index 3357e9cfc3e..daf9a6536fb 100644 --- a/packages/config-yaml/src/load/unroll.test.ts +++ b/packages/config-yaml/src/load/unroll.test.ts @@ -1,8 +1,10 @@ import { PackageIdentifier } from "../interfaces/slugs.js"; +import type { Registry } from "../interfaces/index.js"; import { fillTemplateVariables, getTemplateVariables, parseMarkdownRuleOrAssistantUnrolled, + unrollBlocks, replaceInputsWithSecrets, } from "./unroll.js"; @@ -94,6 +96,133 @@ model: # should be models }); }); +describe("unrollBlocks markdown file blocks", () => { + it("unrolls markdown prompt file blocks into prompts", async () => { + const registry: Registry = { + async getContent(fullSlug) { + if ( + fullSlug.uriType === "file" && + fullSlug.fileUri === "prompts/scotty.md" + ) { + return ` +--- +name: Scotty +description: Engineering prompt +--- +Use concise engineering language. +`; + } + + throw new Error("Unexpected registry lookup"); + }, + }; + + const result = await unrollBlocks( + { + name: "Test Assistant", + version: "1.0.0", + prompts: [{ uses: "file://prompts/scotty.md" }], + }, + registry, + undefined, + ); + + expect(result.config).toBeDefined(); + expect(result.config!.prompts).toEqual([ + { + name: "Scotty", + description: "Engineering prompt", + prompt: "Use concise engineering language.", + sourceFile: "prompts/scotty.md", + }, + ]); + expect(result.config!.rules).toBeUndefined(); + }); + + it("uses markdown prompt filenames when frontmatter omits name", async () => { + const registry: Registry = { + async getContent(fullSlug) { + if ( + fullSlug.uriType === "file" && + fullSlug.fileUri === "prompts/fallback.md" + ) { + return ` +--- +description: Fallback prompt +--- +Use the fallback filename. +`; + } + + throw new Error("Unexpected registry lookup"); + }, + }; + + const result = await unrollBlocks( + { + name: "Test Assistant", + version: "1.0.0", + prompts: [{ uses: "file://prompts/fallback.md" }], + }, + registry, + undefined, + ); + + expect(result.config?.prompts?.[0]).toEqual({ + name: "fallback", + description: "Fallback prompt", + prompt: "Use the fallback filename.", + sourceFile: "prompts/fallback.md", + }); + }); + + it("keeps markdown rule blocks resolving as rules", async () => { + const registry: Registry = { + async getContent(fullSlug) { + if ( + fullSlug.uriType === "file" && + fullSlug.fileUri === "rules/concise.md" + ) { + return ` +--- +name: concise-rule +description: Keep replies concise +--- +Respond in short paragraphs. +`; + } + + throw new Error("Unexpected registry lookup"); + }, + }; + + const result = await unrollBlocks( + { + name: "Test Assistant", + version: "1.0.0", + rules: [{ uses: "file://rules/concise.md" }], + }, + registry, + undefined, + ); + + expect(result.config).toBeDefined(); + expect(result.config!.rules).toEqual([ + { + name: "concise-rule", + description: "Keep replies concise", + globs: undefined, + regex: undefined, + alwaysApply: undefined, + invokable: undefined, + rule: "Respond in short paragraphs.", + sourceFile: "rules/concise.md", + }, + ]); + expect(result.config!.prompts).toBeUndefined(); + }); +}); + describe("replaceInputsWithSecrets tests", () => { it("replaces single input with secret", () => { const yamlContent = ` diff --git a/packages/config-yaml/src/load/unroll.ts b/packages/config-yaml/src/load/unroll.ts index ac59d6c5fb7..71e894cb429 100644 --- a/packages/config-yaml/src/load/unroll.ts +++ b/packages/config-yaml/src/load/unroll.ts @@ -14,7 +14,7 @@ import { PackageSlug, packageSlugsEqual, } from "../interfaces/slugs.js"; -import { markdownToRule } from "../markdown/index.js"; +import { markdownToRule, parseMarkdownPrompt } from "../markdown/index.js"; import { AssistantUnrolled, assistantUnrolledSchema, @@ -437,6 +437,7 @@ export async function unrollBlocks( blockIdentifier, unrolledBlock.with, registry, + section, ); const block = blockConfigYaml[section]?.[0]; if (block) { @@ -720,6 +721,7 @@ export async function resolveBlock( id: PackageIdentifier, inputs: Record | undefined, registry: Registry, + sectionHint?: keyof AssistantUnrolled, ): Promise { // Retrieve block raw yaml const rawYaml = await registry.getContent(id); @@ -753,7 +755,11 @@ export async function resolveBlock( } // Add source slug for mcp servers - const parsed = parseMarkdownRuleOrAssistantUnrolled(templatedYaml, id); + const parsed = parseMarkdownRuleOrAssistantUnrolled( + templatedYaml, + id, + sectionHint, + ); if ( id.uriType === "slug" && "mcpServers" in parsed && @@ -768,8 +774,14 @@ export async function resolveBlock( export function parseMarkdownRuleOrAssistantUnrolled( content: string, id: PackageIdentifier, + sectionHint?: keyof AssistantUnrolled, ): AssistantUnrolled { - return parseYamlOrMarkdownRule(content, id, parseBlock); + return parseYamlOrMarkdownRule( + content, + id, + parseBlock, + sectionHint, + ); } function parseMarkdownRuleOrConfigYaml( @@ -783,6 +795,7 @@ function parseYamlOrMarkdownRule( content: string, id: PackageIdentifier, parseYamlFn: (content: string) => T, + sectionHint?: keyof AssistantUnrolled, ): T { let parsedYaml: T; try { @@ -797,6 +810,16 @@ function parseYamlOrMarkdownRule( } // If YAML parsing fails, try parsing as markdown rule try { + if (sectionHint === "prompts") { + const prompt = parseMarkdownPrompt(content, id); + parsedYaml = { + name: prompt.name, + version: "1.0.0", + prompts: [prompt], + } as T; + return parsedYaml; + } + const rule = markdownToRule(content, id); // Convert the rule object to the expected format parsedYaml = { name: rule.name, version: "1.0.0", rules: [rule] } as T; diff --git a/packages/config-yaml/src/markdown/index.ts b/packages/config-yaml/src/markdown/index.ts index 6414936f598..5808f509be1 100644 --- a/packages/config-yaml/src/markdown/index.ts +++ b/packages/config-yaml/src/markdown/index.ts @@ -2,4 +2,5 @@ export * from "./createMarkdownPrompt.js"; export * from "./createMarkdownRule.js"; export * from "./getRuleType.js"; export * from "./markdownToRule.js"; +export * from "./parseMarkdownPrompt.js"; export * from "./agentFiles.js"; diff --git a/packages/config-yaml/src/markdown/parseMarkdownPrompt.ts b/packages/config-yaml/src/markdown/parseMarkdownPrompt.ts new file mode 100644 index 00000000000..5dcec1f496e --- /dev/null +++ b/packages/config-yaml/src/markdown/parseMarkdownPrompt.ts @@ -0,0 +1,37 @@ +import { + PackageIdentifier, + packageIdentifierToDisplayName, +} from "../browser.js"; +import { Prompt } from "../schemas/index.js"; +import { parseMarkdownRule, RuleFrontmatter } from "./markdownToRule.js"; + +function getPromptName( + frontmatter: RuleFrontmatter, + id: PackageIdentifier, +): string { + if (frontmatter.name) { + return frontmatter.name; + } + + if (id.uriType === "file") { + const segments = id.fileUri.split(/[/\\]/); + const basename = segments.at(-1) || id.fileUri; + return basename.replace(/\.md$/i, ""); + } + + return packageIdentifierToDisplayName(id); +} + +export function parseMarkdownPrompt( + content: string, + id: PackageIdentifier, +): Prompt { + const { frontmatter, markdown } = parseMarkdownRule(content); + + return { + name: getPromptName(frontmatter, id), + description: frontmatter.description, + prompt: markdown, + sourceFile: id.uriType === "file" ? id.fileUri : undefined, + }; +}