using Microsoft.Extensions.Time.Testing; using Volcanic.AgenticCore.Abstractions.Extensions.Events; using Volcanic.AgenticCore.Abstractions.Json; using Volcanic.AgenticCore.Abstractions.Messages; using Volcanic.AgenticCore.Abstractions.Sessions; using Volcanic.AgenticCore.Abstractions.Tools; using Volcanic.AgenticCore.Features.Compaction; using Volcanic.AgenticCore.Features.Models; using Volcanic.AgenticCore.Features.Sessions; using Volcanic.AgenticCore.Features.SystemPrompt; using Volcanic.AgenticCore.Testing; namespace Volcanic.AgenticCore.Tests.Features; public class SystemPromptBuilderTests { [Fact] public void Renders_tools_guidelines_sections_project_context_and_environment() { var dir = Path.Combine(Path.GetTempPath(), "volcanic-prompt", Guid.NewGuid().ToString("N")); var sub = Path.Combine(dir, "sub"); Directory.CreateDirectory(sub); File.WriteAllText(Path.Combine(dir, "AGENTS.md"), "Root rules"); File.WriteAllText(Path.Combine(sub, "CLAUDE.md"), "Sub rules"); try { var tools = new ITool[] { new DelegateTool("alpha", "d", (_, _, _) => Task.FromResult(ToolResult.Text("")), promptSnippet: "alpha: does a", promptGuidelines: ["Use alpha wisely"]), new DelegateTool("silent", "d", (_, _, _) => Task.FromResult(ToolResult.Text(""))), }; var builder = new SystemPromptBuilder(sub, () => tools, new FakeTimeProvider(new DateTimeOffset(2026, 9, 25, 0, 0, 0, TimeSpan.Zero)), () => TimeZoneInfo.Utc); var options = builder.DefaultOptions("BASE"); options.Sections["extra"] = "more"; var prompt = builder.Render(options); Assert.StartsWith("BASE", prompt); Assert.Contains("\nAvailable tools:\n- alpha: does a\n", prompt); Assert.DoesNotContain("silent", prompt); Assert.Contains("- Use alpha wisely", prompt); Assert.Contains("\nmore\n", prompt); var ctxStart = prompt.IndexOf("", StringComparison.Ordinal); Assert.True(prompt.IndexOf("Sub rules", StringComparison.Ordinal) < prompt.IndexOf("Root rules", StringComparison.Ordinal)); Assert.True(ctxStart > 0); Assert.Contains("Current date: 2026-09-25 (Friday)\nTime zone: ", prompt); Assert.Contains($"Working directory: {sub}", prompt); options.SelectedTools.Clear(); options.SelectedTools.Add("silent"); options.IncludeProjectContext = false; var narrow = builder.Render(options); Assert.DoesNotContain("", narrow); Assert.DoesNotContain("project_context", narrow); Assert.DoesNotContain("", narrow); } finally { Directory.Delete(dir, true); } } [Fact] public void Loads_AGENTS_md_in_preference_to_CLAUDE_md_then_persona_and_memory() { var dir = Path.Combine(Path.GetTempPath(), "volcanic-prompt", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); File.WriteAllText(Path.Combine(dir, "agents.md"), "Agent rules"); File.WriteAllText(Path.Combine(dir, "CLAUDE.md"), "Claude rules"); File.WriteAllText(Path.Combine(dir, "persona.md"), "Dry wit."); File.WriteAllText(Path.Combine(dir, "memory.md"), "- User prefers tabs"); try { var builder = new SystemPromptBuilder(dir, () => [], new FakeTimeProvider(DateTimeOffset.UnixEpoch), () => TimeZoneInfo.Utc); var prompt = builder.Render(builder.DefaultOptions("BASE")); Assert.Contains("Agent rules", prompt); Assert.DoesNotContain("Claude rules", prompt); Assert.Contains("\nDry wit.\n", prompt); Assert.Contains($"from {Path.Combine(dir, "memory.md")}", prompt); Assert.Contains("- User prefers tabs\n", prompt); var order = new[] { "", "", "", "" }.Select(t => prompt.IndexOf(t, StringComparison.Ordinal)).ToList(); Assert.Equal(order.Order(), order); File.Delete(Path.Combine(dir, "agents.md")); File.WriteAllText(Path.Combine(dir, "memory.md"), ""); prompt = builder.Render(builder.DefaultOptions("BASE")); Assert.Contains("Claude rules", prompt); Assert.Contains("", prompt); File.Delete(Path.Combine(dir, "persona.md")); File.Delete(Path.Combine(dir, "memory.md")); prompt = builder.Render(builder.DefaultOptions("BASE")); Assert.DoesNotContain("", prompt); Assert.DoesNotContain("", prompt); } finally { Directory.Delete(dir, true); } } } public class SkillCatalogTests { [Fact] public void Lists_workspace_skills_and_picks_up_new_ones_on_the_next_render() { var dir = Path.Combine(Path.GetTempPath(), "volcanic-prompt", Guid.NewGuid().ToString("N")); var skills = Path.Combine(dir, ".volcanic", "skills"); Directory.CreateDirectory(Path.Combine(skills, "pdf")); Directory.CreateDirectory(Path.Combine(skills, "no-description")); Directory.CreateDirectory(Path.Combine(skills, "folded")); File.WriteAllText(Path.Combine(skills, "pdf", "SKILL.md"), "---\nname: pdf-tools\ndescription: \"Fill forms\"\n---\n# Body stays out"); File.WriteAllText(Path.Combine(skills, "no-description", "SKILL.md"), "---\nname: nope\n---\nbody"); File.WriteAllText(Path.Combine(skills, "folded", "skill.md"), "---\ndescription: >\n Plans trips\n and books hotels\n---\n"); try { var builder = new SystemPromptBuilder(dir, () => [], new FakeTimeProvider(DateTimeOffset.UnixEpoch), () => TimeZoneInfo.Utc); var prompt = builder.Render(builder.DefaultOptions("BASE")); Assert.Contains($"{skills}//SKILL.md", prompt); Assert.Contains("pdf-tools\n Fill <PDF> forms\n " + Path.Combine(skills, "pdf", "SKILL.md"), prompt); Assert.Contains("folded\n Plans trips and books hotels", prompt); Assert.DoesNotContain("nope", prompt); Assert.DoesNotContain("Body stays out", prompt); var order = new[] { "", "" }.Select(t => prompt.IndexOf(t, StringComparison.Ordinal)).ToList(); Assert.Equal(order.Order(), order); Directory.CreateDirectory(Path.Combine(skills, "zeta")); File.WriteAllText(Path.Combine(skills, "zeta", "SKILL.md"), "---\nname: zeta\ndescription: Added mid-session\n---\n"); Assert.Contains("zeta", builder.Render(builder.DefaultOptions("BASE"))); Assert.Equal(["folded", "pdf-tools", "zeta"], SkillCatalog.Load(dir).Select(s => s.Name)); var narrow = builder.DefaultOptions("BASE"); narrow.IncludeProjectContext = false; Assert.DoesNotContain("", builder.Render(narrow)); } finally { Directory.Delete(dir, true); } } [Fact] public void Without_skills_the_section_still_says_where_to_write_them() { var dir = Path.Combine(Path.GetTempPath(), "volcanic-prompt", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); try { Assert.Empty(SkillCatalog.Load(dir)); var section = SkillCatalog.Render(dir, []); Assert.Contains(Path.Combine(dir, ".volcanic", "skills"), section); Assert.EndsWith("No skills yet.", section); } finally { Directory.Delete(dir, true); } } [Fact] public void Instructions_go_into_every_prompt_with_or_without_a_skill_md() { var dir = Path.Combine(Path.GetTempPath(), "volcanic-prompt", Guid.NewGuid().ToString("N")); var skills = Path.Combine(dir, ".volcanic", "skills"); foreach (var name in new[] { "b-trips", "a-rules", "c-empty", "d-nameless" }) Directory.CreateDirectory(Path.Combine(skills, name)); File.WriteAllText(Path.Combine(skills, "b-trips", "SKILL.md"), "---\nname: trip-planner\ndescription: Plans trips\n---\nLazy body"); File.WriteAllText(Path.Combine(skills, "b-trips", "instructions.md"), "---\ntitle: ignored\n---\nAlways quote prices in EUR.\n"); File.WriteAllText(Path.Combine(skills, "a-rules", "INSTRUCTIONS.md"), "Never book before 9:00."); File.WriteAllText(Path.Combine(skills, "c-empty", "INSTRUCTIONS.md"), "---\nx: y\n---\n \n"); File.WriteAllText(Path.Combine(skills, "d-nameless", "SKILL.md"), "---\nname: \"named \\\"rules\\\"\"\n---\n"); File.WriteAllText(Path.Combine(skills, "d-nameless", "INSTRUCTIONS.md"), "Use tags as is."); try { var builder = new SystemPromptBuilder(dir, () => [], new FakeTimeProvider(DateTimeOffset.UnixEpoch), () => TimeZoneInfo.Utc); var prompt = builder.Render(builder.DefaultOptions("BASE")); Assert.Equal(["a-rules", "trip-planner", "named \\\"rules\\\""], SkillCatalog.LoadInstructions(dir).Select(i => i.Name)); Assert.Contains($"\nNever book before 9:00.\n", prompt); Assert.Contains($"\nAlways quote prices in EUR.\n", prompt); Assert.Contains("skill=\"named \\"rules\\"\"", prompt); Assert.Contains("Use tags as is.", prompt); Assert.DoesNotContain("ignored", prompt); Assert.DoesNotContain("c-empty", prompt); Assert.DoesNotContain("Lazy body", prompt); Assert.Equal(["trip-planner"], SkillCatalog.Load(dir).Select(s => s.Name)); var order = new[] { "\n\n", "\n\n", "\n\n" }.Select(t => prompt.IndexOf(t, StringComparison.Ordinal)).ToList(); Assert.Equal(order.Order(), order); Assert.True(order[0] >= 0); File.WriteAllText(Path.Combine(skills, "a-rules", "INSTRUCTIONS.md"), "Never book before 10:00."); Assert.Contains("Never book before 10:00.", builder.Render(builder.DefaultOptions("BASE"))); var narrow = builder.DefaultOptions("BASE"); narrow.IncludeProjectContext = false; Assert.DoesNotContain("", builder.Render(narrow)); } finally { Directory.Delete(dir, true); } } [Fact] public void Without_instructions_there_is_no_instructions_section() { var dir = Path.Combine(Path.GetTempPath(), "volcanic-prompt", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); try { var builder = new SystemPromptBuilder(dir, () => [], new FakeTimeProvider(DateTimeOffset.UnixEpoch), () => TimeZoneInfo.Utc); Assert.DoesNotContain("\n\n", builder.Render(builder.DefaultOptions("BASE"))); } finally { Directory.Delete(dir, true); } } [Fact] public void Instructions_are_capped_per_file_and_in_total_with_a_pointer_to_the_file() { var dir = Path.Combine(Path.GetTempPath(), "volcanic-prompt", Guid.NewGuid().ToString("N")); var skills = Path.Combine(dir, ".volcanic", "skills"); var per = SkillCatalog.MaxInstructionsLength; // 3 full files use 48 KB, the fourth is cut by its own cap, the fifth gets nothing. for (var i = 0; i < 5; i++) { Directory.CreateDirectory(Path.Combine(skills, $"s{i}")); File.WriteAllText(Path.Combine(skills, $"s{i}", "INSTRUCTIONS.md"), new string((char)('a' + i), i == 3 ? per + 500 : per)); } Directory.CreateDirectory(Path.Combine(skills, "s5")); File.WriteAllText(Path.Combine(skills, "s5", "INSTRUCTIONS.md"), "late"); try { var loaded = SkillCatalog.LoadInstructions(dir); Assert.Equal([per, per, per, per, 0, 0], loaded.Select(i => i.Content.Length)); Assert.Equal([false, false, false, true, true, true], loaded.Select(i => i.Truncated)); Assert.Equal(SkillCatalog.MaxTotalInstructionsLength, loaded.Sum(i => i.Content.Length)); var section = SkillCatalog.RenderInstructions(loaded); Assert.Contains($"[…truncated, read the full file at {Path.Combine(skills, "s3", "INSTRUCTIONS.md")}]", section); Assert.Contains($"location=\"{Path.Combine(skills, "s5", "INSTRUCTIONS.md")}\">\n[…truncated, read the full file at {Path.Combine(skills, "s5", "INSTRUCTIONS.md")}]\n", section); Assert.DoesNotContain("late", section); } finally { Directory.Delete(dir, true); } } [Theory] [InlineData("---\na: b\n---\nbody", "body")] [InlineData("\uFEFF---\r\na: b\r\n---\r\nbody", "body")] [InlineData("---\nnever closed\nbody", "---\nnever closed\nbody")] [InlineData("plain\n---\nrule", "plain\n---\nrule")] public void StripFrontmatter_cuts_only_a_leading_closed_block(string text, string expected) => Assert.Equal(expected, SkillCatalog.StripFrontmatter(text)); } public class CompactorTests { private static AssistantMessage Assistant(string text, int? usageTokens = null) => new() { Provider = "p", Api = "a", Model = "m", StopReason = StopReason.Stop, Content = [new TextContent(text)], Usage = usageTokens is { } u ? new Usage(u, 0, 0, 0) : Usage.Zero, }; [Fact] public void ShouldCompact_and_ContextTokens() { var compactor = new Compactor(new CompactionSettings(ReserveTokens: 1_000), new ModelRegistry(new ApiKeyResolver(null, _ => null))); var model = FauxProvider.EchoModel with { ContextWindow = 5_000 }; Assert.False(compactor.ShouldCompact(3_999, model)); Assert.True(compactor.ShouldCompact(4_001, model)); Assert.False(compactor.ShouldCompact(null, model)); Assert.False(new Compactor(new CompactionSettings(Enabled: false), new ModelRegistry(new ApiKeyResolver(null, _ => null))).ShouldCompact(999_999, model)); Assert.Null(Compactor.ContextTokens([])); Assert.Equal(3, Compactor.ContextTokens([UserMessage.FromText("twelve chars")])); var withUsage = Compactor.ContextTokens([UserMessage.FromText("q"), Assistant("a", 500), UserMessage.FromText("eight ch")]); Assert.Equal(502, withUsage); } [Fact] public async Task Prepare_keeps_recent_messages_and_never_cuts_before_a_tool_result() { var session = await SessionManager.CreateAsync(new InMemorySessionStore(), "s", "/tmp"); await session.AppendMessageAsync(UserMessage.FromText(new string('a', 400))); await session.AppendMessageAsync(Assistant(new string('b', 400))); var call = new AssistantMessage { Provider = "p", Api = "a", Model = "m", StopReason = StopReason.ToolUse, Content = [new ToolCallContent("c1", "bash", VolcanicJson.EmptyObject)] }; var callEntry = await session.AppendMessageAsync(call); await session.AppendMessageAsync(new ToolResultMessage { ToolCallId = "c1", ToolName = "bash", Content = [new TextContent(new string('r', 400))] }); await session.AppendMessageAsync(Assistant("short")); var compactor = new Compactor(new CompactionSettings(KeepRecentTokens: 120), new ModelRegistry(new ApiKeyResolver(null, _ => null))); var prep = compactor.Prepare(session); Assert.Equal(callEntry.Id, prep.FirstKeptEntryId); Assert.Equal(2, prep.EntriesToSummarize.Count); Assert.Equal(3, prep.EntriesToKeep.Count); Assert.Null(prep.PreviousSummary); var text = Compactor.Serialize(prep.EntriesToSummarize, maxBlockChars: 10); Assert.Contains("[User]: aaaaaaaaaa… [390 more chars]", text); Assert.Contains("[Assistant]: bbbbbbbbbb", text); var full = Compactor.Serialize(session.GetBranch()); Assert.Contains("[Assistant tool calls]: bash({})", full); Assert.Contains("[Tool result]: rrr", full); } [Fact] public async Task Summarize_uses_the_model_and_extracts_file_lists() { var faux = new FauxProvider().Enqueue(FauxTurn.Say("## Goal\nDo X\n\na.cs\nb.cs\n\n\nc.cs\n")); var registry = new ModelRegistry(new ApiKeyResolver(null, _ => null)); registry.Register(faux.Registration); var model = registry.Find(FauxProvider.ModelKey)!; var session = await SessionManager.CreateAsync(new InMemorySessionStore(), "s", "/tmp"); await session.AppendMessageAsync(UserMessage.FromText("old question")); await session.AppendMessageAsync(Assistant("old answer")); await session.AppendMessageAsync(UserMessage.FromText("new")); var compactor = new Compactor(new CompactionSettings(KeepRecentTokens: 1), registry); var prep = compactor.Prepare(session); var result = await compactor.SummarizeAsync(prep, model, "keep it short", CancellationToken.None); Assert.StartsWith("## Goal", result.Summary); Assert.Equal(prep.FirstKeptEntryId, result.FirstKeptEntryId); Assert.Equal(["a.cs", "b.cs"], result.Details!.Value.GetProperty("readFiles").EnumerateArray().Select(e => e.GetString())); var request = faux.Requests.Single(); Assert.Equal(CompactionPrompts.SystemPrompt, request.SystemPrompt); Assert.Contains("[User]: old question", request.Messages[0].Text); Assert.Contains("keep it short", request.Messages[0].Text); Assert.Equal(Volcanic.AgenticCore.Abstractions.Providers.CacheRetention.None, faux.Options.Single().CacheRetention); faux.Enqueue(FauxTurn.Error("nope")); await Assert.ThrowsAsync(() => compactor.SummarizeAsync(prep, model, null, CancellationToken.None)); } }