From 0ffc07578ee80541be6c11ea9293232fc7552470 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Tue, 18 Aug 2026 18:52:44 -0400 Subject: [PATCH] dotnet-server: Rich Interactions runtime + choices kind (AskUserQuestion) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 2 of the polyglot Rich Interactions effort — mirror the merged Rust reference (#475) on the .NET (C#) server, Brent's tsx-dev-agent host. Framework (generalizes the write-confirmation park/resume kind-agnostically): - IInteractionKind / InteractionCatalog — the kind catalog (default = choices). - InteractionParkRegistry — session-keyed park store (peek vs resolve, so an invalid submit re-prompts without consuming the park), the interaction analog of ConfirmationRegistry. - RequestInteractionTool / SubmitInteractionTool — the per-kind request_ raise tool (rich = park + interaction_required; fallback = conversational directive) and the generic submit_interaction fallback tool. - ProtocolEvents.InteractionRequired / InteractionInvalid — the double-nested data.data envelopes matching spec/events/*.schema.json. The choices kind (mirrors choices.rs exactly): request_choices (1–4 questions, header ≤12 unique, 2–4 options, optional multiSelect), validate_choices (all-answered; labels ∈ options; single = one label XOR other, multi = ≥1; blank other dropped; one-pass errors), fallback directive, capability id choice_chips. Server wiring: submit_interaction dispatch (validate → resume, invalid → retryable interaction_invalid, decline path, INTERACTION_MISMATCH / NO_PENDING_INTERACTION guards, ownership-scoped); per-connection `supports` capture at create_conversation_session drives the rich-vs-fallback branch; teardown/cancel unpark. The catalog is a DI-provided capability (AddSmoothOperatorServer registers InteractionCatalog.Default), so direct FrameDispatcher construction is unchanged. Tests: xUnit validator unit tests (ported from choices.rs, + the shared choices_* conformance fixtures through the C# validator) and a WS park/resume integration suite (raise → interaction_required → submit → resume; invalid → stays parked → resubmit; decline; text-only fallback; no-pending error). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YbN45JeWDbcjvFqGJvmVD3 --- .changeset/dotnet-choices-interaction.md | 7 + .../aspnetcore/ServiceCollectionExtensions.cs | 5 + .../SmoothOperatorWebSocketExtensions.cs | 4 + .../SubmitInteractionTests.cs | 368 ++++++++++++++++ dotnet/server/src/ChoicesKind.cs | 366 ++++++++++++++++ dotnet/server/src/FrameDispatcher.cs | 163 ++++++- dotnet/server/src/Interactions.cs | 402 ++++++++++++++++++ dotnet/server/src/ProtocolEvents.cs | 58 +++ dotnet/server/src/TurnRunner.cs | 36 +- .../server/tests/ChoicesInteractionTests.cs | 288 +++++++++++++ 10 files changed, 1694 insertions(+), 3 deletions(-) create mode 100644 .changeset/dotnet-choices-interaction.md create mode 100644 dotnet/server/integration-tests/SubmitInteractionTests.cs create mode 100644 dotnet/server/src/ChoicesKind.cs create mode 100644 dotnet/server/src/Interactions.cs create mode 100644 dotnet/server/tests/ChoicesInteractionTests.cs diff --git a/.changeset/dotnet-choices-interaction.md b/.changeset/dotnet-choices-interaction.md new file mode 100644 index 00000000..ed26407b --- /dev/null +++ b/.changeset/dotnet-choices-interaction.md @@ -0,0 +1,7 @@ +--- +'@smooai/smooth-operator': patch +--- + +Port the Rich Interactions runtime + the `choices` kind (AskUserQuestion) to the .NET (C#) server, at parity with the Rust reference. + +The C# server now hosts a kind-agnostic interaction framework (`IInteractionKind` / `InteractionCatalog` / a session-keyed `InteractionParkRegistry` generalizing the write-confirmation park/resume) and the `choices` kind (`request_choices` raise tool, `validate_choices`, conversational fallback, capability id `choice_chips`). A turn on a `choice_chips`-capable session parks emitting `interaction_required` and resumes on a `submit_interaction` frame (invalid values → retryable `interaction_invalid`, never a terminal error); text-only sessions degrade to the enumerated conversational directive and submit via the `submit_interaction` tool. Validated against the shared `spec/interactions/choices.schema.json` conformance fixtures. diff --git a/dotnet/server/aspnetcore/ServiceCollectionExtensions.cs b/dotnet/server/aspnetcore/ServiceCollectionExtensions.cs index be328082..230ae864 100644 --- a/dotnet/server/aspnetcore/ServiceCollectionExtensions.cs +++ b/dotnet/server/aspnetcore/ServiceCollectionExtensions.cs @@ -21,6 +21,11 @@ public static IServiceCollection AddSmoothOperatorServer(this IServiceCollection { services.TryAddSingleton(); + // The hosted Rich Interaction kinds. Default catalog = the `choices` kind; a host can register + // its own InteractionCatalog (more kinds) before calling this. Per-connection park state lives + // in the FrameDispatcher; the rich-vs-fallback path is decided per session by its `supports`. + services.TryAddSingleton(InteractionCatalog.Default); + // The executor a turn runs on (ADR-030) — the one place a durable backend plugs in, mirroring // the Rust server's runner.rs::turn_executor. A host that wants durable turns registers a // durable IAgentExecutor under ExecutorSelection.DurableExecutorServiceKey (it may reference diff --git a/dotnet/server/aspnetcore/SmoothOperatorWebSocketExtensions.cs b/dotnet/server/aspnetcore/SmoothOperatorWebSocketExtensions.cs index 555e5b2d..1164efb9 100644 --- a/dotnet/server/aspnetcore/SmoothOperatorWebSocketExtensions.cs +++ b/dotnet/server/aspnetcore/SmoothOperatorWebSocketExtensions.cs @@ -99,6 +99,10 @@ private static FrameDispatcher BuildDispatcher(HttpContext context) // Tool-name patterns gated behind write-confirmation HITL (default none). Each connection // gets its own ConfirmationRegistry (see the ConfirmTools type doc). confirmTools: services.GetService()?.Patterns, + // The hosted Rich Interaction kinds. A host may register its own InteractionCatalog to add + // kinds; absent one, the FrameDispatcher defaults to InteractionCatalog.Default (the choices + // kind). Each connection gets its own interaction park registry (built inside the dispatcher). + interactions: services.GetService(), agentConfigResolver: agentConfigResolver, judge: judge, // Identity-verification seam for end_user tools on public agents (default fails closed). diff --git a/dotnet/server/integration-tests/SubmitInteractionTests.cs b/dotnet/server/integration-tests/SubmitInteractionTests.cs new file mode 100644 index 00000000..284a019f --- /dev/null +++ b/dotnet/server/integration-tests/SubmitInteractionTests.cs @@ -0,0 +1,368 @@ +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using SmooAI.SmoothOperator.Server.AspNetCore; + +namespace SmooAI.SmoothOperator.Server.IntegrationTests; + +/// +/// Rich Interactions — the choices kind's park → interaction_required → +/// submit_interaction → resume path, driven end-to-end over a REAL WebSocket against the +/// in-process ASP.NET Core host. The C# parity of the Rust tests for the interaction runtime. +/// +/// The turn runs offline (a scripted calls request_choices), so +/// there is no gateway. The submit_interaction frame arrives on the same connection's reader +/// while the turn is parked — proving the turn runs as a background task. Covers: the rich (card) path +/// with a valid submit, a retryable invalid submit (interaction_invalid, turn stays parked) then +/// a valid resubmit, a decline, and the text-only FALLBACK (no capability → no card, the raise returns +/// the conversational directive instead). The submitted values validate against the shared +/// choices conformance fixture. +/// +public class SubmitInteractionTests +{ + // The choices_spec conformance fixture's questions — the exact shared spec the Rust server uses. + private const string ChoicesArgsJson = """ + { + "questions": [ + { "question": "Which plan are you interested in?", "header": "Plan", + "options": [ { "label": "Basic", "description": "For individuals" }, { "label": "Pro", "description": "For growing teams" } ] }, + { "question": "What topics can we help with?", "header": "Topics", + "options": [ { "label": "Sales" }, { "label": "Support" }, { "label": "Billing" } ], "multiSelect": true } + ], + "reason": "to route you to the right team" + } + """; + + private static WebApplication BuildApp() + { + var chat = new MockChatClient(); + var args = JsonSerializer.Deserialize(ChoicesArgsJson); + chat.PushToolCall("call-1", "request_choices", new Dictionary + { + ["questions"] = args.GetProperty("questions"), + ["reason"] = args.GetProperty("reason"), + }); + chat.PushText("Great — routing you to the right team now."); + + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddSingleton(chat); + builder.Services.AddSmoothOperatorServer(); + + var app = builder.Build(); + app.MapSmoothOperatorWebSocket("/ws"); + return app; + } + + private static async Task ConnectAsync(TestServer server) + { + var client = server.CreateWebSocketClient(); + return await client.ConnectAsync(new Uri(server.BaseAddress, "ws"), CancellationToken.None); + } + + private static Task SendAsync(WebSocket socket, JsonObject frame) => + socket.SendAsync(Encoding.UTF8.GetBytes(frame.ToJsonString()), WebSocketMessageType.Text, endOfMessage: true, CancellationToken.None); + + private static async Task NextEventAsync(WebSocket socket) + { + while (true) + { + var buffer = new byte[16 * 1024]; + using var stream = new MemoryStream(); + WebSocketReceiveResult result; + do + { + result = await socket.ReceiveAsync(buffer, CancellationToken.None); + stream.Write(buffer, 0, result.Count); + } + while (!result.EndOfMessage); + + var ev = JsonNode.Parse(Encoding.UTF8.GetString(stream.ToArray()))!.AsObject(); + var type = ev["type"]?.GetValue(); + if (type is not ("keepalive" or "pong")) + { + return ev; + } + } + } + + private static async Task CreateSessionAsync(WebSocket socket, string[]? supports = null) + { + var frame = new JsonObject + { + ["action"] = "create_conversation_session", + ["requestId"] = "r-create", + ["agentId"] = "11111111-1111-1111-1111-111111111111", + ["userName"] = "Alice", + ["userEmail"] = "alice@example.com", + }; + if (supports is not null) + { + frame["supports"] = new JsonArray(supports.Select(s => (JsonNode)s).ToArray()); + } + await SendAsync(socket, frame); + while (true) + { + var ev = await NextEventAsync(socket); + if (ev["type"]!.GetValue() == "immediate_response") + { + return ev["data"]!["sessionId"]!.GetValue(); + } + } + } + + /// Read events until the given type, returning that event; collects any tool results seen. + private static async Task ReadUntilAsync(WebSocket socket, string type, List? toolResults = null) + { + while (true) + { + var ev = await NextEventAsync(socket); + var t = ev["type"]!.GetValue(); + if (toolResults is not null && t == "stream_chunk" + && ev["data"]?["state"]?["rawResponse"]?["toolResult"]?.AsObject() is { } tr) + { + toolResults.Add(tr); + } + if (t == type) + { + return ev; + } + } + } + + [Fact] + public async Task RichPath_ParksOnCard_Resumes_OnValidSubmit() + { + await using var app = BuildApp(); + await app.StartAsync(); + using var socket = await ConnectAsync(app.GetTestServer()); + var sessionId = await CreateSessionAsync(socket, supports: new[] { "choice_chips" }); + + await SendAsync(socket, new JsonObject + { + ["action"] = "send_message", + ["requestId"] = "r-msg", + ["sessionId"] = sessionId, + ["message"] = "I need help choosing", + }); + + // The raise tool parks the turn: interaction_required carries the kind, spec, and reason. + var required = await ReadUntilAsync(socket, "interaction_required"); + Assert.Equal("r-msg", required["requestId"]!.GetValue()); + var payload = required["data"]!["data"]!; + var interactionId = payload["interactionId"]!.GetValue(); + Assert.False(string.IsNullOrEmpty(interactionId)); + Assert.Equal("choices", payload["kind"]!.GetValue()); + Assert.Equal("to route you to the right team", payload["reason"]!.GetValue()); + Assert.Equal("Plan", payload["spec"]!["questions"]![0]!["header"]!.GetValue()); + + // Submit the shared choices_values fixture (validates against choices_spec → choices_payload). + await SendAsync(socket, new JsonObject + { + ["action"] = "submit_interaction", + ["requestId"] = "r-msg", + ["sessionId"] = sessionId, + ["interactionId"] = interactionId, + ["kind"] = "choices", + ["values"] = JsonNode.Parse(""" + { "answers": [ { "header": "Plan", "options": ["Pro"] }, + { "header": "Topics", "options": ["Sales", "Billing"], "other": "Partnerships" } ] } + """), + }); + + // The submit is acked, and the parked turn resumes to completion. + var toolResults = new List(); + var ack = await ReadUntilAsync(socket, "immediate_response", toolResults); + Assert.Equal(200, ack["status"]!.GetValue()); + Assert.Equal("choices", ack["data"]!["kind"]!.GetValue()); + + var final = await ReadUntilAsync(socket, "eventual_response", toolResults); + Assert.Equal("Great — routing you to the right team now.", + final["data"]!["data"]!["response"]!["responseParts"]![0]!.GetValue()); + + // The raise tool's result — the validated, canonicalized payload — reached the model. + Assert.Contains(toolResults, tr => + tr["name"]!.GetValue() == "request_choices" + && tr["result"]!.GetValue().Contains("submitted", StringComparison.Ordinal) + && tr["result"]!.GetValue().Contains("Partnerships", StringComparison.Ordinal)); + + await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "done", CancellationToken.None); + await app.StopAsync(); + } + + [Fact] + public async Task RichPath_InvalidSubmit_StaysParked_ThenResumesOnValid() + { + await using var app = BuildApp(); + await app.StartAsync(); + using var socket = await ConnectAsync(app.GetTestServer()); + var sessionId = await CreateSessionAsync(socket, supports: new[] { "choice_chips" }); + + await SendAsync(socket, new JsonObject + { + ["action"] = "send_message", + ["requestId"] = "r-msg", + ["sessionId"] = sessionId, + ["message"] = "help me choose", + }); + + var required = await ReadUntilAsync(socket, "interaction_required"); + var interactionId = required["data"]!["data"]!["interactionId"]!.GetValue(); + + // Invalid: an option that isn't offered → retryable interaction_invalid, turn STAYS parked. + await SendAsync(socket, new JsonObject + { + ["action"] = "submit_interaction", + ["requestId"] = "r-msg", + ["sessionId"] = sessionId, + ["interactionId"] = interactionId, + ["values"] = JsonNode.Parse("""{ "answers": [ { "header": "Plan", "options": ["Platinum"] }, { "header": "Topics", "options": ["Sales"] } ] }"""), + }); + + var invalid = await ReadUntilAsync(socket, "interaction_invalid"); + Assert.Equal("r-msg", invalid["requestId"]!.GetValue()); + var invData = invalid["data"]!["data"]!; + Assert.Equal(interactionId, invData["interactionId"]!.GetValue()); + Assert.Equal("choices", invData["kind"]!.GetValue()); + Assert.NotEmpty(invData["errors"]!.AsArray()); + Assert.Equal("Plan", invData["errors"]![0]!["field"]!.GetValue()); + + // Resubmit the same interactionId with valid values → the turn resumes. + await SendAsync(socket, new JsonObject + { + ["action"] = "submit_interaction", + ["requestId"] = "r-msg", + ["sessionId"] = sessionId, + ["interactionId"] = interactionId, + ["values"] = JsonNode.Parse("""{ "answers": [ { "header": "Plan", "options": ["Pro"] }, { "header": "Topics", "options": ["Sales"] } ] }"""), + }); + + var final = await ReadUntilAsync(socket, "eventual_response"); + Assert.Equal(200, final["status"]!.GetValue()); + + await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "done", CancellationToken.None); + await app.StopAsync(); + } + + [Fact] + public async Task RichPath_Decline_ResumesTheTurn() + { + await using var app = BuildApp(); + await app.StartAsync(); + using var socket = await ConnectAsync(app.GetTestServer()); + var sessionId = await CreateSessionAsync(socket, supports: new[] { "choice_chips" }); + + await SendAsync(socket, new JsonObject + { + ["action"] = "send_message", + ["requestId"] = "r-msg", + ["sessionId"] = sessionId, + ["message"] = "help me choose", + }); + + var required = await ReadUntilAsync(socket, "interaction_required"); + var interactionId = required["data"]!["data"]!["interactionId"]!.GetValue(); + + await SendAsync(socket, new JsonObject + { + ["action"] = "submit_interaction", + ["requestId"] = "r-msg", + ["sessionId"] = sessionId, + ["interactionId"] = interactionId, + ["declined"] = true, + }); + + var toolResults = new List(); + var ack = await ReadUntilAsync(socket, "immediate_response", toolResults); + Assert.Equal(200, ack["status"]!.GetValue()); + var final = await ReadUntilAsync(socket, "eventual_response", toolResults); + Assert.Equal(200, final["status"]!.GetValue()); + // The raise tool resumed with a declined payload. + Assert.Contains(toolResults, tr => + tr["name"]!.GetValue() == "request_choices" + && tr["result"]!.GetValue().Contains("declined", StringComparison.Ordinal)); + + await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "done", CancellationToken.None); + await app.StopAsync(); + } + + [Fact] + public async Task TextOnlyChannel_DegradesToConversationalFallback() + { + await using var app = BuildApp(); + await app.StartAsync(); + using var socket = await ConnectAsync(app.GetTestServer()); + // No `supports` → the choice_chips capability is absent → the raise degrades to fallback. + var sessionId = await CreateSessionAsync(socket, supports: null); + + await SendAsync(socket, new JsonObject + { + ["action"] = "send_message", + ["requestId"] = "r-msg", + ["sessionId"] = sessionId, + ["message"] = "help me choose", + }); + + // The turn completes WITHOUT ever parking: the raise tool returns the conversational directive + // and there is no interaction_required, no submit needed. + var toolResults = new List(); + var sawInteractionRequired = false; + while (true) + { + var ev = await NextEventAsync(socket); + var type = ev["type"]!.GetValue(); + if (type == "interaction_required") + { + sawInteractionRequired = true; + } + else if (type == "stream_chunk" + && ev["data"]?["state"]?["rawResponse"]?["toolResult"]?.AsObject() is { } tr) + { + toolResults.Add(tr); + } + else if (type == "eventual_response") + { + break; + } + } + + Assert.False(sawInteractionRequired, "a text-only session must never receive an interaction card"); + Assert.Contains(toolResults, tr => + tr["name"]!.GetValue() == "request_choices" + && tr["result"]!.GetValue().Contains("cannot display choice chips", StringComparison.Ordinal)); + + await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "done", CancellationToken.None); + await app.StopAsync(); + } + + [Fact] + public async Task SubmitWithoutPending_IsACleanError() + { + await using var app = BuildApp(); + await app.StartAsync(); + using var socket = await ConnectAsync(app.GetTestServer()); + var sessionId = await CreateSessionAsync(socket, supports: new[] { "choice_chips" }); + + await SendAsync(socket, new JsonObject + { + ["action"] = "submit_interaction", + ["requestId"] = "r-msg", + ["sessionId"] = sessionId, + ["interactionId"] = "00000000-0000-0000-0000-000000000000", + ["values"] = JsonNode.Parse("""{ "answers": [] }"""), + }); + + var err = await NextEventAsync(socket); + Assert.Equal("error", err["type"]!.GetValue()); + Assert.Equal("NO_PENDING_INTERACTION", err["error"]!["code"]!.GetValue()); + + await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "done", CancellationToken.None); + await app.StopAsync(); + } +} diff --git a/dotnet/server/src/ChoicesKind.cs b/dotnet/server/src/ChoicesKind.cs new file mode 100644 index 00000000..2bdcdaa4 --- /dev/null +++ b/dotnet/server/src/ChoicesKind.cs @@ -0,0 +1,366 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace SmooAI.SmoothOperator.Server; + +/// +/// The choices Rich Interaction kind — a structured multiple-choice ask modeled on Claude Code's +/// AskUserQuestion: 1–4 short questions, each with 2–4 labeled options, that the turn parks on +/// until the visitor picks. Every question also carries an implicit free-text "Other" escape hatch. +/// On a choice_chips channel the client renders chips; on text-only channels the raise degrades +/// to a conversational directive. Both paths validate through . +/// A faithful C# port of the Rust reference smooth-operator/src/choices.rs. +/// +public sealed class ChoicesKind : IInteractionKind +{ + /// Max length of a question's short header label (chip/tab caption). + public const int HeaderMaxChars = 12; + + public string Kind => "choices"; + public string Capability => "choice_chips"; + + public string ToolDescription => + "Ask the visitor a structured multiple-choice question (1–4 questions, each with 2–4 labeled " + + "options) and wait for their pick. On channels that can render chips/menus the visitor taps an " + + "option; on text channels you will be told to enumerate the options and accept a natural-language " + + "answer. An implicit free-text \"Other\" is always available, so use this whenever the answer is " + + "likely (but not certainly) one of a small set — never free-form the menu yourself."; + + private static readonly JsonElement Schema = JsonSerializer.Deserialize($$""" + { + "type": "object", + "properties": { + "questions": { + "type": "array", + "minItems": 1, + "maxItems": 4, + "description": "The questions to ask, in order (1–4).", + "items": { + "type": "object", + "properties": { + "question": { "type": "string", "description": "The question prompt shown to the visitor." }, + "header": { "type": "string", "maxLength": {{HeaderMaxChars}}, "description": "A short label (≤12 chars), unique within the raise. Used as the answer key and the chip/tab caption." }, + "options": { + "type": "array", + "minItems": 2, + "maxItems": 4, + "description": "The 2–4 options to offer. A free-text 'Other' is always available in addition.", + "items": { + "type": "object", + "properties": { + "label": { "type": "string", "description": "The option label (the value submitted)." }, + "description": { "type": "string", "description": "A short gloss for the option." } + }, + "required": ["label"] + } + }, + "multiSelect": { "type": "boolean", "description": "Allow selecting more than one option (default false)." } + }, + "required": ["question", "header", "options"] + } + }, + "reason": { + "type": "string", + "description": "Why you're asking, phrased for the visitor (e.g. \"to route you to the right team\")." + } + }, + "required": ["questions", "reason"] + } + """); + + public JsonElement ToolSchema => Schema; + + public InteractionRequest ParseRequest(JsonObject args) + { + var questions = ChoicesValidator.ParseQuestions(args["questions"]); + var reason = args["reason"]?.GetValue()?.Trim() is { Length: > 0 } r ? r : "to help you better"; + var spec = new JsonObject { ["questions"] = JsonSerializer.SerializeToNode(questions, ChoicesValidator.SerializerOptions) }; + return new InteractionRequest(Kind, spec, reason); + } + + public InteractionValidation Validate(JsonNode? spec, JsonNode? values) + { + // Missing/garbled spec ⇒ format-only validation (a prior-turn fallback whose spec is gone). + var questions = ChoicesValidator.QuestionsFromSpec(spec); + + ChoiceValues parsed; + try + { + parsed = values is null ? throw new JsonException("values is required") : values.Deserialize(ChoicesValidator.SerializerOptions) ?? throw new JsonException("values is null"); + } + catch (JsonException e) + { + return InteractionValidation.Invalid(new[] { new InteractionFieldError("values", $"invalid values shape: {e.Message}") }); + } + + var (validated, errors) = ChoicesValidator.Validate(questions, parsed); + if (errors is not null) + { + return InteractionValidation.Invalid(errors); + } + return InteractionValidation.Valid(JsonSerializer.SerializeToNode(validated, ChoicesValidator.SerializerOptions)!); + } + + public string FallbackDirective(JsonNode? spec, string reason) + { + var lines = new List(); + if (spec?["questions"] is JsonArray questions) + { + foreach (var q in questions) + { + if (q?["question"]?.GetValue() is not { } question) + { + continue; + } + var header = q["header"]?.GetValue() ?? question; + var multi = q["multiSelect"]?.GetValue() ?? false; + var labels = q["options"] is JsonArray opts + ? string.Join(", ", opts.Where(o => o?["label"] is not null).Select(o => o!["label"]!.GetValue())) + : string.Empty; + lines.Add($"- [{header}] {question} Options: {labels}{(multi ? " (choose one or more)" : string.Empty)}."); + } + } + var enumerated = string.Join("\n", lines); + return + "This visitor's channel cannot display choice chips. Ask the following question(s) " + + "conversationally, naturally weaving in the reason (" + reason + "), and read out each " + + "option so the visitor can pick:\n" + enumerated + "\nThe visitor may also answer with " + + "something not listed (that's fine — capture it as their 'other' answer). When you " + + "have their pick(s), call the `submit_interaction` tool with kind \"choices\" and " + + "`values.answers` — one entry per question `{ header, options: [chosen label(s)], " + + "other?: \"their free-text answer\" }`. It validates each answer and will tell you " + + "if a pick isn't offered so you can re-ask. If the visitor declines to choose, call " + + "`submit_interaction` with declined=true and continue helping them."; + } +} + +/// One selectable option in a question. +public sealed class ChoiceOption +{ + [JsonPropertyName("label")] public string Label { get; set; } = string.Empty; + [JsonPropertyName("description")] public string Description { get; set; } = string.Empty; +} + +/// One question in a choices raise. +public sealed class ChoiceQuestion +{ + [JsonPropertyName("question")] public string Question { get; set; } = string.Empty; + [JsonPropertyName("header")] public string Header { get; set; } = string.Empty; + [JsonPropertyName("options")] public List Options { get; set; } = new(); + [JsonPropertyName("multiSelect")] public bool MultiSelect { get; set; } +} + +/// The visitor's answer to one question, submitted via submit_interaction. +public sealed class ChoiceAnswer +{ + [JsonPropertyName("header")] public string Header { get; set; } = string.Empty; + [JsonPropertyName("options")] public List Options { get; set; } = new(); + + // Blank ⇒ omitted (the free-text "Other" escape hatch), matching serde's skip_serializing_if. + [JsonPropertyName("other")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Other { get; set; } + + /// Total picks: selected labels + one for a non-blank "Other". + public int SelectionCount() => Options.Count + (Other is null ? 0 : 1); +} + +/// Validated, normalized choice answers — the payload the parked turn resumes with. +public sealed class ChoiceValues +{ + [JsonPropertyName("answers")] public List Answers { get; set; } = new(); +} + +/// +/// The choices validator + raise-arg parser — a faithful port of validate_choices and +/// parse_questions from the Rust reference. Kept separate from so it is +/// unit-testable in isolation (like the Rust module's #[cfg(test)] block). +/// +public static class ChoicesValidator +{ + /// Serializer options for the choice wire types — no camelCase munging (the property names + /// are explicit) and no null-skipping beyond 's own attribute. + public static readonly JsonSerializerOptions SerializerOptions = new(); + + /// Deserialize a spec's questions into the typed list; empty on a missing/garbled + /// spec (⇒ format-only validation), mirroring Rust's unwrap_or_default. + public static List QuestionsFromSpec(JsonNode? spec) + { + if (spec?["questions"] is not JsonNode questions) + { + return new List(); + } + try + { + return questions.Deserialize>(SerializerOptions) ?? new List(); + } + catch (JsonException) + { + return new List(); + } + } + + /// + /// Validate submitted against the raised . + /// Returns (validated, null) on success or (null, errors) with EVERY failed question + /// (one-pass), a line-for-line port of Rust's validate_choices. + /// + public static (ChoiceValues? Validated, IReadOnlyList? Errors) Validate( + IReadOnlyList questions, + ChoiceValues values) + { + // Normalize first: trim headers + labels (drop blanks), trim "Other" (blank ⇒ absent). + var normalized = values.Answers.Select(a => new ChoiceAnswer + { + Header = a.Header.Trim(), + Options = a.Options.Select(o => o.Trim()).Where(o => o.Length > 0).ToList(), + Other = a.Other?.Trim() is { Length: > 0 } t ? t : null, + }).ToList(); + + // Format-only path: no spec to check membership/required-ness against. + if (questions.Count == 0) + { + var fmtErrors = new List(); + foreach (var answer in normalized) + { + if (answer.SelectionCount() == 0) + { + fmtErrors.Add(new InteractionFieldError(answer.Header, "select an option or provide an 'other' answer")); + } + } + if (normalized.Count == 0) + { + fmtErrors.Add(new InteractionFieldError("answers", "provide an answer for each question, or declined=true")); + } + return fmtErrors.Count == 0 + ? (new ChoiceValues { Answers = normalized }, null) + : (null, fmtErrors); + } + + var errors = new List(); + var outAnswers = new List(questions.Count); + + foreach (var question in questions) + { + var answer = normalized.FirstOrDefault(a => a.Header == question.Header); + if (answer is null) + { + errors.Add(new InteractionFieldError(question.Header, "this question must be answered")); + continue; + } + + // Every selected label must be one of the enumerated options. + var badLabel = false; + foreach (var label in answer.Options) + { + if (!question.Options.Any(o => o.Label == label)) + { + badLabel = true; + errors.Add(new InteractionFieldError(question.Header, $"'{label}' is not one of the offered options")); + } + } + + var count = answer.SelectionCount(); + if (count == 0) + { + errors.Add(new InteractionFieldError(question.Header, "select an option or provide an 'other' answer")); + } + else if (!question.MultiSelect && count > 1) + { + errors.Add(new InteractionFieldError(question.Header, "this question takes a single answer")); + } + + if (!badLabel) + { + outAnswers.Add(answer); + } + } + + return errors.Count == 0 ? (new ChoiceValues { Answers = outAnswers }, null) : (null, errors); + } + + /// + /// Parse the raise tool's questions argument into validated s, + /// enforcing the LLM-facing contract (1–4 questions; non-empty prompt; non-empty unique header ≤12 + /// chars; 2–4 options with non-empty labels). Accepts a bare-string option shorthand. Throws + /// on a violation — a port of Rust's parse_questions. + /// + public static List ParseQuestions(JsonNode? raw) + { + if (raw is not JsonArray items) + { + throw new InteractionParseException("'questions' must be an array"); + } + if (items.Count is < 1 or > 4) + { + throw new InteractionParseException("'questions' must contain between 1 and 4 questions"); + } + + var questions = new List(items.Count); + var seenHeaders = new List(items.Count); + foreach (var item in items) + { + if (item is not JsonObject obj) + { + throw new InteractionParseException("each question must be an object"); + } + + var question = obj["question"]?.GetValue()?.Trim() is { Length: > 0 } q + ? q + : throw new InteractionParseException("each question needs a non-empty 'question'"); + + var header = obj["header"]?.GetValue()?.Trim() is { Length: > 0 } h + ? h + : throw new InteractionParseException("each question needs a non-empty 'header'"); + if (header.Length > ChoicesKind.HeaderMaxChars) + { + throw new InteractionParseException($"header '{header}' is too long (max {ChoicesKind.HeaderMaxChars} characters)"); + } + if (seenHeaders.Contains(header)) + { + throw new InteractionParseException($"duplicate question header '{header}'"); + } + seenHeaders.Add(header); + + if (obj["options"] is not JsonArray rawOptions) + { + throw new InteractionParseException($"question '{header}' needs an 'options' array"); + } + if (rawOptions.Count is < 2 or > 4) + { + throw new InteractionParseException($"question '{header}' must offer between 2 and 4 options"); + } + + var options = new List(rawOptions.Count); + foreach (var opt in rawOptions) + { + var option = opt switch + { + JsonValue v when v.TryGetValue(out var s) => new ChoiceOption { Label = s.Trim(), Description = string.Empty }, + JsonObject o => new ChoiceOption + { + Label = (o["label"]?.GetValue() ?? string.Empty).Trim(), + Description = (o["description"]?.GetValue() ?? string.Empty).Trim(), + }, + _ => throw new InteractionParseException($"invalid option entry in '{header}'"), + }; + if (option.Label.Length == 0) + { + throw new InteractionParseException($"an option in '{header}' has an empty label"); + } + options.Add(option); + } + + questions.Add(new ChoiceQuestion + { + Question = question, + Header = header, + Options = options, + MultiSelect = obj["multiSelect"]?.GetValue() ?? false, + }); + } + return questions; + } +} diff --git a/dotnet/server/src/FrameDispatcher.cs b/dotnet/server/src/FrameDispatcher.cs index c9d835a8..ef6c5b66 100644 --- a/dotnet/server/src/FrameDispatcher.cs +++ b/dotnet/server/src/FrameDispatcher.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Text.Json.Nodes; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; @@ -36,6 +37,13 @@ public sealed class FrameDispatcher private readonly IReadOnlyList _toolHooks; private readonly IReadOnlyList _confirmTools; private readonly ConfirmationRegistry _confirmations; + private readonly InteractionCatalog? _interactions; + private readonly InteractionParkRegistry _interactionPark = new(); + // Per-connection render capabilities declared at create_conversation_session (the `supports` array), + // keyed by sessionId. The rich-vs-fallback interaction branch reads this. Captured per connection + // (like the confirmation registry) rather than persisted on the session — a client re-declares its + // capabilities on every connect, and create always precedes send on a connection. + private readonly ConcurrentDictionary> _sessionSupports = new(); private readonly IAgentConfigResolver? _agentConfigResolver; private readonly IWorkflowJudge? _judge; private readonly ISessionAuthenticator _authenticator; @@ -78,6 +86,7 @@ public FrameDispatcher( IReadOnlyList? tools = null, IReadOnlyList? confirmTools = null, ConfirmationRegistry? confirmations = null, + InteractionCatalog? interactions = null, IAgentConfigResolver? agentConfigResolver = null, IWorkflowJudge? judge = null, ISessionAuthenticator? authenticator = null, @@ -105,6 +114,11 @@ public FrameDispatcher( // Session-keyed pending-confirmation registry shared with each spawned turn so a // confirm_tool_action frame resolves the verdict a parked turn awaits. One per connection. _confirmations = confirmations ?? new ConfirmationRegistry(); + // The hosted Rich Interaction kinds — a host-provided capability wired through DI (like tools / + // reranker / OTP), NOT self-defaulted here: absent one (null), no interaction tools are ever + // registered and behavior is identical to before Rich Interactions. AddSmoothOperatorServer + // registers InteractionCatalog.Default (the `choices` kind) so the WS host gets it out of the box. + _interactions = interactions; // Per-agent config resolution (null ⇒ no per-agent instructions/workflow are applied; every // agent uses the default persona, unchanged) and the post-turn workflow judge. _agentConfigResolver = agentConfigResolver; @@ -195,6 +209,8 @@ public bool TryCancelActiveTurn(out string? turnRequestId) // Mirrors the Rust reference dropping the confirmation future on handle.abort(). No-op when the // turn wasn't parked. th cancel-unpark. _confirmations.Resolve(turn.SessionId, approved: false); + // Same for a turn parked on a Rich Interaction: unpark it (no_response) so it unwinds cleanly. + _interactionPark.Resolve(turn.SessionId, InteractionOutcome.NoResponse); return true; } @@ -203,7 +219,12 @@ public bool TryCancelActiveTurn(out string? turnRequestId) /// auto-approved on disconnect), so any turn parked on a confirmation unparks and finishes /// cleanly. Called by the connection loop on teardown, before . /// - public void RejectPendingConfirmations() => _confirmations.RejectAll(); + public void RejectPendingConfirmations() + { + _confirmations.RejectAll(); + // Unpark any turn parked on a Rich Interaction too (no_response, fail-soft), same teardown contract. + _interactionPark.RejectAll(); + } public async Task DispatchAsync(string rawFrame, Action sink, CancellationToken cancellationToken = default) { @@ -261,6 +282,9 @@ public async Task DispatchAsync(string rawFrame, Action sink, Cancel case "confirm_tool_action": await HandleConfirmToolActionAsync(frame, requestId, sink, cancellationToken).ConfigureAwait(false); break; + case "submit_interaction": + await HandleSubmitInteractionAsync(frame, requestId, sink, cancellationToken).ConfigureAwait(false); + break; case "verify_otp": await HandleVerifyOtpAsync(frame, requestId, sink, cancellationToken).ConfigureAwait(false); break; @@ -353,6 +377,12 @@ private async Task HandleCreateSessionAsync(JsonObject frame, string? requestId, string.IsNullOrEmpty(conversationId) ? null : conversationId, cancellationToken).ConfigureAwait(false); + // Capture the client's render capabilities (`supports`) for this session so a mid-turn Rich + // Interaction takes the rich (card) path only on a channel that can render it; an omitted / + // empty `supports` (text-only channels: SMS, voice) leaves the set empty → every kind degrades + // to its conversational fallback. Unknown values are kept but simply never match a kind. + _sessionSupports[session.SessionId] = ParseSupports(frame["supports"]); + // A freshly created session never passes through ScopedSessionAsync, so associate here too. AssociateSession(session); @@ -765,7 +795,10 @@ private async Task HandleSendMessageAsync(JsonObject frame, string? requestId, A // 5. Stream the turn, retrieving through knowledge SCOPED to this connection's access (computed // above, and reused to back the built-in knowledge_search tool) — so a user only ever sees // documents their groups grant (ACL enforced on the chat path). - var runner = new TurnRunner(_chatClient, _store, scopedKnowledge, _systemPrompt, _reranker, gatedTools, confirmTools, _confirmations, agentConfig, _judge, _limits, _logger, toolHooks: _toolHooks); + // Rich Interactions: hand the turn the hosted-kind catalog, the connection's park registry, and + // THIS session's declared render capabilities (empty for a text-only session → fallback path). + var capabilities = _sessionSupports.TryGetValue(session.SessionId, out var supports) ? supports : null; + var runner = new TurnRunner(_chatClient, _store, scopedKnowledge, _systemPrompt, _reranker, gatedTools, confirmTools, _confirmations, agentConfig, _judge, _limits, _logger, toolHooks: _toolHooks, interactions: _interactions, interactionPark: _interactionPark, capabilities: capabilities); // Run the turn as a background task, NOT awaited inline. A turn that calls a // confirmation-gated tool PARKS awaiting a later confirm_tool_action frame; the connection's @@ -988,6 +1021,132 @@ private async Task HandleConfirmToolActionAsync(JsonObject frame, string? reques new JsonObject { ["sessionId"] = sessionId, ["approved"] = approved })); } + /// + /// submit_interaction — resume a turn parked on a Rich Interaction. Per + /// spec/actions/submit-interaction.schema.json the client replies with + /// {action, requestId, sessionId, interactionId, kind?, values | declined}. We PEEK (not + /// consume) the parked interaction, guard the interactionId (a stale card can't resolve a + /// newer park → INTERACTION_MISMATCH), then either decline or run the kind's validator: invalid + /// values emit a retryable interaction_invalid and LEAVE the turn parked (mirrors + /// otp_invalid); valid values canonicalize, resume the parked turn, and ack with an + /// immediate_response. Ownership-scoped (a submit on a session you don't own is refused with the + /// same NO_PENDING_INTERACTION an unknown one gets). The C# analog of the Rust + /// handle_submit_interaction. + /// + private async Task HandleSubmitInteractionAsync(JsonObject frame, string? requestId, Action sink, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(requestId)) + { + sink(ProtocolEvents.Error(requestId, "VALIDATION_ERROR", "submit_interaction requires a 'requestId'")); + return; + } + + var sessionId = frame["sessionId"]?.GetValue(); + if (string.IsNullOrEmpty(sessionId)) + { + sink(ProtocolEvents.Error(requestId, "VALIDATION_ERROR", "submit_interaction requires a 'sessionId'")); + return; + } + + var interactionId = frame["interactionId"]?.GetValue(); + if (string.IsNullOrEmpty(interactionId)) + { + sink(ProtocolEvents.Error(requestId, "VALIDATION_ERROR", "submit_interaction requires an 'interactionId'")); + return; + } + + // SECURITY (th-1b7ed0): scope to the owner, and refuse an unowned/unknown session with the SAME + // NO_PENDING_INTERACTION so the two stay indistinguishable. Peek does not consume the park. + var scoped = await ScopedSessionAsync(sessionId, cancellationToken).ConfigureAwait(false); + var pending = scoped is null ? null : _interactionPark.Peek(sessionId); + if (pending is null) + { + sink(ProtocolEvents.Error(requestId, "NO_PENDING_INTERACTION", $"no interaction is awaiting submission for session '{sessionId}'")); + return; + } + + // Stale-card guard: the submit must target the CURRENT park, not a superseded one. + if (interactionId != pending.InteractionId) + { + sink(ProtocolEvents.Error(requestId, "INTERACTION_MISMATCH", "interactionId does not match the pending interaction")); + return; + } + var kindId = frame["kind"]?.GetValue(); + if (!string.IsNullOrEmpty(kindId) && kindId != pending.Kind) + { + sink(ProtocolEvents.Error(requestId, "INTERACTION_MISMATCH", "kind does not match the pending interaction")); + return; + } + + // Decline: resume the turn with a declined payload so the agent proceeds gracefully. + if (frame["declined"] is JsonValue declinedNode && declinedNode.TryGetValue(out var declined) && declined) + { + if (!_interactionPark.Resolve(sessionId, InteractionOutcome.Declined)) + { + sink(ProtocolEvents.Error(requestId, "NO_PENDING_INTERACTION", $"no interaction is awaiting submission for session '{sessionId}'")); + return; + } + sink(ProtocolEvents.ImmediateResponse(requestId, 200, "Interaction declined", + new JsonObject { ["sessionId"] = sessionId, ["interactionId"] = interactionId, ["kind"] = pending.Kind })); + return; + } + + var values = frame["values"]; + if (values is null) + { + sink(ProtocolEvents.Error(requestId, "VALIDATION_ERROR", "submit_interaction requires 'values' or 'declined': true")); + return; + } + + // A park only exists when interactions are configured, but guard the nullable for the compiler. + var kind = _interactions?.Get(pending.Kind); + if (kind is null) + { + // The parked kind is always a hosted kind; a miss means the park is stale/gone. + sink(ProtocolEvents.Error(requestId, "NO_PENDING_INTERACTION", $"no interaction is awaiting submission for session '{sessionId}'")); + return; + } + + var validation = kind.Validate(pending.Spec, values); + if (!validation.Ok) + { + // Retryable: the turn stays parked (no Resolve). Re-render the card with the field errors. + sink(ProtocolEvents.InteractionInvalid(requestId, pending.InteractionId, pending.Kind, validation.Errors!, "Some fields need attention.")); + return; + } + + if (!_interactionPark.Resolve(sessionId, InteractionOutcome.Submitted(validation.Canonical!))) + { + sink(ProtocolEvents.Error(requestId, "NO_PENDING_INTERACTION", $"no interaction is awaiting submission for session '{sessionId}'")); + return; + } + sink(ProtocolEvents.ImmediateResponse(requestId, 200, "Interaction submitted", new JsonObject + { + ["sessionId"] = sessionId, + ["interactionId"] = interactionId, + ["kind"] = pending.Kind, + ["values"] = validation.Canonical!.DeepClone(), + })); + } + + /// Parse the create-session supports field (a string array of render capabilities) + /// into a set. Absent/non-array ⇒ empty (text-only ⇒ every interaction kind uses its fallback). + private static HashSet ParseSupports(JsonNode? node) + { + var set = new HashSet(StringComparer.Ordinal); + if (node is JsonArray array) + { + foreach (var entry in array) + { + if (entry is JsonValue value && value.TryGetValue(out var capability) && !string.IsNullOrEmpty(capability)) + { + set.Add(capability); + } + } + } + return set; + } + /// /// Emit the OTP-offer sequence for a turn whose end_user tool was refused for lack of a /// verified session: otp_verification_required (prompt the client), then diff --git a/dotnet/server/src/Interactions.cs b/dotnet/server/src/Interactions.cs new file mode 100644 index 00000000..19a18eb3 --- /dev/null +++ b/dotnet/server/src/Interactions.cs @@ -0,0 +1,402 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.AI; + +namespace SmooAI.SmoothOperator.Server; + +/// +/// The Rich Interactions framework — a kind-agnostic generalization of the write-confirmation +/// park/resume (). An agent turn calls a per-kind raise tool +/// (request_<kind>); on a channel that declared the kind's render capability the turn +/// parks emitting interaction_required and resumes when the client replies with a +/// submit_interaction frame; on a text-only channel the raise degrades to a conversational +/// fallback directive and the model submits via the submit_interaction tool. Mirrors the Rust +/// reference smooth-operator/src/interaction.rs + tools/interaction.rs. +/// +public sealed record InteractionFieldError(string Field, string Message) +{ + public JsonObject ToWire() => new() { ["field"] = Field, ["message"] = Message }; +} + +/// A parsed raise: which kind, the kind-specific render spec, and why it was raised. +public sealed record InteractionRequest(string Kind, JsonNode Spec, string Reason); + +/// Thrown by when the raise args violate the +/// kind's contract (count/uniqueness/length). The raise tool returns the message to the model as a +/// tool result rather than faulting the turn — mirrors Rust's parse_request ?. +public sealed class InteractionParseException(string message) : Exception(message); + +/// How a parked interaction resolved. Canonicalized is present only for +/// submitted. The C# analog of Rust's InteractionOutcome plus the no-response backstop. +public sealed record InteractionOutcome(string Status, JsonNode? Values) +{ + public static InteractionOutcome Submitted(JsonNode values) => new("submitted", values); + public static readonly InteractionOutcome Declined = new("declined", null); + public static readonly InteractionOutcome NoResponse = new("no_response", null); +} + +/// Result of a kind's : canonicalized values, OR a +/// one-pass list of per-field errors (every failure, so a card annotates all in one round-trip). +public sealed record InteractionValidation(JsonNode? Canonical, IReadOnlyList? Errors) +{ + public bool Ok => Errors is null; + public static InteractionValidation Valid(JsonNode canonical) => new(canonical, null); + public static InteractionValidation Invalid(IReadOnlyList errors) => new(null, errors); +} + +/// +/// One interaction kind — the extension seam. Selects the client card + the server validator, declares +/// the render capability that gates its rich path, produces the LLM-facing raise tool, and owns the +/// parse/validate/fallback logic. Mirrors the Rust InteractionKind trait. +/// +public interface IInteractionKind +{ + /// Wire id (e.g. choices). Selects the client card and this validator. + string Kind { get; } + + /// The client render capability that gates the rich path (e.g. choice_chips). A + /// session that lists it in supports gets the parked card; otherwise the conversational fallback. + string Capability { get; } + + /// The raise tool name — request_<kind>. + string ToolName => $"request_{Kind}"; + + /// The raise tool's model-facing description. + string ToolDescription { get; } + + /// The raise tool's JSON Schema (the parameters the model fills). + JsonElement ToolSchema { get; } + + /// Parse + constraint-check the raise args into a request. Throws + /// on a contract violation. + InteractionRequest ParseRequest(JsonObject args); + + /// Validate submitted against (which may + /// be null on the conversational path — the kind then does format-only checks). Returns the + /// canonicalized values, or ALL field errors. + InteractionValidation Validate(JsonNode? spec, JsonNode? values); + + /// The directive handed to the model when the channel can't render the card: how to ask + /// the interaction conversationally and submit it via the submit_interaction tool. + string FallbackDirective(JsonNode? spec, string reason); +} + +/// +/// The hosted interaction kinds, looked up by . The C# analog of the +/// Rust InteractionRegistry. is the reference catalog. +/// +public sealed class InteractionCatalog +{ + private readonly IReadOnlyList _kinds; + + public InteractionCatalog(params IInteractionKind[] kinds) => _kinds = kinds; + + public IReadOnlyList Kinds => _kinds; + + /// First kind whose matches, else null. + public IInteractionKind? Get(string kind) => _kinds.FirstOrDefault(k => k.Kind == kind); + + /// The reference catalog: the choices kind. Additional kinds register here. + public static InteractionCatalog Default { get; } = new(new ChoicesKind()); +} + +/// One parked interaction — the record a submit_interaction frame resolves. +public sealed record PendingInteraction( + string InteractionId, + string Kind, + JsonNode? Spec, + TaskCompletionSource Completion); + +/// +/// Session-keyed park store for Rich Interactions — the interaction analog of +/// , but richer: the pending value carries the interactionId +/// (stale-submit guard), kind, and spec (the validation contract), and it exposes a +/// distinct from so an invalid submit re-prompts the +/// card without consuming the park (mirrors the Rust pending_interaction peek vs +/// take_interaction). One outstanding interaction per session. +/// +public sealed class InteractionParkRegistry +{ + private readonly ConcurrentDictionary _pending = new(); + + /// Register a fresh park for and return the task the raising + /// turn awaits. Any prior park is resolved first (the + /// newest raise wins, no dangling turn) — mirrors . + public Task Register(string sessionId, string interactionId, string kind, JsonNode? spec) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (_pending.TryRemove(sessionId, out var prior)) + { + prior.Completion.TrySetResult(InteractionOutcome.NoResponse); + } + _pending[sessionId] = new PendingInteraction(interactionId, kind, spec, tcs); + return tcs.Task; + } + + /// Peek the parked interaction WITHOUT consuming it — an invalid submit must leave the park + /// intact for a resubmit. Returns null when nothing is parked. + public PendingInteraction? Peek(string sessionId) => + _pending.TryGetValue(sessionId, out var pending) ? pending : null; + + /// Resolve + consume the park for . Returns false when nothing + /// was parked (a duplicate/stale submit → clean no-op). Taking the entry out guarantees one resolve. + public bool Resolve(string sessionId, InteractionOutcome outcome) + { + if (!_pending.TryRemove(sessionId, out var pending)) + { + return false; + } + return pending.Completion.TrySetResult(outcome); + } + + /// Drop any park for (turn ended). Idempotent. + public void Clear(string sessionId) => _pending.TryRemove(sessionId, out _); + + /// Resolve every outstanding park — called on + /// connection teardown so a parked turn unparks and finishes cleanly (never hangs). + public void RejectAll() + { + foreach (var key in _pending.Keys.ToArray()) + { + if (_pending.TryRemove(key, out var pending)) + { + pending.Completion.TrySetResult(InteractionOutcome.NoResponse); + } + } + } +} + +/// +/// The per-kind raise tool (request_<kind>), one per hosted kind per turn. On a +/// capability-bearing (rich) session it parks — emits interaction_required and awaits the +/// client's submit_interaction — and resumes with the validated payload. On a text-only session +/// it returns the kind's conversational fallback directive instead (and stashes the raised spec so a +/// later submit_interaction tool call can validate format-only). Never faults the turn: a +/// no-response/decline is a normal status the model reads. Mirrors Rust's RequestInteractionTool. +/// +internal sealed class RequestInteractionTool : AIFunction +{ + /// How long a parked raise waits for the visitor before resuming no_response. + public static readonly TimeSpan ParkTimeout = TimeSpan.FromSeconds(300); + + private readonly IInteractionKind _kind; + private readonly bool _rich; + private readonly Action _sink; + private readonly string _requestId; + private readonly string _sessionId; + private readonly InteractionParkRegistry _park; + private readonly ConcurrentDictionary _raised; + private readonly TimeSpan _timeout; + + public RequestInteractionTool( + IInteractionKind kind, + bool rich, + Action sink, + string requestId, + string sessionId, + InteractionParkRegistry park, + ConcurrentDictionary raised, + TimeSpan? timeout = null) + { + _kind = kind; + _rich = rich; + _sink = sink; + _requestId = requestId; + _sessionId = sessionId; + _park = park; + _raised = raised; + _timeout = timeout ?? ParkTimeout; + } + + public override string Name => _kind.ToolName; + public override string Description => _kind.ToolDescription; + public override JsonElement JsonSchema => _kind.ToolSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + InteractionRequest request; + try + { + request = _kind.ParseRequest(Interactions.ArgsToObject(arguments)); + } + catch (InteractionParseException ex) + { + // Contract violation (count/length/uniqueness) — hand the reason back so the model can fix + // and re-call, exactly like Rust returning the parse error as the tool result. + return ex.Message; + } + + if (!_rich) + { + // Text-only channel: no card. Stash the spec for a format-only submit, and return the + // conversational directive telling the model to ask + submit via submit_interaction. + _raised[request.Kind] = request.Spec; + return new JsonObject + { + ["mode"] = "conversational", + ["kind"] = request.Kind, + ["spec"] = request.Spec.DeepClone(), + ["reason"] = request.Reason, + ["instructions"] = _kind.FallbackDirective(request.Spec, request.Reason), + }.ToJsonString(); + } + + // Rich: park. Register, emit interaction_required, await the client's submit_interaction. The + // turn runs on a background task, so this await frees the read loop to receive the reply. + var interactionId = Guid.NewGuid().ToString(); + var parked = _park.Register(_sessionId, interactionId, request.Kind, request.Spec); + _sink(ProtocolEvents.InteractionRequired(_requestId, interactionId, request.Kind, request.Spec.DeepClone(), request.Reason)); + + var outcome = await AwaitOutcome(parked, cancellationToken).ConfigureAwait(false); + return outcome.Status switch + { + "submitted" => new JsonObject { ["status"] = "submitted", ["values"] = outcome.Values?.DeepClone() }.ToJsonString(), + "declined" => new JsonObject + { + ["status"] = "declined", + ["message"] = "The visitor declined. Continue helping them without this and do not ask again this conversation.", + }.ToJsonString(), + _ => new JsonObject + { + ["status"] = "no_response", + ["message"] = "The visitor did not respond to the card. Continue without it; you may offer again later if it becomes relevant.", + }.ToJsonString(), + }; + } + + /// Await the park, backstopped by a timeout and the turn's cancellation — either resolves + /// no_response so the tool never hangs the turn. + private async Task AwaitOutcome(Task parked, CancellationToken cancellationToken) + { + try + { + var completed = await Task.WhenAny(parked, Task.Delay(_timeout, cancellationToken)).ConfigureAwait(false); + if (completed == parked) + { + return await parked.ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Turn cancelled/torn down — the park is (or will be) resolved elsewhere; treat as no_response. + } + _park.Resolve(_sessionId, InteractionOutcome.NoResponse); + return InteractionOutcome.NoResponse; + } +} + +/// +/// The generic submit_interaction tool — the conversational-path counterpart of the +/// client submit_interaction frame. Registered when any hosted kind is running in fallback +/// (text-only) so the model, after asking the question conversationally, submits the visitor's answer +/// for the same server-side validation the rich path runs. Mirrors Rust's SubmitInteractionTool. +/// +internal sealed class SubmitInteractionTool : AIFunction +{ + private readonly InteractionCatalog _catalog; + private readonly ConcurrentDictionary _raised; + + public SubmitInteractionTool(InteractionCatalog catalog, ConcurrentDictionary raised) + { + _catalog = catalog; + _raised = raised; + } + + public override string Name => "submit_interaction"; + + public override string Description => + "Submit the visitor's answer to an interaction you asked conversationally (because their channel " + + "can't render the card). Pass `kind` (the interaction kind), `values` in the kind's shape, or " + + "`declined: true` if they refused. The answer is validated server-side; if a value isn't valid " + + "you'll get an error explaining what to re-ask, then call this tool again with the correction."; + + public override JsonElement JsonSchema => Interactions.SubmitToolSchema(_catalog); + + protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + var args = Interactions.ArgsToObject(arguments); + var kindId = args["kind"]?.GetValue(); + if (string.IsNullOrEmpty(kindId)) + { + return new ValueTask("submit_interaction requires a 'kind'."); + } + + var kind = _catalog.Get(kindId); + if (kind is null) + { + return new ValueTask($"unknown interaction kind '{kindId}'."); + } + + if (args["declined"] is JsonValue declined && declined.TryGetValue(out var isDeclined) && isDeclined) + { + return new ValueTask(new JsonObject + { + ["status"] = "declined", + ["message"] = "Noted. Continue helping the visitor without this and do not ask again this conversation.", + }.ToJsonString()); + } + + var spec = _raised.TryGetValue(kindId, out var raised) ? raised : null; + var result = kind.Validate(spec, args["values"]); + if (!result.Ok) + { + var detail = string.Join("; ", result.Errors!.Select(e => $"{e.Field}: {e.Message}")); + return new ValueTask( + $"validation failed — {detail}. Re-ask the visitor for the corrected value(s) and submit again."); + } + + return new ValueTask(new JsonObject + { + ["status"] = "submitted", + ["values"] = result.Canonical!.DeepClone(), + }.ToJsonString()); + } +} + +/// Shared helpers for the interaction tools. +internal static class Interactions +{ + /// Project (values arrive as + /// from the engine, or CLR objects from a scripted client) into a . + public static JsonObject ArgsToObject(AIFunctionArguments arguments) + { + var obj = new JsonObject(); + foreach (var (key, value) in arguments) + { + obj[key] = ToNode(value); + } + return obj; + } + + private static JsonNode? ToNode(object? value) => value switch + { + null => null, + JsonNode node => node.DeepClone(), + JsonElement element => JsonSerializer.SerializeToNode(element), + _ => JsonSerializer.SerializeToNode(value), + }; + + /// The submit_interaction tool schema: kind (enum of hosted kinds), an opaque + /// values object, and a declined flag. + public static JsonElement SubmitToolSchema(InteractionCatalog catalog) + { + var enumArray = new JsonArray(); + foreach (var kind in catalog.Kinds) + { + enumArray.Add(kind.Kind); + } + var schema = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["kind"] = new JsonObject { ["type"] = "string", ["enum"] = enumArray, ["description"] = "The interaction kind being submitted." }, + ["values"] = new JsonObject { ["type"] = "object", ["description"] = "The visitor's answer, in the kind's values shape." }, + ["declined"] = new JsonObject { ["type"] = "boolean", ["description"] = "True if the visitor refused to answer." }, + }, + ["required"] = new JsonArray { "kind" }, + }; + return JsonSerializer.Deserialize(schema.ToJsonString()); + } +} diff --git a/dotnet/server/src/ProtocolEvents.cs b/dotnet/server/src/ProtocolEvents.cs index 2d6b70a6..acb526b8 100644 --- a/dotnet/server/src/ProtocolEvents.cs +++ b/dotnet/server/src/ProtocolEvents.cs @@ -152,6 +152,64 @@ public static JsonObject EventualResponse(string requestId, int status, string m ["timestamp"] = NowMs(), }; + /// + /// interaction_required — the Rich Interactions envelope: emitted mid-turn when the agent + /// raises a structured interaction (a choices card, …) on a session that declared the kind's render + /// capability. The turn is parked until the client replies with a submit_interaction + /// action carrying the same requestId + interactionId. Wire shape matches + /// spec/events/interaction-required.schema.json and the Rust reference byte-for-byte (the + /// double-nested data.data.{interactionId, kind, spec, reason}). + /// + public static JsonObject InteractionRequired(string requestId, string interactionId, string kind, JsonNode spec, string reason) => new() + { + ["type"] = "interaction_required", + ["requestId"] = requestId, + ["data"] = new JsonObject + { + ["requestId"] = requestId, + ["data"] = new JsonObject + { + ["interactionId"] = interactionId, + ["kind"] = kind, + ["spec"] = spec, + ["reason"] = reason, + }, + }, + ["timestamp"] = NowMs(), + }; + + /// + /// interaction_invalid — emitted when a submit_interaction carried values that failed + /// the kind's server-side validation. The turn REMAINS parked (retryable, like otp_invalid — + /// never a terminal error); the client re-renders the card with the per-field errors. Wire + /// shape matches spec/events/interaction-invalid.schema.json (double-nested data.data). + /// + public static JsonObject InteractionInvalid(string requestId, string interactionId, string kind, IReadOnlyList errors, string message) + { + var errorArray = new JsonArray(); + foreach (var error in errors) + { + errorArray.Add(error.ToWire()); + } + return new JsonObject + { + ["type"] = "interaction_invalid", + ["requestId"] = requestId, + ["data"] = new JsonObject + { + ["requestId"] = requestId, + ["data"] = new JsonObject + { + ["interactionId"] = interactionId, + ["kind"] = kind, + ["errors"] = errorArray, + ["message"] = message, + }, + }, + ["timestamp"] = NowMs(), + }; + } + /// /// otp_verification_required — emitted after a turn's auth gate refused an end_user /// tool on an unverified session and the host has an OTP service installed. Tells the client to diff --git a/dotnet/server/src/TurnRunner.cs b/dotnet/server/src/TurnRunner.cs index 8e292174..80a35c83 100644 --- a/dotnet/server/src/TurnRunner.cs +++ b/dotnet/server/src/TurnRunner.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Diagnostics; using System.Runtime.CompilerServices; using System.Text; @@ -67,6 +68,9 @@ public sealed class TurnRunner private readonly IReadOnlyList _toolHooks; private readonly IReadOnlyList _confirmTools; private readonly ConfirmationRegistry? _confirmations; + private readonly InteractionCatalog? _interactions; + private readonly InteractionParkRegistry? _interactionPark; + private readonly IReadOnlyCollection _capabilities; private readonly AgentConfig _agentConfig; private readonly IWorkflowJudge? _judge; private readonly TurnLimits _limits; @@ -81,7 +85,7 @@ public sealed class TurnRunner /// public Task PreambleCompleted { get; private set; } = Task.CompletedTask; - public TurnRunner(IChatClient chatClient, ISessionStore store, IKnowledgeBase? knowledge = null, string? systemPrompt = null, IReranker? reranker = null, IReadOnlyList? tools = null, IReadOnlyList? confirmTools = null, ConfirmationRegistry? confirmations = null, AgentConfig? agentConfig = null, IWorkflowJudge? judge = null, TurnLimits? limits = null, ILogger? logger = null, IChatClient? preambleChatClient = null, IReadOnlyList? toolHooks = null) + public TurnRunner(IChatClient chatClient, ISessionStore store, IKnowledgeBase? knowledge = null, string? systemPrompt = null, IReranker? reranker = null, IReadOnlyList? tools = null, IReadOnlyList? confirmTools = null, ConfirmationRegistry? confirmations = null, AgentConfig? agentConfig = null, IWorkflowJudge? judge = null, TurnLimits? limits = null, ILogger? logger = null, IChatClient? preambleChatClient = null, IReadOnlyList? toolHooks = null, InteractionCatalog? interactions = null, InteractionParkRegistry? interactionPark = null, IReadOnlyCollection? capabilities = null) { _chatClient = chatClient ?? throw new ArgumentNullException(nameof(chatClient)); _store = store ?? throw new ArgumentNullException(nameof(store)); @@ -99,6 +103,12 @@ public TurnRunner(IChatClient chatClient, ISessionStore store, IKnowledgeBase? k _confirmTools = confirmTools ?? Array.Empty(); // The session-keyed pending-confirmation registry the gate parks on (null → HITL off). _confirmations = confirmations; + // Rich Interactions: the hosted kinds catalog + the session-keyed park registry the raise tools + // park on, and the session's declared render capabilities (from `supports`). Any null ⇒ no + // interaction tools are registered → behavior identical to before Rich Interactions. + _interactions = interactions; + _interactionPark = interactionPark; + _capabilities = capabilities ?? Array.Empty(); // Per-agent config: instructions.prompt overrides the default persona; conversation_workflow // drives the guided-agency flow. Empty (the default) ⇒ the org/default persona, unchanged. _agentConfig = agentConfig ?? AgentConfig.Empty; @@ -333,6 +343,27 @@ public async Task RunAsync(string conversationId, string requestId, }); } + // Rich Interactions: register a per-kind raise tool (request_) for each hosted kind. A kind + // whose render capability the session declared in `supports` gets the RICH path (park + emit + // interaction_required); a kind without it gets the conversational FALLBACK (a directive the + // model asks + submits via the submit_interaction tool). When any kind is in fallback, the + // generic submit_interaction tool is registered too. Mirrors the Rust runner's interaction wiring. + if (_interactions is not null && _interactionPark is not null && _interactions.Kinds.Count > 0) + { + var raised = new ConcurrentDictionary(); + var anyFallback = false; + foreach (var kind in _interactions.Kinds) + { + var rich = _capabilities.Contains(kind.Capability); + anyFallback |= !rich; + options.Tools.Add(new RequestInteractionTool(kind, rich, sink, requestId, sessionId, _interactionPark, raised)); + } + if (anyFallback) + { + options.Tools.Add(new SubmitInteractionTool(_interactions, raised)); + } + } + var agent = new SmoothAgent(_chatClient, options); var thread = agent.GetNewThread(); foreach (var message in priorMessages) @@ -480,6 +511,9 @@ public async Task RunAsync(string conversationId, string requestId, // Turn over: drop any lingering pending confirmation so a stale entry can't mis-route a // later confirm_tool_action (mirrors the Rust clear at turn end). No-op when HITL is off. _confirmations?.Clear(sessionId); + // Same for a lingering interaction park, so a stale entry can't mis-route a later + // submit_interaction. No-op when Rich Interactions are off. + _interactionPark?.Clear(sessionId); } // Record token usage on the turn span (omitted when the engine reported none, per the GenAI diff --git a/dotnet/server/tests/ChoicesInteractionTests.cs b/dotnet/server/tests/ChoicesInteractionTests.cs new file mode 100644 index 00000000..e13d45fe --- /dev/null +++ b/dotnet/server/tests/ChoicesInteractionTests.cs @@ -0,0 +1,288 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace SmooAI.SmoothOperator.Server.Tests; + +/// +/// Unit tests for the choices Rich Interaction kind — the validator, the raise-arg parser, and +/// the fallback directive. Ports the Rust reference's choices.rs #[cfg(test)] block, and +/// additionally ties the C# validator to the SHARED conformance fixtures (choices_spec / +/// choices_values / choices_payload) so the same inputs the Rust server validates against +/// produce the same canonical payload here. +/// +public sealed class ChoicesInteractionTests +{ + private static ChoiceQuestion Question(string header, string[] labels, bool multi) => new() + { + Question = $"{header}?", + Header = header, + Options = labels.Select(l => new ChoiceOption { Label = l }).ToList(), + MultiSelect = multi, + }; + + private static ChoiceValues Values(params ChoiceAnswer[] answers) => new() { Answers = answers.ToList() }; + + private static ChoiceAnswer Answer(string header, string[] options, string? other = null) => new() + { + Header = header, + Options = options.ToList(), + Other = other, + }; + + [Fact] + public void ValidSingleSelectNormalizes() + { + var (validated, errors) = ChoicesValidator.Validate( + new[] { Question("Plan", new[] { "Basic", "Pro" }, false) }, + Values(Answer("Plan", new[] { " Pro " }))); + + Assert.Null(errors); + Assert.Single(validated!.Answers); + Assert.Equal(new[] { "Pro" }, validated.Answers[0].Options); + Assert.Null(validated.Answers[0].Other); + } + + [Fact] + public void ValidMultiSelectKeepsAllPicks() + { + var (validated, errors) = ChoicesValidator.Validate( + new[] { Question("Topics", new[] { "Sales", "Support", "Billing" }, true) }, + Values(Answer("Topics", new[] { "Sales", "Billing" }))); + + Assert.Null(errors); + Assert.Equal(new[] { "Sales", "Billing" }, validated!.Answers[0].Options); + } + + [Fact] + public void OtherEscapeHatchIsAccepted() + { + var (validated, errors) = ChoicesValidator.Validate( + new[] { Question("Plan", new[] { "Basic", "Pro" }, false) }, + Values(Answer("Plan", Array.Empty(), " Enterprise, actually "))); + + Assert.Null(errors); + Assert.Empty(validated!.Answers[0].Options); + Assert.Equal("Enterprise, actually", validated.Answers[0].Other); + } + + [Fact] + public void UnknownLabelIsAFieldError() + { + var (validated, errors) = ChoicesValidator.Validate( + new[] { Question("Plan", new[] { "Basic", "Pro" }, false) }, + Values(Answer("Plan", new[] { "Platinum" }))); + + Assert.Null(validated); + Assert.Single(errors!); + Assert.Equal("Plan", errors![0].Field); + Assert.Contains("not one of the offered", errors[0].Message); + } + + [Fact] + public void SingleSelectRejectsMultiplePicks() + { + var (_, errors) = ChoicesValidator.Validate( + new[] { Question("Plan", new[] { "Basic", "Pro" }, false) }, + Values(Answer("Plan", new[] { "Basic", "Pro" }))); + + Assert.NotNull(errors); + Assert.Contains(errors!, e => e.Message.Contains("single answer")); + } + + [Fact] + public void UnansweredQuestionIsRequired() + { + var (_, errors) = ChoicesValidator.Validate( + new[] { Question("Plan", new[] { "Basic", "Pro" }, false), Question("Size", new[] { "S", "M" }, false) }, + Values(Answer("Plan", new[] { "Pro" }))); + + Assert.NotNull(errors); + Assert.Single(errors!); + Assert.Equal("Size", errors![0].Field); + Assert.Contains("must be answered", errors[0].Message); + } + + [Fact] + public void EmptyAnswerNeedsAPickOrOther() + { + var (_, errors) = ChoicesValidator.Validate( + new[] { Question("Plan", new[] { "Basic", "Pro" }, false) }, + Values(Answer("Plan", Array.Empty()))); + + Assert.NotNull(errors); + Assert.Contains(errors!, e => e.Message.Contains("select an option")); + } + + [Fact] + public void AllErrorsAccumulateInOnePass() + { + // Two questions both wrong: one unanswered, one with a bad label — both must be reported together. + var (_, errors) = ChoicesValidator.Validate( + new[] { Question("Plan", new[] { "Basic", "Pro" }, false), Question("Size", new[] { "S", "M" }, false) }, + Values(Answer("Plan", new[] { "Platinum" }))); + + Assert.NotNull(errors); + Assert.Equal(2, errors!.Count); + Assert.Contains(errors, e => e.Field == "Plan"); + Assert.Contains(errors, e => e.Field == "Size"); + } + + [Fact] + public void FormatOnlyPathAcceptsAnswersWithoutASpec() + { + // Prior-turn fallback: no questions to membership-check against, so any answer with a pick passes. + var (validated, errors) = ChoicesValidator.Validate( + Array.Empty(), + Values(Answer("Plan", new[] { "anything the visitor typed" }))); + + Assert.Null(errors); + Assert.Single(validated!.Answers); + } + + [Fact] + public void FormatOnlyPathRejectsNoAnswers() + { + var (_, errors) = ChoicesValidator.Validate(Array.Empty(), Values()); + + Assert.NotNull(errors); + Assert.Contains(errors!, e => e.Field == "answers"); + } + + [Fact] + public void ParseQuestionsEnforcesTheContract() + { + // Happy path with shorthand string options. + var qs = ChoicesValidator.ParseQuestions(JsonNode.Parse(""" + [ { "question": "Which plan?", "header": "Plan", "options": ["Basic", "Pro"] } ] + """)); + Assert.Single(qs); + Assert.Equal("Basic", qs[0].Options[0].Label); + Assert.False(qs[0].MultiSelect); + + // Too many questions. + Assert.Throws(() => ChoicesValidator.ParseQuestions(JsonNode.Parse(""" + [ {"question":"q","header":"H0","options":["a","b"]}, + {"question":"q","header":"H1","options":["a","b"]}, + {"question":"q","header":"H2","options":["a","b"]}, + {"question":"q","header":"H3","options":["a","b"]}, + {"question":"q","header":"H4","options":["a","b"]} ] + """))); + + // Too few options. + Assert.Throws(() => ChoicesValidator.ParseQuestions(JsonNode.Parse(""" + [ { "question": "q", "header": "H", "options": ["only"] } ] + """))); + + // Header too long. + Assert.Throws(() => ChoicesValidator.ParseQuestions(JsonNode.Parse(""" + [ { "question": "q", "header": "ThisHeaderIsWayTooLong", "options": ["a", "b"] } ] + """))); + + // Duplicate headers. + Assert.Throws(() => ChoicesValidator.ParseQuestions(JsonNode.Parse(""" + [ { "question": "q1", "header": "H", "options": ["a","b"] }, + { "question": "q2", "header": "H", "options": ["a","b"] } ] + """))); + } + + [Fact] + public void KindWiresTheReferenceSurface() + { + IInteractionKind kind = new ChoicesKind(); + Assert.Equal("choices", kind.Kind); + Assert.Equal("choice_chips", kind.Capability); + Assert.Equal("request_choices", kind.ToolName); + + var request = kind.ParseRequest(JsonNode.Parse(""" + { + "questions": [ { "question": "Which plan interests you?", "header": "Plan", + "options": [ { "label": "Basic" }, { "label": "Pro" } ] } ], + "reason": "to route you" + } + """)!.AsObject()); + Assert.Equal("choices", request.Kind); + Assert.Equal("to route you", request.Reason); + Assert.Equal("Plan", request.Spec["questions"]![0]!["header"]!.GetValue()); + + // The validator, through the kind, produces the canonical values. + var validation = kind.Validate(request.Spec, JsonNode.Parse("""{ "answers": [ { "header": "Plan", "options": ["Pro"] } ] }""")); + Assert.True(validation.Ok); + Assert.Equal("Pro", validation.Canonical!["answers"]![0]!["options"]![0]!.GetValue()); + + // The fallback directive enumerates the options + names submit_interaction. + var directive = kind.FallbackDirective(request.Spec, "to route you"); + Assert.Contains("Basic, Pro", directive); + Assert.Contains("submit_interaction", directive); + } + + [Fact] + public void ParseRequestDefaultsBlankReason() + { + var request = new ChoicesKind().ParseRequest(JsonNode.Parse(""" + { "questions": [ { "question": "q", "header": "H", "options": ["a","b"] } ] } + """)!.AsObject()); + Assert.Equal("to help you better", request.Reason); + } + + // ---- Shared conformance fixtures: the C# validator must agree with the shared spec ---- + + [Fact] + public void ValidatesTheSharedChoicesFixtures() + { + var fixtures = LoadFixtures(); + + // choices_spec parses into the kind's questions (2 questions, second is multiSelect). + var spec = fixtures["choices_spec"]; + var questions = ChoicesValidator.QuestionsFromSpec(spec); + Assert.Equal(2, questions.Count); + Assert.Equal("Plan", questions[0].Header); + Assert.False(questions[0].MultiSelect); + Assert.Equal("Topics", questions[1].Header); + Assert.True(questions[1].MultiSelect); + + // choices_values, validated against choices_spec, yields exactly choices_payload.values. + var values = fixtures["choices_values"]; + var validation = new ChoicesKind().Validate(spec, values); + Assert.True(validation.Ok); + + // Normalize both through the typed model so key order can't cause a spurious mismatch. + var expected = fixtures["choices_payload"]!["values"]!; + var opts = ChoicesValidator.SerializerOptions; + var expectedNorm = JsonSerializer.Serialize(expected.Deserialize(opts), opts); + var actualNorm = JsonSerializer.Serialize(validation.Canonical!.Deserialize(opts), opts); + Assert.Equal(expectedNorm, actualNorm); + } + + /// Load the instance of every fixture in the shared spec/conformance/fixtures.json. + private static Dictionary LoadFixtures() + { + var path = FindUp(Path.Combine("spec", "conformance", "fixtures.json")) + ?? throw new FileNotFoundException("could not locate spec/conformance/fixtures.json above " + AppContext.BaseDirectory); + var root = JsonNode.Parse(File.ReadAllText(path))!.AsObject(); + var fixtures = new Dictionary(); + foreach (var (name, node) in root) + { + if (name.StartsWith('$') || node?["instance"] is not JsonNode instance) + { + continue; + } + fixtures[name] = instance; + } + return fixtures; + } + + private static string? FindUp(string relative) + { + var dir = AppContext.BaseDirectory; + while (dir is not null) + { + var candidate = Path.Combine(dir, relative); + if (File.Exists(candidate)) + { + return candidate; + } + dir = Path.GetDirectoryName(dir.TrimEnd(Path.DirectorySeparatorChar)); + } + return null; + } +}