feat(core): 实现动态提示词模板系统
- 新增 prompt-template 模块,支持运行时变量替换
- 支持 ${variable}、${obj.prop}、${cond ? "a" : "b"} 语法
- AgentInfo 新增 promptTemplate 字段标记动态模板
- Plan Agent 提示词改用模板语法,支持动态工具名和计划文件路径
- AgentExecutor.buildSystemPrompt 集成模板渲染
- 新增 27 个单元测试验证模板功能
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
renderTemplate,
|
||||
createDefaultContext,
|
||||
createPlanContext,
|
||||
DEFAULT_TOOL_NAMES,
|
||||
generatePlanPrompt,
|
||||
resolveAgentPrompt,
|
||||
} from '../../../src/agent/prompt-template/index.js';
|
||||
import type { PromptContext } from '../../../src/agent/prompt-template/types.js';
|
||||
|
||||
describe('Prompt Template System', () => {
|
||||
let context: PromptContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = createDefaultContext({
|
||||
plan: {
|
||||
isActive: true,
|
||||
planExists: false,
|
||||
planFilePath: '/tmp/test-plan.md',
|
||||
allowedWritePaths: ['/tmp/*'],
|
||||
},
|
||||
env: {
|
||||
workdir: '/test/project',
|
||||
isGitRepo: true,
|
||||
platform: 'darwin',
|
||||
date: '2024-01-01',
|
||||
homeDir: '/home/test',
|
||||
},
|
||||
agent: {
|
||||
name: 'test-agent',
|
||||
mode: 'primary',
|
||||
isSubagent: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderTemplate', () => {
|
||||
it('should replace simple variables', () => {
|
||||
const template = 'Use ${tools.glob} to find files';
|
||||
const result = renderTemplate(template, context);
|
||||
expect(result).toBe('Use glob to find files');
|
||||
});
|
||||
|
||||
it('should replace nested properties', () => {
|
||||
const template = 'Plan file: ${plan.planFilePath}';
|
||||
const result = renderTemplate(template, context);
|
||||
expect(result).toBe('Plan file: /tmp/test-plan.md');
|
||||
});
|
||||
|
||||
it('should handle multiple variables in one template', () => {
|
||||
const template = 'Use ${tools.glob}, ${tools.grep}, and ${tools.read}';
|
||||
const result = renderTemplate(template, context);
|
||||
expect(result).toBe('Use glob, grep_content, and read_file');
|
||||
});
|
||||
|
||||
it('should handle ternary expressions - false condition', () => {
|
||||
const template = '${plan.planExists ? "exists" : "not exists"}';
|
||||
const result = renderTemplate(template, context);
|
||||
expect(result).toBe('not exists');
|
||||
});
|
||||
|
||||
it('should handle ternary expressions - true condition', () => {
|
||||
context.plan.planExists = true;
|
||||
const template = '${plan.planExists ? "exists" : "not exists"}';
|
||||
const result = renderTemplate(template, context);
|
||||
expect(result).toBe('exists');
|
||||
});
|
||||
|
||||
it('should handle nested variables in ternary expressions', () => {
|
||||
const template =
|
||||
'${plan.planExists ? "File at ${plan.planFilePath}" : "Create at ${plan.planFilePath}"}';
|
||||
const result = renderTemplate(template, context);
|
||||
expect(result).toBe('Create at /tmp/test-plan.md');
|
||||
});
|
||||
|
||||
it('should handle boolean values', () => {
|
||||
const template = 'Is git repo: ${env.isGitRepo}';
|
||||
const result = renderTemplate(template, context);
|
||||
expect(result).toBe('Is git repo: true');
|
||||
});
|
||||
|
||||
it('should handle undefined variables gracefully', () => {
|
||||
const template = 'Value: ${custom.undefined}';
|
||||
const result = renderTemplate(template, context);
|
||||
expect(result).toBe('Value: ');
|
||||
});
|
||||
|
||||
it('should preserve text without variables', () => {
|
||||
const template = 'No variables here';
|
||||
const result = renderTemplate(template, context);
|
||||
expect(result).toBe('No variables here');
|
||||
});
|
||||
|
||||
it('should handle complex nested ternary with tool references', () => {
|
||||
context.plan.planExists = true;
|
||||
const template =
|
||||
'${plan.planExists ? "Edit with ${tools.edit}" : "Write with ${tools.write}"}';
|
||||
const result = renderTemplate(template, context);
|
||||
expect(result).toBe('Edit with edit_file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDefaultContext', () => {
|
||||
it('should create context with default tool names', () => {
|
||||
const ctx = createDefaultContext();
|
||||
expect(ctx.tools.glob).toBe('glob');
|
||||
expect(ctx.tools.grep).toBe('grep_content');
|
||||
expect(ctx.tools.read).toBe('read_file');
|
||||
expect(ctx.tools.write).toBe('write_file');
|
||||
expect(ctx.tools.bash).toBe('bash');
|
||||
});
|
||||
|
||||
it('should allow overriding tool names', () => {
|
||||
const ctx = createDefaultContext({
|
||||
tools: {
|
||||
...DEFAULT_TOOL_NAMES,
|
||||
glob: 'custom_glob',
|
||||
},
|
||||
});
|
||||
expect(ctx.tools.glob).toBe('custom_glob');
|
||||
expect(ctx.tools.grep).toBe('grep_content'); // Others unchanged
|
||||
});
|
||||
|
||||
it('should set default plan values', () => {
|
||||
const ctx = createDefaultContext();
|
||||
expect(ctx.plan.isActive).toBe(false);
|
||||
expect(ctx.plan.planExists).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPlanContext', () => {
|
||||
it('should create context with plan mode active', () => {
|
||||
const ctx = createPlanContext({});
|
||||
expect(ctx.plan.isActive).toBe(true);
|
||||
expect(ctx.agent.name).toBe('plan');
|
||||
});
|
||||
|
||||
it('should set isSubagent correctly', () => {
|
||||
const ctx = createPlanContext({ isSubagent: true });
|
||||
expect(ctx.agent.isSubagent).toBe(true);
|
||||
expect(ctx.agent.mode).toBe('subagent');
|
||||
});
|
||||
|
||||
it('should use custom workdir', () => {
|
||||
const ctx = createPlanContext({ workdir: '/custom/path' });
|
||||
expect(ctx.env.workdir).toBe('/custom/path');
|
||||
});
|
||||
|
||||
it('should use custom plan file path', () => {
|
||||
const ctx = createPlanContext({ planFilePath: '/custom/plan.md' });
|
||||
expect(ctx.plan.planFilePath).toBe('/custom/plan.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generatePlanPrompt', () => {
|
||||
it('should generate prompt with tool names resolved', () => {
|
||||
const prompt = generatePlanPrompt({});
|
||||
expect(prompt).toContain('glob');
|
||||
expect(prompt).toContain('grep_content');
|
||||
expect(prompt).toContain('read_file');
|
||||
});
|
||||
|
||||
it('should include plan file path', () => {
|
||||
const prompt = generatePlanPrompt({ planFilePath: '/test/plan.md' });
|
||||
expect(prompt).toContain('/test/plan.md');
|
||||
});
|
||||
|
||||
it('should generate shorter prompt for subagent', () => {
|
||||
const mainPrompt = generatePlanPrompt({ isSubagent: false });
|
||||
const subPrompt = generatePlanPrompt({ isSubagent: true });
|
||||
expect(subPrompt.length).toBeLessThan(mainPrompt.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAgentPrompt', () => {
|
||||
it('should resolve template variables in prompt', () => {
|
||||
const basePrompt = 'Use ${tools.glob} to search';
|
||||
const resolved = resolveAgentPrompt(basePrompt, {});
|
||||
expect(resolved).toBe('Use glob to search');
|
||||
});
|
||||
|
||||
it('should handle isPlanMode option', () => {
|
||||
const basePrompt = '${plan.isActive ? "Plan mode" : "Normal mode"}';
|
||||
const planResolved = resolveAgentPrompt(basePrompt, { isPlanMode: true });
|
||||
expect(planResolved).toBe('Plan mode');
|
||||
|
||||
const normalResolved = resolveAgentPrompt(basePrompt, { isPlanMode: false });
|
||||
expect(normalResolved).toBe('Normal mode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty template', () => {
|
||||
const result = renderTemplate('', context);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should handle template with only variable', () => {
|
||||
const result = renderTemplate('${tools.bash}', context);
|
||||
expect(result).toBe('bash');
|
||||
});
|
||||
|
||||
it('should handle unclosed variable syntax', () => {
|
||||
const result = renderTemplate('${tools.bash', context);
|
||||
expect(result).toBe('${tools.bash');
|
||||
});
|
||||
|
||||
it('should handle escaped-like patterns (no real escaping)', () => {
|
||||
// Note: This system doesn't support escaping, so $$ or similar won't work
|
||||
const result = renderTemplate('Price: $100', context);
|
||||
expect(result).toBe('Price: $100');
|
||||
});
|
||||
|
||||
it('should handle deeply nested properties', () => {
|
||||
const result = renderTemplate('${agent.mode}', context);
|
||||
expect(result).toBe('primary');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user