Templating.

This commit is contained in:
Christian Gunderman
2026-03-20 14:34:16 -07:00
parent 81f978abeb
commit aced1dec56
2 changed files with 115 additions and 29 deletions
@@ -5,7 +5,7 @@
*/
import { describe, expect, it } from 'vitest';
import { promptComponent, toPrompt, enabledWhen, section, xmlSection, each, switchOn } from './promptTemplating.js';
import { promptComponent, promptTemplate, renderSnippet, renderTemplate, enabledWhen, section, xmlSection, each, switchOn, type Snippet, type PromptTemplateBase } from './promptTemplating.js';
// Sample prompt components.
type MainPromptOptions = { isInteractive: boolean };
@@ -37,60 +37,113 @@ const emptyMarkdownPrompt = section('Rules', '');
const customHeaderMarkdownPrompt = section('Rules', 'Be helpful', { headerLevel: 3 });
const eachPrompt = each<{ items: string[] }>('items', (item) => `Item: ${item}`);
const eachPrompt = each<{ items: string[] }, string>('items', (item: string) => `Item: ${item}`);
const switchOnPrompt = switchOn<{ mode: string }>('mode', {
const eachCustomSeparatorPrompt = each<{ items: string[] }, string>('items', (item: string) => item, ', ');
const switchOnPrompt = switchOn<{ mode: string | number }>('mode', {
fast: 'Speed is key',
safe: 'Safety first',
1: 'Mode one',
});
// A high-level template.
interface SystemPromptOptions { name: string }
interface SystemPromptTemplate extends PromptTemplateBase<SystemPromptOptions> {
identity: Snippet<SystemPromptOptions>;
instructions: Snippet<SystemPromptOptions>;
}
const mainTemplate = promptTemplate<SystemPromptOptions, SystemPromptTemplate>({
identity: (opt: SystemPromptOptions) => `Your name is ${opt.name}`,
instructions: 'Be helpful',
});
describe('promptTemplating', () => {
it('should take variadic arguments', () => {
expect(toPrompt({}, variadicPrompt)).toBe('A,B,C');
expect(renderSnippet({}, variadicPrompt)).toBe('A,B,C');
});
it('should handle nested arrays (variadic with arrays)', () => {
expect(toPrompt({}, nestedVariadicPrompt)).toBe('A,B,C,D');
expect(renderSnippet({}, nestedVariadicPrompt)).toBe('A,B,C,D');
});
it('should handle enabledWhen', () => {
expect(toPrompt({ show: true }, enabledWhenPrompt)).toBe('Always,Sometimes');
expect(toPrompt({ show: false }, enabledWhenPrompt)).toBe('Always,');
expect(renderSnippet({ show: true }, enabledWhenPrompt)).toBe('Always,Sometimes');
expect(renderSnippet({ show: false }, enabledWhenPrompt)).toBe('Always,');
});
it('should handle xmlSection', () => {
expect(toPrompt({}, xmlPrompt)).toBe('<rules>\nBe helpful\n</rules>');
expect(renderSnippet({}, xmlPrompt)).toBe('<rules>\nBe helpful\n</rules>');
});
it('should return empty string for xmlSection if content is empty', () => {
expect(toPrompt({}, emptyXmlPrompt)).toBe('');
expect(renderSnippet({}, emptyXmlPrompt)).toBe('');
});
it('should handle markdown section with default header', () => {
expect(toPrompt({}, markdownPrompt)).toBe('# Rules\n\nBe helpful');
expect(renderSnippet({}, markdownPrompt)).toBe('# Rules\n\nBe helpful');
});
it('should return empty string for markdown section if content is empty', () => {
expect(toPrompt({}, emptyMarkdownPrompt)).toBe('');
expect(renderSnippet({}, emptyMarkdownPrompt)).toBe('');
});
it('should handle markdown section with custom header level', () => {
expect(toPrompt({}, customHeaderMarkdownPrompt)).toBe('### Rules\n\nBe helpful');
expect(renderSnippet({}, customHeaderMarkdownPrompt)).toBe('### Rules\n\nBe helpful');
});
it('should handle each', () => {
const options = { items: ['A', 'B'] };
expect(toPrompt(options, eachPrompt)).toBe('Item: A\nItem: B');
expect(renderSnippet(options, eachPrompt)).toBe('Item: A\nItem: B');
});
it('should handle switchOn', () => {
expect(toPrompt({ mode: 'fast' }, switchOnPrompt)).toBe('Speed is key');
expect(toPrompt({ mode: 'safe' }, switchOnPrompt)).toBe('Safety first');
expect(toPrompt({ mode: 'unknown' }, switchOnPrompt)).toBe('');
expect(renderSnippet({ mode: 'fast' }, switchOnPrompt)).toBe('Speed is key');
expect(renderSnippet({ mode: 'safe' }, switchOnPrompt)).toBe('Safety first');
expect(renderSnippet({ mode: 'unknown' }, switchOnPrompt)).toBe('');
});
it('should handle switchOn with numeric values', () => {
expect(renderSnippet({ mode: 1 }, switchOnPrompt)).toBe('Mode one');
});
it('should handle each with custom separator', () => {
const options = { items: ['A', 'B'] };
expect(renderSnippet(options, eachCustomSeparatorPrompt)).toBe('A, B');
});
it('should handle each with non-array value', () => {
const options = { items: 'not an array' as unknown as string[] };
expect(renderSnippet(options, eachPrompt)).toBe('');
});
it('should handle section with different header levels', () => {
const h2Section = section('Level 2', 'Content', { headerLevel: 2 });
const h6Section = section('Level 6', 'Content', { headerLevel: 6 });
expect(renderSnippet({}, h2Section)).toBe('## Level 2\n\nContent');
expect(renderSnippet({}, h6Section)).toBe('###### Level 6\n\nContent');
});
it('should handle toPrompt with basic types', () => {
expect(renderSnippet({}, 'just a string')).toBe('just a string');
expect(renderSnippet({ name: 'test' }, (opt: { name: string }) => opt.name)).toBe('test');
});
it('should handle toPrompt with empty array or object', () => {
expect(renderSnippet({}, [])).toBe('');
expect(renderSnippet({}, {})).toBe('');
});
it('should handle renderTemplate with a record of snippets (implements required members)', () => {
const options = {
name: 'Gemini CLI',
};
expect(renderTemplate(options, mainTemplate)).toBe('Your name is Gemini CLI,Be helpful');
});
it('should output correctly for mainPrompt', () => {
const text = toPrompt({ isInteractive: true }, mainPrompt);
const text = renderSnippet({ isInteractive: true }, mainPrompt);
expect(text).toContain('Your name is Gemini CLI and you are a coding agent');
expect(text).toContain('You are operating in an interactive session');
});
+45 -12
View File
@@ -6,19 +6,35 @@
export type DynamicSnippet<TOption> = (options: TOption) => string;
export type Snippet<TOption> = string | DynamicSnippet<TOption> | Array<Snippet<TOption>>;
export type Snippet<TOption> =
| string
| DynamicSnippet<TOption>
| Array<Snippet<TOption>>
| PromptTemplateBase<TOption>;
export interface PromptTemplateBase<TOption> {
[key: string]: Snippet<TOption>;
}
export type PromptTemplate<TOption, TTemplate extends PromptTemplateBase<TOption>> = TTemplate;
export function promptTemplate<TOption, TTemplate extends PromptTemplateBase<TOption>>(
template: TTemplate
): PromptTemplate<TOption, TTemplate> {
return template;
}
export function promptComponent<TOption>(...snippets: Array<Snippet<TOption>>): Snippet<TOption> {
return snippets;
}
export function enabledWhen<TOption>(optionName: keyof TOption, snippet: Snippet<TOption>): Snippet<TOption> {
return (option: TOption) => option[optionName] ? toPrompt(option, snippet) : '';
return (option: TOption) => (option[optionName] ? renderSnippet(option, snippet) : '');
}
export function xmlSection<TOption>(name: string, snippet: Snippet<TOption>): Snippet<TOption> {
return (options: TOption) => {
const content = toPrompt(options, snippet);
const content = renderSnippet(options, snippet);
return content ? `<${name}>\n${content}\n</${name}>` : '';
};
}
@@ -27,24 +43,28 @@ export interface SectionOptions {
headerLevel?: number;
}
export function section<TOption>(name: string, snippet: Snippet<TOption>, sectionOptions?: SectionOptions): Snippet<TOption> {
export function section<TOption>(
name: string,
snippet: Snippet<TOption>,
sectionOptions?: SectionOptions
): Snippet<TOption> {
return (options: TOption) => {
const content = toPrompt(options, snippet);
const content = renderSnippet(options, snippet);
const level = sectionOptions?.headerLevel ?? 1;
const hashes = '#'.repeat(level);
return content ? `${hashes} ${name}\n\n${content}` : '';
};
}
export function each<TOption>(
export function each<TOption, TItem = unknown>(
optionName: keyof TOption,
snippet: Snippet<TOption>,
snippet: Snippet<TItem>,
separator = '\n'
): Snippet<TOption> {
return (options: TOption) => {
const items = options[optionName];
if (Array.isArray(items)) {
return items.map((item: any) => toPrompt(item, snippet)).join(separator);
return items.map((item: TItem) => renderSnippet(item, snippet)).join(separator);
}
return '';
};
@@ -57,16 +77,29 @@ export function switchOn<TOption>(
return (options: TOption) => {
const value = String(options[optionName]);
const caseSnippet = cases[value];
return caseSnippet ? toPrompt(options, caseSnippet) : '';
return caseSnippet ? renderSnippet(options, caseSnippet) : '';
};
}
export function toPrompt<TOption>(options: TOption, snippet: Snippet<TOption>): string {
export function renderTemplate<TOption, TTemplate extends PromptTemplateBase<TOption>>(
options: TOption,
implementation: TTemplate
): string {
return Object.values(implementation)
.map(eachSnippet => renderSnippet<TOption>(options, eachSnippet))
.join();
}
export function renderSnippet<TOption>(options: TOption, snippet: Snippet<TOption>): string {
if (typeof snippet === 'string') {
return snippet;
} else if (Array.isArray(snippet)) {
return snippet.map(eachSnippet => toPrompt<TOption>(options, eachSnippet)).join();
} else {
return snippet.map(eachSnippet => renderSnippet<TOption>(options, eachSnippet)).join();
} else if (typeof snippet === 'function') {
return snippet(options);
} else {
return Object.values(snippet)
.map(eachSnippet => renderSnippet<TOption>(options, eachSnippet))
.join();
}
}