feat: extract MyAgent core and web console

This commit is contained in:
justme committed 2026-09-29 20:47:00 +08:00
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
};
}
+20
View File
@@ -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"));
}
}