Files
VolcanicAgenticCore/tests/Volcanic.AgenticCore.Tests/Features/PromptAndCompactionTests.cs

230 lines
12 KiB
C#

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<object>("alpha", "d", (_, _, _) => Task.FromResult(ToolResult.Text("")), promptSnippet: "alpha: does a", promptGuidelines: ["Use alpha wisely"]),
new DelegateTool<object>("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("<tools>\nAvailable tools:\n- alpha: does a\n</tools>", prompt);
Assert.DoesNotContain("silent", prompt);
Assert.Contains("- Use alpha wisely", prompt);
Assert.Contains("<extra>\nmore\n</extra>", prompt);
var ctxStart = prompt.IndexOf("<project_context>", 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("<tools>", narrow);
Assert.DoesNotContain("project_context", narrow);
Assert.DoesNotContain("<memory>", 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("<persona>\nDry wit.\n</persona>", prompt);
Assert.Contains($"from {Path.Combine(dir, "memory.md")}", prompt);
Assert.Contains("- User prefers tabs\n</memory>", prompt);
var order = new[] { "<project_context>", "<persona>", "<memory>", "<environment>" }.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("<memory>", prompt);
File.Delete(Path.Combine(dir, "persona.md"));
File.Delete(Path.Combine(dir, "memory.md"));
prompt = builder.Render(builder.DefaultOptions("BASE"));
Assert.DoesNotContain("<persona>", prompt);
Assert.DoesNotContain("<memory>", 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 <PDF> 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-name>/SKILL.md", prompt);
Assert.Contains("<name>pdf-tools</name>\n <description>Fill &lt;PDF&gt; forms</description>\n <location>" + Path.Combine(skills, "pdf", "SKILL.md"), prompt);
Assert.Contains("<name>folded</name>\n <description>Plans trips and books hotels</description>", prompt);
Assert.DoesNotContain("nope", prompt);
Assert.DoesNotContain("Body stays out", prompt);
var order = new[] { "<skills>", "<environment>" }.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("<name>zeta</name>", 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("<skills>", 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); }
}
}
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<read-files>\na.cs\nb.cs\n</read-files>\n<modified-files>\nc.cs\n</modified-files>"));
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<InvalidOperationException>(() => compactor.SummarizeAsync(prep, model, null, CancellationToken.None));
}
}