feat(prompts-core): add prompt loader
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: Test Prompt
|
||||
enabled: true
|
||||
---
|
||||
Default prompt body with {X}.
|
||||
Second line remains verbatim.
|
||||
@@ -0,0 +1,3 @@
|
||||
---
|
||||
---
|
||||
GPT prompt body with {A} and {B}.
|
||||
@@ -8,3 +8,4 @@ export type {
|
||||
} from "./types"
|
||||
export { resolveVariant } from "./variant-resolver"
|
||||
export type { ResolveVariantInput } from "./variant-resolver"
|
||||
export { loadPrompt, PromptFileNotFoundError } from "./loader"
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { loadPrompt, PromptFileNotFoundError } from "./loader"
|
||||
import type { PromptSource } from "./types"
|
||||
|
||||
const fixtureSource: PromptSource = {
|
||||
baseDir: join(dirname(fileURLToPath(import.meta.url)), "__test_fixtures__"),
|
||||
}
|
||||
|
||||
class ResolverFailureError extends Error {
|
||||
readonly name = "ResolverFailureError"
|
||||
}
|
||||
|
||||
class ExpectedErrorMissingError extends Error {
|
||||
readonly name = "ExpectedErrorMissingError"
|
||||
}
|
||||
|
||||
describe("loadPrompt", () => {
|
||||
test("#given markdown fixture #then returns markdown body verbatim", async () => {
|
||||
const prompt = await loadPrompt({ source: fixtureSource, name: "test-prompt", variant: "default" })
|
||||
|
||||
expect(prompt.body).toBe("Default prompt body with {X}.\nSecond line remains verbatim.\n")
|
||||
})
|
||||
|
||||
test("#given frontmatter fixture #then returns parsed frontmatter", async () => {
|
||||
const prompt = await loadPrompt<{ readonly title: string; readonly enabled: boolean }>({
|
||||
source: fixtureSource,
|
||||
name: "test-prompt",
|
||||
variant: "default",
|
||||
})
|
||||
|
||||
expect(prompt.frontmatter.title).toBe("Test Prompt")
|
||||
expect(prompt.frontmatter.enabled).toBe(true)
|
||||
})
|
||||
|
||||
test("#given empty frontmatter #then parses without crashing", async () => {
|
||||
const prompt = await loadPrompt({ source: fixtureSource, name: "test-prompt", variant: "gpt" })
|
||||
|
||||
expect(prompt.frontmatter).toEqual({})
|
||||
expect(prompt.body).toBe("GPT prompt body with {A} and {B}.\n")
|
||||
})
|
||||
|
||||
test("#given missing file #then error mentions prompt name and variant", async () => {
|
||||
const error = await captureError(() =>
|
||||
loadPrompt({ source: fixtureSource, name: "test-prompt", variant: "missing" })
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(PromptFileNotFoundError)
|
||||
expect(expectError(error).message).toContain("test-prompt/missing")
|
||||
})
|
||||
|
||||
test("#given runtime injection #then replaces placeholder in body", async () => {
|
||||
const prompt = await loadPrompt({
|
||||
source: fixtureSource,
|
||||
name: "test-prompt",
|
||||
variant: "default",
|
||||
inject: [{ placeholder: "{X}", resolver: () => "Y" }],
|
||||
})
|
||||
|
||||
expect(prompt.body).toBe("Default prompt body with Y.\nSecond line remains verbatim.\n")
|
||||
})
|
||||
|
||||
test("#given multiple runtime injections #then applies all and ignores absent placeholders", async () => {
|
||||
const prompt = await loadPrompt({
|
||||
source: fixtureSource,
|
||||
name: "test-prompt",
|
||||
variant: "gpt",
|
||||
inject: [
|
||||
{ placeholder: "{A}", resolver: () => "Alpha" },
|
||||
{ placeholder: "{B}", resolver: () => "Beta" },
|
||||
{ placeholder: "{ABSENT}", resolver: () => "No-op" },
|
||||
],
|
||||
})
|
||||
|
||||
expect(prompt.body).toBe("GPT prompt body with Alpha and Beta.\n")
|
||||
})
|
||||
|
||||
test("#given injection resolver throws #then propagates the error", async () => {
|
||||
const error = await captureError(() =>
|
||||
loadPrompt({
|
||||
source: fixtureSource,
|
||||
name: "test-prompt",
|
||||
variant: "default",
|
||||
inject: [
|
||||
{
|
||||
placeholder: "{X}",
|
||||
resolver: () => {
|
||||
throw new ResolverFailureError("resolver failed")
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(ResolverFailureError)
|
||||
})
|
||||
})
|
||||
|
||||
async function captureError(operation: () => Promise<unknown>): Promise<unknown> {
|
||||
try {
|
||||
await operation()
|
||||
return undefined
|
||||
} catch (error) {
|
||||
return error
|
||||
}
|
||||
}
|
||||
|
||||
function expectError(error: unknown): Error {
|
||||
if (error instanceof Error) return error
|
||||
throw new ExpectedErrorMissingError("Expected operation to throw an Error instance")
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { parseFrontmatter } from "@oh-my-opencode/utils"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { join } from "node:path"
|
||||
import type { LoadedPrompt, LoadPromptInput, RuntimeInjection } from "./types"
|
||||
|
||||
export class PromptFileNotFoundError extends Error {
|
||||
readonly name = "PromptFileNotFoundError"
|
||||
|
||||
constructor(
|
||||
readonly promptName: string,
|
||||
readonly variant: string,
|
||||
readonly filePath: string,
|
||||
options?: ErrorOptions
|
||||
) {
|
||||
super(`Prompt file not found for ${promptName}/${variant}: ${filePath}`, options)
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadPrompt<TFrontmatter = Record<string, unknown>>(
|
||||
input: LoadPromptInput
|
||||
): Promise<LoadedPrompt<TFrontmatter>> {
|
||||
const filePath = join(input.source.baseDir, input.name, `${input.variant}.md`)
|
||||
const content = await readPromptFile(input.name, input.variant, filePath)
|
||||
const parsed = parseFrontmatter<TFrontmatter>(content)
|
||||
const body = await applyRuntimeInjections(parsed.body, input.inject ?? [])
|
||||
|
||||
return {
|
||||
frontmatter: parsed.data,
|
||||
body,
|
||||
hadFrontmatter: parsed.hadFrontmatter,
|
||||
parseError: parsed.parseError,
|
||||
filePath,
|
||||
}
|
||||
}
|
||||
|
||||
async function readPromptFile(promptName: string, variant: string, filePath: string): Promise<string> {
|
||||
try {
|
||||
return await readFile(filePath, "utf8")
|
||||
} catch (error) {
|
||||
if (error instanceof Error && getErrorCode(error) === "ENOENT") {
|
||||
throw new PromptFileNotFoundError(promptName, variant, filePath, { cause: error })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function applyRuntimeInjections(
|
||||
body: string,
|
||||
injections: readonly RuntimeInjection[]
|
||||
): Promise<string> {
|
||||
let renderedBody = body
|
||||
for (const injection of injections) {
|
||||
renderedBody = renderedBody.replaceAll(injection.placeholder, await injection.resolver())
|
||||
}
|
||||
return renderedBody
|
||||
}
|
||||
|
||||
function getErrorCode(error: Error): string | undefined {
|
||||
if (!("code" in error)) return undefined
|
||||
return typeof error.code === "string" ? error.code : undefined
|
||||
}
|
||||
Reference in New Issue
Block a user