From 6d560ed9ee5c8b41c899eb3850c7587ce7b09bc8 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Fri, 5 Jun 2026 19:57:49 -0300 Subject: [PATCH] feat(api): surface describe_feature related_surfaces on ChatResponse The describe_feature tool returns `related_surfaces` in its read-only payload, but BuildActionResult returns null for read-only tools so it never reached the client. Extract `related_surfaces` from successful read-only tool payloads in the tool-execution accumulator (deduped, first-seen order) and thread it onto a new optional `ChatResponse.RelatedSurfaces`. The client maps these app-surface IDs to deep links in a "Related" chat footer. Refs thomasluizon/orbit-ui-mobile#95 Paired UI PR (consumes RelatedSurfaces, renders the footer): thomasluizon/orbit-ui-mobile feature/wave2-chat-rendering Co-Authored-By: Claude Opus 4.8 --- .../Chat/Commands/ProcessUserChatCommand.cs | 66 ++++++++++++++++- .../ProcessUserChatCommandHandlerTests.cs | 72 +++++++++++++++++++ 2 files changed, 136 insertions(+), 2 deletions(-) diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs index 26405c000..83b200e9a 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 333933bd3..026d4da1c 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]