diff --git a/agents/Aevatar.GAgents.NyxidChat/ConversationContextAttachmentAdmission.cs b/agents/Aevatar.GAgents.NyxidChat/ConversationContextAttachmentAdmission.cs index a09e09720..9a1fff02e 100644 --- a/agents/Aevatar.GAgents.NyxidChat/ConversationContextAttachmentAdmission.cs +++ b/agents/Aevatar.GAgents.NyxidChat/ConversationContextAttachmentAdmission.cs @@ -35,11 +35,21 @@ public static bool ByteEquivalent( public static bool TryNormalize( ConversationContextAttachmentSet? source, - out ConversationContextAttachmentSet normalized) + out ConversationContextAttachmentSet normalized) => + TryNormalize(source, out normalized, out _); + + public static bool TryNormalize( + ConversationContextAttachmentSet? source, + out ConversationContextAttachmentSet normalized, + out ConversationContextAttachmentAdmissionFailureReason failureReason) { normalized = CloneSet(source?.Attachments); + failureReason = ConversationContextAttachmentAdmissionFailureReason.Unspecified; if (normalized.Attachments.Count > MaximumAttachments) + { + failureReason = ConversationContextAttachmentAdmissionFailureReason.OverLimit; return false; + } var ids = new HashSet(StringComparer.Ordinal); foreach (var attachment in normalized.Attachments) @@ -48,12 +58,18 @@ public static bool TryNormalize( attachment.PinnedRevisionId = attachment.PinnedRevisionId.Trim(); if (string.IsNullOrWhiteSpace(attachment.ArtifactId) || !ids.Add(attachment.ArtifactId)) + { + failureReason = ConversationContextAttachmentAdmissionFailureReason.InvalidRequest; return false; + } if (attachment.RevisionMode == ConversationContextAttachmentRevisionMode.PinnedRevision) { if (string.IsNullOrWhiteSpace(attachment.PinnedRevisionId)) + { + failureReason = ConversationContextAttachmentAdmissionFailureReason.InvalidRequest; return false; + } } else if (attachment.RevisionMode == ConversationContextAttachmentRevisionMode.FollowCurrent) { @@ -61,6 +77,7 @@ public static bool TryNormalize( } else { + failureReason = ConversationContextAttachmentAdmissionFailureReason.InvalidRequest; return false; } } diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.Streaming.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.Streaming.cs index 88ccfa204..edf1e0f2a 100644 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.Streaming.cs +++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.Streaming.cs @@ -621,6 +621,21 @@ private static async Task HandleInteractionFailureAsync( if (result.Succeeded) return; + var attachmentReason = ToAttachmentAdmissionFailureReason(result.Error); + if (attachmentReason != ConversationContextAttachmentAdmissionFailureReason.Unspecified) + { + await writerGate.WriteTerminalAsync( + token => writer.WriteRunErrorAsync( + turnId, + "ATTACHMENT_ADMISSION_DENIED", + ToAttachmentAdmissionMessage(attachmentReason), + ToAttachmentAdmissionWireName(attachmentReason), + 0, + token), + CancellationToken.None); + return; + } + await writerGate.WriteTerminalAsync( token => writer.WriteRunErrorAsync( turnId, @@ -644,6 +659,66 @@ await writerGate.WriteTerminalAsync( CancellationToken.None); } + private static ConversationContextAttachmentAdmissionFailureReason ToAttachmentAdmissionFailureReason( + NyxIdChatStartError error) => error switch + { + NyxIdChatStartError.AttachmentNotFound => + ConversationContextAttachmentAdmissionFailureReason.NotFound, + NyxIdChatStartError.AttachmentAccessDenied => + ConversationContextAttachmentAdmissionFailureReason.AccessDenied, + NyxIdChatStartError.AttachmentUnsupportedKind => + ConversationContextAttachmentAdmissionFailureReason.UnsupportedKind, + NyxIdChatStartError.AttachmentOverLimit => + ConversationContextAttachmentAdmissionFailureReason.OverLimit, + NyxIdChatStartError.AttachmentPinnedRevisionUnavailable => + ConversationContextAttachmentAdmissionFailureReason.PinnedRevisionUnavailable, + NyxIdChatStartError.AttachmentInvalidRequest => + ConversationContextAttachmentAdmissionFailureReason.InvalidRequest, + NyxIdChatStartError.AttachmentInactive => + ConversationContextAttachmentAdmissionFailureReason.Inactive, + NyxIdChatStartError.AttachmentReadModelUnavailable => + ConversationContextAttachmentAdmissionFailureReason.ReadModelUnavailable, + _ => ConversationContextAttachmentAdmissionFailureReason.Unspecified, + }; + + private static string ToAttachmentAdmissionWireName( + ConversationContextAttachmentAdmissionFailureReason reason) => reason switch + { + ConversationContextAttachmentAdmissionFailureReason.NotFound => "not_found", + ConversationContextAttachmentAdmissionFailureReason.AccessDenied => "access_denied", + ConversationContextAttachmentAdmissionFailureReason.UnsupportedKind => "unsupported_kind", + ConversationContextAttachmentAdmissionFailureReason.OverLimit => "over_limit", + ConversationContextAttachmentAdmissionFailureReason.PinnedRevisionUnavailable => + "pinned_revision_unavailable", + ConversationContextAttachmentAdmissionFailureReason.InvalidRequest => "invalid_request", + ConversationContextAttachmentAdmissionFailureReason.Inactive => "inactive", + ConversationContextAttachmentAdmissionFailureReason.ReadModelUnavailable => + "read_model_unavailable", + _ => "unspecified", + }; + + private static string ToAttachmentAdmissionMessage( + ConversationContextAttachmentAdmissionFailureReason reason) => reason switch + { + ConversationContextAttachmentAdmissionFailureReason.NotFound => + "A requested context attachment was not found.", + ConversationContextAttachmentAdmissionFailureReason.AccessDenied => + "Access to a requested context attachment was denied.", + ConversationContextAttachmentAdmissionFailureReason.UnsupportedKind => + "A requested context attachment kind is unsupported.", + ConversationContextAttachmentAdmissionFailureReason.OverLimit => + $"A conversation can bind at most {ConversationContextAttachmentAdmission.MaximumAttachments} context attachments.", + ConversationContextAttachmentAdmissionFailureReason.PinnedRevisionUnavailable => + "A requested pinned context attachment revision is unavailable.", + ConversationContextAttachmentAdmissionFailureReason.InvalidRequest => + "The context attachment declaration is invalid.", + ConversationContextAttachmentAdmissionFailureReason.Inactive => + "A requested context attachment is inactive.", + ConversationContextAttachmentAdmissionFailureReason.ReadModelUnavailable => + "Context attachment admission is temporarily unavailable.", + _ => "Context attachment admission was denied.", + }; + private static bool IsTerminalFrame(AGUIEvent evt) => evt.EventCase is AGUIEvent.EventOneofCase.RunFinished or AGUIEvent.EventOneofCase.RunError; diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatInteraction.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatInteraction.cs index 40c906f0b..daa77c27c 100644 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatInteraction.cs +++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatInteraction.cs @@ -189,6 +189,14 @@ public enum NyxIdChatStartError ActorNotFound = 1, ProjectionUnavailable = 2, AdmissionUnavailable = 3, + AttachmentNotFound = 4, + AttachmentAccessDenied = 5, + AttachmentUnsupportedKind = 6, + AttachmentOverLimit = 7, + AttachmentPinnedRevisionUnavailable = 8, + AttachmentInvalidRequest = 9, + AttachmentInactive = 10, + AttachmentReadModelUnavailable = 11, } public readonly record struct NyxIdChatCompletionStatus @@ -427,6 +435,22 @@ public async Task NyxIdChatStartError.AdmissionUnavailable, + NyxIdChatLifecycleCommandStartError.AttachmentNotFound => + NyxIdChatStartError.AttachmentNotFound, + NyxIdChatLifecycleCommandStartError.AttachmentAccessDenied => + NyxIdChatStartError.AttachmentAccessDenied, + NyxIdChatLifecycleCommandStartError.AttachmentUnsupportedKind => + NyxIdChatStartError.AttachmentUnsupportedKind, + NyxIdChatLifecycleCommandStartError.AttachmentOverLimit => + NyxIdChatStartError.AttachmentOverLimit, + NyxIdChatLifecycleCommandStartError.AttachmentPinnedRevisionUnavailable => + NyxIdChatStartError.AttachmentPinnedRevisionUnavailable, + NyxIdChatLifecycleCommandStartError.AttachmentInvalidRequest => + NyxIdChatStartError.AttachmentInvalidRequest, + NyxIdChatLifecycleCommandStartError.AttachmentInactive => + NyxIdChatStartError.AttachmentInactive, + NyxIdChatLifecycleCommandStartError.AttachmentReadModelUnavailable => + NyxIdChatStartError.AttachmentReadModelUnavailable, _ => NyxIdChatStartError.ProjectionUnavailable, }); } diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatLifecycleFacade.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatLifecycleFacade.cs index 9aa3fc888..2aa2f251b 100644 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatLifecycleFacade.cs +++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatLifecycleFacade.cs @@ -52,6 +52,14 @@ public enum NyxIdChatLifecycleCommandStartError AdmissionUnavailable = 2, TargetNotFound = 3, AccessDenied = 4, + AttachmentNotFound = 5, + AttachmentAccessDenied = 6, + AttachmentUnsupportedKind = 7, + AttachmentOverLimit = 8, + AttachmentPinnedRevisionUnavailable = 9, + AttachmentInvalidRequest = 10, + AttachmentInactive = 11, + AttachmentReadModelUnavailable = 12, } public sealed class NyxIdChatLifecycleFacade @@ -229,10 +237,11 @@ public async Task.Failure( - NyxIdChatLifecycleCommandStartError.AdmissionUnavailable); + ToAttachmentStartError(attachmentFailureReason)); } var callerScope = OwnerScope.ForNyxIdNative(command.ScopeId); @@ -281,23 +290,27 @@ public async Task ValidateContextAttachmentsAsync( + // Implement (issue #3543): + // Behavior: Every create-only attachment rejection retains its typed client recovery reason. + // Why this shape: Admission remains read-model-only and fails before actor creation. + private async Task ValidateContextAttachmentsAsync( NyxIdChatConversationCreateCommand command, CancellationToken ct) { if (!ConversationContextAttachmentAdmission.TryNormalize( command.ContextAttachments, - out var normalized)) - return false; + out var normalized, + out var failureReason)) + return failureReason; command.ContextAttachments = normalized; if (normalized.Attachments.Count == 0) - return true; + return ConversationContextAttachmentAdmissionFailureReason.Unspecified; if (_contentArtifactQueryPort is null) - return false; + return ConversationContextAttachmentAdmissionFailureReason.ReadModelUnavailable; var requester = command.FirstTurn?.ToolContext?.Caller?.OwnerSubject?.Trim(); if (string.IsNullOrWhiteSpace(requester)) - return false; + return ConversationContextAttachmentAdmissionFailureReason.AccessDenied; foreach (var attachment in normalized.Attachments) { @@ -311,14 +324,20 @@ private async Task ValidateContextAttachmentsAsync( } catch { - return false; + return ConversationContextAttachmentAdmissionFailureReason.ReadModelUnavailable; } - if (artifact is null || - !string.Equals(artifact.LifecycleStatus, ContentArtifactLifecycleStatusNames.Active, StringComparison.Ordinal) || - !ConversationContextAttachmentAdmission.IsAllowedKind(artifact.Kind) || - !ConversationContextAttachmentAdmission.IsAuthorized(artifact, requester)) - return false; + if (artifact is null) + return ConversationContextAttachmentAdmissionFailureReason.NotFound; + if (!string.Equals( + artifact.LifecycleStatus, + ContentArtifactLifecycleStatusNames.Active, + StringComparison.Ordinal)) + return ConversationContextAttachmentAdmissionFailureReason.Inactive; + if (!ConversationContextAttachmentAdmission.IsAllowedKind(artifact.Kind)) + return ConversationContextAttachmentAdmissionFailureReason.UnsupportedKind; + if (!ConversationContextAttachmentAdmission.IsAuthorized(artifact, requester)) + return ConversationContextAttachmentAdmissionFailureReason.AccessDenied; if (attachment.RevisionMode == ConversationContextAttachmentRevisionMode.PinnedRevision) { @@ -326,12 +345,34 @@ private async Task ValidateContextAttachmentsAsync( string.Equals(item.RevisionId, attachment.PinnedRevisionId, StringComparison.Ordinal)); if (revision is null || !string.Equals(revision.Availability, ContentArtifactRevisionAvailabilityNames.Available, StringComparison.Ordinal)) - return false; + return ConversationContextAttachmentAdmissionFailureReason.PinnedRevisionUnavailable; } } - return true; + return ConversationContextAttachmentAdmissionFailureReason.Unspecified; } + + private static NyxIdChatLifecycleCommandStartError ToAttachmentStartError( + ConversationContextAttachmentAdmissionFailureReason reason) => reason switch + { + ConversationContextAttachmentAdmissionFailureReason.NotFound => + NyxIdChatLifecycleCommandStartError.AttachmentNotFound, + ConversationContextAttachmentAdmissionFailureReason.AccessDenied => + NyxIdChatLifecycleCommandStartError.AttachmentAccessDenied, + ConversationContextAttachmentAdmissionFailureReason.UnsupportedKind => + NyxIdChatLifecycleCommandStartError.AttachmentUnsupportedKind, + ConversationContextAttachmentAdmissionFailureReason.OverLimit => + NyxIdChatLifecycleCommandStartError.AttachmentOverLimit, + ConversationContextAttachmentAdmissionFailureReason.PinnedRevisionUnavailable => + NyxIdChatLifecycleCommandStartError.AttachmentPinnedRevisionUnavailable, + ConversationContextAttachmentAdmissionFailureReason.InvalidRequest => + NyxIdChatLifecycleCommandStartError.AttachmentInvalidRequest, + ConversationContextAttachmentAdmissionFailureReason.Inactive => + NyxIdChatLifecycleCommandStartError.AttachmentInactive, + ConversationContextAttachmentAdmissionFailureReason.ReadModelUnavailable => + NyxIdChatLifecycleCommandStartError.AttachmentReadModelUnavailable, + _ => NyxIdChatLifecycleCommandStartError.AdmissionUnavailable, + }; } internal sealed class NyxIdChatConversationDeleteCommandTargetResolver diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatPublicEndpoints.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatPublicEndpoints.cs index 9785482c1..03c570e27 100644 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatPublicEndpoints.cs +++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatPublicEndpoints.cs @@ -131,6 +131,7 @@ internal static async Task HandlePublicListConversationsAsync( AttentionSince = summary.AttentionSince, ActiveStepSummary = summary.ActiveStepSummary, StateVersion = summary.StateVersion, + ContextAttachments = summary.ContextAttachments, }; }).ToList(), page.NextCursor)); diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatSseWriter.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatSseWriter.cs index 04524ff53..414b0f0a2 100644 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatSseWriter.cs +++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatSseWriter.cs @@ -229,6 +229,20 @@ public ValueTask WriteRunErrorAsync(string turnId, string code, string message, runError = new { runId = turnId, code, message }, }, sequence, ct); + public ValueTask WriteRunErrorAsync( + string turnId, + string code, + string message, + string reason, + long sequence, + CancellationToken ct) => + WriteFrameAsync(new + { + type = "RUN_ERROR", + turnId, + runError = new { runId = turnId, code, message, reason }, + }, sequence, ct); + public ValueTask WriteMediaContentAsync( Aevatar.AI.Abstractions.MediaContentEvent evt, long sequence, diff --git a/docs/canon/conversation-context-and-memory.md b/docs/canon/conversation-context-and-memory.md index 8123db616..01655bf87 100644 --- a/docs/canon/conversation-context-and-memory.md +++ b/docs/canon/conversation-context-and-memory.md @@ -42,10 +42,18 @@ Conversation structured attachments are create-only, immutable references. Each `FOLLOW_CURRENT` or `PINNED_REVISION`; the set is bounded to four artifact identities and is sealed by `ConversationContextAttachmentsBoundEvent`. Replaying the same deterministic protobuf bytes is idempotent; a different or missing set cannot replace an existing binding. Create -admission reads only the ContentArtifact read model and fails closed with `ADMISSION_UNAVAILABLE` -for missing, inactive, unauthorized, unsupported-kind, duplicate/over-limit, or unavailable -pinned revisions. A turn may degrade to a typed unavailable placeholder when a later verified -read is redacted, tombstoned, expired, over budget, or temporarily unavailable. +admission reads only the ContentArtifact read model. Attachment failures use +`ATTACHMENT_ADMISSION_DENIED` plus a typed reason (`not_found`, `access_denied`, +`unsupported_kind`, `over_limit`, `pinned_revision_unavailable`, or the narrower invalid, +inactive, and read-model-unavailable reasons); Profile and route failures retain +`ADMISSION_UNAVAILABLE`. A turn may degrade to a typed unavailable placeholder when a later +verified read is redacted, tombstoned, expired, over budget, or temporarily unavailable. + +The sealed reference set is copied from committed Conversation state into the actor-scoped +current-state read model. Both `/api/chat/conversations/{conversationId}/state` and the +`/api/chat/conversations` index expose only `artifactId`, `revisionMode`, and +`pinnedRevisionId`; artifact bodies remain exclusively behind the verified ContentArtifact read +path and never enter Conversation state, transcript, or either response. `FOLLOW_CURRENT` resolves only the artifact read model's explicit `CurrentRevisionId` on each turn; it never infers the highest revision number or silently retargets to the latest append. diff --git a/docs/canon/nyxid-chat-agent-profile-binding.md b/docs/canon/nyxid-chat-agent-profile-binding.md index 4e3e67e1d..728e19a3f 100644 --- a/docs/canon/nyxid-chat-agent-profile-binding.md +++ b/docs/canon/nyxid-chat-agent-profile-binding.md @@ -74,7 +74,9 @@ reference. This is a separate authority: an attachment names an exact ContentArt and chooses `FOLLOW_CURRENT` or `PINNED_REVISION`; it does not grant write access or turn the artifact into profile content. The create resolver validates the artifact read model before actor creation (active lifecycle, owner/reader ACL, `TEXT`/`MARKDOWN`/`STRUCTURED_DOCUMENT` kind, and -available pinned revision). Any failure is `ADMISSION_UNAVAILABLE` and no actor is created. +available pinned revision). Attachment failures return `ATTACHMENT_ADMISSION_DENIED` with a typed +reason; Profile and route failures continue to use `ADMISSION_UNAVAILABLE`. No actor is created +after either rejection. The Conversation actor commits `ConversationContextAttachmentsBoundEvent` once. Equal protobuf bytes are idempotent; a different or absent later declaration is rejected. The sealed set is @@ -87,6 +89,10 @@ backing failures, read-model unavailability, and either prompt budget produce a placeholder plus diagnostic; content is never truncated or persisted in Conversation state, read models, or transcript history. +The current-state projector copies the sealed set as reference-only data. The state and list +resources expose `artifactId`, `revisionMode`, and `pinnedRevisionId` for reconciliation, without +materializing or persisting any artifact body. + ## Static route tool ceiling Profiled 与 genuinely unprofiled NyxID Chat 共用 Host 静态注册的 diff --git a/docs/canon/nyxid-chat-api.md b/docs/canon/nyxid-chat-api.md index ac4cfc54e..46cc96fdf 100644 --- a/docs/canon/nyxid-chat-api.md +++ b/docs/canon/nyxid-chat-api.md @@ -666,6 +666,9 @@ effect evidence, available actions, pending input, approval presentation, latest safe input/approval resolution facts, typed `pendingActions` and bounded `recentActions`, control fences, continuation admission, progress sequence, actor-authored attention, and actor version. It also exposes +the create-only sealed `contextAttachments` set as reference-only +`artifactId / revisionMode / pinnedRevisionId` entries. No attachment body is part of this +document or response. The state resource also exposes the exact safe typed parameters needed to resume browser actions after reload: `key.create` preserves `name`, `platform`, and the nonempty `allowedServiceIds`; `key.rotate` preserves only `keyId`. These values come @@ -691,7 +694,7 @@ All resources use the authenticated scope and the same public `conversationId`; | Route | Behavior | |---|---| -| `GET /api/chat/conversations?pageSize={n}&cursor={cursor}` | Lists the caller's NyxID Assistant transcript index. `pageSize` defaults to `50`; `cursor` is opaque. Each materialized conversation may include actor-authored `taskStatus`, `attentionKind`, `attentionSince`, `activeStepSummary`, and `stateVersion`; the response also contains an optional `nextCursor`. | +| `GET /api/chat/conversations?pageSize={n}&cursor={cursor}` | Lists the caller's NyxID Assistant transcript index. `pageSize` defaults to `50`; `cursor` is opaque. Each materialized conversation may include actor-authored `taskStatus`, `attentionKind`, `attentionSince`, `activeStepSummary`, `stateVersion`, and the reference-only sealed `contextAttachments`; the response also contains an optional `nextCursor`. | | `GET /api/chat/conversations/{conversationId}` | Returns the durable transcript as `messages` plus its `stateVersion`. | | `GET /api/chat/conversations/{conversationId}/state` | Returns the conditional current-state result documented above. | | `DELETE /api/chat/conversations/{conversationId}` | Submits the existing authoritative conversation retirement/deletion commands. | @@ -706,6 +709,13 @@ NyxID Assistant ingress is `application/json` (including `+json`) with one recog The caller must authenticate with exactly one non-conflicting `scope_id` or `workflow.scope_id` claim. Missing or ambiguous scope returns `401`; an owned-resource mismatch returns `403`; absent conversations/read models return `404`; unavailable admission returns `503`. Stream setup and execution failures are emitted as safe AGUI `RUN_ERROR` terminals when streaming has begun. +Create-time attachment rejection emits `RUN_ERROR(code=ATTACHMENT_ADMISSION_DENIED)` with a typed +`reason`: `not_found`, `access_denied`, `unsupported_kind`, `over_limit`, +`pinned_revision_unavailable`, `invalid_request`, `inactive`, or `read_model_unavailable`. +Clients may drop or correct attachments for structural reasons and retry transient read-model +unavailability. Profile and route admission failures remain `ADMISSION_UNAVAILABLE` and do not +claim an attachment cause. + `clientRequestId` is the transport idempotency identity. When both the body and `Idempotency-Key` header provide one, the body wins. An exact retry preserves the existing admission/result, while reuse with different content fails closed. Input, approval, controls, and delete return honest `202` receipts; committed state and projection visibility are observed later through AGUI or the public state resource. ## Scoped-route compatibility diff --git a/docs/contracts/nyxid-assistant-conformance/v1/sources.json b/docs/contracts/nyxid-assistant-conformance/v1/sources.json index 908320cfe..1094c7ac4 100644 --- a/docs/contracts/nyxid-assistant-conformance/v1/sources.json +++ b/docs/contracts/nyxid-assistant-conformance/v1/sources.json @@ -2,8 +2,8 @@ "schema_version": 1, "aevatar": { "repository": "https://github.com/AevatarAI/aevatar.git", - "revision": "4f5066066786faed3dba5f7410f090c1fbddcb17", - "contract_files_sha256": "616e3dc31d4a1b0dde898f8a5029d21e967c475b596dfefba33d261c354d75f6", + "revision": "676d577fd381e1b3dae227579b435662d6d49a44", + "contract_files_sha256": "6a1e73840ef81d6fa06a1e249e03b7f42d20ed2cc8430d04e1f4ccc3aaff5aa9", "files": { "agents/Aevatar.GAgents.NyxidChat/NyxIdActionPostconditionPort.cs": "7791de469b567dcde70a0f8e2a88cc818972ca557617a2538294e8ccabd5bda0", "agents/Aevatar.GAgents.NyxidChat/NyxIdAssistantActionRegistry.cs": "60e6f67c94ae11b1bf0dac036ad8ac0c35901e31787b1f0c8173964f6a12d263", @@ -12,7 +12,7 @@ "agents/Aevatar.GAgents.NyxidChat/protos/nyxid_chat_recovery_secret.proto": "07dbc449a732df6c7a6d0a97054ddbe35a517f4af4c5670b05ed0bea0bf2011a", "agents/Aevatar.GAgents.NyxidChat/protos/nyxid_chat_task.proto": "8badff0b887a90765c1cd3fb9304c0310fab7178b601c04e08e6136e501b5b6f", "docs/adr/0048-nyxid-assistant-operation-class-boundary.md": "884aca09774e773e68154c923fec8078610b2cf8e97f581fedc36e10451ccec3", - "src/Aevatar.AI.Abstractions/ai_messages.proto": "2aed90b870f4fd02e6cad50ddfc9eacef0b851581f5a111b150158d8c3518c5d", + "src/Aevatar.AI.Abstractions/ai_messages.proto": "363bb1bd8ee01c8c1917ad78653d878bcf14dc29c1e236f06de86f2740dc729e", "src/Aevatar.AI.ToolProviders.NyxId/NyxIdApiAccessContracts.cs": "a2e526a0a227f868304f122e9a65796164089fa69b0c27926f4c27c78b3ab0de", "src/Aevatar.AI.ToolProviders.NyxId/NyxIdAssistantToolSource.cs": "1b033df9cb55c741e9b52054cbd4a91067f03c8c3797bd076a7e3d6133eb0fcb", "src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestKeyCreateTool.cs": "2c4f2cda99154f2e667c6cfd291497e697ef11df17f081f96ec70070a8af8b8c", diff --git a/src/Aevatar.AI.Abstractions/ai_messages.proto b/src/Aevatar.AI.Abstractions/ai_messages.proto index 6bf2f4121..15e37aa0a 100644 --- a/src/Aevatar.AI.Abstractions/ai_messages.proto +++ b/src/Aevatar.AI.Abstractions/ai_messages.proto @@ -1135,6 +1135,18 @@ message ConversationContextAttachmentSet { repeated ConversationContextAttachment attachments = 1; } +enum ConversationContextAttachmentAdmissionFailureReason { + CONVERSATION_CONTEXT_ATTACHMENT_ADMISSION_FAILURE_REASON_UNSPECIFIED = 0; + CONVERSATION_CONTEXT_ATTACHMENT_ADMISSION_FAILURE_REASON_NOT_FOUND = 1; + CONVERSATION_CONTEXT_ATTACHMENT_ADMISSION_FAILURE_REASON_ACCESS_DENIED = 2; + CONVERSATION_CONTEXT_ATTACHMENT_ADMISSION_FAILURE_REASON_UNSUPPORTED_KIND = 3; + CONVERSATION_CONTEXT_ATTACHMENT_ADMISSION_FAILURE_REASON_OVER_LIMIT = 4; + CONVERSATION_CONTEXT_ATTACHMENT_ADMISSION_FAILURE_REASON_PINNED_REVISION_UNAVAILABLE = 5; + CONVERSATION_CONTEXT_ATTACHMENT_ADMISSION_FAILURE_REASON_INVALID_REQUEST = 6; + CONVERSATION_CONTEXT_ATTACHMENT_ADMISSION_FAILURE_REASON_INACTIVE = 7; + CONVERSATION_CONTEXT_ATTACHMENT_ADMISSION_FAILURE_REASON_READ_MODEL_UNAVAILABLE = 8; +} + enum ConversationContextAttachmentUnavailableReason { CONVERSATION_CONTEXT_ATTACHMENT_UNAVAILABLE_REASON_UNSPECIFIED = 0; CONVERSATION_CONTEXT_ATTACHMENT_UNAVAILABLE_REASON_NOT_FOUND = 1; diff --git a/src/Aevatar.Studio.Application.Abstractions/Studio/Abstractions/IChatHistoryQueryPort.cs b/src/Aevatar.Studio.Application.Abstractions/Studio/Abstractions/IChatHistoryQueryPort.cs index 0c23f81bd..22d8b3d34 100644 --- a/src/Aevatar.Studio.Application.Abstractions/Studio/Abstractions/IChatHistoryQueryPort.cs +++ b/src/Aevatar.Studio.Application.Abstractions/Studio/Abstractions/IChatHistoryQueryPort.cs @@ -174,7 +174,8 @@ public sealed record ConversationMeta( string? AttentionKind = null, DateTimeOffset? AttentionSince = null, string? ActiveStepSummary = null, - long StateVersion = 0); + long StateVersion = 0, + IReadOnlyList? ContextAttachments = null); public sealed record StoredChatMessage( string Id, diff --git a/src/Aevatar.Studio.Application.Abstractions/Studio/Abstractions/INyxIdChatConversationStateQueryPort.cs b/src/Aevatar.Studio.Application.Abstractions/Studio/Abstractions/INyxIdChatConversationStateQueryPort.cs index bfe98f214..e1d6a37f4 100644 --- a/src/Aevatar.Studio.Application.Abstractions/Studio/Abstractions/INyxIdChatConversationStateQueryPort.cs +++ b/src/Aevatar.Studio.Application.Abstractions/Studio/Abstractions/INyxIdChatConversationStateQueryPort.cs @@ -27,7 +27,13 @@ public sealed record NyxIdChatConversationAttentionSummary( string AttentionKind, DateTimeOffset? AttentionSince, string? ActiveStepSummary, - long StateVersion); + long StateVersion, + IReadOnlyList? ContextAttachments = null); + +public sealed record NyxIdChatConversationContextAttachmentSnapshot( + string ArtifactId, + string RevisionMode, + string PinnedRevisionId); public sealed record NyxIdChatConversationStateQuery( string ScopeId, @@ -122,7 +128,8 @@ public sealed record NyxIdChatConversationStateSnapshot( IReadOnlyList? RecentActions = null, NyxIdChatStepControlResultSnapshot? LatestStepControlResult = null, IReadOnlyList? RecentStepControlResults = null, - NyxIdChatCanaryEffectFaultSnapshot? CanaryEffectFault = null); + NyxIdChatCanaryEffectFaultSnapshot? CanaryEffectFault = null, + IReadOnlyList? ContextAttachments = null); public sealed record NyxIdChatCanaryEffectFaultSnapshot( string ArmId, diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ProjectionNyxIdChatConversationStateQueryPort.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ProjectionNyxIdChatConversationStateQueryPort.cs index 1ced9859e..4388e3e4c 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ProjectionNyxIdChatConversationStateQueryPort.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ProjectionNyxIdChatConversationStateQueryPort.cs @@ -156,7 +156,8 @@ public async Task ToStepControlResult(result)!).ToArray(), - ToCanaryEffectFault(document.CanaryEffectFault)); + ToCanaryEffectFault(document.CanaryEffectFault), + ToContextAttachments(document.ContextAttachments)); + + private static IReadOnlyList ToContextAttachments( + IEnumerable attachments) => + attachments.Select(static attachment => + new NyxIdChatConversationContextAttachmentSnapshot( + attachment.ArtifactId, + attachment.RevisionMode, + attachment.PinnedRevisionId)) + .ToArray(); private static NyxIdChatCanaryEffectFaultSnapshot? ToCanaryEffectFault( NyxIdChatConversationCanaryEffectFaultDocument? fault) => diff --git a/src/Aevatar.Studio.Projection/Projectors/NyxIdChatConversationCurrentStateProjector.cs b/src/Aevatar.Studio.Projection/Projectors/NyxIdChatConversationCurrentStateProjector.cs index f2f3782a6..adc339990 100644 --- a/src/Aevatar.Studio.Projection/Projectors/NyxIdChatConversationCurrentStateProjector.cs +++ b/src/Aevatar.Studio.Projection/Projectors/NyxIdChatConversationCurrentStateProjector.cs @@ -95,6 +95,21 @@ public async ValueTask ProjectAsync( document.RecentActions.AddRange(state.RecentActions.Select(ToAction)); document.RecentStepControlResults.AddRange( state.RecentStepControlResults.Select(result => ToStepControlResult(result)!)); + if (state.ContextAttachments is not null) + { + document.ContextAttachments.AddRange(state.ContextAttachments.Attachments.Select(static attachment => + new NyxIdChatConversationContextAttachmentDocument + { + ArtifactId = attachment.ArtifactId, + RevisionMode = attachment.RevisionMode switch + { + ConversationContextAttachmentRevisionMode.FollowCurrent => "follow_current", + ConversationContextAttachmentRevisionMode.PinnedRevision => "pinned_revision", + _ => "unspecified", + }, + PinnedRevisionId = attachment.PinnedRevisionId, + })); + } var result = await _writeDispatcher.UpsertAsync(document, ct).ConfigureAwait(false); if (result.IsRejected) diff --git a/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto b/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto index afed1b2ac..d772c045c 100644 --- a/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto +++ b/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto @@ -875,6 +875,12 @@ message NyxIdChatConversationServiceAccessReviewDocument { string resource_uri = 3; } +message NyxIdChatConversationContextAttachmentDocument { + string artifact_id = 1; + string revision_mode = 2; + string pinned_revision_id = 3; +} + message NyxIdChatConversationCurrentStateDocument { string id = 1; string actor_id = 2; @@ -907,6 +913,7 @@ message NyxIdChatConversationCurrentStateDocument { NyxIdChatConversationCanaryEffectFaultDocument canary_effect_fault = 42; bool deleted = 43; google.protobuf.Timestamp deleted_at = 44; + repeated NyxIdChatConversationContextAttachmentDocument context_attachments = 45; } message NyxIdChatConversationCanaryEffectFaultDocument { diff --git a/test/Aevatar.AI.Tests/NyxIdChatAdmissionErrorTests.cs b/test/Aevatar.AI.Tests/NyxIdChatAdmissionErrorTests.cs index 0a6fa6dd6..66dc3f7f5 100644 --- a/test/Aevatar.AI.Tests/NyxIdChatAdmissionErrorTests.cs +++ b/test/Aevatar.AI.Tests/NyxIdChatAdmissionErrorTests.cs @@ -38,6 +38,43 @@ await InvokeTaskAsync( body.Should().NotContain("PROJECTION_UNAVAILABLE"); } + [Theory] + [InlineData(NyxIdChatStartError.AttachmentNotFound, "not_found")] + [InlineData(NyxIdChatStartError.AttachmentAccessDenied, "access_denied")] + [InlineData(NyxIdChatStartError.AttachmentUnsupportedKind, "unsupported_kind")] + [InlineData(NyxIdChatStartError.AttachmentOverLimit, "over_limit")] + [InlineData(NyxIdChatStartError.AttachmentPinnedRevisionUnavailable, "pinned_revision_unavailable")] + [InlineData(NyxIdChatStartError.AttachmentInvalidRequest, "invalid_request")] + [InlineData(NyxIdChatStartError.AttachmentInactive, "inactive")] + [InlineData(NyxIdChatStartError.AttachmentReadModelUnavailable, "read_model_unavailable")] + public async Task HandleStreamMessageAsync_ShouldExposeTypedAttachmentAdmissionReason( + NyxIdChatStartError startError, + string expectedReason) + { + var context = CreateAuthorizedStreamContext(); + var interactionService = new StubNyxIdChatInteractionService + { + Failure = startError, + }; + + await InvokeTaskAsync( + "HandleStreamMessageAsync", + context, + "scope-a", + "actor-1", + new NyxIdChatEndpoints.NyxIdChatStreamRequest("hello", Type: "text"), + new StubGAgentActorStore(), + interactionService, + NullLoggerFactory.Instance, + CancellationToken.None); + + context.Response.Body.Position = 0; + var body = await new StreamReader(context.Response.Body).ReadToEndAsync(); + body.Should().Contain("ATTACHMENT_ADMISSION_DENIED") + .And.Contain($"\"reason\":\"{expectedReason}\"") + .And.NotContain("ADMISSION_UNAVAILABLE"); + } + [Theory] [InlineData(NyxIdChatLifecycleCommandStartError.AdmissionUnavailable)] [InlineData(NyxIdChatLifecycleCommandStartError.RouteRejected)] @@ -65,6 +102,38 @@ public async Task ChatCommandTargetResolver_ShouldMapCreateAdmissionFailuresTrut result.Error.Should().Be(NyxIdChatStartError.AdmissionUnavailable); } + [Theory] + [InlineData(NyxIdChatLifecycleCommandStartError.AttachmentNotFound, NyxIdChatStartError.AttachmentNotFound)] + [InlineData(NyxIdChatLifecycleCommandStartError.AttachmentAccessDenied, NyxIdChatStartError.AttachmentAccessDenied)] + [InlineData(NyxIdChatLifecycleCommandStartError.AttachmentUnsupportedKind, NyxIdChatStartError.AttachmentUnsupportedKind)] + [InlineData(NyxIdChatLifecycleCommandStartError.AttachmentOverLimit, NyxIdChatStartError.AttachmentOverLimit)] + [InlineData(NyxIdChatLifecycleCommandStartError.AttachmentPinnedRevisionUnavailable, NyxIdChatStartError.AttachmentPinnedRevisionUnavailable)] + [InlineData(NyxIdChatLifecycleCommandStartError.AttachmentInvalidRequest, NyxIdChatStartError.AttachmentInvalidRequest)] + [InlineData(NyxIdChatLifecycleCommandStartError.AttachmentInactive, NyxIdChatStartError.AttachmentInactive)] + [InlineData(NyxIdChatLifecycleCommandStartError.AttachmentReadModelUnavailable, NyxIdChatStartError.AttachmentReadModelUnavailable)] + public async Task ChatCommandTargetResolver_ShouldPreserveAttachmentAdmissionReason( + NyxIdChatLifecycleCommandStartError createError, + NyxIdChatStartError expectedError) + { + var resolver = new NyxIdChatCommandTargetResolver( + new StubActorRuntime(), + new StubNyxIdChatSessionProjectionPort(), + () => new FailingConversationCreateTargetResolver(createError)); + + var result = await resolver.ResolveAsync(new NyxIdChatCommand( + "actor-1", + "scope-a", + "hello", + "turn-1", + "access-token", + null, + null, + CreateIfMissing: true)); + + result.Succeeded.Should().BeFalse(); + result.Error.Should().Be(expectedError); + } + private sealed class FailingConversationCreateTargetResolver( NyxIdChatLifecycleCommandStartError error) : ICommandTargetResolver< diff --git a/test/Aevatar.AI.Tests/NyxIdChatGAgentTests.cs b/test/Aevatar.AI.Tests/NyxIdChatGAgentTests.cs index 393ff26b9..d260ea372 100644 --- a/test/Aevatar.AI.Tests/NyxIdChatGAgentTests.cs +++ b/test/Aevatar.AI.Tests/NyxIdChatGAgentTests.cs @@ -285,13 +285,17 @@ public async Task CreateTargetResolver_ShouldFailClosedWhenProfileResolutionIsUn // Create admission rejection cases were not proven to stop actor creation. // The matrix now asserts the typed error and an empty runtime create ledger. [Theory] - [InlineData("missing")] - [InlineData("unauthorized")] - [InlineData("other-content")] - [InlineData("over-limit")] - [InlineData("pinned-unavailable")] + [InlineData("missing", NyxIdChatLifecycleCommandStartError.AttachmentNotFound)] + [InlineData("unauthorized", NyxIdChatLifecycleCommandStartError.AttachmentAccessDenied)] + [InlineData("other-content", NyxIdChatLifecycleCommandStartError.AttachmentUnsupportedKind)] + [InlineData("over-limit", NyxIdChatLifecycleCommandStartError.AttachmentOverLimit)] + [InlineData("pinned-unavailable", NyxIdChatLifecycleCommandStartError.AttachmentPinnedRevisionUnavailable)] + [InlineData("invalid", NyxIdChatLifecycleCommandStartError.AttachmentInvalidRequest)] + [InlineData("inactive", NyxIdChatLifecycleCommandStartError.AttachmentInactive)] + [InlineData("read-model-unavailable", NyxIdChatLifecycleCommandStartError.AttachmentReadModelUnavailable)] public async Task CreateTargetResolver_ShouldRejectUnavailableContextAttachmentsBeforeCreatingActor( - string failure) + string failure, + NyxIdChatLifecycleCommandStartError expectedError) { var runtime = new RecordingActorRuntime(); var artifact = BuildAdmissionArtifact(); @@ -339,6 +343,21 @@ artifact.Revisions[0] with ConversationContextAttachmentRevisionMode.PinnedRevision, "rev-1")); break; + case "invalid": + command.ContextAttachments = BuildContextAttachmentSet( + BuildContextAttachment( + "artifact-a", + ConversationContextAttachmentRevisionMode.FollowCurrent), + BuildContextAttachment( + "artifact-a", + ConversationContextAttachmentRevisionMode.FollowCurrent)); + break; + case "inactive": + query.Artifact = artifact with + { + LifecycleStatus = ContentArtifactLifecycleStatusNames.Tombstoned, + }; + break; } var resolver = new NyxIdChatConversationCreateCommandTargetResolver( @@ -346,12 +365,12 @@ artifact.Revisions[0] with StaticChatRoutePolicyQueryPort.ForSnapshot(null), NewChatRouteResolver(), new DisabledNyxIdChatAgentProfileResolver(), - query); + failure == "read-model-unavailable" ? null : query); var result = await resolver.ResolveAsync(command); result.Succeeded.Should().BeFalse(); - result.Error.Should().Be(NyxIdChatLifecycleCommandStartError.AdmissionUnavailable); + result.Error.Should().Be(expectedError); runtime.CreateCalls.Should().BeEmpty(); } diff --git a/test/Aevatar.AI.Tests/NyxIdChatPublicEndpointsTests.cs b/test/Aevatar.AI.Tests/NyxIdChatPublicEndpointsTests.cs index e5fd76daa..646c34dde 100644 --- a/test/Aevatar.AI.Tests/NyxIdChatPublicEndpointsTests.cs +++ b/test/Aevatar.AI.Tests/NyxIdChatPublicEndpointsTests.cs @@ -655,7 +655,17 @@ public async Task ListConversations_ShouldFilterNyxIdHistoryAndRejectAmbiguousSc "input", DateTimeOffset.Parse("2026-08-01T12:00:00Z"), "Choose a deployment region.", - 23), + 23, + [ + new NyxIdChatConversationContextAttachmentSnapshot( + "artifact-follow", + "follow_current", + string.Empty), + new NyxIdChatConversationContextAttachmentSnapshot( + "artifact-pinned", + "pinned_revision", + "revision-7"), + ]), }, }; var context = CreateContext("scope-alpha", services => services @@ -678,6 +688,12 @@ public async Task ListConversations_ShouldFilterNyxIdHistoryAndRejectAmbiguousSc conversation.GetProperty("activeStepSummary").GetString().Should() .Be("Choose a deployment region."); conversation.GetProperty("stateVersion").GetInt64().Should().Be(23); + var contextAttachments = conversation.GetProperty("contextAttachments"); + contextAttachments.GetArrayLength().Should().Be(2); + contextAttachments[0].GetProperty("artifactId").GetString().Should().Be("artifact-follow"); + contextAttachments[1].GetProperty("artifactId").GetString().Should().Be("artifact-pinned"); + contextAttachments[1].GetProperty("pinnedRevisionId").GetString().Should().Be("revision-7"); + response.Body.Should().NotContain("inlineContent").And.NotContain("body"); state.AttentionRequests.Should().ContainSingle().Which.Should().BeEquivalentTo( ("scope-alpha", (IReadOnlyCollection)["conversation-alpha"])); diff --git a/test/Aevatar.AI.Tests/NyxIdChatStateEndpointTests.cs b/test/Aevatar.AI.Tests/NyxIdChatStateEndpointTests.cs index ab251f6f0..d1f0df872 100644 --- a/test/Aevatar.AI.Tests/NyxIdChatStateEndpointTests.cs +++ b/test/Aevatar.AI.Tests/NyxIdChatStateEndpointTests.cs @@ -452,7 +452,18 @@ public async Task GetState_ShouldReturnCurrentSnapshotFromTypedQueryPort() [], null, null, - null)), + null, + ContextAttachments: + [ + new NyxIdChatConversationContextAttachmentSnapshot( + "artifact-follow", + "follow_current", + string.Empty), + new NyxIdChatConversationContextAttachmentSnapshot( + "artifact-pinned", + "pinned_revision", + "revision-7"), + ])), }; var response = await ExecuteAsync( @@ -471,6 +482,16 @@ public async Task GetState_ShouldReturnCurrentSnapshotFromTypedQueryPort() json.RootElement.GetProperty("turnId").GetString().Should().Be("turn-alpha"); json.RootElement.GetProperty("snapshot").GetProperty("actorId").GetString() .Should().Be("conversation-alpha"); + var contextAttachments = json.RootElement.GetProperty("snapshot") + .GetProperty("contextAttachments"); + contextAttachments.GetArrayLength().Should().Be(2); + contextAttachments[0].GetProperty("artifactId").GetString().Should().Be("artifact-follow"); + contextAttachments[0].GetProperty("revisionMode").GetString().Should().Be("follow_current"); + contextAttachments[0].GetProperty("pinnedRevisionId").GetString().Should().BeEmpty(); + contextAttachments[1].GetProperty("artifactId").GetString().Should().Be("artifact-pinned"); + contextAttachments[1].GetProperty("revisionMode").GetString().Should().Be("pinned_revision"); + contextAttachments[1].GetProperty("pinnedRevisionId").GetString().Should().Be("revision-7"); + response.Body.Should().NotContain("inlineContent").And.NotContain("body"); json.RootElement .GetProperty("snapshot") .GetProperty("activeTask") diff --git a/test/Aevatar.Studio.Tests/NyxIdChatConversationCurrentStateProjectorTests.cs b/test/Aevatar.Studio.Tests/NyxIdChatConversationCurrentStateProjectorTests.cs index f13de1f3f..bdac3cad3 100644 --- a/test/Aevatar.Studio.Tests/NyxIdChatConversationCurrentStateProjectorTests.cs +++ b/test/Aevatar.Studio.Tests/NyxIdChatConversationCurrentStateProjectorTests.cs @@ -1,4 +1,5 @@ using System.Text; +using Aevatar.AI.Abstractions; using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.CQRS.Projection.Providers.InMemory.Stores; using Aevatar.CQRS.Projection.Runtime.Abstractions; @@ -18,6 +19,50 @@ public sealed class NyxIdChatConversationCurrentStateProjectorTests { private const string ActorId = "conversation-alpha"; + [Fact] + public async Task ProjectAsync_ShouldCopySealedContextAttachmentReferencesWithoutBodies() + { + var dispatcher = new RecordingWriteDispatcher(); + var projector = new NyxIdChatConversationCurrentStateProjector( + dispatcher, + new FixedProjectionClock(DateTimeOffset.Parse("2026-08-27T04:00:00Z"))); + var state = BuildState(); + state.ContextAttachments = new ConversationContextAttachmentSet + { + Attachments = + { + new ConversationContextAttachment + { + ArtifactId = "artifact-follow", + RevisionMode = ConversationContextAttachmentRevisionMode.FollowCurrent, + }, + new ConversationContextAttachment + { + ArtifactId = "artifact-pinned", + RevisionMode = ConversationContextAttachmentRevisionMode.PinnedRevision, + PinnedRevisionId = "revision-7", + }, + }, + }; + + await projector.ProjectAsync( + NewContext(), + WrapCommitted( + new ConversationContextAttachmentsBoundEvent(), + state, + version: 32, + eventId: "event-alpha-32", + stateEventTimestamp: DateTimeOffset.Parse("2026-08-27T03:59:00Z"))); + + var attachments = dispatcher.Upserts.Should().ContainSingle().Which + .ContextAttachments; + attachments.Select(static attachment => + (attachment.ArtifactId, attachment.RevisionMode, attachment.PinnedRevisionId)) + .Should().Equal( + ("artifact-follow", "follow_current", string.Empty), + ("artifact-pinned", "pinned_revision", "revision-7")); + } + [Fact] public async Task ProjectAsync_ShouldExposeReloadablePendingAndRecentActionRequests() { diff --git a/test/Aevatar.Studio.Tests/ProjectionNyxIdChatConversationStateQueryPortTests.cs b/test/Aevatar.Studio.Tests/ProjectionNyxIdChatConversationStateQueryPortTests.cs index d1b9f65f3..0f81e5f0a 100644 --- a/test/Aevatar.Studio.Tests/ProjectionNyxIdChatConversationStateQueryPortTests.cs +++ b/test/Aevatar.Studio.Tests/ProjectionNyxIdChatConversationStateQueryPortTests.cs @@ -95,6 +95,15 @@ public async Task GetAsync_ShouldReturnCurrentSnapshotWhenServerIsNewer() result.Snapshot.TaskStatus.Should().Be("active"); result.Snapshot.AttentionKind.Should().Be("input"); result.Snapshot.ActiveStepSummary.Should().Be("Choose a deployment region."); + result.Snapshot.ContextAttachments.Should().Equal( + new NyxIdChatConversationContextAttachmentSnapshot( + "artifact-follow", + "follow_current", + string.Empty), + new NyxIdChatConversationContextAttachmentSnapshot( + "artifact-pinned", + "pinned_revision", + "revision-7")); result.Snapshot.LatestStepControlResult.Should().NotBeNull(); result.Snapshot.LatestStepControlResult!.RequestId.Should().Be("retry-alpha"); result.Snapshot.LatestStepControlResult.Kind.Should().Be("retry"); @@ -287,6 +296,15 @@ public async Task GetAttentionSummariesAsync_ShouldBatchReadActorScopedProjectio DateTimeOffset.Parse("2026-08-01T12:00:00Z")); summary.Value.ActiveStepSummary.Should().Be("Choose a deployment region."); summary.Value.StateVersion.Should().Be(23); + summary.Value.ContextAttachments.Should().Equal( + new NyxIdChatConversationContextAttachmentSnapshot( + "artifact-follow", + "follow_current", + string.Empty), + new NyxIdChatConversationContextAttachmentSnapshot( + "artifact-pinned", + "pinned_revision", + "revision-7")); reader.Queries.Should().ContainSingle().Which.Should().Match(query => query.Take == 3 && query.Filters.Any(filter => @@ -403,6 +421,20 @@ public async Task GetAsync_ShouldReturnNotFoundWhenDocumentIsDeleted() LastEventId = $"event-alpha-{stateVersion}", UpdatedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.Parse("2026-07-25T06:20:00Z")), ProgressSequence = 34, + ContextAttachments = + { + new NyxIdChatConversationContextAttachmentDocument + { + ArtifactId = "artifact-follow", + RevisionMode = "follow_current", + }, + new NyxIdChatConversationContextAttachmentDocument + { + ArtifactId = "artifact-pinned", + RevisionMode = "pinned_revision", + PinnedRevisionId = "revision-7", + }, + }, PendingInput = new NyxIdChatConversationPendingInputDocument { RequestId = "input-alpha",