refactor: rename Kopaya to Eryx

Rename the product, runtime surfaces, sidecar projects, docs, tests, and packaging outputs from Kopaya to Eryx across the repository.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-23 22:01:23 +01:00
co-authored by Copilot
parent fd120b6f60
commit 50a8e5dfbe
50 changed files with 157 additions and 157 deletions
@@ -0,0 +1,142 @@
using Eryx.AgentHost.Contracts;
using Eryx.AgentHost.Services;
namespace Eryx.AgentHost.Tests;
public sealed class AgentIdentityResolverTests
{
[Fact]
public void TryResolveKnownAgentIdentity_MatchesRuntimeExecutorIdentifier()
{
PatternDefinitionDto pattern = CreatePattern(
[
CreateAgent(id: "agent-concurrent-architect", name: "Architect"),
CreateAgent(id: "agent-concurrent-product", name: "Product"),
]);
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
pattern,
"Architect_agent_concurrent_architect",
out AgentIdentity agent);
Assert.True(resolved);
Assert.Equal("agent-concurrent-architect", agent.AgentId);
Assert.Equal("Architect", agent.AgentName);
}
[Fact]
public void TryResolveKnownAgentIdentity_MatchesSanitizedNameAndId()
{
PatternDefinitionDto pattern = CreatePattern(
[
CreateAgent(id: "agent-single-primary", name: "Primary Agent"),
],
mode: "single");
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
pattern,
"Primary_Agent_agent_single_primary",
out AgentIdentity agent);
Assert.True(resolved);
Assert.Equal("agent-single-primary", agent.AgentId);
Assert.Equal("Primary Agent", agent.AgentName);
}
[Fact]
public void TryResolveKnownAgentIdentity_MapsAssistantToSingleAgent()
{
PatternDefinitionDto pattern = CreatePattern(
[
CreateAgent(id: "agent-single-primary", name: "Primary Agent"),
],
mode: "single");
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
pattern,
"assistant",
out AgentIdentity agent);
Assert.True(resolved);
Assert.Equal("agent-single-primary", agent.AgentId);
Assert.Equal("Primary Agent", agent.AgentName);
}
[Fact]
public void TryResolveKnownAgentIdentity_DoesNotGuessAssistantForMultiAgentPattern()
{
PatternDefinitionDto pattern = CreatePattern(
[
CreateAgent(id: "agent-concurrent-architect", name: "Architect"),
CreateAgent(id: "agent-concurrent-product", name: "Product"),
]);
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
pattern,
"assistant",
out _);
Assert.False(resolved);
}
[Fact]
public void ResolveDisplayAuthorName_UsesCanonicalAgentName()
{
PatternDefinitionDto pattern = CreatePattern(
[
CreateAgent(id: "agent-concurrent-implementer", name: "Implementer"),
],
mode: "single");
string authorName = AgentIdentityResolver.ResolveDisplayAuthorName(
pattern,
"Implementer_agent_concurrent_implementer");
Assert.Equal("Implementer", authorName);
}
[Fact]
public void TryResolveObservedAgentIdentity_UsesFallbackAgentForGenericAssistant()
{
PatternDefinitionDto pattern = CreatePattern(
[
CreateAgent(id: "agent-handoff-ux", name: "UX Specialist"),
CreateAgent(id: "agent-handoff-runtime", name: "Runtime Specialist"),
]);
bool resolved = AgentIdentityResolver.TryResolveObservedAgentIdentity(
pattern,
"assistant",
new AgentIdentity("agent-handoff-ux", "UX Specialist"),
out AgentIdentity agent);
Assert.True(resolved);
Assert.Equal("agent-handoff-ux", agent.AgentId);
Assert.Equal("UX Specialist", agent.AgentName);
}
private static PatternDefinitionDto CreatePattern(
IReadOnlyList<PatternAgentDefinitionDto> agents,
string mode = "concurrent")
{
return new PatternDefinitionDto
{
Id = $"{mode}-pattern",
Name = "Pattern",
Mode = mode,
Availability = "available",
Agents = agents,
};
}
private static PatternAgentDefinitionDto CreateAgent(string id, string name)
{
return new PatternAgentDefinitionDto
{
Id = id,
Name = name,
Model = "gpt-5.4",
Instructions = "Help with the request.",
};
}
}
@@ -0,0 +1,106 @@
using Eryx.AgentHost.Contracts;
using Eryx.AgentHost.Services;
namespace Eryx.AgentHost.Tests;
public sealed class AgentInstructionComposerTests
{
[Fact]
public void Compose_LeavesNonHandoffInstructionsUnchanged()
{
PatternDefinitionDto pattern = new()
{
Id = "pattern-sequential",
Name = "Sequential",
Mode = "sequential",
Availability = "available",
};
PatternAgentDefinitionDto agent = CreateAgent(
id: "agent-reviewer",
name: "Reviewer",
instructions: "Review the proposal.");
string instructions = AgentInstructionComposer.Compose(pattern, agent, agentIndex: 0);
Assert.Equal("Review the proposal.", instructions);
}
[Fact]
public void Compose_StrengthensHandoffTriageInstructions()
{
PatternDefinitionDto pattern = new()
{
Id = "pattern-handoff",
Name = "Handoff",
Mode = "handoff",
Availability = "available",
};
PatternAgentDefinitionDto triage = CreateAgent(
id: "agent-handoff-triage",
name: "Triage",
instructions: "You triage requests and must hand them off to the most appropriate specialist.");
string instructions = AgentInstructionComposer.Compose(pattern, triage, agentIndex: 0);
Assert.Contains("routing gate", instructions, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Do not perform the specialist", instructions, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Compose_StrengthensHandoffSpecialistInstructions()
{
PatternDefinitionDto pattern = new()
{
Id = "pattern-handoff",
Name = "Handoff",
Mode = "handoff",
Availability = "available",
};
PatternAgentDefinitionDto specialist = CreateAgent(
id: "agent-handoff-ux",
name: "UX Specialist",
instructions: "You focus on navigation, UX, and interaction details.");
string instructions = AgentInstructionComposer.Compose(pattern, specialist, agentIndex: 1);
Assert.Contains("Once the triage agent hands work to you", instructions, StringComparison.OrdinalIgnoreCase);
Assert.Contains("own the substantive answer", instructions, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Compose_AddsScratchpadGuidanceForProjectlessQaSessions()
{
PatternDefinitionDto pattern = new()
{
Id = "pattern-single",
Name = "Single",
Mode = "single",
Availability = "available",
};
PatternAgentDefinitionDto agent = CreateAgent(
id: "agent-primary",
name: "Primary Agent",
instructions: "You are a helpful assistant.");
string instructions = AgentInstructionComposer.Compose(
pattern,
agent,
agentIndex: 0,
workspaceKind: "scratchpad");
Assert.Contains("scratchpad mode", instructions, StringComparison.OrdinalIgnoreCase);
Assert.Contains("pure ad-hoc Q&A", instructions, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Do not inspect, modify, create, or delete files", instructions, StringComparison.OrdinalIgnoreCase);
}
private static PatternAgentDefinitionDto CreateAgent(string id, string name, string instructions)
{
return new PatternAgentDefinitionDto
{
Id = id,
Name = name,
Instructions = instructions,
Model = "gpt-5.4",
};
}
}
@@ -0,0 +1,125 @@
using Eryx.AgentHost.Services;
namespace Eryx.AgentHost.Tests;
public sealed class CopilotCliPathResolverTests
{
[Fact]
public void Resolve_UsesCopilotFromPath()
{
string copilotDirectory = @"C:\tools\copilot";
HashSet<string> existingFiles = new(StringComparer.OrdinalIgnoreCase)
{
Path.Combine(copilotDirectory, "copilot.exe"),
};
string? cliPath = CopilotCliPathResolver.Resolve(
pathValue: $"C:\\other;\"{copilotDirectory}\"",
pathExtValue: ".COM;.EXE;.BAT;.CMD",
isWindows: true,
fileExists: existingFiles.Contains);
Assert.Equal(Path.Combine(copilotDirectory, "copilot.exe"), cliPath, ignoreCase: true);
}
[Fact]
public void Resolve_UsesDefaultWindowsExtensionsWhenPathExtMissing()
{
string copilotDirectory = @"C:\tools\copilot";
HashSet<string> existingFiles = new(StringComparer.OrdinalIgnoreCase)
{
Path.Combine(copilotDirectory, "copilot.cmd"),
};
string? cliPath = CopilotCliPathResolver.Resolve(
pathValue: $"C:\\other;\"{copilotDirectory}\"",
pathExtValue: null,
isWindows: true,
fileExists: existingFiles.Contains);
Assert.Equal(Path.Combine(copilotDirectory, "copilot.cmd"), cliPath, ignoreCase: true);
}
[Fact]
public void Resolve_ReturnsNullWhenPathDoesNotContainCopilot()
{
string? cliPath = CopilotCliPathResolver.Resolve(
pathValue: @"C:\tools;C:\other",
pathExtValue: ".COM;.EXE;.BAT;.CMD",
isWindows: true,
fileExists: _ => false);
Assert.Null(cliPath);
}
[Fact]
public void ResolveCliEnvironment_RemovesRuntimeSpecificPrefixes()
{
IReadOnlyDictionary<string, string> environment = CopilotCliPathResolver.ResolveCliEnvironment(
[
new KeyValuePair<string, string?>("PATH", @"C:\tools"),
new KeyValuePair<string, string?>("APPDATA", @"C:\Users\mail\AppData\Roaming"),
new KeyValuePair<string, string?>("COPILOT_CLI", "1"),
new KeyValuePair<string, string?>("NODE_OPTIONS", "--no-warnings"),
new KeyValuePair<string, string?>("electron_run_as_node", "1"),
new KeyValuePair<string, string?>("BUN_FAKE_FLAG", "1"),
new KeyValuePair<string, string?>("npm_config_user_agent", "bun/1.3.6"),
]);
Assert.Equal(
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["PATH"] = @"C:\tools",
["APPDATA"] = @"C:\Users\mail\AppData\Roaming",
},
environment);
}
[Fact]
public void ResolveCliEnvironment_PreservesUnrelatedVariables()
{
IReadOnlyDictionary<string, string> environment = CopilotCliPathResolver.ResolveCliEnvironment(
[
new KeyValuePair<string, string?>("PATH", @"C:\tools"),
new KeyValuePair<string, string?>("HOME", @"C:\Users\mail"),
new KeyValuePair<string, string?>("HTTPS_PROXY", "http://proxy.local:8080"),
new KeyValuePair<string, string?>("FORCE_COLOR", "1"),
]);
Assert.Equal(
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["PATH"] = @"C:\tools",
["HOME"] = @"C:\Users\mail",
["HTTPS_PROXY"] = "http://proxy.local:8080",
["FORCE_COLOR"] = "1",
},
environment);
}
[Fact]
public void ResolveCliLaunch_UsesCommandProcessorWrapperOnWindows()
{
CopilotCliLaunch launch = CopilotCliPathResolver.ResolveCliLaunch(
cliPath: @"C:\Tools With Spaces\copilot.exe",
isWindows: true,
commandProcessorPath: @"C:\Windows\System32\cmd.exe");
Assert.Equal(@"C:\Windows\System32\cmd.exe", launch.Path);
Assert.Equal(
["/d", "/s", "/c", "copilot"],
launch.Args);
}
[Fact]
public void ResolveCliLaunch_UsesCliDirectlyOutsideWindows()
{
CopilotCliLaunch launch = CopilotCliPathResolver.ResolveCliLaunch(
cliPath: "/usr/local/bin/copilot",
isWindows: false,
commandProcessorPath: null);
Assert.Equal("/usr/local/bin/copilot", launch.Path);
Assert.Empty(launch.Args);
}
}
@@ -0,0 +1,75 @@
using Eryx.AgentHost.Contracts;
using Eryx.AgentHost.Services;
namespace Eryx.AgentHost.Tests;
public sealed class CopilotConnectionMetadataResolverTests
{
[Fact]
public void ParseCliVersionOutput_ReturnsLatestStatusForCurrentInstall()
{
SidecarCopilotCliVersionDiagnosticsDto diagnostics =
CopilotConnectionMetadataResolver.ParseCliVersionOutput(
"""
GitHub Copilot CLI 1.0.10
You are running the latest version.
""");
Assert.Equal("latest", diagnostics.Status);
Assert.Equal("1.0.10", diagnostics.InstalledVersion);
Assert.Equal("1.0.10", diagnostics.LatestVersion);
}
[Fact]
public void ParseCliVersionOutput_ReturnsOutdatedStatusWhenNewerVersionIsAvailable()
{
SidecarCopilotCliVersionDiagnosticsDto diagnostics =
CopilotConnectionMetadataResolver.ParseCliVersionOutput(
"""
GitHub Copilot CLI 1.0.9
A newer version 1.0.10 is available.
Run 'copilot update' to install it.
""");
Assert.Equal("outdated", diagnostics.Status);
Assert.Equal("1.0.9", diagnostics.InstalledVersion);
Assert.Equal("1.0.10", diagnostics.LatestVersion);
}
[Fact]
public void CreateCliCommand_UsesLaunchPathAndAppendsCommandArguments()
{
CopilotCliContext cliContext = new(
CliPath: @"C:\tools\copilot.exe",
LaunchPath: @"C:\Windows\System32\cmd.exe",
LaunchArgs: ["/d", "/s", "/c", "copilot"],
Environment: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase));
(string executablePath, string[] arguments) =
CopilotConnectionMetadataResolver.CreateCliCommand(cliContext, "version");
Assert.Equal(@"C:\Windows\System32\cmd.exe", executablePath);
Assert.Equal(["/d", "/s", "/c", "copilot", "version"], arguments);
}
[Fact]
public void NormalizeHost_StripsSchemeAndTrailingSlash()
{
string? host = CopilotConnectionMetadataResolver.NormalizeHost("https://github.example.com/");
Assert.Equal("github.example.com", host);
}
[Fact]
public void ParseOrganizationsOutput_ReturnsDistinctOrganizations()
{
IReadOnlyList<string> organizations = CopilotConnectionMetadataResolver.ParseOrganizationsOutput(
"""
github
octo-org
github
""");
Assert.Equal(["github", "octo-org"], organizations);
}
}
@@ -0,0 +1,246 @@
using Eryx.AgentHost.Contracts;
using Eryx.AgentHost.Services;
using Microsoft.Extensions.AI;
namespace Eryx.AgentHost.Tests;
public sealed class CopilotWorkflowRunnerTests
{
[Fact]
public void SelectNewOutputMessages_SkipsFullTranscriptPrefix()
{
List<ChatMessage> inputMessages =
[
new(ChatRole.User, "Hello"),
];
List<ChatMessage> outputMessages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi there."),
];
IReadOnlyList<ChatMessage> newMessages = CopilotWorkflowRunner.SelectNewOutputMessages(
outputMessages,
inputMessages);
ChatMessage message = Assert.Single(newMessages);
Assert.Equal(ChatRole.Assistant, message.Role);
Assert.Equal("Hi there.", message.Text);
}
[Fact]
public void SelectNewOutputMessages_SkipsOnlyTheLatestInputOverlap()
{
List<ChatMessage> inputMessages =
[
new(ChatRole.Assistant, "Earlier answer"),
new(ChatRole.User, "Hello"),
];
List<ChatMessage> outputMessages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi there."),
];
IReadOnlyList<ChatMessage> newMessages = CopilotWorkflowRunner.SelectNewOutputMessages(
outputMessages,
inputMessages);
ChatMessage message = Assert.Single(newMessages);
Assert.Equal(ChatRole.Assistant, message.Role);
Assert.Equal("Hi there.", message.Text);
}
[Fact]
public void SelectNewOutputMessages_PreservesAssistantOnlyOutput()
{
List<ChatMessage> inputMessages =
[
new(ChatRole.User, "Hello"),
];
List<ChatMessage> outputMessages =
[
new(ChatRole.Assistant, "Hi there."),
];
IReadOnlyList<ChatMessage> newMessages = CopilotWorkflowRunner.SelectNewOutputMessages(
outputMessages,
inputMessages);
ChatMessage message = Assert.Single(newMessages);
Assert.Equal(ChatRole.Assistant, message.Role);
Assert.Equal("Hi there.", message.Text);
}
[Fact]
public void ProjectCompletedMessages_FallsBackToStreamingSegmentsWhenWorkflowOutputIsMissing()
{
RunTurnCommandDto command = new()
{
RequestId = "turn-1",
SessionId = "session-1",
Pattern = new PatternDefinitionDto
{
Id = "pattern-concurrent",
Name = "Concurrent Brainstorm",
Mode = "concurrent",
Availability = "available",
Agents =
[
CreateAgent(id: "agent-concurrent-architect", name: "Architect"),
CreateAgent(id: "agent-concurrent-implementer", name: "Implementer"),
],
},
};
IReadOnlyList<ChatMessageDto> messages = CopilotWorkflowRunner.ProjectCompletedMessages(
command,
[],
[
("msg-1", "Architect", "Architecture reply"),
("msg-2", "Implementer", "Implementation reply"),
]);
Assert.Collection(
messages,
architect =>
{
Assert.Equal("msg-1", architect.Id);
Assert.Equal("Architect", architect.AuthorName);
Assert.Equal("Architecture reply", architect.Content);
},
implementer =>
{
Assert.Equal("msg-2", implementer.Id);
Assert.Equal("Implementer", implementer.AuthorName);
Assert.Equal("Implementation reply", implementer.Content);
});
}
[Fact]
public void ProjectCompletedMessages_CanonicalizesWorkflowOutputAuthorNames()
{
RunTurnCommandDto command = new()
{
RequestId = "turn-1",
SessionId = "session-1",
Pattern = new PatternDefinitionDto
{
Id = "pattern-single",
Name = "Single Agent",
Mode = "single",
Availability = "available",
Agents =
[
CreateAgent(id: "agent-single-primary", name: "Primary Agent"),
],
},
};
IReadOnlyList<ChatMessageDto> messages = CopilotWorkflowRunner.ProjectCompletedMessages(
command,
[
new ChatMessage(ChatRole.Assistant, "Hello")
{
AuthorName = "assistant",
},
],
[
("msg-1", "Primary Agent", "Hello"),
]);
ChatMessageDto message = Assert.Single(messages);
Assert.Equal("msg-1", message.Id);
Assert.Equal("Primary Agent", message.AuthorName);
Assert.Equal("Hello", message.Content);
}
[Fact]
public void ProjectCompletedMessages_UsesFallbackAgentForGenericAssistantOutput()
{
RunTurnCommandDto command = new()
{
RequestId = "turn-1",
SessionId = "session-1",
Pattern = new PatternDefinitionDto
{
Id = "pattern-handoff",
Name = "Handoff Support Flow",
Mode = "handoff",
Availability = "available",
Agents =
[
CreateAgent(id: "agent-handoff-triage", name: "Triage"),
CreateAgent(id: "agent-handoff-ux", name: "UX Specialist"),
],
},
};
IReadOnlyList<ChatMessageDto> messages = CopilotWorkflowRunner.ProjectCompletedMessages(
command,
[
new ChatMessage(ChatRole.Assistant, "The button is in place.")
{
AuthorName = "assistant",
},
],
[],
new AgentIdentity("agent-handoff-ux", "UX Specialist"));
ChatMessageDto message = Assert.Single(messages);
Assert.Equal("UX Specialist", message.AuthorName);
Assert.Equal("The button is in place.", message.Content);
}
[Fact]
public void ProjectCompletedMessages_DropsBlankAssistantOutputMessages()
{
RunTurnCommandDto command = new()
{
RequestId = "turn-1",
SessionId = "session-1",
Pattern = new PatternDefinitionDto
{
Id = "pattern-handoff",
Name = "Handoff Support Flow",
Mode = "handoff",
Availability = "available",
Agents =
[
CreateAgent(id: "agent-handoff-triage", name: "Triage"),
CreateAgent(id: "agent-handoff-ux", name: "UX Specialist"),
],
},
};
IReadOnlyList<ChatMessageDto> messages = CopilotWorkflowRunner.ProjectCompletedMessages(
command,
[
new ChatMessage(ChatRole.Assistant, string.Empty)
{
AuthorName = "assistant",
},
new ChatMessage(ChatRole.Assistant, "Real content")
{
AuthorName = "assistant",
},
],
[],
new AgentIdentity("agent-handoff-ux", "UX Specialist"));
ChatMessageDto message = Assert.Single(messages);
Assert.Equal("UX Specialist", message.AuthorName);
Assert.Equal("Real content", message.Content);
}
private static PatternAgentDefinitionDto CreateAgent(string id, string name)
{
return new PatternAgentDefinitionDto
{
Id = id,
Name = name,
Model = "gpt-5.4",
Instructions = "Help with the request.",
};
}
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Eryx.AgentHost\Eryx.AgentHost.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,55 @@
using Eryx.AgentHost.Contracts;
using Eryx.AgentHost.Services;
namespace Eryx.AgentHost.Tests;
public sealed class HandoffWorkflowGuidanceTests
{
[Fact]
public void CreateWorkflowInstructions_RequiresRealHandoffs()
{
string instructions = HandoffWorkflowGuidance.CreateWorkflowInstructions();
Assert.Contains("explicit handoffs", instructions, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Do not claim that you delegated", instructions, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Specialists should complete the substantive work", instructions, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void CreateForwardReason_UsesTargetSpecialtyAndOwnership()
{
PatternAgentDefinitionDto specialist = new()
{
Id = "agent-handoff-ux",
Name = "UX Specialist",
Description = "Handles user experience questions.",
Instructions = "Focus on UX.",
Model = "claude-opus-4.5",
};
string reason = HandoffWorkflowGuidance.CreateForwardReason(specialist);
Assert.Contains("Handles user experience questions", reason, StringComparison.OrdinalIgnoreCase);
Assert.Contains("UX Specialist", reason, StringComparison.OrdinalIgnoreCase);
Assert.Contains("substantive response", reason, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void CreateReturnReason_RestrictsReturnToReroutingCases()
{
PatternAgentDefinitionDto triage = new()
{
Id = "agent-handoff-triage",
Name = "Triage",
Description = "Routes the request to the right specialist.",
Instructions = "Triages requests.",
Model = "gpt-5.4",
};
string reason = HandoffWorkflowGuidance.CreateReturnReason(triage);
Assert.Contains("Triage", reason, StringComparison.OrdinalIgnoreCase);
Assert.Contains("re-routing", reason, StringComparison.OrdinalIgnoreCase);
Assert.Contains("outside your specialty", reason, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,395 @@
using System.Text.Json;
using Eryx.AgentHost.Contracts;
using Eryx.AgentHost.Services;
namespace Eryx.AgentHost.Tests;
public sealed class SidecarProtocolHostTests
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
};
[Fact]
public async Task DescribeCapabilitiesCommand_ReturnsCapabilitiesAndCompletion()
{
IReadOnlyList<JsonElement> events = await RunHostAsync(new DescribeCapabilitiesCommandDto
{
Type = "describe-capabilities",
RequestId = "cap-1",
}, CreateHostForTests());
Assert.Collection(
events,
capabilitiesEvent =>
{
Assert.Equal("capabilities", capabilitiesEvent.GetProperty("type").GetString());
Assert.Equal("cap-1", capabilitiesEvent.GetProperty("requestId").GetString());
JsonElement capabilities = capabilitiesEvent.GetProperty("capabilities");
Assert.Equal("dotnet-maf", capabilities.GetProperty("runtime").GetString());
JsonElement modes = capabilities.GetProperty("modes");
Assert.True(modes.GetProperty("single").GetProperty("available").GetBoolean());
Assert.False(modes.GetProperty("magentic").GetProperty("available").GetBoolean());
JsonElement[] models = capabilities.GetProperty("models").EnumerateArray().ToArray();
JsonElement model = Assert.Single(models);
Assert.Equal("gpt-5.4", model.GetProperty("id").GetString());
Assert.Equal("medium", model.GetProperty("defaultReasoningEffort").GetString());
JsonElement connection = capabilities.GetProperty("connection");
Assert.Equal("ready", connection.GetProperty("status").GetString());
Assert.Equal(@"C:\tools\copilot\copilot.exe", connection.GetProperty("copilotCliPath").GetString());
JsonElement cliVersion = connection.GetProperty("copilotCliVersion");
Assert.Equal("latest", cliVersion.GetProperty("status").GetString());
Assert.Equal("1.0.10", cliVersion.GetProperty("installedVersion").GetString());
JsonElement account = connection.GetProperty("account");
Assert.True(account.GetProperty("authenticated").GetBoolean());
Assert.Equal("octocat", account.GetProperty("login").GetString());
string magenticReason = modes.GetProperty("magentic").GetProperty("reason").GetString() ?? string.Empty;
Assert.Contains("unsupported", magenticReason, StringComparison.OrdinalIgnoreCase);
},
completionEvent =>
{
Assert.Equal("command-complete", completionEvent.GetProperty("type").GetString());
Assert.Equal("cap-1", completionEvent.GetProperty("requestId").GetString());
});
}
[Fact]
public async Task ValidatePatternCommand_ReturnsIssuesAndCompletion()
{
IReadOnlyList<JsonElement> events = await RunHostAsync(new ValidatePatternCommandDto
{
Type = "validate-pattern",
RequestId = "validate-1",
Pattern = new PatternDefinitionDto
{
Id = "single-pattern",
Name = "",
Mode = "single",
Availability = "available",
Agents =
[
CreateAgent(),
CreateAgent(id: "agent-2", name: "Reviewer", model: ""),
],
},
});
Assert.Collection(
events,
validationEvent =>
{
Assert.Equal("pattern-validation", validationEvent.GetProperty("type").GetString());
Assert.Equal("validate-1", validationEvent.GetProperty("requestId").GetString());
JsonElement[] issues = validationEvent.GetProperty("issues").EnumerateArray().ToArray();
Assert.Contains(issues, issue =>
issue.GetProperty("field").GetString() == "name"
&& issue.GetProperty("message").GetString() == "Pattern name is required.");
Assert.Contains(issues, issue =>
issue.GetProperty("field").GetString() == "agents"
&& issue.GetProperty("message").GetString() == "Single-agent chat requires exactly one agent.");
Assert.Contains(issues, issue =>
issue.GetProperty("field").GetString() == "agents.model"
&& issue.GetProperty("message").GetString() == "Agent \"Reviewer\" requires a model identifier.");
},
completionEvent =>
{
Assert.Equal("command-complete", completionEvent.GetProperty("type").GetString());
Assert.Equal("validate-1", completionEvent.GetProperty("requestId").GetString());
});
}
[Fact]
public async Task RunTurnCommand_ReturnsActivityEventsAndCompletion()
{
SidecarProtocolHost host = new(
new PatternValidator(),
new FakeWorkflowRunner(async (command, onDelta, onActivity, cancellationToken) =>
{
await onActivity(new AgentActivityEventDto
{
Type = "agent-activity",
RequestId = command.RequestId,
SessionId = command.SessionId,
ActivityType = "thinking",
AgentId = "agent-1",
AgentName = "Primary",
});
await onDelta(new TurnDeltaEventDto
{
Type = "turn-delta",
RequestId = command.RequestId,
SessionId = command.SessionId,
MessageId = "assistant-1",
AuthorName = "Primary",
ContentDelta = "Hello",
});
await onActivity(new AgentActivityEventDto
{
Type = "agent-activity",
RequestId = command.RequestId,
SessionId = command.SessionId,
ActivityType = "tool-calling",
AgentId = "agent-1",
AgentName = "Primary",
ToolName = "read_file",
});
return
[
new ChatMessageDto
{
Id = "assistant-1",
Role = "assistant",
AuthorName = "Primary",
Content = "Hello world",
CreatedAt = "2026-01-01T00:00:00.0000000Z",
},
];
}));
IReadOnlyList<JsonElement> events = await RunHostAsync(
new RunTurnCommandDto
{
Type = "run-turn",
RequestId = "turn-1",
SessionId = "session-1",
ProjectPath = "C:\\workspace\\project",
Pattern = new PatternDefinitionDto
{
Id = "pattern-1",
Name = "Single Agent",
Mode = "single",
Availability = "available",
Agents =
[
CreateAgent(name: "Primary"),
],
},
Messages =
[
new ChatMessageDto
{
Id = "user-1",
Role = "user",
AuthorName = "You",
Content = "Hello",
CreatedAt = "2026-01-01T00:00:00.0000000Z",
},
],
},
host);
Assert.Collection(
events,
thinkingEvent =>
{
Assert.Equal("agent-activity", thinkingEvent.GetProperty("type").GetString());
Assert.Equal("turn-1", thinkingEvent.GetProperty("requestId").GetString());
Assert.Equal("session-1", thinkingEvent.GetProperty("sessionId").GetString());
Assert.Equal("thinking", thinkingEvent.GetProperty("activityType").GetString());
Assert.Equal("agent-1", thinkingEvent.GetProperty("agentId").GetString());
Assert.Equal("Primary", thinkingEvent.GetProperty("agentName").GetString());
},
deltaEvent =>
{
Assert.Equal("turn-delta", deltaEvent.GetProperty("type").GetString());
Assert.Equal("Hello", deltaEvent.GetProperty("contentDelta").GetString());
},
toolEvent =>
{
Assert.Equal("agent-activity", toolEvent.GetProperty("type").GetString());
Assert.Equal("tool-calling", toolEvent.GetProperty("activityType").GetString());
Assert.Equal("agent-1", toolEvent.GetProperty("agentId").GetString());
Assert.Equal("read_file", toolEvent.GetProperty("toolName").GetString());
},
completionEvent =>
{
Assert.Equal("turn-complete", completionEvent.GetProperty("type").GetString());
Assert.Equal("session-1", completionEvent.GetProperty("sessionId").GetString());
JsonElement[] messages = completionEvent.GetProperty("messages").EnumerateArray().ToArray();
Assert.Single(messages);
Assert.Equal("Hello world", messages[0].GetProperty("content").GetString());
},
commandCompleteEvent =>
{
Assert.Equal("command-complete", commandCompleteEvent.GetProperty("type").GetString());
Assert.Equal("turn-1", commandCompleteEvent.GetProperty("requestId").GetString());
});
}
[Fact]
public void ClassifyConnectionStatus_ReturnsAuthRequiredForLoginFailures()
{
string status = SidecarProtocolHost.ClassifyConnectionStatus(
new InvalidOperationException("Please run copilot auth login to continue."));
Assert.Equal("copilot-auth-required", status);
}
[Fact]
public void CreateReadyConnectionDiagnostics_ReportsCliPathAndModelCount()
{
SidecarConnectionDiagnosticsDto diagnostics =
SidecarProtocolHost.CreateReadyConnectionDiagnostics(
@"C:\tools\copilot\copilot.exe",
2,
new SidecarCopilotCliVersionDiagnosticsDto
{
Status = "outdated",
InstalledVersion = "1.0.9",
LatestVersion = "1.0.10",
},
new SidecarCopilotAccountDiagnosticsDto
{
Authenticated = true,
Login = "octocat",
Host = "github.com",
Organizations = ["github"],
});
Assert.Equal("ready", diagnostics.Status);
Assert.Equal(@"C:\tools\copilot\copilot.exe", diagnostics.CopilotCliPath);
Assert.Contains("2 models", diagnostics.Summary, StringComparison.OrdinalIgnoreCase);
Assert.Equal("outdated", diagnostics.CopilotCliVersion?.Status);
Assert.Equal("octocat", diagnostics.Account?.Login);
Assert.Equal(["github"], diagnostics.Account?.Organizations);
Assert.False(string.IsNullOrWhiteSpace(diagnostics.CheckedAt));
}
private static async Task<IReadOnlyList<JsonElement>> RunHostAsync(
object command,
SidecarProtocolHost? host = null)
{
string input = JsonSerializer.Serialize(command, JsonOptions) + Environment.NewLine;
using StringReader reader = new(input);
using StringWriter writer = new();
await (host ?? CreateHostForTests()).RunAsync(reader, writer, CancellationToken.None);
return ParseEvents(writer.ToString());
}
private static SidecarProtocolHost CreateHostForTests()
{
return new SidecarProtocolHost(
new PatternValidator(),
capabilitiesProvider: _ => Task.FromResult(new SidecarCapabilitiesDto
{
Modes = new Dictionary<string, SidecarModeCapabilityDto>(StringComparer.OrdinalIgnoreCase)
{
["single"] = new() { Available = true },
["sequential"] = new() { Available = true },
["concurrent"] = new() { Available = true },
["handoff"] = new() { Available = true },
["group-chat"] = new() { Available = true },
["magentic"] = new()
{
Available = false,
Reason = "Microsoft Agent Framework currently documents Magentic orchestration as unsupported in C#.",
},
},
Models =
[
new SidecarModelCapabilityDto
{
Id = "gpt-5.4",
Name = "GPT-5.4",
SupportedReasoningEfforts = ["low", "medium", "high", "xhigh"],
DefaultReasoningEffort = "medium",
},
],
Connection = new SidecarConnectionDiagnosticsDto
{
Status = "ready",
Summary = "Connected to GitHub Copilot. 1 model is available.",
CopilotCliPath = @"C:\tools\copilot\copilot.exe",
CopilotCliVersion = new SidecarCopilotCliVersionDiagnosticsDto
{
Status = "latest",
InstalledVersion = "1.0.10",
LatestVersion = "1.0.10",
},
Account = new SidecarCopilotAccountDiagnosticsDto
{
Authenticated = true,
Login = "octocat",
Host = "github.com",
Organizations = ["github", "mona"],
},
CheckedAt = "2026-01-01T00:00:00.0000000Z",
},
}));
}
private static IReadOnlyList<JsonElement> ParseEvents(string output)
{
List<JsonElement> events = [];
using StringReader reader = new(output);
string? line;
while ((line = reader.ReadLine()) is not null)
{
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
using JsonDocument document = JsonDocument.Parse(line);
events.Add(document.RootElement.Clone());
}
return events;
}
private static PatternAgentDefinitionDto CreateAgent(
string id = "agent-1",
string name = "Primary",
string model = "gpt-5.4",
string instructions = "Help with the user's request.")
{
return new PatternAgentDefinitionDto
{
Id = id,
Name = name,
Model = model,
Instructions = instructions,
};
}
private sealed class FakeWorkflowRunner : ITurnWorkflowRunner
{
private readonly Func<
RunTurnCommandDto,
Func<TurnDeltaEventDto, Task>,
Func<AgentActivityEventDto, Task>,
CancellationToken,
Task<IReadOnlyList<ChatMessageDto>>> _handler;
public FakeWorkflowRunner(
Func<
RunTurnCommandDto,
Func<TurnDeltaEventDto, Task>,
Func<AgentActivityEventDto, Task>,
CancellationToken,
Task<IReadOnlyList<ChatMessageDto>>> handler)
{
_handler = handler;
}
public Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
RunTurnCommandDto command,
Func<TurnDeltaEventDto, Task> onDelta,
Func<AgentActivityEventDto, Task> onActivity,
CancellationToken cancellationToken)
{
return _handler(command, onDelta, onActivity, cancellationToken);
}
}
}
@@ -0,0 +1,39 @@
using Eryx.AgentHost.Services;
namespace Eryx.AgentHost.Tests;
public sealed class StreamingTextMergerTests
{
[Fact]
public void Merge_AppendsPlainDeltas()
{
Assert.Equal("I am going", StreamingTextMerger.Merge("I am", " going"));
}
[Fact]
public void Merge_ReplacesWithGrowingSnapshot()
{
Assert.Equal("I am going", StreamingTextMerger.Merge("I am", "I am going"));
}
[Fact]
public void Merge_PreservesCurrentTextForDuplicateSubset()
{
Assert.Equal("I am going", StreamingTextMerger.Merge("I am going", "going"));
}
[Fact]
public void Merge_UsesOverlapToAvoidDuplicateJoins()
{
Assert.Equal("Hello world", StreamingTextMerger.Merge("Hello wor", "world"));
}
[Fact]
public void Merge_ReplacesWithRevisedSnapshotWhenMostTokensOverlap()
{
const string current = "I mirror the existing button pattern and add brief toggle docs.";
const string incoming = "I found the standalone component pattern and I am updating toggle docs next.";
Assert.Equal(incoming, StreamingTextMerger.Merge(current, incoming));
}
}
@@ -0,0 +1,108 @@
using Eryx.AgentHost.Contracts;
using Eryx.AgentHost.Services;
namespace Eryx.AgentHost.Tests;
public sealed class PatternValidatorTests
{
private readonly PatternValidator _validator = new();
[Fact]
public void SingleAgentPattern_WithExactlyOneAgent_IsValid()
{
IReadOnlyList<PatternValidationIssueDto> issues = _validator.Validate(
CreatePattern(
"single",
[CreateAgent()]));
Assert.Empty(issues);
}
[Fact]
public void HandoffPattern_WithSingleAgent_IsReportedAsInvalid()
{
IReadOnlyList<PatternValidationIssueDto> issues = _validator.Validate(
CreatePattern(
"handoff",
[CreateAgent()]));
Assert.Contains(issues, issue =>
issue.Field == "agents"
&& issue.Message == "Handoff orchestration requires at least two agents.");
}
[Fact]
public void AgentWithoutModel_IsReportedAsInvalid()
{
IReadOnlyList<PatternValidationIssueDto> issues = _validator.Validate(
CreatePattern(
"sequential",
[
CreateAgent(model: ""),
CreateAgent(id: "agent-2", name: "Reviewer"),
]));
Assert.Contains(issues, issue =>
issue.Field == "agents.model"
&& issue.Message == "Agent \"Primary\" requires a model identifier.");
}
[Fact]
public void MagenticPattern_IsReportedAsUnavailable()
{
IReadOnlyList<PatternValidationIssueDto> issues = _validator.Validate(
CreatePattern(
"magentic",
[
CreateAgent(id: "agent-1", name: "Planner", instructions: "Plan the task."),
CreateAgent(
id: "agent-2",
name: "Specialist",
model: "claude-opus-4.5",
instructions: "Complete the task."),
],
availability: "unavailable",
unavailabilityReason: "Unsupported in C#.",
name: "Magentic"));
Assert.Contains(issues, issue =>
issue.Field == "availability"
&& issue.Message.Contains("Unsupported", StringComparison.OrdinalIgnoreCase));
Assert.Contains(issues, issue =>
issue.Field == "mode"
&& issue.Message.Contains("Unsupported", StringComparison.OrdinalIgnoreCase));
}
private static PatternDefinitionDto CreatePattern(
string mode,
IReadOnlyList<PatternAgentDefinitionDto> agents,
string availability = "available",
string? unavailabilityReason = null,
string name = "Pattern")
{
return new PatternDefinitionDto
{
Id = $"{mode}-pattern",
Name = name,
Mode = mode,
Availability = availability,
UnavailabilityReason = unavailabilityReason,
Agents = agents,
};
}
private static PatternAgentDefinitionDto CreateAgent(
string id = "agent-1",
string name = "Primary",
string model = "gpt-5.4",
string instructions = "Help with the user's request.")
{
return new PatternAgentDefinitionDto
{
Id = id,
Name = name,
Model = model,
Instructions = instructions,
};
}
}