refactor(core): remove synchronous rendering from render-prompt

This commit is contained in:
Michael Bleigh
2026-03-21 16:09:07 -07:00
parent 8ed910051d
commit cfbee68dfd
2 changed files with 24 additions and 175 deletions
@@ -5,7 +5,7 @@
*/ */
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { renderPrompt, renderPromptSync, p } from './render-prompt.js'; import { renderPrompt, p } from './render-prompt.js';
import type { PromptContent } from './render-prompt.js'; import type { PromptContent } from './render-prompt.js';
type TestContext = { name?: string; shouldRender?: boolean }; type TestContext = { name?: string; shouldRender?: boolean };
@@ -350,24 +350,3 @@ describe('renderPrompt', () => {
expect(result).toBe(test.expect); expect(result).toBe(test.expect);
}); });
}); });
describe('renderPromptSync', () => {
const syncTests = tests.filter(
(t) =>
!t.desc.includes('async') &&
!t.desc.includes('Promise') &&
!t.desc.includes('resolves recursive async functions') &&
!t.desc.includes('async condition'),
);
it.each(syncTests)('$desc', (test) => {
const result = renderPromptSync({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
content: test.content as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
contributions: test.contributions as any,
context: test.context,
});
expect(result).toBe(test.expect);
});
});
+23 -153
View File
@@ -6,17 +6,11 @@
import type { SystemPromptOptions } from './snippets.js'; import type { SystemPromptOptions } from './snippets.js';
type MaybePromise<T, Sync extends boolean> = Sync extends true export type ContextResolver<C, O> = O | ((ctx: C) => O | Promise<O>);
? T
: T | Promise<T>;
export type ContextResolver<C, O, Sync extends boolean = false> =
| O
| ((ctx: C) => MaybePromise<O, Sync>);
export type PromptSlot = { slot: string; content?: never }; export type PromptSlot = { slot: string; content?: never };
export type PromptSection<C, Sync extends boolean = false> = { export type PromptSection<C> = {
/** Add a Markdown heading of appropriate level to this section. */ /** Add a Markdown heading of appropriate level to this section. */
heading?: string; heading?: string;
/** If supplied, wrap this section in an XML tag. */ /** If supplied, wrap this section in an XML tag. */
@@ -32,32 +26,28 @@ export type PromptSection<C, Sync extends boolean = false> = {
| ((parts: string[]) => string); | ((parts: string[]) => string);
/** Condition that must evaluate to true for the section to be rendered. */ /** Condition that must evaluate to true for the section to be rendered. */
condition?: boolean | ((ctx: C) => MaybePromise<boolean, Sync>); condition?: boolean | ((ctx: C) => boolean | Promise<boolean>);
content: PromptContent<C, Sync>; content: PromptContent<C>;
/** Alternate content to render if the primary content resolves to a falsy value. */ /** Alternate content to render if the primary content resolves to a falsy value. */
fallback?: PromptContent<C, Sync>; fallback?: PromptContent<C>;
}; };
// The core recursive type. // The core recursive type.
// It wraps your 3 base node shapes (string, section, or array) in the resolver. // It wraps your 3 base node shapes (string, section, or array) in the resolver.
export type PromptContent<C, Sync extends boolean = false> = ContextResolver< export type PromptContent<C> = ContextResolver<
C, C,
| string | string
| number | number
| boolean | boolean
| null | null
| undefined | undefined
| PromptSection<C, Sync> | PromptSection<C>
| PromptSlot | PromptSlot
| Array<PromptContent<C, Sync>>, | Array<PromptContent<C>>
Sync
>; >;
type BaseContent = string | StaticSection | PromptSlot | BaseContent[]; type BaseContent = string | StaticSection | PromptSlot | BaseContent[];
type StaticSection = Omit< type StaticSection = Omit<PromptSection<unknown>, 'condition' | 'content'> & {
PromptSection<unknown, boolean>,
'condition' | 'content'
> & {
content: BaseContent; content: BaseContent;
}; };
@@ -69,22 +59,22 @@ function renderAttributes(attrs?: Record<string, string>): string {
.join(''); .join('');
} }
export function p<C = SystemPromptOptions, Sync extends boolean = false>( export function p<C = SystemPromptOptions>(
strings: TemplateStringsArray, strings: TemplateStringsArray,
...values: Array<PromptContent<C, Sync>> ...values: Array<PromptContent<C>>
): PromptContent<C, Sync> { ): PromptContent<C> {
const content = strings.reduce<Array<PromptContent<C, Sync>>>( const content = strings.reduce<Array<PromptContent<C>>>(
(acc, str, i) => [...acc, str, values[i] ?? ''], (acc, str, i) => [...acc, str, values[i] ?? ''],
[], [],
); );
return { format: 'inline', content }; return { format: 'inline', content };
} }
export interface RenderPromptOptions<C, Sync extends boolean = false> { export interface RenderPromptOptions<C> {
content: PromptContent<C, Sync> | Array<PromptContent<C, Sync>>; content: PromptContent<C> | Array<PromptContent<C>>;
contributions?: contributions?:
| Record<string, PromptContent<C, Sync>> | Record<string, PromptContent<C>>
| Array<Record<string, PromptContent<C, Sync>>>; | Array<Record<string, PromptContent<C>>>;
context: C; context: C;
options?: { depth?: number }; options?: { depth?: number };
} }
@@ -163,9 +153,9 @@ export async function renderPrompt<C = SystemPromptOptions>({
contributions, contributions,
context, context,
options, options,
}: RenderPromptOptions<C, false>): Promise<string> { }: RenderPromptOptions<C>): Promise<string> {
const contents = Array.isArray(content) ? content : [content]; const contents = Array.isArray(content) ? content : [content];
const _contributions: Record<string, Array<PromptContent<C, false>>> = {}; const _contributions: Record<string, Array<PromptContent<C>>> = {};
if (contributions) { if (contributions) {
const batches = Array.isArray(contributions) const batches = Array.isArray(contributions)
@@ -180,7 +170,7 @@ export async function renderPrompt<C = SystemPromptOptions>({
} }
const resolveToBasic = async ( const resolveToBasic = async (
c: PromptContent<C, false>, c: PromptContent<C>,
): Promise<BaseContent | null> => { ): Promise<BaseContent | null> => {
if (c === undefined || c === null) return null; if (c === undefined || c === null) return null;
if (typeof c === 'function') { if (typeof c === 'function') {
@@ -269,128 +259,8 @@ export async function renderPrompt<C = SystemPromptOptions>({
return normalizeResult(rawResult); return normalizeResult(rawResult);
} }
export function renderPromptSync<C = SystemPromptOptions>({ export function prompt<C = SystemPromptOptions>(
content, ...content: Array<PromptContent<C>>
contributions, ): PromptContent<C> {
context,
options,
}: RenderPromptOptions<C, true>): string {
const contents = Array.isArray(content) ? content : [content];
const _contributions: Record<string, Array<PromptContent<C, true>>> = {};
if (contributions) {
const batches = Array.isArray(contributions)
? contributions
: [contributions];
for (const batch of batches) {
for (const [slot, c] of Object.entries(batch)) {
_contributions[slot] = _contributions[slot] || [];
_contributions[slot].push(c);
}
}
}
const resolveToBasicSync = (
c: PromptContent<C, true>,
): BaseContent | null => {
if (c === undefined || c === null) return null;
if (typeof c === 'function') {
const resolved = c(context);
// Extra safety check at runtime for JS users
if (resolved instanceof Promise) {
throw new Error(
'renderPromptSync encountered a Promise from a resolver function.',
);
}
return resolveToBasicSync(resolved);
}
if (typeof c === 'boolean') {
return null;
}
if (typeof c === 'string' || typeof c === 'number') {
const val = String(c);
return val === '' ? null : val;
}
if (Array.isArray(c)) {
const resolved = c.map((item) => resolveToBasicSync(item));
const filtered = resolved.filter(
(item): item is BaseContent => item !== null,
);
if (filtered.length === 0) return null;
return filtered;
}
if (typeof c === 'object' && c !== null) {
if ('slot' in c) {
return c;
}
const section = c;
if (section.condition !== undefined) {
let shouldRender;
if (typeof section.condition === 'function') {
shouldRender = section.condition(context);
if ((shouldRender as unknown) instanceof Promise) {
throw new Error(
'renderPromptSync encountered a Promise from a condition function.',
);
}
} else {
shouldRender = section.condition;
}
if (!shouldRender) return null;
}
let resolvedInner = resolveToBasicSync(section.content);
if (
resolvedInner === null ||
resolvedInner === '' ||
(Array.isArray(resolvedInner) && resolvedInner.length === 0)
) {
if (section.fallback !== undefined) {
resolvedInner = resolveToBasicSync(section.fallback);
}
}
if (
resolvedInner === null ||
resolvedInner === '' ||
(Array.isArray(resolvedInner) && resolvedInner.length === 0)
) {
return null;
}
return {
heading: section.heading,
tag: section.tag,
attrs: section.attrs,
format: section.format,
content: resolvedInner,
};
}
return null;
};
const resolvedContents = contents.map((c) => resolveToBasicSync(c));
const resolvedContributions: Record<string, BaseContent[]> = {};
for (const [slot, slotContributions] of Object.entries(_contributions)) {
const resolved = slotContributions.map((c) => resolveToBasicSync(c));
resolvedContributions[slot] = resolved.filter(
(c): c is BaseContent => c !== null,
);
}
const parts = resolvedContents
.map((c) =>
formatBasic(c, options?.depth ?? 1, 'block', resolvedContributions),
)
.filter((p) => p !== null && p !== '');
const rawResult = parts.join('\n\n').trim();
return normalizeResult(rawResult);
}
export function prompt<C = SystemPromptOptions, Sync extends boolean = false>(
...content: Array<PromptContent<C, Sync>>
): PromptContent<C, Sync> {
return content.length === 1 ? content[0] : content; return content.length === 1 ? content[0] : content;
} }