feat: extract MyAgent core and web console
This commit is contained in:
commit
e491b1a35b
83 files changed
+13651
No files matched your search
@@ -0,0 +1,85 @@
|
||||
using MyAgent.Agents;
|
||||
using MyAgent.Core;
|
||||
using MyAgent.Tools;
|
||||
using Xunit;
|
||||
|
||||
namespace MyAgent.Tests.Agents;
|
||||
|
||||
public sealed class PlannerIntentWeightTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParsePlan_HighSemanticWeightSelectsToolWithoutKeywordRules()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"nextAction":"NeedTool",
|
||||
"taskType":"FactCheck",
|
||||
"complexity":"Normal",
|
||||
"toolWeight":0.91,
|
||||
"memoryRecallWeight":0.08,
|
||||
"analysisWeight":0.2,
|
||||
"tools":[{
|
||||
"name":"web_search",
|
||||
"query":"Acme Cloud service availability July 2026",
|
||||
"reason":"The answer depends on current external state",
|
||||
"maxResults":5,
|
||||
"confidence":0.88
|
||||
}]
|
||||
}
|
||||
""";
|
||||
|
||||
var plan = PlannerAgent.ParsePlan(json, Router(), new ToolCatalog());
|
||||
|
||||
Assert.True(plan.NeedsTool);
|
||||
Assert.Single(plan.Tools);
|
||||
Assert.Equal("web_search", plan.Tools[0].Name);
|
||||
Assert.Equal(0.91, plan.ToolWeight, 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsePlan_LowWeightsDoNotTriggerToolsOrMemoryDespiteToolWords()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"nextAction":"NeedTool",
|
||||
"taskType":"Chat",
|
||||
"complexity":"Simple",
|
||||
"toolWeight":0.2,
|
||||
"memoryRecallWeight":0.3,
|
||||
"analysisWeight":0.1,
|
||||
"reason":"The user is discussing the words search, image and previous, not requesting actions",
|
||||
"tools":[{
|
||||
"name":"image_search",
|
||||
"query":"wallpaper avatar image",
|
||||
"confidence":0.25
|
||||
}]
|
||||
}
|
||||
""";
|
||||
|
||||
var plan = PlannerAgent.ParsePlan(json, Router(), new ToolCatalog());
|
||||
|
||||
Assert.False(plan.NeedsTool);
|
||||
Assert.False(plan.NeedsMemoryRecall);
|
||||
Assert.False(plan.NeedsAnalysis);
|
||||
Assert.Empty(plan.Tools);
|
||||
Assert.Equal(PlannerNextAction.ReadyToReply, plan.NextAction);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.49, false)]
|
||||
[InlineData(0.5, true)]
|
||||
[InlineData(0.9, true)]
|
||||
public void ToolThreshold_IsStable(double weight, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, IntentWeightPolicy.ShouldUseTool(weight));
|
||||
}
|
||||
|
||||
private static RouterDecisionEnvelope Router() => new()
|
||||
{
|
||||
ShouldReply = true,
|
||||
ShouldEnter = true,
|
||||
Target = ConversationTarget.Bot,
|
||||
Complexity = TaskComplexity.Simple,
|
||||
Route = new RouteDecision { ShouldReply = true }
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using MyAgent.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace MyAgent.Tests.Core;
|
||||
|
||||
public sealed class ArtifactJournalTests
|
||||
{
|
||||
[Fact]
|
||||
public void AppendAndRead_PreservesTypedArtifactsAndSequence()
|
||||
{
|
||||
var journal = new ArtifactJournal();
|
||||
|
||||
var first = journal.Append("text", "agent-a", "one");
|
||||
var second = journal.Append("number", "agent-b", 2);
|
||||
|
||||
Assert.Equal(1, first.Sequence);
|
||||
Assert.Equal(2, second.Sequence);
|
||||
Assert.Equal(["one"], journal.Read<string>());
|
||||
Assert.Equal([2], journal.Read<int>());
|
||||
Assert.Equal(2, journal.Snapshot().Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using MyAgent.Core;
|
||||
using MyAgent.Tools;
|
||||
using Xunit;
|
||||
|
||||
namespace MyAgent.Tests.Core;
|
||||
|
||||
public sealed class ExecutionPlanCompilerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Compile_CreatesParallelToolsAndDependentAnalyst()
|
||||
{
|
||||
var compiler = new ExecutionPlanCompiler(new SubagentRegistry(), new ToolCatalog());
|
||||
var decision = new PlannerDecisionEnvelope
|
||||
{
|
||||
NeedsTool = true,
|
||||
NeedsAnalysis = true,
|
||||
Route = new RouteDecision { WebPolicy = WebPolicy.Required },
|
||||
Tools =
|
||||
[
|
||||
new RouteToolRequest { Name = "web_search", Query = "one" },
|
||||
new RouteToolRequest { Name = "web_fetch", Url = "https://example.com" }
|
||||
]
|
||||
};
|
||||
|
||||
var plan = compiler.Compile(decision);
|
||||
|
||||
Assert.Equal(3, plan.Nodes.Count);
|
||||
Assert.Empty(plan.Nodes[0].DependsOn);
|
||||
Assert.Empty(plan.Nodes[1].DependsOn);
|
||||
Assert.Equal(["tool-1", "tool-2"], plan.Nodes[2].DependsOn);
|
||||
Assert.Equal("analyst", plan.Nodes[2].AgentId);
|
||||
Assert.Equal(2, plan.MaxConcurrency);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using MyAgent.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace MyAgent.Tests.Core;
|
||||
|
||||
public sealed class SessionManagerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DebounceAsync_TwoRapidMessagesProduceOneStableBatch()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
var now = DateTimeOffset.Now;
|
||||
var first = manager.DebounceAsync(CreateMessage("1", "第一条", now));
|
||||
await Task.Delay(10);
|
||||
var second = manager.DebounceAsync(CreateMessage("2", "第二条", now.AddMilliseconds(10)));
|
||||
|
||||
Assert.Null(await first);
|
||||
var batch = await second;
|
||||
Assert.NotNull(batch);
|
||||
Assert.Equal(2, batch!.Messages.Count);
|
||||
Assert.Contains("第一条", batch.MergedText);
|
||||
Assert.Contains("第二条", batch.MergedText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DebounceAsync_CompletedBatchIsConsumed()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
var first = await manager.DebounceAsync(CreateMessage("1", "第一轮", DateTimeOffset.Now));
|
||||
var second = await manager.DebounceAsync(CreateMessage("2", "第二轮", DateTimeOffset.Now.AddSeconds(1)));
|
||||
|
||||
Assert.Single(first!.Messages);
|
||||
Assert.Single(second!.Messages);
|
||||
Assert.Equal("第二轮", second.MergedText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DebounceAsync_CallerCancellationDoesNotLeaveBrokenPendingState()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
var cancelled = manager.DebounceAsync(CreateMessage("1", "取消", DateTimeOffset.Now), cancellation.Token);
|
||||
cancellation.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => cancelled);
|
||||
var next = await manager.DebounceAsync(CreateMessage("2", "下一条", DateTimeOffset.Now.AddSeconds(1)));
|
||||
|
||||
Assert.NotNull(next);
|
||||
Assert.Single(next!.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DebounceAsync_DifferentUsersReplyingToSameMessageStayIsolated()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
var firstMessage = CreateMessage("1", "用户一", DateTimeOffset.Now, "1", "root");
|
||||
var secondMessage = CreateMessage("2", "用户二", DateTimeOffset.Now, "2", "root");
|
||||
|
||||
var first = manager.DebounceAsync(firstMessage);
|
||||
var second = manager.DebounceAsync(secondMessage);
|
||||
var batches = await Task.WhenAll(first, second);
|
||||
|
||||
Assert.All(batches, batch => Assert.Single(batch!.Messages));
|
||||
Assert.NotEqual(batches[0]!.ThreadId, batches[1]!.ThreadId);
|
||||
}
|
||||
|
||||
private static SessionManager CreateManager()
|
||||
{
|
||||
var options = Options.Create(new ShirobotOptions
|
||||
{
|
||||
Fusion = new FusionOptions
|
||||
{
|
||||
DebounceMs = 40,
|
||||
SameUserMergeWindowSec = 8,
|
||||
MaxMergedMessages = 8
|
||||
}
|
||||
});
|
||||
return new SessionManager(options, TimeProvider.System);
|
||||
}
|
||||
|
||||
private static IncomingMessage CreateMessage(string id, string text, DateTimeOffset at, string userId = "1", string? replyToMessageId = null) => new()
|
||||
{
|
||||
MessageId = id,
|
||||
GroupId = "1",
|
||||
UserId = userId,
|
||||
Text = text,
|
||||
CreatedAt = at,
|
||||
ReplyToMessageId = replyToMessageId
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using MyAgent.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace MyAgent.Tests.Core;
|
||||
|
||||
public sealed class SubagentRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Registry_ContainsOnlyControlledRoles()
|
||||
{
|
||||
var registry = new SubagentRegistry();
|
||||
|
||||
Assert.Equal(["analyst", "memory-recall", "tool"], registry.List().Select(x => x.Id).ToArray());
|
||||
Assert.False(registry.TryGet("System.IO.File", out _));
|
||||
Assert.True(registry.TryGet("tool", out var tool));
|
||||
Assert.Equal(4, tool.MaxInstancesPerPlan);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using MyAgent.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace MyAgent.Tests.Core;
|
||||
|
||||
public sealed class TaskRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void StartLease_NewerTaskCancelsPreviousAndOldTaskCannotComplete()
|
||||
{
|
||||
var registry = new TaskRegistry();
|
||||
var first = CreateTask("first");
|
||||
var second = CreateTask("second");
|
||||
|
||||
var firstLease = registry.StartLease(first, ["message-1"]);
|
||||
var secondLease = registry.StartLease(second, ["message-2"]);
|
||||
|
||||
Assert.True(firstLease.Token.IsCancellationRequested);
|
||||
Assert.True(secondLease.CancelledPrevious);
|
||||
Assert.False(registry.Complete(first.TaskId, "late", firstLease.Generation));
|
||||
Assert.True(registry.IsCurrent(secondLease));
|
||||
Assert.True(registry.TryGet(first.TaskId, out var firstState));
|
||||
Assert.Equal("cancelled", firstState!.Status);
|
||||
Assert.Equal("cancelled_by_newer_message", firstState.CancellationReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Complete_IsIdempotentAndClearsCurrentOwner()
|
||||
{
|
||||
var registry = new TaskRegistry();
|
||||
var task = CreateTask("only");
|
||||
var lease = registry.StartLease(task, ["message-1"]);
|
||||
|
||||
Assert.True(registry.Complete(task.TaskId, "ok", lease.Generation));
|
||||
Assert.False(registry.Complete(task.TaskId, "again", lease.Generation));
|
||||
Assert.False(registry.IsCurrent(lease));
|
||||
}
|
||||
|
||||
private static AgentTaskState CreateTask(string id) => new()
|
||||
{
|
||||
TaskId = id,
|
||||
ThreadId = "group:1:user:1",
|
||||
GroupId = "1",
|
||||
UserId = "1",
|
||||
MergedText = id
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\MyAgent\MyAgent.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,27 @@
|
||||
using MyAgent.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace MyAgent.Tests.Storage;
|
||||
|
||||
public sealed class SafePathSegmentTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(".")]
|
||||
[InlineData("..")]
|
||||
[InlineData("../other")]
|
||||
[InlineData("group/name")]
|
||||
[InlineData("group\\name")]
|
||||
public void Normalize_RejectsPathTraversal(string value)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => SafePathSegment.Normalize(value, "groupId"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("915449089")]
|
||||
[InlineData("demo-group")]
|
||||
[InlineData("user_1")]
|
||||
public void Normalize_AcceptsStableIdentifiers(string value)
|
||||
{
|
||||
Assert.Equal(value, SafePathSegment.Normalize(value, "id"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user