mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-09 05:08:44 +02:00
refactor: rename sidecar host to Aryx
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
|
||||
namespace Aryx.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,137 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
|
||||
namespace Aryx.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_StrengthensGroupChatCollaborationRoles()
|
||||
{
|
||||
PatternDefinitionDto pattern = new()
|
||||
{
|
||||
Id = "pattern-group-chat",
|
||||
Name = "Group Chat",
|
||||
Mode = "group-chat",
|
||||
Availability = "available",
|
||||
};
|
||||
PatternAgentDefinitionDto writer = CreateAgent(
|
||||
id: "agent-group-writer",
|
||||
name: "Writer",
|
||||
instructions: "Draft an answer.");
|
||||
PatternAgentDefinitionDto reviewer = CreateAgent(
|
||||
id: "agent-group-reviewer",
|
||||
name: "Reviewer",
|
||||
instructions: "Review the draft.");
|
||||
|
||||
string writerInstructions = AgentInstructionComposer.Compose(pattern, writer, agentIndex: 0);
|
||||
string reviewerInstructions = AgentInstructionComposer.Compose(pattern, reviewer, agentIndex: 1);
|
||||
|
||||
Assert.Contains("collaborative multi-turn group chat", writerInstructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("refine your earlier draft", writerInstructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("specific critique or improvements", reviewerInstructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Do not restart the conversation", reviewerInstructions, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[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 inspect files", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("actual handoff", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Do not claim that you handed work off", 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("ad-hoc work inside the scratchpad workspace", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("use the available tools and files inside the scratchpad workspace", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("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,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\Aryx.AgentHost\Aryx.AgentHost.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Reflection;
|
||||
using Aryx.AgentHost.Services;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class CopilotAgentBundleTests
|
||||
{
|
||||
[Fact]
|
||||
public void ApplySessionTooling_MapsMcpServersAndToolsOntoTheSessionConfig()
|
||||
{
|
||||
SessionConfig sessionConfig = new()
|
||||
{
|
||||
AvailableTools = ["glob"],
|
||||
};
|
||||
Dictionary<string, object> mcpServers = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Git MCP"] = new McpLocalServerConfig
|
||||
{
|
||||
Type = "local",
|
||||
Command = "node",
|
||||
Args = ["server.js"],
|
||||
Tools = ["git.status"],
|
||||
},
|
||||
};
|
||||
AIFunction tool = CreateTool();
|
||||
|
||||
CopilotAgentBundle.ApplySessionTooling(sessionConfig, mcpServers, [tool]);
|
||||
|
||||
Assert.Same(mcpServers, sessionConfig.McpServers);
|
||||
Assert.NotNull(sessionConfig.Tools);
|
||||
AIFunction configuredTool = Assert.Single(sessionConfig.Tools);
|
||||
Assert.Same(tool, configuredTool);
|
||||
Assert.Equal(["glob"], sessionConfig.AvailableTools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplySessionTooling_LeavesSessionConfigUnsetWhenNoToolingIsProvided()
|
||||
{
|
||||
SessionConfig sessionConfig = new()
|
||||
{
|
||||
AvailableTools = ["glob", "view"],
|
||||
};
|
||||
|
||||
CopilotAgentBundle.ApplySessionTooling(sessionConfig, null, []);
|
||||
|
||||
Assert.Null(sessionConfig.McpServers);
|
||||
Assert.Null(sessionConfig.Tools);
|
||||
Assert.Equal(["glob", "view"], sessionConfig.AvailableTools);
|
||||
}
|
||||
|
||||
private static AIFunction CreateTool()
|
||||
{
|
||||
ToolTarget target = new();
|
||||
MethodInfo method = typeof(ToolTarget).GetMethod(nameof(ToolTarget.Echo))
|
||||
?? throw new InvalidOperationException("Expected test method to exist.");
|
||||
|
||||
return AIFunctionFactory.Create(
|
||||
method,
|
||||
target,
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "echo",
|
||||
Description = "Echo test tool",
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class ToolTarget
|
||||
{
|
||||
public string Echo() => "ok";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using Aryx.AgentHost.Services;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class CopilotCliPathResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void Resolve_UsesCopilotFromPath()
|
||||
{
|
||||
string copilotDirectory = @"C:\tools\copilot";
|
||||
string expectedCliPath = @"C:\tools\copilot\copilot.exe";
|
||||
HashSet<string> existingFiles = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
expectedCliPath,
|
||||
};
|
||||
|
||||
string? cliPath = CopilotCliPathResolver.Resolve(
|
||||
pathValue: $"C:\\other;\"{copilotDirectory}\"",
|
||||
pathExtValue: ".COM;.EXE;.BAT;.CMD",
|
||||
isWindows: true,
|
||||
fileExists: existingFiles.Contains);
|
||||
|
||||
Assert.Equal(expectedCliPath, cliPath, ignoreCase: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_UsesDefaultWindowsExtensionsWhenPathExtMissing()
|
||||
{
|
||||
string copilotDirectory = @"C:\tools\copilot";
|
||||
string expectedCliPath = @"C:\tools\copilot\copilot.cmd";
|
||||
HashSet<string> existingFiles = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
expectedCliPath,
|
||||
};
|
||||
|
||||
string? cliPath = CopilotCliPathResolver.Resolve(
|
||||
pathValue: $"C:\\other;\"{copilotDirectory}\"",
|
||||
pathExtValue: null,
|
||||
isWindows: true,
|
||||
fileExists: existingFiles.Contains);
|
||||
|
||||
Assert.Equal(expectedCliPath, cliPath, ignoreCase: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_UsesCopilotFromPathOutsideWindows()
|
||||
{
|
||||
const string expectedCliPath = "/usr/local/bin/copilot";
|
||||
HashSet<string> existingFiles = new(StringComparer.Ordinal)
|
||||
{
|
||||
expectedCliPath,
|
||||
};
|
||||
|
||||
string? cliPath = CopilotCliPathResolver.Resolve(
|
||||
pathValue: "/usr/bin:/usr/local/bin",
|
||||
pathExtValue: null,
|
||||
isWindows: false,
|
||||
fileExists: existingFiles.Contains);
|
||||
|
||||
Assert.Equal(expectedCliPath, cliPath);
|
||||
}
|
||||
|
||||
[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 Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
|
||||
namespace Aryx.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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
[assembly: Experimental(
|
||||
"MEAI001",
|
||||
UrlFormat = "https://aka.ms/dotnet-extensions-warnings/{0}")]
|
||||
@@ -0,0 +1,56 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
|
||||
namespace Aryx.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("Do not narrate a handoff", 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,56 @@
|
||||
using System.Text.Json;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class LspToolSessionTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateJsonSerializerOptions_ReturnsReadOnlyResolverBackedOptions()
|
||||
{
|
||||
JsonSerializerOptions options = LspToolSession.CreateJsonSerializerOptions();
|
||||
|
||||
Assert.True(options.IsReadOnly);
|
||||
Assert.NotNull(options.TypeInfoResolver);
|
||||
|
||||
string json = JsonSerializer.Serialize(
|
||||
new
|
||||
{
|
||||
RelativePath = "src/file.ts",
|
||||
Line = 12,
|
||||
Character = 4,
|
||||
},
|
||||
options);
|
||||
|
||||
Assert.Contains("relativePath", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveProcessArguments_AddsStdioForTypeScriptLanguageServer()
|
||||
{
|
||||
RunTurnLspProfileConfigDto profile = new()
|
||||
{
|
||||
Command = "typescript-language-server",
|
||||
Args = [],
|
||||
};
|
||||
|
||||
IReadOnlyList<string> args = LspToolSession.ResolveProcessArguments(profile);
|
||||
|
||||
Assert.Equal(["--stdio"], args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveProcessArguments_DoesNotDuplicateStdioWhenAlreadyPresent()
|
||||
{
|
||||
RunTurnLspProfileConfigDto profile = new()
|
||||
{
|
||||
Command = "typescript-language-server",
|
||||
Args = ["--stdio"],
|
||||
};
|
||||
|
||||
IReadOnlyList<string> args = LspToolSession.ResolveProcessArguments(profile);
|
||||
|
||||
Assert.Equal(["--stdio"], args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class PatternGraphResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolveOrderedAgentIds_UsesSequentialGraphPath()
|
||||
{
|
||||
PatternDefinitionDto pattern = CreatePattern(
|
||||
"sequential",
|
||||
[
|
||||
CreateAgent("agent-1", "Analyst"),
|
||||
CreateAgent("agent-2", "Builder"),
|
||||
CreateAgent("agent-3", "Reviewer"),
|
||||
],
|
||||
new PatternGraphDto
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
CreateSystemNode("system-user-input", "user-input"),
|
||||
CreateAgentNode("agent-1", 0),
|
||||
CreateAgentNode("agent-2", 1),
|
||||
CreateAgentNode("agent-3", 2),
|
||||
CreateSystemNode("system-user-output", "user-output"),
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
CreateEdge("system-user-input", "agent-node-agent-3"),
|
||||
CreateEdge("agent-node-agent-3", "agent-node-agent-1"),
|
||||
CreateEdge("agent-node-agent-1", "agent-node-agent-2"),
|
||||
CreateEdge("agent-node-agent-2", "system-user-output"),
|
||||
],
|
||||
});
|
||||
|
||||
IReadOnlyList<string> orderedAgentIds = PatternGraphResolver.ResolveOrderedAgentIds(pattern);
|
||||
|
||||
Assert.Equal(["agent-3", "agent-1", "agent-2"], orderedAgentIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveHandoff_UsesExplicitEntryAndRoutes()
|
||||
{
|
||||
PatternDefinitionDto pattern = CreatePattern(
|
||||
"handoff",
|
||||
[
|
||||
CreateAgent("agent-1", "Triage"),
|
||||
CreateAgent("agent-2", "UX"),
|
||||
CreateAgent("agent-3", "Runtime"),
|
||||
],
|
||||
new PatternGraphDto
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
CreateSystemNode("system-user-input", "user-input"),
|
||||
CreateSystemNode("system-user-output", "user-output"),
|
||||
CreateAgentNode("agent-1", 0),
|
||||
CreateAgentNode("agent-2", 1),
|
||||
CreateAgentNode("agent-3", 2),
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
CreateEdge("system-user-input", "agent-node-agent-3"),
|
||||
CreateEdge("agent-node-agent-3", "agent-node-agent-2"),
|
||||
CreateEdge("agent-node-agent-2", "agent-node-agent-1"),
|
||||
CreateEdge("agent-node-agent-2", "system-user-output"),
|
||||
],
|
||||
});
|
||||
|
||||
PatternHandoffTopology topology = PatternGraphResolver.ResolveHandoff(pattern);
|
||||
|
||||
Assert.Equal("agent-3", topology.EntryAgentId);
|
||||
Assert.Contains(new PatternHandoffRoute("agent-3", "agent-2"), topology.Routes);
|
||||
Assert.Contains(new PatternHandoffRoute("agent-2", "agent-1"), topology.Routes);
|
||||
Assert.DoesNotContain(new PatternHandoffRoute("agent-1", "agent-2"), topology.Routes);
|
||||
}
|
||||
|
||||
private static PatternDefinitionDto CreatePattern(
|
||||
string mode,
|
||||
IReadOnlyList<PatternAgentDefinitionDto> agents,
|
||||
PatternGraphDto graph)
|
||||
=> new()
|
||||
{
|
||||
Id = $"{mode}-pattern",
|
||||
Name = "Pattern",
|
||||
Mode = mode,
|
||||
Availability = "available",
|
||||
Agents = agents,
|
||||
Graph = graph,
|
||||
};
|
||||
|
||||
private static PatternAgentDefinitionDto CreateAgent(string id, string name)
|
||||
=> new()
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help with the user's request.",
|
||||
};
|
||||
|
||||
private static PatternGraphNodeDto CreateSystemNode(string id, string kind)
|
||||
=> new()
|
||||
{
|
||||
Id = id,
|
||||
Kind = kind,
|
||||
Position = new PatternGraphPositionDto(),
|
||||
};
|
||||
|
||||
private static PatternGraphNodeDto CreateAgentNode(string agentId, int order)
|
||||
=> new()
|
||||
{
|
||||
Id = $"agent-node-{agentId}",
|
||||
Kind = "agent",
|
||||
AgentId = agentId,
|
||||
Order = order,
|
||||
Position = new PatternGraphPositionDto(),
|
||||
};
|
||||
|
||||
private static PatternGraphEdgeDto CreateEdge(string source, string target)
|
||||
=> new()
|
||||
{
|
||||
Id = $"edge-{source}-to-{target}",
|
||||
Source = source,
|
||||
Target = target,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class SessionToolingBundleTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildMcpServerConfigurations_MapsLocalAndRemoteServers()
|
||||
{
|
||||
IReadOnlyList<RunTurnMcpServerConfigDto> servers =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Id = "mcp-local",
|
||||
Name = "Local MCP",
|
||||
Transport = "local",
|
||||
Command = "node",
|
||||
Args = ["server.js", "--stdio"],
|
||||
Env = new Dictionary<string, string>
|
||||
{
|
||||
["DEBUG"] = "true",
|
||||
},
|
||||
Cwd = @"C:\workspace\repo",
|
||||
Tools = ["git.status"],
|
||||
TimeoutMs = 1500,
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "mcp-remote",
|
||||
Name = "Remote MCP",
|
||||
Transport = "http",
|
||||
Url = "https://example.com/mcp",
|
||||
Headers = new Dictionary<string, string>
|
||||
{
|
||||
["Authorization"] = "Bearer token",
|
||||
},
|
||||
Tools = ["*"],
|
||||
},
|
||||
];
|
||||
|
||||
Dictionary<string, object> configurations = SessionToolingBundle.BuildMcpServerConfigurations(servers);
|
||||
|
||||
McpLocalServerConfig localConfig = Assert.IsType<McpLocalServerConfig>(configurations["Local MCP"]);
|
||||
Assert.Equal("local", localConfig.Type);
|
||||
Assert.Equal("node", localConfig.Command);
|
||||
Assert.Equal(["server.js", "--stdio"], localConfig.Args);
|
||||
KeyValuePair<string, string> localEnv = Assert.Single(localConfig.Env!);
|
||||
Assert.Equal("DEBUG", localEnv.Key);
|
||||
Assert.Equal("true", localEnv.Value);
|
||||
Assert.Equal(@"C:\workspace\repo", localConfig.Cwd);
|
||||
Assert.Equal(["git.status"], localConfig.Tools);
|
||||
Assert.Equal(1500, localConfig.Timeout);
|
||||
|
||||
McpRemoteServerConfig remoteConfig = Assert.IsType<McpRemoteServerConfig>(configurations["Remote MCP"]);
|
||||
Assert.Equal("http", remoteConfig.Type);
|
||||
Assert.Equal("https://example.com/mcp", remoteConfig.Url);
|
||||
KeyValuePair<string, string> remoteHeader = Assert.Single(remoteConfig.Headers!);
|
||||
Assert.Equal("Authorization", remoteHeader.Key);
|
||||
Assert.Equal("Bearer token", remoteHeader.Value);
|
||||
Assert.Equal(["*"], remoteConfig.Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildMcpServerConfigurations_DefaultsMissingToolsToWildcard()
|
||||
{
|
||||
IReadOnlyList<RunTurnMcpServerConfigDto> servers =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Id = "mcp-local",
|
||||
Transport = "local",
|
||||
Command = "node",
|
||||
Tools = [],
|
||||
},
|
||||
];
|
||||
|
||||
Dictionary<string, object> configurations = SessionToolingBundle.BuildMcpServerConfigurations(servers);
|
||||
McpLocalServerConfig localConfig = Assert.IsType<McpLocalServerConfig>(configurations["mcp-local"]);
|
||||
|
||||
Assert.Equal(["*"], localConfig.Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildMcpServerConfigurations_RejectsMissingTransportTargets()
|
||||
{
|
||||
InvalidOperationException localError = Assert.Throws<InvalidOperationException>(
|
||||
() => SessionToolingBundle.BuildMcpServerConfigurations(
|
||||
[
|
||||
new RunTurnMcpServerConfigDto
|
||||
{
|
||||
Id = "mcp-local",
|
||||
Name = "Local MCP",
|
||||
Transport = "local",
|
||||
},
|
||||
]));
|
||||
Assert.Contains("missing a command", localError.Message);
|
||||
|
||||
InvalidOperationException remoteError = Assert.Throws<InvalidOperationException>(
|
||||
() => SessionToolingBundle.BuildMcpServerConfigurations(
|
||||
[
|
||||
new RunTurnMcpServerConfigDto
|
||||
{
|
||||
Id = "mcp-remote",
|
||||
Name = "Remote MCP",
|
||||
Transport = "sse",
|
||||
},
|
||||
]));
|
||||
Assert.Contains("missing a URL", remoteError.Message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,645 @@
|
||||
using System.Text.Json;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
|
||||
namespace Aryx.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[] runtimeTools = capabilities.GetProperty("runtimeTools").EnumerateArray().ToArray();
|
||||
JsonElement runtimeTool = Assert.Single(runtimeTools);
|
||||
Assert.Equal("web_fetch", runtimeTool.GetProperty("id").GetString());
|
||||
Assert.Equal("web_fetch", runtimeTool.GetProperty("label").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, onApproval, 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",
|
||||
Content = "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());
|
||||
Assert.Equal("Hello", deltaEvent.GetProperty("content").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());
|
||||
Assert.False(completionEvent.GetProperty("cancelled").GetBoolean());
|
||||
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 async Task RunTurnCommand_ReturnsApprovalEvents()
|
||||
{
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, cancellationToken) =>
|
||||
{
|
||||
await onApproval(new ApprovalRequestedEventDto
|
||||
{
|
||||
Type = "approval-requested",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
ApprovalId = "approval-1",
|
||||
ApprovalKind = "tool-call",
|
||||
AgentId = "agent-1",
|
||||
AgentName = "Primary",
|
||||
PermissionKind = "tool access",
|
||||
Title = "Approve tool access",
|
||||
});
|
||||
|
||||
return [];
|
||||
}));
|
||||
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||
new RunTurnCommandDto
|
||||
{
|
||||
Type = "run-turn",
|
||||
RequestId = "turn-approval",
|
||||
SessionId = "session-1",
|
||||
ProjectPath = "C:\\workspace\\project",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "Single Agent",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent(name: "Primary"),
|
||||
],
|
||||
},
|
||||
},
|
||||
host);
|
||||
|
||||
Assert.Collection(
|
||||
events,
|
||||
approvalEvent =>
|
||||
{
|
||||
Assert.Equal("approval-requested", approvalEvent.GetProperty("type").GetString());
|
||||
Assert.Equal("turn-approval", approvalEvent.GetProperty("requestId").GetString());
|
||||
Assert.Equal("approval-1", approvalEvent.GetProperty("approvalId").GetString());
|
||||
Assert.Equal("tool-call", approvalEvent.GetProperty("approvalKind").GetString());
|
||||
Assert.Equal("Approve tool access", approvalEvent.GetProperty("title").GetString());
|
||||
},
|
||||
completionEvent =>
|
||||
{
|
||||
Assert.Equal("turn-complete", completionEvent.GetProperty("type").GetString());
|
||||
Assert.False(completionEvent.GetProperty("cancelled").GetBoolean());
|
||||
},
|
||||
commandCompleteEvent =>
|
||||
{
|
||||
Assert.Equal("command-complete", commandCompleteEvent.GetProperty("type").GetString());
|
||||
Assert.Equal("turn-approval", commandCompleteEvent.GetProperty("requestId").GetString());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelTurnCommand_CancelsInProgressTurnAndCompletesBothCommands()
|
||||
{
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, cancellationToken) =>
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||
return [];
|
||||
}));
|
||||
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||
[
|
||||
CreateRunTurnCommand(requestId: "turn-cancel"),
|
||||
new CancelTurnCommandDto
|
||||
{
|
||||
Type = "cancel-turn",
|
||||
RequestId = "cancel-command-1",
|
||||
TargetRequestId = "turn-cancel",
|
||||
},
|
||||
],
|
||||
host);
|
||||
|
||||
JsonElement turnCompleteEvent = AssertSingleEvent(events, "turn-complete", "turn-cancel");
|
||||
Assert.Equal("session-1", turnCompleteEvent.GetProperty("sessionId").GetString());
|
||||
Assert.True(turnCompleteEvent.GetProperty("cancelled").GetBoolean());
|
||||
Assert.Empty(turnCompleteEvent.GetProperty("messages").EnumerateArray().ToArray());
|
||||
|
||||
AssertSingleEvent(events, "command-complete", "turn-cancel");
|
||||
AssertSingleEvent(events, "command-complete", "cancel-command-1");
|
||||
Assert.DoesNotContain(events, evt => evt.GetProperty("type").GetString() == "command-error");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelTurnCommand_UnknownTarget_CompletesWithoutError()
|
||||
{
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(new CancelTurnCommandDto
|
||||
{
|
||||
Type = "cancel-turn",
|
||||
RequestId = "cancel-command-unknown",
|
||||
TargetRequestId = "missing-turn",
|
||||
});
|
||||
|
||||
JsonElement completionEvent = Assert.Single(events);
|
||||
Assert.Equal("command-complete", completionEvent.GetProperty("type").GetString());
|
||||
Assert.Equal("cancel-command-unknown", completionEvent.GetProperty("requestId").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelTurnCommand_AfterTurnCompletion_IsNoOp()
|
||||
{
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, cancellationToken) => []));
|
||||
|
||||
await RunHostAsync(CreateRunTurnCommand(requestId: "turn-completed"), host);
|
||||
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(new CancelTurnCommandDto
|
||||
{
|
||||
Type = "cancel-turn",
|
||||
RequestId = "cancel-command-completed",
|
||||
TargetRequestId = "turn-completed",
|
||||
}, host);
|
||||
|
||||
JsonElement completionEvent = Assert.Single(events);
|
||||
Assert.Equal("command-complete", completionEvent.GetProperty("type").GetString());
|
||||
Assert.Equal("cancel-command-completed", completionEvent.GetProperty("requestId").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveApprovalCommand_DelegatesToWorkflowRunnerAndCompletes()
|
||||
{
|
||||
ResolveApprovalCommandDto? captured = null;
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new FakeWorkflowRunner(
|
||||
handler: async (command, onDelta, onActivity, onApproval, cancellationToken) => [],
|
||||
resolveApprovalHandler: (command, cancellationToken) =>
|
||||
{
|
||||
captured = command;
|
||||
return Task.CompletedTask;
|
||||
}));
|
||||
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||
new ResolveApprovalCommandDto
|
||||
{
|
||||
Type = "resolve-approval",
|
||||
RequestId = "approval-command-1",
|
||||
ApprovalId = "approval-1",
|
||||
Decision = "approved",
|
||||
},
|
||||
host);
|
||||
|
||||
JsonElement completionEvent = Assert.Single(events);
|
||||
Assert.Equal("command-complete", completionEvent.GetProperty("type").GetString());
|
||||
Assert.Equal("approval-command-1", completionEvent.GetProperty("requestId").GetString());
|
||||
Assert.Equal("approval-1", captured?.ApprovalId);
|
||||
Assert.Equal("approved", captured?.Decision);
|
||||
}
|
||||
|
||||
[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)
|
||||
{
|
||||
return await RunHostAsync([command], host);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<JsonElement>> RunHostAsync(
|
||||
IReadOnlyList<object> commands,
|
||||
SidecarProtocolHost? host = null)
|
||||
{
|
||||
string input = string.Join(
|
||||
Environment.NewLine,
|
||||
commands.Select(command => 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 JsonElement AssertSingleEvent(
|
||||
IEnumerable<JsonElement> events,
|
||||
string eventType,
|
||||
string requestId)
|
||||
{
|
||||
return Assert.Single(events.Where(evt =>
|
||||
evt.GetProperty("type").GetString() == eventType
|
||||
&& evt.GetProperty("requestId").GetString() == requestId));
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
],
|
||||
RuntimeTools =
|
||||
[
|
||||
new SidecarRuntimeToolDto
|
||||
{
|
||||
Id = "web_fetch",
|
||||
Label = "web_fetch",
|
||||
Description = "Fetch content from the web.",
|
||||
},
|
||||
],
|
||||
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 static RunTurnCommandDto CreateRunTurnCommand(
|
||||
string requestId = "turn-1",
|
||||
string sessionId = "session-1")
|
||||
{
|
||||
return new RunTurnCommandDto
|
||||
{
|
||||
Type = "run-turn",
|
||||
RequestId = requestId,
|
||||
SessionId = sessionId,
|
||||
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",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FakeWorkflowRunner : ITurnWorkflowRunner
|
||||
{
|
||||
private readonly Func<
|
||||
RunTurnCommandDto,
|
||||
Func<TurnDeltaEventDto, Task>,
|
||||
Func<AgentActivityEventDto, Task>,
|
||||
Func<ApprovalRequestedEventDto, Task>,
|
||||
CancellationToken,
|
||||
Task<IReadOnlyList<ChatMessageDto>>> _handler;
|
||||
private readonly Func<ResolveApprovalCommandDto, CancellationToken, Task> _resolveApprovalHandler;
|
||||
|
||||
public FakeWorkflowRunner(
|
||||
Func<
|
||||
RunTurnCommandDto,
|
||||
Func<TurnDeltaEventDto, Task>,
|
||||
Func<AgentActivityEventDto, Task>,
|
||||
Func<ApprovalRequestedEventDto, Task>,
|
||||
CancellationToken,
|
||||
Task<IReadOnlyList<ChatMessageDto>>> handler,
|
||||
Func<ResolveApprovalCommandDto, CancellationToken, Task>? resolveApprovalHandler = null)
|
||||
{
|
||||
_handler = handler;
|
||||
_resolveApprovalHandler = resolveApprovalHandler ?? ((_, _) => Task.CompletedTask);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
|
||||
RunTurnCommandDto command,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<AgentActivityEventDto, Task> onActivity,
|
||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return _handler(command, onDelta, onActivity, onApproval, cancellationToken);
|
||||
}
|
||||
|
||||
public Task ResolveApprovalAsync(
|
||||
ResolveApprovalCommandDto command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return _resolveApprovalHandler(command, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using Aryx.AgentHost.Services;
|
||||
|
||||
namespace Aryx.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));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_InsertsWhitespaceWhenSnapshotLikeUpdatesWouldOtherwiseGlueWordsTogether()
|
||||
{
|
||||
Assert.Equal(
|
||||
"How about The **Ashen Crown** feels",
|
||||
StreamingTextMerger.Merge("How about", "The **Ashen Crown** feels"));
|
||||
Assert.Equal(
|
||||
"The **Ashen Crown** feels classic and timeless.",
|
||||
StreamingTextMerger.Merge("The **Ashen Crown** feels", "classic and timeless."));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_InsertsNewlineBeforeStreamedMarkdownBlockMarkers()
|
||||
{
|
||||
Assert.Equal(
|
||||
"If you want, I can also give you\n- darker titles",
|
||||
StreamingTextMerger.Merge("If you want, I can also give you", "- darker titles"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
|
||||
namespace Aryx.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));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SequentialPattern_WithBranchedGraph_IsReportedAsInvalid()
|
||||
{
|
||||
IReadOnlyList<PatternValidationIssueDto> issues = _validator.Validate(
|
||||
CreatePattern(
|
||||
"sequential",
|
||||
[
|
||||
CreateAgent(id: "agent-1", name: "Analyst"),
|
||||
CreateAgent(id: "agent-2", name: "Builder"),
|
||||
],
|
||||
graph: new PatternGraphDto
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
CreateSystemNode("system-user-input", "user-input"),
|
||||
CreateAgentNode("agent-1", 0),
|
||||
CreateAgentNode("agent-2", 1),
|
||||
CreateSystemNode("system-user-output", "user-output"),
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
CreateEdge("system-user-input", "agent-node-agent-1"),
|
||||
CreateEdge("system-user-input", "agent-node-agent-2"),
|
||||
CreateEdge("agent-node-agent-1", "agent-node-agent-2"),
|
||||
CreateEdge("agent-node-agent-2", "system-user-output"),
|
||||
],
|
||||
}));
|
||||
|
||||
Assert.Contains(issues, issue =>
|
||||
issue.Field == "graph"
|
||||
&& issue.Message.Contains("single path", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static PatternDefinitionDto CreatePattern(
|
||||
string mode,
|
||||
IReadOnlyList<PatternAgentDefinitionDto> agents,
|
||||
string availability = "available",
|
||||
string? unavailabilityReason = null,
|
||||
string name = "Pattern",
|
||||
PatternGraphDto? graph = null)
|
||||
{
|
||||
return new PatternDefinitionDto
|
||||
{
|
||||
Id = $"{mode}-pattern",
|
||||
Name = name,
|
||||
Mode = mode,
|
||||
Availability = availability,
|
||||
UnavailabilityReason = unavailabilityReason,
|
||||
Agents = agents,
|
||||
Graph = graph,
|
||||
};
|
||||
}
|
||||
|
||||
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 static PatternGraphNodeDto CreateSystemNode(string id, string kind)
|
||||
=> new()
|
||||
{
|
||||
Id = id,
|
||||
Kind = kind,
|
||||
Position = new PatternGraphPositionDto(),
|
||||
};
|
||||
|
||||
private static PatternGraphNodeDto CreateAgentNode(string agentId, int order)
|
||||
=> new()
|
||||
{
|
||||
Id = $"agent-node-{agentId}",
|
||||
Kind = "agent",
|
||||
AgentId = agentId,
|
||||
Order = order,
|
||||
Position = new PatternGraphPositionDto(),
|
||||
};
|
||||
|
||||
private static PatternGraphEdgeDto CreateEdge(string source, string target)
|
||||
=> new()
|
||||
{
|
||||
Id = $"edge-{source}-to-{target}",
|
||||
Source = source,
|
||||
Target = target,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class WorkflowRequestInfoInterpreterTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryCreateActivityFromRequest_ReturnsToolCallingActivityForFunctionCalls()
|
||||
{
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
|
||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
|
||||
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>()));
|
||||
|
||||
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
|
||||
CreateSingleAgentCommand(),
|
||||
requestInfo,
|
||||
new AgentIdentity("agent-1", "Primary"),
|
||||
toolNamesByCallId);
|
||||
|
||||
Assert.NotNull(activity);
|
||||
Assert.Equal("tool-calling", activity.ActivityType);
|
||||
Assert.Equal("agent-1", activity.AgentId);
|
||||
Assert.Equal("Primary", activity.AgentName);
|
||||
Assert.Equal("view", activity.ToolName);
|
||||
Assert.Equal("view", toolNamesByCallId["call-1"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateActivityFromRequest_MapsMcpToolCalls()
|
||||
{
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
|
||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
|
||||
CreateMcpToolCall("call-1", "git.status", "Git MCP"));
|
||||
|
||||
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
|
||||
CreateSingleAgentCommand(),
|
||||
requestInfo,
|
||||
new AgentIdentity("agent-1", "Primary"),
|
||||
toolNamesByCallId);
|
||||
|
||||
Assert.NotNull(activity);
|
||||
Assert.Equal("tool-calling", activity.ActivityType);
|
||||
Assert.Equal("git.status", activity.ToolName);
|
||||
Assert.Equal("git.status", toolNamesByCallId["call-1"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateActivityFromRequest_MapsCodeInterpreterCallsToSyntheticToolName()
|
||||
{
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
|
||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(CreateCodeInterpreterToolCall("call-1"));
|
||||
|
||||
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
|
||||
CreateSingleAgentCommand(),
|
||||
requestInfo,
|
||||
new AgentIdentity("agent-1", "Primary"),
|
||||
toolNamesByCallId);
|
||||
|
||||
Assert.NotNull(activity);
|
||||
Assert.Equal("tool-calling", activity.ActivityType);
|
||||
Assert.Equal("code interpreter", activity.ToolName);
|
||||
Assert.Equal("code interpreter", toolNamesByCallId["call-1"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateActivityFromRequest_MapsImageGenerationCallsWithoutTrackingCallId()
|
||||
{
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
|
||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(CreateImageGenerationToolCall());
|
||||
|
||||
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
|
||||
CreateSingleAgentCommand(),
|
||||
requestInfo,
|
||||
new AgentIdentity("agent-1", "Primary"),
|
||||
toolNamesByCallId);
|
||||
|
||||
Assert.NotNull(activity);
|
||||
Assert.Equal("tool-calling", activity.ActivityType);
|
||||
Assert.Equal("image generation", activity.ToolName);
|
||||
Assert.Empty(toolNamesByCallId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateActivityFromRequest_ReturnsHandoffActivityForKnownTargets()
|
||||
{
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
|
||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
|
||||
CreateHandoffTarget("agent-handoff-ux", "UX Specialist"));
|
||||
|
||||
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
|
||||
CreateHandoffCommand(),
|
||||
requestInfo,
|
||||
new AgentIdentity("agent-handoff-triage", "Triage"),
|
||||
toolNamesByCallId);
|
||||
|
||||
Assert.NotNull(activity);
|
||||
Assert.Equal("handoff", activity.ActivityType);
|
||||
Assert.Equal("agent-handoff-ux", activity.AgentId);
|
||||
Assert.Equal("UX Specialist", activity.AgentName);
|
||||
Assert.Equal("agent-handoff-triage", activity.SourceAgentId);
|
||||
Assert.Equal("Triage", activity.SourceAgentName);
|
||||
Assert.Null(activity.ToolName);
|
||||
Assert.Empty(toolNamesByCallId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequiresUserInputTurnBoundary_ReturnsTrueForUnhandledHandoffRequests()
|
||||
{
|
||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(new
|
||||
{
|
||||
Prompt = "Please provide more detail.",
|
||||
});
|
||||
|
||||
bool requiresBoundary = WorkflowRequestInfoInterpreter.RequiresUserInputTurnBoundary(
|
||||
CreateHandoffCommand(),
|
||||
requestInfo);
|
||||
|
||||
Assert.True(requiresBoundary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequiresUserInputTurnBoundary_ReturnsFalseForExplicitHandoffs()
|
||||
{
|
||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
|
||||
CreateHandoffTarget("agent-handoff-ux", "UX Specialist"));
|
||||
|
||||
bool requiresBoundary = WorkflowRequestInfoInterpreter.RequiresUserInputTurnBoundary(
|
||||
CreateHandoffCommand(),
|
||||
requestInfo);
|
||||
|
||||
Assert.False(requiresBoundary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequiresUserInputTurnBoundary_ReturnsFalseForToolRequests()
|
||||
{
|
||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
|
||||
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>()));
|
||||
|
||||
bool requiresBoundary = WorkflowRequestInfoInterpreter.RequiresUserInputTurnBoundary(
|
||||
CreateHandoffCommand(),
|
||||
requestInfo);
|
||||
|
||||
Assert.False(requiresBoundary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequiresUserInputTurnBoundary_ReturnsFalseOutsideHandoffMode()
|
||||
{
|
||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(new
|
||||
{
|
||||
Prompt = "Please provide more detail.",
|
||||
});
|
||||
|
||||
bool requiresBoundary = WorkflowRequestInfoInterpreter.RequiresUserInputTurnBoundary(
|
||||
CreateSingleAgentCommand(),
|
||||
requestInfo);
|
||||
|
||||
Assert.False(requiresBoundary);
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateSingleAgentCommand()
|
||||
{
|
||||
return new RunTurnCommandDto
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-single",
|
||||
Name = "Single Agent",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent("agent-1", "Primary"),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateHandoffCommand()
|
||||
{
|
||||
return new RunTurnCommandDto
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-handoff",
|
||||
Name = "Handoff Flow",
|
||||
Mode = "handoff",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent("agent-handoff-triage", "Triage"),
|
||||
CreateAgent("agent-handoff-ux", "UX Specialist"),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static PatternAgentDefinitionDto CreateAgent(string id, string name)
|
||||
{
|
||||
return new PatternAgentDefinitionDto
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help with the request.",
|
||||
};
|
||||
}
|
||||
|
||||
private static RequestInfoEvent CreateRequestInfoEvent(object payload)
|
||||
{
|
||||
RequestPort port = RequestPort.Create<object, object>("test-port");
|
||||
ExternalRequest request = ExternalRequest.Create(port, payload, "request-1");
|
||||
return new RequestInfoEvent(request);
|
||||
}
|
||||
|
||||
private static object CreateCodeInterpreterToolCall(string callId)
|
||||
{
|
||||
Type type = Type.GetType(
|
||||
"Microsoft.Extensions.AI.CodeInterpreterToolCallContent, Microsoft.Extensions.AI.Abstractions",
|
||||
throwOnError: true)!;
|
||||
object instance = Activator.CreateInstance(type)!;
|
||||
type.GetProperty("CallId")!.SetValue(instance, callId);
|
||||
return instance;
|
||||
}
|
||||
|
||||
private static object CreateMcpToolCall(string callId, string toolName, string serverName)
|
||||
{
|
||||
Type type = Type.GetType(
|
||||
"Microsoft.Extensions.AI.McpServerToolCallContent, Microsoft.Extensions.AI.Abstractions",
|
||||
throwOnError: true)!;
|
||||
return Activator.CreateInstance(type, callId, toolName, serverName)!;
|
||||
}
|
||||
|
||||
private static object CreateImageGenerationToolCall()
|
||||
{
|
||||
Type type = Type.GetType(
|
||||
"Microsoft.Extensions.AI.ImageGenerationToolCallContent, Microsoft.Extensions.AI.Abstractions",
|
||||
throwOnError: true)!;
|
||||
return Activator.CreateInstance(type)!;
|
||||
}
|
||||
|
||||
private static object CreateHandoffTarget(string id, string name)
|
||||
{
|
||||
Type type = Type.GetType(
|
||||
"Microsoft.Agents.AI.Workflows.Specialized.HandoffTarget, Microsoft.Agents.AI.Workflows",
|
||||
throwOnError: true)!;
|
||||
return Activator.CreateInstance(type, CreateChatClientAgent(id, name), "Handle the UX work.")!;
|
||||
}
|
||||
|
||||
private static ChatClientAgent CreateChatClientAgent(string id, string name)
|
||||
{
|
||||
return new ChatClientAgent(
|
||||
new StubChatClient(),
|
||||
id,
|
||||
name,
|
||||
"Stub agent for handoff tests.",
|
||||
[],
|
||||
null!,
|
||||
null!);
|
||||
}
|
||||
|
||||
private sealed class StubChatClient : IChatClient
|
||||
{
|
||||
public Task<ChatResponse> GetResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options,
|
||||
[EnumeratorCancellation]
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType, object? serviceKey)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user