diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs index 26405c00..83b200e9 100644 --- a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs @@ -36,7 +36,8 @@ public record ChatResponse( IReadOnlyList? Operations = null, IReadOnlyList? PendingOperations = null, IReadOnlyList? PolicyDenials = null, - string? CorrelationId = null); + string? CorrelationId = null, + IReadOnlyList? RelatedSurfaces = null); public record ActionResult( string Type, @@ -252,7 +253,8 @@ public async Task> Handle( executionResults.OperationResults, executionResults.PendingOperations, executionResults.PolicyDenials, - request.CorrelationId)); + request.CorrelationId, + executionResults.RelatedSurfaces.Count > 0 ? executionResults.RelatedSurfaces : null)); } /// @@ -644,11 +646,20 @@ private static bool RequiresStreakRecalculation(IEnumerable action private sealed class ToolExecutionAccumulator { + private readonly List _relatedSurfaces = []; + private readonly HashSet _seenRelatedSurfaces = new(StringComparer.Ordinal); + public List ActionResults { get; } = []; public List OperationResults { get; } = []; public List PendingOperations { get; } = []; public List PolicyDenials { get; } = []; + /// + /// App surface IDs (e.g. "today", "gamification") surfaced by read-only tools such as + /// describe_feature, deduplicated in first-seen order. The client maps these to deep links. + /// + public IReadOnlyList RelatedSurfaces => _relatedSurfaces; + public void Add( ActionResult? actionResult, AgentOperationResult? operationResult, @@ -659,7 +670,10 @@ public void Add( ActionResults.Add(actionResult); if (operationResult is not null) + { OperationResults.Add(operationResult); + CollectRelatedSurfaces(operationResult); + } if (policyDenial is not null) PolicyDenials.Add(policyDenial); @@ -667,6 +681,54 @@ public void Add( if (pendingOperation is not null) PendingOperations.Add(pendingOperation); } + + private void CollectRelatedSurfaces(AgentOperationResult operationResult) + { + if (operationResult.Status != AgentOperationStatus.Succeeded) + return; + + foreach (var surface in ExtractRelatedSurfaces(operationResult.Payload)) + { + if (_seenRelatedSurfaces.Add(surface)) + _relatedSurfaces.Add(surface); + } + } + } + + /// + /// Reads the optional "related_surfaces" string array from a tool's anonymous payload + /// (e.g. describe_feature) by round-tripping it through JSON. Returns an empty sequence + /// when the payload is null, not an object, or carries no usable surface IDs. + /// + private static IEnumerable ExtractRelatedSurfaces(object? payload) + { + if (payload is null) + return []; + + JsonElement element; + try + { + element = JsonSerializer.SerializeToElement(payload); + } + catch (NotSupportedException) + { + return []; + } + + if (element.ValueKind != JsonValueKind.Object + || !element.TryGetProperty("related_surfaces", out var surfaces) + || surfaces.ValueKind != JsonValueKind.Array) + { + return []; + } + + return surfaces + .EnumerateArray() + .Where(item => item.ValueKind == JsonValueKind.String) + .Select(item => item.GetString()) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value!) + .ToList(); } /// diff --git a/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs index 333933bd..026d4da1 100644 --- a/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs @@ -562,6 +562,78 @@ public async Task Handle_ReadOnlyToolCall_DoesNotProduceActionResult() result.Value.Actions.Should().BeEmpty(); } + // --- Read-only tool surfacing related surfaces --- + + [Fact] + public async Task Handle_ReadOnlyToolWithRelatedSurfaces_SurfacesThemOnResponse() + { + SetupUserAndPayGate(); + + var describeTool = Substitute.For(); + describeTool.Name.Returns("describe_feature"); + describeTool.Description.Returns("Explains a feature"); + describeTool.IsReadOnly.Returns(true); + describeTool.GetParameterSchema().Returns(new { type = "object" }); + describeTool.ExecuteAsync(Arg.Any(), UserId, Arg.Any()) + .Returns(new ToolResult(true, Payload: new + { + key = "streaks", + related_surfaces = new[] { "gamification", "today" }, + markdown = "# Streaks" + })); + + var handler = CreateHandler(describeTool); + + var aiResponseWithTool = new AiResponse + { + ToolCalls = [new AiToolCall("describe_feature", "call_1", JsonDocument.Parse("{}").RootElement)], + ConversationContext = TestConversationContext + }; + SetupAiResponse(aiResponseWithTool); + + _aiIntentService.ContinueWithToolResultsAsync( + Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Result.Success(new AiResponse { TextMessage = "Streaks work like this.", ToolCalls = null })); + + var result = await handler.Handle(new ProcessUserChatCommand(UserId, "How do streaks work?"), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Actions.Should().BeEmpty(); + result.Value.RelatedSurfaces.Should().Equal("gamification", "today"); + } + + [Fact] + public async Task Handle_MutatingOnlyTurn_LeavesRelatedSurfacesNull() + { + SetupUserAndPayGate(); + + var mockTool = Substitute.For(); + mockTool.Name.Returns("create_habit"); + mockTool.Description.Returns("Creates a habit"); + mockTool.IsReadOnly.Returns(false); + mockTool.GetParameterSchema().Returns(new { type = "object" }); + mockTool.ExecuteAsync(Arg.Any(), UserId, Arg.Any()) + .Returns(new ToolResult(true, EntityId: Guid.NewGuid().ToString(), EntityName: "Morning Run")); + + var handler = CreateHandler(mockTool); + + var aiResponseWithTool = new AiResponse + { + ToolCalls = [new AiToolCall("create_habit", "call_1", JsonDocument.Parse("{}").RootElement)], + ConversationContext = TestConversationContext + }; + SetupAiResponse(aiResponseWithTool); + + _aiIntentService.ContinueWithToolResultsAsync( + Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Result.Success(new AiResponse { TextMessage = "Created your habit!", ToolCalls = null })); + + var result = await handler.Handle(new ProcessUserChatCommand(UserId, "Create a habit"), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.RelatedSurfaces.Should().BeNull(); + } + // --- Multiple tool calls in sequence --- [Fact]