From e3e148fcf22f39ad1694013dafe8ae3a90491aba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 20:36:05 +0000 Subject: [PATCH 1/8] Initial plan From c24bf5f6f0704cf49355fd104182a23959b3bf32 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 20:43:21 +0000 Subject: [PATCH 2/8] feat: Add prompt library with local save/delete and project prompt discovery - Add PromptLibrary model (SavedPrompt, PromptSource enum) - Add PromptLibraryService to discover prompts from standard coding agent locations (.github/copilot-prompts/, .github/prompts/, .copilot/prompts/, .claude/prompts/) and manage user-saved prompts (~/.polypilot/prompts/) - Add /prompt slash command (list, use, save, delete) - Add prompts count indicator in ExpandedSessionView status bar with popup - Add 25 unit tests for PromptLibrary model and service Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- PolyPilot.Tests/PolyPilot.Tests.csproj | 2 + PolyPilot.Tests/PromptLibraryTests.cs | 285 ++++++++++++++++++ .../Components/ExpandedSessionView.razor | 61 +++- PolyPilot/Components/Pages/Dashboard.razor | 107 +++++++ PolyPilot/Models/PromptLibrary.cs | 26 ++ PolyPilot/Services/PromptLibraryService.cs | 234 ++++++++++++++ 6 files changed, 714 insertions(+), 1 deletion(-) create mode 100644 PolyPilot.Tests/PromptLibraryTests.cs create mode 100644 PolyPilot/Models/PromptLibrary.cs create mode 100644 PolyPilot/Services/PromptLibraryService.cs diff --git a/PolyPilot.Tests/PolyPilot.Tests.csproj b/PolyPilot.Tests/PolyPilot.Tests.csproj index b1e48790bc..3c21127246 100644 --- a/PolyPilot.Tests/PolyPilot.Tests.csproj +++ b/PolyPilot.Tests/PolyPilot.Tests.csproj @@ -34,6 +34,8 @@ + + diff --git a/PolyPilot.Tests/PromptLibraryTests.cs b/PolyPilot.Tests/PromptLibraryTests.cs new file mode 100644 index 0000000000..71e2fd24fa --- /dev/null +++ b/PolyPilot.Tests/PromptLibraryTests.cs @@ -0,0 +1,285 @@ +using PolyPilot.Models; +using PolyPilot.Services; + +namespace PolyPilot.Tests; + +public class PromptLibraryTests : IDisposable +{ + private readonly string _testDir; + + public PromptLibraryTests() + { + _testDir = Path.Combine(Path.GetTempPath(), $"PolyPilot-prompt-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_testDir); + } + + [Fact] + public void SavedPrompt_DefaultValues() + { + var prompt = new SavedPrompt(); + + Assert.Equal("", prompt.Name); + Assert.Equal("", prompt.Content); + Assert.Equal("", prompt.Description); + Assert.Equal(PromptSource.User, prompt.Source); + Assert.Null(prompt.FilePath); + } + + [Fact] + public void SavedPrompt_SourceLabel_User() + { + var prompt = new SavedPrompt { Source = PromptSource.User }; + Assert.Equal("user", prompt.SourceLabel); + } + + [Fact] + public void SavedPrompt_SourceLabel_Project() + { + var prompt = new SavedPrompt { Source = PromptSource.Project }; + Assert.Equal("project", prompt.SourceLabel); + } + + [Fact] + public void ParsePromptFile_PlainMarkdown_UsesFilename() + { + var content = "Fix all the bugs in the codebase."; + var filePath = "/prompts/fix-bugs.md"; + + var (name, description, body) = PromptLibraryService.ParsePromptFile(content, filePath); + + Assert.Equal("fix-bugs", name); + Assert.Equal("", description); + Assert.Equal("Fix all the bugs in the codebase.", body); + } + + [Fact] + public void ParsePromptFile_WithFrontmatter() + { + var content = "---\nname: Code Review\ndescription: Review code for best practices\n---\nPlease review the following code..."; + var filePath = "/prompts/review.md"; + + var (name, description, body) = PromptLibraryService.ParsePromptFile(content, filePath); + + Assert.Equal("Code Review", name); + Assert.Equal("Review code for best practices", description); + Assert.Equal("Please review the following code...", body); + } + + [Fact] + public void ParsePromptFile_FrontmatterNameOnly() + { + var content = "---\nname: Quick Fix\n---\nFix the issue quickly."; + var filePath = "/prompts/quick.md"; + + var (name, description, body) = PromptLibraryService.ParsePromptFile(content, filePath); + + Assert.Equal("Quick Fix", name); + Assert.Equal("", description); + Assert.Equal("Fix the issue quickly.", body); + } + + [Fact] + public void ParsePromptFile_QuotedValues() + { + var content = "---\nname: \"My Prompt\"\ndescription: 'A helpful prompt'\n---\nDo something."; + var filePath = "/prompts/test.md"; + + var (name, description, body) = PromptLibraryService.ParsePromptFile(content, filePath); + + Assert.Equal("My Prompt", name); + Assert.Equal("A helpful prompt", description); + } + + [Fact] + public void ParsePromptFile_NoFrontmatterEnd_UsesFilename() + { + var content = "---\nname: Broken\nThis is not closed"; + var filePath = "/prompts/broken.md"; + + var (name, description, body) = PromptLibraryService.ParsePromptFile(content, filePath); + + Assert.Equal("broken", name); + Assert.Equal("", description); + } + + [Fact] + public void ScanPromptDirectory_FindsMdFiles() + { + var promptDir = Path.Combine(_testDir, "prompts"); + Directory.CreateDirectory(promptDir); + File.WriteAllText(Path.Combine(promptDir, "test1.md"), "---\nname: Test One\ndescription: First test\n---\nContent one"); + File.WriteAllText(Path.Combine(promptDir, "test2.md"), "Plain content without frontmatter"); + File.WriteAllText(Path.Combine(promptDir, "not-a-prompt.txt"), "Should be ignored"); + + var prompts = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + PromptLibraryService.ScanPromptDirectory(promptDir, PromptSource.Project, prompts, seen); + + Assert.Equal(2, prompts.Count); + Assert.Contains(prompts, p => p.Name == "Test One" && p.Description == "First test"); + Assert.Contains(prompts, p => p.Name == "test2" && p.Content == "Plain content without frontmatter"); + } + + [Fact] + public void ScanPromptDirectory_SkipsDuplicateNames() + { + var promptDir = Path.Combine(_testDir, "prompts-dedup"); + Directory.CreateDirectory(promptDir); + File.WriteAllText(Path.Combine(promptDir, "review.md"), "---\nname: Review\n---\nFirst"); + + var prompts = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + seen.Add("Review"); // Already seen + PromptLibraryService.ScanPromptDirectory(promptDir, PromptSource.Project, prompts, seen); + + Assert.Empty(prompts); + } + + [Fact] + public void DiscoverPrompts_FromProjectDirectories() + { + var projectDir = Path.Combine(_testDir, "my-project"); + var promptDir = Path.Combine(projectDir, ".github", "prompts"); + Directory.CreateDirectory(promptDir); + File.WriteAllText(Path.Combine(promptDir, "deploy.md"), "---\nname: Deploy\ndescription: Deploy the app\n---\nDeploy steps..."); + + var prompts = PromptLibraryService.DiscoverPrompts(projectDir); + + Assert.Single(prompts); + Assert.Equal("Deploy", prompts[0].Name); + Assert.Equal(PromptSource.Project, prompts[0].Source); + } + + [Fact] + public void DiscoverPrompts_CopilotPromptsDir() + { + var projectDir = Path.Combine(_testDir, "copilot-project"); + var promptDir = Path.Combine(projectDir, ".github", "copilot-prompts"); + Directory.CreateDirectory(promptDir); + File.WriteAllText(Path.Combine(promptDir, "review.md"), "Review code carefully."); + + var prompts = PromptLibraryService.DiscoverPrompts(projectDir); + + Assert.Single(prompts); + Assert.Equal("review", prompts[0].Name); + Assert.Equal("Review code carefully.", prompts[0].Content); + } + + [Fact] + public void DiscoverPrompts_MultipleProjectDirs() + { + var projectDir = Path.Combine(_testDir, "multi-project"); + var githubDir = Path.Combine(projectDir, ".github", "prompts"); + var copilotDir = Path.Combine(projectDir, ".copilot", "prompts"); + Directory.CreateDirectory(githubDir); + Directory.CreateDirectory(copilotDir); + File.WriteAllText(Path.Combine(githubDir, "from-github.md"), "---\nname: GitHub Prompt\n---\nFrom github"); + File.WriteAllText(Path.Combine(copilotDir, "from-copilot.md"), "---\nname: Copilot Prompt\n---\nFrom copilot"); + + var prompts = PromptLibraryService.DiscoverPrompts(projectDir); + + Assert.Equal(2, prompts.Count); + Assert.Contains(prompts, p => p.Name == "GitHub Prompt"); + Assert.Contains(prompts, p => p.Name == "Copilot Prompt"); + } + + [Fact] + public void DiscoverPrompts_NoDirectory_ReturnsEmpty() + { + var prompts = PromptLibraryService.DiscoverPrompts("/nonexistent/path"); + // Should not throw, may be empty (depends on user prompts dir existence) + Assert.NotNull(prompts); + } + + [Fact] + public void DiscoverPrompts_NullDirectory_ReturnsAtLeastEmpty() + { + var prompts = PromptLibraryService.DiscoverPrompts(null); + Assert.NotNull(prompts); + } + + [Fact] + public void SanitizeFileName_AlphanumericUnchanged() + { + Assert.Equal("hello-world", PromptLibraryService.SanitizeFileName("hello-world")); + } + + [Fact] + public void SanitizeFileName_SpacesReplaced() + { + Assert.Equal("hello-world", PromptLibraryService.SanitizeFileName("hello world")); + } + + [Fact] + public void SanitizeFileName_SpecialCharsReplaced() + { + Assert.Equal("test-prompt--v2", PromptLibraryService.SanitizeFileName("test/prompt!@v2")); + } + + [Fact] + public void SanitizeFileName_EmptyString_FallsBack() + { + Assert.Equal("prompt", PromptLibraryService.SanitizeFileName("")); + } + + [Fact] + public void SanitizeFileName_AllSpecialChars_FallsBack() + { + Assert.Equal("prompt", PromptLibraryService.SanitizeFileName("@#$")); + } + + [Fact] + public void SanitizeFileName_Underscores_Preserved() + { + Assert.Equal("my_prompt", PromptLibraryService.SanitizeFileName("my_prompt")); + } + + [Fact] + public void PromptSource_Enum_HasExpectedValues() + { + Assert.Equal(0, (int)PromptSource.User); + Assert.Equal(1, (int)PromptSource.Project); + } + + [Fact] + public void ParsePromptFile_MultilineDescription_Skipped() + { + var content = "---\nname: Test\ndescription: >\n multiline desc\n---\nBody content"; + var filePath = "/test.md"; + + var (name, description, body) = PromptLibraryService.ParsePromptFile(content, filePath); + + Assert.Equal("Test", name); + Assert.Equal("", description); // multiline > is skipped + Assert.Equal("Body content", body); + } + + [Fact] + public void ParsePromptFile_EmptyContent() + { + var (name, description, body) = PromptLibraryService.ParsePromptFile("", "/empty.md"); + + Assert.Equal("empty", name); + Assert.Equal("", description); + Assert.Equal("", body); + } + + [Fact] + public void DiscoverPrompts_ClaudePromptsDir() + { + var projectDir = Path.Combine(_testDir, "claude-project"); + var promptDir = Path.Combine(projectDir, ".claude", "prompts"); + Directory.CreateDirectory(promptDir); + File.WriteAllText(Path.Combine(promptDir, "analyze.md"), "---\nname: Analyze\n---\nAnalyze the code."); + + var prompts = PromptLibraryService.DiscoverPrompts(projectDir); + + Assert.Single(prompts); + Assert.Equal("Analyze", prompts[0].Name); + } + + public void Dispose() + { + try { Directory.Delete(_testDir, true); } catch { } + } +} diff --git a/PolyPilot/Components/ExpandedSessionView.razor b/PolyPilot/Components/ExpandedSessionView.razor index 72abce6d12..796e266425 100644 --- a/PolyPilot/Components/ExpandedSessionView.razor +++ b/PolyPilot/Components/ExpandedSessionView.razor @@ -234,6 +234,11 @@ · @availableAgents.Count agents } + @if (availablePrompts?.Count > 0) + { + · + @availablePrompts.Count prompts + } · @@ -308,11 +308,9 @@ [Parameter] public EventCallback OnFontSizeChange { get; set; } [Parameter] public EventCallback OnLoadMore { get; set; } - [Parameter] public EventCallback OnLoadFullHistory { get; set; } [Parameter] public EventCallback<(string SessionName, FiestaStartRequest Request)> OnStartFiesta { get; set; } [Parameter] public EventCallback OnStopFiesta { get; set; } [Parameter] public EventCallback OnStopReflection { get; set; } - [Parameter] public EventCallback OnUsePrompt { get; set; } private bool _showFiestaPicker; private string _fiestaName = ""; @@ -500,7 +498,7 @@ (function(){{ var old = document.getElementById('skills-popup-overlay'); if(old) old.remove(); - var trigger = document.querySelector('[class*=""skills-trigger""]'); + var trigger = document.querySelector('[data-trigger=""skills""]'); var rect = trigger ? trigger.getBoundingClientRect() : {{left:20,bottom:60}}; var ov = document.createElement('div'); ov.id = 'skills-popup-overlay'; @@ -537,9 +535,8 @@ (function(){{ var old = document.getElementById('skills-popup-overlay'); if(old) old.remove(); - var trigger = document.querySelectorAll('[class*=""skills-trigger""]'); - var el = trigger.length > 1 ? trigger[1] : trigger[0]; - var rect = el ? el.getBoundingClientRect() : {{left:20,bottom:60}}; + var trigger = document.querySelector('[data-trigger=""agents""]'); + var rect = trigger ? trigger.getBoundingClientRect() : {{left:20,bottom:60}}; var ov = document.createElement('div'); ov.id = 'skills-popup-overlay'; ov.style.cssText = 'position:fixed;inset:0;z-index:9998;background:rgba(0,0,0,0.3)'; @@ -575,9 +572,8 @@ (function(){{ var old = document.getElementById('skills-popup-overlay'); if(old) old.remove(); - var triggers = document.querySelectorAll('[class*=""skills-trigger""]'); - var el = triggers[triggers.length - 1]; - var rect = el ? el.getBoundingClientRect() : {{left:20,bottom:60}}; + var trigger = document.querySelector('[data-trigger=""prompts""]'); + var rect = trigger ? trigger.getBoundingClientRect() : {{left:20,bottom:60}}; var ov = document.createElement('div'); ov.id = 'skills-popup-overlay'; ov.style.cssText = 'position:fixed;inset:0;z-index:9998;background:rgba(0,0,0,0.3)'; diff --git a/PolyPilot/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index 076591f0e9..b25a1f602e 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -1771,7 +1771,7 @@ case "save": if (string.IsNullOrEmpty(target)) { - session.History.Add(ChatMessage.ErrorMessage("Usage: `/prompt save `\nOr: `/prompt save ` (saves last assistant message)")); + session.History.Add(ChatMessage.ErrorMessage("Usage: `/prompt save `\nOr: `/prompt save ` (saves last user message)")); break; } var saveParts = target.Split(' ', 2, StringSplitOptions.TrimEntries); diff --git a/PolyPilot/Services/PromptLibraryService.cs b/PolyPilot/Services/PromptLibraryService.cs index 7ab6f13e55..354a0ebf86 100644 --- a/PolyPilot/Services/PromptLibraryService.cs +++ b/PolyPilot/Services/PromptLibraryService.cs @@ -172,6 +172,29 @@ public static SavedPrompt SavePrompt(string name, string content, string? descri var safeName = SanitizeFileName(name); var filePath = Path.Combine(UserPromptsDir, safeName + ".md"); + // Resolve filename collisions: if file exists with a different prompt name, append suffix + if (File.Exists(filePath)) + { + try + { + var existing = File.ReadAllText(filePath); + var (existingName, _, _) = ParsePromptFile(existing, filePath); + if (!string.Equals(existingName, name, StringComparison.OrdinalIgnoreCase)) + { + for (var i = 2; i < 100; i++) + { + filePath = Path.Combine(UserPromptsDir, $"{safeName}-{i}.md"); + if (!File.Exists(filePath)) break; + var existingN = File.ReadAllText(filePath); + var (n, _, _) = ParsePromptFile(existingN, filePath); + if (string.Equals(n, name, StringComparison.OrdinalIgnoreCase)) + break; // Same logical name — overwrite is fine + } + } + } + catch { } + } + // Sanitize name/description to prevent YAML corruption var yamlName = SanitizeYamlValue(name); var yamlDesc = description != null ? SanitizeYamlValue(description) : null; @@ -234,15 +257,17 @@ public static bool DeletePrompt(string name) } /// - /// Sanitize a string for safe inclusion in a YAML value. - /// Strips newlines and escapes double quotes. + /// Sanitize a string for safe inclusion in a YAML double-quoted value. + /// Strips newlines, backslashes, and double quotes to avoid YAML corruption. + /// The hand-rolled parser does not handle YAML escapes, so we strip rather than escape. /// internal static string SanitizeYamlValue(string value) { return value .Replace("\r", "") .Replace("\n", " ") - .Replace("\"", "\\\""); + .Replace("\\", "") + .Replace("\"", ""); } /// From a84da8571ba569005a3640749dfba2e75660ca45 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 21:45:06 +0000 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20Save=E2=86=92Lookup=20round-trip=20f?= =?UTF-8?q?or=20sanitized=20names,=20real=20collision=20tests=20with=20GUI?= =?UTF-8?q?D=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. SavePrompt now returns the sanitized yamlName (not the original) so GetPrompt/DeletePrompt lookups match what's stored on disk 2. Collision comparisons use yamlName instead of raw name 3. Collision loop uses GUID fallback if all 98 suffix slots exhausted 4. Added SetUserPromptsDirForTesting() so tests can redirect SavePrompt 5. Rewrote collision test to actually call SavePrompt() twice and verify the -2 suffix file is created 6. Added SavePrompt_RoundTrip_NameWithQuotes_Survives test 7. Added SavePrompt_SameNameOverwrites test Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- PolyPilot.Tests/PromptLibraryTests.cs | 82 ++++++++++++++++------ PolyPilot/Services/PromptLibraryService.cs | 28 +++++--- 2 files changed, 77 insertions(+), 33 deletions(-) diff --git a/PolyPilot.Tests/PromptLibraryTests.cs b/PolyPilot.Tests/PromptLibraryTests.cs index 95b22078ce..84776d094e 100644 --- a/PolyPilot.Tests/PromptLibraryTests.cs +++ b/PolyPilot.Tests/PromptLibraryTests.cs @@ -336,21 +336,35 @@ public void SavePrompt_RoundTrip_NameSurvives() { var promptDir = Path.Combine(_testDir, "rt-prompts"); Directory.CreateDirectory(promptDir); + PromptLibraryService.SetUserPromptsDirForTesting(promptDir); - // Write a prompt file manually with a specific name - var name = "My Test Prompt"; - var content = "Do the thing."; - var fileContent = $"---\nname: \"{name}\"\n---\n{content}"; - File.WriteAllText(Path.Combine(promptDir, "my-test-prompt.md"), fileContent); + var saved = PromptLibraryService.SavePrompt("My Test Prompt", "Do the thing."); - // Read it back via ScanPromptDirectory - var prompts = new List(); - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - PromptLibraryService.ScanPromptDirectory(promptDir, PromptSource.User, prompts, seen); + Assert.Equal("My Test Prompt", saved.Name); + Assert.Equal("Do the thing.", saved.Content); - Assert.Single(prompts); - Assert.Equal("My Test Prompt", prompts[0].Name); - Assert.Equal("Do the thing.", prompts[0].Content); + // Read it back via GetPrompt — name must match + var found = PromptLibraryService.GetPrompt("My Test Prompt"); + Assert.NotNull(found); + Assert.Equal("My Test Prompt", found!.Name); + Assert.Equal("Do the thing.", found.Content); + } + + [Fact] + public void SavePrompt_RoundTrip_NameWithQuotes_Survives() + { + var promptDir = Path.Combine(_testDir, "rt-quotes"); + Directory.CreateDirectory(promptDir); + PromptLibraryService.SetUserPromptsDirForTesting(promptDir); + + // Quotes are stripped by SanitizeYamlValue; returned name is sanitized + var saved = PromptLibraryService.SavePrompt("say \"hello\"", "content"); + Assert.Equal("say hello", saved.Name); + + // GetPrompt with the sanitized name should find it + var found = PromptLibraryService.GetPrompt("say hello"); + Assert.NotNull(found); + Assert.Equal("say hello", found!.Name); } [Fact] @@ -358,20 +372,42 @@ public void SavePrompt_FilenameCollision_AppendsNumericSuffix() { var promptDir = Path.Combine(_testDir, "collision-prompts"); Directory.CreateDirectory(promptDir); + PromptLibraryService.SetUserPromptsDirForTesting(promptDir); + + // Save "foo/bar" — sanitizes filename to "foo-bar.md" + var first = PromptLibraryService.SavePrompt("foo/bar", "First prompt"); + Assert.True(File.Exists(first.FilePath)); + Assert.EndsWith("foo-bar.md", first.FilePath!); + + // Save "foo?bar" — also sanitizes to "foo-bar.md" but different logical name + var second = PromptLibraryService.SavePrompt("foo?bar", "Second prompt"); + Assert.True(File.Exists(second.FilePath)); + Assert.EndsWith("foo-bar-2.md", second.FilePath!); + + // Both are discoverable + var all = PromptLibraryService.DiscoverPrompts(null) + .Where(p => p.Source == PromptSource.User).ToList(); + Assert.Contains(all, p => p.Name == "foo/bar" && p.Content == "First prompt"); + Assert.Contains(all, p => p.Name == "foo?bar" && p.Content == "Second prompt"); + } + + [Fact] + public void SavePrompt_SameNameOverwrites() + { + var promptDir = Path.Combine(_testDir, "overwrite-prompts"); + Directory.CreateDirectory(promptDir); + PromptLibraryService.SetUserPromptsDirForTesting(promptDir); - // Create two files that would collide: "foo/bar" and "foo?bar" both sanitize to "foo-bar.md" - File.WriteAllText( - Path.Combine(promptDir, "foo-bar.md"), - "---\nname: \"foo/bar\"\n---\nFirst prompt"); + PromptLibraryService.SavePrompt("my prompt", "version 1"); + PromptLibraryService.SavePrompt("my prompt", "version 2"); - // Simulate SavePrompt collision resolution by checking names differ - var existingContent = File.ReadAllText(Path.Combine(promptDir, "foo-bar.md")); - var (existingName, _, _) = PromptLibraryService.ParsePromptFile(existingContent, "foo-bar.md"); - Assert.Equal("foo/bar", existingName); + // Only one file should exist, with the latest content + var files = Directory.GetFiles(promptDir, "*.md"); + Assert.Single(files); - // The name "foo?bar" sanitizes to "foo-bar" — same filename but different name - var newSafeName = PromptLibraryService.SanitizeFileName("foo?bar"); - Assert.Equal("foo-bar", newSafeName); + var found = PromptLibraryService.GetPrompt("my prompt"); + Assert.NotNull(found); + Assert.Equal("version 2", found!.Content); } public void Dispose() diff --git a/PolyPilot/Services/PromptLibraryService.cs b/PolyPilot/Services/PromptLibraryService.cs index 354a0ebf86..ccea4ef507 100644 --- a/PolyPilot/Services/PromptLibraryService.cs +++ b/PolyPilot/Services/PromptLibraryService.cs @@ -13,6 +13,11 @@ public class PromptLibraryService private static string? _userPromptsDir; private static string UserPromptsDir => _userPromptsDir ??= Path.Combine(GetPolyPilotDir(), "prompts"); + /// + /// Override the user prompts directory. Used by tests to avoid reading real ~/.polypilot/prompts/. + /// + internal static void SetUserPromptsDirForTesting(string path) => _userPromptsDir = path; + /// /// Standard project subdirectories where coding agents store prompt files. /// @@ -169,6 +174,10 @@ public static SavedPrompt SavePrompt(string name, string content, string? descri { Directory.CreateDirectory(UserPromptsDir); + // Sanitize name/description to prevent YAML corruption + var yamlName = SanitizeYamlValue(name); + var yamlDesc = description != null ? SanitizeYamlValue(description) : null; + var safeName = SanitizeFileName(name); var filePath = Path.Combine(UserPromptsDir, safeName + ".md"); @@ -179,26 +188,25 @@ public static SavedPrompt SavePrompt(string name, string content, string? descri { var existing = File.ReadAllText(filePath); var (existingName, _, _) = ParsePromptFile(existing, filePath); - if (!string.Equals(existingName, name, StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(existingName, yamlName, StringComparison.OrdinalIgnoreCase)) { + var found = false; for (var i = 2; i < 100; i++) { filePath = Path.Combine(UserPromptsDir, $"{safeName}-{i}.md"); - if (!File.Exists(filePath)) break; + if (!File.Exists(filePath)) { found = true; break; } var existingN = File.ReadAllText(filePath); var (n, _, _) = ParsePromptFile(existingN, filePath); - if (string.Equals(n, name, StringComparison.OrdinalIgnoreCase)) - break; // Same logical name — overwrite is fine + if (string.Equals(n, yamlName, StringComparison.OrdinalIgnoreCase)) + { found = true; break; } // Same logical name — overwrite is fine } + if (!found) + filePath = Path.Combine(UserPromptsDir, $"{safeName}-{Guid.NewGuid():N}.md"); } } catch { } } - // Sanitize name/description to prevent YAML corruption - var yamlName = SanitizeYamlValue(name); - var yamlDesc = description != null ? SanitizeYamlValue(description) : null; - var fileContent = ""; if (!string.IsNullOrWhiteSpace(yamlDesc)) { @@ -213,9 +221,9 @@ public static SavedPrompt SavePrompt(string name, string content, string? descri return new SavedPrompt { - Name = name, + Name = yamlName, Content = content, - Description = description ?? "", + Description = yamlDesc ?? "", Source = PromptSource.User, FilePath = filePath }; From c4f2e6a76968fc1680393dfce5d1b70da74be936 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:27:54 +0000 Subject: [PATCH 7/8] fix: Use SavePrompt return value in confirmation, strip single quotes in SanitizeYamlValue 1. Dashboard /prompt save handler now uses the SavedPrompt return value so the confirmation message shows the sanitized name that matches disk 2. SanitizeYamlValue strips single quotes to match ParsePromptFile's .Trim('"', '\'') behavior, preventing round-trip mismatch 3. Added SanitizeYamlValue_StripsSingleQuotes test Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- PolyPilot.Tests/PromptLibraryTests.cs | 8 ++++++++ PolyPilot/Components/Pages/Dashboard.razor | 4 ++-- PolyPilot/Services/PromptLibraryService.cs | 3 ++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/PolyPilot.Tests/PromptLibraryTests.cs b/PolyPilot.Tests/PromptLibraryTests.cs index 84776d094e..4c6069dd96 100644 --- a/PolyPilot.Tests/PromptLibraryTests.cs +++ b/PolyPilot.Tests/PromptLibraryTests.cs @@ -309,6 +309,14 @@ public void SanitizeYamlValue_StripsQuotes() Assert.Equal("say hello", result); } + [Fact] + public void SanitizeYamlValue_StripsSingleQuotes() + { + Assert.Equal("cool", PromptLibraryService.SanitizeYamlValue("'cool'")); + Assert.Equal("name", PromptLibraryService.SanitizeYamlValue("'name")); + Assert.Equal("its cool", PromptLibraryService.SanitizeYamlValue("it's cool")); + } + [Fact] public void SanitizeYamlValue_StripsBackslashes() { diff --git a/PolyPilot/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index b25a1f602e..a5e2e86906 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -1792,8 +1792,8 @@ } saveContent = lastUser.Content; } - PromptLibraryService.SavePrompt(saveName, saveContent); - session.History.Add(ChatMessage.SystemMessage($"✅ Prompt **{saveName}** saved.")); + var saved = PromptLibraryService.SavePrompt(saveName, saveContent); + session.History.Add(ChatMessage.SystemMessage($"✅ Prompt **{saved.Name}** saved.")); break; case "delete": diff --git a/PolyPilot/Services/PromptLibraryService.cs b/PolyPilot/Services/PromptLibraryService.cs index ccea4ef507..e2bf5f5e91 100644 --- a/PolyPilot/Services/PromptLibraryService.cs +++ b/PolyPilot/Services/PromptLibraryService.cs @@ -275,7 +275,8 @@ internal static string SanitizeYamlValue(string value) .Replace("\r", "") .Replace("\n", " ") .Replace("\\", "") - .Replace("\"", ""); + .Replace("\"", "") + .Replace("'", ""); } /// From 6d8e2ea797fcdf7bc32042879583c1242ba3e252 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Sun, 22 Feb 2026 19:58:27 -0600 Subject: [PATCH 8/8] fix: restore OnLoadFullHistory parameter removed during rebase The rebase onto origin/main brought in PR #181 (paginated history loading) which uses OnLoadFullHistory. The PR #111 dead-code cleanup had removed it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Components/ExpandedSessionView.razor | 1 + 1 file changed, 1 insertion(+) diff --git a/PolyPilot/Components/ExpandedSessionView.razor b/PolyPilot/Components/ExpandedSessionView.razor index ed15a9fc59..6c3c2fa640 100644 --- a/PolyPilot/Components/ExpandedSessionView.razor +++ b/PolyPilot/Components/ExpandedSessionView.razor @@ -308,6 +308,7 @@ [Parameter] public EventCallback OnFontSizeChange { get; set; } [Parameter] public EventCallback OnLoadMore { get; set; } + [Parameter] public EventCallback OnLoadFullHistory { get; set; } [Parameter] public EventCallback<(string SessionName, FiestaStartRequest Request)> OnStartFiesta { get; set; } [Parameter] public EventCallback OnStopFiesta { get; set; } [Parameter] public EventCallback OnStopReflection { get; set; }