diff --git a/agents/Aevatar.GAgents.UserMemory/UserMemoryGAgent.cs b/agents/Aevatar.GAgents.UserMemory/UserMemoryGAgent.cs index 9fed99bd73..61ce3fa75e 100644 --- a/agents/Aevatar.GAgents.UserMemory/UserMemoryGAgent.cs +++ b/agents/Aevatar.GAgents.UserMemory/UserMemoryGAgent.cs @@ -13,9 +13,10 @@ namespace Aevatar.GAgents.UserMemory; /// 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. +/// 1. Without a retention policy, preserve the legacy same-category-first behavior. +/// 2. With a policy, enforce the added category's cap before the global cap. +/// 3. At the global cap, higher-ranked categories are evicted first and rank ties +/// retain the legacy same-category-first order. /// /// [GAgent("user.memory")] @@ -27,6 +28,8 @@ public sealed class UserMemoryGAgent : GAgentBase, IProjectedAc public static string ProjectionKind => "user-memory"; internal const int MaxEntries = 50; + private const int DefaultEvictionRank = 100; + private const int MaxEvictionRank = 1000; [EventHandler(EndpointName = "addMemoryEntry")] public async Task HandleAddUserMemoryEntry(AddUserMemoryEntryCommand command) @@ -70,6 +73,31 @@ public async Task HandleClearUserMemoryEntries(ClearUserMemoryEntriesCommand com await PersistDomainEventAsync(new MemoryEntriesClearedEvent()); } + [EventHandler(EndpointName = "replaceRetentionPolicy")] + public async Task HandleReplaceRetentionPolicy(ReplaceUserMemoryRetentionPolicyCommand command) + { + ArgumentNullException.ThrowIfNull(command); + if (command.ExpectedStateVersion < 0) + throw new InvalidOperationException("user_memory_expected_state_version_invalid"); + + var mutationId = NormalizeMutationId(command.MutationId); + var replacedEvent = BuildRetentionPolicyReplacedEvent(State, command, mutationId); + if (string.Equals(State.LastRetentionPolicyMutationId, mutationId, StringComparison.Ordinal)) + { + if (PolicyMatchesState(State.RetentionPolicy, replacedEvent.Policy)) + return; + + throw new InvalidOperationException("user_memory_policy_mutation_conflict"); + } + + var currentVersion = EventSourcing?.CurrentVersion + ?? throw new InvalidOperationException("user_memory_event_sourcing_unavailable"); + if (command.ExpectedStateVersion != currentVersion) + throw new InvalidOperationException("user_memory_expected_state_version_conflict"); + + await PersistDomainEventAsync(replacedEvent); + } + protected override async Task OnActivateAsync(CancellationToken ct) { await base.OnActivateAsync(ct); @@ -83,47 +111,116 @@ protected override UserMemoryState TransitionState( .On(ApplyAdded) .On(ApplyRemoved) .On(ApplyCleared) + .On(ApplyRetentionPolicyReplaced) .OrCurrent(); } + // Implement (issue #3528): + // Behavior: Policy replacement is validated and revisioned inside the owning actor state. + // Why this shape: Replay must derive retention from committed facts without external policy reads. + public static UserMemoryRetentionPolicyReplacedEvent BuildRetentionPolicyReplacedEvent( + UserMemoryState state, + ReplaceUserMemoryRetentionPolicyCommand command, + string? normalizedMutationId = null) + { + ArgumentNullException.ThrowIfNull(state); + ArgumentNullException.ThrowIfNull(command); + + var policy = new UserMemoryRetentionPolicy + { + PolicyRevision = (state.RetentionPolicy?.PolicyRevision ?? 0) + 1, + }; + policy.Rules.AddRange(NormalizeRules(command.Rules)); + return new UserMemoryRetentionPolicyReplacedEvent + { + Policy = policy, + MutationId = normalizedMutationId ?? NormalizeMutationId(command.MutationId), + }; + } + 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. + if (next.RetentionPolicy is not null) + EnforceCategoryCap(next, evt.Entry); + while (next.Entries.Count > MaxEntries) { - var category = evt.Entry.Category; - var oldestSameCategory = next.Entries - .Where(e => e.Category == category - && !string.Equals(e.Id, evt.Entry.Id, StringComparison.Ordinal)) - .OrderBy(e => e.CreatedAtMs) - .FirstOrDefault(); + var evicted = next.RetentionPolicy is null + ? SelectLegacyEvictionCandidate(next.Entries, evt.Entry) + : SelectPolicyEvictionCandidate(next, evt.Entry); + if (evicted is null) + break; - 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.CreatedAtMs) - .FirstOrDefault(); - - if (globallyOldest is not null) - next.Entries.Remove(globallyOldest); - else - break; - } + next.Entries.Remove(evicted); } return next; } + private static void EnforceCategoryCap(UserMemoryState state, UserMemoryEntryProto addedEntry) + { + var rule = state.RetentionPolicy!.Rules.FirstOrDefault(candidate => + candidate.Category == addedEntry.Category); + if (rule is null || rule.MaxEntries == 0) + return; + + while (state.Entries.Count(entry => entry.Category == addedEntry.Category) > rule.MaxEntries) + { + var oldest = state.Entries + .Where(entry => entry.Category == addedEntry.Category && + !string.Equals(entry.Id, addedEntry.Id, StringComparison.Ordinal)) + .OrderBy(entry => entry.CreatedAtMs) + .FirstOrDefault(); + if (oldest is null) + break; + + state.Entries.Remove(oldest); + } + } + + // Implement (issue #3528): + // Behavior: Global eviction ranks categories, then preserves legacy ordering for equal ranks. + // Why this shape: It protects low-rank categories without changing tie behavior or evicting the new entry. + private static UserMemoryEntryProto? SelectPolicyEvictionCandidate( + UserMemoryState state, + UserMemoryEntryProto addedEntry) + { + var candidates = state.Entries + .Where(entry => !string.Equals(entry.Id, addedEntry.Id, StringComparison.Ordinal)) + .ToArray(); + if (candidates.Length == 0) + return null; + + var highestRank = candidates.Max(entry => ResolveEvictionRank(state.RetentionPolicy!, entry.Category)); + var highestRankCandidates = candidates + .Where(entry => ResolveEvictionRank(state.RetentionPolicy!, entry.Category) == highestRank) + .ToArray(); + return SelectLegacyEvictionCandidate(highestRankCandidates, addedEntry); + } + + private static UserMemoryEntryProto? SelectLegacyEvictionCandidate( + IEnumerable entries, + UserMemoryEntryProto addedEntry) => + entries + .Where(entry => entry.Category == addedEntry.Category && + !string.Equals(entry.Id, addedEntry.Id, StringComparison.Ordinal)) + .OrderBy(entry => entry.CreatedAtMs) + .FirstOrDefault() + ?? entries + .Where(entry => !string.Equals(entry.Id, addedEntry.Id, StringComparison.Ordinal)) + .OrderBy(entry => entry.CreatedAtMs) + .FirstOrDefault(); + + private static int ResolveEvictionRank( + UserMemoryRetentionPolicy policy, + UserMemoryCategory category) => + policy.Rules.FirstOrDefault(rule => rule.Category == category)?.EvictionRank + ?? DefaultEvictionRank; + private static UserMemoryState ApplyRemoved( UserMemoryState state, MemoryEntryRemovedEvent evt) { @@ -145,6 +242,51 @@ private static UserMemoryState ApplyCleared( return next; } + private static UserMemoryState ApplyRetentionPolicyReplaced( + UserMemoryState state, + UserMemoryRetentionPolicyReplacedEvent evt) + { + var next = state.Clone(); + next.RetentionPolicy = evt.Policy.Clone(); + next.LastRetentionPolicyMutationId = evt.MutationId; + return next; + } + + private static IReadOnlyList NormalizeRules( + IEnumerable rules) + { + var normalized = new List(); + var categories = new HashSet(); + foreach (var rule in rules ?? []) + { + if (!Enum.IsDefined(rule.Category) || rule.Category == UserMemoryCategory.Unspecified) + throw new InvalidOperationException("user_memory_policy_category_invalid"); + if (!categories.Add(rule.Category)) + throw new InvalidOperationException("user_memory_policy_category_duplicate"); + if (rule.MaxEntries is < 0 or > MaxEntries) + throw new InvalidOperationException("user_memory_policy_max_entries_invalid"); + if (rule.EvictionRank is < 0 or > MaxEvictionRank) + throw new InvalidOperationException("user_memory_policy_eviction_rank_invalid"); + + normalized.Add(rule.Clone()); + } + + return normalized.OrderBy(static rule => rule.Category).ToArray(); + } + + private static bool PolicyMatchesState( + UserMemoryRetentionPolicy? statePolicy, + UserMemoryRetentionPolicy policy) => + statePolicy is not null && statePolicy.Rules.SequenceEqual(policy.Rules); + + private static string NormalizeMutationId(string? mutationId) + { + var normalized = mutationId?.Trim() ?? string.Empty; + if (normalized.Length == 0) + throw new InvalidOperationException("user_memory_policy_mutation_id_invalid"); + return normalized; + } + private static void ValidateEntry(UserMemoryEntryProto? entry) { if (entry is null) diff --git a/agents/Aevatar.GAgents.UserMemory/user_memory_messages.proto b/agents/Aevatar.GAgents.UserMemory/user_memory_messages.proto index 5d07aebd8e..d706d00d98 100644 --- a/agents/Aevatar.GAgents.UserMemory/user_memory_messages.proto +++ b/agents/Aevatar.GAgents.UserMemory/user_memory_messages.proto @@ -26,8 +26,21 @@ message UserMemoryEntryProto { int64 updated_at_ms = 6; } +message UserMemoryCategoryRetentionRule { + UserMemoryCategory category = 1; + int32 max_entries = 2; + int32 eviction_rank = 3; +} + +message UserMemoryRetentionPolicy { + repeated UserMemoryCategoryRetentionRule rules = 1; + int64 policy_revision = 2; +} + message UserMemoryState { repeated UserMemoryEntryProto entries = 1; + UserMemoryRetentionPolicy retention_policy = 2; + string last_retention_policy_mutation_id = 3; } // ─── Commands ─── @@ -43,6 +56,12 @@ message RemoveUserMemoryEntryCommand { message ClearUserMemoryEntriesCommand { } +message ReplaceUserMemoryRetentionPolicyCommand { + repeated UserMemoryCategoryRetentionRule rules = 1; + int64 expected_state_version = 2; + string mutation_id = 3; +} + // ─── Events ─── message MemoryEntryAddedEvent { @@ -55,3 +74,8 @@ message MemoryEntryRemovedEvent { message MemoryEntriesClearedEvent { } + +message UserMemoryRetentionPolicyReplacedEvent { + UserMemoryRetentionPolicy policy = 1; + string mutation_id = 2; +} diff --git a/docs/canon/conversation-context-and-memory.md b/docs/canon/conversation-context-and-memory.md index af21a4f52c..79589f3e77 100644 --- a/docs/canon/conversation-context-and-memory.md +++ b/docs/canon/conversation-context-and-memory.md @@ -33,7 +33,7 @@ route helper 和测试不得假设它们相等,也不得从 actor ID 前缀推 | Execution state | 从 typed admission/start 到 terminal/reconciled;只覆盖该 turn、session 或 run | 只保留恢复、幂等和终态查询所需的 actor-owned waterline;例如 `RoleGAgent` 只跟踪有界的已完成 session,NyxID turn actor 只保留有界 delivery evidence | 执行 owner 在 terminal/delivery 已安全后按自己的 typed policy 清理;不得把 checkpoint 复制到 transcript 或 user memory | | Prompt context | 单次 LLM call 或当前执行 turn | 受 message/token/character budget 限制;调用结束即可丢弃 | 构建它的执行模块;截断只改变下一次调用输入,不删除任何权威事实 | | Conversation transcript | conversation 初始化后持续存在,并可继续 append | 按 #3141:所有 committed turns 在显式删除整个 conversation 前可查询;无 per-turn TTL、silent rolling eviction 或隐式 archive | `ChatConversationGAgent` 通过 typed whole-conversation deletion fact 清理;projection 只物化该事实 | -| User memory | 用户 scope 存续期间跨 conversation 存在 | 当前 actor 上限为 50 条;新增超限时优先淘汰同 category 最旧项,再淘汰全局最旧项;也可显式 remove/clear | `UserMemoryGAgent` 在 command handler 内决定 eviction/remove/clear 并提交 event;prompt builder 和 query adapter不得清理 | +| User memory | 用户 scope 存续期间跨 conversation 存在 | actor 保持 50 条全局硬上限;未配置 retention policy 时沿用“同 category 最旧、再全局最旧”,配置后先执行新增 category 的 `max_entries` cap,再按 `eviction_rank` 从高到低执行全局 eviction,rank 相同时回退旧顺序;cap 不预留空位 | `UserMemoryGAgent` 通过带 revision、CAS 与 mutation 幂等的 typed policy command/event 持有 retention policy,并在状态转换内决定 eviction/remove/clear;prompt builder 和 query adapter 不得清理 | 如果未来需要 transcript archive、user-memory 向量检索或不同 retention,必须先定义新的 owner、typed lifecycle 和正式 query contract;不得在 prompt 截断或 query adapter 中静默实现。 @@ -59,9 +59,11 @@ read is redacted, tombstoned, expired, over budget, or temporarily unavailable. `InitializeChatConversationCommand`、`AppendChatTurnCommand` 及其 domain events。 3. User memory 使用 `UserMemoryState`、typed `UserMemoryCategory`、typed `UserMemorySource`,以及 `AddUserMemoryEntryCommand`、 - `RemoveUserMemoryEntryCommand`、`ClearUserMemoryEntriesCommand`。actor 校验 command, - 再提交 `MemoryEntryAddedEvent`、`MemoryEntryRemovedEvent` 或 - `MemoryEntriesClearedEvent`;domain event 不再冒充 command。 + `RemoveUserMemoryEntryCommand`、`ClearUserMemoryEntriesCommand` 与 + `ReplaceUserMemoryRetentionPolicyCommand`。actor 校验 command,再提交 + `MemoryEntryAddedEvent`、`MemoryEntryRemovedEvent`、`MemoryEntriesClearedEvent` 或 + `UserMemoryRetentionPolicyReplacedEvent`;domain event 不再冒充 command,retention + policy 不从 conversation profile 或 query path 注入。 4. Prompt context 通过 typed LLM request/control 字段传递;`user_memory_prompt` 是已经 派生、受限长控制的 prompt 输入,不是 user-memory persistence contract。只有最终文本 拼装可以是字符串;category、source、identity、control 和 recovery policy 不得降级到 diff --git a/src/Aevatar.Studio.Application.Abstractions/Studio/Abstractions/IUserMemoryQueryPort.cs b/src/Aevatar.Studio.Application.Abstractions/Studio/Abstractions/IUserMemoryQueryPort.cs index f28e65d29d..c998e25357 100644 --- a/src/Aevatar.Studio.Application.Abstractions/Studio/Abstractions/IUserMemoryQueryPort.cs +++ b/src/Aevatar.Studio.Application.Abstractions/Studio/Abstractions/IUserMemoryQueryPort.cs @@ -39,15 +39,31 @@ public sealed record UserMemoryEntrySnapshot( DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt); +public sealed record UserMemoryCategoryRetentionRule( + UserMemoryCategory Category, + int MaxEntries, + int EvictionRank); + +public sealed record UserMemoryRetentionPolicy( + IReadOnlyList Rules); + public sealed record UserMemorySnapshot( UserMemoryOwnerKey Owner, long StateVersion, - IReadOnlyList Entries) + IReadOnlyList Entries, + UserMemoryRetentionPolicy? RetentionPolicy = null, + long PolicyRevision = 0) { public static UserMemorySnapshot Empty(UserMemoryOwnerKey owner) => new(owner, 0, []); } +public sealed record ReplaceUserMemoryRetentionPolicy( + UserMemoryOwnerKey Owner, + IReadOnlyList Rules, + long ExpectedStateVersion, + string MutationId); + /// /// Reads the per-user current-state replica materialized from committed /// UserMemoryGAgent facts. Implementations must not activate actors, @@ -57,3 +73,14 @@ public interface IUserMemoryQueryPort { Task GetAsync(CancellationToken ct = default); } + +/// +/// Replaces actor-owned user-memory retention policy through the standard +/// command dispatch path. Acceptance does not imply commit or read-model visibility. +/// +public interface IUserMemoryRetentionPolicyCommandPort +{ + Task ReplaceAsync( + ReplaceUserMemoryRetentionPolicy command, + CancellationToken ct = default); +} diff --git a/src/Aevatar.Studio.Hosting/Controllers/UserMemoryRetentionPolicyController.cs b/src/Aevatar.Studio.Hosting/Controllers/UserMemoryRetentionPolicyController.cs new file mode 100644 index 0000000000..4806da267c --- /dev/null +++ b/src/Aevatar.Studio.Hosting/Controllers/UserMemoryRetentionPolicyController.cs @@ -0,0 +1,98 @@ +using System.Text.Json.Serialization; +using Aevatar.Studio.Application.Studio.Abstractions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Aevatar.Studio.Hosting.Controllers; + +[ApiController] +[Authorize] +[Route("api/user-memory/retention-policy")] +public sealed class UserMemoryRetentionPolicyController : ControllerBase +{ + private readonly IUserMemoryRetentionPolicyCommandPort _commandPort; + private readonly IAppScopeResolver _scopeResolver; + + public UserMemoryRetentionPolicyController( + IUserMemoryRetentionPolicyCommandPort commandPort, + IAppScopeResolver scopeResolver) + { + _commandPort = commandPort ?? throw new ArgumentNullException(nameof(commandPort)); + _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); + } + + [HttpPut] + public async Task> Replace( + [FromBody] ReplaceUserMemoryRetentionPolicyRequest? request, + CancellationToken ct) + { + if (request is null) + return BadRequest(new { message = "Request body is required." }); + if (request.ExpectedStateVersion is null) + return BadRequest(new { message = "expectedStateVersion is required." }); + + try + { + var owner = UserMemoryOwnerKey.ForScope(_scopeResolver.ResolveScopeIdOrDefault()); + var receipt = await _commandPort.ReplaceAsync( + request.ToApplication(owner), + ct).ConfigureAwait(false); + return Accepted(UserMemoryRetentionPolicySaveReceiptResponse.FromApplication(receipt)); + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException) + { + return BadRequest(new { message = exception.Message }); + } + } +} + +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +public sealed record ReplaceUserMemoryRetentionPolicyRequest( + [property: JsonPropertyName("rules")] IReadOnlyList? Rules, + [property: JsonPropertyName("expectedStateVersion")] long? ExpectedStateVersion, + [property: JsonPropertyName("mutationId")] string? MutationId) +{ + public ReplaceUserMemoryRetentionPolicy ToApplication(UserMemoryOwnerKey owner) => new( + owner, + (Rules ?? []).Select(static rule => + rule?.ToApplication() ?? throw new InvalidOperationException("Retention rule is required.")) + .ToArray(), + ExpectedStateVersion ?? throw new InvalidOperationException("expectedStateVersion is required."), + MutationId ?? string.Empty); +} + +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +public sealed record UserMemoryCategoryRetentionRuleRequest( + [property: JsonPropertyName("category")] string? Category, + [property: JsonPropertyName("maxEntries")] int MaxEntries, + [property: JsonPropertyName("evictionRank")] int EvictionRank) +{ + public UserMemoryCategoryRetentionRule ToApplication() => new( + Category?.Trim().ToLowerInvariant() switch + { + "preference" => UserMemoryCategory.Preference, + "instruction" => UserMemoryCategory.Instruction, + "context" => UserMemoryCategory.Context, + _ => UserMemoryCategory.Unspecified, + }, + MaxEntries, + EvictionRank); +} + +public sealed record UserMemoryRetentionPolicySaveReceiptResponse( + [property: JsonPropertyName("accepted")] bool Accepted, + [property: JsonPropertyName("commandId")] string CommandId, + [property: JsonPropertyName("ackStage")] string AckStage, + [property: JsonPropertyName("actorId")] string ActorId, + [property: JsonPropertyName("correlationId")] string CorrelationId, + [property: JsonPropertyName("ackedAtUtc")] DateTimeOffset AckedAtUtc) +{ + public static UserMemoryRetentionPolicySaveReceiptResponse FromApplication( + UserConfigSaveReceipt receipt) => new( + receipt.Accepted, + receipt.CommandId, + receipt.AckStage, + receipt.ActorId, + receipt.CorrelationId, + receipt.AckedAtUtc); +} diff --git a/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchUserMemoryRetentionPolicyCommandService.cs b/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchUserMemoryRetentionPolicyCommandService.cs new file mode 100644 index 0000000000..92b6a7db1c --- /dev/null +++ b/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchUserMemoryRetentionPolicyCommandService.cs @@ -0,0 +1,100 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.UserMemory; +using Aevatar.Studio.Application.Studio.Abstractions; +using Google.Protobuf.WellKnownTypes; +using ApplicationRetentionRule = Aevatar.Studio.Application.Studio.Abstractions.UserMemoryCategoryRetentionRule; +using ActorRetentionRule = Aevatar.GAgents.UserMemory.UserMemoryCategoryRetentionRule; +using ActorUserMemoryCategory = Aevatar.GAgents.UserMemory.UserMemoryCategory; + +namespace Aevatar.Studio.Projection.CommandServices; + +internal sealed class ActorDispatchUserMemoryRetentionPolicyCommandService + : IUserMemoryRetentionPolicyCommandPort +{ + private const string ActorIdPrefix = "user-memory-"; + private const string DirectRoute = "aevatar.studio.projection.user-memory-retention-policy"; + + private readonly IStudioActorBootstrap _bootstrap; + private readonly IActorDispatchPort _dispatchPort; + + public ActorDispatchUserMemoryRetentionPolicyCommandService( + IStudioActorBootstrap bootstrap, + IActorDispatchPort dispatchPort) + { + _bootstrap = bootstrap ?? throw new ArgumentNullException(nameof(bootstrap)); + _dispatchPort = dispatchPort ?? throw new ArgumentNullException(nameof(dispatchPort)); + } + + public async Task ReplaceAsync( + ReplaceUserMemoryRetentionPolicy command, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(command); + if (command.ExpectedStateVersion < 0) + { + throw new ArgumentOutOfRangeException( + nameof(command.ExpectedStateVersion), + "Expected state version must be non-negative."); + } + if (string.IsNullOrWhiteSpace(command.Owner.ScopeId)) + throw new ArgumentException("User-memory owner scope is required.", nameof(command.Owner)); + if (command.Rules is null) + throw new ArgumentNullException(nameof(command.Rules)); + + var payload = MapCommand(command); + _ = UserMemoryGAgent.BuildRetentionPolicyReplacedEvent(new UserMemoryState(), payload); + + var actorId = ActorIdPrefix + command.Owner.ScopeId; + var actor = await _bootstrap.EnsureAsync(actorId, ct); + var envelope = new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTime(DateTime.UtcNow), + Payload = Any.Pack(payload), + Route = EnvelopeRouteSemantics.CreateDirect(DirectRoute, actor.Id), + }; + + var admission = await _dispatchPort.DispatchAsync(actor.Id, envelope, ct).ConfigureAwait(false); + return new UserConfigSaveReceipt( + admission.Accepted, + admission.CommandId, + admission.Accepted + ? UserConfigCommandAckStage.Accepted + : UserConfigCommandAckStage.AdmissionRejected, + admission.ActorId, + admission.CorrelationId, + admission.AckedAt); + } + + private static ReplaceUserMemoryRetentionPolicyCommand MapCommand( + ReplaceUserMemoryRetentionPolicy command) + { + var payload = new ReplaceUserMemoryRetentionPolicyCommand + { + ExpectedStateVersion = command.ExpectedStateVersion, + MutationId = command.MutationId ?? string.Empty, + }; + payload.Rules.AddRange(command.Rules.Select(MapRule)); + return payload; + } + + private static ActorRetentionRule MapRule(ApplicationRetentionRule rule) + { + ArgumentNullException.ThrowIfNull(rule); + return new ActorRetentionRule + { + Category = rule.Category switch + { + Aevatar.Studio.Application.Studio.Abstractions.UserMemoryCategory.Preference => + ActorUserMemoryCategory.Preference, + Aevatar.Studio.Application.Studio.Abstractions.UserMemoryCategory.Instruction => + ActorUserMemoryCategory.Instruction, + Aevatar.Studio.Application.Studio.Abstractions.UserMemoryCategory.Context => + ActorUserMemoryCategory.Context, + _ => ActorUserMemoryCategory.Unspecified, + }, + MaxEntries = rule.MaxEntries, + EvictionRank = rule.EvictionRank, + }; + } +} diff --git a/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs index e8ad395b16..5a5b2799f1 100644 --- a/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs @@ -21,6 +21,7 @@ using Aevatar.GAgents.ContentArtifacts; using Aevatar.GAgents.WorkOrder; using Aevatar.GAgents.WorkflowDelivery; +using Aevatar.GAgents.UserMemory; using Aevatar.Studio.Workspace; using Aevatar.GAgentService.Abstractions.Schedules.Authorization; using Microsoft.Extensions.Configuration; @@ -51,6 +52,7 @@ public static IServiceCollection AddStudioProjectionComponents( services.AddCqrsCore(); services.AddAevatarAgentKindRegistry(builder => builder.ScanAssemblies( typeof(Aevatar.GAgents.UserConfig.UserConfigGAgent).Assembly, + typeof(UserMemoryGAgent).Assembly, typeof(Aevatar.GAgents.StudioMember.StudioMemberGAgent).Assembly, typeof(Aevatar.GAgents.StudioTeam.StudioTeamGAgent).Assembly, typeof(ContentArtifactGAgent).Assembly, @@ -318,6 +320,9 @@ public static IServiceCollection AddStudioProjectionComponents( services.TryAddSingleton< ILLMModelCatalogPolicyCommandPort, ActorDispatchLLMModelCatalogPolicyCommandService>(); + services.TryAddSingleton< + IUserMemoryRetentionPolicyCommandPort, + ActorDispatchUserMemoryRetentionPolicyCommandService>(); services.TryAddSingleton(); services.TryAddSingleton< IStudioWorkflowScheduleProvisioningCommandPort, diff --git a/src/Aevatar.Studio.Projection/QueryPorts/ProjectionUserMemoryQueryPort.cs b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionUserMemoryQueryPort.cs index 0c8a323083..4a9fb66e0f 100644 --- a/src/Aevatar.Studio.Projection/QueryPorts/ProjectionUserMemoryQueryPort.cs +++ b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionUserMemoryQueryPort.cs @@ -3,6 +3,8 @@ using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Projection.ReadModels; using ApplicationUserMemoryCategory = Aevatar.Studio.Application.Studio.Abstractions.UserMemoryCategory; +using ApplicationRetentionPolicy = Aevatar.Studio.Application.Studio.Abstractions.UserMemoryRetentionPolicy; +using ApplicationRetentionRule = Aevatar.Studio.Application.Studio.Abstractions.UserMemoryCategoryRetentionRule; using ApplicationUserMemorySource = Aevatar.Studio.Application.Studio.Abstractions.UserMemorySource; using ActorUserMemoryCategory = Aevatar.GAgents.UserMemory.UserMemoryCategory; using ActorUserMemorySource = Aevatar.GAgents.UserMemory.UserMemorySource; @@ -50,8 +52,22 @@ public async Task GetAsync(CancellationToken ct = default) DateTimeOffset.FromUnixTimeMilliseconds(entry.UpdatedAtMs))) .ToList() .AsReadOnly(); + var retentionPolicy = state.RetentionPolicy is null + ? null + : new ApplicationRetentionPolicy(state.RetentionPolicy.Rules + .Select(static rule => new ApplicationRetentionRule( + MapCategory(rule.Category), + rule.MaxEntries, + rule.EvictionRank)) + .ToList() + .AsReadOnly()); - return new UserMemorySnapshot(owner, document.StateVersion, entries); + return new UserMemorySnapshot( + owner, + document.StateVersion, + entries, + retentionPolicy, + state.RetentionPolicy?.PolicyRevision ?? 0); } private static bool IsReadable(UserMemoryEntryProto entry) => diff --git a/test/Aevatar.Studio.Tests/ActorDispatchUserMemoryRetentionPolicyCommandServiceTests.cs b/test/Aevatar.Studio.Tests/ActorDispatchUserMemoryRetentionPolicyCommandServiceTests.cs new file mode 100644 index 0000000000..e88811821b --- /dev/null +++ b/test/Aevatar.Studio.Tests/ActorDispatchUserMemoryRetentionPolicyCommandServiceTests.cs @@ -0,0 +1,142 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.UserMemory; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Projection.CommandServices; +using Aevatar.Studio.Projection.DependencyInjection; +using FluentAssertions; +using Google.Protobuf; +using Microsoft.Extensions.DependencyInjection; + +namespace Aevatar.Studio.Tests; + +public sealed class ActorDispatchUserMemoryRetentionPolicyCommandServiceTests +{ + [Fact] + public async Task ReplaceAsync_ShouldDispatchTypedScopeCommandAndReturnHonestReceipt() + { + var bootstrap = new RecordingBootstrap(); + var ackedAt = DateTimeOffset.Parse("2026-08-25T08:00:00Z"); + var dispatch = new RecordingDispatchPort(new DispatchAdmission( + true, + "command-alpha", + ackedAt, + "user-memory-scope-alpha", + "correlation-alpha")); + var service = new ActorDispatchUserMemoryRetentionPolicyCommandService(bootstrap, dispatch); + + var receipt = await service.ReplaceAsync(new ReplaceUserMemoryRetentionPolicy( + UserMemoryOwnerKey.ForScope("scope-alpha"), + [ + new( + Aevatar.Studio.Application.Studio.Abstractions.UserMemoryCategory.Preference, + 8, + 20), + ], + 7, + "mutation-alpha")); + + bootstrap.ActorIds.Should().ContainSingle("user-memory-scope-alpha"); + var dispatched = dispatch.Dispatches.Should().ContainSingle().Subject; + dispatched.ActorId.Should().Be("user-memory-scope-alpha"); + dispatched.Envelope.Route.PublisherActorId.Should() + .Be("aevatar.studio.projection.user-memory-retention-policy"); + var payload = dispatched.Envelope.Payload.Unpack(); + payload.ExpectedStateVersion.Should().Be(7); + payload.MutationId.Should().Be("mutation-alpha"); + payload.Rules.Should().ContainSingle(); + payload.Rules[0].Category.Should().Be(Aevatar.GAgents.UserMemory.UserMemoryCategory.Preference); + payload.Rules[0].MaxEntries.Should().Be(8); + payload.Rules[0].EvictionRank.Should().Be(20); + receipt.Should().Be(new UserConfigSaveReceipt( + true, + "command-alpha", + UserConfigCommandAckStage.Accepted, + "user-memory-scope-alpha", + "correlation-alpha", + ackedAt)); + } + + [Fact] + public async Task ReplaceAsync_WithInvalidRule_ShouldRejectBeforeActorBootstrap() + { + var bootstrap = new RecordingBootstrap(); + var dispatch = RecordingDispatchPort.Accepting(); + var service = new ActorDispatchUserMemoryRetentionPolicyCommandService(bootstrap, dispatch); + var command = new ReplaceUserMemoryRetentionPolicy( + UserMemoryOwnerKey.ForScope("scope-alpha"), + [ + new( + Aevatar.Studio.Application.Studio.Abstractions.UserMemoryCategory.Unspecified, + 0, + 100), + ], + 0, + "mutation-invalid"); + + var act = () => service.ReplaceAsync(command); + + await act.Should().ThrowAsync() + .WithMessage("user_memory_policy_category_invalid"); + bootstrap.ActorIds.Should().BeEmpty(); + dispatch.Dispatches.Should().BeEmpty(); + } + + [Fact] + public void AddStudioProjectionComponents_ShouldRegisterRetentionPolicyCommandPort() + { + var services = new ServiceCollection(); + + services.AddStudioProjectionComponents(); + + services.Should().Contain(descriptor => + descriptor.ServiceType == typeof(IUserMemoryRetentionPolicyCommandPort) && + descriptor.ImplementationType == + typeof(ActorDispatchUserMemoryRetentionPolicyCommandService)); + } + + private sealed class RecordingBootstrap : IStudioActorBootstrap + { + public List ActorIds { get; } = []; + + public Task EnsureAsync(string actorId, CancellationToken ct = default) + where TAgent : IAgent, IProjectedActor + { + ActorIds.Add(actorId); + return Task.FromResult(new StubActor(actorId)); + } + } + + private sealed class StubActor(string id) : IActor + { + public string Id { get; } = id; + public IAgent Agent => throw new NotSupportedException(); + public Task ActivateAsync(CancellationToken ct = default) => Task.CompletedTask; + public Task DeactivateAsync(CancellationToken ct = default) => Task.CompletedTask; + public Task HandleEventAsync(EventEnvelope envelope, CancellationToken ct = default) => + Task.CompletedTask; + public Task GetParentIdAsync() => Task.FromResult(null); + public Task> GetChildrenIdsAsync() => + Task.FromResult>([]); + } + + private sealed class RecordingDispatchPort(DispatchAdmission admission) : IActorDispatchPort + { + public List<(string ActorId, EventEnvelope Envelope)> Dispatches { get; } = []; + + public static RecordingDispatchPort Accepting() => new(new DispatchAdmission( + true, + "command-alpha", + DateTimeOffset.Parse("2026-08-25T08:00:00Z"), + "user-memory-scope-alpha", + "correlation-alpha")); + + public Task DispatchAsync( + string actorId, + EventEnvelope envelope, + CancellationToken ct = default) + { + Dispatches.Add((actorId, envelope)); + return Task.FromResult(admission); + } + } +} diff --git a/test/Aevatar.Studio.Tests/ProjectionUserMemoryQueryPortTests.cs b/test/Aevatar.Studio.Tests/ProjectionUserMemoryQueryPortTests.cs index 1724733b4c..6a638b59cc 100644 --- a/test/Aevatar.Studio.Tests/ProjectionUserMemoryQueryPortTests.cs +++ b/test/Aevatar.Studio.Tests/ProjectionUserMemoryQueryPortTests.cs @@ -22,6 +22,19 @@ public async Task GetAsync_ShouldReadOwnerCurrentStateWithoutConflatingConversat var owner = UserMemoryOwnerKey.ForScope("user-gamma"); var state = new ActorUserMemoryState { + RetentionPolicy = new Aevatar.GAgents.UserMemory.UserMemoryRetentionPolicy + { + PolicyRevision = 4, + Rules = + { + new Aevatar.GAgents.UserMemory.UserMemoryCategoryRetentionRule + { + Category = ActorUserMemoryCategory.Preference, + MaxEntries = 8, + EvictionRank = 20, + }, + }, + }, Entries = { new ActorUserMemoryEntry @@ -59,6 +72,9 @@ public async Task GetAsync_ShouldReadOwnerCurrentStateWithoutConflatingConversat entry.Category.Should().Be(UserMemoryCategory.Preference); entry.Source.Should().Be(UserMemorySource.Explicit); entry.CreatedAt.Should().Be(DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_000)); + snapshot.PolicyRevision.Should().Be(4); + snapshot.RetentionPolicy!.Rules.Should().ContainSingle().Which.Should().Be( + new UserMemoryCategoryRetentionRule(UserMemoryCategory.Preference, 8, 20)); } [Fact] diff --git a/test/Aevatar.Studio.Tests/UserMemoryGAgentCommandTests.cs b/test/Aevatar.Studio.Tests/UserMemoryGAgentCommandTests.cs index c228294ea5..45ff21885f 100644 --- a/test/Aevatar.Studio.Tests/UserMemoryGAgentCommandTests.cs +++ b/test/Aevatar.Studio.Tests/UserMemoryGAgentCommandTests.cs @@ -107,6 +107,268 @@ await act.Should().ThrowAsync() (await eventStore.GetEventsAsync(ActorId)).Should().BeEmpty(); } + [Fact] + public async Task ReplayWithoutPolicy_ShouldPreserveLegacyStateBytes() + { + var agent = await CreateAgentAsync(new InMemoryEventStore()); + var context = Entry("context-oldest", UserMemoryCategory.Context, 1); + var preference = Entry("preference-old", UserMemoryCategory.Preference, 10); + await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand { Entry = context }); + await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand { Entry = preference }); + var instructions = Enumerable.Range(0, 48) + .Select(index => Entry($"instruction-{index}", UserMemoryCategory.Instruction, 100 + index)) + .ToArray(); + foreach (var instruction in instructions) + await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand { Entry = instruction }); + + var added = Entry("preference-new", UserMemoryCategory.Preference, 1_000); + await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand { Entry = added }); + + var expected = new UserMemoryState(); + expected.Entries.Add(context); + expected.Entries.AddRange(instructions); + expected.Entries.Add(added); + agent.State.RetentionPolicy.Should().BeNull(); + agent.State.ToByteArray().Should().Equal(expected.ToByteArray()); + } + + [Fact] + public async Task PolicyEviction_ShouldEnforceAddedCategoryCap() + { + var agent = await CreateAgentAsync(new InMemoryEventStore()); + await agent.HandleReplaceRetentionPolicy(PolicyCommand( + 0, + "policy-cap", + Rule(UserMemoryCategory.Preference, maxEntries: 2, evictionRank: 100))); + + await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand + { + Entry = Entry("preference-1", UserMemoryCategory.Preference, 1), + }); + await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand + { + Entry = Entry("preference-2", UserMemoryCategory.Preference, 2), + }); + await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand + { + Entry = Entry("preference-3", UserMemoryCategory.Preference, 3), + }); + + agent.State.Entries.Select(static entry => entry.Id) + .Should().Equal("preference-2", "preference-3"); + } + + [Fact] + public async Task PolicyEviction_ShouldEvictHigherRankBeforeOlderLowRankEntry() + { + var agent = await CreateAgentAsync(new InMemoryEventStore()); + await agent.HandleReplaceRetentionPolicy(PolicyCommand( + 0, + "policy-rank", + Rule(UserMemoryCategory.Preference, 0, 0), + Rule(UserMemoryCategory.Context, 0, 900))); + await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand + { + Entry = Entry("context-newer", UserMemoryCategory.Context, 1_000), + }); + for (var index = 0; index < 49; index++) + { + await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand + { + Entry = Entry($"preference-{index}", UserMemoryCategory.Preference, index + 1), + }); + } + + await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand + { + Entry = Entry("instruction-added", UserMemoryCategory.Instruction, 2_000), + }); + + agent.State.Entries.Should().HaveCount(50); + agent.State.Entries.Should().NotContain(entry => entry.Id == "context-newer"); + agent.State.Entries.Should().Contain(entry => entry.Id == "preference-0"); + } + + [Fact] + public async Task PolicyEviction_WhenRanksTie_ShouldUseLegacySameCategoryOrder() + { + var agent = await CreateAgentAsync(new InMemoryEventStore()); + await agent.HandleReplaceRetentionPolicy(PolicyCommand(0, "policy-tie")); + await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand + { + Entry = Entry("context-oldest", UserMemoryCategory.Context, 1), + }); + await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand + { + Entry = Entry("preference-old", UserMemoryCategory.Preference, 100), + }); + for (var index = 0; index < 48; index++) + { + await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand + { + Entry = Entry($"instruction-{index}", UserMemoryCategory.Instruction, 200 + index), + }); + } + + await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand + { + Entry = Entry("preference-added", UserMemoryCategory.Preference, 2_000), + }); + + agent.State.Entries.Should().Contain(entry => entry.Id == "context-oldest"); + agent.State.Entries.Should().NotContain(entry => entry.Id == "preference-old"); + agent.State.Entries.Should().Contain(entry => entry.Id == "preference-added"); + } + + [Fact] + public async Task PolicyEviction_ShouldNeverEvictEntryBeingAdded() + { + var agent = await CreateAgentAsync(new InMemoryEventStore()); + await agent.HandleReplaceRetentionPolicy(PolicyCommand( + 0, + "policy-new-entry", + Rule(UserMemoryCategory.Preference, 0, 0), + Rule(UserMemoryCategory.Context, 0, 1_000))); + for (var index = 0; index < 50; index++) + { + await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand + { + Entry = Entry($"preference-{index}", UserMemoryCategory.Preference, 100 + index), + }); + } + + await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand + { + Entry = Entry("context-added", UserMemoryCategory.Context, 1), + }); + + agent.State.Entries.Should().Contain(entry => entry.Id == "context-added"); + agent.State.Entries.Should().NotContain(entry => entry.Id == "preference-0"); + } + + [Fact] + public async Task ReplacePolicy_ShouldApplyCasRevisionAndMutationIdempotency() + { + var agent = await CreateAgentAsync(new InMemoryEventStore()); + var initial = PolicyCommand( + 0, + " policy-alpha ", + Rule(UserMemoryCategory.Context, 5, 10), + Rule(UserMemoryCategory.Preference, 2, 900)); + + await agent.HandleReplaceRetentionPolicy(initial); + + agent.EventSourcing!.CurrentVersion.Should().Be(1); + agent.State.RetentionPolicy.PolicyRevision.Should().Be(1); + agent.State.RetentionPolicy.Rules.Select(static rule => rule.Category) + .Should().Equal(UserMemoryCategory.Preference, UserMemoryCategory.Context); + agent.State.LastRetentionPolicyMutationId.Should().Be("policy-alpha"); + + var retry = PolicyCommand( + 0, + "policy-alpha", + Rule(UserMemoryCategory.Preference, 2, 900), + Rule(UserMemoryCategory.Context, 5, 10)); + await agent.HandleReplaceRetentionPolicy(retry); + agent.EventSourcing.CurrentVersion.Should().Be(1); + + var conflictingMutation = () => agent.HandleReplaceRetentionPolicy(PolicyCommand( + 0, + "policy-alpha", + Rule(UserMemoryCategory.Context, 4, 10))); + await conflictingMutation.Should().ThrowAsync() + .WithMessage("user_memory_policy_mutation_conflict"); + + var staleVersion = () => agent.HandleReplaceRetentionPolicy(PolicyCommand( + 0, + "policy-beta", + Rule(UserMemoryCategory.Context, 4, 10))); + await staleVersion.Should().ThrowAsync() + .WithMessage("user_memory_expected_state_version_conflict"); + agent.EventSourcing.CurrentVersion.Should().Be(1); + + await agent.HandleReplaceRetentionPolicy(PolicyCommand( + 1, + "policy-beta", + Rule(UserMemoryCategory.Context, 4, 10))); + agent.State.RetentionPolicy.PolicyRevision.Should().Be(2); + agent.EventSourcing.CurrentVersion.Should().Be(2); + } + + [Theory] + [InlineData(UserMemoryCategory.Unspecified, 0, 0, "user_memory_policy_category_invalid")] + [InlineData(UserMemoryCategory.Preference, -1, 0, "user_memory_policy_max_entries_invalid")] + [InlineData(UserMemoryCategory.Preference, 51, 0, "user_memory_policy_max_entries_invalid")] + [InlineData(UserMemoryCategory.Preference, 0, -1, "user_memory_policy_eviction_rank_invalid")] + [InlineData(UserMemoryCategory.Preference, 0, 1001, "user_memory_policy_eviction_rank_invalid")] + public async Task ReplacePolicy_WithInvalidRule_ShouldRejectWithoutCommitting( + UserMemoryCategory category, + int maxEntries, + int evictionRank, + string error) + { + var agent = await CreateAgentAsync(new InMemoryEventStore()); + var act = () => agent.HandleReplaceRetentionPolicy(PolicyCommand( + 0, + "policy-invalid", + Rule(category, maxEntries, evictionRank))); + + await act.Should().ThrowAsync().WithMessage(error); + agent.EventSourcing!.CurrentVersion.Should().Be(0); + } + + [Fact] + public async Task ReplacePolicy_WithDuplicateCategory_ShouldRejectWithoutCommitting() + { + var agent = await CreateAgentAsync(new InMemoryEventStore()); + var act = () => agent.HandleReplaceRetentionPolicy(PolicyCommand( + 0, + "policy-duplicate", + Rule(UserMemoryCategory.Preference, 2, 10), + Rule(UserMemoryCategory.Preference, 3, 20))); + + await act.Should().ThrowAsync() + .WithMessage("user_memory_policy_category_duplicate"); + agent.EventSourcing!.CurrentVersion.Should().Be(0); + } + + private static ReplaceUserMemoryRetentionPolicyCommand PolicyCommand( + long expectedStateVersion, + string mutationId, + params UserMemoryCategoryRetentionRule[] rules) + { + var command = new ReplaceUserMemoryRetentionPolicyCommand + { + ExpectedStateVersion = expectedStateVersion, + MutationId = mutationId, + }; + command.Rules.AddRange(rules); + return command; + } + + private static UserMemoryCategoryRetentionRule Rule( + UserMemoryCategory category, + int maxEntries, + int evictionRank) => new() + { + Category = category, + MaxEntries = maxEntries, + EvictionRank = evictionRank, + }; + + private static UserMemoryEntryProto Entry( + string id, + UserMemoryCategory category, + long createdAtMs) => new() + { + Id = id, + Category = category, + Content = $"content-{id}", + Source = UserMemorySource.Explicit, + CreatedAtMs = createdAtMs, + UpdatedAtMs = createdAtMs, + }; + private static EventEnvelope Envelope(IMessage payload) => new() { diff --git a/test/Aevatar.Studio.Tests/UserMemoryRetentionPolicyControllerTests.cs b/test/Aevatar.Studio.Tests/UserMemoryRetentionPolicyControllerTests.cs new file mode 100644 index 0000000000..c985cb71ba --- /dev/null +++ b/test/Aevatar.Studio.Tests/UserMemoryRetentionPolicyControllerTests.cs @@ -0,0 +1,110 @@ +using System.Reflection; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Hosting.Controllers; +using FluentAssertions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Aevatar.Studio.Tests; + +public sealed class UserMemoryRetentionPolicyControllerTests +{ + [Fact] + public void Controller_ShouldExposeAuthorizedPutRoute() + { + typeof(UserMemoryRetentionPolicyController) + .GetCustomAttribute() + .Should().NotBeNull(); + typeof(UserMemoryRetentionPolicyController) + .GetCustomAttribute()!.Template + .Should().Be("api/user-memory/retention-policy"); + typeof(UserMemoryRetentionPolicyController) + .GetMethod(nameof(UserMemoryRetentionPolicyController.Replace))! + .GetCustomAttribute() + .Should().NotBeNull(); + } + + [Fact] + public async Task Replace_ShouldUseAuthorizedScopeAndReturnAcceptedReceipt() + { + var ackedAt = DateTimeOffset.Parse("2026-08-25T08:00:00Z"); + var commandPort = new RecordingCommandPort(new UserConfigSaveReceipt( + true, + "command-alpha", + UserConfigCommandAckStage.Accepted, + "user-memory-scope-alpha", + "correlation-alpha", + ackedAt)); + var controller = new UserMemoryRetentionPolicyController( + commandPort, + new StubScopeResolver("scope-alpha")); + var request = new ReplaceUserMemoryRetentionPolicyRequest( + [new("preference", 8, 20)], + 7, + "mutation-alpha"); + + var response = await controller.Replace(request, CancellationToken.None); + + var accepted = response.Result.Should().BeOfType().Subject; + accepted.Value.Should().Be(new UserMemoryRetentionPolicySaveReceiptResponse( + true, + "command-alpha", + UserConfigCommandAckStage.Accepted, + "user-memory-scope-alpha", + "correlation-alpha", + ackedAt)); + var command = commandPort.Commands.Should().ContainSingle().Subject; + command.Owner.Should().Be(UserMemoryOwnerKey.ForScope("scope-alpha")); + command.ExpectedStateVersion.Should().Be(7); + command.MutationId.Should().Be("mutation-alpha"); + command.Rules.Should().ContainSingle().Which.Should().Be( + new UserMemoryCategoryRetentionRule(UserMemoryCategory.Preference, 8, 20)); + } + + [Fact] + public async Task Replace_WithoutExpectedVersion_ShouldRejectBeforeDispatch() + { + var commandPort = new RecordingCommandPort(new UserConfigSaveReceipt( + true, + "command-alpha", + UserConfigCommandAckStage.Accepted, + "user-memory-scope-alpha", + "correlation-alpha", + DateTimeOffset.UtcNow)); + var controller = new UserMemoryRetentionPolicyController( + commandPort, + new StubScopeResolver("scope-alpha")); + + var response = await controller.Replace( + new ReplaceUserMemoryRetentionPolicyRequest([], null, "mutation-alpha"), + CancellationToken.None); + + response.Result.Should().BeOfType(); + commandPort.Commands.Should().BeEmpty(); + } + + private sealed class RecordingCommandPort(UserConfigSaveReceipt receipt) + : IUserMemoryRetentionPolicyCommandPort + { + public List Commands { get; } = []; + + public Task ReplaceAsync( + ReplaceUserMemoryRetentionPolicy command, + CancellationToken ct = default) + { + Commands.Add(command); + return Task.FromResult(receipt); + } + } + + private sealed class StubScopeResolver(string scopeId) : IAppScopeResolver + { + public AppScopeContext? Resolve(HttpContext? httpContext = null) => + new(scopeId, "claim:scope_id"); + + public bool HasHttpRequestContext(HttpContext? httpContext = null) => true; + + public bool HasAuthenticatedRequestWithoutScope(HttpContext? httpContext = null) => false; + } +}