diff --git a/packages/core/src/prompts/utils.test.ts b/packages/core/src/prompts/utils.test.ts index d40c2649b92..fbb1afd293f 100644 --- a/packages/core/src/prompts/utils.test.ts +++ b/packages/core/src/prompts/utils.test.ts @@ -13,6 +13,7 @@ import { } from './utils.js'; import type { Config } from '../config/config.js'; import type { ToolRegistry } from '../tools/tool-registry.js'; +import * as snippets from './snippets.js'; vi.mock('../utils/paths.js', () => ({ homedir: vi.fn().mockReturnValue('/mock/home'), @@ -312,4 +313,54 @@ describe('applySubstitutions', () => { ); expect(result).toBe('A plain prompt with no variables.'); }); + + it('should preserve dollar sequences in ${AgentSkills} verbatim', () => { + const result = applySubstitutions( + 'Skills: ${AgentSkills} | Tail', + mockConfig, + "echo $'a\\nb' and $$ and $& and $VAR", + ); + expect(result).toBe("Skills: echo $'a\\nb' and $$ and $& and $VAR | Tail"); + }); + + it('should preserve dollar sequences in ${SubAgents} verbatim', () => { + vi.mocked(snippets.renderSubAgents).mockReturnValueOnce( + "echo $'a\\nb' and $$ and $&", + ); + const result = applySubstitutions( + 'Agents: ${SubAgents} | Tail', + mockConfig, + '', + true, + ); + expect(result).toBe("Agents: echo $'a\\nb' and $$ and $& | Tail"); + }); + + it('should preserve dollar sequences in ${AvailableTools} verbatim', () => { + (mockConfig as unknown as { toolRegistry: ToolRegistry }).toolRegistry = { + getAllToolNames: vi.fn().mockReturnValue(["echo $'a\\nb'", '$$', '$&']), + getAllTools: vi.fn().mockReturnValue([]), + } as unknown as ToolRegistry; + + const result = applySubstitutions( + 'Tools: ${AvailableTools} | Tail', + mockConfig, + '', + ); + expect(result).toContain("- echo $'a\\nb'\n- $$\n- $& | Tail"); + }); + + it('should replace tool-specific ${toolName_ToolName} variables containing special characters', () => { + (mockConfig as unknown as { toolRegistry: ToolRegistry }).toolRegistry = { + getAllToolNames: vi.fn().mockReturnValue(["echo $'a\\nb'"]), + getAllTools: vi.fn().mockReturnValue([]), + } as unknown as ToolRegistry; + + const result = applySubstitutions( + "Use ${echo $'a\\nb'_ToolName} to run", + mockConfig, + '', + ); + expect(result).toBe("Use echo $'a\\nb' to run"); + }); }); diff --git a/packages/core/src/prompts/utils.ts b/packages/core/src/prompts/utils.ts index 651151efdf1..535f22bfe4c 100644 --- a/packages/core/src/prompts/utils.ts +++ b/packages/core/src/prompts/utils.ts @@ -69,7 +69,7 @@ export function applySubstitutions( ): string { let result = prompt; - result = result.replace(/\${AgentSkills}/g, skillsPrompt); + result = result.replace(/\${AgentSkills}/g, () => skillsPrompt); const activeSnippets = isGemini3 ? snippets : legacySnippets; const subAgentsContent = activeSnippets.renderSubAgents( @@ -82,7 +82,7 @@ export function applySubstitutions( })), ); - result = result.replace(/\${SubAgents}/g, subAgentsContent); + result = result.replace(/\${SubAgents}/g, () => subAgentsContent); const toolRegistry = context.toolRegistry; const allToolNames = toolRegistry.getAllToolNames(); @@ -90,13 +90,14 @@ export function applySubstitutions( allToolNames.length > 0 ? allToolNames.map((name) => `- ${name}`).join('\n') : 'No tools are currently available.'; - result = result.replace(/\${AvailableTools}/g, availableToolsList); + result = result.replace(/\${AvailableTools}/g, () => availableToolsList); for (const toolName of allToolNames) { const varName = `${toolName}_ToolName`; + const escapedVarName = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); result = result.replace( - new RegExp(`\\\${\\b${varName}\\b}`, 'g'), - toolName, + new RegExp(`\\$\\{${escapedVarName}\\}`, 'g'), + () => toolName, ); }