diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs
index 968df7f6..a5903127 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs
@@ -40,6 +40,7 @@ public sealed class BrowseService : IBrowseService
string siteId,
string connectionName,
string? parentNodeId,
+ string? continuationToken = null,
CancellationToken cancellationToken = default)
{
// CentralUI-side role guard — sites don't enforce envelope-level roles,
@@ -57,7 +58,7 @@ public sealed class BrowseService : IBrowseService
{
return await _communication.BrowseNodeAsync(
siteId,
- new BrowseNodeCommand(connectionName, parentNodeId),
+ new BrowseNodeCommand(connectionName, parentNodeId, continuationToken),
cancellationToken);
}
catch (TimeoutException ex)
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBrowseService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBrowseService.cs
index 8e45feba..8597fc49 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBrowseService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBrowseService.cs
@@ -28,11 +28,13 @@ public interface IBrowseService
/// The target site identifier.
/// Name of the site-local data connection to browse against — the site's DataConnectionManagerActor indexes its children by name.
/// Node to browse, or null to browse from the server root.
+ /// Opaque cursor from a prior to fetch the NEXT page under ; null for the first page.
/// Cancellation token.
/// A task that resolves to a containing child nodes or a on error.
Task BrowseChildrenAsync(
string siteId,
string connectionName,
string? parentNodeId,
+ string? continuationToken = null,
CancellationToken cancellationToken = default);
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs
index c72c404f..999585fc 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs
@@ -15,14 +15,30 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
///
/// Name of the site-local data connection to browse against.
/// Node to browse, or null to browse from the server root (ObjectsFolder).
+///
+/// Opaque adapter cursor for fetching the NEXT page of children under
+/// . Null on the first request; on a subsequent
+/// request carry back the token from the prior .
+/// Additive (appended last) so positional construction stays source-compatible.
+///
public record BrowseNodeCommand(
string ConnectionName,
- string? ParentNodeId);
+ string? ParentNodeId,
+ string? ContinuationToken = null);
+/// Immediate children resolved for the browsed node (this page only).
+/// True when the result was clipped (frame-budget cap or adapter-reported truncation).
+/// Structured failure, or null on success.
+///
+/// Opaque adapter cursor for the NEXT page when more children remain, or null
+/// when this is the final page. Surface it back in a follow-up
+/// . Additive (appended last).
+///
public record BrowseNodeResult(
IReadOnlyList Children,
bool Truncated,
- BrowseFailure? Failure);
+ BrowseFailure? Failure,
+ string? ContinuationToken = null);
public record BrowseFailure(
BrowseFailureKind Kind,
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs
index 60ad161a..4b706960 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs
@@ -1163,14 +1163,16 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
_log.Debug("[{0}] Browsing children of {1}", _connectionName, command.ParentNodeId ?? "(root)");
- browsable.BrowseChildrenAsync(command.ParentNodeId).ContinueWith(t =>
+ browsable.BrowseChildrenAsync(command.ParentNodeId, command.ContinuationToken).ContinueWith(t =>
{
if (t.IsCompletedSuccessfully)
{
// Bound the reply to stay under Akka's remote frame size before it
// crosses the site→central boundary (see CapBrowseChildren).
var (children, truncated) = CapBrowseChildren(t.Result.Children, t.Result.Truncated);
- return new BrowseNodeResult(children, truncated, Failure: null);
+ // Carry the adapter's continuation cursor through so the UI can ask
+ // for the next page (BrowseNext). Null when this is the final page.
+ return new BrowseNodeResult(children, truncated, Failure: null, t.Result.ContinuationToken);
}
var baseEx = t.Exception?.GetBaseException();
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs
index 34526b2d..af885f89 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs
@@ -253,4 +253,57 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
var keptBytes = reply.Children.Sum(n => 64 + n.NodeId.Length + n.DisplayName.Length);
Assert.True(keptBytes <= byteBudget, $"kept estimate {keptBytes} exceeds budget {byteBudget}");
}
+
+ [Fact]
+ public void ContinuationToken_round_trips_through_the_browse_handler()
+ {
+ // Task B3 (T15): the BrowseNext continuation token must thread through the
+ // actor plumbing in both directions — the inbound command's token must reach
+ // the adapter, and the adapter's returned token must be surfaced on the reply
+ // so the UI can request the next page. We capture the token the adapter is
+ // called with and return a fresh one, then assert both halves.
+ const string inboundToken = "page-1-cursor";
+ const string outboundToken = "page-2-cursor";
+ string? receivedToken = "NOT-CALLED";
+
+ var adapter = Substitute.For();
+ ((IDataConnection)adapter).ConnectAsync(Arg.Any>(), Arg.Any())
+ .Returns(Task.CompletedTask);
+ ((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);
+
+ var children = new[]
+ {
+ new BrowseNode("ns=2;s=Next", "Next", BrowseNodeClass.Variable, HasChildren: false),
+ };
+ ((IBrowsableDataConnection)adapter)
+ .BrowseChildrenAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(ci =>
+ {
+ receivedToken = ci.ArgAt(1);
+ return new BrowseChildrenResult(children, Truncated: true, ContinuationToken: outboundToken);
+ });
+
+ _factory.Create("OpcUa", Arg.Any>())
+ .Returns((IDataConnection)adapter);
+
+ var manager = Sys.ActorOf(Props.Create(() =>
+ new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));
+ manager.Tell(new CreateConnectionCommand(
+ "conn-paged", "OpcUa", new Dictionary(), null, 3));
+
+ AwaitCondition(
+ () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
+ TimeSpan.FromSeconds(2));
+
+ manager.Tell(new BrowseNodeCommand("conn-paged", ParentNodeId: "ns=2;s=Folder", ContinuationToken: inboundToken));
+
+ var reply = ExpectMsg(TimeSpan.FromSeconds(3));
+ Assert.Null(reply.Failure);
+ // Inbound token reached the adapter verbatim.
+ Assert.Equal(inboundToken, receivedToken);
+ // Outbound token from the adapter is surfaced on the reply for the next page.
+ Assert.Equal(outboundToken, reply.ContinuationToken);
+ Assert.True(reply.Truncated);
+ Assert.Single(reply.Children);
+ }
}