Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions src/Netclaw.Actors.Tests/Sessions/CurrentTurnScopeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// -----------------------------------------------------------------------
// <copyright file="CurrentTurnScopeTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
using Netclaw.Actors.Channels;
using Netclaw.Actors.Protocol;
using Netclaw.Actors.Sessions.Handlers;
using Netclaw.Configuration;
using Xunit;

namespace Netclaw.Actors.Tests.Sessions;

/// <summary>
/// Characterization tests for the turn correlation-identity derivation that
/// <see cref="CurrentTurnScope"/> took over from the actor's former
/// <c>BindTurnTelemetry</c> overloads. The fallback chain (source turn id →
/// source message id → generated id) is the only real logic in the scope; these
/// lock it before the actor's ~40 read sites are rewired onto the container.
/// </summary>
public sealed class CurrentTurnScopeTests
{
private static MessageSource Source(string? messageId, TurnId? turnId, ChannelType channelType = ChannelType.Slack)
=> new()
{
ChannelType = channelType,
SenderId = new SenderId("U123"),
MessageId = messageId,
TurnId = turnId,
Audience = TrustAudience.Team,
Boundary = TrustBoundary.Team,
Principal = PrincipalClassification.TrustedInternal,
Provenance = new SourceProvenance(TransportAuthenticity.Verified, PayloadTaint.Community)
};

[Fact]
public void Bind_from_source_uses_the_source_turn_id_when_present()
{
var scope = new CurrentTurnScope();

scope.Bind(Source(messageId: "msg-1", turnId: new TurnId("turn-1"), channelType: ChannelType.Discord));

Assert.Equal("turn-1", scope.TurnId?.Value);
Assert.Equal("msg-1", scope.MessageId);
Assert.Equal(ChannelType.Discord, scope.ChannelType);
}

[Fact]
public void Bind_from_source_falls_back_to_message_id_when_no_turn_id()
{
var scope = new CurrentTurnScope();

scope.Bind(Source(messageId: "msg-2", turnId: null));

Assert.Equal("msg-2", scope.TurnId?.Value);
Assert.Equal("msg-2", scope.MessageId);
}

[Fact]
public void Bind_from_source_generates_a_turn_id_when_neither_is_present()
{
var scope = new CurrentTurnScope();

scope.Bind(Source(messageId: null, turnId: null));

Assert.False(string.IsNullOrWhiteSpace(scope.TurnId?.Value));
Assert.Null(scope.MessageId);
}

[Fact]
public void Bind_from_null_source_still_yields_a_generated_turn_id()
{
var scope = new CurrentTurnScope();

scope.Bind((MessageSource?)null);

Assert.False(string.IsNullOrWhiteSpace(scope.TurnId?.Value));
Assert.Null(scope.MessageId);
Assert.Null(scope.ChannelType);
}

[Fact]
public void Bind_from_turn_context_takes_id_and_channel_and_clears_message_id()
{
var scope = new CurrentTurnScope();
// A prior source bind leaves a message id behind; the context re-bind must clear it.
scope.Bind(Source(messageId: "stale", turnId: new TurnId("old")));

var context = TurnContext.FromMessageSource(
new SessionId("C1/1"),
new TurnId("turn-ctx"),
Source(messageId: "ignored", turnId: new TurnId("ignored"), channelType: ChannelType.Mattermost));

scope.Bind(context);

Assert.Equal("turn-ctx", scope.TurnId?.Value);
Assert.Equal(ChannelType.Mattermost, scope.ChannelType);
Assert.Null(scope.MessageId);
}
}
69 changes: 69 additions & 0 deletions src/Netclaw.Actors/Sessions/Handlers/CurrentTurnScope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// -----------------------------------------------------------------------
// <copyright file="CurrentTurnScope.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
using Netclaw.Actors.Channels;
using Netclaw.Configuration;

namespace Netclaw.Actors.Sessions.Handlers;

/// <summary>
/// Owns the transient "what turn is active and where did it come from" state:
/// the inbound source, the derived turn/trust context, the turn's recalled
/// memories, and the diagnostic correlation identity (turn/message/channel).
/// Populated at turn start and re-bound on approval re-drive; the actor reads
/// these to build persisted event records, authorize tool exposure, and enrich
/// turn-scoped logs.
///
/// The correlation identity (<see cref="TurnId"/>/<see cref="MessageId"/>/
/// <see cref="ChannelType"/>) is mutated only through <see cref="Bind(MessageSource?)"/>
/// and <see cref="Bind(TurnContext)"/> so the three stay in lockstep. The
/// remaining fields are overwritten each turn; only <see cref="TurnContext"/>
/// is explicitly cleared at a turn boundary (the actor nulls it directly).
/// </summary>
internal sealed class CurrentTurnScope
{
/// <summary>Provenance of the active turn (channel, sender, reminder/job id).</summary>
public MessageSource? Source { get; set; }

/// <summary>Trust/boundary/audience context derived from <see cref="Source"/>.</summary>
public TurnContext? TurnContext { get; set; }

/// <summary>Effective trust context used to authorize approvals and tool exposure.</summary>
public EffectiveTrustContext? TrustContext { get; set; }

/// <summary>Memories recalled for this turn, reused across the tool loop.</summary>
public AutomaticRecallResult? Recall { get; set; }

/// <summary>Correlation turn id for telemetry/logging (ephemeral).</summary>
public Protocol.TurnId? TurnId { get; private set; }

/// <summary>Inbound message id for crash-context breadcrumbs (ephemeral).</summary>
public string? MessageId { get; private set; }

/// <summary>Channel type of the active turn (ephemeral).</summary>
public Channels.ChannelType? ChannelType { get; private set; }

/// <summary>
/// Establishes the diagnostic correlation identity for a turn from its
/// inbound source, generating a turn id when the source carries none.
/// </summary>
public void Bind(MessageSource? source)
{
MessageId = source?.MessageId;
TurnId = source?.TurnId ?? new Protocol.TurnId(MessageId ?? IdGen.ShortId());
ChannelType = source?.ChannelType;
}

/// <summary>
/// Re-binds correlation identity from a recovered or parked turn context.
/// No inbound message id is available on this path.
/// </summary>
public void Bind(TurnContext context)
{
MessageId = null;
TurnId = context.TurnId;
ChannelType = context.ChannelType;
}
}
Loading
Loading