diff --git a/agents/Aevatar.GAgents.Device/DeviceCommandFacades.cs b/agents/Aevatar.GAgents.Device/DeviceCommandFacades.cs index 20ef254c8..61fca6a8c 100644 --- a/agents/Aevatar.GAgents.Device/DeviceCommandFacades.cs +++ b/agents/Aevatar.GAgents.Device/DeviceCommandFacades.cs @@ -252,7 +252,7 @@ public EventEnvelope CreateEnvelope(DeviceCallbackDispatchCommand command, Comma ArgumentNullException.ThrowIfNull(command.Inbound); ArgumentNullException.ThrowIfNull(context); - // Refactor (iter1281/cluster-001-device-inbound-typed-payload): Old pattern risk was adding a second callback envelope shape. + // Refactor (issue1485/first-slice): Old pattern: adding a second callback envelope shape for typed device callbacks. // New principle: keep the existing command facade as the only dispatch skeleton. // DeviceInbound is packed once into the single EventEnvelope payload used by the actor system. // The endpoint must have already rejected unknown adapter event_type values; this facade only dispatches typed inbound payloads. diff --git a/agents/Aevatar.GAgents.Device/DeviceEventEndpoints.cs b/agents/Aevatar.GAgents.Device/DeviceEventEndpoints.cs index a5a8ad523..15df22ae1 100644 --- a/agents/Aevatar.GAgents.Device/DeviceEventEndpoints.cs +++ b/agents/Aevatar.GAgents.Device/DeviceEventEndpoints.cs @@ -193,7 +193,7 @@ internal static DeviceInbound ParseCallbackPayload(byte[] bodyBytes) : string.Empty; } - // Refactor (iter1281/cluster-001-device-inbound-typed-payload): Old pattern: pass content.text through as DeviceInbound.PayloadJson. + // Refactor (issue1485/first-slice): Old pattern: pass content.text through as DeviceInbound.PayloadJson. // New principle: terminate NyxID callback JSON at the Host/Adapter boundary. // Known device events are allowlisted and mapped to typed Protobuf payload cases. // Unknown or malformed content is rejected before any EventEnvelope dispatch can happen. diff --git a/agents/Aevatar.GAgents.Household/HouseholdEntity.cs b/agents/Aevatar.GAgents.Household/HouseholdEntity.cs index cddb7767b..73d5aac28 100644 --- a/agents/Aevatar.GAgents.Household/HouseholdEntity.cs +++ b/agents/Aevatar.GAgents.Household/HouseholdEntity.cs @@ -192,7 +192,7 @@ public async Task HandleDeviceInbound(DeviceInbound evt) try { - // Refactor (iter1281/cluster-001-device-inbound-typed-payload): Old pattern: actor parsed DeviceInbound.PayloadJson by event_type. + // Refactor (issue1485/first-slice): Old pattern: actor parsed DeviceInbound.PayloadJson by event_type. // New principle: Host/Adapter owns JSON parsing and allowlist admission. // HouseholdEntity consumes only typed Protobuf payload cases and ignores missing payloads. // This keeps actor control flow independent of external callback JSON shape. diff --git a/agents/Aevatar.GAgents.Household/household_messages.proto b/agents/Aevatar.GAgents.Household/household_messages.proto index f5e2825ff..40abf8a51 100644 --- a/agents/Aevatar.GAgents.Household/household_messages.proto +++ b/agents/Aevatar.GAgents.Household/household_messages.proto @@ -153,7 +153,7 @@ message SpeechDeviceInboundPayload { // Defined in Household domain because it's the inbound contract for // HouseholdEntity; DeviceEventEndpoints (boundary layer) references // this project, keeping dependency direction correct (Host → Domain). -// Refactor (iter1281/cluster-001-device-inbound-typed-payload): Old pattern: DeviceInbound carried payload_json across the actor boundary. +// Refactor (issue1485/first-slice): Old pattern: DeviceInbound carried payload_json across the actor boundary. // New principle: the Host/Adapter parses NyxID callback JSON and admits only known typed device event payloads. // The actor receives Protobuf fields, while the old payload_json tag and name stay reserved. // Unknown adapter event_type values are rejected before dispatch instead of becoming an untyped raw payload bag. diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/DeviceEventEndpointsTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/DeviceEventEndpointsTests.cs index 36e996361..42159125a 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/DeviceEventEndpointsTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/DeviceEventEndpointsTests.cs @@ -1,6 +1,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; +using Aevatar.CQRS.Core.Abstractions.Commands; using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Builder; @@ -240,6 +241,55 @@ public void DeviceInbound_contract_should_not_expose_raw_payload_bag_fields() .Should().ContainSingle(oneof => oneof.Name == "payload"); } + [Fact] + public async Task HandleDeviceCallbackAsync_known_event_type_dispatches_typed_inbound_without_raw_payload() + { + DeviceCallbackDispatchCommand? capturedCommand = null; + var queryPort = Substitute.For(); + var callbackService = Substitute.For(); + queryPort.GetAsync("reg-known", Arg.Any()) + .Returns(Task.FromResult(MakeRegistration())); + callbackService.DispatchCallbackAsync( + Arg.Do(command => capturedCommand = command), + Arg.Any()) + .Returns(Task.FromResult( + CommandDispatchResult.Success( + new DeviceCommandAcceptedReceipt( + "household-reg-known", + "cmd-known", + "corr-known", + "reg-known")))); + + var innerEvent = JsonSerializer.Serialize(new + { + event_id = "evt-known", + source = "temperature-sensor", + event_type = "temperature_change", + temperature = 23.5, + humidity = 44.0, + light_level = 18.0, + }); + var context = CreateJsonHttpContext(EncodeCallbackPayload(innerEvent, senderPlatformId: "sensor-1")); + + var result = await InvokeHandleDeviceCallbackAsync( + context, + "reg-known", + queryPort, + callbackService); + var (statusCode, body) = await ExecuteResultAsync(result); + + statusCode.Should().Be(StatusCodes.Status202Accepted); + body.Should().Contain("accepted"); + capturedCommand.Should().NotBeNull(); + capturedCommand!.RegistrationId.Should().Be("reg-known"); + capturedCommand.Inbound.DeviceId.Should().Be("sensor-1"); + capturedCommand.Inbound.PayloadCase.Should().Be(Aevatar.GAgents.Household.DeviceInbound.PayloadOneofCase.Sensor); + capturedCommand.Inbound.Sensor.Temperature.Should().Be(23.5); + Aevatar.GAgents.Household.DeviceInbound.Descriptor.FindFieldByName("payload_json").Should().BeNull(); + await callbackService.Received(1) + .DispatchCallbackAsync(Arg.Any(), Arg.Any()); + } + [Fact] public async Task HandleDeviceCallbackAsync_unknown_event_type_returns_reject_status_and_does_not_dispatch() { @@ -335,6 +385,27 @@ public void ParseCallbackPayload_malformed_typed_payload_throws_at_adapter_bound act.Should().Throw(); } + [Fact] + public void DeviceCallbackCommandFacade_ShouldPackDeviceInboundOnceInSingleEventEnvelopePayload() + { + var sourcePath = Path.GetFullPath(Path.Combine( + AppContext.BaseDirectory, + "../../../../../agents/Aevatar.GAgents.Device/DeviceCommandFacades.cs")); + var source = File.ReadAllText(sourcePath); + var callbackFactoryStart = source.IndexOf("internal sealed class DeviceCallbackCommandEnvelopeFactory", StringComparison.Ordinal); + callbackFactoryStart.Should().BeGreaterThanOrEqualTo(0); + var callbackReceiptFactoryStart = source.IndexOf("internal sealed class DeviceCallbackCommandReceiptFactory", callbackFactoryStart, StringComparison.Ordinal); + callbackReceiptFactoryStart.Should().BeGreaterThan(callbackFactoryStart); + var callbackFactorySource = source[callbackFactoryStart..callbackReceiptFactoryStart]; + + callbackFactorySource.Should().Contain("new EventEnvelope"); + callbackFactorySource.Should().Contain("Payload = Any.Pack(command.Inbound)"); + callbackFactorySource.Should().NotContain("Payload = Any.Pack(command)"); + callbackFactorySource.Should().NotContain("new DeviceCallbackEnvelope"); + callbackFactorySource.Should().Contain("EnvelopeRouteSemantics.CreateDirect(PublisherActorId, context.TargetId)"); + callbackFactorySource.Should().Contain("Refactor (issue1485/first-slice): Old pattern:"); + } + // ─── HMAC Verification Tests ─── private static DeviceRegistrationEntry MakeRegistration(string hmacKey = "test-secret") => new()