forked from admin/VolcanicAgenticCore
176 lines
9.4 KiB
C#
176 lines
9.4 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 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));
|
|
}
|
|
}
|