Files
Playa/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ContextSerializerTests.cs

217 lines
12 KiB
C#

using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Extensions.ClaudeCode;
namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
public class ContextSerializerTests : IDisposable
{
private readonly ClaudeImageStore _images = new();
public void Dispose() => _images.Dispose();
private static AssistantMessage Assistant(params ContentBlock[] content) => new()
{
Content = content,
Provider = "claude-code",
Api = "claude-code-headless",
Model = "sonnet",
StopReason = StopReason.Stop,
Usage = new Usage(1, 1, 0, 0),
};
private static ImageContent Pixels(string text) => new(Convert.ToBase64String(Encoding.UTF8.GetBytes(text)), "image/png");
private static IReadOnlyList<JsonElement> Blocks(JsonObject stdin) =>
stdin["message"]!["content"]!.AsArray().Select(b => JsonSerializer.Deserialize<JsonElement>(b!.ToJsonString())).ToList();
private static JsonElement ParseRecord(JsonElement block) => JsonSerializer.Deserialize<JsonElement>(block.GetProperty("text").GetString()!);
[Fact]
public void Header_then_one_record_per_message_inside_one_user_message()
{
var context = new LlmContext(
"system is passed separately",
[
UserMessage.FromText("hello"),
Assistant(new ThinkingContent("thought", "opaque-signature"), new TextContent("hi"), new ToolCallContent("call-1", "probe", Fixtures.Record("{\"path\":\"a\"}"))),
new ToolResultMessage { ToolCallId = "call-1", ToolName = "probe", Content = [new TextContent("result")], IsError = false },
],
[Fixtures.Probe]);
var stdin = ContextSerializer.BuildStdinMessage(context, Fixtures.ProbeCatalog, _images, cacheBreakpoint: false);
Assert.Equal("user", stdin["type"]!.GetValue<string>());
Assert.Equal("user", stdin["message"]!["role"]!.GetValue<string>());
var blocks = Blocks(stdin);
Assert.Equal(4, blocks.Count);
Assert.All(blocks, b => Assert.Equal("text", b.GetProperty("type").GetString()));
var header = ParseRecord(blocks[0]);
Assert.Equal("volcanic-claude-code-context-v1", header.GetProperty("protocol").GetString());
Assert.Contains("Continue this conversation", header.GetProperty("instruction").GetString());
var map = Assert.Single(header.GetProperty("toolNameMap").EnumerateArray());
Assert.Equal("mcp__pi__probe", map.GetProperty("transportName").GetString());
Assert.Equal("probe", map.GetProperty("toolName").GetString());
var user = ParseRecord(blocks[1]);
Assert.Equal("user", user.GetProperty("role").GetString());
Assert.Equal("hello", user.GetProperty("content").GetString());
var assistant = ParseRecord(blocks[2]);
Assert.Equal("assistant", assistant.GetProperty("role").GetString());
var content = assistant.GetProperty("content");
Assert.Equal("thinking", content[0].GetProperty("type").GetString());
Assert.Equal("thought", content[0].GetProperty("thinking").GetString());
Assert.False(content[0].TryGetProperty("signature", out _));
Assert.False(content[0].TryGetProperty("redacted", out _));
Assert.Equal("hi", content[1].GetProperty("text").GetString());
Assert.Equal("toolCall", content[2].GetProperty("type").GetString());
Assert.Equal("call-1", content[2].GetProperty("id").GetString());
Assert.Equal("mcp__pi__probe", content[2].GetProperty("name").GetString());
Assert.Equal("a", content[2].GetProperty("arguments").GetProperty("path").GetString());
Assert.False(assistant.TryGetProperty("usage", out _));
var result = ParseRecord(blocks[3]);
Assert.Equal("toolResult", result.GetProperty("role").GetString());
Assert.Equal("call-1", result.GetProperty("toolCallId").GetString());
Assert.Equal("mcp__pi__probe", result.GetProperty("toolName").GetString());
Assert.Equal("result", result.GetProperty("content")[0].GetProperty("text").GetString());
Assert.False(result.GetProperty("isError").GetBoolean());
}
[Fact]
public void At_signs_are_escaped_so_claude_cannot_expand_paths()
{
var context = new LlmContext(null, [UserMessage.FromText("Do not expand @/etc/passwd"), Assistant(new TextContent("mail me@example.com"))], []);
var stdin = ContextSerializer.BuildStdinMessage(context, Fixtures.EmptyCatalog, _images, cacheBreakpoint: false);
foreach (var block in Blocks(stdin))
{
var text = block.GetProperty("text").GetString()!;
Assert.DoesNotContain("@", text);
Assert.Contains(text.Contains("passwd") || text.Contains("example") ? "\\u0040" : "protocol", text);
}
Assert.Equal("Do not expand @/etc/passwd", ParseRecord(Blocks(stdin)[1]).GetProperty("content").GetString());
Assert.Equal("mail me@example.com", ParseRecord(Blocks(stdin)[2]).GetProperty("content")[0].GetProperty("text").GetString());
}
[Fact]
public void Cache_control_marks_only_the_last_block_and_only_when_requested()
{
var context = new LlmContext(null, [UserMessage.FromText("one"), Assistant(new TextContent("two")), UserMessage.FromText("three")], []);
var marked = Blocks(ContextSerializer.BuildStdinMessage(context, Fixtures.EmptyCatalog, _images, cacheBreakpoint: true));
Assert.Equal(4, marked.Count);
for (var i = 0; i < 3; i++) Assert.False(marked[i].TryGetProperty("cache_control", out _));
var control = marked[3].GetProperty("cache_control");
Assert.Equal("ephemeral", control.GetProperty("type").GetString());
Assert.Equal("1h", control.GetProperty("ttl").GetString());
var unmarked = Blocks(ContextSerializer.BuildStdinMessage(context, Fixtures.EmptyCatalog, _images, cacheBreakpoint: false));
Assert.All(unmarked, b => Assert.False(b.TryGetProperty("cache_control", out _)));
}
[Fact]
public void Images_become_attachments_referenced_after_the_breakpoint()
{
var image = Pixels("png!");
var context = new LlmContext(null,
[
new UserMessage { Content = [new TextContent("look"), image] },
Assistant(new ToolCallContent("c1", "probe", Fixtures.Record("{}"))),
new ToolResultMessage { ToolCallId = "c1", ToolName = "probe", Content = [Pixels("tool result"), image], IsError = false },
],
[Fixtures.Probe]);
var stdin = ContextSerializer.BuildStdinMessage(context, Fixtures.ProbeCatalog, _images, cacheBreakpoint: true);
var blocks = Blocks(stdin);
// header + 3 records + the attachment instruction
Assert.Equal(5, blocks.Count);
var user = ParseRecord(blocks[1]).GetProperty("content");
Assert.Equal("image_attachment", user[1].GetProperty("type").GetString());
Assert.Matches("^image-[a-f0-9]{64}\\.png$", user[1].GetProperty("attachment").GetString());
Assert.Equal("image/png", user[1].GetProperty("mimeType").GetString());
Assert.True(blocks[3].TryGetProperty("cache_control", out _), "the breakpoint sits on the last transcript record");
Assert.False(blocks[4].TryGetProperty("cache_control", out _), "the attachment list stays outside the breakpoint");
var instruction = blocks[4].GetProperty("text").GetString()!;
Assert.StartsWith("Generated image attachments for image_attachment blocks: @\"", instruction);
var transcript = ContextSerializer.Serialize(context, Fixtures.ProbeCatalog, _images);
Assert.Equal(2, transcript.AttachmentPaths.Count);
Assert.Equal(3, transcript.ImageCount);
foreach (var path in transcript.AttachmentPaths)
{
Assert.True(Path.IsPathRooted(path));
Assert.True(File.Exists(path));
Assert.Contains($"@\"{path}\"", instruction);
}
Assert.Equal("png!", File.ReadAllText(transcript.AttachmentPaths[0]));
}
[Fact]
public void Tools_outside_the_catalogue_get_the_unavailable_label_and_results_follow_their_call()
{
var context = new LlmContext(null,
[
Assistant(new ToolCallContent("c1", "removed tool", Fixtures.Record("{\"x\":1}")), new ToolCallContent("c2", "probe", Fixtures.Record("{}"))),
new ToolResultMessage { ToolCallId = "c1", ToolName = "stale name", Content = [new TextContent("one")], IsError = true },
new ToolResultMessage { ToolCallId = "orphan", ToolName = "orphaned tool", Content = [new TextContent("two")], IsError = false },
],
[Fixtures.Probe]);
var blocks = Blocks(ContextSerializer.BuildStdinMessage(context, Fixtures.ProbeCatalog, _images, cacheBreakpoint: false));
var assistant = ParseRecord(blocks[1]).GetProperty("content");
Assert.Equal("[unavailable tool removed tool]", assistant[0].GetProperty("name").GetString());
Assert.Equal("mcp__pi__probe", assistant[1].GetProperty("name").GetString());
Assert.Equal("[unavailable tool removed tool]", ParseRecord(blocks[2]).GetProperty("toolName").GetString());
Assert.True(ParseRecord(blocks[2]).GetProperty("isError").GetBoolean());
Assert.Equal("[unavailable tool orphaned tool]", ParseRecord(blocks[3]).GetProperty("toolName").GetString());
}
[Fact]
public void Custom_messages_are_user_turns_and_empty_assistant_messages_are_dropped()
{
var context = new LlmContext(null,
[CustomMessage.FromText("note", "steer"), Assistant(), new UserMessage { Content = [new TextContent("a"), new TextContent("b")] }],
[]);
var blocks = Blocks(ContextSerializer.BuildStdinMessage(context, Fixtures.EmptyCatalog, _images, cacheBreakpoint: false));
Assert.Equal(3, blocks.Count);
Assert.Equal("steer", ParseRecord(blocks[1]).GetProperty("content").GetString());
Assert.Equal(2, ParseRecord(blocks[2]).GetProperty("content").GetArrayLength());
}
[Fact]
public void Transcript_over_32_megabytes_is_a_context_overflow()
{
var context = new LlmContext(null, [UserMessage.FromText(new string('x', ContextSerializer.MaxTranscriptBytes + 1024))], []);
var ex = Assert.Throws<ClaudeCodeException>(() => ContextSerializer.BuildStdinMessage(context, Fixtures.EmptyCatalog, _images, cacheBreakpoint: false));
Assert.Equal("context_overflow", ex.Code);
Assert.StartsWith("context_length_exceeded: ", ex.Message);
Assert.StartsWith("context_length_exceeded: ", ex.FormatForUser());
}
[Fact]
public void Invalid_images_and_misplaced_blocks_are_rejected()
{
var badBase64 = new LlmContext(null, [new UserMessage { Content = [new ImageContent("%%%", "image/png")] }], []);
Assert.Equal("image", Assert.Throws<ClaudeCodeException>(() => ContextSerializer.Serialize(badBase64, Fixtures.EmptyCatalog, _images)).Code);
var badType = new LlmContext(null, [new UserMessage { Content = [new ImageContent("AA==", "image/bmp")] }], []);
Assert.Equal("image", Assert.Throws<ClaudeCodeException>(() => ContextSerializer.Serialize(badType, Fixtures.EmptyCatalog, _images)).Code);
var thinkingInUser = new LlmContext(null, [new UserMessage { Content = [new ThinkingContent("leaked")] }], []);
Assert.Equal("content_shape", Assert.Throws<ClaudeCodeException>(() => ContextSerializer.Serialize(thinkingInUser, Fixtures.EmptyCatalog, _images)).Code);
var tooMany = new LlmContext(null, [new UserMessage { Content = Enumerable.Range(0, 21).Select(i => (ContentBlock)Pixels($"img {i}")).ToList() }], []);
Assert.Contains("At most 20 images", Assert.Throws<ClaudeCodeException>(() => ContextSerializer.Serialize(tooMany, Fixtures.EmptyCatalog, _images)).Message);
}
}