diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Events/EventTimelineEmitterDecisionStateEnrichmentTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Events/EventTimelineEmitterDecisionStateEnrichmentTests.cs new file mode 100644 index 00000000..29253a4b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Events/EventTimelineEmitterDecisionStateEnrichmentTests.cs @@ -0,0 +1,238 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Telemetry.Events; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json.Linq; +using SharedConstants = AutopilotMonitor.Shared.Constants; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events +{ + /// + /// Tests for the decisionState-snapshot enrichment in + /// (Plan §A — Edge-Triggered State + /// Snapshots, 2026-05-03). The enrichment runs after ResolveData so it + /// works for both event paths (parameter-rebuilt Data and typedPayload-merged + /// Data) and never clobbers existing event-specific fields. + /// + public sealed class EventTimelineEmitterDecisionStateEnrichmentTests + { + private static readonly DateTime At = new DateTime(2026, 5, 1, 13, 45, 32, DateTimeKind.Utc); + + private sealed class Rig : IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public FakeTelemetryTransport Transport { get; } = new FakeTelemetryTransport(); + public EventSequenceCounter Counter { get; } + public TelemetryEventEmitter Inner { get; } + public EventTimelineEmitter Sut { get; } + + public Rig() + { + Counter = new EventSequenceCounter(new EventSequencePersistence(Tmp.File("seq.json"))); + Inner = new TelemetryEventEmitter(Transport, Counter, "S1", "T1"); + Sut = new EventTimelineEmitter(Inner); + } + + public JObject EmittedData() => + (JObject)JObject.Parse(Transport.Enqueued[0].PayloadJson)["Data"]!; + + public void Dispose() => Tmp.Dispose(); + } + + private static DecisionState State() => DecisionState.CreateInitial("S1", "T1"); + + // ============================================================================ + // Full-coverage: every anchor event-type gets decisionState enrichment + // ============================================================================ + + public static IEnumerable AllAnchorEventTypes => new[] + { + new object[] { SharedConstants.EventTypes.AgentStarted }, + new object[] { SharedConstants.EventTypes.EspPhaseChanged }, + new object[] { SharedConstants.EventTypes.NetworkStateChange }, + new object[] { SharedConstants.EventTypes.DesktopArrived }, + new object[] { SharedConstants.EventTypes.AadPlaceholderUserDetected }, + new object[] { SharedConstants.EventTypes.AadUserJoinedLate }, + new object[] { SharedConstants.EventTypes.UserAadSignInComplete }, + new object[] { SharedConstants.EventTypes.HybridLoginPending }, + new object[] { SharedConstants.EventTypes.AgentShuttingDown }, + new object[] { SharedConstants.EventTypes.SystemRebootDetected }, + new object[] { SharedConstants.EventTypes.PerformanceCollectorStopped }, + new object[] { SharedConstants.EventTypes.AgentMetricsCollectorStopped }, + new object[] { SharedConstants.EventTypes.PriorRunDiedWithState }, + }; + + [Theory] + [MemberData(nameof(AllAnchorEventTypes))] + public void Anchor_event_gets_decisionState_added_to_Data(string anchorEventType) + { + using var r = new Rig(); + var parameters = new Dictionary { ["eventType"] = anchorEventType }; + + r.Sut.Emit(parameters, State(), At); + + var data = r.EmittedData(); + Assert.NotNull(data["decisionState"]); + // schemaVersion proves we wrote the actual builder output, not just any key + Assert.Equal( + DecisionStateSnapshotBuilder.SchemaVersion, + (string?)data["decisionState"]!["schemaVersion"]); + } + + // ============================================================================ + // Parameter-based path — existing Data fields preserved + // ============================================================================ + + [Fact] + public void Anchor_with_parameter_data_preserves_event_specific_fields() + { + using var r = new Rig(); + // esp_phase_changed historically carries reason + custom keys via parameters + // (not typedPayload). Verify those parameters survive into the wire Data + // alongside the new decisionState. + var parameters = new Dictionary + { + ["eventType"] = SharedConstants.EventTypes.EspPhaseChanged, + ["reason"] = "IME-ESP-PHASE", + ["espPhase"] = "AccountSetup", + }; + + r.Sut.Emit(parameters, State(), At); + + var data = r.EmittedData(); + // Original parameter fields stay intact. + Assert.Equal("IME-ESP-PHASE", (string?)data["reason"]); + Assert.Equal("AccountSetup", (string?)data["espPhase"]); + // Plus enrichment. + Assert.NotNull(data["decisionState"]); + } + + // ============================================================================ + // typedPayload-based path — existing Data fields preserved + // ============================================================================ + + [Fact] + public void Anchor_with_typedPayload_dict_preserves_existing_fields() + { + using var r = new Rig(); + // network_state_change is the canonical typedPayload-bearing anchor — it + // flows via InformationalEventPost.Emit(EnrollmentEvent) which routes + // EnrollmentEvent.Data through typedPayload. Verify the original payload + // survives the enrichment. + var parameters = new Dictionary + { + ["eventType"] = SharedConstants.EventTypes.NetworkStateChange, + }; + var typedPayload = new Dictionary + { + ["from"] = "WiFi 'GBM_Wireless'", + ["to"] = "WiFi 'GBM_Guests'", + ["transition"] = "wifi_change", + }; + + r.Sut.Emit(parameters, State(), At, typedPayload); + + var data = r.EmittedData(); + Assert.Equal("WiFi 'GBM_Wireless'", (string?)data["from"]); + Assert.Equal("WiFi 'GBM_Guests'", (string?)data["to"]); + Assert.Equal("wifi_change", (string?)data["transition"]); + Assert.NotNull(data["decisionState"]); + } + + // ============================================================================ + // Non-anchor — no enrichment + // ============================================================================ + + [Theory] + [InlineData("enrollment_complete")] // already carries DecisionAuditTrail + [InlineData("enrollment_failed")] // already carries DecisionAuditTrail + [InlineData("app_install_completed")] // hochfrequent, intentionally excluded + [InlineData("performance_snapshot")] // hochfrequent, intentionally excluded + [InlineData("download_progress")] // hochfrequent, intentionally excluded + public void Non_anchor_event_does_NOT_get_decisionState(string nonAnchorEventType) + { + using var r = new Rig(); + var parameters = new Dictionary + { + ["eventType"] = nonAnchorEventType, + ["reason"] = "test", + }; + + r.Sut.Emit(parameters, State(), At); + + var data = r.EmittedData(); + Assert.Null(data["decisionState"]); + } + + // ============================================================================ + // Idempotence — pre-existing decisionState in typedPayload is not clobbered + // ============================================================================ + + [Fact] + public void Anchor_with_decisionState_already_in_typedPayload_is_NOT_clobbered() + { + using var r = new Rig(); + // Defensive contract: if for some reason an upstream caller already put a + // "decisionState" key in the typedPayload, the emitter must not overwrite + // it. This shouldn't occur in production (only the emitter writes that + // key) but documents the merge invariant. + var parameters = new Dictionary + { + ["eventType"] = SharedConstants.EventTypes.AgentStarted, + }; + var prebuilt = new Dictionary + { + ["sentinel"] = "prebuilt-snapshot", + }; + var typedPayload = new Dictionary + { + ["decisionState"] = prebuilt, + ["original"] = "value", + }; + + r.Sut.Emit(parameters, State(), At, typedPayload); + + var data = r.EmittedData(); + // The prebuilt sentinel survives — emitter respected the existing key. + Assert.Equal("prebuilt-snapshot", (string?)data["decisionState"]!["sentinel"]); + // Original sibling field also survives. + Assert.Equal("value", (string?)data["original"]); + } + + // ============================================================================ + // decisionState shape sanity — reads through to the actual builder output + // ============================================================================ + + [Fact] + public void Anchor_decisionState_carries_top_level_allowlist_keys() + { + using var r = new Rig(); + var parameters = new Dictionary + { + ["eventType"] = SharedConstants.EventTypes.DesktopArrived, + }; + + r.Sut.Emit(parameters, State(), At); + + var data = r.EmittedData(); + var snap = (JObject)data["decisionState"]!; + // Spot-check the top-level allowlist contract — full coverage lives in + // DecisionStateSnapshotBuilderTests. + Assert.NotNull(snap["schemaVersion"]); + Assert.NotNull(snap["stepIndex"]); + Assert.NotNull(snap["lastAppliedSignalOrdinal"]); + Assert.NotNull(snap["stage"]); + Assert.NotNull(snap["facts"]); + Assert.NotNull(snap["scenario"]); + Assert.NotNull(snap["activeDeadlines"]); + // Outcome is null on a fresh state — must be present as JSON null, + // not missing (consumer differentiates "no outcome" from "missing field"). + Assert.True(snap.ContainsKey("outcome")); + Assert.Equal(JTokenType.Null, snap["outcome"]!.Type); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/EventTimelineEmitter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/EventTimelineEmitter.cs index 94103096..75819481 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/EventTimelineEmitter.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/EventTimelineEmitter.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.DecisionCore.Engine; using AutopilotMonitor.DecisionCore.State; using AutopilotMonitor.Shared.Models; @@ -101,6 +102,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events nameof(parameters)); } + var data = ResolveData(parameters, typedPayload); + + // Plan A — Edge-Triggered State Snapshots (2026-05-03): for events on the + // lifecycle-anchor allowlist, attach the current DecisionState snapshot under + // data["decisionState"] for post-mortem diagnosis without client-side logs. + // Mutation is safe: ResolveData always returns a fresh Dictionary instance + // (both the typedPayload-as-dict copy path and the parameter-rebuild path + // construct new dicts). The defensive ContainsKey-check prevents clobbering + // a snapshot already in the typedPayload — should never occur in practice + // but documents the merge contract. + if (LifecycleAnchorEventTypes.Contains(eventType) && !data.ContainsKey("decisionState")) + { + data["decisionState"] = DecisionStateSnapshotBuilder.Build(currentState); + } + var evt = new EnrollmentEvent { SessionId = currentState.SessionId, @@ -112,7 +128,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events Message = BuildMessage(eventType, parameters), Timestamp = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), ImmediateUpload = ParseImmediateUpload(parameters), - Data = ResolveData(parameters, typedPayload), + Data = data, // Codex follow-up #3 forward-link: every EmitEventTimelineEntry effect runs // as part of a reducer step, so currentState.StepIndex == the emitting diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index 4970e693..2d78492f 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -544 +547