diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ec0dbee..fa36b723 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -299,7 +299,7 @@ jobs: if: ${{ success() && env.CODECOV_TOKEN != '' }} uses: codecov/codecov-action@v5 with: - files: ./artifacts/coverage/**/report/Cobertura.xml + files: ./artifacts/coverage/**/raw/**/coverage.cobertura.xml disable_search: true fail_ci_if_error: true flags: ci diff --git a/aevatar.slnx b/aevatar.slnx index 26b52b2d..6ce68e96 100644 --- a/aevatar.slnx +++ b/aevatar.slnx @@ -2,7 +2,14 @@ + + + + + + + @@ -23,7 +30,6 @@ - @@ -99,6 +105,7 @@ + diff --git a/agents/Aevatar.GAgents.ChatHistory/Aevatar.GAgents.ChatHistory.csproj b/agents/Aevatar.GAgents.ChatHistory/Aevatar.GAgents.ChatHistory.csproj new file mode 100644 index 00000000..769c215f --- /dev/null +++ b/agents/Aevatar.GAgents.ChatHistory/Aevatar.GAgents.ChatHistory.csproj @@ -0,0 +1,24 @@ + + + net10.0 + enable + enable + Aevatar.GAgents.ChatHistory + Aevatar.GAgents.ChatHistory + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/agents/Aevatar.GAgents.ChatHistory/ChatConversationGAgent.cs b/agents/Aevatar.GAgents.ChatHistory/ChatConversationGAgent.cs new file mode 100644 index 00000000..31ba900c --- /dev/null +++ b/agents/Aevatar.GAgents.ChatHistory/ChatConversationGAgent.cs @@ -0,0 +1,122 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; +using Microsoft.Extensions.DependencyInjection; + +namespace Aevatar.GAgents.ChatHistory; + +/// +/// Per-conversation actor that holds all messages for a single conversation. +/// Actor ID: chat-{scopeId}-{conversationId}. +/// +/// When messages are replaced or the conversation is deleted, forwards +/// the change to the via SendToAsync, +/// ensuring transactional consistency between conversation and index actors. +/// +public sealed class ChatConversationGAgent : GAgentBase +{ + /// Maximum messages retained per conversation. + internal const int MaxMessages = 500; + + [EventHandler(EndpointName = "replaceMessages")] + public async Task HandleMessagesReplaced(MessagesReplacedEvent evt) + { + if (evt.Meta is null) + return; + + // Trim to MaxMessages (keep newest) + var trimmed = TrimMessages(evt); + + await PersistDomainEventAsync(trimmed); + + // Forward index upsert to the index actor + if (!string.IsNullOrWhiteSpace(evt.ScopeId)) + { + var indexActorId = IndexActorId(evt.ScopeId); + await EnsureIndexActorAsync(indexActorId); + var indexMeta = State.Meta?.Clone(); + if (indexMeta is not null) + { + indexMeta.MessageCount = State.Messages.Count; + await SendToAsync(indexActorId, new ConversationUpsertedEvent { Meta = indexMeta }); + } + } + } + + [EventHandler(EndpointName = "deleteConversation")] + public async Task HandleConversationDeleted(ConversationDeletedEvent evt) + { + if (string.IsNullOrWhiteSpace(evt.ConversationId)) + return; + + // Only delete if there is state to delete + if (State.Meta is null && State.Messages.Count == 0) + return; + + await PersistDomainEventAsync(evt); + + // Forward index removal to the index actor + if (!string.IsNullOrWhiteSpace(evt.ScopeId)) + { + var indexActorId = IndexActorId(evt.ScopeId); + await EnsureIndexActorAsync(indexActorId); + await SendToAsync(indexActorId, new ConversationRemovedEvent { ConversationId = evt.ConversationId }); + } + } + + protected override async Task OnActivateAsync(CancellationToken ct) + { + await base.OnActivateAsync(ct); + } + + protected override ChatConversationState TransitionState( + ChatConversationState current, IMessage evt) + { + return StateTransitionMatcher + .Match(current, evt) + .On(ApplyMessagesReplaced) + .On(ApplyConversationDeleted) + .OrCurrent(); + } + + private static MessagesReplacedEvent TrimMessages(MessagesReplacedEvent evt) + { + if (evt.Messages.Count <= MaxMessages) + return evt; + + var trimmed = evt.Clone(); + var excess = trimmed.Messages.Count - MaxMessages; + for (var i = 0; i < excess; i++) + trimmed.Messages.RemoveAt(0); + + if (trimmed.Meta is not null) + trimmed.Meta.MessageCount = trimmed.Messages.Count; + + return trimmed; + } + + private static ChatConversationState ApplyMessagesReplaced( + ChatConversationState state, MessagesReplacedEvent evt) + { + var next = new ChatConversationState { Meta = evt.Meta?.Clone() }; + next.Messages.AddRange(evt.Messages); + return next; + } + + private static ChatConversationState ApplyConversationDeleted( + ChatConversationState state, ConversationDeletedEvent evt) + { + return new ChatConversationState(); + } + + private async Task EnsureIndexActorAsync(string indexActorId) + { + var runtime = Services.GetRequiredService(); + if (await runtime.GetAsync(indexActorId) is null) + await runtime.CreateAsync(indexActorId); + } + + private static string IndexActorId(string scopeId) => $"chat-index-{scopeId}"; +} diff --git a/agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexGAgent.cs b/agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexGAgent.cs new file mode 100644 index 00000000..5ce65866 --- /dev/null +++ b/agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexGAgent.cs @@ -0,0 +1,81 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; + +namespace Aevatar.GAgents.ChatHistory; + +/// +/// Per-user index actor that holds conversation list and metadata. +/// Actor ID: chat-index-{scopeId}. +/// +public sealed class ChatHistoryIndexGAgent : GAgentBase +{ + [EventHandler(EndpointName = "upsertConversation")] + public async Task HandleConversationUpserted(ConversationUpsertedEvent evt) + { + if (evt.Meta is null || string.IsNullOrWhiteSpace(evt.Meta.Id)) + return; + + await PersistDomainEventAsync(evt); + } + + [EventHandler(EndpointName = "removeConversation")] + public async Task HandleConversationRemoved(ConversationRemovedEvent evt) + { + if (string.IsNullOrWhiteSpace(evt.ConversationId)) + return; + + // Idempotent: skip if not present + var existing = State.Conversations.FirstOrDefault(c => + string.Equals(c.Id, evt.ConversationId, StringComparison.Ordinal)); + if (existing is null) + return; + + await PersistDomainEventAsync(evt); + } + + protected override async Task OnActivateAsync(CancellationToken ct) + { + await base.OnActivateAsync(ct); + } + + protected override ChatHistoryIndexState TransitionState( + ChatHistoryIndexState current, IMessage evt) + { + return StateTransitionMatcher + .Match(current, evt) + .On(ApplyConversationUpserted) + .On(ApplyConversationRemoved) + .OrCurrent(); + } + + private static ChatHistoryIndexState ApplyConversationUpserted( + ChatHistoryIndexState state, ConversationUpsertedEvent evt) + { + var next = state.Clone(); + + var existing = next.Conversations.FirstOrDefault(c => + string.Equals(c.Id, evt.Meta.Id, StringComparison.Ordinal)); + if (existing is not null) + next.Conversations.Remove(existing); + + next.Conversations.Add(evt.Meta.Clone()); + return next; + } + + private static ChatHistoryIndexState ApplyConversationRemoved( + ChatHistoryIndexState state, ConversationRemovedEvent evt) + { + var next = state.Clone(); + + var existing = next.Conversations.FirstOrDefault(c => + string.Equals(c.Id, evt.ConversationId, StringComparison.Ordinal)); + if (existing is not null) + next.Conversations.Remove(existing); + + return next; + } + +} diff --git a/agents/Aevatar.GAgents.ChatHistory/chat_history_messages.proto b/agents/Aevatar.GAgents.ChatHistory/chat_history_messages.proto new file mode 100644 index 00000000..6cbca659 --- /dev/null +++ b/agents/Aevatar.GAgents.ChatHistory/chat_history_messages.proto @@ -0,0 +1,64 @@ +syntax = "proto3"; +package aevatar.gagents.chathistory; +option csharp_namespace = "Aevatar.GAgents.ChatHistory"; + +// ─── Shared Types ─── + +message StoredChatMessageProto { + string id = 1; + string role = 2; + string content = 3; + int64 timestamp = 4; + string status = 5; + string error = 6; + string thinking = 7; +} + +message ConversationMetaProto { + string id = 1; + string title = 2; + string service_id = 3; + string service_kind = 4; + int64 created_at_ms = 5; + int64 updated_at_ms = 6; + int32 message_count = 7; + string llm_route = 8; + string llm_model = 9; +} + +// ─── ChatConversationGAgent State ─── + +message ChatConversationState { + repeated StoredChatMessageProto messages = 1; + ConversationMetaProto meta = 2; +} + +// ─── ChatConversationGAgent Events ─── + +message MessagesReplacedEvent { + repeated StoredChatMessageProto messages = 1; + ConversationMetaProto meta = 2; + string scope_id = 3; +} + +message ConversationDeletedEvent { + string conversation_id = 1; + string scope_id = 2; +} + +// ─── ChatHistoryIndexGAgent State ─── + +message ChatHistoryIndexState { + repeated ConversationMetaProto conversations = 1; +} + +// ─── ChatHistoryIndexGAgent Events ─── + +message ConversationUpsertedEvent { + ConversationMetaProto meta = 1; +} + +message ConversationRemovedEvent { + string conversation_id = 1; +} + diff --git a/agents/Aevatar.GAgents.ConnectorCatalog/Aevatar.GAgents.ConnectorCatalog.csproj b/agents/Aevatar.GAgents.ConnectorCatalog/Aevatar.GAgents.ConnectorCatalog.csproj new file mode 100644 index 00000000..12e3dbaa --- /dev/null +++ b/agents/Aevatar.GAgents.ConnectorCatalog/Aevatar.GAgents.ConnectorCatalog.csproj @@ -0,0 +1,24 @@ + + + net10.0 + enable + enable + Aevatar.GAgents.ConnectorCatalog + Aevatar.GAgents.ConnectorCatalog + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogGAgent.cs b/agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogGAgent.cs new file mode 100644 index 00000000..7c6d8123 --- /dev/null +++ b/agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogGAgent.cs @@ -0,0 +1,85 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; + +namespace Aevatar.GAgents.ConnectorCatalog; + +/// +/// Singleton actor that owns the connector catalog and draft. +/// Replaces the chrono-storage backed ChronoStorageConnectorCatalogStore +/// for remote persistence concerns. +/// +/// Actor ID: connector-catalog (cluster-scoped singleton). +/// +public sealed class ConnectorCatalogGAgent : GAgentBase +{ + [EventHandler(EndpointName = "saveCatalog")] + public async Task HandleCatalogSaved(ConnectorCatalogSavedEvent evt) + { + await PersistDomainEventAsync(evt); + } + + [EventHandler(EndpointName = "saveDraft")] + public async Task HandleDraftSaved(ConnectorDraftSavedEvent evt) + { + await PersistDomainEventAsync(evt); + } + + [EventHandler(EndpointName = "deleteDraft")] + public async Task HandleDraftDeleted(ConnectorDraftDeletedEvent evt) + { + // Idempotent: skip if no draft exists + if (State.Draft is null) + return; + + await PersistDomainEventAsync(evt); + } + + protected override async Task OnActivateAsync(CancellationToken ct) + { + await base.OnActivateAsync(ct); + } + + protected override ConnectorCatalogState TransitionState( + ConnectorCatalogState current, IMessage evt) + { + return StateTransitionMatcher + .Match(current, evt) + .On(ApplyCatalogSaved) + .On(ApplyDraftSaved) + .On(ApplyDraftDeleted) + .OrCurrent(); + } + + private static ConnectorCatalogState ApplyCatalogSaved( + ConnectorCatalogState state, ConnectorCatalogSavedEvent evt) + { + var next = state.Clone(); + next.Connectors.Clear(); + next.Connectors.AddRange(evt.Connectors); + return next; + } + + private static ConnectorCatalogState ApplyDraftSaved( + ConnectorCatalogState state, ConnectorDraftSavedEvent evt) + { + var next = state.Clone(); + next.Draft = new ConnectorDraftEntry + { + Draft = evt.Draft?.Clone(), + UpdatedAtUtc = evt.UpdatedAtUtc, + }; + return next; + } + + private static ConnectorCatalogState ApplyDraftDeleted( + ConnectorCatalogState state, ConnectorDraftDeletedEvent _) + { + var next = state.Clone(); + next.Draft = null; + return next; + } + +} diff --git a/agents/Aevatar.GAgents.ConnectorCatalog/connector_catalog_messages.proto b/agents/Aevatar.GAgents.ConnectorCatalog/connector_catalog_messages.proto new file mode 100644 index 00000000..6fa2e3b3 --- /dev/null +++ b/agents/Aevatar.GAgents.ConnectorCatalog/connector_catalog_messages.proto @@ -0,0 +1,84 @@ +syntax = "proto3"; +package aevatar.gagents.connector_catalog; +option csharp_namespace = "Aevatar.GAgents.ConnectorCatalog"; + +import "google/protobuf/timestamp.proto"; + +// ─── Sub-messages ─── + +message ConnectorAuthEntry { + string type = 1; + string token_url = 2; + string client_id = 3; + string client_secret = 4; + string scope = 5; +} + +message HttpConnectorConfigEntry { + string base_url = 1; + repeated string allowed_methods = 2; + repeated string allowed_paths = 3; + repeated string allowed_input_keys = 4; + map default_headers = 5; + ConnectorAuthEntry auth = 6; +} + +message CliConnectorConfigEntry { + string command = 1; + repeated string fixed_arguments = 2; + repeated string allowed_operations = 3; + repeated string allowed_input_keys = 4; + string working_directory = 5; + map environment = 6; +} + +message McpConnectorConfigEntry { + string server_name = 1; + string command = 2; + string url = 3; + repeated string arguments = 4; + map environment = 5; + map additional_headers = 6; + ConnectorAuthEntry auth = 7; + string default_tool = 8; + repeated string allowed_tools = 9; + repeated string allowed_input_keys = 10; +} + +// ─── State ─── + +message ConnectorDefinitionEntry { + string name = 1; + string type = 2; + bool enabled = 3; + int32 timeout_ms = 4; + int32 retry = 5; + HttpConnectorConfigEntry http = 6; + CliConnectorConfigEntry cli = 7; + McpConnectorConfigEntry mcp = 8; +} + +message ConnectorDraftEntry { + ConnectorDefinitionEntry draft = 1; + google.protobuf.Timestamp updated_at_utc = 2; +} + +message ConnectorCatalogState { + repeated ConnectorDefinitionEntry connectors = 1; + ConnectorDraftEntry draft = 2; // null when no draft exists +} + +// ─── Events ─── + +message ConnectorCatalogSavedEvent { + repeated ConnectorDefinitionEntry connectors = 1; +} + +message ConnectorDraftSavedEvent { + ConnectorDefinitionEntry draft = 1; + google.protobuf.Timestamp updated_at_utc = 2; +} + +message ConnectorDraftDeletedEvent { +} + diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatActorStore.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatActorStore.cs deleted file mode 100644 index 06822842..00000000 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatActorStore.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Collections.Concurrent; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Aevatar.GAgents.NyxidChat; - -/// -/// Manages NyxIdChat conversation actors. -/// Phase 1: in-memory store. Phase 2: chrono-storage persistence. -/// -public sealed class NyxIdChatActorStore -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; - - private readonly ConcurrentDictionary> _store = new(StringComparer.OrdinalIgnoreCase); - - public Task> ListActorsAsync(string scopeId, CancellationToken ct = default) - { - var key = BuildKey(scopeId); - var actors = _store.GetOrAdd(key, _ => []); - IReadOnlyList result; - lock (actors) - { - result = actors.ToList().AsReadOnly(); - } - return Task.FromResult(result); - } - - public Task CreateActorAsync(string scopeId, CancellationToken ct = default) - { - var key = BuildKey(scopeId); - var entry = new ActorEntry - { - ActorId = NyxIdChatServiceDefaults.GenerateActorId(), - CreatedAt = DateTimeOffset.UtcNow, - }; - var actors = _store.GetOrAdd(key, _ => []); - lock (actors) - { - actors.Add(entry); - } - return Task.FromResult(entry); - } - - public Task DeleteActorAsync(string scopeId, string actorId, CancellationToken ct = default) - { - var key = BuildKey(scopeId); - if (!_store.TryGetValue(key, out var actors)) - return Task.FromResult(false); - - bool removed; - lock (actors) - { - removed = actors.RemoveAll(e => - string.Equals(e.ActorId, actorId, StringComparison.Ordinal)) > 0; - } - return Task.FromResult(removed); - } - - /// Ensure an actor entry exists for the given scope + actorId (idempotent). - public Task EnsureActorAsync(string scopeId, string actorId, CancellationToken ct = default) - { - var key = BuildKey(scopeId); - var actors = _store.GetOrAdd(key, _ => []); - lock (actors) - { - if (!actors.Exists(e => string.Equals(e.ActorId, actorId, StringComparison.Ordinal))) - actors.Add(new ActorEntry { ActorId = actorId, CreatedAt = DateTimeOffset.UtcNow }); - } - return Task.CompletedTask; - } - - private static string BuildKey(string scopeId) => scopeId.Trim().ToLowerInvariant(); - - public sealed class ActorEntry - { - public string ActorId { get; set; } = string.Empty; - public DateTimeOffset CreatedAt { get; set; } - } -} diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs index 8dfe62ab..8e0ab2bf 100644 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs +++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs @@ -14,6 +14,7 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Aevatar.Studio.Application.Studio.Abstractions; using Microsoft.Extensions.Logging; namespace Aevatar.GAgents.NyxidChat; @@ -76,21 +77,39 @@ public static IEndpointRouteBuilder MapNyxIdChatEndpoints(this IEndpointRouteBui private static async Task HandleCreateConversationAsync( HttpContext http, string scopeId, - [FromServices] NyxIdChatActorStore actorStore, + [FromServices] IGAgentActorStore actorStore, CancellationToken ct) { - var entry = await actorStore.CreateActorAsync(scopeId, ct); - return Results.Ok(new { actorId = entry.ActorId, createdAt = entry.CreatedAt }); + var actorId = NyxIdChatServiceDefaults.GenerateActorId(); + try + { + await actorStore.AddActorAsync(NyxIdChatServiceDefaults.GAgentTypeName, actorId, ct); + } + catch (InvalidOperationException) + { + // chrono-storage unavailable — actor still usable via runtime + } + return Results.Ok(new { actorId }); } private static async Task HandleListConversationsAsync( HttpContext http, string scopeId, - [FromServices] NyxIdChatActorStore actorStore, + [FromServices] IGAgentActorStore actorStore, CancellationToken ct) { - var actors = await actorStore.ListActorsAsync(scopeId, ct); - return Results.Ok(actors); + try + { + var groups = await actorStore.GetAsync(ct); + var group = groups.FirstOrDefault(g => + string.Equals(g.GAgentType, NyxIdChatServiceDefaults.GAgentTypeName, StringComparison.Ordinal)); + var actorIds = group?.ActorIds ?? []; + return Results.Ok(actorIds.Select(id => new { actorId = id })); + } + catch (InvalidOperationException) + { + return Results.Ok(Array.Empty()); + } } private static async Task HandleStreamMessageAsync( @@ -300,11 +319,18 @@ private static async Task HandleDeleteConversationAsync( HttpContext http, string scopeId, string actorId, - [FromServices] NyxIdChatActorStore actorStore, + [FromServices] IGAgentActorStore actorStore, CancellationToken ct) { - var removed = await actorStore.DeleteActorAsync(scopeId, actorId, ct); - return removed ? Results.Ok() : Results.NotFound(); + try + { + await actorStore.RemoveActorAsync(NyxIdChatServiceDefaults.GAgentTypeName, actorId, ct); + } + catch (InvalidOperationException) + { + // chrono-storage unavailable + } + return Results.Ok(); } /// @@ -693,7 +719,7 @@ private static async Task HandleRelayWebhookAsync( HttpContext http, [FromServices] IActorRuntime actorRuntime, [FromServices] IActorEventSubscriptionProvider subscriptionProvider, - [FromServices] NyxIdChatActorStore actorStore, + [FromServices] IGAgentActorStore actorStore, [FromServices] NyxIdRelayOptions relayOptions, [FromServices] ILoggerFactory loggerFactory, CancellationToken ct) @@ -758,7 +784,14 @@ private static async Task HandleRelayWebhookAsync( // ─── Get or create actor ─── var actor = await actorRuntime.GetAsync(actorId) ?? await actorRuntime.CreateAsync(actorId, ct); - await actorStore.EnsureActorAsync(scopeId, actorId, ct); + try + { + await actorStore.AddActorAsync(NyxIdChatServiceDefaults.GAgentTypeName, actorId, ct); + } + catch (InvalidOperationException) + { + // chrono-storage unavailable — actor still usable via runtime + } // ─── Subscribe and collect response ─── var responseTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatServiceDefaults.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatServiceDefaults.cs index 517b9378..4661de04 100644 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatServiceDefaults.cs +++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatServiceDefaults.cs @@ -4,7 +4,7 @@ public static class NyxIdChatServiceDefaults { public const string ServiceId = "nyxid-chat"; public const string DisplayName = "NyxID Chat"; - public const string GAgentTypeName = "Aevatar.GAgents.NyxidChat.NyxIdChatGAgent"; + public static readonly string GAgentTypeName = typeof(NyxIdChatGAgent).FullName!; public const string ActorIdPrefix = "nyxid-chat"; public const string ActorsFileName = "actors"; public const string ProviderName = "nyxid"; diff --git a/agents/Aevatar.GAgents.NyxidChat/ServiceCollectionExtensions.cs b/agents/Aevatar.GAgents.NyxidChat/ServiceCollectionExtensions.cs index 889fde98..0eb959f6 100644 --- a/agents/Aevatar.GAgents.NyxidChat/ServiceCollectionExtensions.cs +++ b/agents/Aevatar.GAgents.NyxidChat/ServiceCollectionExtensions.cs @@ -11,7 +11,6 @@ public static IServiceCollection AddNyxIdChat(this IServiceCollection services, { RuntimeHelpers.RunClassConstructor(typeof(NyxIdChatGAgent).TypeHandle); - services.TryAddSingleton(); services.TryAddSingleton(BindRelayOptions(configuration)); return services; diff --git a/agents/Aevatar.GAgents.Registry/Aevatar.GAgents.Registry.csproj b/agents/Aevatar.GAgents.Registry/Aevatar.GAgents.Registry.csproj new file mode 100644 index 00000000..39ef14d1 --- /dev/null +++ b/agents/Aevatar.GAgents.Registry/Aevatar.GAgents.Registry.csproj @@ -0,0 +1,24 @@ + + + net10.0 + enable + enable + Aevatar.GAgents.Registry + Aevatar.GAgents.Registry + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/agents/Aevatar.GAgents.Registry/GAgentRegistryGAgent.cs b/agents/Aevatar.GAgents.Registry/GAgentRegistryGAgent.cs new file mode 100644 index 00000000..3f9919e6 --- /dev/null +++ b/agents/Aevatar.GAgents.Registry/GAgentRegistryGAgent.cs @@ -0,0 +1,99 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; + +namespace Aevatar.GAgents.Registry; + +/// +/// Per-scope registry actor that tracks all GAgent actor IDs grouped by type. +/// Replaces the chrono-storage backed ChronoStorageGAgentActorStore. +/// +/// Actor ID: gagent-registry-{scopeId} (per-scope). +/// +public sealed class GAgentRegistryGAgent : GAgentBase +{ + [EventHandler(EndpointName = "registerActor")] + public async Task HandleActorRegistered(ActorRegisteredEvent evt) + { + if (string.IsNullOrWhiteSpace(evt.GagentType) || string.IsNullOrWhiteSpace(evt.ActorId)) + return; + + // Idempotent: skip if already registered + var group = State.Groups.FirstOrDefault(g => + string.Equals(g.GagentType, evt.GagentType, StringComparison.Ordinal)); + if (group is not null && group.ActorIds.Contains(evt.ActorId)) + return; + + await PersistDomainEventAsync(evt); + } + + [EventHandler(EndpointName = "unregisterActor")] + public async Task HandleActorUnregistered(ActorUnregisteredEvent evt) + { + if (string.IsNullOrWhiteSpace(evt.GagentType) || string.IsNullOrWhiteSpace(evt.ActorId)) + return; + + // Idempotent: skip if not registered + var group = State.Groups.FirstOrDefault(g => + string.Equals(g.GagentType, evt.GagentType, StringComparison.Ordinal)); + if (group is null || !group.ActorIds.Contains(evt.ActorId)) + return; + + await PersistDomainEventAsync(evt); + } + + protected override async Task OnActivateAsync(CancellationToken ct) + { + await base.OnActivateAsync(ct); + } + + protected override GAgentRegistryState TransitionState( + GAgentRegistryState current, IMessage evt) + { + return StateTransitionMatcher + .Match(current, evt) + .On(ApplyRegistered) + .On(ApplyUnregistered) + .OrCurrent(); + } + + private static GAgentRegistryState ApplyRegistered( + GAgentRegistryState state, ActorRegisteredEvent evt) + { + var next = state.Clone(); + var group = next.Groups.FirstOrDefault(g => + string.Equals(g.GagentType, evt.GagentType, StringComparison.Ordinal)); + + if (group is null) + { + group = new GAgentRegistryEntry { GagentType = evt.GagentType }; + next.Groups.Add(group); + } + + if (!group.ActorIds.Contains(evt.ActorId)) + group.ActorIds.Add(evt.ActorId); + + return next; + } + + private static GAgentRegistryState ApplyUnregistered( + GAgentRegistryState state, ActorUnregisteredEvent evt) + { + var next = state.Clone(); + var group = next.Groups.FirstOrDefault(g => + string.Equals(g.GagentType, evt.GagentType, StringComparison.Ordinal)); + + if (group is null) + return next; + + group.ActorIds.Remove(evt.ActorId); + + if (group.ActorIds.Count == 0) + next.Groups.Remove(group); + + return next; + } + +} diff --git a/agents/Aevatar.GAgents.Registry/gagent_registry_messages.proto b/agents/Aevatar.GAgents.Registry/gagent_registry_messages.proto new file mode 100644 index 00000000..619ffb2b --- /dev/null +++ b/agents/Aevatar.GAgents.Registry/gagent_registry_messages.proto @@ -0,0 +1,27 @@ +syntax = "proto3"; +package aevatar.gagents.registry; +option csharp_namespace = "Aevatar.GAgents.Registry"; + +// ─── State ─── + +message GAgentRegistryEntry { + string gagent_type = 1; + repeated string actor_ids = 2; +} + +message GAgentRegistryState { + repeated GAgentRegistryEntry groups = 1; +} + +// ─── Events ─── + +message ActorRegisteredEvent { + string gagent_type = 1; + string actor_id = 2; +} + +message ActorUnregisteredEvent { + string gagent_type = 1; + string actor_id = 2; +} + diff --git a/agents/Aevatar.GAgents.RoleCatalog/Aevatar.GAgents.RoleCatalog.csproj b/agents/Aevatar.GAgents.RoleCatalog/Aevatar.GAgents.RoleCatalog.csproj new file mode 100644 index 00000000..87ad2db4 --- /dev/null +++ b/agents/Aevatar.GAgents.RoleCatalog/Aevatar.GAgents.RoleCatalog.csproj @@ -0,0 +1,24 @@ + + + net10.0 + enable + enable + Aevatar.GAgents.RoleCatalog + Aevatar.GAgents.RoleCatalog + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/agents/Aevatar.GAgents.RoleCatalog/RoleCatalogGAgent.cs b/agents/Aevatar.GAgents.RoleCatalog/RoleCatalogGAgent.cs new file mode 100644 index 00000000..f8eda8de --- /dev/null +++ b/agents/Aevatar.GAgents.RoleCatalog/RoleCatalogGAgent.cs @@ -0,0 +1,84 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; + +namespace Aevatar.GAgents.RoleCatalog; + +/// +/// Singleton actor that owns the role catalog and draft. +/// Replaces the chrono-storage backed ChronoStorageRoleCatalogStore +/// for remote persistence concerns. +/// +/// Actor ID: role-catalog (cluster-scoped singleton). +/// +public sealed class RoleCatalogGAgent : GAgentBase +{ + [EventHandler(EndpointName = "saveCatalog")] + public async Task HandleCatalogSaved(RoleCatalogSavedEvent evt) + { + await PersistDomainEventAsync(evt); + } + + [EventHandler(EndpointName = "saveDraft")] + public async Task HandleDraftSaved(RoleDraftSavedEvent evt) + { + await PersistDomainEventAsync(evt); + } + + [EventHandler(EndpointName = "deleteDraft")] + public async Task HandleDraftDeleted(RoleDraftDeletedEvent evt) + { + if (State.Draft is null) + return; + + await PersistDomainEventAsync(evt); + } + + protected override async Task OnActivateAsync(CancellationToken ct) + { + await base.OnActivateAsync(ct); + } + + protected override RoleCatalogState TransitionState( + RoleCatalogState current, IMessage evt) + { + return StateTransitionMatcher + .Match(current, evt) + .On(ApplyCatalogSaved) + .On(ApplyDraftSaved) + .On(ApplyDraftDeleted) + .OrCurrent(); + } + + private static RoleCatalogState ApplyCatalogSaved( + RoleCatalogState state, RoleCatalogSavedEvent evt) + { + var next = state.Clone(); + next.Roles.Clear(); + next.Roles.AddRange(evt.Roles); + return next; + } + + private static RoleCatalogState ApplyDraftSaved( + RoleCatalogState state, RoleDraftSavedEvent evt) + { + var next = state.Clone(); + next.Draft = new RoleDraftEntry + { + Draft = evt.Draft?.Clone(), + UpdatedAtUtc = evt.UpdatedAtUtc, + }; + return next; + } + + private static RoleCatalogState ApplyDraftDeleted( + RoleCatalogState state, RoleDraftDeletedEvent _) + { + var next = state.Clone(); + next.Draft = null; + return next; + } + +} diff --git a/agents/Aevatar.GAgents.RoleCatalog/role_catalog_messages.proto b/agents/Aevatar.GAgents.RoleCatalog/role_catalog_messages.proto new file mode 100644 index 00000000..c3b995e1 --- /dev/null +++ b/agents/Aevatar.GAgents.RoleCatalog/role_catalog_messages.proto @@ -0,0 +1,40 @@ +syntax = "proto3"; +package aevatar.gagents.role_catalog; +option csharp_namespace = "Aevatar.GAgents.RoleCatalog"; + +import "google/protobuf/timestamp.proto"; + +// ─── State ─── + +message RoleDefinitionEntry { + string id = 1; + string name = 2; + string system_prompt = 3; + string provider = 4; + string model = 5; + repeated string connectors = 6; +} + +message RoleDraftEntry { + RoleDefinitionEntry draft = 1; + google.protobuf.Timestamp updated_at_utc = 2; +} + +message RoleCatalogState { + repeated RoleDefinitionEntry roles = 1; + RoleDraftEntry draft = 2; // null when no draft exists +} + +// ─── Events ─── + +message RoleCatalogSavedEvent { + repeated RoleDefinitionEntry roles = 1; +} + +message RoleDraftSavedEvent { + RoleDefinitionEntry draft = 1; + google.protobuf.Timestamp updated_at_utc = 2; +} + +message RoleDraftDeletedEvent {} + diff --git a/agents/Aevatar.GAgents.StreamingProxy/Aevatar.GAgents.StreamingProxy.csproj b/agents/Aevatar.GAgents.StreamingProxy/Aevatar.GAgents.StreamingProxy.csproj index 87cc9140..326c8080 100644 --- a/agents/Aevatar.GAgents.StreamingProxy/Aevatar.GAgents.StreamingProxy.csproj +++ b/agents/Aevatar.GAgents.StreamingProxy/Aevatar.GAgents.StreamingProxy.csproj @@ -10,6 +10,7 @@ + diff --git a/agents/Aevatar.GAgents.StreamingProxy/ServiceCollectionExtensions.cs b/agents/Aevatar.GAgents.StreamingProxy/ServiceCollectionExtensions.cs index a334a298..c6e1deb7 100644 --- a/agents/Aevatar.GAgents.StreamingProxy/ServiceCollectionExtensions.cs +++ b/agents/Aevatar.GAgents.StreamingProxy/ServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; namespace Aevatar.GAgents.StreamingProxy; @@ -7,7 +6,6 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddStreamingProxy(this IServiceCollection services) { - services.TryAddSingleton(); return services; } } diff --git a/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyActorStore.cs b/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyActorStore.cs deleted file mode 100644 index b49298aa..00000000 --- a/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyActorStore.cs +++ /dev/null @@ -1,103 +0,0 @@ -using System.Collections.Concurrent; - -namespace Aevatar.GAgents.StreamingProxy; - -/// -/// Manages StreamingProxy room actors. -/// Phase 1: in-memory store. Phase 2: persistent storage. -/// -public sealed class StreamingProxyActorStore -{ - private readonly ConcurrentDictionary> _store = new(StringComparer.OrdinalIgnoreCase); - private readonly ConcurrentDictionary> _participants = new(StringComparer.OrdinalIgnoreCase); - - public Task> ListRoomsAsync(string scopeId, CancellationToken ct = default) - { - var key = BuildKey(scopeId); - var rooms = _store.GetOrAdd(key, _ => []); - IReadOnlyList result; - lock (rooms) - { - result = rooms.ToList().AsReadOnly(); - } - return Task.FromResult(result); - } - - public Task CreateRoomAsync(string scopeId, string? roomName, CancellationToken ct = default) - { - var key = BuildKey(scopeId); - var entry = new RoomEntry - { - RoomId = StreamingProxyDefaults.GenerateRoomId(), - RoomName = roomName ?? "Group Chat", - CreatedAt = DateTimeOffset.UtcNow, - }; - var rooms = _store.GetOrAdd(key, _ => []); - lock (rooms) - { - rooms.Add(entry); - } - return Task.FromResult(entry); - } - - public Task DeleteRoomAsync(string scopeId, string roomId, CancellationToken ct = default) - { - var key = BuildKey(scopeId); - if (!_store.TryGetValue(key, out var rooms)) - return Task.FromResult(false); - - bool removed; - lock (rooms) - { - removed = rooms.RemoveAll(e => - string.Equals(e.RoomId, roomId, StringComparison.Ordinal)) > 0; - } - return Task.FromResult(removed); - } - - public void AddParticipant(string scopeId, string roomId, string agentId, string displayName) - { - var key = BuildParticipantKey(scopeId, roomId); - var participants = _participants.GetOrAdd(key, _ => []); - lock (participants) - { - participants.RemoveAll(p => string.Equals(p.AgentId, agentId, StringComparison.Ordinal)); - participants.Add(new ParticipantEntry - { - AgentId = agentId, - DisplayName = displayName, - JoinedAt = DateTimeOffset.UtcNow, - }); - } - } - - public IReadOnlyList ListParticipants(string scopeId, string roomId) - { - var key = BuildParticipantKey(scopeId, roomId); - if (!_participants.TryGetValue(key, out var participants)) - return []; - - lock (participants) - { - return participants.ToList().AsReadOnly(); - } - } - - private static string BuildKey(string scopeId) => scopeId.Trim().ToLowerInvariant(); - private static string BuildParticipantKey(string scopeId, string roomId) => - $"{scopeId.Trim().ToLowerInvariant()}:{roomId}"; - - public sealed class RoomEntry - { - public string RoomId { get; set; } = string.Empty; - public string RoomName { get; set; } = string.Empty; - public DateTimeOffset CreatedAt { get; set; } - } - - public sealed class ParticipantEntry - { - public string AgentId { get; set; } = string.Empty; - public string DisplayName { get; set; } = string.Empty; - public DateTimeOffset JoinedAt { get; set; } - } -} diff --git a/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyDefaults.cs b/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyDefaults.cs index 2104852a..98f8ce31 100644 --- a/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyDefaults.cs +++ b/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyDefaults.cs @@ -4,6 +4,7 @@ public static class StreamingProxyDefaults { public const string ServiceId = "streaming-proxy"; public const string DisplayName = "Streaming Proxy"; + public static readonly string GAgentTypeName = typeof(StreamingProxyGAgent).FullName!; public const string ActorIdPrefix = "streaming-proxy"; public const int MaxMessages = 500; diff --git a/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs b/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs index 88a030c2..175d8d68 100644 --- a/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs +++ b/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using Aevatar.Studio.Application.Studio.Abstractions; using Microsoft.Extensions.Logging; namespace Aevatar.GAgents.StreamingProxy; @@ -44,51 +45,100 @@ private static async Task HandleCreateRoomAsync( HttpContext http, string scopeId, [FromBody] CreateRoomRequest? request, - [FromServices] StreamingProxyActorStore store, + [FromServices] IGAgentActorStore actorStore, [FromServices] IActorRuntime actorRuntime, + [FromServices] ILoggerFactory loggerFactory, CancellationToken ct) { + var logger = loggerFactory.CreateLogger("Aevatar.GAgents.StreamingProxy.Endpoints"); var roomName = request?.RoomName?.Trim(); if (string.IsNullOrWhiteSpace(roomName)) roomName = "Group Chat"; - var entry = await store.CreateRoomAsync(scopeId, roomName, ct); + var roomId = StreamingProxyDefaults.GenerateRoomId(); + try + { + await actorStore.AddActorAsync(StreamingProxyDefaults.GAgentTypeName, roomId, ct); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + logger.LogError(ex, "Failed to register room {RoomId} before activation", roomId); + return Results.Json( + new { error = "Failed to create room" }, + statusCode: StatusCodes.Status503ServiceUnavailable); + } - // Create the actor and initialize it - var actor = await actorRuntime.CreateAsync(entry.RoomId, ct); + try + { + var actor = await actorRuntime.CreateAsync(roomId, ct); - var initEvent = new GroupChatRoomInitializedEvent { RoomName = roomName }; - var envelope = new EventEnvelope + var initEvent = new GroupChatRoomInitializedEvent { RoomName = roomName }; + var envelope = new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + Payload = Any.Pack(initEvent), + Route = new EnvelopeRoute { Direct = new DirectRoute { TargetActorId = actor.Id } }, + }; + await actor.HandleEventAsync(envelope, ct); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) { - Id = Guid.NewGuid().ToString("N"), - Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), - Payload = Any.Pack(initEvent), - Route = new EnvelopeRoute { Direct = new DirectRoute { TargetActorId = actor.Id } }, - }; - await actor.HandleEventAsync(envelope, ct); + logger.LogError(ex, "Failed to activate room {RoomId}; rolling back registration", roomId); + await TryRollbackRoomCreationAsync(roomId, actorStore, actorRuntime, logger); + return Results.Json( + new { error = "Failed to create room" }, + statusCode: StatusCodes.Status500InternalServerError); + } - return Results.Ok(new { roomId = entry.RoomId, roomName = entry.RoomName, createdAt = entry.CreatedAt }); + return Results.Ok(new { roomId, roomName }); } private static async Task HandleListRoomsAsync( HttpContext http, string scopeId, - [FromServices] StreamingProxyActorStore store, + [FromServices] IGAgentActorStore actorStore, + [FromServices] ILoggerFactory loggerFactory, CancellationToken ct) { - var rooms = await store.ListRoomsAsync(scopeId, ct); - return Results.Ok(rooms); + var logger = loggerFactory.CreateLogger("Aevatar.GAgents.StreamingProxy.Endpoints"); + try + { + var groups = await actorStore.GetAsync(ct); + var group = groups.FirstOrDefault(g => + string.Equals(g.GAgentType, StreamingProxyDefaults.GAgentTypeName, StringComparison.Ordinal)); + var roomIds = group?.ActorIds ?? []; + return Results.Ok(roomIds.Select(id => new { roomId = id })); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to list rooms from actor store"); + return Results.Ok(Array.Empty()); + } } private static async Task HandleDeleteRoomAsync( HttpContext http, string scopeId, string roomId, - [FromServices] StreamingProxyActorStore store, + [FromServices] IGAgentActorStore actorStore, + [FromServices] ILoggerFactory loggerFactory, CancellationToken ct) { - var removed = await store.DeleteRoomAsync(scopeId, roomId, ct); - return removed ? Results.Ok() : Results.NotFound(); + var logger = loggerFactory.CreateLogger("Aevatar.GAgents.StreamingProxy.Endpoints"); + try + { + await actorStore.RemoveActorAsync(StreamingProxyDefaults.GAgentTypeName, roomId, ct); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to remove room {RoomId} from actor store", roomId); + } + return Results.Ok(); } // ─── User Chat (trigger topic + SSE stream) ─── @@ -271,15 +321,28 @@ private static async Task HandleMessageStreamAsync( // ─── Participant management ─── - private static Task HandleListParticipantsAsync( + private static async Task HandleListParticipantsAsync( HttpContext http, string scopeId, string roomId, - [FromServices] StreamingProxyActorStore store, + [FromServices] IStreamingProxyParticipantStore participantStore, + [FromServices] ILoggerFactory loggerFactory, CancellationToken ct) { - var participants = store.ListParticipants(scopeId, roomId); - return Task.FromResult(Results.Ok(participants)); + var logger = loggerFactory.CreateLogger("Aevatar.GAgents.StreamingProxy.Endpoints"); + try + { + var participants = await participantStore.ListAsync(roomId, ct); + return Results.Ok(participants); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + logger.LogError(ex, "Failed to list participants for room {RoomId}", roomId); + return Results.Json( + new { error = "Failed to list participants" }, + statusCode: StatusCodes.Status500InternalServerError); + } } private static async Task HandleJoinAsync( @@ -288,7 +351,8 @@ private static async Task HandleJoinAsync( string roomId, JoinRoomRequest request, [FromServices] IActorRuntime actorRuntime, - [FromServices] StreamingProxyActorStore store, + [FromServices] IStreamingProxyParticipantStore participantStore, + [FromServices] ILoggerFactory loggerFactory, CancellationToken ct) { if (string.IsNullOrWhiteSpace(request.AgentId)) @@ -316,8 +380,16 @@ private static async Task HandleJoinAsync( }; await actor.HandleEventAsync(envelope, ct); - // Track participant in the store for query endpoints - store.AddParticipant(scopeId, roomId, agentId, displayName); + var logger = loggerFactory.CreateLogger("Aevatar.GAgents.StreamingProxy.Endpoints"); + try + { + await participantStore.AddAsync(roomId, agentId, displayName, ct); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to persist participant {AgentId} in room {RoomId}", agentId, roomId); + } return Results.Ok(new { status = "joined", agentId }); } @@ -352,6 +424,34 @@ private static async ValueTask MapAndWriteEventAsync(EventEnvelope envelope, Str } } + private static async Task TryRollbackRoomCreationAsync( + string roomId, + IGAgentActorStore actorStore, + IActorRuntime actorRuntime, + ILogger logger) + { + try + { + await actorRuntime.DestroyAsync(roomId, CancellationToken.None); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to destroy room actor {RoomId} during rollback", roomId); + } + + try + { + await actorStore.RemoveActorAsync( + StreamingProxyDefaults.GAgentTypeName, + roomId, + CancellationToken.None); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to remove room {RoomId} from actor store during rollback", roomId); + } + } + // ─── Request DTOs ─── public sealed record CreateRoomRequest(string? RoomName); diff --git a/agents/Aevatar.GAgents.StreamingProxyParticipant/Aevatar.GAgents.StreamingProxyParticipant.csproj b/agents/Aevatar.GAgents.StreamingProxyParticipant/Aevatar.GAgents.StreamingProxyParticipant.csproj new file mode 100644 index 00000000..0a8c0688 --- /dev/null +++ b/agents/Aevatar.GAgents.StreamingProxyParticipant/Aevatar.GAgents.StreamingProxyParticipant.csproj @@ -0,0 +1,24 @@ + + + net10.0 + enable + enable + Aevatar.GAgents.StreamingProxyParticipant + Aevatar.GAgents.StreamingProxyParticipant + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/agents/Aevatar.GAgents.StreamingProxyParticipant/StreamingProxyParticipantGAgent.cs b/agents/Aevatar.GAgents.StreamingProxyParticipant/StreamingProxyParticipantGAgent.cs new file mode 100644 index 00000000..735fe628 --- /dev/null +++ b/agents/Aevatar.GAgents.StreamingProxyParticipant/StreamingProxyParticipantGAgent.cs @@ -0,0 +1,90 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; + +namespace Aevatar.GAgents.StreamingProxyParticipant; + +/// +/// Singleton actor that tracks streaming proxy room participants. +/// Replaces the chrono-storage backed ChronoStorageStreamingProxyParticipantStore. +/// +/// Actor ID: streaming-proxy-participants (cluster-scoped singleton). +/// +public sealed class StreamingProxyParticipantGAgent + : GAgentBase +{ + [EventHandler(EndpointName = "addParticipant")] + public async Task HandleParticipantAdded(ParticipantAddedEvent evt) + { + if (string.IsNullOrWhiteSpace(evt.RoomId) || string.IsNullOrWhiteSpace(evt.AgentId)) + return; + + await PersistDomainEventAsync(evt); + } + + [EventHandler(EndpointName = "removeRoomParticipants")] + public async Task HandleRoomParticipantsRemoved(RoomParticipantsRemovedEvent evt) + { + if (string.IsNullOrWhiteSpace(evt.RoomId)) + return; + + // Idempotent: skip if room does not exist + if (!State.Rooms.ContainsKey(evt.RoomId)) + return; + + await PersistDomainEventAsync(evt); + } + + protected override async Task OnActivateAsync(CancellationToken ct) + { + await base.OnActivateAsync(ct); + } + + protected override StreamingProxyParticipantGAgentState TransitionState( + StreamingProxyParticipantGAgentState current, IMessage evt) + { + return StateTransitionMatcher + .Match(current, evt) + .On(ApplyParticipantAdded) + .On(ApplyRoomRemoved) + .OrCurrent(); + } + + private static StreamingProxyParticipantGAgentState ApplyParticipantAdded( + StreamingProxyParticipantGAgentState state, ParticipantAddedEvent evt) + { + var next = state.Clone(); + + if (!next.Rooms.TryGetValue(evt.RoomId, out var list)) + { + list = new ParticipantList(); + next.Rooms[evt.RoomId] = list; + } + + // Remove existing entry for the same agent (upsert semantics) + var existing = list.Participants.FirstOrDefault(p => + string.Equals(p.AgentId, evt.AgentId, StringComparison.Ordinal)); + if (existing is not null) + list.Participants.Remove(existing); + + list.Participants.Add(new ParticipantEntry + { + AgentId = evt.AgentId, + DisplayName = evt.DisplayName, + JoinedAt = evt.JoinedAt, + }); + + return next; + } + + private static StreamingProxyParticipantGAgentState ApplyRoomRemoved( + StreamingProxyParticipantGAgentState state, RoomParticipantsRemovedEvent evt) + { + var next = state.Clone(); + next.Rooms.Remove(evt.RoomId); + return next; + } + +} diff --git a/agents/Aevatar.GAgents.StreamingProxyParticipant/streaming_proxy_participant_messages.proto b/agents/Aevatar.GAgents.StreamingProxyParticipant/streaming_proxy_participant_messages.proto new file mode 100644 index 00000000..c02210eb --- /dev/null +++ b/agents/Aevatar.GAgents.StreamingProxyParticipant/streaming_proxy_participant_messages.proto @@ -0,0 +1,36 @@ +syntax = "proto3"; +package aevatar.gagents.streaming_proxy_participant; +option csharp_namespace = "Aevatar.GAgents.StreamingProxyParticipant"; + +import "google/protobuf/timestamp.proto"; + +// ─── State ─── + +message ParticipantEntry { + string agent_id = 1; + string display_name = 2; + google.protobuf.Timestamp joined_at = 3; +} + +message ParticipantList { + repeated ParticipantEntry participants = 1; +} + +message StreamingProxyParticipantGAgentState { + // roomId → participants + map rooms = 1; +} + +// ─── Events ─── + +message ParticipantAddedEvent { + string room_id = 1; + string agent_id = 2; + string display_name = 3; + google.protobuf.Timestamp joined_at = 4; +} + +message RoomParticipantsRemovedEvent { + string room_id = 1; +} + diff --git a/agents/Aevatar.GAgents.UserConfig/Aevatar.GAgents.UserConfig.csproj b/agents/Aevatar.GAgents.UserConfig/Aevatar.GAgents.UserConfig.csproj new file mode 100644 index 00000000..9661f708 --- /dev/null +++ b/agents/Aevatar.GAgents.UserConfig/Aevatar.GAgents.UserConfig.csproj @@ -0,0 +1,24 @@ + + + net10.0 + enable + enable + Aevatar.GAgents.UserConfig + Aevatar.GAgents.UserConfig + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/agents/Aevatar.GAgents.UserConfig/UserConfigGAgent.cs b/agents/Aevatar.GAgents.UserConfig/UserConfigGAgent.cs new file mode 100644 index 00000000..05053036 --- /dev/null +++ b/agents/Aevatar.GAgents.UserConfig/UserConfigGAgent.cs @@ -0,0 +1,51 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; + +namespace Aevatar.GAgents.UserConfig; + +/// +/// User-scoped actor that owns the user configuration state. +/// Replaces the chrono-storage backed ChronoStorageUserConfigStore. +/// +/// Actor ID: user-config-{scopeId} (per-scope). +/// +public sealed class UserConfigGAgent : GAgentBase +{ + [EventHandler(EndpointName = "updateConfig")] + public async Task HandleConfigUpdated(UserConfigUpdatedEvent evt) + { + await PersistDomainEventAsync(evt); + } + + protected override async Task OnActivateAsync(CancellationToken ct) + { + await base.OnActivateAsync(ct); + } + + protected override UserConfigGAgentState TransitionState( + UserConfigGAgentState current, IMessage evt) + { + return StateTransitionMatcher + .Match(current, evt) + .On(ApplyConfigUpdated) + .OrCurrent(); + } + + private static UserConfigGAgentState ApplyConfigUpdated( + UserConfigGAgentState state, UserConfigUpdatedEvent evt) + { + return new UserConfigGAgentState + { + DefaultModel = evt.DefaultModel, + PreferredLlmRoute = evt.PreferredLlmRoute, + RuntimeMode = evt.RuntimeMode, + LocalRuntimeBaseUrl = evt.LocalRuntimeBaseUrl, + RemoteRuntimeBaseUrl = evt.RemoteRuntimeBaseUrl, + MaxToolRounds = evt.MaxToolRounds, + }; + } + +} diff --git a/agents/Aevatar.GAgents.UserConfig/user_config_messages.proto b/agents/Aevatar.GAgents.UserConfig/user_config_messages.proto new file mode 100644 index 00000000..ad0676e7 --- /dev/null +++ b/agents/Aevatar.GAgents.UserConfig/user_config_messages.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; +package aevatar.gagents.user_config; +option csharp_namespace = "Aevatar.GAgents.UserConfig"; + +// ─── State ─── + +message UserConfigGAgentState { + string default_model = 1; + string preferred_llm_route = 2; + string runtime_mode = 3; + string local_runtime_base_url = 4; + string remote_runtime_base_url = 5; + int32 max_tool_rounds = 6; +} + +// ─── Events ─── + +message UserConfigUpdatedEvent { + string default_model = 1; + string preferred_llm_route = 2; + string runtime_mode = 3; + string local_runtime_base_url = 4; + string remote_runtime_base_url = 5; + int32 max_tool_rounds = 6; +} + diff --git a/agents/Aevatar.GAgents.UserMemory/Aevatar.GAgents.UserMemory.csproj b/agents/Aevatar.GAgents.UserMemory/Aevatar.GAgents.UserMemory.csproj new file mode 100644 index 00000000..759383e3 --- /dev/null +++ b/agents/Aevatar.GAgents.UserMemory/Aevatar.GAgents.UserMemory.csproj @@ -0,0 +1,24 @@ + + + net10.0 + enable + enable + Aevatar.GAgents.UserMemory + Aevatar.GAgents.UserMemory + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/agents/Aevatar.GAgents.UserMemory/UserMemoryGAgent.cs b/agents/Aevatar.GAgents.UserMemory/UserMemoryGAgent.cs new file mode 100644 index 00000000..c3975cd7 --- /dev/null +++ b/agents/Aevatar.GAgents.UserMemory/UserMemoryGAgent.cs @@ -0,0 +1,136 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; + +namespace Aevatar.GAgents.UserMemory; + +/// +/// Per-user memory actor that maintains a capped set of memory entries. +/// +/// Actor ID: user-memory-{userId} (user-scoped). +/// +/// Eviction policy (runs inside ): +/// 1. When adding an entry that would exceed , +/// evict the oldest entry in the same category first. +/// 2. If no same-category entry remains, evict the globally oldest entry. +/// +/// +public sealed class UserMemoryGAgent : GAgentBase +{ + internal const int MaxEntries = 50; + + [EventHandler(EndpointName = "addMemoryEntry")] + public async Task HandleMemoryEntryAdded(MemoryEntryAddedEvent evt) + { + if (evt.Entry is null + || string.IsNullOrWhiteSpace(evt.Entry.Id) + || string.IsNullOrWhiteSpace(evt.Entry.Content)) + return; + + // Idempotent: skip if an entry with this ID already exists + if (State.Entries.Any(e => string.Equals(e.Id, evt.Entry.Id, StringComparison.Ordinal))) + return; + + await PersistDomainEventAsync(evt); + } + + [EventHandler(EndpointName = "removeMemoryEntry")] + public async Task HandleMemoryEntryRemoved(MemoryEntryRemovedEvent evt) + { + if (string.IsNullOrWhiteSpace(evt.EntryId)) + return; + + // Idempotent: skip if not present + if (!State.Entries.Any(e => string.Equals(e.Id, evt.EntryId, StringComparison.Ordinal))) + return; + + await PersistDomainEventAsync(evt); + } + + [EventHandler(EndpointName = "clearMemoryEntries")] + public async Task HandleMemoryEntriesCleared(MemoryEntriesClearedEvent evt) + { + if (State.Entries.Count == 0) + return; + + await PersistDomainEventAsync(evt); + } + + protected override async Task OnActivateAsync(CancellationToken ct) + { + await base.OnActivateAsync(ct); + } + + protected override UserMemoryState TransitionState( + UserMemoryState current, IMessage evt) + { + return StateTransitionMatcher + .Match(current, evt) + .On(ApplyAdded) + .On(ApplyRemoved) + .On(ApplyCleared) + .OrCurrent(); + } + + private static UserMemoryState ApplyAdded( + UserMemoryState state, MemoryEntryAddedEvent evt) + { + var next = state.Clone(); + next.Entries.Add(evt.Entry.Clone()); + + // Eviction: enforce global cap. + // Priority: evict oldest in same category first, then globally oldest. + while (next.Entries.Count > MaxEntries) + { + var category = evt.Entry.Category; + var oldestSameCategory = next.Entries + .Where(e => string.Equals(e.Category, category, StringComparison.Ordinal) + && !string.Equals(e.Id, evt.Entry.Id, StringComparison.Ordinal)) + .OrderBy(e => e.CreatedAt) + .FirstOrDefault(); + + if (oldestSameCategory is not null) + { + next.Entries.Remove(oldestSameCategory); + } + else + { + var globallyOldest = next.Entries + .Where(e => !string.Equals(e.Id, evt.Entry.Id, StringComparison.Ordinal)) + .OrderBy(e => e.CreatedAt) + .FirstOrDefault(); + + if (globallyOldest is not null) + next.Entries.Remove(globallyOldest); + else + break; + } + } + + return next; + } + + private static UserMemoryState ApplyRemoved( + UserMemoryState state, MemoryEntryRemovedEvent evt) + { + var next = state.Clone(); + var entry = next.Entries.FirstOrDefault(e => + string.Equals(e.Id, evt.EntryId, StringComparison.Ordinal)); + + if (entry is not null) + next.Entries.Remove(entry); + + return next; + } + + private static UserMemoryState ApplyCleared( + UserMemoryState state, MemoryEntriesClearedEvent _) + { + var next = state.Clone(); + next.Entries.Clear(); + return next; + } + +} diff --git a/agents/Aevatar.GAgents.UserMemory/user_memory_messages.proto b/agents/Aevatar.GAgents.UserMemory/user_memory_messages.proto new file mode 100644 index 00000000..8b71ffc5 --- /dev/null +++ b/agents/Aevatar.GAgents.UserMemory/user_memory_messages.proto @@ -0,0 +1,32 @@ +syntax = "proto3"; +package aevatar.gagents.user_memory; +option csharp_namespace = "Aevatar.GAgents.UserMemory"; + +// ─── State ─── + +message UserMemoryEntryProto { + string id = 1; + string category = 2; + string content = 3; + string source = 4; + int64 created_at = 5; + int64 updated_at = 6; +} + +message UserMemoryState { + repeated UserMemoryEntryProto entries = 1; +} + +// ─── Events ─── + +message MemoryEntryAddedEvent { + UserMemoryEntryProto entry = 1; +} + +message MemoryEntryRemovedEvent { + string entry_id = 1; +} + +message MemoryEntriesClearedEvent { +} + diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/IStreamingProxyParticipantStore.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/IStreamingProxyParticipantStore.cs new file mode 100644 index 00000000..795ab006 --- /dev/null +++ b/src/Aevatar.Studio.Application/Studio/Abstractions/IStreamingProxyParticipantStore.cs @@ -0,0 +1,29 @@ +namespace Aevatar.Studio.Application.Studio.Abstractions; + +/// +/// Persistent participant index for streaming proxy rooms. +/// +/// +/// TODO: When wiring to endpoints, the caller must handle corrupt-data exceptions +/// from the underlying store (e.g. from +/// deserialization failures). Swallowing errors and returning an empty list would +/// silently discard existing participants. The chrono-storage implementation +/// intentionally throws on corruption to prevent data loss. +/// +public interface IStreamingProxyParticipantStore +{ + Task> ListAsync( + string roomId, CancellationToken cancellationToken = default); + + Task AddAsync( + string roomId, string agentId, string displayName, + CancellationToken cancellationToken = default); + + Task RemoveRoomAsync( + string roomId, CancellationToken cancellationToken = default); +} + +public sealed record StreamingProxyParticipant( + string AgentId, + string DisplayName, + DateTimeOffset JoinedAt); diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/IUserConfigCommandService.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/IUserConfigCommandService.cs new file mode 100644 index 00000000..44395464 --- /dev/null +++ b/src/Aevatar.Studio.Application/Studio/Abstractions/IUserConfigCommandService.cs @@ -0,0 +1,10 @@ +namespace Aevatar.Studio.Application.Studio.Abstractions; + +/// +/// Pure-write command service for user configuration. +/// Dispatches commands to the UserConfigGAgent actor. +/// +public interface IUserConfigCommandService +{ + Task SaveAsync(UserConfig config, CancellationToken ct = default); +} diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/IUserConfigQueryPort.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/IUserConfigQueryPort.cs new file mode 100644 index 00000000..a2bdb625 --- /dev/null +++ b/src/Aevatar.Studio.Application/Studio/Abstractions/IUserConfigQueryPort.cs @@ -0,0 +1,10 @@ +namespace Aevatar.Studio.Application.Studio.Abstractions; + +/// +/// Pure-read query port for user configuration. +/// Reads from the projection document store, not from actor state. +/// +public interface IUserConfigQueryPort +{ + Task GetAsync(CancellationToken ct = default); +} diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/IUserConfigStore.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/IUserConfigStore.cs index fa42b12f..9f6c76dc 100644 --- a/src/Aevatar.Studio.Application/Studio/Abstractions/IUserConfigStore.cs +++ b/src/Aevatar.Studio.Application/Studio/Abstractions/IUserConfigStore.cs @@ -1,11 +1,5 @@ namespace Aevatar.Studio.Application.Studio.Abstractions; -public interface IUserConfigStore -{ - Task GetAsync(CancellationToken cancellationToken = default); - Task SaveAsync(UserConfig config, CancellationToken cancellationToken = default); -} - public static class UserConfigLlmRouteDefaults { public const string Gateway = ""; diff --git a/src/Aevatar.Studio.Application/Studio/Services/ExecutionService.cs b/src/Aevatar.Studio.Application/Studio/Services/ExecutionService.cs index 8b9e6990..ab531a03 100644 --- a/src/Aevatar.Studio.Application/Studio/Services/ExecutionService.cs +++ b/src/Aevatar.Studio.Application/Studio/Services/ExecutionService.cs @@ -17,7 +17,7 @@ public sealed class ExecutionService private const string ExecutionStreamFailedCode = "EXECUTION_STREAM_FAILED"; private readonly IStudioWorkspaceStore _store; - private readonly IUserConfigStore? _userConfigStore; + private readonly IUserConfigQueryPort? _userConfigStore; private readonly IHttpClientFactory _httpClientFactory; private readonly IStudioBackendRequestAuthSnapshotProvider? _authSnapshotProvider; private readonly string _observationSessionId = Guid.NewGuid().ToString("N"); @@ -26,7 +26,7 @@ public ExecutionService( IStudioWorkspaceStore store, IHttpClientFactory httpClientFactory, IStudioBackendRequestAuthSnapshotProvider? authSnapshotProvider = null, - IUserConfigStore? userConfigStore = null) + IUserConfigQueryPort? userConfigStore = null) { _store = store; _httpClientFactory = httpClientFactory; diff --git a/src/Aevatar.Studio.Hosting/Aevatar.Studio.Hosting.csproj b/src/Aevatar.Studio.Hosting/Aevatar.Studio.Hosting.csproj index 39ddea5b..a11a1b53 100644 --- a/src/Aevatar.Studio.Hosting/Aevatar.Studio.Hosting.csproj +++ b/src/Aevatar.Studio.Hosting/Aevatar.Studio.Hosting.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Aevatar.Studio.Hosting/Controllers/UserConfigController.cs b/src/Aevatar.Studio.Hosting/Controllers/UserConfigController.cs index e053c181..0591495a 100644 --- a/src/Aevatar.Studio.Hosting/Controllers/UserConfigController.cs +++ b/src/Aevatar.Studio.Hosting/Controllers/UserConfigController.cs @@ -2,7 +2,6 @@ using System.Text.Json; using System.Text.Json.Serialization; using Aevatar.Studio.Application.Studio.Abstractions; -using Aevatar.Studio.Infrastructure.Storage; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; @@ -21,18 +20,21 @@ public sealed class UserConfigController : ControllerBase PropertyNameCaseInsensitive = true, }; - private readonly IUserConfigStore _userConfigStore; + private readonly IUserConfigQueryPort _queryPort; + private readonly IUserConfigCommandService _commandService; private readonly IHttpClientFactory _httpClientFactory; private readonly IConfiguration _configuration; private readonly ILogger _logger; public UserConfigController( - IUserConfigStore userConfigStore, + IUserConfigQueryPort queryPort, + IUserConfigCommandService commandService, IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger logger) { - _userConfigStore = userConfigStore; + _queryPort = queryPort; + _commandService = commandService; _httpClientFactory = httpClientFactory; _configuration = configuration; _logger = logger; @@ -43,12 +45,7 @@ public async Task> Get(CancellationToken cancellationTo { try { - return Ok(await _userConfigStore.GetAsync(cancellationToken)); - } - catch (ChronoStorageServiceException exception) - { - _logger.LogWarning(exception, "Chrono-storage is unavailable when reading user config"); - return ChronoStorageErrorResponses.ToActionResult(exception); + return Ok(await _queryPort.GetAsync(cancellationToken)); } catch (InvalidOperationException exception) { @@ -68,21 +65,16 @@ public async Task> Save( { try { - var current = await _userConfigStore.GetAsync(cancellationToken); + var current = await _queryPort.GetAsync(cancellationToken); var merged = new UserConfig( DefaultModel: request.DefaultModel is null ? current.DefaultModel : request.DefaultModel.Trim(), PreferredLlmRoute: request.PreferredLlmRoute is null ? current.PreferredLlmRoute : UserConfigLlmRoute.Normalize(request.PreferredLlmRoute), RuntimeMode: request.RuntimeMode is null ? current.RuntimeMode : request.RuntimeMode.Trim(), LocalRuntimeBaseUrl: request.LocalRuntimeBaseUrl is null ? current.LocalRuntimeBaseUrl : request.LocalRuntimeBaseUrl.Trim(), RemoteRuntimeBaseUrl: request.RemoteRuntimeBaseUrl is null ? current.RemoteRuntimeBaseUrl : request.RemoteRuntimeBaseUrl.Trim()); - await _userConfigStore.SaveAsync(merged, cancellationToken); + await _commandService.SaveAsync(merged, cancellationToken); return Ok(merged); } - catch (ChronoStorageServiceException exception) - { - _logger.LogWarning(exception, "Chrono-storage is unavailable when saving user config"); - return ChronoStorageErrorResponses.ToActionResult(exception); - } catch (InvalidOperationException exception) { return BadRequest(new { message = exception.Message }); diff --git a/src/Aevatar.Studio.Hosting/StudioHostingServiceCollectionExtensions.cs b/src/Aevatar.Studio.Hosting/StudioHostingServiceCollectionExtensions.cs index a90695f7..53719749 100644 --- a/src/Aevatar.Studio.Hosting/StudioHostingServiceCollectionExtensions.cs +++ b/src/Aevatar.Studio.Hosting/StudioHostingServiceCollectionExtensions.cs @@ -6,6 +6,7 @@ using Aevatar.Studio.Hosting.Endpoints; using Aevatar.Studio.Infrastructure.DependencyInjection; using Aevatar.Studio.Infrastructure.ScopeResolution; +using Aevatar.Studio.Projection.DependencyInjection; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -29,6 +30,7 @@ internal static IServiceCollection AddStudioHostingCore( services.AddSingleton(); services.AddStudioApplication(); services.AddStudioInfrastructure(configuration); + services.AddStudioProjectionComponents(); return services; } diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedChatHistoryStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedChatHistoryStore.cs new file mode 100644 index 00000000..4980cfbd --- /dev/null +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedChatHistoryStore.cs @@ -0,0 +1,166 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.ChatHistory; +using Aevatar.Studio.Application.Studio.Abstractions; +using Microsoft.Extensions.Logging; + +namespace Aevatar.Studio.Infrastructure.ActorBacked; + +/// +/// Actor-backed implementation of . +/// Reads the write actors' state directly. +/// Writes send commands only to +/// (index updates are handled internally by the conversation actor). +/// +internal sealed class ActorBackedChatHistoryStore : IChatHistoryStore +{ + private readonly IActorRuntime _runtime; + private readonly ILogger _logger; + + public ActorBackedChatHistoryStore( + IActorRuntime runtime, + ILogger logger) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task GetIndexAsync(string scopeId, CancellationToken ct = default) + { + var state = await ReadIndexActorStateAsync(scopeId, ct); + if (state is null) + return new ChatHistoryIndex([]); + + return new ChatHistoryIndex(state.Conversations + .Select(ToConversationMeta) + .OrderByDescending(static c => c.UpdatedAt) + .ThenBy(static c => c.Id, StringComparer.Ordinal) + .ToList() + .AsReadOnly()); + } + + public async Task> GetMessagesAsync( + string scopeId, string conversationId, CancellationToken ct = default) + { + var state = await ReadConversationActorStateAsync(scopeId, conversationId, ct); + if (state is null || state.Messages.Count == 0) + return []; + + return state.Messages + .Select(ToStoredChatMessage) + .ToList() + .AsReadOnly(); + } + + public async Task SaveMessagesAsync( + string scopeId, string conversationId, ConversationMeta meta, + IReadOnlyList messages, CancellationToken ct = default) + { + // Only send to conversation actor; it forwards to index actor internally + var conversationActor = await EnsureConversationActorAsync(scopeId, conversationId, ct); + var metaProto = ToConversationMetaProto(conversationId, meta); + var replaceEvt = new MessagesReplacedEvent { Meta = metaProto, ScopeId = scopeId }; + foreach (var msg in messages) + replaceEvt.Messages.Add(ToStoredChatMessageProto(msg)); + + await ActorCommandDispatcher.SendAsync(conversationActor, replaceEvt, ct); + } + + public async Task DeleteConversationAsync( + string scopeId, string conversationId, CancellationToken ct = default) + { + // Only send to conversation actor; it forwards to index actor internally + var conversationActor = await EnsureConversationActorAsync(scopeId, conversationId, ct); + var deleteEvt = new ConversationDeletedEvent + { + ConversationId = conversationId, + ScopeId = scopeId, + }; + await ActorCommandDispatcher.SendAsync(conversationActor, deleteEvt, ct); + } + + // ── Read write actor state directly ─── + + private async Task ReadIndexActorStateAsync( + string scopeId, CancellationToken ct) + { + var actorId = IndexActorId(scopeId); + var actor = await _runtime.GetAsync(actorId); + return (actor?.Agent as IAgent)?.State; + } + + private async Task ReadConversationActorStateAsync( + string scopeId, string conversationId, CancellationToken ct) + { + var actorId = ConversationActorId(scopeId, conversationId); + var actor = await _runtime.GetAsync(actorId); + return (actor?.Agent as IAgent)?.State; + } + + // ── Actor resolution ─────────────────────────────────────── + + private async Task EnsureConversationActorAsync( + string scopeId, string conversationId, CancellationToken ct) + { + var actorId = ConversationActorId(scopeId, conversationId); + var actor = await _runtime.GetAsync(actorId); + return actor ?? await _runtime.CreateAsync(actorId, ct); + } + + // ── Actor ID conventions ─────────────────────────────────── + + private static string IndexActorId(string scopeId) => $"chat-index-{scopeId}"; + private static string ConversationActorId(string scopeId, string conversationId) => $"chat-{scopeId}-{conversationId}"; + + // ── Mapping helpers ──────────────────────────────────────── + + private static ConversationMeta ToConversationMeta(ConversationMetaProto proto) => + new( + Id: proto.Id, + Title: proto.Title, + ServiceId: proto.ServiceId, + ServiceKind: proto.ServiceKind, + CreatedAt: FromUnixMs(proto.CreatedAtMs), + UpdatedAt: FromUnixMs(proto.UpdatedAtMs), + MessageCount: proto.MessageCount, + LlmRoute: string.IsNullOrEmpty(proto.LlmRoute) ? null : proto.LlmRoute, + LlmModel: string.IsNullOrEmpty(proto.LlmModel) ? null : proto.LlmModel); + + private static ConversationMetaProto ToConversationMetaProto(string conversationId, ConversationMeta meta) => + new() + { + Id = conversationId, + Title = meta.Title ?? string.Empty, + ServiceId = meta.ServiceId ?? string.Empty, + ServiceKind = meta.ServiceKind ?? string.Empty, + CreatedAtMs = meta.CreatedAt.ToUnixTimeMilliseconds(), + UpdatedAtMs = meta.UpdatedAt.ToUnixTimeMilliseconds(), + MessageCount = meta.MessageCount, + LlmRoute = meta.LlmRoute ?? string.Empty, + LlmModel = meta.LlmModel ?? string.Empty, + }; + + private static StoredChatMessage ToStoredChatMessage(StoredChatMessageProto proto) => + new( + Id: proto.Id, + Role: proto.Role, + Content: proto.Content, + Timestamp: proto.Timestamp, + Status: proto.Status, + Error: string.IsNullOrEmpty(proto.Error) ? null : proto.Error, + Thinking: string.IsNullOrEmpty(proto.Thinking) ? null : proto.Thinking); + + private static StoredChatMessageProto ToStoredChatMessageProto(StoredChatMessage msg) => + new() + { + Id = msg.Id ?? string.Empty, + Role = msg.Role ?? string.Empty, + Content = msg.Content ?? string.Empty, + Timestamp = msg.Timestamp, + Status = msg.Status ?? string.Empty, + Error = msg.Error ?? string.Empty, + Thinking = msg.Thinking ?? string.Empty, + }; + + private static DateTimeOffset FromUnixMs(long ms) => + ms > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(ms) : DateTimeOffset.UnixEpoch; +} diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs new file mode 100644 index 00000000..857bc490 --- /dev/null +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs @@ -0,0 +1,322 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.ConnectorCatalog; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Infrastructure.ScopeResolution; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging; + +namespace Aevatar.Studio.Infrastructure.ActorBacked; + +/// +/// Actor-backed implementation of . +/// Reads the write actor's state directly. +/// Writes send commands to the Write GAgent. +/// Local workspace operations (import, draft backup) delegate to . +/// Per-scope isolation: each scope gets its own connector-catalog-{scopeId} actor. +/// +internal sealed class ActorBackedConnectorCatalogStore : IConnectorCatalogStore +{ + private const string WriteActorIdPrefix = "connector-catalog-"; + private const string ActorHomeDirectory = "actor://connector-catalog"; + private const string ActorFilePath = "actor://connector-catalog/connectors"; + + private readonly IActorRuntime _runtime; + private readonly IAppScopeResolver _scopeResolver; + private readonly IStudioWorkspaceStore _workspaceStore; + private readonly ILogger _logger; + + public ActorBackedConnectorCatalogStore( + IActorRuntime runtime, + IAppScopeResolver scopeResolver, + IStudioWorkspaceStore workspaceStore, + ILogger logger) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); + _workspaceStore = workspaceStore ?? throw new ArgumentNullException(nameof(workspaceStore)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task GetConnectorCatalogAsync( + CancellationToken cancellationToken = default) + { + var state = await ReadWriteActorStateAsync(cancellationToken); + if (state is null) + { + return new StoredConnectorCatalog( + HomeDirectory: ActorHomeDirectory, + FilePath: ActorFilePath, + FileExists: false, + Connectors: []); + } + + var connectors = state.Connectors + .Select(ToStoredConnectorDefinition) + .ToList() + .AsReadOnly(); + + return new StoredConnectorCatalog( + HomeDirectory: ActorHomeDirectory, + FilePath: ActorFilePath, + FileExists: connectors.Count > 0, + Connectors: connectors); + } + + public async Task SaveConnectorCatalogAsync( + StoredConnectorCatalog catalog, + CancellationToken cancellationToken = default) + { + var actor = await EnsureWriteActorAsync(cancellationToken); + var evt = new ConnectorCatalogSavedEvent(); + evt.Connectors.AddRange(catalog.Connectors.Select(ToProtoConnectorDefinition)); + await ActorCommandDispatcher.SendAsync(actor, evt, cancellationToken); + + return new StoredConnectorCatalog( + HomeDirectory: ActorHomeDirectory, + FilePath: ActorFilePath, + FileExists: true, + Connectors: catalog.Connectors); + } + + public async Task ImportLocalCatalogAsync( + CancellationToken cancellationToken = default) + { + var localCatalog = await _workspaceStore.GetConnectorCatalogAsync(cancellationToken); + if (!localCatalog.FileExists) + { + throw new InvalidOperationException( + $"Local connector catalog not found at '{localCatalog.FilePath}'."); + } + + var actor = await EnsureWriteActorAsync(cancellationToken); + var evt = new ConnectorCatalogSavedEvent(); + evt.Connectors.AddRange(localCatalog.Connectors.Select(ToProtoConnectorDefinition)); + await ActorCommandDispatcher.SendAsync(actor, evt, cancellationToken); + + var importedCatalog = new StoredConnectorCatalog( + HomeDirectory: ActorHomeDirectory, + FilePath: ActorFilePath, + FileExists: true, + Connectors: localCatalog.Connectors); + + return new ImportedConnectorCatalog(localCatalog.FilePath, true, importedCatalog); + } + + public async Task GetConnectorDraftAsync( + CancellationToken cancellationToken = default) + { + var state = await ReadWriteActorStateAsync(cancellationToken); + var draftEntry = state?.Draft; + if (draftEntry is null) + { + return new StoredConnectorDraft( + HomeDirectory: ActorHomeDirectory, + FilePath: ActorFilePath + "/draft", + FileExists: false, + UpdatedAtUtc: null, + Draft: null); + } + + return new StoredConnectorDraft( + HomeDirectory: ActorHomeDirectory, + FilePath: ActorFilePath + "/draft", + FileExists: true, + UpdatedAtUtc: draftEntry.UpdatedAtUtc?.ToDateTimeOffset(), + Draft: draftEntry.Draft is not null ? ToStoredConnectorDefinition(draftEntry.Draft) : null); + } + + public async Task SaveConnectorDraftAsync( + StoredConnectorDraft draft, + CancellationToken cancellationToken = default) + { + var actor = await EnsureWriteActorAsync(cancellationToken); + var updatedAtUtc = draft.UpdatedAtUtc ?? DateTimeOffset.UtcNow; + var evt = new ConnectorDraftSavedEvent + { + Draft = draft.Draft is not null ? ToProtoConnectorDefinition(draft.Draft) : null, + UpdatedAtUtc = Timestamp.FromDateTimeOffset(updatedAtUtc), + }; + await ActorCommandDispatcher.SendAsync(actor, evt, cancellationToken); + + // Also persist to local workspace for offline access + await _workspaceStore.SaveConnectorDraftAsync(draft, cancellationToken); + + return new StoredConnectorDraft( + HomeDirectory: ActorHomeDirectory, + FilePath: ActorFilePath + "/draft", + FileExists: true, + UpdatedAtUtc: updatedAtUtc, + Draft: draft.Draft); + } + + public async Task DeleteConnectorDraftAsync(CancellationToken cancellationToken = default) + { + var actor = await EnsureWriteActorAsync(cancellationToken); + await ActorCommandDispatcher.SendAsync(actor, new ConnectorDraftDeletedEvent(), cancellationToken); + + await _workspaceStore.DeleteConnectorDraftAsync(cancellationToken); + } + + // ── Read write actor state directly ── + + private async Task ReadWriteActorStateAsync(CancellationToken ct) + { + var actorId = ResolveWriteActorId(); + var actor = await _runtime.GetAsync(actorId); + return (actor?.Agent as IAgent)?.State; + } + + // ── Actor resolution ── + + private string ResolveWriteActorId() => WriteActorIdPrefix + _scopeResolver.ResolveScopeIdOrDefault(); + + private async Task EnsureWriteActorAsync(CancellationToken ct) + { + var actorId = ResolveWriteActorId(); + var actor = await _runtime.GetAsync(actorId); + return actor ?? await _runtime.CreateAsync(actorId, ct); + } + + // ── Proto <-> Domain mapping ── + + private static StoredConnectorDefinition ToStoredConnectorDefinition(ConnectorDefinitionEntry entry) => + new( + Name: entry.Name, + Type: entry.Type, + Enabled: entry.Enabled, + TimeoutMs: entry.TimeoutMs, + Retry: entry.Retry, + Http: entry.Http is not null ? ToStoredHttpConfig(entry.Http) : EmptyHttpConfig(), + Cli: entry.Cli is not null ? ToStoredCliConfig(entry.Cli) : EmptyCliConfig(), + Mcp: entry.Mcp is not null ? ToStoredMcpConfig(entry.Mcp) : EmptyMcpConfig()); + + private static StoredHttpConnectorConfig ToStoredHttpConfig(HttpConnectorConfigEntry entry) => + new( + BaseUrl: entry.BaseUrl, + AllowedMethods: entry.AllowedMethods.ToList().AsReadOnly(), + AllowedPaths: entry.AllowedPaths.ToList().AsReadOnly(), + AllowedInputKeys: entry.AllowedInputKeys.ToList().AsReadOnly(), + DefaultHeaders: new Dictionary(entry.DefaultHeaders, StringComparer.OrdinalIgnoreCase), + Auth: entry.Auth is not null ? ToStoredAuthConfig(entry.Auth) : EmptyAuthConfig()); + + private static StoredCliConnectorConfig ToStoredCliConfig(CliConnectorConfigEntry entry) => + new( + Command: entry.Command, + FixedArguments: entry.FixedArguments.ToList().AsReadOnly(), + AllowedOperations: entry.AllowedOperations.ToList().AsReadOnly(), + AllowedInputKeys: entry.AllowedInputKeys.ToList().AsReadOnly(), + WorkingDirectory: entry.WorkingDirectory, + Environment: new Dictionary(entry.Environment, StringComparer.OrdinalIgnoreCase)); + + private static StoredMcpConnectorConfig ToStoredMcpConfig(McpConnectorConfigEntry entry) => + new( + ServerName: entry.ServerName, + Command: entry.Command, + Url: entry.Url, + Arguments: entry.Arguments.ToList().AsReadOnly(), + Environment: new Dictionary(entry.Environment, StringComparer.OrdinalIgnoreCase), + AdditionalHeaders: new Dictionary(entry.AdditionalHeaders, StringComparer.OrdinalIgnoreCase), + Auth: entry.Auth is not null ? ToStoredAuthConfig(entry.Auth) : EmptyAuthConfig(), + DefaultTool: entry.DefaultTool, + AllowedTools: entry.AllowedTools.ToList().AsReadOnly(), + AllowedInputKeys: entry.AllowedInputKeys.ToList().AsReadOnly()); + + private static StoredConnectorAuthConfig ToStoredAuthConfig(ConnectorAuthEntry entry) => + new( + Type: entry.Type, + TokenUrl: entry.TokenUrl, + ClientId: entry.ClientId, + ClientSecret: entry.ClientSecret, + Scope: entry.Scope); + + private static ConnectorDefinitionEntry ToProtoConnectorDefinition(StoredConnectorDefinition def) + { + var entry = new ConnectorDefinitionEntry + { + Name = def.Name, + Type = def.Type, + Enabled = def.Enabled, + TimeoutMs = def.TimeoutMs, + Retry = def.Retry, + Http = ToProtoHttpConfig(def.Http), + Cli = ToProtoCliConfig(def.Cli), + Mcp = ToProtoMcpConfig(def.Mcp), + }; + return entry; + } + + private static HttpConnectorConfigEntry ToProtoHttpConfig(StoredHttpConnectorConfig config) + { + var entry = new HttpConnectorConfigEntry + { + BaseUrl = config.BaseUrl, + Auth = ToProtoAuthConfig(config.Auth), + }; + entry.AllowedMethods.AddRange(config.AllowedMethods); + entry.AllowedPaths.AddRange(config.AllowedPaths); + entry.AllowedInputKeys.AddRange(config.AllowedInputKeys); + foreach (var kvp in config.DefaultHeaders) + entry.DefaultHeaders[kvp.Key] = kvp.Value; + return entry; + } + + private static CliConnectorConfigEntry ToProtoCliConfig(StoredCliConnectorConfig config) + { + var entry = new CliConnectorConfigEntry + { + Command = config.Command, + WorkingDirectory = config.WorkingDirectory, + }; + entry.FixedArguments.AddRange(config.FixedArguments); + entry.AllowedOperations.AddRange(config.AllowedOperations); + entry.AllowedInputKeys.AddRange(config.AllowedInputKeys); + foreach (var kvp in config.Environment) + entry.Environment[kvp.Key] = kvp.Value; + return entry; + } + + private static McpConnectorConfigEntry ToProtoMcpConfig(StoredMcpConnectorConfig config) + { + var entry = new McpConnectorConfigEntry + { + ServerName = config.ServerName, + Command = config.Command, + Url = config.Url, + Auth = ToProtoAuthConfig(config.Auth), + DefaultTool = config.DefaultTool, + }; + entry.Arguments.AddRange(config.Arguments); + entry.AllowedTools.AddRange(config.AllowedTools); + entry.AllowedInputKeys.AddRange(config.AllowedInputKeys); + foreach (var kvp in config.Environment) + entry.Environment[kvp.Key] = kvp.Value; + foreach (var kvp in config.AdditionalHeaders) + entry.AdditionalHeaders[kvp.Key] = kvp.Value; + return entry; + } + + private static ConnectorAuthEntry ToProtoAuthConfig(StoredConnectorAuthConfig config) => + new() + { + Type = config.Type, + TokenUrl = config.TokenUrl, + ClientId = config.ClientId, + ClientSecret = config.ClientSecret, + Scope = config.Scope, + }; + + private static StoredHttpConnectorConfig EmptyHttpConfig() => + new(string.Empty, [], [], [], new Dictionary(StringComparer.OrdinalIgnoreCase), EmptyAuthConfig()); + + private static StoredCliConnectorConfig EmptyCliConfig() => + new(string.Empty, [], [], [], string.Empty, new Dictionary(StringComparer.OrdinalIgnoreCase)); + + private static StoredMcpConnectorConfig EmptyMcpConfig() => + new(string.Empty, string.Empty, string.Empty, [], + new Dictionary(StringComparer.OrdinalIgnoreCase), + new Dictionary(StringComparer.OrdinalIgnoreCase), + EmptyAuthConfig(), string.Empty, [], []); + + private static StoredConnectorAuthConfig EmptyAuthConfig() => + new(string.Empty, string.Empty, string.Empty, string.Empty, string.Empty); +} diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentActorStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentActorStore.cs new file mode 100644 index 00000000..be24518b --- /dev/null +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentActorStore.cs @@ -0,0 +1,90 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.Registry; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Infrastructure.ScopeResolution; +using Microsoft.Extensions.Logging; + +namespace Aevatar.Studio.Infrastructure.ActorBacked; + +/// +/// Actor-backed implementation of . +/// Reads the write actor's state directly. +/// Writes send commands to the Write GAgent. +/// +internal sealed class ActorBackedGAgentActorStore : IGAgentActorStore +{ + private const string WriteActorIdPrefix = "gagent-registry-"; + + private readonly IActorRuntime _runtime; + private readonly IAppScopeResolver _scopeResolver; + private readonly ILogger _logger; + + public ActorBackedGAgentActorStore( + IActorRuntime runtime, + IAppScopeResolver scopeResolver, + ILogger logger) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task> GetAsync( + CancellationToken cancellationToken = default) + { + var state = await ReadWriteActorStateAsync(cancellationToken); + if (state is null) + return []; + + return state.Groups + .Select(g => new GAgentActorGroup( + g.GagentType, + g.ActorIds.ToList().AsReadOnly())) + .ToList() + .AsReadOnly(); + } + + public async Task AddActorAsync( + string gagentType, string actorId, + CancellationToken cancellationToken = default) + { + var actor = await EnsureWriteActorAsync(cancellationToken); + await ActorCommandDispatcher.SendAsync(actor, new ActorRegisteredEvent + { + GagentType = gagentType, + ActorId = actorId, + }, cancellationToken); + } + + public async Task RemoveActorAsync( + string gagentType, string actorId, + CancellationToken cancellationToken = default) + { + var actor = await EnsureWriteActorAsync(cancellationToken); + await ActorCommandDispatcher.SendAsync(actor, new ActorUnregisteredEvent + { + GagentType = gagentType, + ActorId = actorId, + }, cancellationToken); + } + + // ── Read write actor state directly ── + + private async Task ReadWriteActorStateAsync(CancellationToken ct) + { + var actorId = ResolveWriteActorId(); + var actor = await _runtime.GetAsync(actorId); + return (actor?.Agent as IAgent)?.State; + } + + // ── Actor resolution ── + + private string ResolveWriteActorId() => WriteActorIdPrefix + _scopeResolver.ResolveScopeIdOrDefault(); + + private async Task EnsureWriteActorAsync(CancellationToken ct) + { + var actorId = ResolveWriteActorId(); + var actor = await _runtime.GetAsync(actorId); + return actor ?? await _runtime.CreateAsync(actorId, ct); + } +} diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedNyxIdUserLlmPreferencesStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedNyxIdUserLlmPreferencesStore.cs new file mode 100644 index 00000000..0db51661 --- /dev/null +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedNyxIdUserLlmPreferencesStore.cs @@ -0,0 +1,27 @@ +using Aevatar.AI.Abstractions.LLMProviders; +using Aevatar.Studio.Application.Studio.Abstractions; + +namespace Aevatar.Studio.Infrastructure.ActorBacked; + +/// +/// Read-only view that extracts LLM preferences from the user config +/// projection via . +/// +internal sealed class ActorBackedNyxIdUserLlmPreferencesStore : INyxIdUserLlmPreferencesStore +{ + private readonly IUserConfigQueryPort _queryPort; + + public ActorBackedNyxIdUserLlmPreferencesStore(IUserConfigQueryPort queryPort) + { + _queryPort = queryPort ?? throw new ArgumentNullException(nameof(queryPort)); + } + + public async Task GetAsync(CancellationToken cancellationToken = default) + { + var config = await _queryPort.GetAsync(cancellationToken); + return new NyxIdUserLlmPreferences( + config.DefaultModel, + UserConfigLlmRoute.Normalize(config.PreferredLlmRoute), + config.MaxToolRounds); + } +} diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs new file mode 100644 index 00000000..d78937d4 --- /dev/null +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs @@ -0,0 +1,190 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.RoleCatalog; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Infrastructure.ScopeResolution; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging; + +namespace Aevatar.Studio.Infrastructure.ActorBacked; + +/// +/// Actor-backed implementation of . +/// Reads the write actor's state directly. +/// Writes send commands to the Write GAgent. +/// Local workspace operations (import, draft backup) delegate to +/// . +/// Per-scope isolation: each scope gets its own role-catalog-{scopeId} actor. +/// +internal sealed class ActorBackedRoleCatalogStore : IRoleCatalogStore +{ + private const string WriteActorIdPrefix = "role-catalog-"; + private const string ActorHomeDirectory = "actor://role-catalog"; + private const string ActorFilePath = "actor://role-catalog/roles"; + + private readonly IActorRuntime _runtime; + private readonly IAppScopeResolver _scopeResolver; + private readonly IStudioWorkspaceStore _localWorkspaceStore; + private readonly ILogger _logger; + + public ActorBackedRoleCatalogStore( + IActorRuntime runtime, + IAppScopeResolver scopeResolver, + IStudioWorkspaceStore localWorkspaceStore, + ILogger logger) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); + _localWorkspaceStore = localWorkspaceStore ?? throw new ArgumentNullException(nameof(localWorkspaceStore)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task GetRoleCatalogAsync(CancellationToken cancellationToken = default) + { + var state = await ReadWriteActorStateAsync(cancellationToken); + var roles = state?.Roles + .Select(ToStoredRoleDefinition) + .ToList() + .AsReadOnly() + ?? (IReadOnlyList)[]; + + return new StoredRoleCatalog( + HomeDirectory: ActorHomeDirectory, + FilePath: ActorFilePath, + FileExists: roles.Count > 0, + Roles: roles); + } + + public async Task SaveRoleCatalogAsync( + StoredRoleCatalog catalog, + CancellationToken cancellationToken = default) + { + var actor = await EnsureWriteActorAsync(cancellationToken); + var evt = new RoleCatalogSavedEvent(); + evt.Roles.AddRange(catalog.Roles.Select(ToProtoRoleDefinition)); + await ActorCommandDispatcher.SendAsync(actor, evt, cancellationToken); + + return new StoredRoleCatalog( + HomeDirectory: ActorHomeDirectory, + FilePath: ActorFilePath, + FileExists: true, + Roles: catalog.Roles); + } + + public async Task ImportLocalCatalogAsync(CancellationToken cancellationToken = default) + { + var localCatalog = await _localWorkspaceStore.GetRoleCatalogAsync(cancellationToken); + if (!localCatalog.FileExists) + { + throw new InvalidOperationException($"Local role catalog not found at '{localCatalog.FilePath}'."); + } + + var actor = await EnsureWriteActorAsync(cancellationToken); + var evt = new RoleCatalogSavedEvent(); + evt.Roles.AddRange(localCatalog.Roles.Select(ToProtoRoleDefinition)); + await ActorCommandDispatcher.SendAsync(actor, evt, cancellationToken); + + var importedCatalog = new StoredRoleCatalog( + HomeDirectory: ActorHomeDirectory, + FilePath: ActorFilePath, + FileExists: true, + Roles: localCatalog.Roles); + + return new ImportedRoleCatalog(localCatalog.FilePath, true, importedCatalog); + } + + public async Task GetRoleDraftAsync(CancellationToken cancellationToken = default) + { + var state = await ReadWriteActorStateAsync(cancellationToken); + var draftEntry = state?.Draft; + if (draftEntry is null) + { + return new StoredRoleDraft( + HomeDirectory: ActorHomeDirectory, + FilePath: ActorFilePath + "/draft", + FileExists: false, + UpdatedAtUtc: null, + Draft: null); + } + + return new StoredRoleDraft( + HomeDirectory: ActorHomeDirectory, + FilePath: ActorFilePath + "/draft", + FileExists: true, + UpdatedAtUtc: draftEntry.UpdatedAtUtc?.ToDateTimeOffset(), + Draft: draftEntry.Draft is not null ? ToStoredRoleDefinition(draftEntry.Draft) : null); + } + + public async Task SaveRoleDraftAsync( + StoredRoleDraft draft, + CancellationToken cancellationToken = default) + { + var actor = await EnsureWriteActorAsync(cancellationToken); + var updatedAtUtc = draft.UpdatedAtUtc ?? DateTimeOffset.UtcNow; + var evt = new RoleDraftSavedEvent + { + Draft = draft.Draft is not null ? ToProtoRoleDefinition(draft.Draft) : null, + UpdatedAtUtc = Timestamp.FromDateTimeOffset(updatedAtUtc), + }; + await ActorCommandDispatcher.SendAsync(actor, evt, cancellationToken); + + await _localWorkspaceStore.SaveRoleDraftAsync(draft, cancellationToken); + + return new StoredRoleDraft( + HomeDirectory: ActorHomeDirectory, + FilePath: ActorFilePath + "/draft", + FileExists: true, + UpdatedAtUtc: updatedAtUtc, + Draft: draft.Draft); + } + + public async Task DeleteRoleDraftAsync(CancellationToken cancellationToken = default) + { + var actor = await EnsureWriteActorAsync(cancellationToken); + await ActorCommandDispatcher.SendAsync(actor, new RoleDraftDeletedEvent(), cancellationToken); + + await _localWorkspaceStore.DeleteRoleDraftAsync(cancellationToken); + } + + // ── Read write actor state directly ── + + private async Task ReadWriteActorStateAsync(CancellationToken ct) + { + var actorId = ResolveWriteActorId(); + var actor = await _runtime.GetAsync(actorId); + return (actor?.Agent as IAgent)?.State; + } + + // ── Actor resolution ── + + private string ResolveWriteActorId() => WriteActorIdPrefix + _scopeResolver.ResolveScopeIdOrDefault(); + + private async Task EnsureWriteActorAsync(CancellationToken ct) + { + var actorId = ResolveWriteActorId(); + var actor = await _runtime.GetAsync(actorId); + return actor ?? await _runtime.CreateAsync(actorId, ct); + } + + private static StoredRoleDefinition ToStoredRoleDefinition(RoleDefinitionEntry entry) => + new( + Id: entry.Id, + Name: entry.Name, + SystemPrompt: entry.SystemPrompt, + Provider: entry.Provider, + Model: entry.Model, + Connectors: entry.Connectors.ToList().AsReadOnly()); + + private static RoleDefinitionEntry ToProtoRoleDefinition(StoredRoleDefinition def) + { + var entry = new RoleDefinitionEntry + { + Id = def.Id, + Name = def.Name, + SystemPrompt = def.SystemPrompt, + Provider = def.Provider, + Model = def.Model, + }; + entry.Connectors.AddRange(def.Connectors); + return entry; + } +} diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedStreamingProxyParticipantStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedStreamingProxyParticipantStore.cs new file mode 100644 index 00000000..3d65cc84 --- /dev/null +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedStreamingProxyParticipantStore.cs @@ -0,0 +1,90 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.StreamingProxyParticipant; +using Aevatar.Studio.Application.Studio.Abstractions; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging; + +namespace Aevatar.Studio.Infrastructure.ActorBacked; + +/// +/// Actor-backed implementation of . +/// Reads the write actor's state directly. +/// Writes send commands to the Write GAgent. +/// +internal sealed class ActorBackedStreamingProxyParticipantStore + : IStreamingProxyParticipantStore +{ + private const string WriteActorId = "streaming-proxy-participants"; + + private readonly IActorRuntime _runtime; + private readonly ILogger _logger; + + public ActorBackedStreamingProxyParticipantStore( + IActorRuntime runtime, + ILogger logger) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task> ListAsync( + string roomId, CancellationToken cancellationToken = default) + { + var state = await ReadWriteActorStateAsync(cancellationToken); + if (state is null) + return []; + + if (!state.Rooms.TryGetValue(roomId, out var list)) + return []; + + return list.Participants + .Select(p => new StreamingProxyParticipant( + p.AgentId, + p.DisplayName, + p.JoinedAt.ToDateTimeOffset())) + .ToList() + .AsReadOnly(); + } + + public async Task AddAsync( + string roomId, string agentId, string displayName, + CancellationToken cancellationToken = default) + { + var actor = await EnsureWriteActorAsync(cancellationToken); + var evt = new ParticipantAddedEvent + { + RoomId = roomId, + AgentId = agentId, + DisplayName = displayName, + JoinedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }; + await ActorCommandDispatcher.SendAsync(actor, evt, cancellationToken); + } + + public async Task RemoveRoomAsync( + string roomId, CancellationToken cancellationToken = default) + { + var actor = await EnsureWriteActorAsync(cancellationToken); + var evt = new RoomParticipantsRemovedEvent + { + RoomId = roomId, + }; + await ActorCommandDispatcher.SendAsync(actor, evt, cancellationToken); + } + + // ── Read write actor state directly ── + + private async Task ReadWriteActorStateAsync(CancellationToken ct) + { + var actor = await _runtime.GetAsync(WriteActorId); + return (actor?.Agent as IAgent)?.State; + } + + // ── Actor resolution ── + + private async Task EnsureWriteActorAsync(CancellationToken ct) + { + var actor = await _runtime.GetAsync(WriteActorId); + return actor ?? await _runtime.CreateAsync(WriteActorId, ct); + } +} diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserMemoryStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserMemoryStore.cs new file mode 100644 index 00000000..4302d091 --- /dev/null +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserMemoryStore.cs @@ -0,0 +1,237 @@ +using System.Security.Cryptography; +using System.Text; +using Aevatar.AI.Abstractions.LLMProviders; +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.UserMemory; +using Aevatar.Studio.Infrastructure.ScopeResolution; +using Microsoft.Extensions.Logging; + +namespace Aevatar.Studio.Infrastructure.ActorBacked; + +/// +/// Actor-backed implementation of . +/// Reads the write actor's state directly. +/// Writes send commands to the Write GAgent. +/// +internal sealed class ActorBackedUserMemoryStore : IUserMemoryStore +{ + private const string WriteActorIdPrefix = "user-memory-"; + + private readonly IActorRuntime _runtime; + private readonly IAppScopeResolver _scopeResolver; + private readonly ILogger _logger; + + public ActorBackedUserMemoryStore( + IActorRuntime runtime, + IAppScopeResolver scopeResolver, + ILogger logger) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task GetAsync(CancellationToken ct = default) + { + var state = await ReadWriteActorStateAsync(ct); + if (state is null) + return UserMemoryDocument.Empty; + + var entries = state.Entries + .Where(e => !string.IsNullOrWhiteSpace(e.Id) && !string.IsNullOrWhiteSpace(e.Content)) + .Select(e => new UserMemoryEntry( + Id: e.Id, + Category: e.Category, + Content: e.Content, + Source: e.Source, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt)) + .ToList(); + + return new UserMemoryDocument(1, entries); + } + + public async Task SaveAsync(UserMemoryDocument document, CancellationToken ct = default) + { + // Actor-backed store does not support bulk save. + // The canonical path is AddEntryAsync / RemoveEntryAsync. + // SaveAsync is kept for interface compatibility; it reconciles + // the actor state by adding missing entries and removing stale ones. + var current = await GetAsync(ct); + + var currentIds = new HashSet(current.Entries.Select(e => e.Id), StringComparer.Ordinal); + var targetIds = new HashSet(document.Entries.Select(e => e.Id), StringComparer.Ordinal); + + // Remove entries not in the target document + foreach (var id in currentIds.Except(targetIds)) + { + await RemoveEntryAsync(id, ct); + } + + // Add entries not in the current state + foreach (var entry in document.Entries.Where(e => !currentIds.Contains(e.Id))) + { + var actor = await EnsureWriteActorAsync(ct); + var evt = new MemoryEntryAddedEvent + { + Entry = new UserMemoryEntryProto + { + Id = entry.Id, + Category = entry.Category, + Content = entry.Content, + Source = entry.Source, + CreatedAt = entry.CreatedAt, + UpdatedAt = entry.UpdatedAt, + }, + }; + await ActorCommandDispatcher.SendAsync(actor, evt, ct); + } + } + + public async Task AddEntryAsync( + string category, string content, string source, CancellationToken ct = default) + { + var actor = await EnsureWriteActorAsync(ct); + var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var entry = new UserMemoryEntryProto + { + Id = GenerateId(), + Category = NormalizeCategory(category), + Content = content.Trim(), + Source = NormalizeSource(source), + CreatedAt = now, + UpdatedAt = now, + }; + + var evt = new MemoryEntryAddedEvent { Entry = entry }; + await ActorCommandDispatcher.SendAsync(actor, evt, ct); + + return new UserMemoryEntry( + Id: entry.Id, + Category: entry.Category, + Content: entry.Content, + Source: entry.Source, + CreatedAt: entry.CreatedAt, + UpdatedAt: entry.UpdatedAt); + } + + public async Task RemoveEntryAsync(string id, CancellationToken ct = default) + { + var state = await ReadWriteActorStateAsync(ct); + if (state is null || !state.Entries.Any(e => string.Equals(e.Id, id, StringComparison.Ordinal))) + return false; + + var actor = await EnsureWriteActorAsync(ct); + var evt = new MemoryEntryRemovedEvent { EntryId = id }; + await ActorCommandDispatcher.SendAsync(actor, evt, ct); + return true; + } + + public async Task BuildPromptSectionAsync(int maxChars = 2000, CancellationToken ct = default) + { + UserMemoryDocument doc; + try + { + doc = await GetAsync(ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Failed to load user memory for prompt injection"); + return string.Empty; + } + + if (doc.Entries.Count == 0) + return string.Empty; + + var sb = new StringBuilder(); + sb.AppendLine(""); + + var categoryOrder = new[] + { + UserMemoryCategories.Preference, + UserMemoryCategories.Instruction, + UserMemoryCategories.Context, + }; + + var grouped = doc.Entries + .GroupBy(e => e.Category) + .OrderBy(g => Array.IndexOf(categoryOrder, g.Key) is var i && i >= 0 ? i : int.MaxValue); + + foreach (var group in grouped) + { + var header = group.Key switch + { + UserMemoryCategories.Preference => "## Preferences", + UserMemoryCategories.Instruction => "## Instructions", + UserMemoryCategories.Context => "## Context", + _ => $"## {Capitalize(group.Key)}", + }; + sb.AppendLine(header); + foreach (var entry in group.OrderByDescending(e => e.UpdatedAt)) + sb.AppendLine($"- {entry.Content}"); + sb.AppendLine(); + } + + sb.Append(""); + + var result = sb.ToString(); + if (result.Length <= maxChars) + return result; + + // Truncate to maxChars at a newline boundary. + var truncated = result[..maxChars]; + var lastNewline = truncated.LastIndexOf('\n'); + return lastNewline > 0 + ? truncated[..lastNewline] + "\n" + : truncated; + } + + // ── Read write actor state directly ── + + private async Task ReadWriteActorStateAsync(CancellationToken ct) + { + var actorId = ResolveWriteActorId(); + var actor = await _runtime.GetAsync(actorId); + return (actor?.Agent as IAgent)?.State; + } + + // ── Actor resolution ── + + private string ResolveScopeId() + => _scopeResolver.Resolve()?.ScopeId + ?? throw new InvalidOperationException( + "User memory store requires an authenticated user scope. No scope could be resolved."); + + private string ResolveWriteActorId() => WriteActorIdPrefix + ResolveScopeId(); + + private async Task EnsureWriteActorAsync(CancellationToken ct) + { + var actorId = ResolveWriteActorId(); + var actor = await _runtime.GetAsync(actorId); + return actor ?? await _runtime.CreateAsync(actorId, ct); + } + + private static string GenerateId() + { + var bytes = RandomNumberGenerator.GetBytes(6); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + private static string NormalizeCategory(string? value) => + value?.Trim().ToLowerInvariant() switch + { + UserMemoryCategories.Preference => UserMemoryCategories.Preference, + UserMemoryCategories.Instruction => UserMemoryCategories.Instruction, + UserMemoryCategories.Context => UserMemoryCategories.Context, + null or "" => UserMemoryCategories.Context, + var v => v, + }; + + private static string NormalizeSource(string? value) => + string.Equals(value?.Trim(), UserMemorySources.Explicit, StringComparison.OrdinalIgnoreCase) + ? UserMemorySources.Explicit + : UserMemorySources.Inferred; + + private static string Capitalize(string s) => + s.Length == 0 ? s : char.ToUpperInvariant(s[0]) + s[1..]; +} diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorCommandDispatcher.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorCommandDispatcher.cs new file mode 100644 index 00000000..33f223d9 --- /dev/null +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorCommandDispatcher.cs @@ -0,0 +1,28 @@ +using Aevatar.Foundation.Abstractions; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.Studio.Infrastructure.ActorBacked; + +/// +/// Sends a domain event (command) to a target actor by wrapping it in an +/// directed to the actor's own inbox. +/// +internal static class ActorCommandDispatcher +{ + public static Task SendAsync( + IActor actor, TEvent evt, CancellationToken ct = default) + where TEvent : IMessage + { + var envelope = new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTime(DateTime.UtcNow), + Payload = Any.Pack(evt), + Route = EnvelopeRouteSemantics.CreateTopologyPublication( + actor.Id, TopologyAudience.Self), + }; + + return actor.HandleEventAsync(envelope, ct); + } +} diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/AppScopeResolverExtensions.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/AppScopeResolverExtensions.cs new file mode 100644 index 00000000..c3a8d5cf --- /dev/null +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/AppScopeResolverExtensions.cs @@ -0,0 +1,15 @@ +using Aevatar.Studio.Infrastructure.ScopeResolution; + +namespace Aevatar.Studio.Infrastructure.ActorBacked; + +/// +/// Extension methods for used by ActorBacked stores. +/// +internal static class AppScopeResolverExtensions +{ + /// + /// Resolves the current scope ID, falling back to "default" if no scope is available. + /// + public static string ResolveScopeIdOrDefault(this IAppScopeResolver resolver) + => resolver.Resolve()?.ScopeId ?? "default"; +} diff --git a/src/Aevatar.Studio.Infrastructure/Aevatar.Studio.Infrastructure.csproj b/src/Aevatar.Studio.Infrastructure/Aevatar.Studio.Infrastructure.csproj index 01c3204e..62f6a017 100644 --- a/src/Aevatar.Studio.Infrastructure/Aevatar.Studio.Infrastructure.csproj +++ b/src/Aevatar.Studio.Infrastructure/Aevatar.Studio.Infrastructure.csproj @@ -11,6 +11,13 @@ + + + + + + + diff --git a/src/Aevatar.Studio.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Studio.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs index d9adbac0..a9999f13 100644 --- a/src/Aevatar.Studio.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Studio.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Domain.Studio.Compatibility; using Aevatar.Studio.Domain.Studio.Services; +using Aevatar.Studio.Infrastructure.ActorBacked; using Aevatar.Studio.Infrastructure.Middleware; using Aevatar.Studio.Infrastructure.Serialization; using Aevatar.Studio.Infrastructure.Storage; @@ -27,18 +28,18 @@ public static IServiceCollection AddStudioInfrastructure( services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); + // chrono-storage blob client retained for media file uploads (ExplorerEndpoints) services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + // ── Actor-backed stores (replacing ChronoStorage* implementations) ── + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); return services; } diff --git a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageChatHistoryStore.cs b/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageChatHistoryStore.cs deleted file mode 100644 index 16d845df..00000000 --- a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageChatHistoryStore.cs +++ /dev/null @@ -1,527 +0,0 @@ -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using Aevatar.Studio.Application.Studio.Abstractions; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Aevatar.Studio.Infrastructure.Storage; - -internal sealed class ChronoStorageChatHistoryStore : IChatHistoryStore -{ - private const string ChatHistoriesDir = "chat-histories"; - private const string MetaDir = $"{ChatHistoriesDir}/_meta"; - private const string LegacyIndexKey = $"{ChatHistoriesDir}/index.json"; - private const string NyxIdChatActorPrefix = "nyxid-chat-"; - private const string LegacyConversationPrefix = "conv-"; - - private static readonly ChatHistoryIndex EmptyIndex = new([]); - - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; - - private readonly ChronoStorageCatalogBlobClient _blobClient; - private readonly ConnectorCatalogStorageOptions _options; - private readonly ILogger _logger; - - public ChronoStorageChatHistoryStore( - ChronoStorageCatalogBlobClient blobClient, - IOptions options, - ILogger logger) - { - _blobClient = blobClient ?? throw new ArgumentNullException(nameof(blobClient)); - _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task GetIndexAsync(string scopeId, CancellationToken ct = default) - { - try - { - var context = TryResolveChatHistoryScope(scopeId); - if (context is null) - return EmptyIndex; - - // Single list call for the entire chat-histories/ prefix. - var objects = await _blobClient.ListObjectsAsync(context, ChatHistoriesDir, ct); - if (objects.Objects.Count == 0) - return EmptyIndex; - - // Partition into .jsonl conversation files and _meta/ sidecar files. - var jsonlByConvId = new Dictionary(StringComparer.Ordinal); - var metaByConvId = new Dictionary(StringComparer.Ordinal); - - foreach (var obj in objects.Objects) - { - if (IsMetaFile(obj.Key)) - { - var convId = TryExtractMetaConversationId(obj.Key); - if (convId is not null) - metaByConvId[convId] = obj; - } - else if (IsConversationFile(obj.Key)) - { - var convId = TryExtractConversationId(obj.Key); - if (convId is not null) - jsonlByConvId[convId] = obj; - } - } - - if (jsonlByConvId.Count == 0) - return EmptyIndex; - - // For each conversation, prefer sidecar if fresh; fallback to full download. - var tasks = new List>(jsonlByConvId.Count); - foreach (var (convId, jsonlObj) in jsonlByConvId) - { - if (metaByConvId.TryGetValue(convId, out var metaObj) && !IsSidecarStale(metaObj, jsonlObj)) - tasks.Add(TryReadSidecarMetaAsync(scopeId, convId, ct)); - else - tasks.Add(TryBuildAndBackfillMetaAsync(scopeId, convId, jsonlObj, ct)); - } - - var conversations = await Task.WhenAll(tasks); - - return new ChatHistoryIndex(conversations - .Where(static c => c is not null) - .Select(static c => c!) - .OrderByDescending(static c => c.UpdatedAt) - .ThenBy(static c => c.Id, StringComparer.Ordinal) - .ToList()); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning(ex, "Failed to read chat history index for scope {ScopeId}", scopeId); - return EmptyIndex; - } - } - - public async Task> GetMessagesAsync( - string scopeId, string conversationId, CancellationToken ct = default) - { - try - { - var context = TryResolveMessages(scopeId, conversationId); - if (context is null) - return []; - - var payload = await _blobClient.TryDownloadAsync(context, ct); - if (payload is null) - return []; - - return DeserializeJsonl(payload); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning(ex, "Failed to read messages for conversation {ConversationId}", conversationId); - return []; - } - } - - public async Task SaveMessagesAsync( - string scopeId, string conversationId, ConversationMeta meta, IReadOnlyList messages, CancellationToken ct = default) - { - var context = TryResolveMessages(scopeId, conversationId) - ?? throw new InvalidOperationException("Chat history storage is not available."); - - var jsonl = SerializeJsonl(messages); - await _blobClient.UploadAsync(context, jsonl, "application/x-ndjson", ct); - - // Best-effort sidecar write after .jsonl succeeds. - try - { - await WriteSidecarAsync(scopeId, conversationId, meta, messages, ct); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning(ex, "Failed to write sidecar for conversation {ConversationId}", conversationId); - } - - await DeleteLegacyIndexIfExistsAsync(scopeId, ct); - } - - public async Task DeleteConversationAsync(string scopeId, string conversationId, CancellationToken ct = default) - { - // Delete sidecar first, then .jsonl — avoids orphan sidecar pointing to deleted conversation. - var metaContext = TryResolveSidecar(scopeId, conversationId); - if (metaContext is not null) - { - try { await _blobClient.DeleteIfExistsAsync(metaContext, ct); } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning(ex, "Failed to delete sidecar for conversation {ConversationId}", conversationId); - } - } - - var messagesContext = TryResolveMessages(scopeId, conversationId); - if (messagesContext is not null) - { - await _blobClient.DeleteIfExistsAsync(messagesContext, ct); - } - await DeleteLegacyIndexIfExistsAsync(scopeId, ct); - } - - private ChronoStorageCatalogBlobClient.RemoteScopeContext? TryResolveChatHistoryScope(string scopeId) - { - try - { - return _blobClient.TryResolveContext( - _options.UserConfigPrefix, - $"{ChatHistoriesDir}/.probe"); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Chrono-storage context could not be resolved for chat history scope"); - return null; - } - } - - private ChronoStorageCatalogBlobClient.RemoteScopeContext? TryResolveMessages(string scopeId, string conversationId) - { - try - { - return _blobClient.TryResolveContext( - _options.UserConfigPrefix, - $"{ChatHistoriesDir}/{conversationId}.jsonl"); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Chrono-storage context could not be resolved for conversation {ConversationId}", conversationId); - return null; - } - } - - // ── Sidecar read/write helpers ────────────────────────────────────── - - private sealed record SidecarPayload( - string Title, - string ServiceId, - string ServiceKind, - long CreatedAtMs, - long UpdatedAtMs, - int MessageCount, - string? LlmRoute, - string? LlmModel); - - private async Task TryReadSidecarMetaAsync( - string scopeId, string conversationId, CancellationToken ct) - { - var context = TryResolveSidecar(scopeId, conversationId); - if (context is null) - return null; - - var payload = await _blobClient.TryDownloadAsync(context, ct); - if (payload is null) - return null; - - try - { - var sidecar = JsonSerializer.Deserialize(payload, JsonOptions); - if (sidecar is null) - return null; - - return new ConversationMeta( - Id: conversationId, - Title: sidecar.Title, - ServiceId: sidecar.ServiceId, - ServiceKind: sidecar.ServiceKind, - CreatedAt: FromUnixTimestampMilliseconds(sidecar.CreatedAtMs), - UpdatedAt: FromUnixTimestampMilliseconds(sidecar.UpdatedAtMs), - MessageCount: sidecar.MessageCount, - LlmRoute: sidecar.LlmRoute, - LlmModel: sidecar.LlmModel); - } - catch (JsonException ex) - { - _logger.LogWarning(ex, "Malformed sidecar for conversation {ConversationId}", conversationId); - return null; - } - } - - /// - /// Fallback: download full .jsonl, build meta, and best-effort write sidecar for next time. - /// - private async Task TryBuildAndBackfillMetaAsync( - string scopeId, - string conversationId, - ChronoStorageCatalogBlobClient.StorageObject jsonlObj, - CancellationToken ct) - { - var meta = await TryBuildConversationMetaFromJsonl(scopeId, conversationId, jsonlObj, ct); - if (meta is null) - return null; - - // Best-effort backfill — don't block the response on this. - _ = Task.Run(async () => - { - try - { - var messagesContext = TryResolveMessages(scopeId, conversationId); - if (messagesContext is null) return; - var bytes = await _blobClient.TryDownloadAsync(messagesContext, ct); - if (bytes is null) return; - var messages = DeserializeJsonl(bytes); - await WriteSidecarAsync(scopeId, conversationId, meta, messages, ct); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning(ex, "Best-effort sidecar backfill failed for {ConversationId}", conversationId); - } - }, ct); - - return meta; - } - - private async Task TryBuildConversationMetaFromJsonl( - string scopeId, - string conversationId, - ChronoStorageCatalogBlobClient.StorageObject storageObject, - CancellationToken ct) - { - var context = TryResolveMessages(scopeId, conversationId); - if (context is null) - return null; - - var payload = await _blobClient.TryDownloadAsync(context, ct); - if (payload is null) - return null; - - var messages = DeserializeJsonl(payload); - return BuildMetaFromMessages(scopeId, conversationId, messages, storageObject); - } - - private static ConversationMeta BuildMetaFromMessages( - string scopeId, - string conversationId, - IReadOnlyList messages, - ChronoStorageCatalogBlobClient.StorageObject? storageObject) - { - if (messages.Count == 0) - { - var fallbackTimestamp = (storageObject is not null ? TryParseStorageTimestamp(storageObject.LastModified) : null) - ?? DateTimeOffset.UtcNow; - var (fsi, fsk) = InferConversationService(scopeId, conversationId); - return new ConversationMeta(conversationId, conversationId, fsi, fsk, fallbackTimestamp, fallbackTimestamp, 0); - } - - var title = BuildConversationTitle(messages, conversationId); - var defaultTs = (storageObject is not null ? TryParseStorageTimestamp(storageObject.LastModified) : null) - ?? DateTimeOffset.UtcNow; - var createdAt = messages.Select(static m => FromUnixTimestampMilliseconds(m.Timestamp)).DefaultIfEmpty(defaultTs).Min(); - var updatedAt = messages.Select(static m => FromUnixTimestampMilliseconds(m.Timestamp)).DefaultIfEmpty(createdAt).Max(); - var (serviceId, serviceKind) = InferConversationService(scopeId, conversationId); - return new ConversationMeta(conversationId, title, serviceId, serviceKind, createdAt, updatedAt, messages.Count); - } - - private async Task WriteSidecarAsync( - string scopeId, string conversationId, - ConversationMeta meta, - IReadOnlyList messages, CancellationToken ct) - { - var context = TryResolveSidecar(scopeId, conversationId); - if (context is null) return; - - var inferredMeta = BuildMetaFromMessages(scopeId, conversationId, messages, storageObject: null); - var normalizedMeta = NormalizeMeta(conversationId, meta, inferredMeta); - var sidecar = new SidecarPayload( - normalizedMeta.Title, - normalizedMeta.ServiceId, - normalizedMeta.ServiceKind, - normalizedMeta.CreatedAt.ToUnixTimeMilliseconds(), - normalizedMeta.UpdatedAt.ToUnixTimeMilliseconds(), - normalizedMeta.MessageCount, - normalizedMeta.LlmRoute, - normalizedMeta.LlmModel); - var bytes = JsonSerializer.SerializeToUtf8Bytes(sidecar, JsonOptions); - await _blobClient.UploadAsync(context, bytes, "application/json", ct); - } - - private static ConversationMeta NormalizeMeta( - string conversationId, - ConversationMeta meta, - ConversationMeta fallback) - { - var title = string.IsNullOrWhiteSpace(meta.Title) - ? fallback.Title - : meta.Title.Trim(); - var serviceId = string.IsNullOrWhiteSpace(meta.ServiceId) - ? fallback.ServiceId - : meta.ServiceId.Trim(); - var serviceKind = string.IsNullOrWhiteSpace(meta.ServiceKind) - ? fallback.ServiceKind - : meta.ServiceKind.Trim(); - var createdAt = meta.CreatedAt == default ? fallback.CreatedAt : meta.CreatedAt; - var updatedAt = meta.UpdatedAt == default ? fallback.UpdatedAt : meta.UpdatedAt; - var messageCount = meta.MessageCount > 0 || fallback.MessageCount == 0 - ? meta.MessageCount - : fallback.MessageCount; - var llmRoute = meta.LlmRoute is null ? null : meta.LlmRoute.Trim(); - var llmModel = string.IsNullOrWhiteSpace(meta.LlmModel) ? null : meta.LlmModel.Trim(); - - return new ConversationMeta( - Id: conversationId, - Title: title, - ServiceId: serviceId, - ServiceKind: serviceKind, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - MessageCount: messageCount, - LlmRoute: llmRoute, - LlmModel: llmModel); - } - - private static bool IsSidecarStale( - ChronoStorageCatalogBlobClient.StorageObject metaObj, - ChronoStorageCatalogBlobClient.StorageObject jsonlObj) - { - var metaTs = TryParseStorageTimestamp(metaObj.LastModified); - var jsonlTs = TryParseStorageTimestamp(jsonlObj.LastModified); - if (metaTs is null || jsonlTs is null) - return true; // Can't compare — treat as stale. - return metaTs.Value < jsonlTs.Value; - } - - private ChronoStorageCatalogBlobClient.RemoteScopeContext? TryResolveSidecar(string scopeId, string conversationId) - { - try - { - return _blobClient.TryResolveContext( - _options.UserConfigPrefix, - $"{MetaDir}/{conversationId}.json"); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Chrono-storage context could not be resolved for sidecar {ConversationId}", conversationId); - return null; - } - } - - private static bool IsMetaFile(string key) => - key.StartsWith($"{MetaDir}/", StringComparison.Ordinal) && - key.EndsWith(".json", StringComparison.OrdinalIgnoreCase); - - private static IReadOnlyList DeserializeJsonl(byte[] payload) - { - var text = Encoding.UTF8.GetString(payload); - var messages = new List(); - - foreach (var line in text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - { - var msg = JsonSerializer.Deserialize(line, JsonOptions); - if (msg is not null) - messages.Add(msg); - } - - return messages; - } - - private static byte[] SerializeJsonl(IReadOnlyList messages) - { - var sb = new StringBuilder(); - foreach (var msg in messages) - { - sb.AppendLine(JsonSerializer.Serialize(msg, JsonOptions)); - } - - return Encoding.UTF8.GetBytes(sb.ToString()); - } - - private async Task DeleteLegacyIndexIfExistsAsync(string scopeId, CancellationToken ct) - { - var context = TryResolveLegacyIndex(scopeId); - if (context is null) - return; - - try - { - await _blobClient.DeleteIfExistsAsync(context, ct); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning(ex, "Failed to delete legacy chat history index for scope {ScopeId}", scopeId); - } - } - - private ChronoStorageCatalogBlobClient.RemoteScopeContext? TryResolveLegacyIndex(string scopeId) - { - try - { - return _blobClient.TryResolveContext(_options.UserConfigPrefix, LegacyIndexKey); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Chrono-storage context could not be resolved for legacy chat history index"); - return null; - } - } - - private static bool IsConversationFile(string key) => - key.StartsWith($"{ChatHistoriesDir}/", StringComparison.Ordinal) && - key.EndsWith(".jsonl", StringComparison.OrdinalIgnoreCase); - - private static string? TryExtractConversationId(string key) - { - if (!IsConversationFile(key)) - return null; - - var fileName = key[(ChatHistoriesDir.Length + 1)..]; - return fileName.EndsWith(".jsonl", StringComparison.OrdinalIgnoreCase) - ? fileName[..^".jsonl".Length] - : null; - } - - private static string? TryExtractMetaConversationId(string key) - { - if (!IsMetaFile(key)) - return null; - - var fileName = key[(MetaDir.Length + 1)..]; - return fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase) - ? fileName[..^".json".Length] - : null; - } - - private static string BuildConversationTitle(IReadOnlyList messages, string fallback) - { - var source = messages - .FirstOrDefault(static message => string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase) && - !string.IsNullOrWhiteSpace(message.Content)) - ?.Content - ?? messages.FirstOrDefault(static message => !string.IsNullOrWhiteSpace(message.Content))?.Content - ?? fallback; - var trimmed = source.Trim(); - return trimmed.Length <= 60 ? trimmed : trimmed[..60]; - } - - private static (string ServiceId, string ServiceKind) InferConversationService(string scopeId, string conversationId) - { - if (conversationId.StartsWith("NyxIdChat:", StringComparison.OrdinalIgnoreCase) || - conversationId.StartsWith(NyxIdChatActorPrefix, StringComparison.OrdinalIgnoreCase) || - conversationId.StartsWith(LegacyConversationPrefix, StringComparison.OrdinalIgnoreCase)) - { - return ("nyxid-chat", "nyxid-chat"); - } - - var separatorIndex = conversationId.IndexOf(':'); - if (separatorIndex > 0) - { - var serviceId = conversationId[..separatorIndex]; - var isNyxIdChat = string.Equals(serviceId, "nyxid-chat", StringComparison.OrdinalIgnoreCase) || - string.Equals(conversationId, $"NyxIdChat:{scopeId}", StringComparison.OrdinalIgnoreCase); - return (serviceId, isNyxIdChat ? "nyxid-chat" : "service"); - } - - return ("nyxid-chat", "nyxid-chat"); - } - - private static DateTimeOffset FromUnixTimestampMilliseconds(long timestamp) => - timestamp > 0 - ? DateTimeOffset.FromUnixTimeMilliseconds(timestamp) - : DateTimeOffset.UnixEpoch; - - private static DateTimeOffset? TryParseStorageTimestamp(string? value) => - DateTimeOffset.TryParse(value, out var timestamp) ? timestamp : null; -} diff --git a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageConnectorCatalogStore.cs b/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageConnectorCatalogStore.cs deleted file mode 100644 index a0413c88..00000000 --- a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageConnectorCatalogStore.cs +++ /dev/null @@ -1,171 +0,0 @@ -using Aevatar.Studio.Application.Studio.Abstractions; -using Microsoft.Extensions.Options; - -namespace Aevatar.Studio.Infrastructure.Storage; - -internal sealed class ChronoStorageConnectorCatalogStore : IConnectorCatalogStore -{ - private const string CatalogFileName = "connectors.json"; - private const string DraftFileName = "connectors.draft.json"; - - private readonly IStudioWorkspaceStore _localWorkspaceStore; - private readonly ChronoStorageCatalogBlobClient _blobClient; - private readonly ConnectorCatalogStorageOptions _options; - - public ChronoStorageConnectorCatalogStore( - IStudioWorkspaceStore localWorkspaceStore, - ChronoStorageCatalogBlobClient blobClient, - IOptions options, - IOptions studioStorageOptions) - { - _localWorkspaceStore = localWorkspaceStore ?? throw new ArgumentNullException(nameof(localWorkspaceStore)); - _blobClient = blobClient ?? throw new ArgumentNullException(nameof(blobClient)); - _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); - _ = studioStorageOptions?.Value.ResolveRootDirectory() - ?? throw new ArgumentNullException(nameof(studioStorageOptions)); - } - - public async Task GetConnectorCatalogAsync(CancellationToken cancellationToken = default) - { - var remoteContext = _blobClient.TryResolveContext(_options.Prefix, CatalogFileName); - if (remoteContext is null) - { - return await _localWorkspaceStore.GetConnectorCatalogAsync(cancellationToken); - } - - var payload = await _blobClient.TryDownloadAsync(remoteContext, cancellationToken); - if (payload is null) - { - return CreateRemoteCatalog(remoteContext, fileExists: false, []); - } - - await using var stream = new MemoryStream(payload, writable: false); - var connectors = await ConnectorCatalogJsonSerializer.ReadCatalogAsync(stream, cancellationToken); - return CreateRemoteCatalog(remoteContext, fileExists: true, connectors); - } - - public async Task SaveConnectorCatalogAsync( - StoredConnectorCatalog catalog, - CancellationToken cancellationToken = default) - { - var remoteContext = _blobClient.TryResolveContext(_options.Prefix, CatalogFileName); - if (remoteContext is null) - { - return await _localWorkspaceStore.SaveConnectorCatalogAsync(catalog, cancellationToken); - } - - await UploadCatalogAsync(remoteContext, catalog.Connectors, cancellationToken); - return CreateRemoteCatalog(remoteContext, fileExists: true, catalog.Connectors); - } - - public async Task ImportLocalCatalogAsync(CancellationToken cancellationToken = default) - { - var remoteContext = _blobClient.TryResolveContext(_options.Prefix, CatalogFileName) - ?? throw new InvalidOperationException("Chrono-storage connector catalog import requires remote storage to be enabled."); - var localCatalog = await _localWorkspaceStore.GetConnectorCatalogAsync(cancellationToken); - if (!localCatalog.FileExists) - { - throw new InvalidOperationException($"Local connector catalog not found at '{localCatalog.FilePath}'."); - } - - await UploadCatalogAsync(remoteContext, localCatalog.Connectors, cancellationToken); - var importedCatalog = CreateRemoteCatalog(remoteContext, fileExists: true, localCatalog.Connectors); - return new ImportedConnectorCatalog(localCatalog.FilePath, true, importedCatalog); - } - - public async Task GetConnectorDraftAsync(CancellationToken cancellationToken = default) - { - var remoteContext = _blobClient.TryResolveContext(_options.Prefix, DraftFileName); - if (remoteContext is null) - { - return await _localWorkspaceStore.GetConnectorDraftAsync(cancellationToken); - } - - var payload = await _blobClient.TryDownloadAsync(remoteContext, cancellationToken); - if (payload is null) - { - return new StoredConnectorDraft( - HomeDirectory: _blobClient.CreateRemoteHomeDirectory(remoteContext), - FilePath: _blobClient.CreateRemoteFilePath(remoteContext), - FileExists: false, - UpdatedAtUtc: null, - Draft: null); - } - - await using var stream = new MemoryStream(payload, writable: false); - var parsed = await ConnectorCatalogJsonSerializer.ReadDraftAsync( - stream, - fallbackUpdatedAtUtc: DateTimeOffset.UtcNow, - cancellationToken); - - return new StoredConnectorDraft( - HomeDirectory: _blobClient.CreateRemoteHomeDirectory(remoteContext), - FilePath: _blobClient.CreateRemoteFilePath(remoteContext), - FileExists: true, - UpdatedAtUtc: parsed.UpdatedAtUtc, - Draft: parsed.Draft); - } - - public async Task SaveConnectorDraftAsync( - StoredConnectorDraft draft, - CancellationToken cancellationToken = default) - { - var remoteContext = _blobClient.TryResolveContext(_options.Prefix, DraftFileName); - if (remoteContext is null) - { - return await _localWorkspaceStore.SaveConnectorDraftAsync(draft, cancellationToken); - } - - var updatedAtUtc = draft.UpdatedAtUtc ?? DateTimeOffset.UtcNow; - await UploadDraftAsync(remoteContext, draft.Draft, updatedAtUtc, cancellationToken); - - return new StoredConnectorDraft( - HomeDirectory: _blobClient.CreateRemoteHomeDirectory(remoteContext), - FilePath: _blobClient.CreateRemoteFilePath(remoteContext), - FileExists: true, - UpdatedAtUtc: updatedAtUtc, - Draft: draft.Draft); - } - - public Task DeleteConnectorDraftAsync(CancellationToken cancellationToken = default) - { - var remoteContext = _blobClient.TryResolveContext(_options.Prefix, DraftFileName); - if (remoteContext is null) - { - return _localWorkspaceStore.DeleteConnectorDraftAsync(cancellationToken); - } - - return _blobClient.DeleteIfExistsAsync(remoteContext, cancellationToken); - } - - private async Task UploadCatalogAsync( - ChronoStorageCatalogBlobClient.RemoteScopeContext remoteContext, - IReadOnlyList connectors, - CancellationToken cancellationToken) - { - await using var stream = new MemoryStream(); - await ConnectorCatalogJsonSerializer.WriteCatalogAsync(stream, connectors, cancellationToken); - await _blobClient.UploadAsync(remoteContext, stream.ToArray(), "application/json", cancellationToken); - } - - private async Task UploadDraftAsync( - ChronoStorageCatalogBlobClient.RemoteScopeContext remoteContext, - StoredConnectorDefinition? draft, - DateTimeOffset updatedAtUtc, - CancellationToken cancellationToken) - { - await using var stream = new MemoryStream(); - await ConnectorCatalogJsonSerializer.WriteDraftAsync(stream, draft, updatedAtUtc, cancellationToken); - await _blobClient.UploadAsync(remoteContext, stream.ToArray(), "application/json", cancellationToken); - } - - private StoredConnectorCatalog CreateRemoteCatalog( - ChronoStorageCatalogBlobClient.RemoteScopeContext remoteContext, - bool fileExists, - IReadOnlyList connectors) => - new( - HomeDirectory: _blobClient.CreateRemoteHomeDirectory(remoteContext), - FilePath: _blobClient.CreateRemoteFilePath(remoteContext), - FileExists: fileExists, - Connectors: connectors); -} diff --git a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageGAgentActorStore.cs b/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageGAgentActorStore.cs deleted file mode 100644 index c5685008..00000000 --- a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageGAgentActorStore.cs +++ /dev/null @@ -1,155 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using Aevatar.Studio.Application.Studio.Abstractions; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Aevatar.Studio.Infrastructure.Storage; - -internal sealed class ChronoStorageGAgentActorStore : IGAgentActorStore -{ - private const string ActorsFileName = "actors.json"; - - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; - - private readonly ChronoStorageCatalogBlobClient _blobClient; - private readonly ConnectorCatalogStorageOptions _options; - private readonly ILogger _logger; - - public ChronoStorageGAgentActorStore( - ChronoStorageCatalogBlobClient blobClient, - IOptions options, - ILogger logger) - { - _blobClient = blobClient ?? throw new ArgumentNullException(nameof(blobClient)); - _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task> GetAsync(CancellationToken cancellationToken = default) - { - var remoteContext = TryResolve(); - if (remoteContext is null) - return []; - - var payload = await _blobClient.TryDownloadAsync(remoteContext, cancellationToken); - if (payload is null) - return []; - - return DeserializeGroups(payload); - } - - public async Task AddActorAsync(string gagentType, string actorId, CancellationToken cancellationToken = default) - { - var remoteContext = TryResolve() - ?? throw new InvalidOperationException("GAgent actor storage is not available."); - - var existing = await DownloadGroupsAsync(remoteContext, cancellationToken); - var group = existing.Find(g => string.Equals(g.GAgentType, gagentType, StringComparison.Ordinal)); - - if (group is not null) - { - if (group.ActorIds.Contains(actorId)) - return; - - var updatedIds = group.ActorIds.Append(actorId).ToList().AsReadOnly(); - var idx = existing.IndexOf(group); - existing[idx] = new GAgentActorGroup(gagentType, updatedIds); - } - else - { - existing.Add(new GAgentActorGroup(gagentType, new[] { actorId })); - } - - await UploadGroupsAsync(remoteContext, existing, cancellationToken); - } - - public async Task RemoveActorAsync(string gagentType, string actorId, CancellationToken cancellationToken = default) - { - var remoteContext = TryResolve() - ?? throw new InvalidOperationException("GAgent actor storage is not available."); - - var existing = await DownloadGroupsAsync(remoteContext, cancellationToken); - var group = existing.Find(g => string.Equals(g.GAgentType, gagentType, StringComparison.Ordinal)); - - if (group is null) - return; - - var updatedIds = group.ActorIds.Where(id => !string.Equals(id, actorId, StringComparison.Ordinal)).ToList().AsReadOnly(); - var idx = existing.IndexOf(group); - - if (updatedIds.Count == 0) - existing.RemoveAt(idx); - else - existing[idx] = new GAgentActorGroup(gagentType, updatedIds); - - await UploadGroupsAsync(remoteContext, existing, cancellationToken); - } - - private ChronoStorageCatalogBlobClient.RemoteScopeContext? TryResolve() - { - try - { - return _blobClient.TryResolveContext(_options.UserConfigPrefix, ActorsFileName); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Chrono-storage context could not be resolved for GAgent actor store"); - return null; - } - } - - private async Task> DownloadGroupsAsync( - ChronoStorageCatalogBlobClient.RemoteScopeContext remoteContext, - CancellationToken cancellationToken) - { - var payload = await _blobClient.TryDownloadAsync(remoteContext, cancellationToken); - return payload is null ? [] : DeserializeGroups(payload); - } - - private async Task UploadGroupsAsync( - ChronoStorageCatalogBlobClient.RemoteScopeContext remoteContext, - List groups, - CancellationToken cancellationToken) - { - var json = JsonSerializer.SerializeToUtf8Bytes(groups, JsonOptions); - await _blobClient.UploadAsync(remoteContext, json, "application/json", cancellationToken); - } - - private static List DeserializeGroups(byte[] payload) - { - var doc = JsonDocument.Parse(payload); - var result = new List(); - - if (doc.RootElement.ValueKind != JsonValueKind.Array) - return result; - - foreach (var element in doc.RootElement.EnumerateArray()) - { - var gagentType = element.TryGetProperty("gagentType", out var typeProp) - ? typeProp.GetString() ?? string.Empty - : string.Empty; - - var actorIds = new List(); - if (element.TryGetProperty("actorIds", out var idsProp) && idsProp.ValueKind == JsonValueKind.Array) - { - foreach (var id in idsProp.EnumerateArray()) - { - var val = id.GetString(); - if (!string.IsNullOrWhiteSpace(val)) - actorIds.Add(val); - } - } - - if (!string.IsNullOrWhiteSpace(gagentType)) - result.Add(new GAgentActorGroup(gagentType, actorIds.AsReadOnly())); - } - - return result; - } -} diff --git a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageNyxIdUserLlmPreferencesStore.cs b/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageNyxIdUserLlmPreferencesStore.cs deleted file mode 100644 index 61b63fb3..00000000 --- a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageNyxIdUserLlmPreferencesStore.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Aevatar.AI.Abstractions.LLMProviders; -using Aevatar.Studio.Application.Studio.Abstractions; - -namespace Aevatar.Studio.Infrastructure.Storage; - -internal sealed class ChronoStorageNyxIdUserLlmPreferencesStore : INyxIdUserLlmPreferencesStore -{ - private readonly IUserConfigStore _userConfigStore; - - public ChronoStorageNyxIdUserLlmPreferencesStore(IUserConfigStore userConfigStore) - { - _userConfigStore = userConfigStore ?? throw new ArgumentNullException(nameof(userConfigStore)); - } - - public async Task GetAsync(CancellationToken cancellationToken = default) - { - var config = await _userConfigStore.GetAsync(cancellationToken); - return new NyxIdUserLlmPreferences( - config.DefaultModel, - UserConfigLlmRoute.Normalize(config.PreferredLlmRoute), - config.MaxToolRounds); - } -} diff --git a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageRoleCatalogStore.cs b/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageRoleCatalogStore.cs deleted file mode 100644 index 1450c60f..00000000 --- a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageRoleCatalogStore.cs +++ /dev/null @@ -1,171 +0,0 @@ -using Aevatar.Studio.Application.Studio.Abstractions; -using Microsoft.Extensions.Options; - -namespace Aevatar.Studio.Infrastructure.Storage; - -internal sealed class ChronoStorageRoleCatalogStore : IRoleCatalogStore -{ - private const string CatalogFileName = "roles.json"; - private const string DraftFileName = "roles.draft.json"; - - private readonly IStudioWorkspaceStore _localWorkspaceStore; - private readonly ChronoStorageCatalogBlobClient _blobClient; - private readonly ConnectorCatalogStorageOptions _options; - - public ChronoStorageRoleCatalogStore( - IStudioWorkspaceStore localWorkspaceStore, - ChronoStorageCatalogBlobClient blobClient, - IOptions options, - IOptions studioStorageOptions) - { - _localWorkspaceStore = localWorkspaceStore ?? throw new ArgumentNullException(nameof(localWorkspaceStore)); - _blobClient = blobClient ?? throw new ArgumentNullException(nameof(blobClient)); - _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); - _ = studioStorageOptions?.Value.ResolveRootDirectory() - ?? throw new ArgumentNullException(nameof(studioStorageOptions)); - } - - public async Task GetRoleCatalogAsync(CancellationToken cancellationToken = default) - { - var remoteContext = _blobClient.TryResolveContext(_options.RolesPrefix, CatalogFileName); - if (remoteContext is null) - { - return await _localWorkspaceStore.GetRoleCatalogAsync(cancellationToken); - } - - var payload = await _blobClient.TryDownloadAsync(remoteContext, cancellationToken); - if (payload is null) - { - return CreateRemoteCatalog(remoteContext, fileExists: false, []); - } - - await using var stream = new MemoryStream(payload, writable: false); - var roles = await RoleCatalogJsonSerializer.ReadCatalogAsync(stream, cancellationToken); - return CreateRemoteCatalog(remoteContext, fileExists: true, roles); - } - - public async Task SaveRoleCatalogAsync( - StoredRoleCatalog catalog, - CancellationToken cancellationToken = default) - { - var remoteContext = _blobClient.TryResolveContext(_options.RolesPrefix, CatalogFileName); - if (remoteContext is null) - { - return await _localWorkspaceStore.SaveRoleCatalogAsync(catalog, cancellationToken); - } - - await UploadCatalogAsync(remoteContext, catalog.Roles, cancellationToken); - return CreateRemoteCatalog(remoteContext, fileExists: true, catalog.Roles); - } - - public async Task ImportLocalCatalogAsync(CancellationToken cancellationToken = default) - { - var remoteContext = _blobClient.TryResolveContext(_options.RolesPrefix, CatalogFileName) - ?? throw new InvalidOperationException("Chrono-storage role catalog import requires remote storage to be enabled."); - var localCatalog = await _localWorkspaceStore.GetRoleCatalogAsync(cancellationToken); - if (!localCatalog.FileExists) - { - throw new InvalidOperationException($"Local role catalog not found at '{localCatalog.FilePath}'."); - } - - await UploadCatalogAsync(remoteContext, localCatalog.Roles, cancellationToken); - var importedCatalog = CreateRemoteCatalog(remoteContext, fileExists: true, localCatalog.Roles); - return new ImportedRoleCatalog(localCatalog.FilePath, true, importedCatalog); - } - - public async Task GetRoleDraftAsync(CancellationToken cancellationToken = default) - { - var remoteContext = _blobClient.TryResolveContext(_options.RolesPrefix, DraftFileName); - if (remoteContext is null) - { - return await _localWorkspaceStore.GetRoleDraftAsync(cancellationToken); - } - - var payload = await _blobClient.TryDownloadAsync(remoteContext, cancellationToken); - if (payload is null) - { - return new StoredRoleDraft( - HomeDirectory: _blobClient.CreateRemoteHomeDirectory(remoteContext), - FilePath: _blobClient.CreateRemoteFilePath(remoteContext), - FileExists: false, - UpdatedAtUtc: null, - Draft: null); - } - - await using var stream = new MemoryStream(payload, writable: false); - var parsed = await RoleCatalogJsonSerializer.ReadDraftAsync( - stream, - fallbackUpdatedAtUtc: DateTimeOffset.UtcNow, - cancellationToken); - - return new StoredRoleDraft( - HomeDirectory: _blobClient.CreateRemoteHomeDirectory(remoteContext), - FilePath: _blobClient.CreateRemoteFilePath(remoteContext), - FileExists: true, - UpdatedAtUtc: parsed.UpdatedAtUtc, - Draft: parsed.Draft); - } - - public async Task SaveRoleDraftAsync( - StoredRoleDraft draft, - CancellationToken cancellationToken = default) - { - var remoteContext = _blobClient.TryResolveContext(_options.RolesPrefix, DraftFileName); - if (remoteContext is null) - { - return await _localWorkspaceStore.SaveRoleDraftAsync(draft, cancellationToken); - } - - var updatedAtUtc = draft.UpdatedAtUtc ?? DateTimeOffset.UtcNow; - await UploadDraftAsync(remoteContext, draft.Draft, updatedAtUtc, cancellationToken); - - return new StoredRoleDraft( - HomeDirectory: _blobClient.CreateRemoteHomeDirectory(remoteContext), - FilePath: _blobClient.CreateRemoteFilePath(remoteContext), - FileExists: true, - UpdatedAtUtc: updatedAtUtc, - Draft: draft.Draft); - } - - public Task DeleteRoleDraftAsync(CancellationToken cancellationToken = default) - { - var remoteContext = _blobClient.TryResolveContext(_options.RolesPrefix, DraftFileName); - if (remoteContext is null) - { - return _localWorkspaceStore.DeleteRoleDraftAsync(cancellationToken); - } - - return _blobClient.DeleteIfExistsAsync(remoteContext, cancellationToken); - } - - private async Task UploadCatalogAsync( - ChronoStorageCatalogBlobClient.RemoteScopeContext remoteContext, - IReadOnlyList roles, - CancellationToken cancellationToken) - { - await using var stream = new MemoryStream(); - await RoleCatalogJsonSerializer.WriteCatalogAsync(stream, roles, cancellationToken); - await _blobClient.UploadAsync(remoteContext, stream.ToArray(), "application/json", cancellationToken); - } - - private async Task UploadDraftAsync( - ChronoStorageCatalogBlobClient.RemoteScopeContext remoteContext, - StoredRoleDefinition? draft, - DateTimeOffset updatedAtUtc, - CancellationToken cancellationToken) - { - await using var stream = new MemoryStream(); - await RoleCatalogJsonSerializer.WriteDraftAsync(stream, draft, updatedAtUtc, cancellationToken); - await _blobClient.UploadAsync(remoteContext, stream.ToArray(), "application/json", cancellationToken); - } - - private StoredRoleCatalog CreateRemoteCatalog( - ChronoStorageCatalogBlobClient.RemoteScopeContext remoteContext, - bool fileExists, - IReadOnlyList roles) => - new( - HomeDirectory: _blobClient.CreateRemoteHomeDirectory(remoteContext), - FilePath: _blobClient.CreateRemoteFilePath(remoteContext), - FileExists: fileExists, - Roles: roles); -} diff --git a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageScriptStoragePort.cs b/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageScriptStoragePort.cs deleted file mode 100644 index c7566a07..00000000 --- a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageScriptStoragePort.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Text; -using Aevatar.Studio.Application.Studio.Abstractions; - -namespace Aevatar.Studio.Infrastructure.Storage; - -internal sealed class ChronoStorageScriptStoragePort : IScriptStoragePort -{ - private readonly ChronoStorageCatalogBlobClient _blobClient; - - public ChronoStorageScriptStoragePort(ChronoStorageCatalogBlobClient blobClient) - { - _blobClient = blobClient ?? throw new ArgumentNullException(nameof(blobClient)); - } - - public async Task UploadScriptAsync(string scriptId, string sourceText, CancellationToken ct) - { - var bytes = Encoding.UTF8.GetBytes(sourceText); - var key = $"scripts/{scriptId}.cs"; - var context = _blobClient.TryResolveContext(string.Empty, key); - if (context == null) return; - - await _blobClient.UploadAsync(context, bytes, "text/plain", ct); - } -} diff --git a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageUserConfigStore.cs b/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageUserConfigStore.cs deleted file mode 100644 index 55f303b7..00000000 --- a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageUserConfigStore.cs +++ /dev/null @@ -1,155 +0,0 @@ -using System.Text.Json; -using Aevatar.Studio.Application.Studio.Abstractions; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Aevatar.Studio.Infrastructure.Storage; - -internal sealed class ChronoStorageUserConfigStore : IUserConfigStore -{ - private const string ConfigFileName = "config.json"; - - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = true, - }; - - private readonly ChronoStorageCatalogBlobClient _blobClient; - private readonly ConnectorCatalogStorageOptions _options; - private readonly string _defaultLocalRuntimeBaseUrl; - private readonly string _defaultRemoteRuntimeBaseUrl; - private readonly ILogger _logger; - - public ChronoStorageUserConfigStore( - ChronoStorageCatalogBlobClient blobClient, - IOptions options, - IOptions studioStorageOptions, - ILogger logger) - { - _blobClient = blobClient ?? throw new ArgumentNullException(nameof(blobClient)); - _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); - var resolvedStudioStorageOptions = studioStorageOptions?.Value.ResolveRootDirectory() - ?? throw new ArgumentNullException(nameof(studioStorageOptions)); - _defaultLocalRuntimeBaseUrl = resolvedStudioStorageOptions.ResolveDefaultLocalRuntimeBaseUrl(); - _defaultRemoteRuntimeBaseUrl = resolvedStudioStorageOptions.ResolveDefaultRemoteRuntimeBaseUrl(); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task GetAsync(CancellationToken cancellationToken = default) - { - try - { - var remoteContext = _blobClient.TryResolveContext(_options.UserConfigPrefix, ConfigFileName); - if (remoteContext is null) - return CreateDefaultConfig(); - - var payload = await _blobClient.TryDownloadAsync(remoteContext, cancellationToken); - if (payload is null) - return CreateDefaultConfig(); - - var doc = JsonDocument.Parse(payload); - var defaultModel = doc.RootElement.TryGetProperty("defaultModel", out var modelElement) - ? modelElement.GetString() ?? string.Empty - : string.Empty; - var preferredLlmRoute = doc.RootElement.TryGetProperty("preferredLlmRoute", out var routeElement) - ? UserConfigLlmRoute.Normalize(routeElement.GetString()) - : UserConfigLlmRouteDefaults.Gateway; - - var maxToolRounds = doc.RootElement.TryGetProperty("maxToolRounds", out var maxToolRoundsElement) - && maxToolRoundsElement.ValueKind == JsonValueKind.Number - ? maxToolRoundsElement.GetInt32() - : 0; - - var hasRuntimeMode = doc.RootElement.TryGetProperty("runtimeMode", out var runtimeModeElement); - var hasLocalRuntimeBaseUrl = doc.RootElement.TryGetProperty("localRuntimeBaseUrl", out var localRuntimeElement); - var hasRemoteRuntimeBaseUrl = doc.RootElement.TryGetProperty("remoteRuntimeBaseUrl", out var remoteRuntimeElement); - - if (hasRuntimeMode || hasLocalRuntimeBaseUrl || hasRemoteRuntimeBaseUrl) - { - return new UserConfig( - DefaultModel: defaultModel, - PreferredLlmRoute: preferredLlmRoute, - RuntimeMode: UserConfigRuntime.NormalizeMode(hasRuntimeMode ? runtimeModeElement.GetString() : null), - LocalRuntimeBaseUrl: UserConfigRuntime.NormalizeBaseUrl( - hasLocalRuntimeBaseUrl ? localRuntimeElement.GetString() : null, - _defaultLocalRuntimeBaseUrl), - RemoteRuntimeBaseUrl: UserConfigRuntime.NormalizeBaseUrl( - hasRemoteRuntimeBaseUrl ? remoteRuntimeElement.GetString() : null, - _defaultRemoteRuntimeBaseUrl), - MaxToolRounds: maxToolRounds); - } - - var legacyRuntimeBaseUrl = doc.RootElement.TryGetProperty("runtimeBaseUrl", out var runtimeElement) - ? runtimeElement.GetString() ?? string.Empty - : string.Empty; - - if (string.IsNullOrWhiteSpace(legacyRuntimeBaseUrl)) - { - return CreateDefaultConfig() with - { - DefaultModel = defaultModel, - PreferredLlmRoute = preferredLlmRoute, - MaxToolRounds = maxToolRounds, - }; - } - - var normalizedLegacyRuntimeBaseUrl = legacyRuntimeBaseUrl.Trim().TrimEnd('/'); - var runtimeMode = UserConfigRuntime.IsLoopbackRuntime(normalizedLegacyRuntimeBaseUrl) - ? UserConfigRuntimeDefaults.LocalMode - : UserConfigRuntimeDefaults.RemoteMode; - - return runtimeMode == UserConfigRuntimeDefaults.RemoteMode - ? new UserConfig( - DefaultModel: defaultModel, - PreferredLlmRoute: preferredLlmRoute, - RuntimeMode: runtimeMode, - LocalRuntimeBaseUrl: _defaultLocalRuntimeBaseUrl, - RemoteRuntimeBaseUrl: normalizedLegacyRuntimeBaseUrl) - : new UserConfig( - DefaultModel: defaultModel, - PreferredLlmRoute: preferredLlmRoute, - RuntimeMode: runtimeMode, - LocalRuntimeBaseUrl: normalizedLegacyRuntimeBaseUrl, - RemoteRuntimeBaseUrl: _defaultRemoteRuntimeBaseUrl); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning(ex, "Failed to read user config from chrono-storage; returning default config"); - return CreateDefaultConfig(); - } - } - - public async Task SaveAsync(UserConfig config, CancellationToken cancellationToken = default) - { - var remoteContext = _blobClient.TryResolveContext(_options.UserConfigPrefix, ConfigFileName); - if (remoteContext is null) - throw new InvalidOperationException( - "User config storage is not available. Chrono-storage is disabled or the remote context could not be resolved."); - - var payload = new Dictionary - { - ["defaultModel"] = config.DefaultModel, - ["preferredLlmRoute"] = UserConfigLlmRoute.Normalize(config.PreferredLlmRoute), - ["runtimeMode"] = UserConfigRuntime.NormalizeMode(config.RuntimeMode), - ["localRuntimeBaseUrl"] = UserConfigRuntime.NormalizeBaseUrl( - config.LocalRuntimeBaseUrl, - _defaultLocalRuntimeBaseUrl), - ["remoteRuntimeBaseUrl"] = UserConfigRuntime.NormalizeBaseUrl( - config.RemoteRuntimeBaseUrl, - _defaultRemoteRuntimeBaseUrl), - }; - if (config.MaxToolRounds > 0) - payload["maxToolRounds"] = config.MaxToolRounds; - var json = JsonSerializer.SerializeToUtf8Bytes(payload, JsonOptions); - await _blobClient.UploadAsync(remoteContext, json, "application/json", cancellationToken); - } - - private UserConfig CreateDefaultConfig() => - new( - DefaultModel: string.Empty, - PreferredLlmRoute: UserConfigLlmRouteDefaults.Gateway, - RuntimeMode: UserConfigRuntimeDefaults.LocalMode, - LocalRuntimeBaseUrl: _defaultLocalRuntimeBaseUrl, - RemoteRuntimeBaseUrl: _defaultRemoteRuntimeBaseUrl); -} diff --git a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageUserMemoryStore.cs b/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageUserMemoryStore.cs deleted file mode 100644 index 2b83ff40..00000000 --- a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageUserMemoryStore.cs +++ /dev/null @@ -1,258 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using Aevatar.AI.Abstractions.LLMProviders; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Aevatar.Studio.Infrastructure.Storage; - -internal sealed class ChronoStorageUserMemoryStore : IUserMemoryStore -{ - private const string MemoryFileName = "user-memory.json"; - private const int MaxEntries = 50; - - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; - - private readonly ChronoStorageCatalogBlobClient _blobClient; - private readonly ConnectorCatalogStorageOptions _options; - private readonly ILogger _logger; - - public ChronoStorageUserMemoryStore( - ChronoStorageCatalogBlobClient blobClient, - IOptions options, - ILogger logger) - { - _blobClient = blobClient ?? throw new ArgumentNullException(nameof(blobClient)); - _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task GetAsync(CancellationToken ct = default) - { - try - { - var context = _blobClient.TryResolveContext(_options.UserConfigPrefix, MemoryFileName); - if (context is null) - return UserMemoryDocument.Empty; - - var payload = await _blobClient.TryDownloadAsync(context, ct); - if (payload is null) - return UserMemoryDocument.Empty; - - var dto = JsonSerializer.Deserialize(payload, JsonOptions); - if (dto is null) - return UserMemoryDocument.Empty; - - var entries = (dto.Entries ?? []) - .Where(e => !string.IsNullOrWhiteSpace(e.Id) && !string.IsNullOrWhiteSpace(e.Content)) - .Select(e => new UserMemoryEntry( - Id: e.Id!, - Category: NormalizeCategory(e.Category), - Content: e.Content!, - Source: NormalizeSource(e.Source), - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt)) - .ToList(); - - return new UserMemoryDocument(dto.Version > 0 ? dto.Version : 1, entries); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning(ex, "Failed to read user memory from chrono-storage; returning empty"); - return UserMemoryDocument.Empty; - } - } - - public async Task SaveAsync(UserMemoryDocument document, CancellationToken ct = default) - { - var context = _blobClient.TryResolveContext(_options.UserConfigPrefix, MemoryFileName); - if (context is null) - throw new InvalidOperationException( - "User memory storage is not available. Chrono-storage is disabled or the remote context could not be resolved."); - - var dto = new UserMemoryDocumentDto - { - Version = document.Version, - Entries = document.Entries.Select(e => new UserMemoryEntryDto - { - Id = e.Id, - Category = e.Category, - Content = e.Content, - Source = e.Source, - CreatedAt = e.CreatedAt, - UpdatedAt = e.UpdatedAt, - }).ToList(), - }; - - var json = JsonSerializer.SerializeToUtf8Bytes(dto, JsonOptions); - await _blobClient.UploadAsync(context, json, "application/json", ct); - } - - public async Task AddEntryAsync( - string category, string content, string source, CancellationToken ct = default) - { - var doc = await GetAsync(ct); - var entries = new List(doc.Entries); - - var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - var entry = new UserMemoryEntry( - Id: GenerateId(), - Category: NormalizeCategory(category), - Content: content.Trim(), - Source: NormalizeSource(source), - CreatedAt: now, - UpdatedAt: now); - - entries.Add(entry); - - // Enforce cap: evict oldest in same category first, then globally oldest. - while (entries.Count > MaxEntries) - { - var normalizedCategory = entry.Category; - var oldestSameCategory = entries - .Where(e => e.Category == normalizedCategory && e.Id != entry.Id) - .OrderBy(e => e.CreatedAt) - .FirstOrDefault(); - - if (oldestSameCategory is not null) - { - entries.Remove(oldestSameCategory); - } - else - { - var globallyOldest = entries - .Where(e => e.Id != entry.Id) - .OrderBy(e => e.CreatedAt) - .FirstOrDefault(); - if (globallyOldest is not null) - entries.Remove(globallyOldest); - else - break; - } - } - - await SaveAsync(new UserMemoryDocument(doc.Version, entries), ct); - return entry; - } - - public async Task RemoveEntryAsync(string id, CancellationToken ct = default) - { - var doc = await GetAsync(ct); - var entries = doc.Entries.ToList(); - var index = entries.FindIndex(e => string.Equals(e.Id, id, StringComparison.Ordinal)); - if (index < 0) - return false; - - entries.RemoveAt(index); - await SaveAsync(new UserMemoryDocument(doc.Version, entries), ct); - return true; - } - - public async Task BuildPromptSectionAsync(int maxChars = 2000, CancellationToken ct = default) - { - UserMemoryDocument doc; - try - { - doc = await GetAsync(ct); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning(ex, "Failed to load user memory for prompt injection"); - return string.Empty; - } - - if (doc.Entries.Count == 0) - return string.Empty; - - var sb = new StringBuilder(); - sb.AppendLine(""); - - var categoryOrder = new[] - { - UserMemoryCategories.Preference, - UserMemoryCategories.Instruction, - UserMemoryCategories.Context, - }; - - var grouped = doc.Entries - .GroupBy(e => e.Category) - .OrderBy(g => Array.IndexOf(categoryOrder, g.Key) is var i && i >= 0 ? i : int.MaxValue); - - foreach (var group in grouped) - { - var header = group.Key switch - { - UserMemoryCategories.Preference => "## Preferences", - UserMemoryCategories.Instruction => "## Instructions", - UserMemoryCategories.Context => "## Context", - _ => $"## {Capitalize(group.Key)}", - }; - sb.AppendLine(header); - foreach (var entry in group.OrderByDescending(e => e.UpdatedAt)) - sb.AppendLine($"- {entry.Content}"); - sb.AppendLine(); - } - - sb.Append(""); - - var result = sb.ToString(); - if (result.Length <= maxChars) - return result; - - // Truncate to maxChars at a newline boundary. - var truncated = result[..maxChars]; - var lastNewline = truncated.LastIndexOf('\n'); - return lastNewline > 0 - ? truncated[..lastNewline] + "\n" - : truncated; - } - - private static string GenerateId() - { - var bytes = RandomNumberGenerator.GetBytes(6); - return Convert.ToHexString(bytes).ToLowerInvariant(); - } - - private static string NormalizeCategory(string? value) => - value?.Trim().ToLowerInvariant() switch - { - UserMemoryCategories.Preference => UserMemoryCategories.Preference, - UserMemoryCategories.Instruction => UserMemoryCategories.Instruction, - UserMemoryCategories.Context => UserMemoryCategories.Context, - null or "" => UserMemoryCategories.Context, - var v => v, - }; - - private static string NormalizeSource(string? value) => - string.Equals(value?.Trim(), UserMemorySources.Explicit, StringComparison.OrdinalIgnoreCase) - ? UserMemorySources.Explicit - : UserMemorySources.Inferred; - - private static string Capitalize(string s) => - s.Length == 0 ? s : char.ToUpperInvariant(s[0]) + s[1..]; - - // ── DTOs for JSON serialization ───────────────────────────────────────── - - private sealed class UserMemoryDocumentDto - { - public int Version { get; set; } = 1; - public List? Entries { get; set; } - } - - private sealed class UserMemoryEntryDto - { - public string? Id { get; set; } - public string? Category { get; set; } - public string? Content { get; set; } - public string? Source { get; set; } - public long CreatedAt { get; set; } - public long UpdatedAt { get; set; } - } -} diff --git a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageWorkflowStoragePort.cs b/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageWorkflowStoragePort.cs deleted file mode 100644 index b14ef1ad..00000000 --- a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageWorkflowStoragePort.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System.Text; -using Aevatar.Studio.Application.Studio.Abstractions; - -namespace Aevatar.Studio.Infrastructure.Storage; - -internal sealed class ChronoStorageWorkflowStoragePort : IWorkflowStoragePort -{ - private const string WorkflowDirectory = "workflows"; - - private readonly ChronoStorageCatalogBlobClient _blobClient; - - public ChronoStorageWorkflowStoragePort(ChronoStorageCatalogBlobClient blobClient) - { - _blobClient = blobClient ?? throw new ArgumentNullException(nameof(blobClient)); - } - - public async Task UploadWorkflowYamlAsync(string workflowId, string workflowName, string yaml, CancellationToken ct) - { - var yamlBytes = Encoding.UTF8.GetBytes(yaml); - var key = $"{WorkflowDirectory}/{workflowId}.yaml"; - var context = _blobClient.TryResolveContext(string.Empty, key); - if (context == null) return; - - await _blobClient.UploadAsync(context, yamlBytes, "text/yaml", ct); - } - - public async Task> ListWorkflowYamlsAsync(CancellationToken ct) - { - var directoryContext = ResolveWorkflowDirectoryContext(); - if (directoryContext == null) - return []; - - var objects = await _blobClient.ListObjectsAsync(directoryContext, WorkflowDirectory, ct); - if (objects.Objects.Count == 0) - return []; - - var workflows = new List(objects.Objects.Count); - foreach (var storageObject in objects.Objects) - { - var workflowId = TryResolveWorkflowId(storageObject.Key); - if (string.IsNullOrWhiteSpace(workflowId)) - continue; - - var stored = await GetWorkflowYamlAsync(workflowId, ct); - if (stored != null) - { - var updatedAtUtc = TryParseUpdatedAt(storageObject.LastModified) ?? stored.UpdatedAtUtc; - workflows.Add(stored with { UpdatedAtUtc = updatedAtUtc }); - } - } - - return workflows; - } - - public async Task GetWorkflowYamlAsync(string workflowId, CancellationToken ct) - { - var normalizedWorkflowId = workflowId?.Trim() ?? string.Empty; - if (normalizedWorkflowId.Length == 0) - return null; - - var context = _blobClient.TryResolveContext(string.Empty, $"{WorkflowDirectory}/{normalizedWorkflowId}.yaml"); - if (context == null) - return null; - - var payload = await _blobClient.TryDownloadAsync(context, ct); - if (payload == null || payload.Length == 0) - return null; - - var yaml = Encoding.UTF8.GetString(payload); - return new StoredWorkflowYaml( - normalizedWorkflowId, - normalizedWorkflowId, - yaml, - UpdatedAtUtc: null); - } - - private ChronoStorageCatalogBlobClient.RemoteScopeContext? ResolveWorkflowDirectoryContext() => - _blobClient.TryResolveContext(string.Empty, $"{WorkflowDirectory}/.index"); - - private static string? TryResolveWorkflowId(string relativeKey) - { - if (string.IsNullOrWhiteSpace(relativeKey)) - return null; - - var normalizedKey = relativeKey.Trim(); - if (!normalizedKey.StartsWith($"{WorkflowDirectory}/", StringComparison.Ordinal) || - !normalizedKey.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase)) - { - return null; - } - - return Path.GetFileNameWithoutExtension(normalizedKey); - } - - private static DateTimeOffset? TryParseUpdatedAt(string? raw) - { - if (string.IsNullOrWhiteSpace(raw)) - return null; - - return DateTimeOffset.TryParse(raw, out var parsed) ? parsed : null; - } -} diff --git a/src/Aevatar.Studio.Projection/Aevatar.Studio.Projection.csproj b/src/Aevatar.Studio.Projection/Aevatar.Studio.Projection.csproj new file mode 100644 index 00000000..eae0e9f0 --- /dev/null +++ b/src/Aevatar.Studio.Projection/Aevatar.Studio.Projection.csproj @@ -0,0 +1,32 @@ + + + net10.0 + enable + enable + Aevatar.Studio.Projection + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchUserConfigCommandService.cs b/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchUserConfigCommandService.cs new file mode 100644 index 00000000..1eb16bfc --- /dev/null +++ b/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchUserConfigCommandService.cs @@ -0,0 +1,58 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.UserConfig; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Infrastructure.ScopeResolution; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.Studio.Projection.CommandServices; + +/// +/// Dispatches user-config write commands to the UserConfigGAgent +/// via . +/// +internal sealed class ActorDispatchUserConfigCommandService : IUserConfigCommandService +{ + private const string ActorIdPrefix = "user-config-"; + + private readonly IActorDispatchPort _dispatchPort; + private readonly IAppScopeResolver _scopeResolver; + + public ActorDispatchUserConfigCommandService( + IActorDispatchPort dispatchPort, + IAppScopeResolver scopeResolver) + { + _dispatchPort = dispatchPort ?? throw new ArgumentNullException(nameof(dispatchPort)); + _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); + } + + public Task SaveAsync(UserConfig config, CancellationToken ct = default) + { + var actorId = ActorIdPrefix + (_scopeResolver.Resolve()?.ScopeId ?? "default"); + + var evt = new UserConfigUpdatedEvent + { + DefaultModel = config.DefaultModel, + PreferredLlmRoute = UserConfigLlmRoute.Normalize(config.PreferredLlmRoute), + RuntimeMode = UserConfigRuntime.NormalizeMode(config.RuntimeMode), + LocalRuntimeBaseUrl = UserConfigRuntime.NormalizeBaseUrl( + config.LocalRuntimeBaseUrl, + UserConfigRuntimeDefaults.LocalRuntimeBaseUrl), + RemoteRuntimeBaseUrl = UserConfigRuntime.NormalizeBaseUrl( + config.RemoteRuntimeBaseUrl, + UserConfigRuntimeDefaults.RemoteRuntimeBaseUrl), + MaxToolRounds = config.MaxToolRounds, + }; + + var envelope = new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTime(DateTime.UtcNow), + Payload = Any.Pack(evt), + Route = EnvelopeRouteSemantics.CreateTopologyPublication( + actorId, TopologyAudience.Self), + }; + + return _dispatchPort.DispatchAsync(actorId, envelope, ct); + } +} diff --git a/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..af843b8b --- /dev/null +++ b/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,64 @@ +using Aevatar.CQRS.Projection.Core.Abstractions; +using Aevatar.CQRS.Projection.Core.DependencyInjection; +using Aevatar.CQRS.Projection.Core.Orchestration; +using Aevatar.CQRS.Projection.Runtime.DependencyInjection; +using Aevatar.CQRS.Projection.Stores.Abstractions; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Projection.CommandServices; +using Aevatar.Studio.Projection.Metadata; +using Aevatar.Studio.Projection.Orchestration; +using Aevatar.Studio.Projection.Projectors; +using Aevatar.Studio.Projection.QueryPorts; +using Aevatar.Studio.Projection.ReadModels; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Aevatar.Studio.Projection.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + /// + /// Registers Studio projection components: materialization runtime, + /// projectors, query ports, command services, and document metadata providers. + /// + public static IServiceCollection AddStudioProjectionComponents(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + // Projection read-model runtime (write dispatcher + sink bindings) + services.AddProjectionReadModelRuntime(); + + // Projection clock (shared, idempotent registration) + services.TryAddSingleton(); + + // Materialization runtime for Studio current-state projections + services.AddProjectionMaterializationRuntimeCore< + StudioMaterializationContext, + StudioMaterializationRuntimeLease, + ProjectionMaterializationScopeGAgent>( + scopeKey => new StudioMaterializationContext + { + RootActorId = scopeKey.RootActorId, + ProjectionKind = scopeKey.ProjectionKind, + }, + context => new StudioMaterializationRuntimeLease(context)); + + // UserConfig projector + services.AddCurrentStateProjectionMaterializer< + StudioMaterializationContext, + UserConfigCurrentStateProjector>(); + + // Document metadata providers (for index creation in Elasticsearch) + services.TryAddSingleton< + IProjectionDocumentMetadataProvider, + UserConfigCurrentStateDocumentMetadataProvider>(); + + // Query ports (read side) + services.TryAddSingleton(); + + // Command services (write side) + services.TryAddSingleton(); + + return services; + } +} diff --git a/src/Aevatar.Studio.Projection/Metadata/UserConfigCurrentStateDocumentMetadataProvider.cs b/src/Aevatar.Studio.Projection/Metadata/UserConfigCurrentStateDocumentMetadataProvider.cs new file mode 100644 index 00000000..148a00a9 --- /dev/null +++ b/src/Aevatar.Studio.Projection/Metadata/UserConfigCurrentStateDocumentMetadataProvider.cs @@ -0,0 +1,17 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; +using Aevatar.Studio.Projection.ReadModels; + +namespace Aevatar.Studio.Projection.Metadata; + +public sealed class UserConfigCurrentStateDocumentMetadataProvider + : IProjectionDocumentMetadataProvider +{ + public DocumentIndexMetadata Metadata { get; } = new( + IndexName: "studio-user-config", + Mappings: new Dictionary(StringComparer.Ordinal) + { + ["dynamic"] = true, + }, + Settings: new Dictionary(StringComparer.Ordinal), + Aliases: new Dictionary(StringComparer.Ordinal)); +} diff --git a/src/Aevatar.Studio.Projection/Orchestration/StudioMaterializationContext.cs b/src/Aevatar.Studio.Projection/Orchestration/StudioMaterializationContext.cs new file mode 100644 index 00000000..9c2bf12c --- /dev/null +++ b/src/Aevatar.Studio.Projection/Orchestration/StudioMaterializationContext.cs @@ -0,0 +1,14 @@ +using Aevatar.CQRS.Projection.Core.Abstractions; + +namespace Aevatar.Studio.Projection.Orchestration; + +/// +/// Materialization context for Studio projection scopes. +/// Shared by all Studio current-state projectors (UserConfig, etc.). +/// +public sealed class StudioMaterializationContext : IProjectionMaterializationContext +{ + public required string RootActorId { get; init; } + + public required string ProjectionKind { get; init; } +} diff --git a/src/Aevatar.Studio.Projection/Orchestration/StudioMaterializationRuntimeLease.cs b/src/Aevatar.Studio.Projection/Orchestration/StudioMaterializationRuntimeLease.cs new file mode 100644 index 00000000..3d692673 --- /dev/null +++ b/src/Aevatar.Studio.Projection/Orchestration/StudioMaterializationRuntimeLease.cs @@ -0,0 +1,17 @@ +using Aevatar.CQRS.Projection.Core.Abstractions; +using Aevatar.CQRS.Projection.Core.Orchestration; + +namespace Aevatar.Studio.Projection.Orchestration; + +public sealed class StudioMaterializationRuntimeLease + : ProjectionRuntimeLeaseBase, + IProjectionContextRuntimeLease +{ + public StudioMaterializationRuntimeLease(StudioMaterializationContext context) + : base(context.RootActorId) + { + Context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public StudioMaterializationContext Context { get; } +} diff --git a/src/Aevatar.Studio.Projection/Projectors/UserConfigCurrentStateProjector.cs b/src/Aevatar.Studio.Projection/Projectors/UserConfigCurrentStateProjector.cs new file mode 100644 index 00000000..05369dd0 --- /dev/null +++ b/src/Aevatar.Studio.Projection/Projectors/UserConfigCurrentStateProjector.cs @@ -0,0 +1,71 @@ +using Aevatar.CQRS.Projection.Core.Abstractions; +using Aevatar.CQRS.Projection.Core.Orchestration; +using Aevatar.CQRS.Projection.Runtime.Abstractions; +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.UserConfig; +using Aevatar.Studio.Projection.Orchestration; +using Aevatar.Studio.Projection.ReadModels; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.Studio.Projection.Projectors; + +/// +/// Materializes committed events into +/// in the projection document store. +/// +/// Follows the pattern +/// from the scripting module. +/// +public sealed class UserConfigCurrentStateProjector + : ICurrentStateProjectionMaterializer +{ + private readonly IProjectionWriteDispatcher _writeDispatcher; + private readonly IProjectionClock _clock; + + public UserConfigCurrentStateProjector( + IProjectionWriteDispatcher writeDispatcher, + IProjectionClock clock) + { + _writeDispatcher = writeDispatcher ?? throw new ArgumentNullException(nameof(writeDispatcher)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + } + + public async ValueTask ProjectAsync( + StudioMaterializationContext context, + EventEnvelope envelope, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(envelope); + + if (!CommittedStateEventEnvelope.TryUnpackState( + envelope, + out _, + out var stateEvent, + out var state) || + stateEvent?.EventData == null || + state == null) + { + return; + } + + var updatedAt = CommittedStateEventEnvelope.ResolveTimestamp(envelope, _clock.UtcNow); + + var document = new UserConfigCurrentStateDocument + { + Id = context.RootActorId, + ActorId = context.RootActorId, + StateVersion = stateEvent.Version, + LastEventId = stateEvent.EventId ?? string.Empty, + UpdatedAt = Timestamp.FromDateTimeOffset(updatedAt), + DefaultModel = state.DefaultModel, + PreferredLlmRoute = state.PreferredLlmRoute, + RuntimeMode = state.RuntimeMode, + LocalRuntimeBaseUrl = state.LocalRuntimeBaseUrl, + RemoteRuntimeBaseUrl = state.RemoteRuntimeBaseUrl, + MaxToolRounds = state.MaxToolRounds, + }; + + await _writeDispatcher.UpsertAsync(document, ct); + } +} diff --git a/src/Aevatar.Studio.Projection/QueryPorts/ProjectionUserConfigQueryPort.cs b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionUserConfigQueryPort.cs new file mode 100644 index 00000000..5c0019d5 --- /dev/null +++ b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionUserConfigQueryPort.cs @@ -0,0 +1,59 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Infrastructure.ScopeResolution; +using Aevatar.Studio.Projection.ReadModels; + +namespace Aevatar.Studio.Projection.QueryPorts; + +/// +/// Reads user configuration from the projection document store. +/// Zero write path. Pure query semantics. +/// +public sealed class ProjectionUserConfigQueryPort : IUserConfigQueryPort +{ + private const string WriteActorIdPrefix = "user-config-"; + + private readonly IProjectionDocumentReader _documentReader; + private readonly IAppScopeResolver _scopeResolver; + + public ProjectionUserConfigQueryPort( + IProjectionDocumentReader documentReader, + IAppScopeResolver scopeResolver) + { + _documentReader = documentReader ?? throw new ArgumentNullException(nameof(documentReader)); + _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); + } + + public async Task GetAsync(CancellationToken ct = default) + { + var actorId = WriteActorIdPrefix + (_scopeResolver.Resolve()?.ScopeId ?? "default"); + var document = await _documentReader.GetAsync(actorId, ct); + + if (document is null) + return CreateDefaultConfig(); + + return new UserConfig( + DefaultModel: document.DefaultModel, + PreferredLlmRoute: string.IsNullOrEmpty(document.PreferredLlmRoute) + ? UserConfigLlmRouteDefaults.Gateway + : document.PreferredLlmRoute, + RuntimeMode: string.IsNullOrEmpty(document.RuntimeMode) + ? UserConfigRuntimeDefaults.LocalMode + : document.RuntimeMode, + LocalRuntimeBaseUrl: string.IsNullOrEmpty(document.LocalRuntimeBaseUrl) + ? UserConfigRuntimeDefaults.LocalRuntimeBaseUrl + : document.LocalRuntimeBaseUrl, + RemoteRuntimeBaseUrl: string.IsNullOrEmpty(document.RemoteRuntimeBaseUrl) + ? UserConfigRuntimeDefaults.RemoteRuntimeBaseUrl + : document.RemoteRuntimeBaseUrl, + MaxToolRounds: document.MaxToolRounds); + } + + private static UserConfig CreateDefaultConfig() => + new( + DefaultModel: string.Empty, + PreferredLlmRoute: UserConfigLlmRouteDefaults.Gateway, + RuntimeMode: UserConfigRuntimeDefaults.LocalMode, + LocalRuntimeBaseUrl: UserConfigRuntimeDefaults.LocalRuntimeBaseUrl, + RemoteRuntimeBaseUrl: UserConfigRuntimeDefaults.RemoteRuntimeBaseUrl); +} diff --git a/src/Aevatar.Studio.Projection/ReadModels/UserConfigCurrentStateDocument.Partial.cs b/src/Aevatar.Studio.Projection/ReadModels/UserConfigCurrentStateDocument.Partial.cs new file mode 100644 index 00000000..46515290 --- /dev/null +++ b/src/Aevatar.Studio.Projection/ReadModels/UserConfigCurrentStateDocument.Partial.cs @@ -0,0 +1,18 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; + +namespace Aevatar.Studio.Projection.ReadModels; + +public sealed partial class UserConfigCurrentStateDocument + : IProjectionReadModel +{ + string IProjectionReadModel.ActorId => ActorId; + + long IProjectionReadModel.StateVersion => StateVersion; + + string IProjectionReadModel.LastEventId => LastEventId; + + DateTimeOffset IProjectionReadModel.UpdatedAt + { + get => UpdatedAt?.ToDateTimeOffset() ?? DateTimeOffset.MinValue; + } +} diff --git a/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto b/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto new file mode 100644 index 00000000..9f9ef2bf --- /dev/null +++ b/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; +package aevatar.studio.projection; +option csharp_namespace = "Aevatar.Studio.Projection.ReadModels"; + +import "google/protobuf/timestamp.proto"; + +// ─── UserConfig Current State ReadModel ─── + +message UserConfigCurrentStateDocument { + string id = 1; + string actor_id = 2; + int64 state_version = 3; + string last_event_id = 4; + google.protobuf.Timestamp updated_at = 5; + + // Business fields (projected from UserConfigGAgentState) + string default_model = 10; + string preferred_llm_route = 11; + string runtime_mode = 12; + string local_runtime_base_url = 13; + string remote_runtime_base_url = 14; + int32 max_tool_rounds = 15; +} diff --git a/src/platform/Aevatar.GAgentService.Hosting/Aevatar.GAgentService.Hosting.csproj b/src/platform/Aevatar.GAgentService.Hosting/Aevatar.GAgentService.Hosting.csproj index 7854f1fb..a81a28d8 100644 --- a/src/platform/Aevatar.GAgentService.Hosting/Aevatar.GAgentService.Hosting.csproj +++ b/src/platform/Aevatar.GAgentService.Hosting/Aevatar.GAgentService.Hosting.csproj @@ -18,6 +18,7 @@ + diff --git a/src/platform/Aevatar.GAgentService.Hosting/DependencyInjection/ServiceCollectionExtensions.cs b/src/platform/Aevatar.GAgentService.Hosting/DependencyInjection/ServiceCollectionExtensions.cs index 0d4fcce2..4558a8c4 100644 --- a/src/platform/Aevatar.GAgentService.Hosting/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/platform/Aevatar.GAgentService.Hosting/DependencyInjection/ServiceCollectionExtensions.cs @@ -19,6 +19,7 @@ using Aevatar.GAgentService.Projection.DependencyInjection; using Aevatar.GAgentService.Projection.ReadModels; using Aevatar.Scripting.Core.Ports; +using Aevatar.Studio.Projection.ReadModels; using Aevatar.Scripting.Hosting.DependencyInjection; using Aevatar.Workflow.Application.Abstractions.Queries; using Aevatar.Workflow.Infrastructure.DependencyInjection; @@ -131,6 +132,11 @@ public static IServiceCollection AddGAgentServiceProjectionReadModelProviders( metadataFactory: sp => sp.GetRequiredService>().Metadata, keySelector: readModel => readModel.Id, keyFormatter: key => key); + services.AddElasticsearchDocumentProjectionStore( + optionsFactory: _ => BuildElasticsearchDocumentOptions(configuration), + metadataFactory: sp => sp.GetRequiredService>().Metadata, + keySelector: readModel => readModel.Id, + keyFormatter: key => key); } else { @@ -158,6 +164,10 @@ public static IServiceCollection AddGAgentServiceProjectionReadModelProviders( keySelector: readModel => readModel.Id, keyFormatter: key => key, defaultSortSelector: readModel => readModel.UpdatedAt); + services.AddInMemoryDocumentProjectionStore( + keySelector: readModel => readModel.Id, + keyFormatter: key => key, + defaultSortSelector: readModel => readModel.UpdatedAt); } return services; diff --git a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeGAgentEndpoints.cs b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeGAgentEndpoints.cs index 35e00108..3dfe1344 100644 --- a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeGAgentEndpoints.cs +++ b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeGAgentEndpoints.cs @@ -395,7 +395,7 @@ or AGUIEvent.EventOneofCase.RunError chatRequest.Metadata[LLMRequestMetadataKeys.NyxIdAccessToken] = bearerToken; // Forward the user's preferred model from their config. - var userConfigStore = http.RequestServices.GetService(); + var userConfigStore = http.RequestServices.GetService(); if (userConfigStore != null) { try diff --git a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeServiceEndpoints.cs b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeServiceEndpoints.cs index 57ad6afa..89eebc93 100644 --- a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeServiceEndpoints.cs +++ b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeServiceEndpoints.cs @@ -1881,7 +1881,7 @@ private static async Task> BuildScopedHeadersAsync( InjectBearerToken(http, scopedHeaders); if (http != null) { - var userConfigStore = http.RequestServices.GetService(); + var userConfigStore = http.RequestServices.GetService(); if (userConfigStore != null) { try diff --git a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowEndpoints.cs b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowEndpoints.cs index 9e17b8e5..e6c4e2f0 100644 --- a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowEndpoints.cs +++ b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowEndpoints.cs @@ -530,7 +530,7 @@ private static async Task> BuildScopedHeadersAsync( scopedHeaders[ConnectorRequest.HttpAuthorizationMetadataKey] = $"Bearer {bearerToken}"; } - var userConfigStore = http.RequestServices.GetService(); + var userConfigStore = http.RequestServices.GetService(); if (userConfigStore != null) { try diff --git a/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs b/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs deleted file mode 100644 index bda9c3f3..00000000 --- a/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs +++ /dev/null @@ -1,1095 +0,0 @@ -using System.Reflection; -using System.Text; -using System.Text.Json; -using Aevatar.AI.Abstractions; -using Aevatar.AI.Abstractions.LLMProviders; -using Aevatar.Authentication.Abstractions; -using Aevatar.Foundation.Abstractions; -using Aevatar.Foundation.Abstractions.Streaming; -using Aevatar.GAgents.NyxidChat; -using FluentAssertions; -using Google.Protobuf; -using Google.Protobuf.Collections; -using Any = Google.Protobuf.WellKnownTypes.Any; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Hosting; - -namespace Aevatar.AI.Tests; - -public class NyxIdChatEndpointsCoverageTests -{ - private static readonly System.Type EndpointsType = typeof(NyxIdChatEndpoints); - - [Fact] - public void MapNyxIdChatEndpoints_ShouldRegisterExpectedRoutes() - { - var builder = WebApplication.CreateBuilder(new WebApplicationOptions - { - EnvironmentName = Environments.Development, - }); - - var app = builder.Build(); - var routeBuilder = (IEndpointRouteBuilder)app; - app.MapNyxIdChatEndpoints(); - - var routes = routeBuilder.DataSources - .SelectMany(x => x.Endpoints) - .OfType() - .Select(x => x.RoutePattern.RawText) - .ToHashSet(StringComparer.Ordinal); - - routes.Should().Contain("/api/scopes/{scopeId}/nyxid-chat/conversations"); - routes.Should().Contain("/api/scopes/{scopeId}/nyxid-chat/conversations/{actorId}:stream"); - routes.Should().Contain("/api/scopes/{scopeId}/nyxid-chat/conversations/{actorId}:approve"); - routes.Should().Contain("/api/webhooks/nyxid-relay"); - } - - [Fact] - public async Task HandleCreateConversationAsync_ShouldReturnCreatedConversation() - { - var store = new NyxIdChatActorStore(); - var result = await InvokeResultAsync( - "HandleCreateConversationAsync", - new DefaultHttpContext(), - "scope-a", - store, - CancellationToken.None); - - var response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status200OK); - using var doc = JsonDocument.Parse(response.Body); - doc.RootElement.TryGetProperty("actorId", out var actorId).Should().BeTrue(); - actorId.GetString().Should().NotBeNullOrWhiteSpace(); - doc.RootElement.TryGetProperty("createdAt", out _).Should().BeTrue(); - (await store.ListActorsAsync("scope-a")).Should().ContainSingle(); - } - - [Fact] - public async Task HandleListConversationsAsync_ShouldReturnList() - { - var store = new NyxIdChatActorStore(); - await store.CreateActorAsync("scope-a"); - - var result = await InvokeResultAsync( - "HandleListConversationsAsync", - new DefaultHttpContext(), - "scope-a", - store, - CancellationToken.None); - - var response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status200OK); - using var doc = JsonDocument.Parse(response.Body); - doc.RootElement.GetArrayLength().Should().Be(1); - } - - [Fact] - public async Task HandleDeleteConversationAsync_ShouldReturnNotFound_WhenMissing() - { - var store = new NyxIdChatActorStore(); - var result = await InvokeResultAsync( - "HandleDeleteConversationAsync", - new DefaultHttpContext(), - "scope-a", - "missing-id", - store, - CancellationToken.None); - - var response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status404NotFound); - } - - [Fact] - public async Task HandleStreamMessageAsync_ShouldRejectWithoutAuthorization() - { - var context = new DefaultHttpContext(); - context.Request.Headers.Authorization = "Bearer"; - var runtime = new StubActorRuntime(); - var subscriptions = new StubSubscriptionProvider(); - - await InvokeTaskAsync( - "HandleStreamMessageAsync", - context, - "scope-a", - "actor-1", - new NyxIdChatEndpoints.NyxIdChatStreamRequest("hello"), - runtime, - subscriptions, - NullLoggerFactory.Instance, - CancellationToken.None); - context.Response.StatusCode.Should().Be(StatusCodes.Status401Unauthorized); - } - - [Fact] - public async Task HandleStreamMessageAsync_ShouldRejectWhenNoPromptAndNoInputParts() - { - var context = new DefaultHttpContext(); - context.Request.Headers.Authorization = "Bearer valid-token"; - var runtime = new StubActorRuntime(); - - await InvokeTaskAsync( - "HandleStreamMessageAsync", - context, - "scope-a", - "actor-1", - new NyxIdChatEndpoints.NyxIdChatStreamRequest(null), - runtime, - new StubSubscriptionProvider(), - NullLoggerFactory.Instance, - CancellationToken.None); - context.Response.StatusCode.Should().Be(StatusCodes.Status400BadRequest); - } - - [Fact] - public async Task HandleApproveAsync_ShouldRejectWithoutAuthorization() - { - var context = new DefaultHttpContext(); - context.Request.Headers.Authorization = "Bearer"; - var runtime = new StubActorRuntime(); - - await InvokeTaskAsync( - "HandleApproveAsync", - context, - "scope-a", - "actor-1", - new NyxIdChatEndpoints.NyxIdApprovalRequest("req"), - runtime, - new StubSubscriptionProvider(), - NullLoggerFactory.Instance, - CancellationToken.None); - context.Response.StatusCode.Should().Be(StatusCodes.Status401Unauthorized); - } - - [Fact] - public async Task HandleApproveAsync_ShouldRejectWhenRequestIdMissing() - { - var context = new DefaultHttpContext(); - context.Request.Headers.Authorization = "Bearer valid-token"; - var runtime = new StubActorRuntime(); - - await InvokeTaskAsync( - "HandleApproveAsync", - context, - "scope-a", - "actor-1", - new NyxIdChatEndpoints.NyxIdApprovalRequest(null), - runtime, - new StubSubscriptionProvider(), - NullLoggerFactory.Instance, - CancellationToken.None); - context.Response.StatusCode.Should().Be(StatusCodes.Status400BadRequest); - } - - [Fact] - public async Task HandleStreamMessageAsync_ShouldDispatchChatRequest_AndWriteRunFinished() - { - var context = new DefaultHttpContext - { - RequestServices = new ServiceCollection() - .AddLogging() - .AddSingleton(new StubPreferencesStore("relay-model", "/relay-route", 7)) - .AddSingleton(new StubUserMemoryStore("remember this")) - .BuildServiceProvider(), - }; - context.Request.Headers.Authorization = "Bearer valid-token"; - context.Response.Body = new MemoryStream(); - - var runtime = new StubActorRuntime(); - var subscriptions = new StubSubscriptionProvider - { - Messages = - { - new EventEnvelope { Payload = Any.Pack(new TextMessageStartEvent()) }, - new EventEnvelope { Payload = Any.Pack(new TextMessageContentEvent { Delta = "hello" }) }, - new EventEnvelope { Payload = Any.Pack(new TextMessageEndEvent { Content = "done" }) }, - }, - }; - - await InvokeTaskAsync( - "HandleStreamMessageAsync", - context, - "scope-a", - "actor-1", - new NyxIdChatEndpoints.NyxIdChatStreamRequest("hello there"), - runtime, - subscriptions, - NullLoggerFactory.Instance, - CancellationToken.None); - - context.Response.StatusCode.Should().Be(StatusCodes.Status200OK); - runtime.CreateCalls.Should().ContainSingle(); - var actor = runtime.Actors["actor-1"].Should().BeOfType().Subject; - var chatRequest = actor.HandledEnvelopes.Should().ContainSingle().Subject.Payload.Unpack(); - chatRequest.Prompt.Should().Be("hello there"); - chatRequest.ScopeId.Should().Be("scope-a"); - chatRequest.Metadata[LLMRequestMetadataKeys.NyxIdAccessToken].Should().Be("valid-token"); - chatRequest.Metadata["scope_id"].Should().Be("scope-a"); - chatRequest.Metadata[LLMRequestMetadataKeys.ModelOverride].Should().Be("relay-model"); - chatRequest.Metadata[LLMRequestMetadataKeys.NyxIdRoutePreference].Should().Be("/relay-route"); - chatRequest.Metadata[LLMRequestMetadataKeys.MaxToolRoundsOverride].Should().Be("7"); - chatRequest.Metadata[LLMRequestMetadataKeys.UserMemoryPrompt].Should().Be("remember this"); - - context.Response.Body.Position = 0; - var body = await new StreamReader(context.Response.Body).ReadToEndAsync(); - body.Should().Contain("RUN_STARTED"); - body.Should().Contain("TEXT_MESSAGE_START"); - body.Should().Contain("hello"); - body.Should().Contain("TEXT_MESSAGE_END"); - body.Should().Contain("RUN_FINISHED"); - } - - [Fact] - public async Task HandleStreamMessageAsync_ShouldReturn500_WhenFailureOccursBeforeWriterStarts() - { - var context = new DefaultHttpContext(); - context.Request.Headers.Authorization = "Bearer valid-token"; - - await InvokeTaskAsync( - "HandleStreamMessageAsync", - context, - "scope-a", - "actor-1", - new NyxIdChatEndpoints.NyxIdChatStreamRequest("hello"), - new ThrowingActorRuntime(new InvalidOperationException("runtime failed")), - new StubSubscriptionProvider(), - NullLoggerFactory.Instance, - CancellationToken.None); - - context.Response.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); - } - - [Fact] - public async Task HandleStreamMessageAsync_ShouldWriteRunError_WhenFailureOccursAfterWriterStarts() - { - var context = new DefaultHttpContext(); - context.Request.Headers.Authorization = "Bearer valid-token"; - context.Response.Body = new MemoryStream(); - - var runtime = new StubActorRuntime(); - runtime.Actors["actor-1"] = new StubActor("actor-1"); - - await InvokeTaskAsync( - "HandleStreamMessageAsync", - context, - "scope-a", - "actor-1", - new NyxIdChatEndpoints.NyxIdChatStreamRequest("hello"), - runtime, - new ThrowingSubscriptionProvider(new InvalidOperationException("subscription failed")), - NullLoggerFactory.Instance, - CancellationToken.None); - - context.Response.StatusCode.Should().Be(StatusCodes.Status200OK); - context.Response.Body.Position = 0; - var body = await new StreamReader(context.Response.Body).ReadToEndAsync(); - body.Should().Contain("RUN_STARTED"); - body.Should().Contain("RUN_ERROR"); - body.Should().Contain("subscription failed"); - } - - [Fact] - public async Task HandleApproveAsync_ShouldDispatchDecision_AndWriteRunFinished() - { - var context = new DefaultHttpContext(); - context.Request.Headers.Authorization = "Bearer valid-token"; - context.Response.Body = new MemoryStream(); - - var runtime = new StubActorRuntime(); - runtime.Actors["actor-1"] = new StubActor("actor-1"); - var subscriptions = new StubSubscriptionProvider - { - Messages = - { - new EventEnvelope { Payload = Any.Pack(new TextMessageStartEvent()) }, - new EventEnvelope { Payload = Any.Pack(new TextMessageEndEvent { Content = "done" }) }, - }, - }; - - await InvokeTaskAsync( - "HandleApproveAsync", - context, - "scope-a", - "actor-1", - new NyxIdChatEndpoints.NyxIdApprovalRequest("req-1", Approved: false, Reason: "deny", SessionId: "session-1"), - runtime, - subscriptions, - NullLoggerFactory.Instance, - CancellationToken.None); - - context.Response.StatusCode.Should().Be(StatusCodes.Status200OK); - var actor = runtime.Actors["actor-1"].Should().BeOfType().Subject; - var decision = actor.HandledEnvelopes.Should().ContainSingle().Subject.Payload.Unpack(); - decision.RequestId.Should().Be("req-1"); - decision.Approved.Should().BeFalse(); - decision.Reason.Should().Be("deny"); - decision.SessionId.Should().Be("session-1"); - - context.Response.Body.Position = 0; - var body = await new StreamReader(context.Response.Body).ReadToEndAsync(); - body.Should().Contain("RUN_STARTED"); - body.Should().Contain("RUN_FINISHED"); - } - - [Fact] - public async Task HandleApproveAsync_ShouldReturn500_WhenFailureOccursBeforeWriterStarts() - { - var context = new DefaultHttpContext(); - context.Request.Headers.Authorization = "Bearer valid-token"; - - await InvokeTaskAsync( - "HandleApproveAsync", - context, - "scope-a", - "actor-1", - new NyxIdChatEndpoints.NyxIdApprovalRequest("req-1"), - new ThrowingActorRuntime(new InvalidOperationException("runtime failed")), - new StubSubscriptionProvider(), - NullLoggerFactory.Instance, - CancellationToken.None); - - context.Response.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); - } - - [Fact] - public async Task HandleApproveAsync_ShouldWriteRunError_WhenFailureOccursAfterWriterStarts() - { - var context = new DefaultHttpContext(); - context.Request.Headers.Authorization = "Bearer valid-token"; - context.Response.Body = new MemoryStream(); - - var runtime = new StubActorRuntime(); - runtime.Actors["actor-1"] = new StubActor("actor-1"); - - await InvokeTaskAsync( - "HandleApproveAsync", - context, - "scope-a", - "actor-1", - new NyxIdChatEndpoints.NyxIdApprovalRequest("req-1"), - runtime, - new ThrowingSubscriptionProvider(new InvalidOperationException("approval subscription failed")), - NullLoggerFactory.Instance, - CancellationToken.None); - - context.Response.StatusCode.Should().Be(StatusCodes.Status200OK); - context.Response.Body.Position = 0; - var body = await new StreamReader(context.Response.Body).ReadToEndAsync(); - body.Should().Contain("RUN_STARTED"); - body.Should().Contain("RUN_ERROR"); - body.Should().Contain("approval subscription failed"); - } - - [Fact] - public async Task HandleRelayWebhookAsync_ShouldReturnParseError_ForInvalidJson() - { - var context = new DefaultHttpContext(); - context.Request.ContentType = "application/json"; - context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{ invalid")); - var result = await InvokeResultAsync( - "HandleRelayWebhookAsync", - context, - new StubActorRuntime(), - new StubSubscriptionProvider(), - new NyxIdChatActorStore(), - new NyxIdRelayOptions(), - NullLoggerFactory.Instance, - CancellationToken.None); - - var response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status200OK); - response.Body.Should().Contain("couldn't understand"); - } - - [Fact] - public async Task HandleRelayWebhookAsync_ShouldRejectMissingText() - { - var payload = """{"content":{}}"""; - var context = new DefaultHttpContext(); - context.Request.ContentType = "application/json"; - context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(payload)); - var result = await InvokeResultAsync( - "HandleRelayWebhookAsync", - context, - new StubActorRuntime(), - new StubSubscriptionProvider(), - new NyxIdChatActorStore(), - new NyxIdRelayOptions(), - NullLoggerFactory.Instance, - CancellationToken.None); - - var response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status200OK); - response.Body.Should().Contain("empty message"); - } - - [Fact] - public async Task HandleRelayWebhookAsync_ShouldRejectWhenUserTokenMissing() - { - var payload = """{"content":{"text":"hello"}}"""; - var context = new DefaultHttpContext(); - context.Request.ContentType = "application/json"; - context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(payload)); - context.RequestServices = new ServiceCollection() - .AddLogging() - .BuildServiceProvider(); - - var result = await InvokeResultAsync( - "HandleRelayWebhookAsync", - context, - new StubActorRuntime(), - new StubSubscriptionProvider(), - new NyxIdChatActorStore(), - new NyxIdRelayOptions(), - NullLoggerFactory.Instance, - CancellationToken.None); - - var response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status200OK); - response.Body.Should().Contain("Authentication is not configured properly"); - } - - [Fact] - public async Task HandleRelayWebhookAsync_ShouldReturnPartialResponse_WhenTimedOut() - { - var payload = """ - { - "message_id":"msg-1", - "platform":"slack", - "agent":{"api_key_id":"scope-a"}, - "conversation":{"platform_id":"room-1"}, - "content":{"text":"hello"} - } - """; - var context = new DefaultHttpContext - { - RequestServices = new ServiceCollection() - .AddLogging() - .BuildServiceProvider(), - }; - context.Request.ContentType = "application/json"; - context.Request.Headers["X-NyxID-User-Token"] = "not-a-jwt"; - context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(payload)); - - var runtime = new StubActorRuntime(); - var subscriptions = new StubSubscriptionProvider - { - Messages = - { - new EventEnvelope { Payload = Any.Pack(new TextMessageContentEvent { Delta = "partial reply" }) }, - }, - }; - var store = new NyxIdChatActorStore(); - - var result = await InvokeResultAsync( - "HandleRelayWebhookAsync", - context, - runtime, - subscriptions, - store, - new NyxIdRelayOptions { ResponseTimeoutSeconds = 0 }, - NullLoggerFactory.Instance, - CancellationToken.None); - - var response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status200OK); - response.Body.Should().Contain("partial reply"); - (await store.ListActorsAsync("scope-a")).Should().ContainSingle("nyxid-relay-slack-room-1"); - } - - [Fact] - public async Task HandleRelayWebhookAsync_ShouldClassifyError_AndAppendDiagnostics() - { - var payload = """ - { - "message_id":"msg-2", - "platform":"discord", - "agent":{"api_key_id":"scope-b"}, - "conversation":{"id":"conv-1","platform_id":"room-2"}, - "content":{"text":"hello"} - } - """; - var context = new DefaultHttpContext - { - RequestServices = new ServiceCollection() - .AddLogging() - .AddSingleton(new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Aevatar:NyxId:DefaultModel"] = "server-fallback", - }) - .Build()) - .BuildServiceProvider(), - }; - context.Request.ContentType = "application/json"; - context.Request.Headers["X-NyxID-User-Token"] = "not-a-jwt"; - context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(payload)); - - var result = await InvokeResultAsync( - "HandleRelayWebhookAsync", - context, - new StubActorRuntime(), - new StubSubscriptionProvider - { - Messages = - { - new EventEnvelope - { - Payload = Any.Pack(new TextMessageEndEvent - { - Content = "[[AEVATAR_LLM_ERROR]]request failed with 403", - }), - }, - }, - }, - new NyxIdChatActorStore(), - new NyxIdRelayOptions - { - ResponseTimeoutSeconds = 1, - EnableDebugDiagnostics = true, - }, - NullLoggerFactory.Instance, - CancellationToken.None); - - var response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status200OK); - response.Body.Should().Contain("403 Forbidden"); - response.Body.Should().Contain("[Debug]"); - response.Body.Should().Contain("Route: gateway"); - response.Body.Should().Contain("Token: present"); - response.Body.Should().Contain("Scope: scope-b"); - } - - [Fact] - public void ExtractBearerToken_ShouldParseBearerHeaderAndIgnoreOthers() - { - var context = new DefaultHttpContext(); - var method = EndpointsType.GetMethod("ExtractBearerToken", BindingFlags.NonPublic | BindingFlags.Static)!; - - context.Request.Headers.Authorization = "Basic abc"; - method.Invoke(null, [context]).Should().BeNull(); - - context.Request.Headers.Authorization = "Bearer my-token"; - method.Invoke(null, [context]).Should().Be("my-token"); - } - - [Fact] - public void ComputeTokenHash_ShouldBeDeterministicShortLowercaseHex() - { - var method = EndpointsType.GetMethod("ComputeTokenHash", BindingFlags.NonPublic | BindingFlags.Static)!; - var first = method.Invoke(null, ["abc"])!.Should().NotBeNull().And.BeOfType().Subject; - var second = method.Invoke(null, ["abc"])!.Should().NotBeNull().And.BeOfType().Subject; - - first.Should().Be(second); - first.Length.Should().Be(16); - first.Should().MatchRegex("^[a-f0-9]{16}$"); - } - - [Fact] - public void BuildConnectedServicesContext_ShouldRenderServiceHintsAndFallbackMessage() - { - var method = EndpointsType.GetMethod("BuildConnectedServicesContext", BindingFlags.NonPublic | BindingFlags.Static)!; - - var arrayPayload = """ - [ - {"slug":"calendar","label":"Calendar","base_url":"https://api.example.com"} - ] - """; - var arrayContext = (string)method.Invoke(null, [arrayPayload])!; - arrayContext.Should().Contain("calendar"); - arrayContext.Should().Contain("Use nyxid_proxy"); - - var emptyContext = (string)method.Invoke(null, ["""{"services":[]}"""])!; - emptyContext.Should().Contain("No services connected yet"); - } - - [Fact] - public void BuildConnectedServicesContext_ShouldHandleDataShape_AndInvalidJson() - { - var method = EndpointsType.GetMethod("BuildConnectedServicesContext", BindingFlags.NonPublic | BindingFlags.Static)!; - - var dataPayload = """ - { - "data":[ - {"slug":"github","name":"GitHub","endpoint_url":"https://api.github.com"} - ] - } - """; - var dataContext = (string)method.Invoke(null, [dataPayload])!; - dataContext.Should().Contain("GitHub"); - dataContext.Should().Contain("https://api.github.com"); - - var invalidContext = (string)method.Invoke(null, ["{ invalid"])!; - invalidContext.Should().Contain("No services connected yet"); - invalidContext.Should().Contain("Use nyxid_proxy"); - } - - [Fact] - public void ClassifyError_ShouldMapKnownCodePatterns() - { - var method = EndpointsType.GetMethod("ClassifyError", BindingFlags.NonPublic | BindingFlags.Static)!; - - method.Invoke(null, ["request failed with 403"])!.Should().Be( - "Sorry, I can't reach the AI service right now (403 Forbidden)."); - method.Invoke(null, ["status=401 unauthorized"])!.Should().Be( - "Sorry, authentication with the AI service failed (401)."); - method.Invoke(null, ["service rate limit reached"])!.Should().Be( - "Sorry, the AI service is busy right now (429). Please wait a moment and try again."); - method.Invoke(null, ["LLM request timeout"])!.Should().Be( - "Sorry, the AI service took too long to respond. Please try again."); - method.Invoke(null, ["model `gpt-5` not found"])!.Should().Be( - "Sorry, the configured AI model is not available."); - method.Invoke(null, ["unknown issue"])!.Should().Be( - "Sorry, something went wrong while generating a response."); - } - - [Fact] - public void BuildRelayDiagnostic_ShouldUseServerDefaultsAndTokenFlag() - { - var method = EndpointsType.GetMethod("BuildRelayDiagnostic", BindingFlags.NonPublic | BindingFlags.Static)!; - var metadata = new MapField - { - [LLMRequestMetadataKeys.NyxIdRoutePreference] = "direct", - [LLMRequestMetadataKeys.ModelOverride] = "deepseek-chat", - [AevatarStandardClaimTypes.ScopeId] = "scope-a", - [LLMRequestMetadataKeys.NyxIdAccessToken] = "secret", - }; - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Aevatar:NyxId:DefaultModel"] = "fallback-model", - }) - .Build(); - - var diag = method.Invoke(null, [metadata, configuration, "LLM request failed: timeout"])!.Should() - .NotBeNull() - .And.BeOfType() - .Subject; - - diag.Should().Contain("Model: deepseek-chat (from config.json)"); - diag.Should().Contain("Route: direct"); - diag.Should().Contain("Scope: scope-a"); - diag.Should().Contain("Token: present"); - diag.Should().Contain("timeout"); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task MapAndWriteEventAsync_ShouldSerializePayloads(bool hasError) - { - var envelope = hasError - ? new EventEnvelope { Payload = Any.Pack(new TextMessageEndEvent { Content = hasError ? "[[AEVATAR_LLM_ERROR]]boom" : string.Empty }) } - : new EventEnvelope { Payload = Any.Pack(new TextMessageStartEvent()) }; - var context = new DefaultHttpContext(); - context.Response.Body = new MemoryStream(); - - var writer = AgentCoverageTestSupport.CreateNonPublicInstance( - typeof(NyxIdChatEndpoints).Assembly, - "Aevatar.GAgents.NyxidChat.NyxIdChatSseWriter", - context.Response); - - var method = EndpointsType.GetMethod("MapAndWriteEventAsync", BindingFlags.NonPublic | BindingFlags.Static)!; - var terminal = await InvokeValueTaskAsync(method, envelope, "m-1", writer); - - context.Response.Body.Position = 0; - var body = await new StreamReader(context.Response.Body).ReadToEndAsync(); - if (hasError) - { - terminal.Should().Be("RUN_ERROR"); - body.Should().Contain("RUN_ERROR"); - } - else - { - terminal.Should().BeNull(); - body.Should().Contain("TEXT_MESSAGE_START"); - } - } - - [Fact] - public async Task MapAndWriteEventAsync_ShouldSerializeContentToolingMediaAndNormalEnd() - { - var context = new DefaultHttpContext(); - context.Response.Body = new MemoryStream(); - - var writer = AgentCoverageTestSupport.CreateNonPublicInstance( - typeof(NyxIdChatEndpoints).Assembly, - "Aevatar.GAgents.NyxidChat.NyxIdChatSseWriter", - context.Response); - - var method = EndpointsType.GetMethod("MapAndWriteEventAsync", BindingFlags.NonPublic | BindingFlags.Static)!; - - (await InvokeValueTaskAsync( - method, - new EventEnvelope { Payload = Any.Pack(new TextMessageContentEvent { Delta = "delta-1" }) }, - "m-2", - writer)).Should().BeNull(); - (await InvokeValueTaskAsync( - method, - new EventEnvelope - { - Payload = Any.Pack(new ToolCallEvent - { - ToolName = "search", - CallId = "call-1", - ArgumentsJson = "{\"q\":\"abc\"}", - }), - }, - "m-2", - writer)).Should().BeNull(); - (await InvokeValueTaskAsync( - method, - new EventEnvelope - { - Payload = Any.Pack(new ToolResultEvent - { - CallId = "call-1", - ResultJson = "{\"ok\":true}", - Success = true, - Error = string.Empty, - }), - }, - "m-2", - writer)).Should().BeNull(); - (await InvokeValueTaskAsync( - method, - new EventEnvelope - { - Payload = Any.Pack(new ToolApprovalRequestEvent - { - RequestId = "req-1", - SessionId = "s1", - ToolName = "connector.run", - ToolCallId = "call-1", - ArgumentsJson = "{}", - IsDestructive = true, - TimeoutSeconds = 30, - }), - }, - "m-2", - writer)).Should().BeNull(); - (await InvokeValueTaskAsync( - method, - new EventEnvelope - { - Payload = Any.Pack(new MediaContentEvent - { - SessionId = "session-1", - AgentId = "agent-1", - Part = new ChatContentPart - { - Kind = ChatContentPartKind.Image, - Uri = "https://example.com/cat.png", - MediaType = "image/png", - Name = "cat", - }, - }), - }, - "m-2", - writer)).Should().BeNull(); - (await InvokeValueTaskAsync( - method, - new EventEnvelope - { - Payload = Any.Pack(new MediaContentEvent - { - SessionId = "session-1", - AgentId = "agent-1", - Part = new ChatContentPart - { - Kind = ChatContentPartKind.Audio, - DataBase64 = "YXVkaW8=", - MediaType = "audio/mpeg", - Name = "clip", - }, - }), - }, - "m-2", - writer)).Should().BeNull(); - (await InvokeValueTaskAsync( - method, - new EventEnvelope - { - Payload = Any.Pack(new MediaContentEvent - { - SessionId = "session-1", - AgentId = "agent-1", - Part = new ChatContentPart - { - Kind = ChatContentPartKind.Video, - Uri = "https://example.com/video.mp4", - MediaType = "video/mp4", - Name = "clip-video", - }, - }), - }, - "m-2", - writer)).Should().BeNull(); - (await InvokeValueTaskAsync( - method, - new EventEnvelope - { - Payload = Any.Pack(new MediaContentEvent - { - SessionId = "session-1", - AgentId = "agent-1", - Part = new ChatContentPart - { - Kind = ChatContentPartKind.Text, - Text = "inline note", - }, - }), - }, - "m-2", - writer)).Should().BeNull(); - (await InvokeValueTaskAsync( - method, - new EventEnvelope - { - Payload = Any.Pack(new MediaContentEvent - { - SessionId = "session-1", - AgentId = "agent-1", - Part = new ChatContentPart - { - Kind = ChatContentPartKind.Unspecified, - Name = "mystery", - }, - }), - }, - "m-2", - writer)).Should().BeNull(); - (await InvokeValueTaskAsync( - method, - new EventEnvelope - { - Payload = Any.Pack(new MediaContentEvent - { - SessionId = "session-1", - AgentId = "agent-1", - }), - }, - "m-2", - writer)).Should().BeNull(); - (await InvokeValueTaskAsync( - method, - new EventEnvelope { Payload = Any.Pack(new TextMessageEndEvent { Content = "done" }) }, - "m-2", - writer)).Should().Be("TEXT_MESSAGE_END"); - - context.Response.Body.Position = 0; - var body = await new StreamReader(context.Response.Body).ReadToEndAsync(); - body.Should().Contain("delta-1"); - body.Should().Contain("search"); - body.Should().Contain("call-1"); - body.Should().Contain("req-1"); - body.Should().Contain("cat.png"); - body.Should().Contain("\"kind\":\"audio\""); - body.Should().Contain("\"kind\":\"video\""); - body.Should().Contain("\"kind\":\"text\""); - body.Should().Contain("\"kind\":\"unknown\""); - body.Should().Contain("TEXT_MESSAGE_END"); - } - - private static async Task InvokeResultAsync(string methodName, params object[] args) - { - var method = EndpointsType.GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Static)!; - var result = method.Invoke(null, args); - return result switch - { - Task task => await task, - ValueTask valueTask => await valueTask, - _ => throw new InvalidOperationException($"Unexpected return type: {result?.GetType().FullName}"), - }; - } - - private static async Task InvokeTaskAsync(string methodName, params object[] args) - { - var method = EndpointsType.GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Static)!; - var result = method.Invoke(null, args)!; - switch (result) - { - case ValueTask valueTask: - await valueTask; - return; - case Task task: - await task; - return; - default: - throw new InvalidOperationException($"Unexpected return type: {result.GetType().FullName}"); - } - } - - private static async Task InvokeValueTaskAsync(MethodInfo method, params object[] args) - { - var result = method.Invoke(null, args)!; - return result switch - { - ValueTask task => await task, - Task task => await task, - _ => throw new InvalidOperationException($"Unexpected return type: {result.GetType().FullName}"), - }; - } - - private static async Task<(int StatusCode, string Body)> ExecuteResultAsync(IResult result) - { - var context = new DefaultHttpContext - { - RequestServices = new ServiceCollection() - .AddLogging() - .BuildServiceProvider(), - }; - await using var body = new MemoryStream(); - context.Response.Body = body; - - await result.ExecuteAsync(context); - context.Response.Body.Position = 0; - return (context.Response.StatusCode, await new StreamReader(context.Response.Body).ReadToEndAsync()); - } - - private sealed class StubActorRuntime : IActorRuntime - { - public Dictionary Actors { get; } = []; - public List<(System.Type Type, string? Id)> CreateCalls { get; } = []; - - public Task GetAsync(string id) => Task.FromResult(Actors.GetValueOrDefault(id)); - - public Task CreateAsync(string? id = null, CancellationToken ct = default) - where TAgent : IAgent => CreateAsync(typeof(TAgent), id, ct); - - public Task CreateAsync(System.Type agentType, string? id = null, CancellationToken ct = default) - { - var actor = new StubActor(id ?? Guid.NewGuid().ToString("N")); - Actors[id ?? actor.Id] = actor; - CreateCalls.Add((agentType, id)); - return Task.FromResult(actor); - } - - public Task DestroyAsync(string id, CancellationToken ct = default) => Task.CompletedTask; - public Task ExistsAsync(string id) => Task.FromResult(Actors.ContainsKey(id)); - public Task LinkAsync(string parentId, string childId, CancellationToken ct = default) => Task.CompletedTask; - public Task UnlinkAsync(string childId, CancellationToken ct = default) => Task.CompletedTask; - } - - private sealed class StubActor : IActor - { - public StubActor(string id) => Id = id; - - public string Id { get; } - public IAgent Agent { get; } = new StubAgent(); - public List HandledEnvelopes { get; } = []; - public Task ActivateAsync(CancellationToken ct = default) => Task.CompletedTask; - public Task DeactivateAsync(CancellationToken ct = default) => Task.CompletedTask; - public Task HandleEventAsync(EventEnvelope envelope, CancellationToken ct = default) - { - HandledEnvelopes.Add(envelope); - return Task.CompletedTask; - } - public Task GetParentIdAsync() => Task.FromResult(null); - public Task> GetChildrenIdsAsync() => Task.FromResult>([]); - } - - private sealed class StubAgent : IAgent - { - public string Id => "agent"; - public Task HandleEventAsync(EventEnvelope envelope, CancellationToken ct = default) => Task.CompletedTask; - public Task GetDescriptionAsync() => Task.FromResult("stub"); - public Task> GetSubscribedEventTypesAsync() => Task.FromResult>([]); - public Task ActivateAsync(CancellationToken ct = default) => Task.CompletedTask; - public Task DeactivateAsync(CancellationToken ct = default) => Task.CompletedTask; - } - - private sealed class StubSubscriptionProvider : IActorEventSubscriptionProvider - { - public List Messages { get; } = []; - - public Task SubscribeAsync( - string actorId, - Func handler, - CancellationToken ct = default) - where TMessage : class, IMessage, new() - { - _ = actorId; - _ = ct; - if (typeof(TMessage) == typeof(EventEnvelope)) - { - foreach (var message in Messages) - handler((TMessage)(object)message).GetAwaiter().GetResult(); - } - return Task.FromResult(new NoopDisposable()); - } - } - - private sealed class ThrowingSubscriptionProvider(Exception exception) : IActorEventSubscriptionProvider - { - public Task SubscribeAsync( - string actorId, - Func handler, - CancellationToken ct = default) - where TMessage : class, IMessage, new() - { - _ = actorId; - _ = handler; - _ = ct; - throw exception; - } - } - - private sealed class ThrowingActorRuntime(Exception exception) : IActorRuntime - { - public Task GetAsync(string id) - { - _ = id; - throw exception; - } - - public Task CreateAsync(string? id = null, CancellationToken ct = default) - where TAgent : IAgent => - throw exception; - - public Task CreateAsync(System.Type agentType, string? id = null, CancellationToken ct = default) - { - _ = agentType; - _ = id; - _ = ct; - throw exception; - } - - public Task DestroyAsync(string id, CancellationToken ct = default) => Task.CompletedTask; - public Task ExistsAsync(string id) => Task.FromResult(false); - public Task LinkAsync(string parentId, string childId, CancellationToken ct = default) => Task.CompletedTask; - public Task UnlinkAsync(string childId, CancellationToken ct = default) => Task.CompletedTask; - } - - private sealed class StubPreferencesStore(string model, string route, int maxToolRounds) : INyxIdUserLlmPreferencesStore - { - public Task GetAsync(CancellationToken cancellationToken = default) => - Task.FromResult(new NyxIdUserLlmPreferences(model, route, maxToolRounds)); - } - - private sealed class StubUserMemoryStore(string promptSection) : IUserMemoryStore - { - public Task GetAsync(CancellationToken ct = default) => - Task.FromResult(UserMemoryDocument.Empty); - - public Task SaveAsync(UserMemoryDocument document, CancellationToken ct = default) => Task.CompletedTask; - - public Task AddEntryAsync(string category, string content, string source, CancellationToken ct = default) => - Task.FromResult(new UserMemoryEntry("id", category, content, source, 0, 0)); - - public Task RemoveEntryAsync(string id, CancellationToken ct = default) => Task.FromResult(true); - - public Task BuildPromptSectionAsync(int maxChars = 2000, CancellationToken ct = default) => - Task.FromResult(promptSection); - } - - private sealed class NoopDisposable : IAsyncDisposable - { - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - } -} diff --git a/test/Aevatar.AI.Tests/NyxIdChatSupportCoverageTests.cs b/test/Aevatar.AI.Tests/NyxIdChatSupportCoverageTests.cs deleted file mode 100644 index d205f4fd..00000000 --- a/test/Aevatar.AI.Tests/NyxIdChatSupportCoverageTests.cs +++ /dev/null @@ -1,116 +0,0 @@ -using System.IO; -using Aevatar.AI.Abstractions; -using Aevatar.GAgents.NyxidChat; -using FluentAssertions; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; - -namespace Aevatar.AI.Tests; - -public class NyxIdChatSupportCoverageTests -{ - [Fact] - public async Task ActorStore_ShouldNormalizeScopes_AndKeepEnsureIdempotent() - { - var store = new NyxIdChatActorStore(); - - await store.EnsureActorAsync(" Scope-A ", "actor-1"); - await store.EnsureActorAsync("scope-a", "actor-1"); - var created = await store.CreateActorAsync("SCOPE-A"); - - var listed = await store.ListActorsAsync("scope-a"); - - listed.Should().HaveCount(2); - listed.Select(x => x.ActorId).Should().Contain(["actor-1", created.ActorId]); - - (await store.DeleteActorAsync("scope-a", "actor-1")).Should().BeTrue(); - (await store.DeleteActorAsync("scope-a", "actor-1")).Should().BeFalse(); - (await store.ListActorsAsync("scope-a")).Should().ContainSingle(x => x.ActorId == created.ActorId); - } - - [Fact] - public void AddNyxIdChat_ShouldRegisterSingletonStore_AndBindRelayOptions() - { - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Aevatar:NyxId:Relay:ResponseTimeoutSeconds"] = "45", - ["Aevatar:NyxId:Relay:EnableDebugDiagnostics"] = "true", - }) - .Build(); - - var provider = new ServiceCollection() - .AddNyxIdChat(configuration) - .BuildServiceProvider(); - - var store1 = provider.GetRequiredService(); - var store2 = provider.GetRequiredService(); - var options = provider.GetRequiredService(); - - store1.Should().BeSameAs(store2); - options.ResponseTimeoutSeconds.Should().Be(45); - options.EnableDebugDiagnostics.Should().BeTrue(); - } - - [Fact] - public void GenerateActorId_ShouldUseStablePrefix_AndProduceUniqueValues() - { - var first = NyxIdChatServiceDefaults.GenerateActorId(); - var second = NyxIdChatServiceDefaults.GenerateActorId(); - - first.Should().StartWith($"{NyxIdChatServiceDefaults.ActorIdPrefix}-"); - second.Should().StartWith($"{NyxIdChatServiceDefaults.ActorIdPrefix}-"); - first.Should().NotBe(second); - } - - [Fact] - public async Task NyxIdChatSseWriter_ShouldStartStream_AndSerializeFrames() - { - var context = new DefaultHttpContext(); - context.Response.Body = new MemoryStream(); - - var writer = AgentCoverageTestSupport.CreateNonPublicInstance( - typeof(NyxIdChatGAgent).Assembly, - "Aevatar.GAgents.NyxidChat.NyxIdChatSseWriter", - context.Response); - - await AgentCoverageTestSupport.InvokeAsync(writer, "WriteTextStartAsync", "msg-1", CancellationToken.None); - await AgentCoverageTestSupport.InvokeAsync(writer, "WriteTextDeltaAsync", "hello", CancellationToken.None); - await AgentCoverageTestSupport.InvokeAsync( - writer, - "WriteMediaContentAsync", - new MediaContentEvent - { - Part = new ChatContentPart - { - Kind = ChatContentPartKind.Image, - DataBase64 = "AQID", - MediaType = "image/png", - Name = "preview", - } - }, - CancellationToken.None); - await AgentCoverageTestSupport.InvokeAsync( - writer, - "WriteToolApprovalRequestAsync", - "req-1", - "connector.run", - "call-1", - """{"slug":"telegram"}""", - true, - 30, - CancellationToken.None); - - AgentCoverageTestSupport.GetBooleanProperty(writer, "Started").Should().BeTrue(); - context.Response.StatusCode.Should().Be(StatusCodes.Status200OK); - context.Response.Headers.ContentType.ToString().Should().Be("text/event-stream; charset=utf-8"); - context.Response.Body.Position = 0; - var body = await new StreamReader(context.Response.Body).ReadToEndAsync(); - body.Should().Contain("TEXT_MESSAGE_START"); - body.Should().Contain("TEXT_MESSAGE_CONTENT"); - body.Should().Contain("MEDIA_CONTENT"); - body.Should().Contain("\"kind\":\"image\""); - body.Should().Contain("TOOL_APPROVAL_REQUEST"); - } -} diff --git a/test/Aevatar.AI.Tests/StreamingProxyCoverageTests.cs b/test/Aevatar.AI.Tests/StreamingProxyCoverageTests.cs deleted file mode 100644 index 99888d6b..00000000 --- a/test/Aevatar.AI.Tests/StreamingProxyCoverageTests.cs +++ /dev/null @@ -1,569 +0,0 @@ -using System.IO; -using System.Reflection; -using Aevatar.Foundation.Abstractions; -using Aevatar.AI.Abstractions; -using Aevatar.Foundation.Core.EventSourcing; -using Aevatar.Foundation.Abstractions.Streaming; -using Google.Protobuf; -using Any = Google.Protobuf.WellKnownTypes.Any; -using Aevatar.GAgents.StreamingProxy; -using FluentAssertions; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using static Aevatar.GAgents.StreamingProxy.StreamingProxyEndpoints; - -namespace Aevatar.AI.Tests; - -public class StreamingProxyCoverageTests -{ - [Fact] - public void AddStreamingProxy_ShouldRegisterSingletonStore() - { - var provider = new ServiceCollection() - .AddStreamingProxy() - .BuildServiceProvider(); - - var first = provider.GetRequiredService(); - var second = provider.GetRequiredService(); - - first.Should().BeSameAs(second); - } - - [Fact] - public void MapStreamingProxyEndpoints_ShouldRegisterExpectedRoutes() - { - var builder = WebApplication.CreateBuilder(new WebApplicationOptions - { - EnvironmentName = Environments.Development, - }); - var app = builder.Build(); - var routeBuilder = (IEndpointRouteBuilder)app; - - app.MapStreamingProxyEndpoints(); - - var routes = routeBuilder.DataSources - .SelectMany(x => x.Endpoints) - .OfType() - .Select(x => x.RoutePattern.RawText) - .ToHashSet(StringComparer.Ordinal); - - routes.Should().Contain("/api/scopes/{scopeId}/streaming-proxy/rooms"); - routes.Should().Contain("/api/scopes/{scopeId}/streaming-proxy/rooms/{roomId}:chat"); - routes.Should().Contain("/api/scopes/{scopeId}/streaming-proxy/rooms/{roomId}/messages"); - routes.Should().Contain("/api/scopes/{scopeId}/streaming-proxy/rooms/{roomId}/messages:stream"); - routes.Should().Contain("/api/scopes/{scopeId}/streaming-proxy/rooms/{roomId}/participants"); - } - - [Fact] - public async Task HandleCreateRoomAsync_ShouldCreateRoomAndInitActor() - { - var store = new StreamingProxyActorStore(); - var runtime = new StubActorRuntime(); - var request = new CreateRoomRequest("Project X"); - - var result = await InvokeResultAsync( - "HandleCreateRoomAsync", - new DefaultHttpContext(), - "scope-a", - request, - store, - runtime, - CancellationToken.None); - - var response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status200OK); - response.Body.Should().Contain("roomName"); - (await store.ListRoomsAsync("scope-a")).Should().HaveCount(1); - runtime.CreateCalls.Should().ContainSingle(); - runtime.CreateCalls[0].agentType.Should().Be(typeof(StreamingProxyGAgent)); - } - - [Fact] - public async Task HandleListRoomsAsync_ShouldReturnRoomsForScope() - { - var store = new StreamingProxyActorStore(); - var room = await store.CreateRoomAsync("scope-a", "Room One"); - - var result = await InvokeResultAsync( - "HandleListRoomsAsync", - new DefaultHttpContext(), - "scope-a", - store, - CancellationToken.None); - - var response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status200OK); - response.Body.Should().Contain(room.RoomId); - } - - [Fact] - public async Task HandleDeleteRoomAsync_ShouldReturnNotFound_WhenMissing() - { - var result = await InvokeResultAsync( - "HandleDeleteRoomAsync", - new DefaultHttpContext(), - "scope-a", - "missing", - new StreamingProxyActorStore(), - CancellationToken.None); - - var response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status404NotFound); - } - - [Fact] - public async Task HandleChatAsync_ShouldRejectEmptyPrompt() - { - var context = new DefaultHttpContext(); - var runtime = new StubActorRuntime(); - var subscriptions = new StubSubscriptionProvider(); - - var method = typeof(StreamingProxyEndpoints).GetMethod( - "HandleChatAsync", - BindingFlags.NonPublic | BindingFlags.Static)!; - var task = method.Invoke(null, [context, "scope-a", "room-a", new ChatTopicRequest(null), runtime, subscriptions, NullLoggerFactory.Instance, CancellationToken.None]); - await InvokeTaskAsync(task); - - context.Response.StatusCode.Should().Be(StatusCodes.Status400BadRequest); - } - - [Fact] - public async Task HandleMessageStreamAsync_ShouldRejectMissingRoom() - { - var context = new DefaultHttpContext(); - var runtime = new StubActorRuntime(); - var method = typeof(StreamingProxyEndpoints).GetMethod( - "HandleMessageStreamAsync", - BindingFlags.NonPublic | BindingFlags.Static)!; - - var task = method.Invoke( - null, - [context, "scope-a", "missing", runtime, new StubSubscriptionProvider(), NullLoggerFactory.Instance, CancellationToken.None]); - await InvokeTaskAsync(task); - - context.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound); - } - - [Fact] - public async Task HandlePostMessageAsync_ShouldRejectMissingFieldsAndReturnAccepted() - { - var result = await InvokeResultAsync( - "HandlePostMessageAsync", - new DefaultHttpContext(), - "scope-a", - "room-a", - new PostMessageRequest(null, "name", "content"), - new StubActorRuntime(), - CancellationToken.None); - - var response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status400BadRequest); - - result = await InvokeResultAsync( - "HandlePostMessageAsync", - new DefaultHttpContext(), - "scope-a", - "missing-room", - new PostMessageRequest("agent", null, "content"), - new StubActorRuntime(), - CancellationToken.None); - - response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status404NotFound); - - var runtime = new StubActorRuntime(new List { new StubActor("room-a") }); - result = await InvokeResultAsync( - "HandlePostMessageAsync", - new DefaultHttpContext(), - "scope-a", - "room-a", - new PostMessageRequest("agent", null, "content"), - runtime, - CancellationToken.None); - - response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status200OK); - ((StubActor)runtime.Actors["room-a"]).HandleEventCalls.Should().Be(1); - } - - [Fact] - public async Task HandleJoinAsync_ShouldRejectMissingAgentIdAndAddParticipant() - { - var store = new StreamingProxyActorStore(); - var runtime = new StubActorRuntime(new List { new StubActor("room-a") }); - - var result = await InvokeResultAsync( - "HandleJoinAsync", - new DefaultHttpContext(), - "scope-a", - "room-a", - new JoinRoomRequest(null, null), - runtime, - store, - CancellationToken.None); - - var response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status400BadRequest); - - var joinRequest = new JoinRoomRequest("agent-1", "Alice"); - result = await InvokeResultAsync( - "HandleJoinAsync", - new DefaultHttpContext(), - "scope-a", - "room-a", - joinRequest, - runtime, - store, - CancellationToken.None); - - response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status200OK); - store.ListParticipants("scope-a", "room-a").Should().ContainSingle(x => x.AgentId == "agent-1"); - ((StubActor)runtime.Actors["room-a"]).HandleEventCalls.Should().Be(1); - } - - [Fact] - public async Task MapAndWriteEventAsync_ShouldWriteTopicAndAgentFrames() - { - var context = new DefaultHttpContext(); - context.Response.Body = new MemoryStream(); - var writer = AgentCoverageTestSupport.CreateNonPublicInstance( - typeof(StreamingProxyGAgent).Assembly, - "Aevatar.GAgents.StreamingProxy.StreamingProxySseWriter", - context.Response); - - var method = typeof(StreamingProxyEndpoints).GetMethod( - "MapAndWriteEventAsync", - BindingFlags.NonPublic | BindingFlags.Static)!; - var methodCalls = new[] - { - new EventEnvelope { Payload = Any.Pack(new GroupChatTopicEvent { Prompt = "topic", SessionId = "s1" }) }, - new EventEnvelope { Payload = Any.Pack(new GroupChatMessageEvent { AgentId = "a1", AgentName = "A1", Content = "hi", SessionId = "s1" }) }, - new EventEnvelope { Payload = Any.Pack(new GroupChatParticipantJoinedEvent { AgentId = "a1", DisplayName = "A1" }) }, - new EventEnvelope { Payload = Any.Pack(new GroupChatParticipantLeftEvent { AgentId = "a1" }) }, - }; - - foreach (var envelope in methodCalls) - { - var result = method.Invoke(null, [envelope, writer])!; - switch (result) - { - case ValueTask valueTask: - await valueTask; - break; - case Task task: - await task; - break; - default: - break; - } - } - - context.Response.Body.Position = 0; - var body = new StreamReader(context.Response.Body).ReadToEnd(); - body.Should().Contain("TOPIC_STARTED"); - body.Should().Contain("AGENT_MESSAGE"); - body.Should().Contain("PARTICIPANT_JOINED"); - body.Should().Contain("PARTICIPANT_LEFT"); - } - - [Fact] - public async Task HandleListParticipantsAsync_ShouldReturnStoreParticipants() - { - var store = new StreamingProxyActorStore(); - store.AddParticipant("scope-a", "room-a", "agent-1", "Alice"); - - var result = await InvokeResultAsync( - "HandleListParticipantsAsync", - new DefaultHttpContext(), - "scope-a", - "room-a", - store, - CancellationToken.None); - - var response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status200OK); - response.Body.Should().Contain("Alice"); - } - - [Fact] - public async Task ActorStore_ShouldNormalizeScopes_ApplyDefaultRoomName_AndReplaceParticipants() - { - var store = new StreamingProxyActorStore(); - - store.ListParticipants("scope-a", "room-1").Should().BeEmpty(); - - var room = await store.CreateRoomAsync(" Scope-A ", roomName: null); - room.RoomName.Should().Be("Group Chat"); - - var rooms = await store.ListRoomsAsync("scope-a"); - rooms.Should().ContainSingle(x => x.RoomId == room.RoomId); - - store.AddParticipant("scope-a", room.RoomId, "agent-1", "Alice"); - store.AddParticipant("scope-a", room.RoomId, "agent-1", "Alice Updated"); - - var participants = store.ListParticipants("SCOPE-A", room.RoomId); - participants.Should().ContainSingle(); - participants[0].DisplayName.Should().Be("Alice Updated"); - - (await store.DeleteRoomAsync("scope-a", room.RoomId)).Should().BeTrue(); - (await store.DeleteRoomAsync("scope-a", room.RoomId)).Should().BeFalse(); - } - - [Fact] - public async Task GAgent_ShouldTrackRoomMessagesAndParticipantLifecycle() - { - using var provider = AgentCoverageTestSupport.BuildServiceProvider(); - var agent = CreateAgent(provider, "streaming-proxy-agent"); - var publisher = new TestRecordingEventPublisher(); - agent.EventPublisher = publisher; - - await agent.ActivateAsync(); - await agent.HandleGroupChatRoomInitialized(new GroupChatRoomInitializedEvent { RoomName = "Nyx Room" }); - await agent.HandleGroupChatParticipantJoined(new GroupChatParticipantJoinedEvent - { - AgentId = "agent-1", - DisplayName = "Alice", - }); - await agent.HandleGroupChatParticipantJoined(new GroupChatParticipantJoinedEvent - { - AgentId = "agent-1", - DisplayName = "Alice Updated", - }); - await agent.HandleChatRequest(new ChatRequestEvent - { - Prompt = "Discuss the webhook setup", - SessionId = "room-session", - }); - await agent.HandleGroupChatMessage(new GroupChatMessageEvent - { - AgentId = "agent-2", - AgentName = "Bob", - Content = "I can help with that.", - SessionId = "room-session", - }); - await agent.HandleGroupChatParticipantLeft(new GroupChatParticipantLeftEvent { AgentId = "agent-1" }); - - var state = AgentCoverageTestSupport.ReadPrivateField(agent, "_proxyState"); - state.RoomName.Should().Be("Nyx Room"); - state.NextSequence.Should().Be(2); - state.Messages.Should().HaveCount(2); - state.Messages[0].IsTopic.Should().BeTrue(); - state.Messages[0].SenderAgentId.Should().Be("user"); - state.Messages[0].Content.Should().Be("Discuss the webhook setup"); - state.Messages[1].IsTopic.Should().BeFalse(); - state.Messages[1].SenderAgentId.Should().Be("agent-2"); - state.Messages[1].SenderName.Should().Be("Bob"); - state.Participants.Should().BeEmpty(); - - publisher.Published.OfType().Should().HaveCount(2); - publisher.Published.OfType() - .Should() - .ContainSingle(x => x.Prompt == "Discuss the webhook setup" && x.SessionId == "room-session"); - publisher.Published.OfType() - .Should() - .ContainSingle(x => x.AgentId == "agent-2" && x.Content == "I can help with that."); - publisher.Published.OfType() - .Should() - .ContainSingle(x => x.AgentId == "agent-1"); - } - - [Fact] - public async Task StreamingProxySseWriter_ShouldStartStream_AndSerializeRoomFrames() - { - var context = new DefaultHttpContext(); - context.Response.Body = new MemoryStream(); - var writer = AgentCoverageTestSupport.CreateNonPublicInstance( - typeof(StreamingProxyGAgent).Assembly, - "Aevatar.GAgents.StreamingProxy.StreamingProxySseWriter", - context.Response); - - await AgentCoverageTestSupport.InvokeAsync(writer, "WriteRoomCreatedAsync", "room-1", "Main Room", CancellationToken.None); - await AgentCoverageTestSupport.InvokeAsync(writer, "WriteAgentMessageAsync", "agent-1", "Alice", "hello", 7L, CancellationToken.None); - await AgentCoverageTestSupport.InvokeAsync(writer, "WriteRunErrorAsync", "boom", CancellationToken.None); - - AgentCoverageTestSupport.GetBooleanProperty(writer, "Started").Should().BeTrue(); - context.Response.StatusCode.Should().Be(StatusCodes.Status200OK); - context.Response.Headers.ContentType.ToString().Should().Be("text/event-stream; charset=utf-8"); - context.Response.Body.Position = 0; - var body = await new StreamReader(context.Response.Body).ReadToEndAsync(); - body.Should().Contain("ROOM_CREATED"); - body.Should().Contain("AGENT_MESSAGE"); - body.Should().Contain("\"sequence\":7"); - body.Should().Contain("RUN_ERROR"); - } - - [Fact] - public void GenerateRoomId_ShouldUseStablePrefix_AndProduceUniqueValues() - { - var first = StreamingProxyDefaults.GenerateRoomId(); - var second = StreamingProxyDefaults.GenerateRoomId(); - - first.Should().StartWith($"{StreamingProxyDefaults.ActorIdPrefix}-"); - second.Should().StartWith($"{StreamingProxyDefaults.ActorIdPrefix}-"); - first.Should().NotBe(second); - } - - private static StreamingProxyGAgent CreateAgent(IServiceProvider provider, string actorId) - { - var agent = new StreamingProxyGAgent - { - Services = provider, - EventSourcingBehaviorFactory = provider.GetRequiredService>(), - }; - - AgentCoverageTestSupport.AssignActorId(agent, actorId); - return agent; - } - - private static async Task<(int StatusCode, string Body)> ExecuteResultAsync(IResult result) - { - var context = new DefaultHttpContext - { - RequestServices = new ServiceCollection() - .AddLogging() - .BuildServiceProvider(), - }; - - await using var body = new MemoryStream(); - context.Response.Body = body; - - await result.ExecuteAsync(context); - context.Response.Body.Position = 0; - return (context.Response.StatusCode, await new StreamReader(context.Response.Body).ReadToEndAsync()); - } - - private static async Task InvokeResultAsync(string methodName, params object[] args) - { - var method = typeof(StreamingProxyEndpoints).GetMethod( - methodName, - BindingFlags.NonPublic | BindingFlags.Static)!; - var result = method.Invoke(null, args) - ?? throw new InvalidOperationException($"Method {methodName} returned null."); - - return result switch - { - Task task => await task, - _ => throw new InvalidOperationException($"Unexpected return type: {result.GetType()}"), - }; - } - - private static async Task InvokeTaskAsync(object? result) - { - result.Should().NotBeNull(); - - switch (result) - { - case Task task: - await task; - return; - case ValueTask valueTask: - await valueTask; - return; - default: - throw new InvalidOperationException($"Unexpected return type: {result!.GetType()}"); - } - } - - private sealed class StubActorRuntime : IActorRuntime - { - public StubActorRuntime(IEnumerable? initialActors = null) - { - if (initialActors is not null) - { - foreach (var actor in initialActors) - Actors[actor.Id] = actor; - } - } - - public Dictionary Actors { get; } = []; - - public List<(System.Type agentType, string actorId)> CreateCalls { get; } = []; - - public Task GetAsync(string id) => Task.FromResult(Actors.TryGetValue(id, out var actor) ? actor : null); - - public Task CreateAsync(string? id = null, CancellationToken ct = default) - where TAgent : IAgent => CreateAsync(typeof(TAgent), id, ct); - - public Task CreateAsync(System.Type agentType, string? id = null, CancellationToken ct = default) - { - var actorId = id ?? Guid.NewGuid().ToString("N"); - var actor = new StubActor(actorId); - Actors[actorId] = actor; - CreateCalls.Add((agentType, actorId)); - return Task.FromResult(actor); - } - - public Task DestroyAsync(string id, CancellationToken ct = default) - { - Actors.Remove(id); - return Task.CompletedTask; - } - - public Task ExistsAsync(string id) => Task.FromResult(Actors.ContainsKey(id)); - public Task LinkAsync(string parentId, string childId, CancellationToken ct = default) => Task.CompletedTask; - public Task UnlinkAsync(string childId, CancellationToken ct = default) => Task.CompletedTask; - } - - private sealed class StubActor : IActor - { - public StubActor(string id) => Id = id; - - public int HandleEventCalls { get; private set; } - - public string Id { get; } - - public IAgent Agent => new StubAgent(); - - public Task ActivateAsync(CancellationToken ct = default) => Task.CompletedTask; - public Task DeactivateAsync(CancellationToken ct = default) => Task.CompletedTask; - - public Task HandleEventAsync(EventEnvelope envelope, CancellationToken ct = default) - { - _ = envelope; - _ = ct; - HandleEventCalls++; - return Task.CompletedTask; - } - - public Task GetParentIdAsync() => Task.FromResult(null); - - public Task> GetChildrenIdsAsync() => - Task.FromResult>([]); - } - - private sealed class StubAgent : IAgent - { - public string Id => "agent"; - public Task HandleEventAsync(EventEnvelope envelope, CancellationToken ct = default) => Task.CompletedTask; - public Task GetDescriptionAsync() => Task.FromResult("stub"); - public Task> GetSubscribedEventTypesAsync() => Task.FromResult>([]); - public Task ActivateAsync(CancellationToken ct = default) => Task.CompletedTask; - public Task DeactivateAsync(CancellationToken ct = default) => Task.CompletedTask; - } - - private sealed class StubSubscriptionProvider : IActorEventSubscriptionProvider - { - public Task SubscribeAsync( - string actorId, - Func handler, - CancellationToken ct = default) - where TMessage : class, IMessage, new() - { - _ = actorId; - _ = handler; - _ = ct; - return Task.FromResult(new NoopDisposable()); - } - } - - private sealed class NoopDisposable : IAsyncDisposable - { - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - } -} diff --git a/test/Aevatar.AI.Tests/StreamingProxyEndpointsCoverageTests.cs b/test/Aevatar.AI.Tests/StreamingProxyEndpointsCoverageTests.cs new file mode 100644 index 00000000..163447ea --- /dev/null +++ b/test/Aevatar.AI.Tests/StreamingProxyEndpointsCoverageTests.cs @@ -0,0 +1,343 @@ +using System.Reflection; +using System.Text; +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.StreamingProxy; +using Aevatar.Studio.Application.Studio.Abstractions; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using AppStreamingProxyParticipant = Aevatar.Studio.Application.Studio.Abstractions.StreamingProxyParticipant; + +namespace Aevatar.AI.Tests; + +public sealed class StreamingProxyEndpointsCoverageTests +{ + private static readonly MethodInfo HandleCreateRoomAsyncMethod = typeof(StreamingProxyEndpoints) + .GetMethod("HandleCreateRoomAsync", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException("HandleCreateRoomAsync not found."); + + private static readonly MethodInfo HandleListParticipantsAsyncMethod = typeof(StreamingProxyEndpoints) + .GetMethod("HandleListParticipantsAsync", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException("HandleListParticipantsAsync not found."); + + [Fact] + public async Task HandleCreateRoomAsync_ShouldRegisterAndInitializeRoomOnSuccess() + { + var operations = new List(); + var actor = new RecordingActor("created-room"); + var actorStore = new RecordingGAgentActorStore(operations); + var runtime = new RecordingActorRuntime(operations, actor); + var loggerFactory = LoggerFactory.Create(_ => { }); + + var result = await InvokeHandleCreateRoomAsync( + new DefaultHttpContext(), + "scope-a", + new StreamingProxyEndpoints.CreateRoomRequest(" Daily Standup "), + actorStore, + runtime, + loggerFactory, + CancellationToken.None); + + var (statusCode, body) = await ExecuteResultAsync(result); + + statusCode.Should().Be(StatusCodes.Status200OK); + actorStore.AddedActors.Should().ContainSingle(); + var registeredActorId = actorStore.AddedActors[0].ActorId; + operations.Should().ContainInOrder( + $"store:add:{registeredActorId}", + $"runtime:create:{registeredActorId}"); + actor.ReceivedEnvelopes.Should().ContainSingle(); + actor.ReceivedEnvelopes[0].Payload.Unpack().RoomName.Should().Be("Daily Standup"); + body.Should().Contain(registeredActorId); + body.Should().Contain("Daily Standup"); + } + + [Fact] + public async Task HandleCreateRoomAsync_ShouldRollbackRegistration_WhenActivationFails() + { + var operations = new List(); + var actorStore = new RecordingGAgentActorStore(operations); + var runtime = new RecordingActorRuntime( + operations, + new RecordingActor("created-room")) + { + ThrowOnCreate = new InvalidOperationException("boom"), + }; + var loggerFactory = LoggerFactory.Create(_ => { }); + + var result = await InvokeHandleCreateRoomAsync( + new DefaultHttpContext(), + "scope-a", + new StreamingProxyEndpoints.CreateRoomRequest("Incident Room"), + actorStore, + runtime, + loggerFactory, + CancellationToken.None); + + var (statusCode, body) = await ExecuteResultAsync(result); + + statusCode.Should().Be(StatusCodes.Status500InternalServerError); + actorStore.AddedActors.Should().ContainSingle(); + actorStore.RemovedActors.Should().ContainSingle(); + actorStore.RemovedActors[0].ActorId.Should().Be(actorStore.AddedActors[0].ActorId); + runtime.DestroyedActorIds.Should().ContainSingle(actorStore.AddedActors[0].ActorId); + operations.Should().ContainInOrder( + $"store:add:{actorStore.AddedActors[0].ActorId}", + $"runtime:create:{actorStore.AddedActors[0].ActorId}", + $"runtime:destroy:{actorStore.AddedActors[0].ActorId}", + $"store:remove:{actorStore.AddedActors[0].ActorId}"); + body.Should().Contain("Failed to create room"); + } + + [Fact] + public async Task HandleListParticipantsAsync_ShouldReturnStoredParticipants() + { + var participantStore = new RecordingParticipantStore + { + Participants = + [ + new AppStreamingProxyParticipant("agent-1", "Bot", DateTimeOffset.Parse("2026-04-14T10:00:00+08:00")), + ], + }; + var loggerFactory = LoggerFactory.Create(_ => { }); + + var result = await InvokeHandleListParticipantsAsync( + new DefaultHttpContext(), + "scope-a", + "room-1", + participantStore, + loggerFactory, + CancellationToken.None); + + var (statusCode, body) = await ExecuteResultAsync(result); + + statusCode.Should().Be(StatusCodes.Status200OK); + body.Should().Contain("agent-1"); + body.Should().Contain("Bot"); + } + + [Fact] + public async Task HandleListParticipantsAsync_ShouldReturnServerError_WhenStoreThrows() + { + var participantStore = new RecordingParticipantStore + { + ThrowOnList = new InvalidOperationException("list failed"), + }; + var loggerFactory = LoggerFactory.Create(_ => { }); + + var result = await InvokeHandleListParticipantsAsync( + new DefaultHttpContext(), + "scope-a", + "room-1", + participantStore, + loggerFactory, + CancellationToken.None); + + var (statusCode, body) = await ExecuteResultAsync(result); + + statusCode.Should().Be(StatusCodes.Status500InternalServerError); + body.Should().Contain("Failed to list participants"); + } + + private static async Task InvokeHandleCreateRoomAsync( + HttpContext context, + string scopeId, + StreamingProxyEndpoints.CreateRoomRequest? request, + IGAgentActorStore actorStore, + IActorRuntime actorRuntime, + ILoggerFactory loggerFactory, + CancellationToken ct) + { + return await (Task)HandleCreateRoomAsyncMethod.Invoke( + null, + [context, scopeId, request, actorStore, actorRuntime, loggerFactory, ct])!; + } + + private static async Task InvokeHandleListParticipantsAsync( + HttpContext context, + string scopeId, + string roomId, + IStreamingProxyParticipantStore participantStore, + ILoggerFactory loggerFactory, + CancellationToken ct) + { + return await (Task)HandleListParticipantsAsyncMethod.Invoke( + null, + [context, scopeId, roomId, participantStore, loggerFactory, ct])!; + } + + private static async Task<(int StatusCode, string Body)> ExecuteResultAsync(IResult result) + { + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + context.RequestServices = new ServiceCollection() + .AddLogging() + .BuildServiceProvider(); + + await result.ExecuteAsync(context); + + context.Response.Body.Position = 0; + using var reader = new StreamReader(context.Response.Body, Encoding.UTF8, leaveOpen: true); + return (context.Response.StatusCode, await reader.ReadToEndAsync()); + } + + private sealed class RecordingGAgentActorStore(List operations) : IGAgentActorStore + { + public List<(string GAgentType, string ActorId)> AddedActors { get; } = []; + public List<(string GAgentType, string ActorId)> RemovedActors { get; } = []; + + public Task> GetAsync(CancellationToken cancellationToken = default) => + Task.FromResult>([]); + + public Task AddActorAsync(string gagentType, string actorId, CancellationToken cancellationToken = default) + { + operations.Add($"store:add:{actorId}"); + AddedActors.Add((gagentType, actorId)); + return Task.CompletedTask; + } + + public Task RemoveActorAsync(string gagentType, string actorId, CancellationToken cancellationToken = default) + { + operations.Add($"store:remove:{actorId}"); + RemovedActors.Add((gagentType, actorId)); + return Task.CompletedTask; + } + } + + private sealed class RecordingParticipantStore : IStreamingProxyParticipantStore + { + public Exception? ThrowOnList { get; init; } + public IReadOnlyList Participants { get; init; } = []; + + public Task> ListAsync( + string roomId, + CancellationToken cancellationToken = default) + { + _ = roomId; + if (ThrowOnList is not null) + throw ThrowOnList; + + return Task.FromResult(Participants); + } + + public Task AddAsync( + string roomId, + string agentId, + string displayName, + CancellationToken cancellationToken = default) + { + _ = roomId; + _ = agentId; + _ = displayName; + return Task.CompletedTask; + } + + public Task RemoveRoomAsync(string roomId, CancellationToken cancellationToken = default) + { + _ = roomId; + return Task.CompletedTask; + } + } + + private sealed class RecordingActorRuntime(List operations, IActor actor) : IActorRuntime + { + public Exception? ThrowOnCreate { get; init; } + public List DestroyedActorIds { get; } = []; + + public Task CreateAsync(string? id = null, CancellationToken ct = default) + where TAgent : IAgent => + CreateAsync(typeof(TAgent), id, ct); + + public Task CreateAsync(System.Type agentType, string? id = null, CancellationToken ct = default) + { + _ = agentType; + ct.ThrowIfCancellationRequested(); + + var actorId = id ?? throw new InvalidOperationException("Actor id is required for this test."); + operations.Add($"runtime:create:{actorId}"); + if (ThrowOnCreate is not null) + throw ThrowOnCreate; + + return Task.FromResult(actor); + } + + public Task DestroyAsync(string id, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + operations.Add($"runtime:destroy:{id}"); + DestroyedActorIds.Add(id); + return Task.CompletedTask; + } + + public Task GetAsync(string id) + { + _ = id; + return Task.FromResult(null); + } + + public Task ExistsAsync(string id) + { + _ = id; + return Task.FromResult(false); + } + + public Task LinkAsync(string parentId, string childId, CancellationToken ct = default) + { + _ = parentId; + _ = childId; + ct.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + + public Task UnlinkAsync(string childId, CancellationToken ct = default) + { + _ = childId; + ct.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + } + + private sealed class RecordingActor(string id) : IActor + { + public List ReceivedEnvelopes { get; } = []; + + public string Id { get; } = id; + + public IAgent Agent { get; } = new StubAgent(id); + + public Task ActivateAsync(CancellationToken ct = default) => Task.CompletedTask; + + public Task DeactivateAsync(CancellationToken ct = default) => Task.CompletedTask; + + public Task HandleEventAsync(EventEnvelope envelope, CancellationToken ct = default) + { + ReceivedEnvelopes.Add(envelope); + return Task.CompletedTask; + } + + public Task GetParentIdAsync() => Task.FromResult(null); + + public Task> GetChildrenIdsAsync() => Task.FromResult>([]); + } + + private sealed class StubAgent(string id) : IAgent + { + public string Id { get; } = id; + + public Task HandleEventAsync(EventEnvelope envelope, CancellationToken ct = default) + { + _ = envelope; + return Task.CompletedTask; + } + + public Task GetDescriptionAsync() => Task.FromResult(string.Empty); + + public Task> GetSubscribedEventTypesAsync() => + Task.FromResult>([]); + + public Task ActivateAsync(CancellationToken ct = default) => Task.CompletedTask; + + public Task DeactivateAsync(CancellationToken ct = default) => Task.CompletedTask; + } +} diff --git a/test/Aevatar.GAgentService.Integration.Tests/ScopeServiceEndpointsTests.cs b/test/Aevatar.GAgentService.Integration.Tests/ScopeServiceEndpointsTests.cs index 448ec6a0..1c0695df 100644 --- a/test/Aevatar.GAgentService.Integration.Tests/ScopeServiceEndpointsTests.cs +++ b/test/Aevatar.GAgentService.Integration.Tests/ScopeServiceEndpointsTests.cs @@ -2316,7 +2316,7 @@ public async Task ScopeServiceEndpointHelpers_ShouldBuildScopedHeaders_AndIgnore var successContext = new DefaultHttpContext { RequestServices = new ServiceCollection() - .AddSingleton(new StubUserConfigStore( + .AddSingleton(new StubUserConfigStore( new UserConfig("user-model", "/preferred-route"))) .BuildServiceProvider(), }; @@ -2339,7 +2339,7 @@ public async Task ScopeServiceEndpointHelpers_ShouldBuildScopedHeaders_AndIgnore var failingContext = new DefaultHttpContext { RequestServices = new ServiceCollection() - .AddSingleton(new ThrowingUserConfigStore()) + .AddSingleton(new ThrowingUserConfigStore()) .BuildServiceProvider(), }; var failedHeaders = await InvokePrivateStaticTask>( @@ -3758,7 +3758,7 @@ private sealed class NoOpAsyncDisposable : IAsyncDisposable public ValueTask DisposeAsync() => ValueTask.CompletedTask; } - private sealed class StubUserConfigStore : IUserConfigStore + private sealed class StubUserConfigStore : IUserConfigQueryPort { private readonly UserConfig _config; @@ -3767,17 +3767,13 @@ public StubUserConfigStore(UserConfig config) _config = config; } - public Task GetAsync(CancellationToken cancellationToken = default) => Task.FromResult(_config); - - public Task SaveAsync(UserConfig config, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task GetAsync(CancellationToken ct = default) => Task.FromResult(_config); } - private sealed class ThrowingUserConfigStore : IUserConfigStore + private sealed class ThrowingUserConfigStore : IUserConfigQueryPort { - public Task GetAsync(CancellationToken cancellationToken = default) => + public Task GetAsync(CancellationToken ct = default) => throw new InvalidOperationException("config unavailable"); - - public Task SaveAsync(UserConfig config, CancellationToken cancellationToken = default) => Task.CompletedTask; } private sealed class RecordingResumeDispatchService diff --git a/test/Aevatar.Tools.Cli.Tests/ActorBackedGAgentStateTransitionTests.cs b/test/Aevatar.Tools.Cli.Tests/ActorBackedGAgentStateTransitionTests.cs new file mode 100644 index 00000000..13bf7c3d --- /dev/null +++ b/test/Aevatar.Tools.Cli.Tests/ActorBackedGAgentStateTransitionTests.cs @@ -0,0 +1,1504 @@ +using Aevatar.GAgents.ChatHistory; +using Aevatar.GAgents.ConnectorCatalog; +using Aevatar.GAgents.Registry; +using Aevatar.GAgents.RoleCatalog; +using Aevatar.GAgents.StreamingProxyParticipant; +using Aevatar.GAgents.UserConfig; +using Aevatar.GAgents.UserMemory; +using Aevatar.Foundation.Core.EventSourcing; +using FluentAssertions; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.Tools.Cli.Tests; + +/// +/// Unit tests for actor-backed GAgent state transition semantics. +/// +/// Each GAgent's TransitionState is protected override with +/// private static Apply* helpers, so these tests replicate the exact +/// transition logic using the public to +/// verify proto definitions and state-change semantics are correct. +/// +public sealed class ActorBackedGAgentStateTransitionTests +{ + // ═══════════════════════════════════════════════════════════════════ + // Helpers — replicate each GAgent's TransitionState using the + // same StateTransitionMatcher + Apply* pattern. + // ═══════════════════════════════════════════════════════════════════ + + #region GAgentRegistry helpers + + private static GAgentRegistryState ApplyRegistry(GAgentRegistryState current, IMessage evt) => + StateTransitionMatcher + .Match(current, evt) + .On(ApplyRegistered) + .On(ApplyUnregistered) + .OrCurrent(); + + private static GAgentRegistryState ApplyRegistered( + GAgentRegistryState state, ActorRegisteredEvent evt) + { + var next = state.Clone(); + var group = next.Groups.FirstOrDefault(g => + string.Equals(g.GagentType, evt.GagentType, StringComparison.Ordinal)); + + if (group is null) + { + group = new GAgentRegistryEntry { GagentType = evt.GagentType }; + next.Groups.Add(group); + } + + if (!group.ActorIds.Contains(evt.ActorId)) + group.ActorIds.Add(evt.ActorId); + + return next; + } + + private static GAgentRegistryState ApplyUnregistered( + GAgentRegistryState state, ActorUnregisteredEvent evt) + { + var next = state.Clone(); + var group = next.Groups.FirstOrDefault(g => + string.Equals(g.GagentType, evt.GagentType, StringComparison.Ordinal)); + + if (group is null) + return next; + + group.ActorIds.Remove(evt.ActorId); + + if (group.ActorIds.Count == 0) + next.Groups.Remove(group); + + return next; + } + + #endregion + + #region StreamingProxyParticipant helpers + + private static StreamingProxyParticipantGAgentState ApplyParticipant( + StreamingProxyParticipantGAgentState current, IMessage evt) => + StateTransitionMatcher + .Match(current, evt) + .On(ApplyParticipantAdded) + .On(ApplyRoomRemoved) + .OrCurrent(); + + private static StreamingProxyParticipantGAgentState ApplyParticipantAdded( + StreamingProxyParticipantGAgentState state, ParticipantAddedEvent evt) + { + var next = state.Clone(); + + if (!next.Rooms.TryGetValue(evt.RoomId, out var list)) + { + list = new ParticipantList(); + next.Rooms[evt.RoomId] = list; + } + + var existing = list.Participants.FirstOrDefault(p => + string.Equals(p.AgentId, evt.AgentId, StringComparison.Ordinal)); + if (existing is not null) + list.Participants.Remove(existing); + + list.Participants.Add(new ParticipantEntry + { + AgentId = evt.AgentId, + DisplayName = evt.DisplayName, + JoinedAt = evt.JoinedAt, + }); + + return next; + } + + private static StreamingProxyParticipantGAgentState ApplyRoomRemoved( + StreamingProxyParticipantGAgentState state, RoomParticipantsRemovedEvent evt) + { + var next = state.Clone(); + next.Rooms.Remove(evt.RoomId); + return next; + } + + #endregion + + #region UserConfig helpers + + private static UserConfigGAgentState ApplyConfig( + UserConfigGAgentState current, IMessage evt) => + StateTransitionMatcher + .Match(current, evt) + .On(ApplyConfigUpdated) + .OrCurrent(); + + private static UserConfigGAgentState ApplyConfigUpdated( + UserConfigGAgentState state, UserConfigUpdatedEvent evt) => + new() + { + DefaultModel = evt.DefaultModel, + PreferredLlmRoute = evt.PreferredLlmRoute, + RuntimeMode = evt.RuntimeMode, + LocalRuntimeBaseUrl = evt.LocalRuntimeBaseUrl, + RemoteRuntimeBaseUrl = evt.RemoteRuntimeBaseUrl, + MaxToolRounds = evt.MaxToolRounds, + }; + + #endregion + + #region UserMemory helpers + + private const int MaxEntries = 50; // mirrors UserMemoryGAgent.MaxEntries + + private static UserMemoryState ApplyMemory( + UserMemoryState current, IMessage evt) => + StateTransitionMatcher + .Match(current, evt) + .On(ApplyMemoryAdded) + .On(ApplyMemoryRemoved) + .On(ApplyMemoryCleared) + .OrCurrent(); + + private static UserMemoryState ApplyMemoryAdded( + UserMemoryState state, MemoryEntryAddedEvent evt) + { + var next = state.Clone(); + next.Entries.Add(evt.Entry.Clone()); + + while (next.Entries.Count > MaxEntries) + { + var category = evt.Entry.Category; + var oldestSameCategory = next.Entries + .Where(e => string.Equals(e.Category, category, StringComparison.Ordinal) + && !string.Equals(e.Id, evt.Entry.Id, StringComparison.Ordinal)) + .OrderBy(e => e.CreatedAt) + .FirstOrDefault(); + + if (oldestSameCategory is not null) + { + next.Entries.Remove(oldestSameCategory); + } + else + { + var globallyOldest = next.Entries + .Where(e => !string.Equals(e.Id, evt.Entry.Id, StringComparison.Ordinal)) + .OrderBy(e => e.CreatedAt) + .FirstOrDefault(); + + if (globallyOldest is not null) + next.Entries.Remove(globallyOldest); + else + break; + } + } + + return next; + } + + private static UserMemoryState ApplyMemoryRemoved( + UserMemoryState state, MemoryEntryRemovedEvent evt) + { + var next = state.Clone(); + var entry = next.Entries.FirstOrDefault(e => + string.Equals(e.Id, evt.EntryId, StringComparison.Ordinal)); + + if (entry is not null) + next.Entries.Remove(entry); + + return next; + } + + private static UserMemoryState ApplyMemoryCleared( + UserMemoryState state, MemoryEntriesClearedEvent _) + { + var next = state.Clone(); + next.Entries.Clear(); + return next; + } + + #endregion + + #region ChatConversation helpers + + private const int MaxMessages = 500; // mirrors ChatConversationGAgent.MaxMessages + + private static ChatConversationState ApplyConversation( + ChatConversationState current, IMessage evt) => + StateTransitionMatcher + .Match(current, evt) + .On(ApplyMessagesReplaced) + .On(ApplyConversationDeleted) + .OrCurrent(); + + private static ChatConversationState ApplyMessagesReplaced( + ChatConversationState state, MessagesReplacedEvent evt) + { + var next = new ChatConversationState { Meta = evt.Meta?.Clone() }; + next.Messages.AddRange(evt.Messages); + return next; + } + + private static ChatConversationState ApplyConversationDeleted( + ChatConversationState state, ConversationDeletedEvent evt) => + new(); + + #endregion + + #region ChatHistoryIndex helpers + + private static ChatHistoryIndexState ApplyHistoryIndex( + ChatHistoryIndexState current, IMessage evt) => + StateTransitionMatcher + .Match(current, evt) + .On(ApplyConversationUpserted) + .On(ApplyConversationRemoved) + .OrCurrent(); + + private static ChatHistoryIndexState ApplyConversationUpserted( + ChatHistoryIndexState state, ConversationUpsertedEvent evt) + { + var next = state.Clone(); + + var existing = next.Conversations.FirstOrDefault(c => + string.Equals(c.Id, evt.Meta.Id, StringComparison.Ordinal)); + if (existing is not null) + next.Conversations.Remove(existing); + + next.Conversations.Add(evt.Meta.Clone()); + return next; + } + + private static ChatHistoryIndexState ApplyConversationRemoved( + ChatHistoryIndexState state, ConversationRemovedEvent evt) + { + var next = state.Clone(); + + var existing = next.Conversations.FirstOrDefault(c => + string.Equals(c.Id, evt.ConversationId, StringComparison.Ordinal)); + if (existing is not null) + next.Conversations.Remove(existing); + + return next; + } + + #endregion + + // ═══════════════════════════════════════════════════════════════════ + // 1. GAgentRegistryGAgent + // ═══════════════════════════════════════════════════════════════════ + + [Fact] + public void Registry_RegisterActor_AddsToNewGroup() + { + var state = new GAgentRegistryState(); + var evt = new ActorRegisteredEvent { GagentType = "TypeA", ActorId = "a1" }; + + var next = ApplyRegistry(state, evt); + + next.Groups.Should().HaveCount(1); + next.Groups[0].GagentType.Should().Be("TypeA"); + next.Groups[0].ActorIds.Should().ContainSingle().Which.Should().Be("a1"); + } + + [Fact] + public void Registry_RegisterActor_AddsToExistingGroup() + { + var state = new GAgentRegistryState(); + state.Groups.Add(new GAgentRegistryEntry + { + GagentType = "TypeA", + ActorIds = { "a1" }, + }); + + var evt = new ActorRegisteredEvent { GagentType = "TypeA", ActorId = "a2" }; + + var next = ApplyRegistry(state, evt); + + next.Groups.Should().HaveCount(1); + next.Groups[0].ActorIds.Should().BeEquivalentTo(new[] { "a1", "a2" }); + } + + [Fact] + public void Registry_RegisterActor_Idempotent_DoesNotDuplicate() + { + var state = new GAgentRegistryState(); + state.Groups.Add(new GAgentRegistryEntry + { + GagentType = "TypeA", + ActorIds = { "a1" }, + }); + + var evt = new ActorRegisteredEvent { GagentType = "TypeA", ActorId = "a1" }; + + var next = ApplyRegistry(state, evt); + + next.Groups.Should().HaveCount(1); + next.Groups[0].ActorIds.Should().ContainSingle().Which.Should().Be("a1"); + } + + [Fact] + public void Registry_RegisterActor_MultipleGroups() + { + var state = new GAgentRegistryState(); + + var s1 = ApplyRegistry(state, new ActorRegisteredEvent { GagentType = "TypeA", ActorId = "a1" }); + var s2 = ApplyRegistry(s1, new ActorRegisteredEvent { GagentType = "TypeB", ActorId = "b1" }); + + s2.Groups.Should().HaveCount(2); + s2.Groups.Should().Contain(g => g.GagentType == "TypeA"); + s2.Groups.Should().Contain(g => g.GagentType == "TypeB"); + } + + [Fact] + public void Registry_UnregisterActor_RemovesFromGroup() + { + var state = new GAgentRegistryState(); + state.Groups.Add(new GAgentRegistryEntry + { + GagentType = "TypeA", + ActorIds = { "a1", "a2" }, + }); + + var evt = new ActorUnregisteredEvent { GagentType = "TypeA", ActorId = "a1" }; + + var next = ApplyRegistry(state, evt); + + next.Groups.Should().HaveCount(1); + next.Groups[0].ActorIds.Should().ContainSingle().Which.Should().Be("a2"); + } + + [Fact] + public void Registry_UnregisterActor_RemovesEmptyGroup() + { + var state = new GAgentRegistryState(); + state.Groups.Add(new GAgentRegistryEntry + { + GagentType = "TypeA", + ActorIds = { "a1" }, + }); + + var evt = new ActorUnregisteredEvent { GagentType = "TypeA", ActorId = "a1" }; + + var next = ApplyRegistry(state, evt); + + next.Groups.Should().BeEmpty(); + } + + [Fact] + public void Registry_UnregisterActor_NonexistentGroup_ReturnsUnchanged() + { + var state = new GAgentRegistryState(); + + var evt = new ActorUnregisteredEvent { GagentType = "NoSuchType", ActorId = "a1" }; + + var next = ApplyRegistry(state, evt); + + next.Groups.Should().BeEmpty(); + } + + [Fact] + public void Registry_UnregisterActor_NonexistentId_ReturnsUnchanged() + { + var state = new GAgentRegistryState(); + state.Groups.Add(new GAgentRegistryEntry + { + GagentType = "TypeA", + ActorIds = { "a1" }, + }); + + var evt = new ActorUnregisteredEvent { GagentType = "TypeA", ActorId = "no-such-id" }; + + var next = ApplyRegistry(state, evt); + + next.Groups.Should().HaveCount(1); + next.Groups[0].ActorIds.Should().ContainSingle().Which.Should().Be("a1"); + } + + [Fact] + public void Registry_EmptyState_IsValid() + { + var state = new GAgentRegistryState(); + + state.Groups.Should().BeEmpty(); + } + + [Fact] + public void Registry_UnknownEvent_ReturnsCurrentState() + { + var state = new GAgentRegistryState(); + state.Groups.Add(new GAgentRegistryEntry { GagentType = "T", ActorIds = { "x" } }); + + // ParticipantAddedEvent is unrelated to the registry matcher + var unrelated = new ParticipantAddedEvent { RoomId = "r1", AgentId = "ag1" }; + + var next = ApplyRegistry(state, unrelated); + + next.Should().BeSameAs(state); + } + + // ═══════════════════════════════════════════════════════════════════ + // 2. StreamingProxyParticipantGAgent + // ═══════════════════════════════════════════════════════════════════ + + [Fact] + public void Participant_Add_CreatesRoom() + { + var state = new StreamingProxyParticipantGAgentState(); + var now = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow); + var evt = new ParticipantAddedEvent + { + RoomId = "room1", + AgentId = "agent1", + DisplayName = "Agent One", + JoinedAt = now, + }; + + var next = ApplyParticipant(state, evt); + + next.Rooms.Should().ContainKey("room1"); + next.Rooms["room1"].Participants.Should().HaveCount(1); + next.Rooms["room1"].Participants[0].AgentId.Should().Be("agent1"); + next.Rooms["room1"].Participants[0].DisplayName.Should().Be("Agent One"); + next.Rooms["room1"].Participants[0].JoinedAt.Should().Be(now); + } + + [Fact] + public void Participant_Add_MultipleToSameRoom() + { + var state = new StreamingProxyParticipantGAgentState(); + var now = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow); + + var s1 = ApplyParticipant(state, new ParticipantAddedEvent + { + RoomId = "room1", AgentId = "agent1", DisplayName = "A1", JoinedAt = now, + }); + var s2 = ApplyParticipant(s1, new ParticipantAddedEvent + { + RoomId = "room1", AgentId = "agent2", DisplayName = "A2", JoinedAt = now, + }); + + s2.Rooms["room1"].Participants.Should().HaveCount(2); + } + + [Fact] + public void Participant_Add_DuplicateAgentId_UpsertsEntry() + { + var state = new StreamingProxyParticipantGAgentState(); + var time1 = Timestamp.FromDateTimeOffset(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero)); + var time2 = Timestamp.FromDateTimeOffset(new DateTimeOffset(2025, 6, 1, 0, 0, 0, TimeSpan.Zero)); + + var s1 = ApplyParticipant(state, new ParticipantAddedEvent + { + RoomId = "room1", AgentId = "agent1", DisplayName = "OldName", JoinedAt = time1, + }); + var s2 = ApplyParticipant(s1, new ParticipantAddedEvent + { + RoomId = "room1", AgentId = "agent1", DisplayName = "NewName", JoinedAt = time2, + }); + + s2.Rooms["room1"].Participants.Should().HaveCount(1); + s2.Rooms["room1"].Participants[0].DisplayName.Should().Be("NewName"); + s2.Rooms["room1"].Participants[0].JoinedAt.Should().Be(time2); + } + + [Fact] + public void Participant_RemoveRoom_RemovesAllParticipants() + { + var state = new StreamingProxyParticipantGAgentState(); + var now = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow); + + var s1 = ApplyParticipant(state, new ParticipantAddedEvent + { + RoomId = "room1", AgentId = "agent1", DisplayName = "A1", JoinedAt = now, + }); + var s2 = ApplyParticipant(s1, new ParticipantAddedEvent + { + RoomId = "room1", AgentId = "agent2", DisplayName = "A2", JoinedAt = now, + }); + + var s3 = ApplyParticipant(s2, new RoomParticipantsRemovedEvent { RoomId = "room1" }); + + s3.Rooms.Should().NotContainKey("room1"); + } + + [Fact] + public void Participant_RemoveRoom_NonexistentRoom_ReturnsUnchanged() + { + var state = new StreamingProxyParticipantGAgentState(); + + var next = ApplyParticipant(state, new RoomParticipantsRemovedEvent { RoomId = "no-room" }); + + next.Rooms.Should().BeEmpty(); + } + + [Fact] + public void Participant_RemoveRoom_DoesNotAffectOtherRooms() + { + var state = new StreamingProxyParticipantGAgentState(); + var now = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow); + + var s1 = ApplyParticipant(state, new ParticipantAddedEvent + { + RoomId = "room1", AgentId = "a1", DisplayName = "A1", JoinedAt = now, + }); + var s2 = ApplyParticipant(s1, new ParticipantAddedEvent + { + RoomId = "room2", AgentId = "a2", DisplayName = "A2", JoinedAt = now, + }); + + var s3 = ApplyParticipant(s2, new RoomParticipantsRemovedEvent { RoomId = "room1" }); + + s3.Rooms.Should().ContainKey("room2"); + s3.Rooms.Should().NotContainKey("room1"); + } + + [Fact] + public void Participant_EmptyState_IsValid() + { + var state = new StreamingProxyParticipantGAgentState(); + + state.Rooms.Should().BeEmpty(); + } + + // ═══════════════════════════════════════════════════════════════════ + // 3. UserConfigGAgent + // ═══════════════════════════════════════════════════════════════════ + + [Fact] + public void UserConfig_Update_ReplacesAllFields() + { + var state = new UserConfigGAgentState + { + DefaultModel = "old-model", + PreferredLlmRoute = "old-route", + RuntimeMode = "remote", + LocalRuntimeBaseUrl = "http://old-local", + RemoteRuntimeBaseUrl = "http://old-remote", + MaxToolRounds = 5, + }; + + var evt = new UserConfigUpdatedEvent + { + DefaultModel = "new-model", + PreferredLlmRoute = "new-route", + RuntimeMode = "local", + LocalRuntimeBaseUrl = "http://new-local", + RemoteRuntimeBaseUrl = "http://new-remote", + MaxToolRounds = 10, + }; + + var next = ApplyConfig(state, evt); + + next.DefaultModel.Should().Be("new-model"); + next.PreferredLlmRoute.Should().Be("new-route"); + next.RuntimeMode.Should().Be("local"); + next.LocalRuntimeBaseUrl.Should().Be("http://new-local"); + next.RemoteRuntimeBaseUrl.Should().Be("http://new-remote"); + next.MaxToolRounds.Should().Be(10); + } + + [Fact] + public void UserConfig_Update_FromEmptyState() + { + var state = new UserConfigGAgentState(); + + var evt = new UserConfigUpdatedEvent + { + DefaultModel = "gpt-4", + PreferredLlmRoute = "azure", + RuntimeMode = "remote", + LocalRuntimeBaseUrl = "", + RemoteRuntimeBaseUrl = "https://api.example.com", + MaxToolRounds = 3, + }; + + var next = ApplyConfig(state, evt); + + next.DefaultModel.Should().Be("gpt-4"); + next.MaxToolRounds.Should().Be(3); + } + + [Fact] + public void UserConfig_Update_FullReplacement_DoesNotRetainOldFields() + { + var state = new UserConfigGAgentState + { + DefaultModel = "old-model", + MaxToolRounds = 99, + }; + + // Event with zero/empty values for some fields + var evt = new UserConfigUpdatedEvent + { + DefaultModel = "new-model", + PreferredLlmRoute = "", + RuntimeMode = "", + LocalRuntimeBaseUrl = "", + RemoteRuntimeBaseUrl = "", + MaxToolRounds = 0, + }; + + var next = ApplyConfig(state, evt); + + next.DefaultModel.Should().Be("new-model"); + next.MaxToolRounds.Should().Be(0); + next.PreferredLlmRoute.Should().BeEmpty(); + } + + [Fact] + public void UserConfig_UnknownEvent_ReturnsCurrentState() + { + var state = new UserConfigGAgentState { DefaultModel = "keep-me" }; + + var unrelated = new ActorRegisteredEvent { GagentType = "T", ActorId = "x" }; + + var next = ApplyConfig(state, unrelated); + + next.Should().BeSameAs(state); + } + + // ═══════════════════════════════════════════════════════════════════ + // 4. UserMemoryGAgent + // ═══════════════════════════════════════════════════════════════════ + + private static UserMemoryEntryProto MakeEntry(string id, string category, long createdAt) => + new() + { + Id = id, + Category = category, + Content = $"content-{id}", + Source = "test", + CreatedAt = createdAt, + UpdatedAt = createdAt, + }; + + [Fact] + public void Memory_AddEntry_ToEmptyState() + { + var state = new UserMemoryState(); + var entry = MakeEntry("e1", "cat-a", 1000); + var evt = new MemoryEntryAddedEvent { Entry = entry }; + + var next = ApplyMemory(state, evt); + + next.Entries.Should().HaveCount(1); + next.Entries[0].Id.Should().Be("e1"); + next.Entries[0].Category.Should().Be("cat-a"); + } + + [Fact] + public void Memory_AddEntry_MultipleEntries() + { + var state = new UserMemoryState(); + + var s1 = ApplyMemory(state, new MemoryEntryAddedEvent { Entry = MakeEntry("e1", "cat-a", 1000) }); + var s2 = ApplyMemory(s1, new MemoryEntryAddedEvent { Entry = MakeEntry("e2", "cat-b", 2000) }); + + s2.Entries.Should().HaveCount(2); + } + + [Fact] + public void Memory_AddEntry_EvictsOldestSameCategoryAtCap() + { + var state = new UserMemoryState(); + + // Fill to capacity with entries in "cat-a" + for (var i = 0; i < MaxEntries; i++) + { + state = ApplyMemory(state, new MemoryEntryAddedEvent + { + Entry = MakeEntry($"e{i}", "cat-a", i * 100), + }); + } + + state.Entries.Should().HaveCount(MaxEntries); + + // Add one more in same category — should evict the oldest (e0) + var next = ApplyMemory(state, new MemoryEntryAddedEvent + { + Entry = MakeEntry("new-entry", "cat-a", MaxEntries * 100), + }); + + next.Entries.Should().HaveCount(MaxEntries); + next.Entries.Should().NotContain(e => e.Id == "e0"); + next.Entries.Should().Contain(e => e.Id == "new-entry"); + } + + [Fact] + public void Memory_AddEntry_EvictsGloballyOldestWhenNoSameCategory() + { + var state = new UserMemoryState(); + + // Fill to capacity: all entries in "cat-a" + for (var i = 0; i < MaxEntries; i++) + { + state = ApplyMemory(state, new MemoryEntryAddedEvent + { + Entry = MakeEntry($"e{i}", "cat-a", i * 100), + }); + } + + // Add in a different category — no same-category to evict, + // so globally oldest (e0) should be evicted + var next = ApplyMemory(state, new MemoryEntryAddedEvent + { + Entry = MakeEntry("diff-cat", "cat-b", MaxEntries * 100), + }); + + next.Entries.Should().HaveCount(MaxEntries); + next.Entries.Should().NotContain(e => e.Id == "e0"); + next.Entries.Should().Contain(e => e.Id == "diff-cat"); + } + + [Fact] + public void Memory_AddEntry_ExactlyAtCap_NoEviction() + { + var state = new UserMemoryState(); + + for (var i = 0; i < MaxEntries; i++) + { + state = ApplyMemory(state, new MemoryEntryAddedEvent + { + Entry = MakeEntry($"e{i}", "cat-a", i * 100), + }); + } + + state.Entries.Should().HaveCount(MaxEntries); + // All entries still present — no eviction at exactly cap + state.Entries.Should().Contain(e => e.Id == "e0"); + state.Entries.Should().Contain(e => e.Id == $"e{MaxEntries - 1}"); + } + + [Fact] + public void Memory_RemoveEntry_RemovesById() + { + var state = new UserMemoryState(); + state = ApplyMemory(state, new MemoryEntryAddedEvent { Entry = MakeEntry("e1", "cat-a", 1000) }); + state = ApplyMemory(state, new MemoryEntryAddedEvent { Entry = MakeEntry("e2", "cat-a", 2000) }); + + var next = ApplyMemory(state, new MemoryEntryRemovedEvent { EntryId = "e1" }); + + next.Entries.Should().HaveCount(1); + next.Entries[0].Id.Should().Be("e2"); + } + + [Fact] + public void Memory_RemoveEntry_NonexistentId_ReturnsUnchanged() + { + var state = new UserMemoryState(); + state = ApplyMemory(state, new MemoryEntryAddedEvent { Entry = MakeEntry("e1", "cat-a", 1000) }); + + var next = ApplyMemory(state, new MemoryEntryRemovedEvent { EntryId = "no-such-id" }); + + next.Entries.Should().HaveCount(1); + next.Entries[0].Id.Should().Be("e1"); + } + + [Fact] + public void Memory_ClearEntries_RemovesAll() + { + var state = new UserMemoryState(); + state = ApplyMemory(state, new MemoryEntryAddedEvent { Entry = MakeEntry("e1", "cat-a", 1000) }); + state = ApplyMemory(state, new MemoryEntryAddedEvent { Entry = MakeEntry("e2", "cat-b", 2000) }); + + var next = ApplyMemory(state, new MemoryEntriesClearedEvent()); + + next.Entries.Should().BeEmpty(); + } + + [Fact] + public void Memory_ClearEntries_OnEmptyState_ReturnsEmpty() + { + var state = new UserMemoryState(); + + var next = ApplyMemory(state, new MemoryEntriesClearedEvent()); + + next.Entries.Should().BeEmpty(); + } + + [Fact] + public void Memory_EmptyState_IsValid() + { + var state = new UserMemoryState(); + + state.Entries.Should().BeEmpty(); + } + + // ═══════════════════════════════════════════════════════════════════ + // 5a. ChatConversationGAgent + // ═══════════════════════════════════════════════════════════════════ + + private static StoredChatMessageProto MakeMessage(string id, string role, string content) => + new() + { + Id = id, + Role = role, + Content = content, + Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + }; + + [Fact] + public void Conversation_ReplaceMessages_SetsState() + { + var state = new ChatConversationState(); + var meta = new ConversationMetaProto + { + Id = "conv1", + Title = "Test Conversation", + MessageCount = 2, + }; + var evt = new MessagesReplacedEvent { Meta = meta }; + evt.Messages.Add(MakeMessage("m1", "user", "Hello")); + evt.Messages.Add(MakeMessage("m2", "assistant", "Hi")); + + var next = ApplyConversation(state, evt); + + next.Meta.Should().NotBeNull(); + next.Meta!.Id.Should().Be("conv1"); + next.Meta.Title.Should().Be("Test Conversation"); + next.Messages.Should().HaveCount(2); + next.Messages[0].Role.Should().Be("user"); + next.Messages[1].Role.Should().Be("assistant"); + } + + [Fact] + public void Conversation_ReplaceMessages_OverwritesPreviousState() + { + var state = new ChatConversationState + { + Meta = new ConversationMetaProto { Id = "conv1", Title = "Old Title" }, + }; + state.Messages.Add(MakeMessage("old1", "user", "old content")); + + var newMeta = new ConversationMetaProto { Id = "conv1", Title = "New Title", MessageCount = 1 }; + var evt = new MessagesReplacedEvent { Meta = newMeta }; + evt.Messages.Add(MakeMessage("new1", "assistant", "new content")); + + var next = ApplyConversation(state, evt); + + next.Meta!.Title.Should().Be("New Title"); + next.Messages.Should().HaveCount(1); + next.Messages[0].Id.Should().Be("new1"); + } + + [Fact] + public void Conversation_Delete_ClearsState() + { + var state = new ChatConversationState + { + Meta = new ConversationMetaProto { Id = "conv1", Title = "Will be deleted" }, + }; + state.Messages.Add(MakeMessage("m1", "user", "content")); + + var evt = new ConversationDeletedEvent { ConversationId = "conv1" }; + + var next = ApplyConversation(state, evt); + + next.Meta.Should().BeNull(); + next.Messages.Should().BeEmpty(); + } + + [Fact] + public void Conversation_Delete_OnEmptyState_ReturnsEmpty() + { + var state = new ChatConversationState(); + + var evt = new ConversationDeletedEvent { ConversationId = "conv1" }; + + var next = ApplyConversation(state, evt); + + next.Meta.Should().BeNull(); + next.Messages.Should().BeEmpty(); + } + + [Fact] + public void Conversation_EmptyState_IsValid() + { + var state = new ChatConversationState(); + + state.Meta.Should().BeNull(); + state.Messages.Should().BeEmpty(); + } + + [Fact] + public void Conversation_UnknownEvent_ReturnsCurrentState() + { + var state = new ChatConversationState + { + Meta = new ConversationMetaProto { Id = "keep" }, + }; + + var unrelated = new ActorRegisteredEvent { GagentType = "T", ActorId = "x" }; + + var next = ApplyConversation(state, unrelated); + + next.Should().BeSameAs(state); + } + + // ═══════════════════════════════════════════════════════════════════ + // 5b. ChatHistoryIndexGAgent + // ═══════════════════════════════════════════════════════════════════ + + private static ConversationMetaProto MakeMeta(string id, string title) => + new() + { + Id = id, + Title = title, + CreatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + }; + + [Fact] + public void HistoryIndex_UpsertConversation_AddsNew() + { + var state = new ChatHistoryIndexState(); + var evt = new ConversationUpsertedEvent { Meta = MakeMeta("c1", "First Chat") }; + + var next = ApplyHistoryIndex(state, evt); + + next.Conversations.Should().HaveCount(1); + next.Conversations[0].Id.Should().Be("c1"); + next.Conversations[0].Title.Should().Be("First Chat"); + } + + [Fact] + public void HistoryIndex_UpsertConversation_UpdatesExisting() + { + var state = new ChatHistoryIndexState(); + state.Conversations.Add(MakeMeta("c1", "Old Title")); + + var evt = new ConversationUpsertedEvent { Meta = MakeMeta("c1", "New Title") }; + + var next = ApplyHistoryIndex(state, evt); + + next.Conversations.Should().HaveCount(1); + next.Conversations[0].Title.Should().Be("New Title"); + } + + [Fact] + public void HistoryIndex_UpsertConversation_MultipleConversations() + { + var state = new ChatHistoryIndexState(); + + var s1 = ApplyHistoryIndex(state, new ConversationUpsertedEvent { Meta = MakeMeta("c1", "Chat 1") }); + var s2 = ApplyHistoryIndex(s1, new ConversationUpsertedEvent { Meta = MakeMeta("c2", "Chat 2") }); + + s2.Conversations.Should().HaveCount(2); + } + + [Fact] + public void HistoryIndex_RemoveConversation_RemovesById() + { + var state = new ChatHistoryIndexState(); + state.Conversations.Add(MakeMeta("c1", "Chat 1")); + state.Conversations.Add(MakeMeta("c2", "Chat 2")); + + var evt = new ConversationRemovedEvent { ConversationId = "c1" }; + + var next = ApplyHistoryIndex(state, evt); + + next.Conversations.Should().HaveCount(1); + next.Conversations[0].Id.Should().Be("c2"); + } + + [Fact] + public void HistoryIndex_RemoveConversation_NonexistentId_ReturnsUnchanged() + { + var state = new ChatHistoryIndexState(); + state.Conversations.Add(MakeMeta("c1", "Chat 1")); + + var evt = new ConversationRemovedEvent { ConversationId = "no-such-id" }; + + var next = ApplyHistoryIndex(state, evt); + + next.Conversations.Should().HaveCount(1); + next.Conversations[0].Id.Should().Be("c1"); + } + + [Fact] + public void HistoryIndex_RemoveConversation_FromEmptyState_ReturnsEmpty() + { + var state = new ChatHistoryIndexState(); + + var evt = new ConversationRemovedEvent { ConversationId = "c1" }; + + var next = ApplyHistoryIndex(state, evt); + + next.Conversations.Should().BeEmpty(); + } + + [Fact] + public void HistoryIndex_EmptyState_IsValid() + { + var state = new ChatHistoryIndexState(); + + state.Conversations.Should().BeEmpty(); + } + + [Fact] + public void HistoryIndex_UnknownEvent_ReturnsCurrentState() + { + var state = new ChatHistoryIndexState(); + state.Conversations.Add(MakeMeta("c1", "Keep")); + + var unrelated = new ActorRegisteredEvent { GagentType = "T", ActorId = "x" }; + + var next = ApplyHistoryIndex(state, unrelated); + + next.Should().BeSameAs(state); + } + + // ═══════════════════════════════════════════════════════════════════ + // 8. ConnectorCatalogGAgent + // ═══════════════════════════════════════════════════════════════════ + + #region ConnectorCatalog helpers + + private static ConnectorCatalogState ApplyConnectorCatalog( + ConnectorCatalogState current, IMessage evt) => + StateTransitionMatcher + .Match(current, evt) + .On(ApplyConnectorCatalogSaved) + .On(ApplyConnectorDraftSaved) + .On(ApplyConnectorDraftDeleted) + .OrCurrent(); + + private static ConnectorCatalogState ApplyConnectorCatalogSaved( + ConnectorCatalogState state, ConnectorCatalogSavedEvent evt) + { + var next = state.Clone(); + next.Connectors.Clear(); + next.Connectors.AddRange(evt.Connectors); + return next; + } + + private static ConnectorCatalogState ApplyConnectorDraftSaved( + ConnectorCatalogState state, ConnectorDraftSavedEvent evt) + { + var next = state.Clone(); + next.Draft = new ConnectorDraftEntry + { + Draft = evt.Draft?.Clone(), + UpdatedAtUtc = evt.UpdatedAtUtc, + }; + return next; + } + + private static ConnectorCatalogState ApplyConnectorDraftDeleted( + ConnectorCatalogState state, ConnectorDraftDeletedEvent _) + { + var next = state.Clone(); + next.Draft = null; + return next; + } + + private static ConnectorDefinitionEntry MakeConnector(string name, string type = "http") => + new() + { + Name = name, + Type = type, + Enabled = true, + TimeoutMs = 30000, + Retry = 3, + }; + + #endregion + + [Fact] + public void ConnectorCatalog_SaveCatalog_ReplacesAll() + { + var state = new ConnectorCatalogState(); + state.Connectors.Add(MakeConnector("old-conn")); + + var evt = new ConnectorCatalogSavedEvent(); + evt.Connectors.Add(MakeConnector("new-conn-1")); + evt.Connectors.Add(MakeConnector("new-conn-2", "mcp")); + + var next = ApplyConnectorCatalog(state, evt); + + next.Connectors.Should().HaveCount(2); + next.Connectors[0].Name.Should().Be("new-conn-1"); + next.Connectors[1].Name.Should().Be("new-conn-2"); + next.Connectors[1].Type.Should().Be("mcp"); + } + + [Fact] + public void ConnectorCatalog_SaveCatalog_EmptyList_ClearsAll() + { + var state = new ConnectorCatalogState(); + state.Connectors.Add(MakeConnector("existing")); + + var evt = new ConnectorCatalogSavedEvent(); + + var next = ApplyConnectorCatalog(state, evt); + + next.Connectors.Should().BeEmpty(); + } + + [Fact] + public void ConnectorCatalog_SaveDraft_SetsNewDraft() + { + var state = new ConnectorCatalogState(); + var ts = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow); + + var evt = new ConnectorDraftSavedEvent + { + Draft = MakeConnector("draft-conn", "cli"), + UpdatedAtUtc = ts, + }; + + var next = ApplyConnectorCatalog(state, evt); + + next.Draft.Should().NotBeNull(); + next.Draft!.Draft.Name.Should().Be("draft-conn"); + next.Draft.Draft.Type.Should().Be("cli"); + next.Draft.UpdatedAtUtc.Should().Be(ts); + } + + [Fact] + public void ConnectorCatalog_SaveDraft_NullDraft_SetsEntryWithNullPayload() + { + var state = new ConnectorCatalogState(); + var ts = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow); + + var evt = new ConnectorDraftSavedEvent + { + Draft = null, + UpdatedAtUtc = ts, + }; + + var next = ApplyConnectorCatalog(state, evt); + + next.Draft.Should().NotBeNull(); + next.Draft!.Draft.Should().BeNull(); + next.Draft.UpdatedAtUtc.Should().Be(ts); + } + + [Fact] + public void ConnectorCatalog_SaveDraft_OverwritesPreviousDraft() + { + var state = new ConnectorCatalogState + { + Draft = new ConnectorDraftEntry + { + Draft = MakeConnector("old-draft"), + UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow.AddHours(-1)), + }, + }; + var ts = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow); + + var evt = new ConnectorDraftSavedEvent + { + Draft = MakeConnector("new-draft", "mcp"), + UpdatedAtUtc = ts, + }; + + var next = ApplyConnectorCatalog(state, evt); + + next.Draft!.Draft.Name.Should().Be("new-draft"); + next.Draft.UpdatedAtUtc.Should().Be(ts); + } + + [Fact] + public void ConnectorCatalog_DeleteDraft_ClearsDraft() + { + var state = new ConnectorCatalogState + { + Draft = new ConnectorDraftEntry + { + Draft = MakeConnector("to-delete"), + UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }, + }; + + var next = ApplyConnectorCatalog(state, new ConnectorDraftDeletedEvent()); + + next.Draft.Should().BeNull(); + } + + [Fact] + public void ConnectorCatalog_DeleteDraft_NoDraft_ReturnsNullDraft() + { + var state = new ConnectorCatalogState(); + + var next = ApplyConnectorCatalog(state, new ConnectorDraftDeletedEvent()); + + next.Draft.Should().BeNull(); + } + + [Fact] + public void ConnectorCatalog_SaveCatalog_DoesNotAffectDraft() + { + var draft = new ConnectorDraftEntry + { + Draft = MakeConnector("my-draft"), + UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }; + var state = new ConnectorCatalogState { Draft = draft }; + + var evt = new ConnectorCatalogSavedEvent(); + evt.Connectors.Add(MakeConnector("catalog-conn")); + + var next = ApplyConnectorCatalog(state, evt); + + next.Connectors.Should().HaveCount(1); + next.Draft.Should().NotBeNull(); + next.Draft!.Draft.Name.Should().Be("my-draft"); + } + + [Fact] + public void ConnectorCatalog_EmptyState_IsValid() + { + var state = new ConnectorCatalogState(); + + state.Connectors.Should().BeEmpty(); + state.Draft.Should().BeNull(); + } + + [Fact] + public void ConnectorCatalog_UnknownEvent_ReturnsCurrentState() + { + var state = new ConnectorCatalogState(); + state.Connectors.Add(MakeConnector("keep")); + + var unrelated = new ActorRegisteredEvent { GagentType = "T", ActorId = "x" }; + + var next = ApplyConnectorCatalog(state, unrelated); + + next.Should().BeSameAs(state); + } + + // ═══════════════════════════════════════════════════════════════════ + // 9. RoleCatalogGAgent + // ═══════════════════════════════════════════════════════════════════ + + #region RoleCatalog helpers + + private static RoleCatalogState ApplyRoleCatalog( + RoleCatalogState current, IMessage evt) => + StateTransitionMatcher + .Match(current, evt) + .On(ApplyRoleCatalogSaved) + .On(ApplyRoleDraftSaved) + .On(ApplyRoleDraftDeleted) + .OrCurrent(); + + private static RoleCatalogState ApplyRoleCatalogSaved( + RoleCatalogState state, RoleCatalogSavedEvent evt) + { + var next = state.Clone(); + next.Roles.Clear(); + next.Roles.AddRange(evt.Roles); + return next; + } + + private static RoleCatalogState ApplyRoleDraftSaved( + RoleCatalogState state, RoleDraftSavedEvent evt) + { + var next = state.Clone(); + next.Draft = new RoleDraftEntry + { + Draft = evt.Draft?.Clone(), + UpdatedAtUtc = evt.UpdatedAtUtc, + }; + return next; + } + + private static RoleCatalogState ApplyRoleDraftDeleted( + RoleCatalogState state, RoleDraftDeletedEvent _) + { + var next = state.Clone(); + next.Draft = null; + return next; + } + + private static RoleDefinitionEntry MakeRole(string id, string name = "Test Role") => + new() + { + Id = id, + Name = name, + SystemPrompt = $"You are {name}", + Provider = "anthropic", + Model = "claude-opus", + }; + + #endregion + + [Fact] + public void RoleCatalog_SaveCatalog_ReplacesAll() + { + var state = new RoleCatalogState(); + state.Roles.Add(MakeRole("old-role")); + + var evt = new RoleCatalogSavedEvent(); + evt.Roles.Add(MakeRole("role-1", "Assistant")); + evt.Roles.Add(MakeRole("role-2", "Translator")); + + var next = ApplyRoleCatalog(state, evt); + + next.Roles.Should().HaveCount(2); + next.Roles[0].Id.Should().Be("role-1"); + next.Roles[0].Name.Should().Be("Assistant"); + next.Roles[1].Id.Should().Be("role-2"); + } + + [Fact] + public void RoleCatalog_SaveCatalog_EmptyList_ClearsAll() + { + var state = new RoleCatalogState(); + state.Roles.Add(MakeRole("existing")); + + var evt = new RoleCatalogSavedEvent(); + + var next = ApplyRoleCatalog(state, evt); + + next.Roles.Should().BeEmpty(); + } + + [Fact] + public void RoleCatalog_SaveDraft_SetsNewDraft() + { + var state = new RoleCatalogState(); + var ts = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow); + + var evt = new RoleDraftSavedEvent + { + Draft = MakeRole("draft-role", "Draft Assistant"), + UpdatedAtUtc = ts, + }; + + var next = ApplyRoleCatalog(state, evt); + + next.Draft.Should().NotBeNull(); + next.Draft!.Draft.Id.Should().Be("draft-role"); + next.Draft.Draft.Name.Should().Be("Draft Assistant"); + next.Draft.UpdatedAtUtc.Should().Be(ts); + } + + [Fact] + public void RoleCatalog_SaveDraft_NullDraft_SetsEntryWithNullPayload() + { + var state = new RoleCatalogState(); + var ts = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow); + + var evt = new RoleDraftSavedEvent + { + Draft = null, + UpdatedAtUtc = ts, + }; + + var next = ApplyRoleCatalog(state, evt); + + next.Draft.Should().NotBeNull(); + next.Draft!.Draft.Should().BeNull(); + next.Draft.UpdatedAtUtc.Should().Be(ts); + } + + [Fact] + public void RoleCatalog_SaveDraft_OverwritesPreviousDraft() + { + var state = new RoleCatalogState + { + Draft = new RoleDraftEntry + { + Draft = MakeRole("old-draft"), + UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow.AddHours(-1)), + }, + }; + var ts = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow); + + var evt = new RoleDraftSavedEvent + { + Draft = MakeRole("new-draft", "Updated Role"), + UpdatedAtUtc = ts, + }; + + var next = ApplyRoleCatalog(state, evt); + + next.Draft!.Draft.Name.Should().Be("Updated Role"); + next.Draft.UpdatedAtUtc.Should().Be(ts); + } + + [Fact] + public void RoleCatalog_DeleteDraft_ClearsDraft() + { + var state = new RoleCatalogState + { + Draft = new RoleDraftEntry + { + Draft = MakeRole("to-delete"), + UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }, + }; + + var next = ApplyRoleCatalog(state, new RoleDraftDeletedEvent()); + + next.Draft.Should().BeNull(); + } + + [Fact] + public void RoleCatalog_DeleteDraft_NoDraft_ReturnsNullDraft() + { + var state = new RoleCatalogState(); + + var next = ApplyRoleCatalog(state, new RoleDraftDeletedEvent()); + + next.Draft.Should().BeNull(); + } + + [Fact] + public void RoleCatalog_SaveCatalog_DoesNotAffectDraft() + { + var draft = new RoleDraftEntry + { + Draft = MakeRole("my-draft", "Keep This"), + UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }; + var state = new RoleCatalogState { Draft = draft }; + + var evt = new RoleCatalogSavedEvent(); + evt.Roles.Add(MakeRole("catalog-role")); + + var next = ApplyRoleCatalog(state, evt); + + next.Roles.Should().HaveCount(1); + next.Draft.Should().NotBeNull(); + next.Draft!.Draft.Name.Should().Be("Keep This"); + } + + [Fact] + public void RoleCatalog_SaveDraft_WithConnectors() + { + var state = new RoleCatalogState(); + var role = MakeRole("with-conn", "Connected Role"); + role.Connectors.Add("web-search"); + role.Connectors.Add("code-runner"); + var ts = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow); + + var evt = new RoleDraftSavedEvent { Draft = role, UpdatedAtUtc = ts }; + + var next = ApplyRoleCatalog(state, evt); + + next.Draft!.Draft.Connectors.Should().BeEquivalentTo(["web-search", "code-runner"]); + } + + [Fact] + public void RoleCatalog_EmptyState_IsValid() + { + var state = new RoleCatalogState(); + + state.Roles.Should().BeEmpty(); + state.Draft.Should().BeNull(); + } + + [Fact] + public void RoleCatalog_UnknownEvent_ReturnsCurrentState() + { + var state = new RoleCatalogState(); + state.Roles.Add(MakeRole("keep")); + + var unrelated = new ActorRegisteredEvent { GagentType = "T", ActorId = "x" }; + + var next = ApplyRoleCatalog(state, unrelated); + + next.Should().BeSameAs(state); + } +} diff --git a/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs b/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs new file mode 100644 index 00000000..fbb067e4 --- /dev/null +++ b/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs @@ -0,0 +1,913 @@ +using Aevatar.AI.Abstractions.LLMProviders; +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.ChatHistory; +using Aevatar.GAgents.ConnectorCatalog; +using Aevatar.GAgents.Registry; +using Aevatar.GAgents.RoleCatalog; +using Aevatar.GAgents.StreamingProxyParticipant; +using Aevatar.GAgents.UserConfig; +using Aevatar.GAgents.UserMemory; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Infrastructure.ActorBacked; +using Aevatar.Studio.Infrastructure.ScopeResolution; +using FluentAssertions; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Type = System.Type; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Aevatar.Studio.Infrastructure.Storage; + +namespace Aevatar.Tools.Cli.Tests; + +public sealed class ActorBackedStoreAdapterTests +{ + // ════════════════════════════════════════════════════════════ + // Fakes + // ════════════════════════════════════════════════════════════ + + private sealed class FakeAgent : IAgent where TState : class, IMessage + { + public FakeAgent(string id, TState state) + { + Id = id; + State = state; + } + + public string Id { get; } + public TState State { get; } + + public Task HandleEventAsync(EventEnvelope envelope, CancellationToken ct = default) => + Task.CompletedTask; + public Task GetDescriptionAsync() => Task.FromResult(string.Empty); + public Task> GetSubscribedEventTypesAsync() => + Task.FromResult>([]); + public Task ActivateAsync(CancellationToken ct = default) => Task.CompletedTask; + public Task DeactivateAsync(CancellationToken ct = default) => Task.CompletedTask; + } + + private sealed class FakeActor : IActor + { + private readonly List _received = []; + + public FakeActor(string id, IAgent? agent = null) + { + Id = id; + Agent = agent ?? new FakeAgent(id, new UserConfigGAgentState()); + } + + public string Id { get; } + public IAgent Agent { get; } + public IReadOnlyList ReceivedEnvelopes => _received; + + public Task ActivateAsync(CancellationToken ct = default) => Task.CompletedTask; + public Task DeactivateAsync(CancellationToken ct = default) => Task.CompletedTask; + + public Task HandleEventAsync(EventEnvelope envelope, CancellationToken ct = default) + { + _received.Add(envelope); + return Task.CompletedTask; + } + + public Task GetParentIdAsync() => Task.FromResult(null); + public Task> GetChildrenIdsAsync() => + Task.FromResult>([]); + } + + /// + /// Fake runtime that supports typed agent state for read tests. + /// + private sealed class FakeActorRuntime : IActorRuntime + { + private readonly Dictionary _actors = new(StringComparer.Ordinal); + public IReadOnlyDictionary Actors => _actors; + + public void RegisterActor(string id, IAgent agent) + { + _actors[id] = new FakeActor(id, agent); + } + + public Task CreateAsync(string? id = null, CancellationToken ct = default) + where TAgent : IAgent + { + var actorId = id ?? Guid.NewGuid().ToString("N"); + if (!_actors.ContainsKey(actorId)) + _actors[actorId] = new FakeActor(actorId); + return Task.FromResult(_actors[actorId]); + } + + public Task CreateAsync(Type agentType, string? id = null, CancellationToken ct = default) + { + var actorId = id ?? Guid.NewGuid().ToString("N"); + if (!_actors.ContainsKey(actorId)) + _actors[actorId] = new FakeActor(actorId); + return Task.FromResult(_actors[actorId]); + } + + public Task DestroyAsync(string id, CancellationToken ct = default) + { + _actors.Remove(id); + return Task.CompletedTask; + } + + public Task GetAsync(string id) => + Task.FromResult(_actors.GetValueOrDefault(id)); + + public Task ExistsAsync(string id) => + Task.FromResult(_actors.ContainsKey(id)); + + public Task LinkAsync(string parentId, string childId, CancellationToken ct = default) => + Task.CompletedTask; + + public Task UnlinkAsync(string childId, CancellationToken ct = default) => + Task.CompletedTask; + } + + private sealed class FakeScopeResolver : IAppScopeResolver + { + public string? ScopeIdToReturn { get; set; } + + public AppScopeContext? Resolve(Microsoft.AspNetCore.Http.HttpContext? httpContext = null) => + ScopeIdToReturn is not null + ? new AppScopeContext(ScopeIdToReturn, "test") + : null; + } + + // UserConfigStore tests removed — ActorBackedUserConfigStore replaced by + // IUserConfigQueryPort (projection) + IUserConfigCommandService (dispatch). + // See ActorDispatchUserConfigCommandService tests in projection test project. + + // ════════════════════════════════════════════════════════════ + // NyxIdUserLlmPreferencesStore: delegation + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task NyxIdUserLlmPreferencesStore_ExtractsDefaultModelAndRoute() + { + var stubConfigStore = new StubUserConfigStore(new UserConfig( + DefaultModel: "claude-opus", + PreferredLlmRoute: "/api/v1/proxy/s/anthropic", + MaxToolRounds: 7)); + + var store = new ActorBackedNyxIdUserLlmPreferencesStore(stubConfigStore); + + var prefs = await store.GetAsync(); + + prefs.DefaultModel.Should().Be("claude-opus"); + prefs.PreferredRoute.Should().Be("/api/v1/proxy/s/anthropic"); + prefs.MaxToolRounds.Should().Be(7); + } + + [Fact] + public async Task NyxIdUserLlmPreferencesStore_NormalizesGatewayRoute() + { + var stubConfigStore = new StubUserConfigStore(new UserConfig( + DefaultModel: "gpt-4", + PreferredLlmRoute: "gateway")); + + var store = new ActorBackedNyxIdUserLlmPreferencesStore(stubConfigStore); + + var prefs = await store.GetAsync(); + + prefs.PreferredRoute.Should().Be(UserConfigLlmRouteDefaults.Gateway, + "gateway should normalize to empty string"); + } + + [Fact] + public async Task NyxIdUserLlmPreferencesStore_DefaultConfig_ReturnsEmptyDefaults() + { + var stubConfigStore = new StubUserConfigStore(new UserConfig( + DefaultModel: string.Empty)); + + var store = new ActorBackedNyxIdUserLlmPreferencesStore(stubConfigStore); + + var prefs = await store.GetAsync(); + + prefs.DefaultModel.Should().BeEmpty(); + prefs.PreferredRoute.Should().Be(UserConfigLlmRouteDefaults.Gateway); + prefs.MaxToolRounds.Should().Be(0); + } + + // ════════════════════════════════════════════════════════════ + // GAgentActorStore: scope isolation + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task GAgentActorStore_GetAsync_NoActor_ReturnsEmptyList() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "empty-scope" }; + var logger = NullLogger.Instance; + + var store = new ActorBackedGAgentActorStore( + runtime, scopeResolver, logger); + + var groups = await store.GetAsync(); + + groups.Should().BeEmpty(); + } + + // ════════════════════════════════════════════════════════════ + // GAgentActorStore: AddActorAsync command construction + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task GAgentActorStore_AddActorAsync_SendsActorRegisteredEvent() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "cmd-scope" }; + var logger = NullLogger.Instance; + + var store = new ActorBackedGAgentActorStore( + runtime, scopeResolver, logger); + + await store.AddActorAsync("MyGAgent", "actor-123"); + + var actorId = "gagent-registry-cmd-scope"; + runtime.Actors.Should().ContainKey(actorId); + + var actor = runtime.Actors[actorId]; + actor.ReceivedEnvelopes.Should().HaveCountGreaterThanOrEqualTo(1); + + // The last envelope should be the ActorRegisteredEvent command + var envelope = actor.ReceivedEnvelopes.Last(); + envelope.Payload.Is(Aevatar.GAgents.Registry.ActorRegisteredEvent.Descriptor).Should().BeTrue(); + + var evt = envelope.Payload.Unpack(); + evt.GagentType.Should().Be("MyGAgent"); + evt.ActorId.Should().Be("actor-123"); + } + + [Fact] + public async Task GAgentActorStore_RemoveActorAsync_SendsActorUnregisteredEvent() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "cmd-scope" }; + var logger = NullLogger.Instance; + + var store = new ActorBackedGAgentActorStore( + runtime, scopeResolver, logger); + + await store.RemoveActorAsync("MyGAgent", "actor-456"); + + var actorId = "gagent-registry-cmd-scope"; + var actor = runtime.Actors[actorId]; + var envelope = actor.ReceivedEnvelopes.Last(); + envelope.Payload.Is(Aevatar.GAgents.Registry.ActorUnregisteredEvent.Descriptor).Should().BeTrue(); + + var evt = envelope.Payload.Unpack(); + evt.GagentType.Should().Be("MyGAgent"); + evt.ActorId.Should().Be("actor-456"); + } + + // ════════════════════════════════════════════════════════════ + // Helper: stub IUserConfigQueryPort for NyxId delegation tests + // ════════════════════════════════════════════════════════════ + + private sealed class StubUserConfigStore : IUserConfigQueryPort + { + private readonly UserConfig _config; + + public StubUserConfigStore(UserConfig config) => _config = config; + + public Task GetAsync(CancellationToken ct = default) => + Task.FromResult(_config); + } + + // ════════════════════════════════════════════════════════════ + // ChatHistoryStore: command dispatch + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task ChatHistoryStore_SaveMessages_SendsMessagesReplacedEvent() + { + var runtime = new FakeActorRuntime(); + var logger = NullLogger.Instance; + var store = new ActorBackedChatHistoryStore(runtime, logger); + + var meta = new ConversationMeta( + Id: "conv-1", Title: "Test", ServiceId: "svc", + ServiceKind: "chat", CreatedAt: DateTimeOffset.UtcNow, + UpdatedAt: DateTimeOffset.UtcNow, MessageCount: 1); + var messages = new List + { + new("msg-1", "user", "Hello", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), "sent"), + }; + + await store.SaveMessagesAsync("scope-1", "conv-1", meta, messages); + + var actorId = "chat-scope-1-conv-1"; + runtime.Actors.Should().ContainKey(actorId); + var actor = runtime.Actors[actorId]; + actor.ReceivedEnvelopes.Should().HaveCount(1); + actor.ReceivedEnvelopes[0].Payload.Is(MessagesReplacedEvent.Descriptor).Should().BeTrue(); + + var evt = actor.ReceivedEnvelopes[0].Payload.Unpack(); + evt.ScopeId.Should().Be("scope-1"); + evt.Messages.Should().HaveCount(1); + evt.Messages[0].Role.Should().Be("user"); + } + + [Fact] + public async Task ChatHistoryStore_DeleteConversation_SendsConversationDeletedEvent() + { + var runtime = new FakeActorRuntime(); + var logger = NullLogger.Instance; + var store = new ActorBackedChatHistoryStore(runtime, logger); + + await store.DeleteConversationAsync("scope-1", "conv-1"); + + var actorId = "chat-scope-1-conv-1"; + runtime.Actors.Should().ContainKey(actorId); + var actor = runtime.Actors[actorId]; + var evt = actor.ReceivedEnvelopes[0].Payload.Unpack(); + evt.ConversationId.Should().Be("conv-1"); + evt.ScopeId.Should().Be("scope-1"); + } + + [Fact] + public async Task ChatHistoryStore_GetIndex_NoActor_ReturnsEmpty() + { + var runtime = new FakeActorRuntime(); + var logger = NullLogger.Instance; + var store = new ActorBackedChatHistoryStore(runtime, logger); + + var index = await store.GetIndexAsync("scope-1"); + + index.Conversations.Should().BeEmpty(); + } + + [Fact] + public async Task ChatHistoryStore_GetIndex_MapsStateCorrectly() + { + var runtime = new FakeActorRuntime(); + var state = new ChatHistoryIndexState(); + state.Conversations.Add(new ConversationMetaProto + { + Id = "conv-1", + Title = "Test Chat", + ServiceId = "svc", + ServiceKind = "chat", + CreatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + MessageCount = 5, + LlmRoute = "/api/proxy", + LlmModel = "claude-opus", + }); + runtime.RegisterActor("chat-index-scope-1", + new FakeAgent("chat-index-scope-1", state)); + var logger = NullLogger.Instance; + var store = new ActorBackedChatHistoryStore(runtime, logger); + + var index = await store.GetIndexAsync("scope-1"); + + index.Conversations.Should().HaveCount(1); + index.Conversations[0].Id.Should().Be("conv-1"); + index.Conversations[0].Title.Should().Be("Test Chat"); + index.Conversations[0].LlmRoute.Should().Be("/api/proxy"); + index.Conversations[0].LlmModel.Should().Be("claude-opus"); + index.Conversations[0].MessageCount.Should().Be(5); + } + + // ════════════════════════════════════════════════════════════ + // StreamingProxyParticipantStore: command dispatch + read + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task ParticipantStore_AddAsync_SendsParticipantAddedEvent() + { + var runtime = new FakeActorRuntime(); + var logger = NullLogger.Instance; + var store = new ActorBackedStreamingProxyParticipantStore(runtime, logger); + + await store.AddAsync("room-1", "agent-abc", "Alice"); + + var actorId = "streaming-proxy-participants"; + runtime.Actors.Should().ContainKey(actorId); + var actor = runtime.Actors[actorId]; + var evt = actor.ReceivedEnvelopes[0].Payload.Unpack(); + evt.RoomId.Should().Be("room-1"); + evt.AgentId.Should().Be("agent-abc"); + evt.DisplayName.Should().Be("Alice"); + } + + [Fact] + public async Task ParticipantStore_RemoveRoomAsync_SendsRoomParticipantsRemovedEvent() + { + var runtime = new FakeActorRuntime(); + var logger = NullLogger.Instance; + var store = new ActorBackedStreamingProxyParticipantStore(runtime, logger); + + await store.RemoveRoomAsync("room-1"); + + var actorId = "streaming-proxy-participants"; + var actor = runtime.Actors[actorId]; + var evt = actor.ReceivedEnvelopes[0].Payload.Unpack(); + evt.RoomId.Should().Be("room-1"); + } + + [Fact] + public async Task ParticipantStore_ListAsync_NoActor_ReturnsEmpty() + { + var runtime = new FakeActorRuntime(); + var logger = NullLogger.Instance; + var store = new ActorBackedStreamingProxyParticipantStore(runtime, logger); + + var participants = await store.ListAsync("room-1"); + + participants.Should().BeEmpty(); + } + + [Fact] + public async Task ParticipantStore_ListAsync_MapsStateCorrectly() + { + var runtime = new FakeActorRuntime(); + var state = new StreamingProxyParticipantGAgentState(); + var room = new ParticipantList(); + var joinedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow); + room.Participants.Add(new ParticipantEntry + { + AgentId = "agent-1", + DisplayName = "Bot", + JoinedAt = joinedAt, + }); + state.Rooms["room-1"] = room; + runtime.RegisterActor("streaming-proxy-participants", + new FakeAgent( + "streaming-proxy-participants", state)); + var logger = NullLogger.Instance; + var store = new ActorBackedStreamingProxyParticipantStore(runtime, logger); + + var participants = await store.ListAsync("room-1"); + + participants.Should().HaveCount(1); + participants[0].AgentId.Should().Be("agent-1"); + participants[0].DisplayName.Should().Be("Bot"); + } + + // ════════════════════════════════════════════════════════════ + // UserMemoryStore: command dispatch + read + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task UserMemoryStore_AddEntry_SendsMemoryEntryAddedEvent() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "user-1" }; + var logger = NullLogger.Instance; + var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, logger); + + var entry = await store.AddEntryAsync("preference", "Dark mode", "explicit"); + + entry.Category.Should().Be("preference"); + entry.Content.Should().Be("Dark mode"); + entry.Source.Should().Be("explicit"); + entry.Id.Should().NotBeNullOrEmpty(); + + var actorId = "user-memory-user-1"; + runtime.Actors.Should().ContainKey(actorId); + var actor = runtime.Actors[actorId]; + var evt = actor.ReceivedEnvelopes[0].Payload.Unpack(); + evt.Entry.Content.Should().Be("Dark mode"); + evt.Entry.Category.Should().Be("preference"); + } + + [Fact] + public async Task UserMemoryStore_GetAsync_NoActor_ReturnsEmpty() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "user-1" }; + var logger = NullLogger.Instance; + var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, logger); + + var doc = await store.GetAsync(); + + doc.Entries.Should().BeEmpty(); + } + + [Fact] + public async Task UserMemoryStore_GetAsync_MapsStateCorrectly() + { + var runtime = new FakeActorRuntime(); + var state = new UserMemoryState(); + state.Entries.Add(new UserMemoryEntryProto + { + Id = "mem-1", + Category = "context", + Content = "Works on ML project", + Source = "inferred", + CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + UpdatedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + }); + runtime.RegisterActor("user-memory-user-1", + new FakeAgent("user-memory-user-1", state)); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "user-1" }; + var logger = NullLogger.Instance; + var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, logger); + + var doc = await store.GetAsync(); + + doc.Entries.Should().HaveCount(1); + doc.Entries[0].Id.Should().Be("mem-1"); + doc.Entries[0].Content.Should().Be("Works on ML project"); + doc.Entries[0].Category.Should().Be("context"); + } + + [Fact] + public async Task UserMemoryStore_NoScope_Throws() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = null }; + var logger = NullLogger.Instance; + var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, logger); + + var act = () => store.AddEntryAsync("preference", "test", "explicit"); + + await act.Should().ThrowAsync(); + } + + // ════════════════════════════════════════════════════════════ + // ConnectorCatalogStore: command dispatch + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task ConnectorCatalogStore_SaveCatalog_SendsCatalogSavedEvent() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var workspaceStore = new StubWorkspaceStore(); + var logger = NullLogger.Instance; + var store = new ActorBackedConnectorCatalogStore( + runtime, scopeResolver, workspaceStore, logger); + + var catalog = new StoredConnectorCatalog( + HomeDirectory: "test", + FilePath: "test", + FileExists: true, + Connectors: + [ + new StoredConnectorDefinition( + "my-conn", "http", true, 30000, 3, + new StoredHttpConnectorConfig("https://api.example.com", [], [], [], + new Dictionary(), + new StoredConnectorAuthConfig("", "", "", "", "")), + new StoredCliConnectorConfig("", [], [], [], "", + new Dictionary()), + new StoredMcpConnectorConfig("", "", "", [], + new Dictionary(), + new Dictionary(), + new StoredConnectorAuthConfig("", "", "", "", ""), + "", [], [])), + ]); + + await store.SaveConnectorCatalogAsync(catalog); + + var actorId = "connector-catalog-scope-1"; + runtime.Actors.Should().ContainKey(actorId); + var actor = runtime.Actors[actorId]; + var evt = actor.ReceivedEnvelopes[0].Payload.Unpack(); + evt.Connectors.Should().HaveCount(1); + evt.Connectors[0].Name.Should().Be("my-conn"); + evt.Connectors[0].Type.Should().Be("http"); + } + + [Fact] + public async Task ConnectorCatalogStore_GetCatalog_NoActor_ReturnsEmpty() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var workspaceStore = new StubWorkspaceStore(); + var logger = NullLogger.Instance; + var store = new ActorBackedConnectorCatalogStore( + runtime, scopeResolver, workspaceStore, logger); + + var catalog = await store.GetConnectorCatalogAsync(); + + catalog.FileExists.Should().BeFalse(); + catalog.Connectors.Should().BeEmpty(); + } + + // ════════════════════════════════════════════════════════════ + // RoleCatalogStore: command dispatch + workspace sync + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task RoleCatalogStore_SaveCatalog_SendsCatalogSavedEvent() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var workspaceStore = new StubWorkspaceStore(); + var logger = NullLogger.Instance; + var store = new ActorBackedRoleCatalogStore( + runtime, scopeResolver, workspaceStore, logger); + + var catalog = new StoredRoleCatalog( + HomeDirectory: "test", + FilePath: "test", + FileExists: true, + Roles: + [ + new StoredRoleDefinition("role-1", "Assistant", "You are helpful", + "anthropic", "claude-opus", []), + ]); + + await store.SaveRoleCatalogAsync(catalog); + + var actorId = "role-catalog-scope-1"; + runtime.Actors.Should().ContainKey(actorId); + var actor = runtime.Actors[actorId]; + var evt = actor.ReceivedEnvelopes[0].Payload.Unpack(); + evt.Roles.Should().HaveCount(1); + evt.Roles[0].Name.Should().Be("Assistant"); + } + + [Fact] + public async Task RoleCatalogStore_DeleteDraft_SyncsToWorkspace() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var workspaceStore = new StubWorkspaceStore(); + var logger = NullLogger.Instance; + var store = new ActorBackedRoleCatalogStore( + runtime, scopeResolver, workspaceStore, logger); + + await store.DeleteRoleDraftAsync(); + + workspaceStore.RoleDraftDeleted.Should().BeTrue(); + } + + [Fact] + public async Task RoleCatalogStore_GetCatalog_NoActor_ReturnsEmpty() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var workspaceStore = new StubWorkspaceStore(); + var logger = NullLogger.Instance; + var store = new ActorBackedRoleCatalogStore( + runtime, scopeResolver, workspaceStore, logger); + + var catalog = await store.GetRoleCatalogAsync(); + + catalog.FileExists.Should().BeFalse(); + catalog.Roles.Should().BeEmpty(); + } + + // ════════════════════════════════════════════════════════════ + // ChatHistoryStore: GetMessagesAsync read mapping + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task ChatHistoryStore_GetMessages_MapsStateCorrectly() + { + var runtime = new FakeActorRuntime(); + var state = new ChatConversationState(); + state.Messages.Add(new StoredChatMessageProto + { + Id = "msg-1", + Role = "user", + Content = "Hello", + Timestamp = 1700000000000, + Status = "sent", + Error = "", + Thinking = "reasoning...", + }); + state.Messages.Add(new StoredChatMessageProto + { + Id = "msg-2", + Role = "assistant", + Content = "Hi there!", + Timestamp = 1700000001000, + Status = "sent", + }); + runtime.RegisterActor("chat-scope-1-conv-1", + new FakeAgent("chat-scope-1-conv-1", state)); + var logger = NullLogger.Instance; + var store = new ActorBackedChatHistoryStore(runtime, logger); + + var messages = await store.GetMessagesAsync("scope-1", "conv-1"); + + messages.Should().HaveCount(2); + messages[0].Id.Should().Be("msg-1"); + messages[0].Role.Should().Be("user"); + messages[0].Content.Should().Be("Hello"); + messages[0].Thinking.Should().Be("reasoning..."); + messages[1].Id.Should().Be("msg-2"); + messages[1].Role.Should().Be("assistant"); + messages[1].Error.Should().BeNull("empty proto string maps to null"); + } + + [Fact] + public async Task ChatHistoryStore_GetMessages_NoActor_ReturnsEmpty() + { + var runtime = new FakeActorRuntime(); + var logger = NullLogger.Instance; + var store = new ActorBackedChatHistoryStore(runtime, logger); + + var messages = await store.GetMessagesAsync("scope-1", "conv-1"); + + messages.Should().BeEmpty(); + } + + // ════════════════════════════════════════════════════════════ + // RoleCatalogStore: SaveDraft workspace sync + read mapping + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task RoleCatalogStore_SaveDraft_SyncsToWorkspace() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var workspaceStore = new StubWorkspaceStore(); + var logger = NullLogger.Instance; + var store = new ActorBackedRoleCatalogStore( + runtime, scopeResolver, workspaceStore, logger); + + var draft = new StoredRoleDraft( + HomeDirectory: "test", + FilePath: "test/draft", + FileExists: true, + UpdatedAtUtc: DateTimeOffset.UtcNow, + Draft: new StoredRoleDefinition("r1", "My Role", "prompt", + "anthropic", "claude-opus", [])); + + await store.SaveRoleDraftAsync(draft); + + workspaceStore.LastSavedRoleDraft.Should().NotBeNull(); + workspaceStore.LastSavedRoleDraft!.Draft!.Name.Should().Be("My Role"); + } + + [Fact] + public async Task RoleCatalogStore_DeleteDraft_SendsEventAndSyncsWorkspace() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var workspaceStore = new StubWorkspaceStore(); + var logger = NullLogger.Instance; + var store = new ActorBackedRoleCatalogStore( + runtime, scopeResolver, workspaceStore, logger); + + await store.DeleteRoleDraftAsync(); + + var actorId = "role-catalog-scope-1"; + runtime.Actors.Should().ContainKey(actorId); + var actor = runtime.Actors[actorId]; + actor.ReceivedEnvelopes.Should().HaveCount(1); + actor.ReceivedEnvelopes[0].Payload.Is(RoleDraftDeletedEvent.Descriptor).Should().BeTrue(); + workspaceStore.RoleDraftDeleted.Should().BeTrue(); + } + + [Fact] + public async Task RoleCatalogStore_GetCatalog_MapsStateCorrectly() + { + var runtime = new FakeActorRuntime(); + var state = new RoleCatalogState(); + state.Roles.Add(new RoleDefinitionEntry + { + Id = "role-1", + Name = "Assistant", + SystemPrompt = "You are helpful", + Provider = "anthropic", + Model = "claude-opus", + }); + runtime.RegisterActor("role-catalog-scope-1", + new FakeAgent("role-catalog-scope-1", state)); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var workspaceStore = new StubWorkspaceStore(); + var logger = NullLogger.Instance; + var store = new ActorBackedRoleCatalogStore( + runtime, scopeResolver, workspaceStore, logger); + + var catalog = await store.GetRoleCatalogAsync(); + + catalog.FileExists.Should().BeTrue(); + catalog.Roles.Should().HaveCount(1); + catalog.Roles[0].Id.Should().Be("role-1"); + catalog.Roles[0].Name.Should().Be("Assistant"); + catalog.Roles[0].Provider.Should().Be("anthropic"); + } + + // ════════════════════════════════════════════════════════════ + // ConnectorCatalogStore: read mapping + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task ConnectorCatalogStore_GetCatalog_MapsStateCorrectly() + { + var runtime = new FakeActorRuntime(); + var state = new ConnectorCatalogState(); + state.Connectors.Add(new ConnectorDefinitionEntry + { + Name = "web-search", + Type = "http", + Enabled = true, + TimeoutMs = 30000, + Retry = 3, + Http = new HttpConnectorConfigEntry + { + BaseUrl = "https://api.search.example.com", + }, + }); + runtime.RegisterActor("connector-catalog-scope-1", + new FakeAgent("connector-catalog-scope-1", state)); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var workspaceStore = new StubWorkspaceStore(); + var logger = NullLogger.Instance; + var store = new ActorBackedConnectorCatalogStore( + runtime, scopeResolver, workspaceStore, logger); + + var catalog = await store.GetConnectorCatalogAsync(); + + catalog.FileExists.Should().BeTrue(); + catalog.Connectors.Should().HaveCount(1); + catalog.Connectors[0].Name.Should().Be("web-search"); + catalog.Connectors[0].Type.Should().Be("http"); + catalog.Connectors[0].Http.BaseUrl.Should().Be("https://api.search.example.com"); + catalog.Connectors[0].TimeoutMs.Should().Be(30000); + } + + // ════════════════════════════════════════════════════════════ + // Scope isolation: two scopes don't share actors + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task GAgentActorStore_DifferentScopes_UseDifferentActors() + { + var runtime = new FakeActorRuntime(); + var logger = NullLogger.Instance; + + var scopeA = new FakeScopeResolver { ScopeIdToReturn = "scope-a" }; + var storeA = new ActorBackedGAgentActorStore(runtime, scopeA, logger); + await storeA.AddActorAsync("MyAgent", "actor-1"); + + var scopeB = new FakeScopeResolver { ScopeIdToReturn = "scope-b" }; + var storeB = new ActorBackedGAgentActorStore(runtime, scopeB, logger); + await storeB.AddActorAsync("MyAgent", "actor-2"); + + runtime.Actors.Should().ContainKey("gagent-registry-scope-a"); + runtime.Actors.Should().ContainKey("gagent-registry-scope-b"); + runtime.Actors["gagent-registry-scope-a"].ReceivedEnvelopes.Should().HaveCount(1); + runtime.Actors["gagent-registry-scope-b"].ReceivedEnvelopes.Should().HaveCount(1); + } + + // ════════════════════════════════════════════════════════════ + // Helper: stub IStudioWorkspaceStore for catalog tests + // ════════════════════════════════════════════════════════════ + + private sealed class StubWorkspaceStore : IStudioWorkspaceStore + { + public bool RoleDraftDeleted { get; private set; } + public bool ConnectorDraftDeleted { get; private set; } + public StoredRoleDraft? LastSavedRoleDraft { get; private set; } + public StoredConnectorDraft? LastSavedConnectorDraft { get; private set; } + + public Task GetSettingsAsync(CancellationToken ct = default) => + Task.FromResult(new StudioWorkspaceSettings("", [], "", "")); + public Task SaveSettingsAsync(StudioWorkspaceSettings settings, CancellationToken ct = default) => + Task.CompletedTask; + public Task> ListWorkflowFilesAsync(CancellationToken ct = default) => + Task.FromResult>([]); + public Task GetWorkflowFileAsync(string workflowId, CancellationToken ct = default) => + Task.FromResult(null); + public Task SaveWorkflowFileAsync(StoredWorkflowFile f, CancellationToken ct = default) => + Task.FromResult(f); + public Task> ListExecutionsAsync(CancellationToken ct = default) => + Task.FromResult>([]); + public Task GetExecutionAsync(string executionId, CancellationToken ct = default) => + Task.FromResult(null); + public Task SaveExecutionAsync(StoredExecutionRecord r, CancellationToken ct = default) => + Task.FromResult(r); + public Task GetConnectorCatalogAsync(CancellationToken ct = default) => + Task.FromResult(new StoredConnectorCatalog("", "", false, [])); + public Task SaveConnectorCatalogAsync(StoredConnectorCatalog c, CancellationToken ct = default) => + Task.FromResult(c); + public Task GetConnectorDraftAsync(CancellationToken ct = default) => + Task.FromResult(new StoredConnectorDraft("", "", false, null, null)); + public Task SaveConnectorDraftAsync(StoredConnectorDraft d, CancellationToken ct = default) + { + LastSavedConnectorDraft = d; + return Task.FromResult(d); + } + public Task DeleteConnectorDraftAsync(CancellationToken ct = default) + { + ConnectorDraftDeleted = true; + return Task.CompletedTask; + } + public Task GetRoleCatalogAsync(CancellationToken ct = default) => + Task.FromResult(new StoredRoleCatalog("", "", false, [])); + public Task SaveRoleCatalogAsync(StoredRoleCatalog c, CancellationToken ct = default) => + Task.FromResult(c); + public Task GetRoleDraftAsync(CancellationToken ct = default) => + Task.FromResult(new StoredRoleDraft("", "", false, null, null)); + public Task SaveRoleDraftAsync(StoredRoleDraft d, CancellationToken ct = default) + { + LastSavedRoleDraft = d; + return Task.FromResult(d); + } + public Task DeleteRoleDraftAsync(CancellationToken ct = default) + { + RoleDraftDeleted = true; + return Task.CompletedTask; + } + } +} diff --git a/test/Aevatar.Tools.Cli.Tests/ChronoStorageChatHistoryStoreTests.cs b/test/Aevatar.Tools.Cli.Tests/ChronoStorageChatHistoryStoreTests.cs deleted file mode 100644 index b398ba6b..00000000 --- a/test/Aevatar.Tools.Cli.Tests/ChronoStorageChatHistoryStoreTests.cs +++ /dev/null @@ -1,372 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Text; -using Aevatar.Studio.Application.Studio.Abstractions; -using Aevatar.Studio.Infrastructure.ScopeResolution; -using Aevatar.Studio.Infrastructure.Storage; -using FluentAssertions; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; - -namespace Aevatar.Tools.Cli.Tests; - -public sealed class ChronoStorageChatHistoryStoreTests -{ - [Fact] - public async Task GetIndexAsync_ShouldBuildConversationListFromJsonlFiles() - { - var storageServer = new InMemoryChronoStorageServer(); - storageServer.StoreText( - "aevatar-studio", - "user-prefix/scope-a/chat-histories/index.json", - "{\"conversations\":[{\"id\":\"stale\",\"title\":\"stale\"}]}"); - storageServer.StoreText( - "aevatar-studio", - "user-prefix/scope-a/chat-histories/NyxIdChat:scope-a.jsonl", - """ - {"id":"u1","role":"user","content":"你好","timestamp":1711968000000,"status":"complete"} - {"id":"a1","role":"assistant","content":"hi","timestamp":1711968060000,"status":"complete"} - """); - storageServer.StoreText( - "aevatar-studio", - "user-prefix/scope-a/chat-histories/workflowtest:run-1.jsonl", - """ - {"id":"u2","role":"user","content":"workflow question","timestamp":1711968120000,"status":"complete"} - {"id":"a2","role":"assistant","content":"workflow answer","timestamp":1711968180000,"status":"complete"} - """); - - var store = CreateStore(storageServer); - - var index = await store.GetIndexAsync("scope-a"); - - index.Conversations.Select(static conversation => conversation.Id).Should() - .Equal("workflowtest:run-1", "NyxIdChat:scope-a"); - index.Conversations[0].Title.Should().Be("workflow question"); - index.Conversations[0].ServiceId.Should().Be("workflowtest"); - index.Conversations[0].ServiceKind.Should().Be("service"); - index.Conversations[0].MessageCount.Should().Be(2); - index.Conversations[1].Title.Should().Be("你好"); - index.Conversations[1].ServiceId.Should().Be("nyxid-chat"); - index.Conversations[1].ServiceKind.Should().Be("nyxid-chat"); - } - - [Fact] - public async Task GetIndexAsync_ShouldUseSidecarWhenFresh() - { - var storageServer = new InMemoryChronoStorageServer(); - // .jsonl written at T0 - storageServer.StoreText( - "aevatar-studio", - "user-prefix/scope-a/chat-histories/NyxIdChat:scope-a.jsonl", - """ - {"id":"u1","role":"user","content":"original question","timestamp":1711968000000,"status":"complete"} - """, - "2026-04-01T00:00:00Z"); - // sidecar written at T1 (after .jsonl) — fresh - storageServer.StoreText( - "aevatar-studio", - "user-prefix/scope-a/chat-histories/_meta/NyxIdChat:scope-a.json", - """{"title":"original question","serviceId":"nyxid-chat","serviceKind":"nyxid-chat","createdAtMs":1711968000000,"updatedAtMs":1711968000000,"messageCount":1,"llmRoute":"","llmModel":"gpt-5.3-codex"}""", - "2026-04-01T00:01:00Z"); - - var store = CreateStore(storageServer); - var index = await store.GetIndexAsync("scope-a"); - - index.Conversations.Should().HaveCount(1); - index.Conversations[0].Title.Should().Be("original question"); - index.Conversations[0].MessageCount.Should().Be(1); - index.Conversations[0].LlmRoute.Should().Be(""); - index.Conversations[0].LlmModel.Should().Be("gpt-5.3-codex"); - } - - [Fact] - public async Task GetIndexAsync_ShouldFallbackWhenSidecarMissing() - { - var storageServer = new InMemoryChronoStorageServer(); - storageServer.StoreText( - "aevatar-studio", - "user-prefix/scope-a/chat-histories/NyxIdChat:scope-a.jsonl", - """ - {"id":"u1","role":"user","content":"legacy conversation","timestamp":1711968000000,"status":"complete"} - {"id":"a1","role":"assistant","content":"reply","timestamp":1711968060000,"status":"complete"} - """); - // No sidecar file at all - - var store = CreateStore(storageServer); - var index = await store.GetIndexAsync("scope-a"); - - index.Conversations.Should().HaveCount(1); - index.Conversations[0].Title.Should().Be("legacy conversation"); - index.Conversations[0].MessageCount.Should().Be(2); - - // Backfill should have written a sidecar (best-effort, give it a moment) - await Task.Delay(200); - storageServer.Objects.Should().ContainKey("aevatar-studio:user-prefix/scope-a/chat-histories/_meta/NyxIdChat:scope-a.json"); - } - - [Fact] - public async Task GetIndexAsync_ShouldFallbackWhenSidecarStale() - { - var storageServer = new InMemoryChronoStorageServer(); - // sidecar written at T0 - storageServer.StoreText( - "aevatar-studio", - "user-prefix/scope-a/chat-histories/_meta/conv-1.json", - """{"title":"old title","serviceId":"nyxid-chat","serviceKind":"nyxid-chat","createdAtMs":1711968000000,"updatedAtMs":1711968000000,"messageCount":1}""", - "2026-04-01T00:00:00Z"); - // .jsonl updated at T1 (after sidecar) — sidecar is stale - storageServer.StoreText( - "aevatar-studio", - "user-prefix/scope-a/chat-histories/conv-1.jsonl", - """ - {"id":"u1","role":"user","content":"new question","timestamp":1711968000000,"status":"complete"} - {"id":"a1","role":"assistant","content":"new answer","timestamp":1711968060000,"status":"complete"} - {"id":"u2","role":"user","content":"follow up","timestamp":1711968120000,"status":"complete"} - """, - "2026-04-01T01:00:00Z"); - - var store = CreateStore(storageServer); - var index = await store.GetIndexAsync("scope-a"); - - index.Conversations.Should().HaveCount(1); - index.Conversations[0].Title.Should().Be("new question"); - index.Conversations[0].MessageCount.Should().Be(3); - } - - [Fact] - public async Task SaveMessagesAsync_ShouldWriteSidecar() - { - var storageServer = new InMemoryChronoStorageServer(); - var store = CreateStore(storageServer); - var messages = new[] - { - new StoredChatMessage("u1", "user", "hello sidecar", 1711968000000, "complete"), - new StoredChatMessage("a1", "assistant", "hi there", 1711968060000, "complete"), - }; - var meta = new ConversationMeta( - "test-conv", - "hello sidecar", - "nyxid-chat", - "nyxid-chat", - DateTimeOffset.FromUnixTimeMilliseconds(1711968000000), - DateTimeOffset.FromUnixTimeMilliseconds(1711968060000), - 2, - LlmRoute: "", - LlmModel: "gpt-5.3-codex"); - - await store.SaveMessagesAsync("scope-a", "test-conv", meta, messages); - - storageServer.Objects.Should().ContainKey("aevatar-studio:user-prefix/scope-a/chat-histories/test-conv.jsonl"); - storageServer.Objects.Should().ContainKey("aevatar-studio:user-prefix/scope-a/chat-histories/_meta/test-conv.json"); - var sidecarJson = Encoding.UTF8.GetString(storageServer.Objects["aevatar-studio:user-prefix/scope-a/chat-histories/_meta/test-conv.json"]); - sidecarJson.Should().Contain("\"llmRoute\":\"\""); - sidecarJson.Should().Contain("\"llmModel\":\"gpt-5.3-codex\""); - } - - [Fact] - public async Task DeleteConversationAsync_ShouldDeleteSidecarAndJsonl() - { - var storageServer = new InMemoryChronoStorageServer(); - storageServer.StoreText( - "aevatar-studio", - "user-prefix/scope-a/chat-histories/conv-del.jsonl", - """{"id":"u1","role":"user","content":"bye","timestamp":1711968000000,"status":"complete"}"""); - storageServer.StoreText( - "aevatar-studio", - "user-prefix/scope-a/chat-histories/_meta/conv-del.json", - """{"title":"bye","serviceId":"nyxid-chat","serviceKind":"nyxid-chat","createdAtMs":1711968000000,"updatedAtMs":1711968000000,"messageCount":1}"""); - - var store = CreateStore(storageServer); - await store.DeleteConversationAsync("scope-a", "conv-del"); - - storageServer.Objects.Should().NotContainKey("aevatar-studio:user-prefix/scope-a/chat-histories/conv-del.jsonl"); - storageServer.Objects.Should().NotContainKey("aevatar-studio:user-prefix/scope-a/chat-histories/_meta/conv-del.json"); - } - - [Fact] - public async Task SaveMessagesAsync_ShouldDeleteLegacyIndexFile() - { - var storageServer = new InMemoryChronoStorageServer(); - storageServer.StoreText( - "aevatar-studio", - "user-prefix/scope-a/chat-histories/index.json", - "{\"conversations\":[]}"); - - var store = CreateStore(storageServer); - var messages = new[] - { - new StoredChatMessage("u1", "user", "hello", 1711968000000, "complete"), - }; - var meta = new ConversationMeta( - "NyxIdChat:scope-a", - "hello", - "nyxid-chat", - "nyxid-chat", - DateTimeOffset.FromUnixTimeMilliseconds(1711968000000), - DateTimeOffset.FromUnixTimeMilliseconds(1711968000000), - 1); - - await store.SaveMessagesAsync("scope-a", "NyxIdChat:scope-a", meta, messages); - - storageServer.Objects.Should().ContainKey("aevatar-studio:user-prefix/scope-a/chat-histories/NyxIdChat:scope-a.jsonl"); - storageServer.Objects.Should().NotContainKey("aevatar-studio:user-prefix/scope-a/chat-histories/index.json"); - } - - private static ChronoStorageChatHistoryStore CreateStore(InMemoryChronoStorageServer storageServer) - { - var blobClient = new ChronoStorageCatalogBlobClient( - new StubAppScopeResolver("scope-a"), - storageServer.CreateHttpClientFactory(), - Options.Create(new ConnectorCatalogStorageOptions - { - Enabled = true, - UseNyxProxy = false, - BaseUrl = "http://chrono-storage.test", - Bucket = "aevatar-studio", - Prefix = "connectors-prefix", - RolesPrefix = "roles-prefix", - UserConfigPrefix = "user-prefix", - })); - - return new ChronoStorageChatHistoryStore( - blobClient, - Options.Create(new ConnectorCatalogStorageOptions - { - Enabled = true, - UseNyxProxy = false, - BaseUrl = "http://chrono-storage.test", - Bucket = "aevatar-studio", - Prefix = "connectors-prefix", - RolesPrefix = "roles-prefix", - UserConfigPrefix = "user-prefix", - }), - NullLogger.Instance); - } - - private sealed class StubAppScopeResolver : IAppScopeResolver - { - private readonly AppScopeContext _context; - - public StubAppScopeResolver(string scopeId) - { - _context = new AppScopeContext(scopeId, "test"); - } - - public AppScopeContext? Resolve(Microsoft.AspNetCore.Http.HttpContext? httpContext = null) => _context; - } - - private sealed class InMemoryChronoStorageServer - { - public Dictionary Objects { get; } = new(StringComparer.Ordinal); - public Dictionary Timestamps { get; } = new(StringComparer.Ordinal); - - public void StoreText(string bucket, string key, string content, string? lastModified = null) - { - Objects[$"{bucket}:{key}"] = Encoding.UTF8.GetBytes(content); - Timestamps[$"{bucket}:{key}"] = lastModified ?? "2026-04-01T00:00:00Z"; - } - - public IHttpClientFactory CreateHttpClientFactory() => - new StubHttpClientFactory(new HttpClient(new Handler(this)) - { - BaseAddress = new Uri("http://chrono-storage.test/"), - }); - - private sealed class Handler : HttpMessageHandler - { - private readonly InMemoryChronoStorageServer _server; - - public Handler(InMemoryChronoStorageServer server) - { - _server = server; - } - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var uri = request.RequestUri ?? throw new InvalidOperationException("Request URI is required."); - var path = uri.AbsolutePath.Trim('/'); - - if (request.Method == HttpMethod.Get && string.Equals(path, "api/buckets/aevatar-studio/objects", StringComparison.Ordinal)) - { - var prefix = GetRequiredQueryValue(uri, "prefix"); - var objects = _server.Objects - .Where(static entry => entry.Key.StartsWith("aevatar-studio:", StringComparison.Ordinal)) - .Select(static entry => entry.Key["aevatar-studio:".Length..]) - .Where(key => key.StartsWith(prefix, StringComparison.Ordinal)) - .OrderBy(static key => key, StringComparer.Ordinal) - .Select(key => new - { - key, - lastModified = _server.Timestamps.GetValueOrDefault($"aevatar-studio:{key}", "2026-04-01T00:00:00Z"), - size = _server.Objects[$"aevatar-studio:{key}"].LongLength, - }) - .ToList(); - return CreateJsonResponse(HttpStatusCode.OK, new { data = new { objects }, error = (object?)null }); - } - - if (request.Method == HttpMethod.Post && string.Equals(path, "api/buckets/aevatar-studio/objects", StringComparison.Ordinal)) - { - var key = GetRequiredQueryValue(uri, "key"); - _server.Objects[$"aevatar-studio:{key}"] = await request.Content!.ReadAsByteArrayAsync(cancellationToken); - _server.Timestamps[$"aevatar-studio:{key}"] = DateTimeOffset.UtcNow.ToString("O"); - return CreateJsonResponse(HttpStatusCode.OK, new { data = new { stored = true }, error = (object?)null }); - } - - if (request.Method == HttpMethod.Get && string.Equals(path, "api/buckets/aevatar-studio/objects/download", StringComparison.Ordinal)) - { - var key = GetRequiredQueryValue(uri, "key"); - if (!_server.Objects.TryGetValue($"aevatar-studio:{key}", out var payload)) - { - return new HttpResponseMessage(HttpStatusCode.NotFound); - } - - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new ByteArrayContent(payload), - }; - } - - if (request.Method == HttpMethod.Delete && string.Equals(path, "api/buckets/aevatar-studio/objects", StringComparison.Ordinal)) - { - var key = GetRequiredQueryValue(uri, "key"); - _server.Objects.Remove($"aevatar-studio:{key}"); - return CreateJsonResponse(HttpStatusCode.OK, new { data = new { deleted = true }, error = (object?)null }); - } - - throw new InvalidOperationException($"Unhandled request {request.Method} {uri}."); - } - - private static string GetRequiredQueryValue(Uri uri, string key) - { - var query = uri.Query.TrimStart('?') - .Split('&', StringSplitOptions.RemoveEmptyEntries) - .Select(pair => pair.Split('=', 2)) - .ToDictionary( - pair => Uri.UnescapeDataString(pair[0]), - pair => pair.Length > 1 ? Uri.UnescapeDataString(pair[1]) : string.Empty, - StringComparer.Ordinal); - return query.TryGetValue(key, out var value) - ? value - : throw new InvalidOperationException($"Missing query key '{key}'."); - } - - private static HttpResponseMessage CreateJsonResponse(HttpStatusCode statusCode, object payload) => - new(statusCode) - { - Content = JsonContent.Create(payload), - }; - } - } - - private sealed class StubHttpClientFactory : IHttpClientFactory - { - private readonly HttpClient _client; - - public StubHttpClientFactory(HttpClient client) - { - _client = client; - } - - public HttpClient CreateClient(string name) => _client; - } -} diff --git a/test/Aevatar.Tools.Cli.Tests/ChronoStorageConnectorCatalogStoreTests.cs b/test/Aevatar.Tools.Cli.Tests/ChronoStorageConnectorCatalogStoreTests.cs deleted file mode 100644 index a4716959..00000000 --- a/test/Aevatar.Tools.Cli.Tests/ChronoStorageConnectorCatalogStoreTests.cs +++ /dev/null @@ -1,620 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Text; -using System.Text.Json; -using Aevatar.Studio.Application.Studio.Abstractions; -using Aevatar.Studio.Infrastructure.ScopeResolution; -using Aevatar.Studio.Infrastructure.Storage; -using FluentAssertions; -using Microsoft.Extensions.Options; - -namespace Aevatar.Tools.Cli.Tests; - -public sealed class ChronoStorageConnectorCatalogStoreTests -{ - [Fact] - public async Task SaveAndGetCatalogAsync_WhenRemoteEnabled_ShouldRoundTripScopeCatalog() - { - using var workspaceRoot = new TemporaryDirectory(); - var storageServer = new InMemoryChronoStorageServer(); - var store = CreateStore( - new InMemoryStudioWorkspaceStore(), - new StubAppScopeResolver("scope-alpha"), - storageServer.CreateHttpClientFactory(), - workspaceRoot.Path); - var catalog = new StoredConnectorCatalog( - HomeDirectory: string.Empty, - FilePath: string.Empty, - FileExists: false, - Connectors: - [ - CreateConnector("scope_web", "https://example.com/api"), - ]); - - var saved = await store.SaveConnectorCatalogAsync(catalog); - var loaded = await store.GetConnectorCatalogAsync(); - - saved.FileExists.Should().BeTrue(); - saved.FilePath.Should().Be("chrono-storage://aevatar-studio/scope-alpha/connectors.json"); - loaded.Connectors.Should().BeEquivalentTo(catalog.Connectors); - storageServer.Objects.Should().ContainKey("aevatar-studio:scope-alpha/connectors.json"); - Encoding.UTF8.GetString(storageServer.Objects["aevatar-studio:scope-alpha/connectors.json"]) - .Should().Contain("scope_web"); - } - - [Fact] - public async Task ImportLocalCatalogAsync_WhenRemoteEnabled_ShouldUploadLocalCatalog() - { - using var workspaceRoot = new TemporaryDirectory(); - var localStore = new InMemoryStudioWorkspaceStore - { - ConnectorCatalog = new StoredConnectorCatalog( - HomeDirectory: "/tmp/.aevatar", - FilePath: "/tmp/.aevatar/connectors.json", - FileExists: true, - Connectors: - [ - CreateConnector("imported_http", "https://import.example.com"), - ]), - }; - var storageServer = new InMemoryChronoStorageServer(); - var store = CreateStore( - localStore, - new StubAppScopeResolver("scope-import"), - storageServer.CreateHttpClientFactory(), - workspaceRoot.Path); - - var imported = await store.ImportLocalCatalogAsync(); - - imported.SourceFilePath.Should().Be("/tmp/.aevatar/connectors.json"); - imported.Catalog.FileExists.Should().BeTrue(); - imported.Catalog.FilePath.Should().Be("chrono-storage://aevatar-studio/scope-import/connectors.json"); - imported.Catalog.Connectors.Should().BeEquivalentTo(localStore.ConnectorCatalog.Connectors); - storageServer.Objects.Should().ContainKey("aevatar-studio:scope-import/connectors.json"); - } - - [Fact] - public async Task SaveAndGetCatalogAsync_WhenRemoteEnabled_ShouldPreserveNyxidRemoteMcpFields() - { - using var workspaceRoot = new TemporaryDirectory(); - var storageServer = new InMemoryChronoStorageServer(); - var store = CreateStore( - new InMemoryStudioWorkspaceStore(), - new StubAppScopeResolver("scope-nyxid"), - storageServer.CreateHttpClientFactory(), - workspaceRoot.Path); - var catalog = new StoredConnectorCatalog( - HomeDirectory: string.Empty, - FilePath: string.Empty, - FileExists: false, - Connectors: - [ - CreateRemoteMcpConnector(), - ]); - - await store.SaveConnectorCatalogAsync(catalog); - var loaded = await store.GetConnectorCatalogAsync(); - - loaded.Connectors.Should().BeEquivalentTo(catalog.Connectors); - } - - [Fact] - public async Task DraftOperations_WhenRemoteEnabled_ShouldUseScopeScopedRemoteDraftFiles() - { - using var workspaceRoot = new TemporaryDirectory(); - var storageServer = new InMemoryChronoStorageServer(); - var draft = new StoredConnectorDraft( - HomeDirectory: string.Empty, - FilePath: string.Empty, - FileExists: false, - UpdatedAtUtc: DateTimeOffset.Parse("2026-03-18T09:30:00Z"), - Draft: CreateConnector("draft_connector", "https://draft.example.com")); - var scopeAStore = CreateStore( - new InMemoryStudioWorkspaceStore(), - new StubAppScopeResolver("scope-a"), - storageServer.CreateHttpClientFactory(), - workspaceRoot.Path); - var scopeBStore = CreateStore( - new InMemoryStudioWorkspaceStore(), - new StubAppScopeResolver("scope-b"), - storageServer.CreateHttpClientFactory(), - workspaceRoot.Path); - - var savedDraft = await scopeAStore.SaveConnectorDraftAsync(draft); - var loadedDraft = await scopeAStore.GetConnectorDraftAsync(); - var otherScopeDraft = await scopeBStore.GetConnectorDraftAsync(); - - savedDraft.FileExists.Should().BeTrue(); - savedDraft.FilePath.Should().Be("chrono-storage://aevatar-studio/scope-a/connectors.draft.json"); - loadedDraft.Draft.Should().BeEquivalentTo(draft.Draft); - otherScopeDraft.FileExists.Should().BeFalse(); - otherScopeDraft.Draft.Should().BeNull(); - storageServer.Objects.Should().ContainKey("aevatar-studio:scope-a/connectors.draft.json"); - } - - [Fact] - public async Task DeleteConnectorDraftAsync_WhenRemoteEnabled_ShouldDeleteRemoteDraftObject() - { - using var workspaceRoot = new TemporaryDirectory(); - var storageServer = new InMemoryChronoStorageServer(); - var store = CreateStore( - new InMemoryStudioWorkspaceStore(), - new StubAppScopeResolver("scope-delete"), - storageServer.CreateHttpClientFactory(), - workspaceRoot.Path); - - await store.SaveConnectorDraftAsync( - new StoredConnectorDraft( - HomeDirectory: string.Empty, - FilePath: string.Empty, - FileExists: false, - UpdatedAtUtc: DateTimeOffset.Parse("2026-03-18T09:30:00Z"), - Draft: CreateConnector("draft_connector", "https://draft.example.com"))); - - await store.DeleteConnectorDraftAsync(); - - storageServer.Objects.Should().NotContainKey("aevatar-studio:scope-delete/connectors.draft.json"); - var loadedDraft = await store.GetConnectorDraftAsync(); - loadedDraft.FileExists.Should().BeFalse(); - loadedDraft.Draft.Should().BeNull(); - } - - [Fact] - public async Task GetConnectorCatalogAsync_WhenDownloadUrlReturnsNotFound_ShouldTreatCatalogAsMissing() - { - using var workspaceRoot = new TemporaryDirectory(); - var storageServer = new BrokenDownloadChronoStorageServer(); - storageServer.MarkObjectPresent("aevatar-studio", "scope-missing/connectors.json"); - var store = CreateStore( - new InMemoryStudioWorkspaceStore(), - new StubAppScopeResolver("scope-missing"), - storageServer.CreateHttpClientFactory(), - workspaceRoot.Path); - - var catalog = await store.GetConnectorCatalogAsync(); - - catalog.FileExists.Should().BeFalse(); - catalog.Connectors.Should().BeEmpty(); - catalog.FilePath.Should().Be("chrono-storage://aevatar-studio/scope-missing/connectors.json"); - } - - private static ChronoStorageConnectorCatalogStore CreateStore( - IStudioWorkspaceStore localStore, - IAppScopeResolver scopeResolver, - IHttpClientFactory httpClientFactory, - string workspaceRoot) - { - var options = CreateOptions(); - var blobClient = new ChronoStorageCatalogBlobClient(scopeResolver, httpClientFactory, options); - return new ChronoStorageConnectorCatalogStore( - localStore, - blobClient, - options, - Options.Create(new StudioStorageOptions - { - RootDirectory = workspaceRoot, - })); - } - - private static IOptions CreateOptions() => - Options.Create(new ConnectorCatalogStorageOptions - { - Enabled = true, - UseNyxProxy = false, - BaseUrl = "http://chrono-storage.test", - Bucket = "aevatar-studio", - Prefix = string.Empty, - RolesPrefix = string.Empty, - }); - - private static StoredConnectorDefinition CreateConnector(string name, string baseUrl) => - new( - Name: name, - Type: "http", - Enabled: true, - TimeoutMs: 30_000, - Retry: 1, - Http: new StoredHttpConnectorConfig( - BaseUrl: baseUrl, - AllowedMethods: ["POST"], - AllowedPaths: ["/"], - AllowedInputKeys: ["input"], - DefaultHeaders: new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["Authorization"] = "Bearer demo", - }, - Auth: new StoredConnectorAuthConfig( - Type: "client_credentials", - TokenUrl: "https://auth.example.com/oauth/token", - ClientId: "http-client", - ClientSecret: "http-secret", - Scope: "proxy:*")), - Cli: new StoredCliConnectorConfig( - Command: string.Empty, - FixedArguments: [], - AllowedOperations: [], - AllowedInputKeys: [], - WorkingDirectory: string.Empty, - Environment: new Dictionary(StringComparer.OrdinalIgnoreCase)), - Mcp: new StoredMcpConnectorConfig( - ServerName: string.Empty, - Command: string.Empty, - Url: string.Empty, - Arguments: [], - Environment: new Dictionary(StringComparer.OrdinalIgnoreCase), - AdditionalHeaders: new Dictionary(StringComparer.OrdinalIgnoreCase), - Auth: new StoredConnectorAuthConfig(string.Empty, string.Empty, string.Empty, string.Empty, string.Empty), - DefaultTool: string.Empty, - AllowedTools: [], - AllowedInputKeys: [])); - - private static StoredConnectorDefinition CreateRemoteMcpConnector() => - new( - Name: "nyxid_mcp", - Type: "mcp", - Enabled: true, - TimeoutMs: 60_000, - Retry: 1, - Http: new StoredHttpConnectorConfig( - BaseUrl: string.Empty, - AllowedMethods: [], - AllowedPaths: [], - AllowedInputKeys: [], - DefaultHeaders: new Dictionary(StringComparer.OrdinalIgnoreCase), - Auth: new StoredConnectorAuthConfig(string.Empty, string.Empty, string.Empty, string.Empty, string.Empty)), - Cli: new StoredCliConnectorConfig( - Command: string.Empty, - FixedArguments: [], - AllowedOperations: [], - AllowedInputKeys: [], - WorkingDirectory: string.Empty, - Environment: new Dictionary(StringComparer.OrdinalIgnoreCase)), - Mcp: new StoredMcpConnectorConfig( - ServerName: "nyxid", - Command: string.Empty, - Url: "https://nyxid.example.com/mcp", - Arguments: [], - Environment: new Dictionary(StringComparer.OrdinalIgnoreCase), - AdditionalHeaders: new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["x-tenant"] = "demo", - }, - Auth: new StoredConnectorAuthConfig( - Type: "client_credentials", - TokenUrl: "https://auth.example.com/oauth/token", - ClientId: "mcp-client", - ClientSecret: "mcp-secret", - Scope: "proxy:*"), - DefaultTool: "chrono-graph__query", - AllowedTools: ["chrono-graph__query"], - AllowedInputKeys: ["query"])); - - private sealed class StubAppScopeResolver : IAppScopeResolver - { - private readonly AppScopeContext _context; - - public StubAppScopeResolver(string scopeId) - { - _context = new AppScopeContext(scopeId, "test"); - } - - public AppScopeContext? Resolve(Microsoft.AspNetCore.Http.HttpContext? httpContext = null) => _context; - } - - private sealed class InMemoryStudioWorkspaceStore : IStudioWorkspaceStore - { - public StoredConnectorCatalog ConnectorCatalog { get; set; } = new( - HomeDirectory: string.Empty, - FilePath: string.Empty, - FileExists: false, - Connectors: []); - - public Task GetSettingsAsync(CancellationToken cancellationToken = default) => - Task.FromResult(new StudioWorkspaceSettings("http://127.0.0.1:5100", [], "blue", "light")); - - public Task SaveSettingsAsync(StudioWorkspaceSettings settings, CancellationToken cancellationToken = default) => Task.CompletedTask; - - public Task> ListWorkflowFilesAsync(CancellationToken cancellationToken = default) => - Task.FromResult>([]); - - public Task GetWorkflowFileAsync(string workflowId, CancellationToken cancellationToken = default) => - Task.FromResult(null); - - public Task SaveWorkflowFileAsync(StoredWorkflowFile workflowFile, CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task> ListExecutionsAsync(CancellationToken cancellationToken = default) => - Task.FromResult>([]); - - public Task GetExecutionAsync(string executionId, CancellationToken cancellationToken = default) => - Task.FromResult(null); - - public Task SaveExecutionAsync(StoredExecutionRecord execution, CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task GetConnectorCatalogAsync(CancellationToken cancellationToken = default) => - Task.FromResult(ConnectorCatalog); - - public Task SaveConnectorCatalogAsync(StoredConnectorCatalog catalog, CancellationToken cancellationToken = default) - { - ConnectorCatalog = catalog with { FileExists = true }; - return Task.FromResult(ConnectorCatalog); - } - - public Task GetConnectorDraftAsync(CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task SaveConnectorDraftAsync(StoredConnectorDraft draft, CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task DeleteConnectorDraftAsync(CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task GetRoleCatalogAsync(CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task SaveRoleCatalogAsync(StoredRoleCatalog catalog, CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task GetRoleDraftAsync(CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task SaveRoleDraftAsync(StoredRoleDraft draft, CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task DeleteRoleDraftAsync(CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - } - - private sealed class InMemoryChronoStorageServer - { - private readonly HashSet _buckets = []; - - public Dictionary Objects { get; } = new(StringComparer.Ordinal); - - public IHttpClientFactory CreateHttpClientFactory() => new StubHttpClientFactory(new HttpClient(new Handler(this)) - { - BaseAddress = new Uri("http://chrono-storage.test/"), - }); - - private sealed class Handler : HttpMessageHandler - { - private readonly InMemoryChronoStorageServer _server; - - public Handler(InMemoryChronoStorageServer server) - { - _server = server; - } - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var uri = request.RequestUri ?? throw new InvalidOperationException("Request URI is required."); - if (string.Equals(uri.Host, "download.local", StringComparison.OrdinalIgnoreCase)) - { - return _server.HandleDownload(uri); - } - - var path = uri.AbsolutePath.Trim('/'); - if (request.Method == HttpMethod.Head && path.StartsWith("api/buckets/", StringComparison.Ordinal)) - { - var bucket = path["api/buckets/".Length..]; - return new HttpResponseMessage(_server._buckets.Contains(bucket) ? HttpStatusCode.OK : HttpStatusCode.NotFound); - } - - if (request.Method == HttpMethod.Post && string.Equals(path, "api/buckets", StringComparison.Ordinal)) - { - var payload = await request.Content!.ReadFromJsonAsync(cancellationToken); - var name = payload.GetProperty("name").GetString() ?? string.Empty; - _server._buckets.Add(name); - return CreateJsonResponse(HttpStatusCode.Created, new { data = new { name, created = true }, error = (object?)null }); - } - - if (request.Method == HttpMethod.Post && string.Equals(path, "api/buckets/aevatar-studio/objects", StringComparison.Ordinal)) - { - var key = GetRequiredQueryValue(uri, "key"); - _server._buckets.Add("aevatar-studio"); - _server.Objects[$"aevatar-studio:{key}"] = await request.Content!.ReadAsByteArrayAsync(cancellationToken); - return CreateJsonResponse(HttpStatusCode.OK, new { data = new { stored = true }, error = (object?)null }); - } - - if (request.Method == HttpMethod.Delete && string.Equals(path, "api/buckets/aevatar-studio/objects", StringComparison.Ordinal)) - { - var key = GetRequiredQueryValue(uri, "key"); - _server.Objects.Remove($"aevatar-studio:{key}"); - return CreateJsonResponse(HttpStatusCode.OK, new { data = new { deleted = true }, error = (object?)null }); - } - - if (request.Method == HttpMethod.Get && string.Equals(path, "api/buckets/aevatar-studio/objects/download", StringComparison.Ordinal)) - { - var key = GetRequiredQueryValue(uri, "key"); - if (!_server.Objects.TryGetValue($"aevatar-studio:{key}", out var payload)) - { - return new HttpResponseMessage(HttpStatusCode.NotFound); - } - - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new ByteArrayContent(payload), - }; - } - - if (request.Method == HttpMethod.Get && string.Equals(path, "api/buckets/aevatar-studio/presigned-url", StringComparison.Ordinal)) - { - var key = GetRequiredQueryValue(uri, "key"); - if (!_server.Objects.ContainsKey($"aevatar-studio:{key}")) - { - return new HttpResponseMessage(HttpStatusCode.NotFound); - } - - return CreateJsonResponse( - HttpStatusCode.OK, - new - { - data = new - { - url = $"http://download.local/aevatar-studio/{Uri.EscapeDataString(key)}", - }, - error = (object?)null, - }); - } - - throw new InvalidOperationException($"Unhandled request {request.Method} {uri}."); - } - - private static string GetRequiredQueryValue(Uri uri, string key) - { - var query = uri.Query.TrimStart('?') - .Split('&', StringSplitOptions.RemoveEmptyEntries) - .Select(pair => pair.Split('=', 2)) - .ToDictionary( - pair => Uri.UnescapeDataString(pair[0]), - pair => pair.Length > 1 ? Uri.UnescapeDataString(pair[1]) : string.Empty, - StringComparer.Ordinal); - return query.TryGetValue(key, out var value) - ? value - : throw new InvalidOperationException($"Missing query key '{key}'."); - } - - private static HttpResponseMessage CreateJsonResponse(HttpStatusCode statusCode, object payload) => - new(statusCode) - { - Content = JsonContent.Create(payload), - }; - } - - private HttpResponseMessage HandleDownload(Uri uri) - { - var segments = uri.AbsolutePath.Trim('/').Split('/', 2, StringSplitOptions.RemoveEmptyEntries); - var bucket = segments[0]; - var key = Uri.UnescapeDataString(segments[1]); - if (!Objects.TryGetValue($"{bucket}:{key}", out var payload)) - { - return new HttpResponseMessage(HttpStatusCode.NotFound); - } - - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new ByteArrayContent(payload), - }; - } - } - - private sealed class BrokenDownloadChronoStorageServer - { - private readonly HashSet _presentObjects = []; - - public void MarkObjectPresent(string bucket, string objectKey) => - _presentObjects.Add($"{bucket}:{objectKey}"); - - public IHttpClientFactory CreateHttpClientFactory() - { - var client = new HttpClient(new Handler(_presentObjects)) - { - BaseAddress = new Uri("http://chrono-storage.test/"), - }; - return new StubHttpClientFactory(client); - } - - private sealed class Handler : HttpMessageHandler - { - private readonly IReadOnlySet _presentObjects; - - public Handler(IReadOnlySet presentObjects) - { - _presentObjects = presentObjects; - } - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var uri = request.RequestUri ?? throw new InvalidOperationException("Request URI is required."); - if (request.Method == HttpMethod.Get && - string.Equals(uri.AbsolutePath.Trim('/'), "api/buckets/aevatar-studio/objects/download", StringComparison.Ordinal)) - { - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); - } - - if (request.Method == HttpMethod.Get && - string.Equals(uri.AbsolutePath.Trim('/'), "api/buckets/aevatar-studio/presigned-url", StringComparison.Ordinal)) - { - var key = GetRequiredQueryValue(uri, "key"); - if (!_presentObjects.Contains($"aevatar-studio:{key}")) - { - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); - } - - return Task.FromResult(CreateJsonResponse( - HttpStatusCode.OK, - new - { - data = new - { - presignedUrl = $"http://download.local/missing/{Uri.EscapeDataString(key)}", - }, - error = (object?)null, - })); - } - - if (string.Equals(uri.Host, "download.local", StringComparison.OrdinalIgnoreCase)) - { - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); - } - - throw new InvalidOperationException($"Unhandled request {request.Method} {uri}."); - } - - private static string GetRequiredQueryValue(Uri uri, string key) - { - var query = uri.Query.TrimStart('?') - .Split('&', StringSplitOptions.RemoveEmptyEntries) - .Select(pair => pair.Split('=', 2)) - .ToDictionary( - pair => Uri.UnescapeDataString(pair[0]), - pair => pair.Length > 1 ? Uri.UnescapeDataString(pair[1]) : string.Empty, - StringComparer.Ordinal); - return query.TryGetValue(key, out var value) - ? value - : throw new InvalidOperationException($"Missing query key '{key}'."); - } - - private static HttpResponseMessage CreateJsonResponse(HttpStatusCode statusCode, object payload) => - new(statusCode) - { - Content = JsonContent.Create(payload), - }; - } - } - - private sealed class StubHttpClientFactory : IHttpClientFactory - { - private readonly HttpClient _client; - - public StubHttpClientFactory(HttpClient client) - { - _client = client; - } - - public HttpClient CreateClient(string name) => _client; - } - - private sealed class TemporaryDirectory : IDisposable - { - public TemporaryDirectory() - { - Path = System.IO.Path.Combine( - System.IO.Path.GetTempPath(), - "aevatar-connector-store-tests", - Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(Path); - } - - public string Path { get; } - - public void Dispose() - { - if (Directory.Exists(Path)) - { - Directory.Delete(Path, recursive: true); - } - } - } -} diff --git a/test/Aevatar.Tools.Cli.Tests/ChronoStorageRoleCatalogStoreTests.cs b/test/Aevatar.Tools.Cli.Tests/ChronoStorageRoleCatalogStoreTests.cs deleted file mode 100644 index b57629ff..00000000 --- a/test/Aevatar.Tools.Cli.Tests/ChronoStorageRoleCatalogStoreTests.cs +++ /dev/null @@ -1,522 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Text; -using System.Text.Json; -using Aevatar.Studio.Application.Studio.Abstractions; -using Aevatar.Studio.Infrastructure.ScopeResolution; -using Aevatar.Studio.Infrastructure.Storage; -using FluentAssertions; -using Microsoft.Extensions.Options; - -namespace Aevatar.Tools.Cli.Tests; - -public sealed class ChronoStorageRoleCatalogStoreTests -{ - [Fact] - public async Task SaveAndGetCatalogAsync_WhenRemoteEnabled_ShouldRoundTripScopeCatalog() - { - using var workspaceRoot = new TemporaryDirectory(); - var storageServer = new InMemoryChronoStorageServer(); - var store = CreateStore( - new InMemoryStudioWorkspaceStore(), - new StubAppScopeResolver("role-scope"), - storageServer.CreateHttpClientFactory(), - workspaceRoot.Path); - var catalog = new StoredRoleCatalog( - HomeDirectory: string.Empty, - FilePath: string.Empty, - FileExists: false, - Roles: - [ - CreateRole("assistant", "Main Assistant"), - ]); - - var saved = await store.SaveRoleCatalogAsync(catalog); - var loaded = await store.GetRoleCatalogAsync(); - - saved.FileExists.Should().BeTrue(); - saved.FilePath.Should().Be("chrono-storage://aevatar-studio/role-scope/roles.json"); - loaded.Roles.Should().BeEquivalentTo(catalog.Roles); - storageServer.Objects.Should().ContainKey("aevatar-studio:role-scope/roles.json"); - Encoding.UTF8.GetString(storageServer.Objects["aevatar-studio:role-scope/roles.json"]) - .Should().Contain("Main Assistant"); - } - - [Fact] - public async Task ImportLocalCatalogAsync_WhenRemoteEnabled_ShouldUploadLocalRoles() - { - using var workspaceRoot = new TemporaryDirectory(); - var localStore = new InMemoryStudioWorkspaceStore - { - RoleCatalog = new StoredRoleCatalog( - HomeDirectory: "/tmp/.aevatar", - FilePath: "/tmp/.aevatar/roles.json", - FileExists: true, - Roles: - [ - CreateRole("reviewer", "Reviewer"), - ]), - }; - var storageServer = new InMemoryChronoStorageServer(); - var store = CreateStore( - localStore, - new StubAppScopeResolver("role-import"), - storageServer.CreateHttpClientFactory(), - workspaceRoot.Path); - - var imported = await store.ImportLocalCatalogAsync(); - - imported.SourceFilePath.Should().Be("/tmp/.aevatar/roles.json"); - imported.Catalog.FileExists.Should().BeTrue(); - imported.Catalog.FilePath.Should().Be("chrono-storage://aevatar-studio/role-import/roles.json"); - imported.Catalog.Roles.Should().BeEquivalentTo(localStore.RoleCatalog.Roles); - storageServer.Objects.Should().ContainKey("aevatar-studio:role-import/roles.json"); - } - - [Fact] - public async Task DraftOperations_WhenRemoteEnabled_ShouldUseScopeScopedRemoteDraftFiles() - { - using var workspaceRoot = new TemporaryDirectory(); - var storageServer = new InMemoryChronoStorageServer(); - var draft = new StoredRoleDraft( - HomeDirectory: string.Empty, - FilePath: string.Empty, - FileExists: false, - UpdatedAtUtc: DateTimeOffset.Parse("2026-03-18T09:30:00Z"), - Draft: CreateRole("catalog_admin", "Catalog Admin")); - var scopeAStore = CreateStore( - new InMemoryStudioWorkspaceStore(), - new StubAppScopeResolver("scope-a"), - storageServer.CreateHttpClientFactory(), - workspaceRoot.Path); - var scopeBStore = CreateStore( - new InMemoryStudioWorkspaceStore(), - new StubAppScopeResolver("scope-b"), - storageServer.CreateHttpClientFactory(), - workspaceRoot.Path); - - var savedDraft = await scopeAStore.SaveRoleDraftAsync(draft); - var loadedDraft = await scopeAStore.GetRoleDraftAsync(); - var otherScopeDraft = await scopeBStore.GetRoleDraftAsync(); - - savedDraft.FileExists.Should().BeTrue(); - savedDraft.FilePath.Should().Be("chrono-storage://aevatar-studio/scope-a/roles.draft.json"); - loadedDraft.Draft.Should().BeEquivalentTo(draft.Draft); - otherScopeDraft.FileExists.Should().BeFalse(); - otherScopeDraft.Draft.Should().BeNull(); - storageServer.Objects.Should().ContainKey("aevatar-studio:scope-a/roles.draft.json"); - } - - [Fact] - public async Task DeleteRoleDraftAsync_WhenRemoteEnabled_ShouldDeleteRemoteDraftObject() - { - using var workspaceRoot = new TemporaryDirectory(); - var storageServer = new InMemoryChronoStorageServer(); - var store = CreateStore( - new InMemoryStudioWorkspaceStore(), - new StubAppScopeResolver("scope-delete"), - storageServer.CreateHttpClientFactory(), - workspaceRoot.Path); - - await store.SaveRoleDraftAsync( - new StoredRoleDraft( - HomeDirectory: string.Empty, - FilePath: string.Empty, - FileExists: false, - UpdatedAtUtc: DateTimeOffset.Parse("2026-03-18T09:30:00Z"), - Draft: CreateRole("catalog_admin", "Catalog Admin"))); - - await store.DeleteRoleDraftAsync(); - - storageServer.Objects.Should().NotContainKey("aevatar-studio:scope-delete/roles.draft.json"); - var loadedDraft = await store.GetRoleDraftAsync(); - loadedDraft.FileExists.Should().BeFalse(); - loadedDraft.Draft.Should().BeNull(); - } - - [Fact] - public async Task GetRoleCatalogAsync_WhenDownloadUrlReturnsNotFound_ShouldTreatCatalogAsMissing() - { - using var workspaceRoot = new TemporaryDirectory(); - var storageServer = new BrokenDownloadChronoStorageServer(); - storageServer.MarkObjectPresent("aevatar-studio", "scope-missing/roles.json"); - var store = CreateStore( - new InMemoryStudioWorkspaceStore(), - new StubAppScopeResolver("scope-missing"), - storageServer.CreateHttpClientFactory(), - workspaceRoot.Path); - - var catalog = await store.GetRoleCatalogAsync(); - - catalog.FileExists.Should().BeFalse(); - catalog.Roles.Should().BeEmpty(); - catalog.FilePath.Should().Be("chrono-storage://aevatar-studio/scope-missing/roles.json"); - } - - private static ChronoStorageRoleCatalogStore CreateStore( - IStudioWorkspaceStore localStore, - IAppScopeResolver scopeResolver, - IHttpClientFactory httpClientFactory, - string workspaceRoot) - { - var options = CreateOptions(); - var blobClient = new ChronoStorageCatalogBlobClient(scopeResolver, httpClientFactory, options); - return new ChronoStorageRoleCatalogStore( - localStore, - blobClient, - options, - Options.Create(new StudioStorageOptions - { - RootDirectory = workspaceRoot, - })); - } - - private static IOptions CreateOptions() => - Options.Create(new ConnectorCatalogStorageOptions - { - Enabled = true, - UseNyxProxy = false, - BaseUrl = "http://chrono-storage.test", - Bucket = "aevatar-studio", - Prefix = string.Empty, - RolesPrefix = string.Empty, - }); - - private static StoredRoleDefinition CreateRole(string id, string name) => - new( - Id: id, - Name: name, - SystemPrompt: "You are helpful.", - Provider: "openai-main", - Model: "gpt-test", - Connectors: ["scope_web"]); - - private sealed class StubAppScopeResolver : IAppScopeResolver - { - private readonly AppScopeContext _context; - - public StubAppScopeResolver(string scopeId) - { - _context = new AppScopeContext(scopeId, "test"); - } - - public AppScopeContext? Resolve(Microsoft.AspNetCore.Http.HttpContext? httpContext = null) => _context; - } - - private sealed class InMemoryStudioWorkspaceStore : IStudioWorkspaceStore - { - public StoredRoleCatalog RoleCatalog { get; set; } = new( - HomeDirectory: string.Empty, - FilePath: string.Empty, - FileExists: false, - Roles: []); - - public Task GetSettingsAsync(CancellationToken cancellationToken = default) => - Task.FromResult(new StudioWorkspaceSettings("http://127.0.0.1:5100", [], "blue", "light")); - - public Task SaveSettingsAsync(StudioWorkspaceSettings settings, CancellationToken cancellationToken = default) => Task.CompletedTask; - - public Task> ListWorkflowFilesAsync(CancellationToken cancellationToken = default) => - Task.FromResult>([]); - - public Task GetWorkflowFileAsync(string workflowId, CancellationToken cancellationToken = default) => - Task.FromResult(null); - - public Task SaveWorkflowFileAsync(StoredWorkflowFile workflowFile, CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task> ListExecutionsAsync(CancellationToken cancellationToken = default) => - Task.FromResult>([]); - - public Task GetExecutionAsync(string executionId, CancellationToken cancellationToken = default) => - Task.FromResult(null); - - public Task SaveExecutionAsync(StoredExecutionRecord execution, CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task GetConnectorCatalogAsync(CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task SaveConnectorCatalogAsync(StoredConnectorCatalog catalog, CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task GetConnectorDraftAsync(CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task SaveConnectorDraftAsync(StoredConnectorDraft draft, CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task DeleteConnectorDraftAsync(CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task GetRoleCatalogAsync(CancellationToken cancellationToken = default) => - Task.FromResult(RoleCatalog); - - public Task SaveRoleCatalogAsync(StoredRoleCatalog catalog, CancellationToken cancellationToken = default) - { - RoleCatalog = catalog with { FileExists = true }; - return Task.FromResult(RoleCatalog); - } - - public Task GetRoleDraftAsync(CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task SaveRoleDraftAsync(StoredRoleDraft draft, CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task DeleteRoleDraftAsync(CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - } - - private sealed class InMemoryChronoStorageServer - { - private readonly HashSet _buckets = []; - - public Dictionary Objects { get; } = new(StringComparer.Ordinal); - - public IHttpClientFactory CreateHttpClientFactory() => new StubHttpClientFactory(new HttpClient(new Handler(this)) - { - BaseAddress = new Uri("http://chrono-storage.test/"), - }); - - private sealed class Handler : HttpMessageHandler - { - private readonly InMemoryChronoStorageServer _server; - - public Handler(InMemoryChronoStorageServer server) - { - _server = server; - } - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var uri = request.RequestUri ?? throw new InvalidOperationException("Request URI is required."); - if (string.Equals(uri.Host, "download.local", StringComparison.OrdinalIgnoreCase)) - { - return _server.HandleDownload(uri); - } - - var path = uri.AbsolutePath.Trim('/'); - if (request.Method == HttpMethod.Head && path.StartsWith("api/buckets/", StringComparison.Ordinal)) - { - var bucket = path["api/buckets/".Length..]; - return new HttpResponseMessage(_server._buckets.Contains(bucket) ? HttpStatusCode.OK : HttpStatusCode.NotFound); - } - - if (request.Method == HttpMethod.Post && string.Equals(path, "api/buckets", StringComparison.Ordinal)) - { - var payload = await request.Content!.ReadFromJsonAsync(cancellationToken); - var name = payload.GetProperty("name").GetString() ?? string.Empty; - _server._buckets.Add(name); - return CreateJsonResponse(HttpStatusCode.Created, new { data = new { name, created = true }, error = (object?)null }); - } - - if (request.Method == HttpMethod.Post && string.Equals(path, "api/buckets/aevatar-studio/objects", StringComparison.Ordinal)) - { - var key = GetRequiredQueryValue(uri, "key"); - _server._buckets.Add("aevatar-studio"); - _server.Objects[$"aevatar-studio:{key}"] = await request.Content!.ReadAsByteArrayAsync(cancellationToken); - return CreateJsonResponse(HttpStatusCode.OK, new { data = new { stored = true }, error = (object?)null }); - } - - if (request.Method == HttpMethod.Delete && string.Equals(path, "api/buckets/aevatar-studio/objects", StringComparison.Ordinal)) - { - var key = GetRequiredQueryValue(uri, "key"); - _server.Objects.Remove($"aevatar-studio:{key}"); - return CreateJsonResponse(HttpStatusCode.OK, new { data = new { deleted = true }, error = (object?)null }); - } - - if (request.Method == HttpMethod.Get && string.Equals(path, "api/buckets/aevatar-studio/objects/download", StringComparison.Ordinal)) - { - var key = GetRequiredQueryValue(uri, "key"); - if (!_server.Objects.TryGetValue($"aevatar-studio:{key}", out var payload)) - { - return new HttpResponseMessage(HttpStatusCode.NotFound); - } - - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new ByteArrayContent(payload), - }; - } - - if (request.Method == HttpMethod.Get && string.Equals(path, "api/buckets/aevatar-studio/presigned-url", StringComparison.Ordinal)) - { - var key = GetRequiredQueryValue(uri, "key"); - if (!_server.Objects.ContainsKey($"aevatar-studio:{key}")) - { - return new HttpResponseMessage(HttpStatusCode.NotFound); - } - - return CreateJsonResponse( - HttpStatusCode.OK, - new - { - data = new - { - url = $"http://download.local/aevatar-studio/{Uri.EscapeDataString(key)}", - }, - error = (object?)null, - }); - } - - throw new InvalidOperationException($"Unhandled request {request.Method} {uri}."); - } - - private static string GetRequiredQueryValue(Uri uri, string key) - { - var query = uri.Query.TrimStart('?') - .Split('&', StringSplitOptions.RemoveEmptyEntries) - .Select(pair => pair.Split('=', 2)) - .ToDictionary( - pair => Uri.UnescapeDataString(pair[0]), - pair => pair.Length > 1 ? Uri.UnescapeDataString(pair[1]) : string.Empty, - StringComparer.Ordinal); - return query.TryGetValue(key, out var value) - ? value - : throw new InvalidOperationException($"Missing query key '{key}'."); - } - - private static HttpResponseMessage CreateJsonResponse(HttpStatusCode statusCode, object payload) => - new(statusCode) - { - Content = JsonContent.Create(payload), - }; - } - - private HttpResponseMessage HandleDownload(Uri uri) - { - var segments = uri.AbsolutePath.Trim('/').Split('/', 2, StringSplitOptions.RemoveEmptyEntries); - var bucket = segments[0]; - var key = Uri.UnescapeDataString(segments[1]); - if (!Objects.TryGetValue($"{bucket}:{key}", out var payload)) - { - return new HttpResponseMessage(HttpStatusCode.NotFound); - } - - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new ByteArrayContent(payload), - }; - } - } - - private sealed class BrokenDownloadChronoStorageServer - { - private readonly HashSet _presentObjects = []; - - public void MarkObjectPresent(string bucket, string objectKey) => - _presentObjects.Add($"{bucket}:{objectKey}"); - - public IHttpClientFactory CreateHttpClientFactory() - { - var client = new HttpClient(new Handler(_presentObjects)) - { - BaseAddress = new Uri("http://chrono-storage.test/"), - }; - return new StubHttpClientFactory(client); - } - - private sealed class Handler : HttpMessageHandler - { - private readonly IReadOnlySet _presentObjects; - - public Handler(IReadOnlySet presentObjects) - { - _presentObjects = presentObjects; - } - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var uri = request.RequestUri ?? throw new InvalidOperationException("Request URI is required."); - if (request.Method == HttpMethod.Get && - string.Equals(uri.AbsolutePath.Trim('/'), "api/buckets/aevatar-studio/objects/download", StringComparison.Ordinal)) - { - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); - } - - if (request.Method == HttpMethod.Get && - string.Equals(uri.AbsolutePath.Trim('/'), "api/buckets/aevatar-studio/presigned-url", StringComparison.Ordinal)) - { - var key = GetRequiredQueryValue(uri, "key"); - if (!_presentObjects.Contains($"aevatar-studio:{key}")) - { - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); - } - - return Task.FromResult(CreateJsonResponse( - HttpStatusCode.OK, - new - { - data = new - { - presignedUrl = $"http://download.local/missing/{Uri.EscapeDataString(key)}", - }, - error = (object?)null, - })); - } - - if (string.Equals(uri.Host, "download.local", StringComparison.OrdinalIgnoreCase)) - { - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); - } - - throw new InvalidOperationException($"Unhandled request {request.Method} {uri}."); - } - - private static string GetRequiredQueryValue(Uri uri, string key) - { - var query = uri.Query.TrimStart('?') - .Split('&', StringSplitOptions.RemoveEmptyEntries) - .Select(pair => pair.Split('=', 2)) - .ToDictionary( - pair => Uri.UnescapeDataString(pair[0]), - pair => pair.Length > 1 ? Uri.UnescapeDataString(pair[1]) : string.Empty, - StringComparer.Ordinal); - return query.TryGetValue(key, out var value) - ? value - : throw new InvalidOperationException($"Missing query key '{key}'."); - } - - private static HttpResponseMessage CreateJsonResponse(HttpStatusCode statusCode, object payload) => - new(statusCode) - { - Content = JsonContent.Create(payload), - }; - } - } - - private sealed class StubHttpClientFactory : IHttpClientFactory - { - private readonly HttpClient _client; - - public StubHttpClientFactory(HttpClient client) - { - _client = client; - } - - public HttpClient CreateClient(string name) => _client; - } - - private sealed class TemporaryDirectory : IDisposable - { - public TemporaryDirectory() - { - Path = System.IO.Path.Combine( - System.IO.Path.GetTempPath(), - "aevatar-role-store-tests", - Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(Path); - } - - public string Path { get; } - - public void Dispose() - { - if (Directory.Exists(Path)) - { - Directory.Delete(Path, recursive: true); - } - } - } -} diff --git a/test/Aevatar.Tools.Cli.Tests/ChronoStorageUserConfigStoreTests.cs b/test/Aevatar.Tools.Cli.Tests/ChronoStorageUserConfigStoreTests.cs deleted file mode 100644 index c0683142..00000000 --- a/test/Aevatar.Tools.Cli.Tests/ChronoStorageUserConfigStoreTests.cs +++ /dev/null @@ -1,231 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Text; -using System.Text.Json; -using Aevatar.Studio.Application.Studio.Abstractions; -using Aevatar.Studio.Infrastructure.ScopeResolution; -using Aevatar.Studio.Infrastructure.Storage; -using FluentAssertions; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; - -namespace Aevatar.Tools.Cli.Tests; - -public sealed class ChronoStorageUserConfigStoreTests -{ - [Fact] - public async Task GetAsync_WhenConfigIsMissing_ShouldReturnLocalDefaults() - { - var store = CreateStore( - new InMemoryChronoStorageServer(), - scopeId: "scope-missing", - defaultLocalRuntimeBaseUrl: "http://127.0.0.1:6001", - defaultRemoteRuntimeBaseUrl: "https://remote-default.example"); - - var config = await store.GetAsync(); - - config.DefaultModel.Should().BeEmpty(); - config.PreferredLlmRoute.Should().Be(UserConfigLlmRouteDefaults.Gateway); - config.RuntimeMode.Should().Be(UserConfigRuntimeDefaults.LocalMode); - config.LocalRuntimeBaseUrl.Should().Be("http://127.0.0.1:6001"); - config.RemoteRuntimeBaseUrl.Should().Be("https://remote-default.example"); - } - - [Fact] - public async Task GetAsync_WhenLegacyRuntimeBaseUrlIsRemote_ShouldMigrateToExplicitRemoteMode() - { - var storageServer = new InMemoryChronoStorageServer(); - storageServer.Objects["aevatar-studio:profiles/scope-remote/config.json"] = Encoding.UTF8.GetBytes( - """ - { - "defaultModel": "gpt-4.1", - "runtimeBaseUrl": "https://runtime.example" - } - """); - var store = CreateStore(storageServer, scopeId: "scope-remote"); - - var config = await store.GetAsync(); - - config.DefaultModel.Should().Be("gpt-4.1"); - config.PreferredLlmRoute.Should().Be(UserConfigLlmRouteDefaults.Gateway); - config.RuntimeMode.Should().Be(UserConfigRuntimeDefaults.RemoteMode); - config.LocalRuntimeBaseUrl.Should().Be(UserConfigRuntimeDefaults.LocalRuntimeBaseUrl); - config.RemoteRuntimeBaseUrl.Should().Be("https://runtime.example"); - } - - [Fact] - public async Task GetAsync_WhenUserConfigProvidesRuntimeUrl_ShouldOverrideAppSettingsDefaults() - { - var storageServer = new InMemoryChronoStorageServer(); - storageServer.Objects["aevatar-studio:profiles/scope-override/config.json"] = Encoding.UTF8.GetBytes( - """ - { - "defaultModel": "gpt-4.1", - "preferredLlmRoute": "chrono-llm", - "runtimeMode": "remote", - "remoteRuntimeBaseUrl": "https://user-remote.example" - } - """); - var store = CreateStore( - storageServer, - scopeId: "scope-override", - defaultLocalRuntimeBaseUrl: "http://127.0.0.1:6001", - defaultRemoteRuntimeBaseUrl: "https://remote-default.example"); - - var config = await store.GetAsync(); - - config.DefaultModel.Should().Be("gpt-4.1"); - config.PreferredLlmRoute.Should().Be("/api/v1/proxy/s/chrono-llm"); - config.RuntimeMode.Should().Be(UserConfigRuntimeDefaults.RemoteMode); - config.LocalRuntimeBaseUrl.Should().Be("http://127.0.0.1:6001"); - config.RemoteRuntimeBaseUrl.Should().Be("https://user-remote.example"); - } - - [Fact] - public async Task SaveAsync_ShouldPersistExplicitRuntimeModeAndUrls() - { - var storageServer = new InMemoryChronoStorageServer(); - var store = CreateStore(storageServer, scopeId: "scope-save"); - var config = new UserConfig( - DefaultModel: "claude-sonnet-4-5-20250929", - PreferredLlmRoute: "chrono-llm", - RuntimeMode: UserConfigRuntimeDefaults.RemoteMode, - LocalRuntimeBaseUrl: "http://127.0.0.1:5080", - RemoteRuntimeBaseUrl: "https://runtime-save.example"); - - await store.SaveAsync(config); - - storageServer.Objects.Should().ContainKey("aevatar-studio:profiles/scope-save/config.json"); - using var json = JsonDocument.Parse(storageServer.Objects["aevatar-studio:profiles/scope-save/config.json"]); - json.RootElement.GetProperty("defaultModel").GetString().Should().Be("claude-sonnet-4-5-20250929"); - json.RootElement.GetProperty("preferredLlmRoute").GetString().Should().Be("/api/v1/proxy/s/chrono-llm"); - json.RootElement.GetProperty("runtimeMode").GetString().Should().Be(UserConfigRuntimeDefaults.RemoteMode); - json.RootElement.GetProperty("localRuntimeBaseUrl").GetString().Should().Be("http://127.0.0.1:5080"); - json.RootElement.GetProperty("remoteRuntimeBaseUrl").GetString().Should().Be("https://runtime-save.example"); - json.RootElement.TryGetProperty("runtimeBaseUrl", out _).Should().BeFalse(); - } - - private static ChronoStorageUserConfigStore CreateStore( - InMemoryChronoStorageServer storageServer, - string scopeId, - string? defaultLocalRuntimeBaseUrl = null, - string? defaultRemoteRuntimeBaseUrl = null) - { - var options = Options.Create(new ConnectorCatalogStorageOptions - { - Enabled = true, - UseNyxProxy = false, - BaseUrl = "http://chrono-storage.test", - Bucket = "aevatar-studio", - UserConfigPrefix = "profiles", - }); - var studioStorageOptions = Options.Create(new StudioStorageOptions - { - DefaultLocalRuntimeBaseUrl = defaultLocalRuntimeBaseUrl ?? UserConfigRuntimeDefaults.LocalRuntimeBaseUrl, - DefaultRemoteRuntimeBaseUrl = defaultRemoteRuntimeBaseUrl ?? UserConfigRuntimeDefaults.RemoteRuntimeBaseUrl, - }); - var blobClient = new ChronoStorageCatalogBlobClient( - new StubAppScopeResolver(scopeId), - storageServer.CreateHttpClientFactory(), - options); - return new ChronoStorageUserConfigStore( - blobClient, - options, - studioStorageOptions, - NullLogger.Instance); - } - - private sealed class StubAppScopeResolver : IAppScopeResolver - { - private readonly AppScopeContext _context; - - public StubAppScopeResolver(string scopeId) - { - _context = new AppScopeContext(scopeId, "test"); - } - - public AppScopeContext? Resolve(Microsoft.AspNetCore.Http.HttpContext? httpContext = null) => _context; - } - - private sealed class InMemoryChronoStorageServer - { - public Dictionary Objects { get; } = new(StringComparer.Ordinal); - - public IHttpClientFactory CreateHttpClientFactory() => - new StubHttpClientFactory(new HttpClient(new Handler(this)) - { - BaseAddress = new Uri("http://chrono-storage.test/"), - }); - - private sealed class Handler : HttpMessageHandler - { - private readonly InMemoryChronoStorageServer _server; - - public Handler(InMemoryChronoStorageServer server) - { - _server = server; - } - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var uri = request.RequestUri ?? throw new InvalidOperationException("Request URI is required."); - var path = uri.AbsolutePath.Trim('/'); - - if (request.Method == HttpMethod.Post && string.Equals(path, "api/buckets/aevatar-studio/objects", StringComparison.Ordinal)) - { - var key = GetRequiredQueryValue(uri, "key"); - _server.Objects[$"aevatar-studio:{key}"] = await request.Content!.ReadAsByteArrayAsync(cancellationToken); - return CreateJsonResponse(HttpStatusCode.OK, new { data = new { stored = true }, error = (object?)null }); - } - - if (request.Method == HttpMethod.Get && string.Equals(path, "api/buckets/aevatar-studio/objects/download", StringComparison.Ordinal)) - { - var key = GetRequiredQueryValue(uri, "key"); - if (!_server.Objects.TryGetValue($"aevatar-studio:{key}", out var payload)) - { - return new HttpResponseMessage(HttpStatusCode.NotFound); - } - - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new ByteArrayContent(payload), - }; - } - - throw new InvalidOperationException($"Unhandled request {request.Method} {uri}."); - } - - private static string GetRequiredQueryValue(Uri uri, string key) - { - var query = uri.Query.TrimStart('?') - .Split('&', StringSplitOptions.RemoveEmptyEntries) - .Select(pair => pair.Split('=', 2)) - .ToDictionary( - pair => Uri.UnescapeDataString(pair[0]), - pair => pair.Length > 1 ? Uri.UnescapeDataString(pair[1]) : string.Empty, - StringComparer.Ordinal); - return query.TryGetValue(key, out var value) - ? value - : throw new InvalidOperationException($"Missing query key '{key}'."); - } - - private static HttpResponseMessage CreateJsonResponse(HttpStatusCode statusCode, object payload) => - new(statusCode) - { - Content = JsonContent.Create(payload), - }; - } - } - - private sealed class StubHttpClientFactory : IHttpClientFactory - { - private readonly HttpClient _client; - - public StubHttpClientFactory(HttpClient client) - { - _client = client; - } - - public HttpClient CreateClient(string name) => _client; - } -} diff --git a/test/Aevatar.Tools.Cli.Tests/ChronoStorageUserMemoryStoreTests.cs b/test/Aevatar.Tools.Cli.Tests/ChronoStorageUserMemoryStoreTests.cs deleted file mode 100644 index 9a567709..00000000 --- a/test/Aevatar.Tools.Cli.Tests/ChronoStorageUserMemoryStoreTests.cs +++ /dev/null @@ -1,351 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Text; -using System.Text.Json; -using Aevatar.AI.Abstractions.LLMProviders; -using Aevatar.Studio.Infrastructure.ScopeResolution; -using Aevatar.Studio.Infrastructure.Storage; -using FluentAssertions; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; - -namespace Aevatar.Tools.Cli.Tests; - -public sealed class ChronoStorageUserMemoryStoreTests -{ - // ─── GetAsync ────────────────────────────────────────────────────────── - - [Fact] - public async Task GetAsync_WhenFileMissing_ShouldReturnEmptyDocument() - { - var store = CreateStore(new InMemoryChronoStorageServer(), scopeId: "scope-empty"); - - var doc = await store.GetAsync(); - - doc.Should().Be(UserMemoryDocument.Empty); - doc.Entries.Should().BeEmpty(); - } - - [Fact] - public async Task GetAsync_WhenFileExists_ShouldDeserializeEntries() - { - var server = new InMemoryChronoStorageServer(); - server.Objects["aevatar-studio:profiles/scope-read/user-memory.json"] = Encoding.UTF8.GetBytes( - """ - { - "version": 1, - "entries": [ - { - "id": "aabbcc112233", - "category": "preference", - "content": "Prefers concise replies", - "source": "explicit", - "createdAt": 1000, - "updatedAt": 2000 - } - ] - } - """); - - var store = CreateStore(server, scopeId: "scope-read"); - - var doc = await store.GetAsync(); - - doc.Version.Should().Be(1); - doc.Entries.Should().HaveCount(1); - var entry = doc.Entries[0]; - entry.Id.Should().Be("aabbcc112233"); - entry.Category.Should().Be(UserMemoryCategories.Preference); - entry.Content.Should().Be("Prefers concise replies"); - entry.Source.Should().Be(UserMemorySources.Explicit); - entry.CreatedAt.Should().Be(1000); - entry.UpdatedAt.Should().Be(2000); - } - - // ─── SaveAsync ───────────────────────────────────────────────────────── - - [Fact] - public async Task SaveAsync_ShouldPersistDocument() - { - var server = new InMemoryChronoStorageServer(); - var store = CreateStore(server, scopeId: "scope-save"); - var entry = new UserMemoryEntry("id1", UserMemoryCategories.Instruction, "Always use code examples", UserMemorySources.Explicit, 100, 200); - var doc = new UserMemoryDocument(1, [entry]); - - await store.SaveAsync(doc); - - server.Objects.Should().ContainKey("aevatar-studio:profiles/scope-save/user-memory.json"); - using var json = JsonDocument.Parse(server.Objects["aevatar-studio:profiles/scope-save/user-memory.json"]); - json.RootElement.GetProperty("version").GetInt32().Should().Be(1); - var entries = json.RootElement.GetProperty("entries").EnumerateArray().ToList(); - entries.Should().HaveCount(1); - entries[0].GetProperty("id").GetString().Should().Be("id1"); - entries[0].GetProperty("category").GetString().Should().Be("instruction"); - entries[0].GetProperty("content").GetString().Should().Be("Always use code examples"); - entries[0].GetProperty("source").GetString().Should().Be("explicit"); - } - - // ─── AddEntryAsync ───────────────────────────────────────────────────── - - [Fact] - public async Task AddEntryAsync_ShouldPersistAndReturnNewEntry() - { - var server = new InMemoryChronoStorageServer(); - var store = CreateStore(server, scopeId: "scope-add"); - - var entry = await store.AddEntryAsync( - UserMemoryCategories.Preference, "Uses Chinese for communication", UserMemorySources.Explicit); - - entry.Category.Should().Be(UserMemoryCategories.Preference); - entry.Content.Should().Be("Uses Chinese for communication"); - entry.Source.Should().Be(UserMemorySources.Explicit); - entry.Id.Should().HaveLength(12); // 6 bytes hex - entry.CreatedAt.Should().BeGreaterThan(0); - - // Round-trip - var doc = await store.GetAsync(); - doc.Entries.Should().HaveCount(1); - doc.Entries[0].Id.Should().Be(entry.Id); - } - - [Fact] - public async Task AddEntryAsync_WhenAtCapacity_ShouldEvictOldestSameCategoryFirst() - { - const int max = 50; - var server = new InMemoryChronoStorageServer(); - var store = CreateStore(server, scopeId: "scope-cap"); - - // Fill with 50 preference entries (oldest first). - for (var i = 0; i < max; i++) - { - await store.AddEntryAsync(UserMemoryCategories.Preference, $"pref-{i}", UserMemorySources.Inferred); - await Task.Delay(1); // Ensure distinct timestamps - } - - // The 51st entry should evict the oldest preference entry. - var newEntry = await store.AddEntryAsync(UserMemoryCategories.Preference, "newest-pref", UserMemorySources.Explicit); - - var doc = await store.GetAsync(); - doc.Entries.Should().HaveCount(max); - doc.Entries.Should().Contain(e => e.Id == newEntry.Id); - doc.Entries.Should().NotContain(e => e.Content == "pref-0"); - } - - [Fact] - public async Task AddEntryAsync_WhenNoSameCategoryToEvict_ShouldEvictGloballyOldest() - { - const int max = 50; - var server = new InMemoryChronoStorageServer(); - var store = CreateStore(server, scopeId: "scope-global-evict"); - - // Fill with 50 preference entries. - for (var i = 0; i < max; i++) - { - await store.AddEntryAsync(UserMemoryCategories.Preference, $"pref-{i}", UserMemorySources.Inferred); - await Task.Delay(1); - } - - // Add an instruction entry — should evict the globally oldest (pref-0). - var newEntry = await store.AddEntryAsync(UserMemoryCategories.Instruction, "always show code", UserMemorySources.Explicit); - - var doc = await store.GetAsync(); - doc.Entries.Should().HaveCount(max); - doc.Entries.Should().Contain(e => e.Id == newEntry.Id); - doc.Entries.Should().NotContain(e => e.Content == "pref-0"); - } - - // ─── RemoveEntryAsync ────────────────────────────────────────────────── - - [Fact] - public async Task RemoveEntryAsync_WhenEntryExists_ShouldRemoveAndReturnTrue() - { - var server = new InMemoryChronoStorageServer(); - var store = CreateStore(server, scopeId: "scope-remove"); - var entry = await store.AddEntryAsync(UserMemoryCategories.Context, "Working on Aevatar", UserMemorySources.Inferred); - - var removed = await store.RemoveEntryAsync(entry.Id); - - removed.Should().BeTrue(); - var doc = await store.GetAsync(); - doc.Entries.Should().BeEmpty(); - } - - [Fact] - public async Task RemoveEntryAsync_WhenEntryMissing_ShouldReturnFalse() - { - var store = CreateStore(new InMemoryChronoStorageServer(), scopeId: "scope-missing-remove"); - - var removed = await store.RemoveEntryAsync("nonexistent"); - - removed.Should().BeFalse(); - } - - // ─── BuildPromptSectionAsync ─────────────────────────────────────────── - - [Fact] - public async Task BuildPromptSectionAsync_WhenEmpty_ShouldReturnEmptyString() - { - var store = CreateStore(new InMemoryChronoStorageServer(), scopeId: "scope-no-memory"); - - var section = await store.BuildPromptSectionAsync(); - - section.Should().BeEmpty(); - } - - [Fact] - public async Task BuildPromptSectionAsync_ShouldGroupByCategoryWithXmlTags() - { - var server = new InMemoryChronoStorageServer(); - var store = CreateStore(server, scopeId: "scope-prompt"); - await store.AddEntryAsync(UserMemoryCategories.Preference, "Concise replies", UserMemorySources.Explicit); - await store.AddEntryAsync(UserMemoryCategories.Instruction, "Always show code", UserMemorySources.Explicit); - await store.AddEntryAsync(UserMemoryCategories.Context, "Working on Aevatar", UserMemorySources.Inferred); - - var section = await store.BuildPromptSectionAsync(); - - section.Should().StartWith(""); - section.Should().EndWith(""); - section.Should().Contain("## Preferences"); - section.Should().Contain("## Instructions"); - section.Should().Contain("## Context"); - section.Should().Contain("Concise replies"); - section.Should().Contain("Always show code"); - section.Should().Contain("Working on Aevatar"); - } - - [Fact] - public async Task BuildPromptSectionAsync_WhenExceedsMaxChars_ShouldTruncate() - { - var server = new InMemoryChronoStorageServer(); - var store = CreateStore(server, scopeId: "scope-truncate"); - var longContent = new string('x', 300); - await store.AddEntryAsync(UserMemoryCategories.Preference, longContent, UserMemorySources.Inferred); - - var section = await store.BuildPromptSectionAsync(maxChars: 100); - - section.Length.Should().BeLessThanOrEqualTo(100 + "".Length); - } - - // ─── Scope isolation ─────────────────────────────────────────────────── - - [Fact] - public async Task StoreIsIsolatedByScope() - { - var server = new InMemoryChronoStorageServer(); - var storeA = CreateStore(server, scopeId: "user-a"); - var storeB = CreateStore(server, scopeId: "user-b"); - - await storeA.AddEntryAsync(UserMemoryCategories.Preference, "A's pref", UserMemorySources.Explicit); - - var docB = await storeB.GetAsync(); - docB.Entries.Should().BeEmpty(); - } - - // ─── Factory / helpers ───────────────────────────────────────────────── - - private static ChronoStorageUserMemoryStore CreateStore( - InMemoryChronoStorageServer server, - string scopeId) - { - var options = Options.Create(new ConnectorCatalogStorageOptions - { - Enabled = true, - UseNyxProxy = false, - BaseUrl = "http://chrono-storage.test", - Bucket = "aevatar-studio", - UserConfigPrefix = "profiles", - }); - var blobClient = new ChronoStorageCatalogBlobClient( - new StubAppScopeResolver(scopeId), - server.CreateHttpClientFactory(), - options); - return new ChronoStorageUserMemoryStore( - blobClient, - options, - NullLogger.Instance); - } - - private sealed class StubAppScopeResolver : IAppScopeResolver - { - private readonly AppScopeContext _context; - - public StubAppScopeResolver(string scopeId) - { - _context = new AppScopeContext(scopeId, "test"); - } - - public AppScopeContext? Resolve(HttpContext? httpContext = null) => _context; - } - - private sealed class InMemoryChronoStorageServer - { - public Dictionary Objects { get; } = new(StringComparer.Ordinal); - - public IHttpClientFactory CreateHttpClientFactory() => - new StubHttpClientFactory(new HttpClient(new Handler(this)) - { - BaseAddress = new Uri("http://chrono-storage.test/"), - }); - - private sealed class Handler : HttpMessageHandler - { - private readonly InMemoryChronoStorageServer _server; - - public Handler(InMemoryChronoStorageServer server) => _server = server; - - protected override async Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - var uri = request.RequestUri ?? throw new InvalidOperationException("Request URI is required."); - var path = uri.AbsolutePath.Trim('/'); - - if (request.Method == HttpMethod.Post && - string.Equals(path, "api/buckets/aevatar-studio/objects", StringComparison.Ordinal)) - { - var key = GetQueryValue(uri, "key"); - _server.Objects[$"aevatar-studio:{key}"] = await request.Content!.ReadAsByteArrayAsync(cancellationToken); - return CreateJsonResponse(HttpStatusCode.OK, new { data = new { stored = true }, error = (object?)null }); - } - - if (request.Method == HttpMethod.Get && - string.Equals(path, "api/buckets/aevatar-studio/objects/download", StringComparison.Ordinal)) - { - var key = GetQueryValue(uri, "key"); - if (!_server.Objects.TryGetValue($"aevatar-studio:{key}", out var payload)) - return new HttpResponseMessage(HttpStatusCode.NotFound); - - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new ByteArrayContent(payload), - }; - } - - throw new InvalidOperationException($"Unhandled request {request.Method} {uri}."); - } - - private static string GetQueryValue(Uri uri, string key) => - uri.Query.TrimStart('?') - .Split('&', StringSplitOptions.RemoveEmptyEntries) - .Select(p => p.Split('=', 2)) - .ToDictionary( - p => Uri.UnescapeDataString(p[0]), - p => p.Length > 1 ? Uri.UnescapeDataString(p[1]) : string.Empty, - StringComparer.Ordinal) - .GetValueOrDefault(key) - ?? throw new InvalidOperationException($"Missing query key '{key}'."); - - private static HttpResponseMessage CreateJsonResponse(HttpStatusCode statusCode, object payload) => - new(statusCode) { Content = JsonContent.Create(payload) }; - } - } - - private sealed class StubHttpClientFactory : IHttpClientFactory - { - private readonly HttpClient _client; - - public StubHttpClientFactory(HttpClient client) => _client = client; - - public HttpClient CreateClient(string name) => _client; - } -} diff --git a/tools/ci/README.md b/tools/ci/README.md index c78500ea..4f43cdd4 100644 --- a/tools/ci/README.md +++ b/tools/ci/README.md @@ -47,6 +47,6 @@ This directory keeps CI gate scripts and smoke tests. - Job `coverage-quality` - Runs restore/build + `tools/ci/coverage_quality_guard.sh`. - Uploads `artifacts/coverage/**` as CI artifacts (`coverage-quality-report`). - - Uploads the filtered `artifacts/coverage/**/report/Cobertura.xml` to Codecov when `CODECOV_TOKEN` is available. + - Uploads the raw `artifacts/coverage/**/raw/**/coverage.cobertura.xml` files to Codecov when `CODECOV_TOKEN` is available, while the filtered report remains the local quality-gate input. - Triggered on `main/dev` pushes, nightly schedule, or manual dispatch. - Job `distributed-3node-smoke` -> `tools/ci/distributed_3node_smoke.sh`