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,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
};
}