From c0b01ee90b7ff4be62019464135c2afdb4471d8a Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Thu, 9 Apr 2026 22:12:52 -0400 Subject: [PATCH 01/16] Feature IMcpSampling and IMcpElicitation for direct MCP capability access Add two injectable interfaces following the IMcpClientRoots pattern so command handlers can use MCP sampling (LLM completions) and elicitation (structured user input) directly as workflow steps. IMcpElicitation exposes typed methods (ElicitTextAsync, ElicitBooleanAsync, ElicitChoiceAsync, ElicitNumberAsync) instead of raw MCP SDK types to keep the public API dependency-agnostic. ElicitChoiceAsync returns the zero-based index for safety against unexpected client responses. Includes E2E tests (9 scenarios), updated sample 08, and documentation. --- docs/mcp-agent-capabilities.md | 259 +++++++++++++++ docs/mcp-server.md | 3 + samples/08-mcp-server/Program.cs | 158 ++++++++- .../Documentation/DocumentationEngine.cs | 4 +- .../Internal/Options/OptionSchemaBuilder.cs | 4 +- src/Repl.Mcp/IMcpElicitation.cs | 57 ++++ src/Repl.Mcp/IMcpSampling.cs | 26 ++ src/Repl.Mcp/McpElicitationService.cs | 126 +++++++ src/Repl.Mcp/McpSamplingService.cs | 44 +++ src/Repl.Mcp/McpServerHandler.cs | 8 + .../Given_McpAgentCapabilities.cs | 307 ++++++++++++++++++ 11 files changed, 992 insertions(+), 4 deletions(-) create mode 100644 docs/mcp-agent-capabilities.md create mode 100644 src/Repl.Mcp/IMcpElicitation.cs create mode 100644 src/Repl.Mcp/IMcpSampling.cs create mode 100644 src/Repl.Mcp/McpElicitationService.cs create mode 100644 src/Repl.Mcp/McpSamplingService.cs create mode 100644 src/Repl.McpTests/Given_McpAgentCapabilities.cs diff --git a/docs/mcp-agent-capabilities.md b/docs/mcp-agent-capabilities.md new file mode 100644 index 0000000..42a4966 --- /dev/null +++ b/docs/mcp-agent-capabilities.md @@ -0,0 +1,259 @@ +# Agent Capabilities: Sampling and Elicitation + +Use MCP [sampling](https://modelcontextprotocol.io/specification/2025-11-05/client/sampling) and [elicitation](https://modelcontextprotocol.io/specification/2025-11-05/client/elicitation) directly from your command handlers to interact with the connected agent client. + +See also: [sample 08-mcp-server](../samples/08-mcp-server/) for a working example that uses both in a CSV import workflow. + +Related guides: + +- [mcp-server.md](mcp-server.md) — interaction degradation (prefill/elicitation/sampling via `IReplInteractionChannel`) +- [mcp-advanced.md](mcp-advanced.md) — roots, dynamic tools, MCP Apps + +## Overview + +Repl provides two MCP capabilities as injectable interfaces: + +| Interface | MCP capability | What it does | +|---|---|---| +| `IMcpSampling` | [Sampling](https://modelcontextprotocol.io/specification/2025-11-05/client/sampling) | Ask the connected LLM to generate a completion | +| `IMcpElicitation` | [Elicitation](https://modelcontextprotocol.io/specification/2025-11-05/client/elicitation) | Ask the user for structured input through the agent client | + +Both work like `IMcpClientRoots` — inject them into any command handler, check `IsSupported`, and use them. They are automatically excluded from MCP tool schemas. + +> **Note:** These interfaces give your commands _direct_ access to MCP capabilities. This is different from `IReplInteractionChannel`, which uses sampling and elicitation _internally_ as part of its [interaction degradation](mcp-server.md#interaction-in-mcp-mode) strategy. Use `IReplInteractionChannel` when you want portable prompts that work across CLI, REPL, and MCP. Use `IMcpSampling` / `IMcpElicitation` when you want MCP-specific behavior that only makes sense when an agent is connected. + +## When to use these + +Sampling and elicitation are most useful as **steps inside a larger workflow** — not as standalone commands. An agent can already summarize or classify on its own; the value is when your command orchestrates something the agent cannot: + +| Pattern | Sampling | Elicitation | +|---|---|---| +| Column mapping for a CSV import | LLM identifies which headers map to which fields | — | +| Ticket triage pipeline | LLM classifies the ticket | User confirms or adjusts the classification | +| Data import with duplicates | LLM fuzzy-matches incoming vs existing records | User decides how to handle conflicts | +| Dynamic deployment wizard | — | User picks environment, flags, and options at runtime | +| Content generation pipeline | LLM drafts a reply or summary | User reviews and approves before sending | + +## Sampling + +Sampling lets your command ask the connected LLM to generate text. The request goes from your MCP server to the agent client, which runs it through its language model and returns the result. + +### API + +```csharp +public interface IMcpSampling +{ + bool IsSupported { get; } + ValueTask SampleAsync(string prompt, int maxTokens = 1024, CancellationToken cancellationToken = default); +} +``` + +- `IsSupported` — `true` when the connected client declares sampling capability +- `SampleAsync` — returns the model's text response, or `null` if sampling is not supported +- `maxTokens` — caps the response length (default 1024) + +### Example: column mapping in a CSV import + +The app reads a CSV but doesn't know what the columns mean. The LLM identifies the mapping — something the app can't do alone: + +```csharp +app.Map("import {file}", + async (string file, ContactStore contacts, IMcpSampling sampling, + IReplInteractionChannel interaction, CancellationToken ct) => +{ + // Phase 1: read the file + await interaction.WriteProgressAsync("Reading CSV...", 0, ct); + var (headers, rawRows) = CsvParser.ReadRaw(file); + + // Phase 2: ask the LLM to map columns (sampling) + int nameCol = 0, emailCol = 1; + + if (sampling.IsSupported) + { + await interaction.WriteProgressAsync("Identifying columns...", 15, ct); + + var mapping = await sampling.SampleAsync( + "A CSV file has these column headers:\n" + + string.Join(", ", headers.Select((h, i) => $"[{i}] \"{h}\"")) + "\n\n" + + "Which column index contains the person's name and which contains " + + "their email address? Reply as: name=0 email=2", + maxTokens: 50, cancellationToken: ct); + + (nameCol, emailCol) = CsvParser.ParseColumnMapping(mapping, nameCol, emailCol); + } + + // Phase 3: import with resolved columns + await interaction.WriteProgressAsync("Importing...", 50, ct); + var rows = CsvParser.MapRows(rawRows, nameCol, emailCol); + var imported = contacts.Import(rows); + + return Results.Success($"Imported {imported} contacts."); +}) +.LongRunning().OpenWorld(); +``` + +The sampling step is **optional** — without it, the command falls back to positional columns. With it, the command handles arbitrary CSV formats automatically. + +### Other sampling patterns + +**Classify or triage** — ask the LLM to categorize data as a step before acting on it: + +```csharp +var category = await sampling.SampleAsync( + $"Classify as 'bug', 'feature', or 'question' (reply with the word only):\n\n{ticket.Body}", + maxTokens: 20, cancellationToken: ct); +``` + +**Extract structured data** — parse unstructured text into fields: + +```csharp +var extracted = await sampling.SampleAsync( + "Extract names and emails as JSON: [{\"name\": \"...\", \"email\": \"...\"}]\n\n" + emailBody, + maxTokens: 512, cancellationToken: ct); +``` + +**Draft content** — generate a starting point for human review: + +```csharp +var draft = await sampling.SampleAsync( + $"Draft a reply to this support ticket:\n\n{ticket.Body}", + maxTokens: 512, cancellationToken: ct); +``` + +In all cases, sampling is a **step** in a pipeline — the command does real work before and after. + +## Elicitation + +Elicitation asks the user for structured input through the agent's UI. The client renders a form with typed fields (text, boolean, enum) and returns the user's response. This is different from sampling — the _user_ answers, not the LLM. + +### API + +```csharp +public interface IMcpElicitation +{ + bool IsSupported { get; } + ValueTask ElicitTextAsync(string message, CancellationToken ct = default); + ValueTask ElicitBooleanAsync(string message, CancellationToken ct = default); + ValueTask ElicitChoiceAsync(string message, IReadOnlyList choices, CancellationToken ct = default); + ValueTask ElicitNumberAsync(string message, CancellationToken ct = default); +} +``` + +- `IsSupported` — `true` when the connected client declares elicitation capability +- Each method returns `null` when the client does not support elicitation or the user cancels +- The interface is dependency-agnostic — no MCP SDK types are exposed + +| Method | Renders as | Example | +|---|---|---| +| `ElicitTextAsync` | Text input | Free-text name, description | +| `ElicitBooleanAsync` | Checkbox / toggle | Confirm, enable/disable | +| `ElicitChoiceAsync` | Dropdown / radio group | Choose environment, priority | +| `ElicitNumberAsync` | Number input | Port, count, threshold | + +> **Multi-field elicitation:** The current API handles one field at a time, which covers most use cases. If you need to gather multiple values in a single form (e.g., environment + dry-run toggle + tag), please [open a feature request](https://github.com/yllibed/repl/issues) — this is a planned extension. + +### Example: conflict resolution during import + +After detecting duplicate contacts, the command asks the user how to handle them: + +```csharp +// ... earlier in the import command, after duplicate detection ... + +var duplicates = FindDuplicates(rows, existingContacts); + +if (duplicates.Count > 0 && elicitation.IsSupported) +{ + string[] strategies = ["skip", "overwrite", "keep-both"]; + + var choice = await elicitation.ElicitChoiceAsync( + $"{duplicates.Count} contact(s) may already exist. How should they be handled?", + strategies, + ct); + + if (choice is null) + return Results.Cancelled("Import cancelled during conflict resolution."); + + rows = ApplyStrategy(rows, duplicates, strategies[choice.Value]); +} + +// ... continue with the import ... +``` + +### Other elicitation patterns + +**Dynamic configuration** — ask the user for settings that depend on runtime context: + +```csharp +var envIndex = await elicitation.ElicitChoiceAsync( + $"Which environment for {service}?", + availableEnvironments, // built from runtime data + ct); +var env = envIndex is not null ? availableEnvironments[envIndex.Value] : "dev"; + +var dryRun = await elicitation.ElicitBooleanAsync("Dry run?", ct); +``` + +**Guided selection** — let the user choose when the code discovers multiple options: + +```csharp +var instances = registry.GetInstances(service); +if (instances.Count > 1 && elicitation.IsSupported) +{ + var hosts = instances.Select(i => i.Host).ToList(); + var selected = await elicitation.ElicitChoiceAsync( + $"Multiple {service} instances found. Which one?", + hosts, + ct); + + // ... connect to the chosen instance ... +} +``` + +## Combining both in a workflow + +The most powerful pattern uses sampling and elicitation as successive steps: the LLM analyzes data, the user confirms or adjusts, then the command acts. See [sample 08-mcp-server](../samples/08-mcp-server/) for a complete example that: + +1. Reads a CSV file +2. Uses **sampling** to identify which columns map to name and email +3. Detects duplicate contacts against the existing store +4. Uses **elicitation** to ask the user how to handle conflicts +5. Imports the resolved contacts with progress reporting + +Both steps are optional — the command works without them but produces better results when the agent supports them. + +## Graceful degradation + +Both interfaces return `null` when the client does not support the capability. Design commands so the capability is an **enhancement**, not a requirement: + +```csharp +// Best: optional enhancement — command works either way +int nameCol = 0, emailCol = 1; // sensible defaults +if (sampling.IsSupported) +{ + // ... LLM improves the result ... +} + +// Acceptable: error when the capability is essential to the command's purpose +if (!elicitation.IsSupported) + return Results.Error("needs-elicitation", "This command requires elicitation support."); +``` + +## Client compatibility + +Not all MCP clients support sampling and elicitation. The table below lists agents with **confirmed support** — agents not listed either do not support these capabilities or have not been validated. + +| Agent | Sampling | Elicitation | Source | Validated | +|---|---|---|---|---| +| VS Code Copilot (Chat) | Yes | Yes | [MCP spec support](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) | 2026-04 | + +Check [mcp-availability.com](https://mcp-availability.com/) for the latest data. Support is expanding rapidly — design your commands to degrade gracefully so they work everywhere even when a capability is missing. + +## IMcpSampling vs IReplInteractionChannel + +| | `IMcpSampling` / `IMcpElicitation` | `IReplInteractionChannel` | +|---|---|---| +| **Purpose** | Direct MCP capability access for data processing and user input | Portable user interaction (prompts, confirmations) | +| **Works in CLI/REPL** | No (`IsSupported` = false) | Yes (renders console prompts) | +| **Works in MCP** | When client supports the capability | Always (with [degradation tiers](mcp-server.md#interaction-in-mcp-mode)) | +| **Who answers** | Sampling: the LLM. Elicitation: the user. | The user (or LLM as fallback in MCP) | +| **Use when** | The command needs AI processing or rich structured input as part of a workflow | You need a simple confirmation, choice, or text prompt that works everywhere | diff --git a/docs/mcp-server.md b/docs/mcp-server.md index 3417d0b..5fb10b8 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -6,6 +6,7 @@ See also: [sample 08-mcp-server](../samples/08-mcp-server/) for a working MCP se Related guides: +- [mcp-agent-capabilities.md](mcp-agent-capabilities.md) for direct sampling and elicitation from command handlers - [mcp-advanced.md](mcp-advanced.md) for roots, soft roots, dynamic tool patterns, and advanced MCP Apps patterns - [mcp-transports.md](mcp-transports.md) for custom transports and HTTP hosting - [mcp-internals.md](mcp-internals.md) for concepts and under-the-hood behavior @@ -290,6 +291,8 @@ app.UseMcpServer(o => o.InteractivityMode = InteractivityMode.PrefillThenElicita | `PrefillThenElicitation` | Prefill → elicitation → sampling → fail | | `PrefillThenSampling` | Prefill → sampling → fail | +> **Direct access:** Commands can also use sampling and elicitation directly via `IMcpSampling` and `IMcpElicitation` — for example, asking the LLM to classify data or asking the user for structured input as part of a longer workflow. See [mcp-agent-capabilities.md](mcp-agent-capabilities.md). + ## Progress and status `WriteProgressAsync` maps to MCP progress notifications — agents see real-time progress. `WriteStatusAsync` maps to MCP log messages (`level: info`). No changes needed in command handlers: diff --git a/samples/08-mcp-server/Program.cs b/samples/08-mcp-server/Program.cs index 36d4f84..b57a653 100644 --- a/samples/08-mcp-server/Program.cs +++ b/samples/08-mcp-server/Program.cs @@ -104,6 +104,91 @@ The email must be unique across all contacts. .WithDescription("Guide the agent through diagnosing a contact issue") .AsPrompt(); +// ── Import with agent capabilities ──────────────────────────────── +// A realistic workflow: import a CSV, use sampling to detect duplicates, +// use elicitation to let the user resolve conflicts, then commit. +// Sampling and elicitation are optional steps — the import works without them. + +app.Map("import {file}", + async (string file, ContactStore contacts, + IMcpSampling sampling, IMcpElicitation elicitation, + Repl.Interaction.IReplInteractionChannel interaction, CancellationToken ct) => + { + // ── Phase 1: Read the file ───────────────────────────────── + await interaction.WriteProgressAsync("Reading CSV...", 0, ct); + var (headers, rawRows) = ContactCsvParser.ReadRaw(file); + // ... validate file structure, reject malformed rows ... + + // ── Phase 2: Column mapping (sampling) ───────────────────── + // The CSV may have arbitrary column names ("Full Name", "E-Mail", + // "Nombre", etc.). The app knows it needs "name" and "email" but + // can't guess the mapping — ask the LLM to figure it out. + int nameCol = 0, emailCol = 1; // defaults for standard headers + + if (sampling.IsSupported) + { + await interaction.WriteProgressAsync("Identifying columns...", 15, ct); + + var sampleRows = string.Join("\n", rawRows.Take(3).Select( + row => string.Join(", ", row.Select((cell, i) => $"[{i}] \"{cell}\"")))); + + var mapping = await sampling.SampleAsync( + "A CSV file has these column headers:\n" + + string.Join(", ", headers.Select((h, i) => $"[{i}] \"{h}\"")) + "\n\n" + + "Here are the first rows of data:\n" + sampleRows + "\n\n" + + "Which column index contains the person's name and which contains " + + "their email address? Reply as two numbers, e.g.: name=0 email=2", + maxTokens: 50, + cancellationToken: ct); + + (nameCol, emailCol) = ContactCsvParser.ParseColumnMapping(mapping, nameCol, emailCol); + } + + var rows = ContactCsvParser.MapRows(rawRows, nameCol, emailCol); + + // ── Phase 3: Duplicate detection ─────────────────────────── + await interaction.WriteProgressAsync("Checking duplicates...", 40, ct); + var duplicates = ContactMatcher.FindCandidates(rows, contacts.All); + + // ── Phase 4: Conflict resolution (elicitation) ───────────── + // Some incoming contacts may match existing ones. + // Ask the user how to handle them through a structured form. + var remaining = duplicates; + + if (remaining.Count > 0 && elicitation.IsSupported) + { + string[] strategies = ["skip", "overwrite", "keep-both"]; + + var choice = await elicitation.ElicitChoiceAsync( + $"{remaining.Count} contact(s) may already exist. How should they be handled?", + strategies, + ct); + + if (choice is null) + { + return Results.Cancelled("Import cancelled during conflict resolution."); + } + + rows = ContactMatcher.ApplyStrategy(rows, remaining, strategies[choice.Value]); + } + + // ── Phase 4: Commit ──────────────────────────────────────── + await interaction.WriteProgressAsync("Importing...", 70, ct); + var imported = contacts.Import(rows); + await interaction.WriteProgressAsync("Done", 100, ct); + + return Results.Success($"Imported {imported} of {rows.Count} contacts."); + }) + .WithDescription("Import contacts from CSV with smart column mapping and conflict resolution") + .WithDetails(""" + Imports contacts from a CSV file. When the connected agent supports it: + - **Sampling** identifies which CSV columns map to name and email (handles arbitrary headers) + - **Elicitation** asks the user how to handle duplicate contacts + Both steps are optional — the import falls back to positional columns and skips conflict resolution. + """) + .LongRunning() + .OpenWorld(); + // ── Interactive-only commands ────────────────────────────────────── app.Map("clear", async (Repl.Interaction.IReplInteractionChannel interaction, CancellationToken ct) => @@ -122,7 +207,7 @@ internal sealed record Contact(string Name, string Email); internal sealed class ContactStore { - private readonly Contact[] _contacts = + private readonly List _contacts = [ new("Alice", "alice@example.com"), new("Bob", "bob@example.com"), @@ -130,7 +215,76 @@ internal sealed class ContactStore public IReadOnlyList All => _contacts; - public Contact? Get(int id) => id >= 1 && id <= _contacts.Length + public Contact? Get(int id) => id >= 1 && id <= _contacts.Count ? _contacts[id - 1] : null; + + public int Import(IReadOnlyList rows) + { + _contacts.AddRange(rows); + return rows.Count; + } +} + +// ── CSV parsing and duplicate detection stubs ───────────────────── +// In a real app these would be full implementations. + +internal static class ContactCsvParser +{ + public static (string[] Headers, string[][] Rows) ReadRaw(string file) => + // ... read CSV headers and raw rows ... + (["Full Name", "E-Mail Address", "Company"], + [["Charlie", "charlie@example.com", "Acme"], ["Alice", "alice@corp.com", "Corp"]]); + + public static (int NameCol, int EmailCol) ParseColumnMapping(string? llmResponse, int defaultName, int defaultEmail) + { + // ... parse "name=0 email=1" from LLM response, fall back to defaults ... + if (llmResponse is null) + { + return (defaultName, defaultEmail); + } + + var nameCol = defaultName; + var emailCol = defaultEmail; + foreach (var part in llmResponse.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + var kv = part.Split('='); + if (kv.Length == 2 && int.TryParse(kv[1], out var idx)) + { + if (kv[0].Contains("name", StringComparison.OrdinalIgnoreCase)) + { + nameCol = idx; + } + else if (kv[0].Contains("email", StringComparison.OrdinalIgnoreCase)) + { + emailCol = idx; + } + } + } + + return (nameCol, emailCol); + } + + public static List MapRows(string[][] rawRows, int nameCol, int emailCol) => + // ... map raw rows to Contact records using the resolved column indices ... + rawRows.Select(row => new Contact(row[nameCol], row[emailCol])).ToList(); +} + +internal sealed record DuplicatePair(Contact Incoming, Contact Existing); + +internal static class ContactMatcher +{ + public static IReadOnlyList FindCandidates( + IReadOnlyList incoming, IReadOnlyList existing) => + // ... fuzzy name/email matching to find potential duplicates ... + incoming + .SelectMany(i => existing.Where(e => + e.Name.Equals(i.Name, StringComparison.OrdinalIgnoreCase) && e.Email != i.Email) + .Select(e => new DuplicatePair(i, e))) + .ToList(); + + public static List ApplyStrategy( + List rows, IReadOnlyList unresolved, string strategy) => + // ... apply user's chosen strategy (skip, overwrite, keep-both) ... + rows; } diff --git a/src/Repl.Core/Documentation/DocumentationEngine.cs b/src/Repl.Core/Documentation/DocumentationEngine.cs index 4ffcf36..4598abf 100644 --- a/src/Repl.Core/Documentation/DocumentationEngine.cs +++ b/src/Repl.Core/Documentation/DocumentationEngine.cs @@ -306,7 +306,9 @@ private static bool IsFrameworkInjectedParameter(Type parameterType) => || parameterType == typeof(IReplInteractionChannel) || parameterType == typeof(IReplIoContext) || parameterType == typeof(IReplKeyReader) - || string.Equals(parameterType.FullName, "Repl.Mcp.IMcpClientRoots", StringComparison.Ordinal); + || string.Equals(parameterType.FullName, "Repl.Mcp.IMcpClientRoots", StringComparison.Ordinal) + || string.Equals(parameterType.FullName, "Repl.Mcp.IMcpSampling", StringComparison.Ordinal) + || string.Equals(parameterType.FullName, "Repl.Mcp.IMcpElicitation", StringComparison.Ordinal); private static bool IsRequiredParameter(ParameterInfo parameter) { diff --git a/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs b/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs index 26f47ed..672ba11 100644 --- a/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs +++ b/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs @@ -198,7 +198,9 @@ private static bool IsFrameworkInjectedParameter(ParameterInfo parameter) => || parameter.ParameterType == typeof(IReplInteractionChannel) || parameter.ParameterType == typeof(IReplIoContext) || parameter.ParameterType == typeof(IReplKeyReader) - || string.Equals(parameter.ParameterType.FullName, "Repl.Mcp.IMcpClientRoots", StringComparison.Ordinal); + || string.Equals(parameter.ParameterType.FullName, "Repl.Mcp.IMcpClientRoots", StringComparison.Ordinal) + || string.Equals(parameter.ParameterType.FullName, "Repl.Mcp.IMcpSampling", StringComparison.Ordinal) + || string.Equals(parameter.ParameterType.FullName, "Repl.Mcp.IMcpElicitation", StringComparison.Ordinal); private static ReplArity ResolveArity(ParameterInfo parameter, ReplOptionAttribute? optionAttribute) { diff --git a/src/Repl.Mcp/IMcpElicitation.cs b/src/Repl.Mcp/IMcpElicitation.cs new file mode 100644 index 0000000..4bc7d90 --- /dev/null +++ b/src/Repl.Mcp/IMcpElicitation.cs @@ -0,0 +1,57 @@ +namespace Repl.Mcp; + +/// +/// Provides direct access to MCP elicitation (structured user input) from the connected client. +/// Inject this interface into command handlers to request user input outside +/// the abstraction. +/// +/// +/// Each method maps to a single-field MCP elicitation request. Returns null when the +/// client does not support elicitation or the user cancels the request. +/// +/// Future: Multi-field elicitation (e.g., a form with several inputs at once) may be +/// added via a builder pattern or a richer interface. If you need this capability, please open +/// a feature request on the Repl repository. +/// +/// +public interface IMcpElicitation +{ + /// + /// Gets a value indicating whether the connected MCP client supports elicitation. + /// + bool IsSupported { get; } + + /// + /// Asks the user for a free-text value. + /// Returns null when elicitation is not supported or the user cancels. + /// + /// The prompt message shown to the user. + /// Cancellation token. + ValueTask ElicitTextAsync(string message, CancellationToken cancellationToken = default); + + /// + /// Asks the user for a boolean (yes/no) value. + /// Returns null when elicitation is not supported or the user cancels. + /// + /// The prompt message shown to the user. + /// Cancellation token. + ValueTask ElicitBooleanAsync(string message, CancellationToken cancellationToken = default); + + /// + /// Asks the user to pick one value from a list of choices. + /// Returns the zero-based index of the selected choice, or null when elicitation + /// is not supported, the user cancels, or the response does not match any choice. + /// + /// The prompt message shown to the user. + /// The available options. + /// Cancellation token. + ValueTask ElicitChoiceAsync(string message, IReadOnlyList choices, CancellationToken cancellationToken = default); + + /// + /// Asks the user for a numeric value. + /// Returns null when elicitation is not supported or the user cancels. + /// + /// The prompt message shown to the user. + /// Cancellation token. + ValueTask ElicitNumberAsync(string message, CancellationToken cancellationToken = default); +} diff --git a/src/Repl.Mcp/IMcpSampling.cs b/src/Repl.Mcp/IMcpSampling.cs new file mode 100644 index 0000000..01c8238 --- /dev/null +++ b/src/Repl.Mcp/IMcpSampling.cs @@ -0,0 +1,26 @@ +namespace Repl.Mcp; + +/// +/// Provides direct access to MCP sampling (LLM completions) from the connected client. +/// Inject this interface into command handlers to request completions outside +/// the abstraction. +/// +public interface IMcpSampling +{ + /// + /// Gets a value indicating whether the connected MCP client supports sampling. + /// + bool IsSupported { get; } + + /// + /// Requests an LLM completion from the connected agent client. + /// Returns null when the client does not support sampling. + /// + /// The user prompt to send. + /// Maximum tokens for the response (default 1024). + /// Cancellation token. + ValueTask SampleAsync( + string prompt, + int maxTokens = 1024, + CancellationToken cancellationToken = default); +} diff --git a/src/Repl.Mcp/McpElicitationService.cs b/src/Repl.Mcp/McpElicitationService.cs new file mode 100644 index 0000000..48b57f6 --- /dev/null +++ b/src/Repl.Mcp/McpElicitationService.cs @@ -0,0 +1,126 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Repl.Mcp; + +/// +/// Internal implementation of backed by a live session. +/// Each public method maps to a single-field MCP elicitation request. +/// +/// +/// Future: multi-field elicitation (Option B) would add a builder-based overload or a richer +/// ElicitAsync method that accepts multiple fields. The internal helper +/// is already structured to support that evolution — +/// a multi-field variant would build the with +/// multiple properties instead of one. +/// +internal sealed class McpElicitationService : IMcpElicitation +{ + private const string FieldName = "value"; + + private McpServer? _server; + + public bool IsSupported => _server?.ClientCapabilities?.Elicitation is not null; + + public async ValueTask ElicitTextAsync( + string message, + CancellationToken cancellationToken = default) + { + var result = await ElicitSingleFieldAsync( + message, + new ElicitRequestParams.StringSchema(), + cancellationToken).ConfigureAwait(false); + + return result?.Content?[FieldName].GetString(); + } + + public async ValueTask ElicitBooleanAsync( + string message, + CancellationToken cancellationToken = default) + { + var result = await ElicitSingleFieldAsync( + message, + new ElicitRequestParams.BooleanSchema(), + cancellationToken).ConfigureAwait(false); + + return result?.Content?[FieldName].GetBoolean(); + } + + public async ValueTask ElicitChoiceAsync( + string message, + IReadOnlyList choices, + CancellationToken cancellationToken = default) + { + var result = await ElicitSingleFieldAsync( + message, + new ElicitRequestParams.UntitledSingleSelectEnumSchema + { + Enum = choices.ToList(), + }, + cancellationToken).ConfigureAwait(false); + + var selected = result?.Content?[FieldName].GetString(); + if (selected is null) + { + return null; + } + + var index = -1; + for (var i = 0; i < choices.Count; i++) + { + if (string.Equals(choices[i], selected, StringComparison.Ordinal)) + { + index = i; + break; + } + } + + return index >= 0 ? index : null; + } + + public async ValueTask ElicitNumberAsync( + string message, + CancellationToken cancellationToken = default) + { + var result = await ElicitSingleFieldAsync( + message, + new ElicitRequestParams.NumberSchema(), + cancellationToken).ConfigureAwait(false); + + return result?.Content?[FieldName].GetDouble(); + } + + internal void AttachServer(McpServer server) => _server = server; + + private async ValueTask ElicitSingleFieldAsync( + string message, + ElicitRequestParams.PrimitiveSchemaDefinition schema, + CancellationToken cancellationToken) + { + if (!IsSupported) + { + return null; + } + + var result = await _server!.ElicitAsync( + new ElicitRequestParams + { + Message = message, + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary(StringComparer.Ordinal) + { + [FieldName] = schema, + }, + }, + }, + cancellationToken).ConfigureAwait(false); + + if (result is not { IsAccepted: true }) + { + return null; + } + + return result; + } +} diff --git a/src/Repl.Mcp/McpSamplingService.cs b/src/Repl.Mcp/McpSamplingService.cs new file mode 100644 index 0000000..5e6a09e --- /dev/null +++ b/src/Repl.Mcp/McpSamplingService.cs @@ -0,0 +1,44 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Repl.Mcp; + +/// +/// Internal implementation of backed by a live session. +/// +internal sealed class McpSamplingService : IMcpSampling +{ + private McpServer? _server; + + public bool IsSupported => _server?.ClientCapabilities?.Sampling is not null; + + public async ValueTask SampleAsync( + string prompt, + int maxTokens = 1024, + CancellationToken cancellationToken = default) + { + if (!IsSupported) + { + return null; + } + + var result = await _server!.SampleAsync( + new CreateMessageRequestParams + { + Messages = + [ + new SamplingMessage + { + Role = Role.User, + Content = [new TextContentBlock { Text = prompt }], + }, + ], + MaxTokens = maxTokens, + }, + cancellationToken).ConfigureAwait(false); + + return result.Content?.OfType().FirstOrDefault()?.Text; + } + + internal void AttachServer(McpServer server) => _server = server; +} diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index 0cdd6c7..ce85ac5 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -26,6 +26,8 @@ internal sealed class McpServerHandler private readonly TimeProvider _timeProvider; private readonly char _separator; private readonly McpClientRootsService _roots; + private readonly McpSamplingService _sampling; + private readonly McpElicitationService _elicitation; private readonly IServiceProvider _sessionServices; private readonly SemaphoreSlim _snapshotGate = new(initialCount: 1, maxCount: 1); private readonly Lock _refreshLock = new(); @@ -52,11 +54,15 @@ public McpServerHandler( _timeProvider = services.GetService(typeof(TimeProvider)) as TimeProvider ?? TimeProvider.System; _separator = McpToolNameFlattener.ResolveSeparator(options.ToolNamingSeparator); _roots = new McpClientRootsService(app); + _sampling = new McpSamplingService(); + _elicitation = new McpElicitationService(); _sessionServices = new McpServiceProviderOverlay( services, new Dictionary { [typeof(IMcpClientRoots)] = _roots, + [typeof(IMcpSampling)] = _sampling, + [typeof(IMcpElicitation)] = _elicitation, }); } @@ -410,6 +416,8 @@ private void AttachServer(McpServer? server) _server = server; _roots.AttachServer(server); + _sampling.AttachServer(server); + _elicitation.AttachServer(server); EnsureRoutingSubscription(); EnsureRootsNotificationHandler(server); } diff --git a/src/Repl.McpTests/Given_McpAgentCapabilities.cs b/src/Repl.McpTests/Given_McpAgentCapabilities.cs new file mode 100644 index 0000000..c8bc80a --- /dev/null +++ b/src/Repl.McpTests/Given_McpAgentCapabilities.cs @@ -0,0 +1,307 @@ +using System.Globalization; +using System.Text.Json; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using Repl.Mcp; + +namespace Repl.McpTests; + +[TestClass] +public sealed class Given_McpAgentCapabilities +{ + // ── Sampling: round-trip ─────────────────────────────────────────── + + [TestMethod] + [Description("When the client supports sampling, SampleAsync returns the LLM response text.")] + public async Task When_ClientSupportsSampling_Then_SampleAsyncReturnsText() + { + var clientOptions = new McpClientOptions + { + Capabilities = new ClientCapabilities + { + Sampling = new SamplingCapability(), + }, + Handlers = new McpClientHandlers + { + SamplingHandler = static (request, _, _) => ValueTask.FromResult(new CreateMessageResult + { + Content = [new TextContentBlock { Text = "name=0 email=2" }], + Model = "test-model", + }), + }, + }; + + await using var fixture = await McpTestFixture.CreateAsync( + app => app.MapModule(new SamplingModule()), + configureOptions: null, + clientOptions: clientOptions); + + var tools = await fixture.Client.ListToolsAsync().ConfigureAwait(false); + tools.Should().ContainSingle(t => string.Equals(t.Name, "sampling_test", StringComparison.Ordinal)); + + var result = await fixture.Client.CallToolAsync( + toolName: "sampling_test", + arguments: new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + + var text = result.Content.OfType().First().Text; + text.Should().Contain("name=0 email=2"); + } + + // ── Sampling: not supported ─────────────────────────────────────── + + [TestMethod] + [Description("When the client does not support sampling, SampleAsync returns null.")] + public async Task When_ClientDoesNotSupportSampling_Then_SampleAsyncReturnsNull() + { + await using var fixture = await McpTestFixture.CreateAsync( + app => app.MapModule(new SamplingModule())); + + var result = await fixture.Client.CallToolAsync( + toolName: "sampling_test", + arguments: new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + + var text = result.Content.OfType().First().Text; + text.Should().Contain("not-supported"); + } + + // ── Sampling: parameter excluded from schema ────────────────────── + + [TestMethod] + [Description("IMcpSampling parameter does not appear in the tool's JSON schema.")] + public async Task When_ToolInjectsSampling_Then_SchemaHasNoSamplingParameter() + { + await using var fixture = await McpTestFixture.CreateAsync( + app => app.MapModule(new SamplingModule())); + + var tools = await fixture.Client.ListToolsAsync().ConfigureAwait(false); + var tool = tools.Single(t => string.Equals(t.Name, "sampling_test", StringComparison.Ordinal)); + var schema = tool.JsonSchema.GetRawText(); + schema.Should().NotContain("sampling", because: "IMcpSampling is a framework-injected parameter and must not appear in the tool schema"); + } + + // ── Elicitation: text round-trip ────────────────────────────────── + + [TestMethod] + [Description("ElicitTextAsync returns the user's text response.")] + public async Task When_ClientSupportsElicitation_Then_ElicitTextAsyncReturnsText() + { + var clientOptions = CreateElicitationClientOptions((request, _) => + { + return ValueTask.FromResult(new ElicitResult + { + Action = "accept", + Content = new Dictionary(StringComparer.Ordinal) + { + ["value"] = JsonSerializer.SerializeToElement("user-input-text"), + }, + }); + }); + + await using var fixture = await McpTestFixture.CreateAsync( + app => app.MapModule(new ElicitTextModule()), + configureOptions: null, + clientOptions: clientOptions); + + var result = await fixture.Client.CallToolAsync( + toolName: "elicit_text_test", + arguments: new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + + var text = result.Content.OfType().First().Text; + text.Should().Contain("user-input-text"); + } + + // ── Elicitation: choice round-trip ──────────────────────────────── + + [TestMethod] + [Description("ElicitChoiceAsync returns the zero-based index of the selected choice.")] + public async Task When_ClientSupportsElicitation_Then_ElicitChoiceAsyncReturnsIndex() + { + var clientOptions = CreateElicitationClientOptions((request, _) => + { + return ValueTask.FromResult(new ElicitResult + { + Action = "accept", + Content = new Dictionary(StringComparer.Ordinal) + { + ["value"] = JsonSerializer.SerializeToElement("overwrite"), + }, + }); + }); + + await using var fixture = await McpTestFixture.CreateAsync( + app => app.MapModule(new ElicitChoiceModule()), + configureOptions: null, + clientOptions: clientOptions); + + var result = await fixture.Client.CallToolAsync( + toolName: "elicit_choice_test", + arguments: new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + + var text = result.Content.OfType().First().Text; + text.Should().Contain("1"); // "overwrite" is at index 1 in ["skip", "overwrite", "keep-both"] + } + + // ── Elicitation: boolean round-trip ─────────────────────────────── + + [TestMethod] + [Description("ElicitBooleanAsync returns the user's boolean response.")] + public async Task When_ClientSupportsElicitation_Then_ElicitBooleanAsyncReturnsBool() + { + var clientOptions = CreateElicitationClientOptions((request, _) => + { + return ValueTask.FromResult(new ElicitResult + { + Action = "accept", + Content = new Dictionary(StringComparer.Ordinal) + { + ["value"] = JsonSerializer.SerializeToElement(value: true), + }, + }); + }); + + await using var fixture = await McpTestFixture.CreateAsync( + app => app.MapModule(new ElicitBooleanModule()), + configureOptions: null, + clientOptions: clientOptions); + + var result = await fixture.Client.CallToolAsync( + toolName: "elicit_boolean_test", + arguments: new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + + var text = result.Content.OfType().First().Text; + text.Should().Contain("True"); + } + + // ── Elicitation: not supported ──────────────────────────────────── + + [TestMethod] + [Description("When the client does not support elicitation, all Elicit methods return null.")] + public async Task When_ClientDoesNotSupportElicitation_Then_ElicitReturnsNull() + { + await using var fixture = await McpTestFixture.CreateAsync( + app => app.MapModule(new ElicitTextModule())); + + var result = await fixture.Client.CallToolAsync( + toolName: "elicit_text_test", + arguments: new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + + var text = result.Content.OfType().First().Text; + text.Should().Contain("not-supported"); + } + + // ── Elicitation: user cancels ───────────────────────────────────── + + [TestMethod] + [Description("When the user declines elicitation, the method returns null.")] + public async Task When_UserCancelsElicitation_Then_ElicitReturnsNull() + { + var clientOptions = CreateElicitationClientOptions((_, _) => + { + return ValueTask.FromResult(new ElicitResult { Action = "decline" }); + }); + + await using var fixture = await McpTestFixture.CreateAsync( + app => app.MapModule(new ElicitTextModule()), + configureOptions: null, + clientOptions: clientOptions); + + var result = await fixture.Client.CallToolAsync( + toolName: "elicit_text_test", + arguments: new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + + var text = result.Content.OfType().First().Text; + text.Should().Contain("not-supported"); + } + + // ── Elicitation: parameter excluded from schema ─────────────────── + + [TestMethod] + [Description("IMcpElicitation parameter does not appear in the tool's JSON schema.")] + public async Task When_ToolInjectsElicitation_Then_SchemaHasNoElicitationParameter() + { + await using var fixture = await McpTestFixture.CreateAsync( + app => app.MapModule(new ElicitTextModule())); + + var tools = await fixture.Client.ListToolsAsync().ConfigureAwait(false); + var tool = tools.Single(t => string.Equals(t.Name, "elicit_text_test", StringComparison.Ordinal)); + var schema = tool.JsonSchema.GetRawText(); + schema.Should().NotContain("elicitation", because: "IMcpElicitation is a framework-injected parameter and must not appear in the tool schema"); + } + + // ── Helpers ─────────────────────────────────────────────────────── + + private static McpClientOptions CreateElicitationClientOptions( + Func> handler) => + new() + { + Capabilities = new ClientCapabilities + { + Elicitation = new ElicitationCapability(), + }, + Handlers = new McpClientHandlers + { + ElicitationHandler = handler, + }, + }; + + // ── Test modules ────────────────────────────────────────────────── + + private sealed class SamplingModule : IReplModule + { + public void Map(IReplMap app) + { + app.Map( + "sampling test", + async (IMcpSampling sampling, CancellationToken ct) => + { + var result = await sampling.SampleAsync("identify columns", cancellationToken: ct).ConfigureAwait(false); + return result ?? "not-supported"; + }).ReadOnly(); + } + } + + private sealed class ElicitTextModule : IReplModule + { + public void Map(IReplMap app) + { + app.Map( + "elicit text test", + async (IMcpElicitation elicitation, CancellationToken ct) => + { + var result = await elicitation.ElicitTextAsync("Enter a value:", ct).ConfigureAwait(false); + return result ?? "not-supported"; + }).ReadOnly(); + } + } + + private sealed class ElicitChoiceModule : IReplModule + { + public void Map(IReplMap app) + { + app.Map( + "elicit choice test", + async (IMcpElicitation elicitation, CancellationToken ct) => + { + var result = await elicitation.ElicitChoiceAsync( + "How to handle duplicates?", + ["skip", "overwrite", "keep-both"], + ct).ConfigureAwait(false); + return result?.ToString(CultureInfo.InvariantCulture) ?? "not-supported"; + }).ReadOnly(); + } + } + + private sealed class ElicitBooleanModule : IReplModule + { + public void Map(IReplMap app) + { + app.Map( + "elicit boolean test", + async (IMcpElicitation elicitation, CancellationToken ct) => + { + var result = await elicitation.ElicitBooleanAsync("Confirm?", ct).ConfigureAwait(false); + return result?.ToString() ?? "not-supported"; + }).ReadOnly(); + } + } +} From c58eec1d54a0504eda4c667531a69eed04b91638 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 14 Apr 2026 23:23:14 -0400 Subject: [PATCH 02/16] Add REPL-aware logging context infrastructure --- src/Directory.Packages.props | 1 + src/Repl.Core/CoreReplApp.Execution.cs | 2 + src/Repl.Core/InternalsVisibleTo.cs | 1 + src/Repl.Core/Session/ReplSessionIO.cs | 31 +++ src/Repl.Defaults/Repl.Defaults.csproj | 1 + src/Repl.Defaults/ReplApp.cs | 2 + .../ReplServiceCollectionExtensions.cs | 6 + .../Given_ProtocolPassthrough.cs | 33 ++- src/Repl.Logging/IReplLogContextAccessor.cs | 12 + .../LiveReplLogContextAccessor.cs | 17 ++ src/Repl.Logging/README.md | 15 ++ src/Repl.Logging/Repl.Logging.csproj | 22 ++ src/Repl.Logging/ReplLogContext.cs | 16 ++ src/Repl.Logging/ReplLoggingMiddleware.cs | 48 ++++ .../ReplLoggingServiceCollectionExtensions.cs | 22 ++ src/Repl.Tests/Given_ReplLogging.cs | 218 ++++++++++++++++++ src/Repl.slnx | 1 + 17 files changed, 446 insertions(+), 2 deletions(-) create mode 100644 src/Repl.Logging/IReplLogContextAccessor.cs create mode 100644 src/Repl.Logging/LiveReplLogContextAccessor.cs create mode 100644 src/Repl.Logging/README.md create mode 100644 src/Repl.Logging/Repl.Logging.csproj create mode 100644 src/Repl.Logging/ReplLogContext.cs create mode 100644 src/Repl.Logging/ReplLoggingMiddleware.cs create mode 100644 src/Repl.Logging/ReplLoggingServiceCollectionExtensions.cs create mode 100644 src/Repl.Tests/Given_ReplLogging.cs diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index e77b507..8a9783c 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -13,6 +13,7 @@ + diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index 6f302a6..20c46a9 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -244,6 +244,8 @@ private async ValueTask ExecuteProtocolPassthroughCommandAsync( IServiceProvider serviceProvider, CancellationToken cancellationToken) { + using var protocolPassthroughScope = ReplSessionIO.PushProtocolPassthrough(); + if (ReplSessionIO.IsSessionActive) { var (exitCode, _) = await ExecuteMatchedCommandAsync( diff --git a/src/Repl.Core/InternalsVisibleTo.cs b/src/Repl.Core/InternalsVisibleTo.cs index 5d86c12..92e02d1 100644 --- a/src/Repl.Core/InternalsVisibleTo.cs +++ b/src/Repl.Core/InternalsVisibleTo.cs @@ -3,6 +3,7 @@ [assembly: InternalsVisibleTo("Repl.Tests")] [assembly: InternalsVisibleTo("Repl.IntegrationTests")] [assembly: InternalsVisibleTo("Repl.Defaults")] +[assembly: InternalsVisibleTo("Repl.Logging")] [assembly: InternalsVisibleTo("Repl.Testing")] [assembly: InternalsVisibleTo("Repl.Spectre")] [assembly: InternalsVisibleTo("Repl.Mcp")] diff --git a/src/Repl.Core/Session/ReplSessionIO.cs b/src/Repl.Core/Session/ReplSessionIO.cs index be2dd74..d441c52 100644 --- a/src/Repl.Core/Session/ReplSessionIO.cs +++ b/src/Repl.Core/Session/ReplSessionIO.cs @@ -28,6 +28,7 @@ internal readonly record struct SessionMetadata( private static readonly AsyncLocal s_keyReader = new(); private static readonly AsyncLocal s_isHostedSession = new(); private static readonly AsyncLocal s_isProgrammatic = new(); + private static readonly AsyncLocal s_isProtocolPassthrough = new(); private static readonly AsyncLocal s_sessionId = new(); private static readonly ConcurrentDictionary s_sessions = new(StringComparer.Ordinal); @@ -74,6 +75,12 @@ internal static bool IsProgrammatic set => s_isProgrammatic.Value = value; } + internal static bool IsProtocolPassthrough + { + get => s_isProtocolPassthrough.Value; + set => s_isProtocolPassthrough.Value = value; + } + /// /// Gets the current hosted session identifier, when available. /// @@ -302,6 +309,13 @@ internal static void UpdateSession(string sessionId, Func s_sessions.TryGetValue(sessionId, out session); @@ -367,4 +381,21 @@ public void Dispose() } } } + + private sealed class ProtocolPassthroughScope(bool previousValue) : IDisposable + { + private readonly bool _previousValue = previousValue; + private bool _disposed; + + public void Dispose() + { + if (_disposed) + { + return; + } + + s_isProtocolPassthrough.Value = _previousValue; + _disposed = true; + } + } } diff --git a/src/Repl.Defaults/Repl.Defaults.csproj b/src/Repl.Defaults/Repl.Defaults.csproj index 5532b92..c1106b4 100644 --- a/src/Repl.Defaults/Repl.Defaults.csproj +++ b/src/Repl.Defaults/Repl.Defaults.csproj @@ -13,6 +13,7 @@ + diff --git a/src/Repl.Defaults/ReplApp.cs b/src/Repl.Defaults/ReplApp.cs index 9d644f1..dd64df3 100644 --- a/src/Repl.Defaults/ReplApp.cs +++ b/src/Repl.Defaults/ReplApp.cs @@ -28,6 +28,7 @@ private ReplApp(IServiceCollection services) _services = services; _core = CoreReplApp.Create(); EnsureDefaultServices(_services, _core); + _core.Use(ReplLoggingMiddleware.InvokeAsync); } /// @@ -667,6 +668,7 @@ private sealed class SessionOverlayServiceProvider( private static void EnsureDefaultServices(IServiceCollection services, CoreReplApp core) { + services.AddReplLogging(); services.TryAddSingleton(core); services.TryAddSingleton(core); services.TryAddSingleton(); diff --git a/src/Repl.Defaults/ReplServiceCollectionExtensions.cs b/src/Repl.Defaults/ReplServiceCollectionExtensions.cs index fd90f6f..34c9a9c 100644 --- a/src/Repl.Defaults/ReplServiceCollectionExtensions.cs +++ b/src/Repl.Defaults/ReplServiceCollectionExtensions.cs @@ -24,6 +24,8 @@ public static IServiceCollection AddRepl( ArgumentNullException.ThrowIfNull(configureReplServices); ArgumentNullException.ThrowIfNull(configure); + services.AddReplLogging(); + var app = ReplApp.Create(configureReplServices); configure(app); services.TryAddSingleton(app); @@ -43,6 +45,8 @@ public static IServiceCollection AddRepl( ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(configure); + services.AddReplLogging(); + var app = ReplApp.Create(); configure(app); services.TryAddSingleton(app); @@ -64,6 +68,8 @@ public static IServiceCollection AddRepl( ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(configure); + services.AddReplLogging(); + services.TryAddSingleton(sp => { var app = ReplApp.Create(); diff --git a/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs b/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs index 8f3f4fa..f96ddba 100644 --- a/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs +++ b/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs @@ -1,8 +1,10 @@ +using Microsoft.Extensions.Logging; + namespace Repl.IntegrationTests; [TestClass] [DoNotParallelize] -public sealed class Given_ProtocolPassthrough +public sealed partial class Given_ProtocolPassthrough { [TestMethod] [Description("Regression guard: verifies protocol passthrough suppresses banners so stdout only contains handler protocol output.")] @@ -72,6 +74,27 @@ public void When_ProtocolPassthroughHandlerUsesIoContext_Then_OutputIsWrittenToS output.StdErr.Should().BeNullOrWhiteSpace(); } + [TestMethod] + [Description("Regression guard: verifies default ILogger usage stays silent during protocol passthrough unless the app opts into a provider.")] + public void When_ProtocolPassthroughUsesDefaultLogging_Then_LogsStayOffStdOutAndStdErr() + { + var sut = ReplApp.Create(); + sut.Map( + "mcp log", + (ILogger logger) => + { + LogMessages.TransportDiagnostic(logger); + return Results.Exit(0); + }) + .AsProtocolPassthrough(); + + var output = ConsoleCaptureHelper.CaptureStdOutAndErr(() => sut.Run(["mcp", "log", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.StdOut.Should().BeNullOrWhiteSpace(); + output.StdErr.Should().BeNullOrWhiteSpace(); + } + [TestMethod] [Description("Regression guard: verifies protocol passthrough routes repl diagnostics to stderr while keeping stdout clean.")] public void When_ProtocolPassthroughHandlerFails_Then_ReplDiagnosticsAreWrittenToStderr() @@ -334,7 +357,13 @@ public void Map(IReplMap map) }) .AsProtocolPassthrough(); }, - () => Results.Validation("context gate failed")); + () => Results.Validation("context gate failed")); } } + + private static partial class LogMessages + { + [LoggerMessage(Level = LogLevel.Information, Message = "transport diagnostic")] + public static partial void TransportDiagnostic(ILogger logger); + } } diff --git a/src/Repl.Logging/IReplLogContextAccessor.cs b/src/Repl.Logging/IReplLogContextAccessor.cs new file mode 100644 index 0000000..97d02b1 --- /dev/null +++ b/src/Repl.Logging/IReplLogContextAccessor.cs @@ -0,0 +1,12 @@ +namespace Repl; + +/// +/// Provides access to the ambient REPL logging context for the current execution. +/// +public interface IReplLogContextAccessor +{ + /// + /// Gets the current REPL logging context snapshot. + /// + ReplLogContext Current { get; } +} diff --git a/src/Repl.Logging/LiveReplLogContextAccessor.cs b/src/Repl.Logging/LiveReplLogContextAccessor.cs new file mode 100644 index 0000000..606115c --- /dev/null +++ b/src/Repl.Logging/LiveReplLogContextAccessor.cs @@ -0,0 +1,17 @@ +namespace Repl; + +internal sealed class LiveReplLogContextAccessor : IReplLogContextAccessor +{ + public ReplLogContext Current => + new( + SessionId: ReplSessionIO.CurrentSessionId, + IsSessionActive: ReplSessionIO.IsSessionActive, + IsHostedSession: ReplSessionIO.IsHostedSession, + IsProgrammatic: ReplSessionIO.IsProgrammatic, + IsProtocolPassthrough: ReplSessionIO.IsProtocolPassthrough, + TransportName: ReplSessionIO.TransportName, + RemotePeer: ReplSessionIO.RemotePeer, + TerminalIdentity: ReplSessionIO.TerminalIdentity, + Output: ReplSessionIO.Output, + Error: ReplSessionIO.Error); +} diff --git a/src/Repl.Logging/README.md b/src/Repl.Logging/README.md new file mode 100644 index 0000000..dfeda40 --- /dev/null +++ b/src/Repl.Logging/README.md @@ -0,0 +1,15 @@ +# Repl.Logging + +`Repl.Logging` provides ambient session-aware logging context for apps built with Repl Toolkit. + +It keeps `Microsoft.Extensions.Logging` as the logging contract and exposes Repl execution metadata so apps can enrich or route operator logs when needed. + +`AddReplLogging()` registers the minimal logging services needed for `ILogger` injection and adds REPL execution metadata through ambient context and scopes. + +What this package does: + +- keeps logging provider selection in application code +- does not add a visible logging sink by default +- does not treat logs as user-facing output + +Use `IReplInteractionChannel` for user-facing notices, warnings, and problem summaries. diff --git a/src/Repl.Logging/Repl.Logging.csproj b/src/Repl.Logging/Repl.Logging.csproj new file mode 100644 index 0000000..98b965e --- /dev/null +++ b/src/Repl.Logging/Repl.Logging.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + Logging infrastructure for Repl Toolkit with ambient session-aware context for Microsoft.Extensions.Logging. + README.md + + + + + + + + + + + + + + + + diff --git a/src/Repl.Logging/ReplLogContext.cs b/src/Repl.Logging/ReplLogContext.cs new file mode 100644 index 0000000..d168afb --- /dev/null +++ b/src/Repl.Logging/ReplLogContext.cs @@ -0,0 +1,16 @@ +namespace Repl; + +/// +/// Immutable snapshot of the current REPL logging context. +/// +public sealed record ReplLogContext( + string? SessionId, + bool IsSessionActive, + bool IsHostedSession, + bool IsProgrammatic, + bool IsProtocolPassthrough, + string? TransportName, + string? RemotePeer, + string? TerminalIdentity, + TextWriter Output, + TextWriter Error); diff --git a/src/Repl.Logging/ReplLoggingMiddleware.cs b/src/Repl.Logging/ReplLoggingMiddleware.cs new file mode 100644 index 0000000..a912d76 --- /dev/null +++ b/src/Repl.Logging/ReplLoggingMiddleware.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Repl; + +/// +/// Adds ambient REPL execution metadata to the active scope. +/// +public static class ReplLoggingMiddleware +{ + private const string LoggerCategory = "Repl.Execution"; + + /// + /// Wraps command execution in a logging scope populated from the current REPL context. + /// + public static async ValueTask InvokeAsync(ReplExecutionContext context, ReplNext next) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(next); + + var loggerFactory = context.Services.GetService(typeof(ILoggerFactory)) as ILoggerFactory + ?? NullLoggerFactory.Instance; + var accessor = context.Services.GetService(typeof(IReplLogContextAccessor)) as IReplLogContextAccessor; + + if (accessor is null) + { + await next().ConfigureAwait(false); + return; + } + + var logContext = accessor.Current; + var logger = loggerFactory.CreateLogger(LoggerCategory); + using var scope = logger.BeginScope(CreateScope(logContext)); + await next().ConfigureAwait(false); + } + + private static IReadOnlyList> CreateScope(ReplLogContext context) => + [ + new("ReplSessionId", context.SessionId), + new("ReplSessionActive", context.IsSessionActive), + new("ReplHostedSession", context.IsHostedSession), + new("ReplProgrammatic", context.IsProgrammatic), + new("ReplProtocolPassthrough", context.IsProtocolPassthrough), + new("ReplTransport", context.TransportName), + new("ReplRemotePeer", context.RemotePeer), + new("ReplTerminalIdentity", context.TerminalIdentity), + ]; +} diff --git a/src/Repl.Logging/ReplLoggingServiceCollectionExtensions.cs b/src/Repl.Logging/ReplLoggingServiceCollectionExtensions.cs new file mode 100644 index 0000000..07efa12 --- /dev/null +++ b/src/Repl.Logging/ReplLoggingServiceCollectionExtensions.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Repl; + +/// +/// Registers REPL-aware logging context services. +/// +public static class ReplLoggingServiceCollectionExtensions +{ + /// + /// Adds REPL logging context services to the container. + /// + public static IServiceCollection AddReplLogging(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddLogging(); + services.TryAddSingleton(); + return services; + } +} diff --git a/src/Repl.Tests/Given_ReplLogging.cs b/src/Repl.Tests/Given_ReplLogging.cs new file mode 100644 index 0000000..9b1b9a0 --- /dev/null +++ b/src/Repl.Tests/Given_ReplLogging.cs @@ -0,0 +1,218 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Repl.Tests; + +[TestClass] +public sealed partial class Given_ReplLogging +{ + [TestMethod] + [Description("Regression guard: verifies ReplApp registers Microsoft logging by default so ILogger can be injected without extra setup.")] + public void When_HandlerRequestsLogger_Then_DefaultAppCanResolveILogger() + { + var app = ReplApp.Create(); + app.Map("ping", (ILogger logger) => + { + LogMessages.PingHandled(logger); + return "pong"; + }); + + using var input = new StringReader(string.Empty); + using var output = new StringWriter(); + var host = new InMemoryHost(input, output); + + var exitCode = app.Run( + ["ping", "--no-logo"], + host, + new ReplRunOptions { HostedServiceLifecycle = HostedServiceLifecycleMode.None }); + + exitCode.Should().Be(0); + output.ToString().Should().Contain("pong"); + } + + [TestMethod] + [Description("Regression guard: verifies default logging stays silent until the app opts into a provider.")] + public void When_DefaultAppLogsWithoutProvider_Then_NoUserFacingLogOutputIsProduced() + { + var app = ReplApp.Create(); + app.Map("status", (ILogger logger) => + { + LogMessages.StatusRequested(logger); + return Results.Exit(0); + }); + + using var input = new StringReader(string.Empty); + using var output = new StringWriter(); + var host = new InMemoryHost(input, output); + + var exitCode = app.Run( + ["status", "--no-logo"], + host, + new ReplRunOptions { HostedServiceLifecycle = HostedServiceLifecycleMode.None }); + + exitCode.Should().Be(0); + output.ToString().Should().BeNullOrWhiteSpace(); + } + + [TestMethod] + [Description("Regression guard: verifies Repl logging middleware opens a structured scope so session metadata is available to providers.")] + public void When_HandlerLogsThroughILogger_Then_ReplScopeMetadataIsCaptured() + { + var provider = new CapturingLoggerProvider(); + var app = ReplApp.Create(services => + { + services.AddSingleton(provider); + services.AddLogging(builder => + { + builder.ClearProviders(); + builder.AddProvider(provider); + }); + }); + + app.Map("status", (ILogger logger) => + { + LogMessages.StatusRequested(logger); + return Results.Exit(0); + }); + + using var input = new StringReader(string.Empty); + using var output = new StringWriter(); + var host = new InMemoryHost(input, output); + + var exitCode = app.Run( + ["status", "--no-logo"], + host, + new ReplRunOptions { HostedServiceLifecycle = HostedServiceLifecycleMode.None }); + + exitCode.Should().Be(0); + provider.Entries.Should().ContainSingle(); + var entry = provider.Entries[0]; + entry.Message.Should().Be("Status requested"); + entry.ScopeValues.Should().ContainKey("ReplSessionActive"); + entry.ScopeValues["ReplSessionActive"].Should().Be(expected: true); + entry.ScopeValues.Should().ContainKey("ReplHostedSession"); + entry.ScopeValues["ReplHostedSession"].Should().Be(expected: true); + entry.ScopeValues.Should().ContainKey("ReplProtocolPassthrough"); + entry.ScopeValues["ReplProtocolPassthrough"].Should().Be(expected: false); + } + + [TestMethod] + [Description("Regression guard: verifies ambient Repl log context reflects hosted session metadata so apps can route logs to the active session.")] + public void When_HostedSessionRuns_Then_LogContextExposesSessionMetadata() + { + var app = ReplApp.Create(); + app.Map("context", (IReplLogContextAccessor accessor) => + { + var current = accessor.Current; + return new + { + current.IsSessionActive, + current.IsHostedSession, + current.IsProgrammatic, + current.IsProtocolPassthrough, + current.SessionId, + current.TransportName, + current.TerminalIdentity, + }; + }); + + using var input = new StringReader(string.Empty); + using var output = new StringWriter(); + var host = new InMemoryHost(input, output); + + var exitCode = app.Run( + ["context", "--json", "--no-logo"], + host, + new ReplRunOptions + { + HostedServiceLifecycle = HostedServiceLifecycleMode.None, + TerminalOverrides = new TerminalSessionOverrides + { + TransportName = "websocket", + TerminalIdentity = "xterm-256color", + }, + }); + + exitCode.Should().Be(0); + output.ToString().Should().Contain("\"isSessionActive\": true"); + output.ToString().Should().Contain("\"isHostedSession\": true"); + output.ToString().Should().Contain("\"transportName\": \"websocket\""); + output.ToString().Should().Contain("\"terminalIdentity\": \"xterm-256color\""); + } + + private sealed class InMemoryHost(TextReader input, TextWriter output) : IReplHost + { + public TextReader Input { get; } = input; + + public TextWriter Output { get; } = output; + } + + private sealed class CapturingLoggerProvider : ILoggerProvider, ISupportExternalScope + { + private IExternalScopeProvider _scopeProvider = new LoggerExternalScopeProvider(); + + public List Entries { get; } = []; + + public ILogger CreateLogger(string categoryName) => new CapturingLogger(categoryName, Entries, () => _scopeProvider); + + public void Dispose() + { + } + + public void SetScopeProvider(IExternalScopeProvider scopeProvider) + { + _scopeProvider = scopeProvider; + } + } + + private sealed class CapturingLogger( + string categoryName, + List entries, + Func scopeProviderAccessor) : ILogger + { + public IDisposable BeginScope(TState state) + where TState : notnull => + scopeProviderAccessor().Push(state); + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + var scopes = new Dictionary(StringComparer.Ordinal); + scopeProviderAccessor().ForEachScope( + (scope, stateDictionary) => + { + if (scope is IEnumerable> kvps) + { + foreach (var pair in kvps) + { + stateDictionary[pair.Key] = pair.Value; + } + } + }, + scopes); + + entries.Add(new CapturedLogEntry(categoryName, logLevel, formatter(state, exception), scopes)); + } + } + + private sealed record CapturedLogEntry( + string Category, + LogLevel Level, + string Message, + IReadOnlyDictionary ScopeValues); + + private static partial class LogMessages + { + [LoggerMessage(Level = LogLevel.Information, Message = "Ping handled")] + public static partial void PingHandled(ILogger logger); + + [LoggerMessage(Level = LogLevel.Information, Message = "Status requested")] + public static partial void StatusRequested(ILogger logger); + } +} diff --git a/src/Repl.slnx b/src/Repl.slnx index b64c5eb..e4c2ae3 100644 --- a/src/Repl.slnx +++ b/src/Repl.slnx @@ -7,6 +7,7 @@ + From 30c11c382098d09050487dbd4ee44b183d0d3ef4 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 14 Apr 2026 23:23:43 -0400 Subject: [PATCH 03/16] Add interaction-based user feedback routing --- docs/commands.md | 2 +- docs/configuration-reference.md | 2 + docs/interaction.md | 25 ++++- .../Console/ConsoleInteractionChannel.cs | 57 +++++++++++ .../ConsoleReplInteractionPresenter.cs | 23 +++++ .../ReplInteractionChannelExtensions.cs | 47 +++++++++ src/Repl.Core/Interaction/ReplNoticeEvent.cs | 7 ++ src/Repl.Core/Interaction/ReplProblemEvent.cs | 10 ++ src/Repl.Core/Interaction/ReplWarningEvent.cs | 7 ++ .../Interaction/WriteNoticeRequest.cs | 8 ++ .../Interaction/WriteProblemRequest.cs | 10 ++ .../Interaction/WriteWarningRequest.cs | 8 ++ src/Repl.Core/Rendering/AnsiPalette.cs | 3 + .../Rendering/DefaultAnsiPaletteProvider.cs | 6 ++ .../Given_InteractionChannel.cs | 23 +++++ .../Given_TestingToolkit.cs | 30 ++++++ src/Repl.Mcp/McpInteractionChannel.cs | 81 +++++++++++++-- src/Repl.Mcp/McpServerHandler.cs | 1 + src/Repl.Mcp/README.md | 9 +- src/Repl.McpTests/Given_McpIntegration.cs | 12 +++ .../Given_McpInteractionChannel.cs | 30 ++++-- src/Repl.McpTests/Given_McpUserFeedback.cs | 98 +++++++++++++++++++ 22 files changed, 478 insertions(+), 21 deletions(-) create mode 100644 src/Repl.Core/Interaction/ReplNoticeEvent.cs create mode 100644 src/Repl.Core/Interaction/ReplProblemEvent.cs create mode 100644 src/Repl.Core/Interaction/ReplWarningEvent.cs create mode 100644 src/Repl.Core/Interaction/WriteNoticeRequest.cs create mode 100644 src/Repl.Core/Interaction/WriteProblemRequest.cs create mode 100644 src/Repl.Core/Interaction/WriteWarningRequest.cs create mode 100644 src/Repl.McpTests/Given_McpUserFeedback.cs diff --git a/docs/commands.md b/docs/commands.md index acd77ad..4028a8c 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -296,7 +296,7 @@ Tuple semantics: ## Interactive prompts -Handlers can use `IReplInteractionChannel` for guided prompts (text, choice, confirmation, secret, multi-choice), progress reporting, and status messages. Extension methods add enum prompts, numeric input, validated text, and more. +Handlers can use `IReplInteractionChannel` for guided prompts, progress reporting, and user-facing feedback. Extension methods add enum prompts, numeric input, validated text, notices, warnings, problem summaries, and more. When the terminal supports ANSI and key reads, choice and multi-choice prompts automatically upgrade to rich arrow-key menus with mnemonic shortcuts. Labels using the `_X` underscore convention get keyboard shortcuts (e.g. `"_Abort"` → press `A`). diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index a8bafdc..0349956 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -132,6 +132,8 @@ Accessed via `ReplOptions.AmbientCommands`. Accessed via `ReplOptions.Interaction`. +These options are configured through `app.Options(...)`. Repl does not currently auto-bind them from `IConfiguration`. + - `DefaultProgressLabel` (`string`, default: `"Progress"`) — Default label for progress indicators. - `ProgressTemplate` (`string`, default: `"{label}: {percent:0}%"`) — Progress display template. Supports placeholders: `{label}`, `{percent}`, `{percent:0}`, `{percent:0.0}`. - `PromptFallback` (`PromptFallback`, default: `UseDefault`) — Behavior when interactive prompts are unavailable. diff --git a/docs/interaction.md b/docs/interaction.md index 9af9853..7ab0b9a 100644 --- a/docs/interaction.md +++ b/docs/interaction.md @@ -3,6 +3,8 @@ The interaction channel is a bidirectional contract between command handlers and the host. Handlers emit **semantic requests** (prompts, status, progress); the host decides **how to render** them. +Use `Interaction` for **user-facing feedback**. Keep `ILogger` for operator diagnostics and centralized logging. + See also: [sample 04-interactive-ops](../samples/04-interactive-ops/) for a working demo. ## Core primitives @@ -71,12 +73,26 @@ await channel.ClearScreenAsync(cancellationToken); ### `WriteStatusAsync` -Inline feedback (validation errors, status messages). +Neutral inline feedback (validation errors, transient status). ```csharp await channel.WriteStatusAsync("Import started", cancellationToken); ``` +### User-facing feedback helpers + +These extension methods live in `Repl.Interaction` and are intended for messages the current user should actually see. + +```csharp +await channel.WriteNoticeAsync("Connection established", cancellationToken); +await channel.WriteWarningAsync("Token expires soon", cancellationToken); +await channel.WriteProblemAsync( + "Sync failed", + details: "Check connectivity and retry.", + code: "sync_failed", + cancellationToken: cancellationToken); +``` + --- ## Extension methods @@ -263,8 +279,11 @@ Each core primitive has a corresponding request record: | `AskSecretRequest` | `string` | `AskSecretAsync` | | `AskMultiChoiceRequest` | `IReadOnlyList` | `AskMultiChoiceAsync` | | `ClearScreenRequest` | — | `ClearScreenAsync` | -| `WriteStatusRequest` | — | `WriteStatusAsync` | -| `WriteProgressRequest` | — | `WriteProgressAsync` | +| `WriteStatusRequest` | `bool` | `WriteStatusAsync` | +| `WriteProgressRequest` | `bool` | `WriteProgressAsync` | +| `WriteNoticeRequest` | `bool` | `WriteNoticeAsync` | +| `WriteWarningRequest` | `bool` | `WriteWarningAsync` | +| `WriteProblemRequest` | `bool` | `WriteProblemAsync` | All request types derive from `InteractionRequest` (or `InteractionRequest` for void operations) and carry the same parameters as the corresponding channel method. diff --git a/src/Repl.Core/Console/ConsoleInteractionChannel.cs b/src/Repl.Core/Console/ConsoleInteractionChannel.cs index a934142..20f8774 100644 --- a/src/Repl.Core/Console/ConsoleInteractionChannel.cs +++ b/src/Repl.Core/Console/ConsoleInteractionChannel.cs @@ -47,10 +47,67 @@ public async ValueTask DispatchAsync( return (TResult)dispatched.Value!; } + if (await TryHandleBuiltInDispatchAsync(request, cancellationToken).ConfigureAwait(false) is { Handled: true } builtIn) + { + return (TResult)builtIn.Value!; + } + throw new NotSupportedException( $"No handler registered for interaction request '{request.GetType().Name}'."); } + private async ValueTask TryHandleBuiltInDispatchAsync( + InteractionRequest request, + CancellationToken cancellationToken) + { + switch (request) + { + case WriteNoticeRequest notice: + await PresentFeedbackAsync( + notice.Text, + new ReplNoticeEvent(notice.Text), + cancellationToken) + .ConfigureAwait(false); + return InteractionResult.Success(value: true); + + case WriteWarningRequest warning: + await PresentFeedbackAsync( + warning.Text, + new ReplWarningEvent(warning.Text), + cancellationToken) + .ConfigureAwait(false); + return InteractionResult.Success(value: true); + + case WriteProblemRequest problem: + if (string.IsNullOrWhiteSpace(problem.Summary)) + { + throw new ArgumentException("Problem summary cannot be empty.", nameof(request)); + } + + await _presenter.PresentAsync( + new ReplProblemEvent(problem.Summary, problem.Details, problem.Code), + cancellationToken) + .ConfigureAwait(false); + return InteractionResult.Success(value: true); + + default: + return InteractionResult.Unhandled; + } + } + + private async ValueTask PresentFeedbackAsync( + string text, + ReplInteractionEvent interactionEvent, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(text)) + { + throw new ArgumentException("Feedback text cannot be empty.", nameof(text)); + } + + await _presenter.PresentAsync(interactionEvent, cancellationToken).ConfigureAwait(false); + } + /// /// Sets the ambient per-command token. Called by the framework before each command dispatch. /// diff --git a/src/Repl.Core/Console/ConsoleReplInteractionPresenter.cs b/src/Repl.Core/Console/ConsoleReplInteractionPresenter.cs index c7000ea..71e981f 100644 --- a/src/Repl.Core/Console/ConsoleReplInteractionPresenter.cs +++ b/src/Repl.Core/Console/ConsoleReplInteractionPresenter.cs @@ -28,6 +28,29 @@ public async ValueTask PresentAsync(ReplInteractionEvent evt, CancellationToken await ReplSessionIO.Output.WriteLineAsync(Styled(status.Text, _palette?.StatusStyle)).ConfigureAwait(false); break; + case ReplNoticeEvent notice: + await CloseProgressLineIfNeededAsync().ConfigureAwait(false); + await ReplSessionIO.Output.WriteLineAsync(Styled(notice.Text, _palette?.NoticeStyle)).ConfigureAwait(false); + break; + + case ReplWarningEvent warning: + await CloseProgressLineIfNeededAsync().ConfigureAwait(false); + await ReplSessionIO.Output.WriteLineAsync(Styled($"Warning: {warning.Text}", _palette?.WarningStyle)).ConfigureAwait(false); + break; + + case ReplProblemEvent problem: + await CloseProgressLineIfNeededAsync().ConfigureAwait(false); + var header = string.IsNullOrWhiteSpace(problem.Code) + ? $"Problem: {problem.Summary}" + : $"Problem [{problem.Code}]: {problem.Summary}"; + await ReplSessionIO.Output.WriteLineAsync(Styled(header, _palette?.ProblemStyle)).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(problem.Details)) + { + await ReplSessionIO.Output.WriteLineAsync(Styled(problem.Details, _palette?.ProblemStyle)).ConfigureAwait(false); + } + + break; + case ReplPromptEvent prompt: await CloseProgressLineIfNeededAsync().ConfigureAwait(false); await ReplSessionIO.Output.WriteAsync($"{Styled(prompt.PromptText, _palette?.PromptStyle)}: ").ConfigureAwait(false); diff --git a/src/Repl.Core/Interaction/ReplInteractionChannelExtensions.cs b/src/Repl.Core/Interaction/ReplInteractionChannelExtensions.cs index 81e13ba..040f6e1 100644 --- a/src/Repl.Core/Interaction/ReplInteractionChannelExtensions.cs +++ b/src/Repl.Core/Interaction/ReplInteractionChannelExtensions.cs @@ -11,6 +11,53 @@ namespace Repl.Interaction; /// public static class ReplInteractionChannelExtensions { + /// + /// Writes an informational user-facing notice. + /// + public static async ValueTask WriteNoticeAsync( + this IReplInteractionChannel channel, + string text, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(channel); + _ = await channel.DispatchAsync( + new WriteNoticeRequest(text, cancellationToken), + cancellationToken) + .ConfigureAwait(false); + } + + /// + /// Writes a user-facing warning. + /// + public static async ValueTask WriteWarningAsync( + this IReplInteractionChannel channel, + string text, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(channel); + _ = await channel.DispatchAsync( + new WriteWarningRequest(text, cancellationToken), + cancellationToken) + .ConfigureAwait(false); + } + + /// + /// Writes a user-facing problem summary. + /// + public static async ValueTask WriteProblemAsync( + this IReplInteractionChannel channel, + string summary, + string? details = null, + string? code = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(channel); + _ = await channel.DispatchAsync( + new WriteProblemRequest(summary, details, code, cancellationToken), + cancellationToken) + .ConfigureAwait(false); + } + /// /// Prompts the user to select a value from an enum type. /// Uses or names when present, diff --git a/src/Repl.Core/Interaction/ReplNoticeEvent.cs b/src/Repl.Core/Interaction/ReplNoticeEvent.cs new file mode 100644 index 0000000..47d84a7 --- /dev/null +++ b/src/Repl.Core/Interaction/ReplNoticeEvent.cs @@ -0,0 +1,7 @@ +namespace Repl.Interaction; + +/// +/// Semantic informational feedback intended for the current user. +/// +public sealed record ReplNoticeEvent(string Text) + : ReplInteractionEvent(DateTimeOffset.UtcNow); diff --git a/src/Repl.Core/Interaction/ReplProblemEvent.cs b/src/Repl.Core/Interaction/ReplProblemEvent.cs new file mode 100644 index 0000000..825cd6f --- /dev/null +++ b/src/Repl.Core/Interaction/ReplProblemEvent.cs @@ -0,0 +1,10 @@ +namespace Repl.Interaction; + +/// +/// Semantic problem feedback intended for the current user. +/// +public sealed record ReplProblemEvent( + string Summary, + string? Details = null, + string? Code = null) + : ReplInteractionEvent(DateTimeOffset.UtcNow); diff --git a/src/Repl.Core/Interaction/ReplWarningEvent.cs b/src/Repl.Core/Interaction/ReplWarningEvent.cs new file mode 100644 index 0000000..c0d8e8b --- /dev/null +++ b/src/Repl.Core/Interaction/ReplWarningEvent.cs @@ -0,0 +1,7 @@ +namespace Repl.Interaction; + +/// +/// Semantic warning feedback intended for the current user. +/// +public sealed record ReplWarningEvent(string Text) + : ReplInteractionEvent(DateTimeOffset.UtcNow); diff --git a/src/Repl.Core/Interaction/WriteNoticeRequest.cs b/src/Repl.Core/Interaction/WriteNoticeRequest.cs new file mode 100644 index 0000000..dcab442 --- /dev/null +++ b/src/Repl.Core/Interaction/WriteNoticeRequest.cs @@ -0,0 +1,8 @@ +namespace Repl.Interaction; + +/// +/// Requests an informational user-facing notice. +/// +public sealed record WriteNoticeRequest( + string Text, + CancellationToken CancellationToken = default) : InteractionRequest("__notice__", Text); diff --git a/src/Repl.Core/Interaction/WriteProblemRequest.cs b/src/Repl.Core/Interaction/WriteProblemRequest.cs new file mode 100644 index 0000000..7bd60ba --- /dev/null +++ b/src/Repl.Core/Interaction/WriteProblemRequest.cs @@ -0,0 +1,10 @@ +namespace Repl.Interaction; + +/// +/// Requests a user-facing problem summary. +/// +public sealed record WriteProblemRequest( + string Summary, + string? Details = null, + string? Code = null, + CancellationToken CancellationToken = default) : InteractionRequest("__problem__", Summary); diff --git a/src/Repl.Core/Interaction/WriteWarningRequest.cs b/src/Repl.Core/Interaction/WriteWarningRequest.cs new file mode 100644 index 0000000..757b5e8 --- /dev/null +++ b/src/Repl.Core/Interaction/WriteWarningRequest.cs @@ -0,0 +1,8 @@ +namespace Repl.Interaction; + +/// +/// Requests a user-facing warning. +/// +public sealed record WriteWarningRequest( + string Text, + CancellationToken CancellationToken = default) : InteractionRequest("__warning__", Text); diff --git a/src/Repl.Core/Rendering/AnsiPalette.cs b/src/Repl.Core/Rendering/AnsiPalette.cs index 3a58c5f..3cfd884 100644 --- a/src/Repl.Core/Rendering/AnsiPalette.cs +++ b/src/Repl.Core/Rendering/AnsiPalette.cs @@ -16,6 +16,9 @@ public sealed record AnsiPalette( string StatusStyle = "", string PromptStyle = "", string ProgressStyle = "", + string NoticeStyle = "", + string WarningStyle = "", + string ProblemStyle = "", string BannerStyle = "", string AutocompleteCommandStyle = "", string AutocompleteContextStyle = "", diff --git a/src/Repl.Core/Rendering/DefaultAnsiPaletteProvider.cs b/src/Repl.Core/Rendering/DefaultAnsiPaletteProvider.cs index 88668bd..817dbbc 100644 --- a/src/Repl.Core/Rendering/DefaultAnsiPaletteProvider.cs +++ b/src/Repl.Core/Rendering/DefaultAnsiPaletteProvider.cs @@ -15,6 +15,9 @@ internal sealed class DefaultAnsiPaletteProvider : IAnsiPaletteProvider StatusStyle: "\u001b[38;5;244m", PromptStyle: "\u001b[38;5;117m", ProgressStyle: "\u001b[38;5;244m", + NoticeStyle: "\u001b[38;5;110m", + WarningStyle: "\u001b[38;5;221m", + ProblemStyle: "\u001b[38;5;203m", BannerStyle: "\u001b[38;5;109m", AutocompleteCommandStyle: "\u001b[38;5;153m", AutocompleteContextStyle: "\u001b[38;5;117m", @@ -37,6 +40,9 @@ internal sealed class DefaultAnsiPaletteProvider : IAnsiPaletteProvider StatusStyle: "\u001b[38;5;240m", PromptStyle: "\u001b[38;5;25m", ProgressStyle: "\u001b[38;5;240m", + NoticeStyle: "\u001b[38;5;31m", + WarningStyle: "\u001b[38;5;166m", + ProblemStyle: "\u001b[38;5;160m", BannerStyle: "\u001b[38;5;66m", AutocompleteCommandStyle: "\u001b[38;5;19m", AutocompleteContextStyle: "\u001b[38;5;24m", diff --git a/src/Repl.IntegrationTests/Given_InteractionChannel.cs b/src/Repl.IntegrationTests/Given_InteractionChannel.cs index e01a100..fd14309 100644 --- a/src/Repl.IntegrationTests/Given_InteractionChannel.cs +++ b/src/Repl.IntegrationTests/Given_InteractionChannel.cs @@ -22,6 +22,29 @@ public void When_HandlerUsesInteractionChannelStatus_Then_StatusIsRendered() output.Text.Should().Contain("done"); } + [TestMethod] + [Description("Regression guard: verifies user-facing notice, warning, and problem feedback render semantically.")] + public void When_HandlerUsesStructuredUserFeedback_Then_FeedbackIsRendered() + { + var sut = ReplApp.Create(); + sut.Map("sync", async (IReplInteractionChannel channel, CancellationToken ct) => + { + await channel.WriteNoticeAsync("Connection established", ct).ConfigureAwait(false); + await channel.WriteWarningAsync("Cache is warming up", ct).ConfigureAwait(false); + await channel.WriteProblemAsync("Sync failed", "Check connectivity and retry.", "sync_failed", ct).ConfigureAwait(false); + return "done"; + }); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["sync", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("Connection established"); + output.Text.Should().Contain("Warning: Cache is warming up"); + output.Text.Should().Contain("Problem [sync_failed]: Sync failed"); + output.Text.Should().Contain("Check connectivity and retry."); + output.Text.Should().Contain("done"); + } + [TestMethod] [Description("Regression guard: verifies percentage progress injection so that handlers can report progress through IProgress.")] public void When_HandlerUsesInjectedPercentageProgress_Then_ProgressIsRendered() diff --git a/src/Repl.IntegrationTests/Given_TestingToolkit.cs b/src/Repl.IntegrationTests/Given_TestingToolkit.cs index f20ac8a..434c285 100644 --- a/src/Repl.IntegrationTests/Given_TestingToolkit.cs +++ b/src/Repl.IntegrationTests/Given_TestingToolkit.cs @@ -115,6 +115,36 @@ public async Task When_CommandEmitsInteraction_Then_EventsAreCaptured() result.TimelineEvents.OfType().Should().ContainSingle(); } + [TestMethod] + [Description("Regression guard: verifies semantic notice, warning, and problem events are captured by the testing toolkit.")] + public async Task When_CommandEmitsSemanticFeedback_Then_FeedbackEventsAreCaptured() + { + await using var host = ReplTestHost.Create(() => + { + var app = ReplApp.Create().UseDefaultInteractive(); + app.Map("sync", async (IReplInteractionChannel channel, CancellationToken ct) => + { + await channel.WriteNoticeAsync("Connected", ct).ConfigureAwait(false); + await channel.WriteWarningAsync("Token expires soon", ct).ConfigureAwait(false); + await channel.WriteProblemAsync("Sync failed", "Retry later.", "sync_failed", ct).ConfigureAwait(false); + return "done"; + }); + return app; + }); + await using var session = await host.OpenSessionAsync(); + + var result = await session.RunCommandAsync("sync --no-logo"); + + result.InteractionEvents.OfType() + .Should().ContainSingle(evt => string.Equals(evt.Text, "Connected", StringComparison.Ordinal)); + result.InteractionEvents.OfType() + .Should().ContainSingle(evt => string.Equals(evt.Text, "Token expires soon", StringComparison.Ordinal)); + result.InteractionEvents.OfType() + .Should().ContainSingle(evt => + string.Equals(evt.Summary, "Sync failed", StringComparison.Ordinal) + && string.Equals(evt.Code, "sync_failed", StringComparison.Ordinal)); + } + [TestMethod] [Description("Regression guard: verifies metadata snapshots expose transport, remote and terminal information for active sessions.")] public async Task When_QueryingSessions_Then_MetadataSnapshotContainsDescriptorData() diff --git a/src/Repl.Mcp/McpInteractionChannel.cs b/src/Repl.Mcp/McpInteractionChannel.cs index 4f8eefc..c1482ac 100644 --- a/src/Repl.Mcp/McpInteractionChannel.cs +++ b/src/Repl.Mcp/McpInteractionChannel.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using ModelContextProtocol; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -206,6 +207,49 @@ await _server.NotifyProgressAsync( } public async ValueTask WriteStatusAsync(string text, CancellationToken cancellationToken) + { + await SendFeedbackAsync( + LoggingLevel.Info, + JsonSerializer.SerializeToElement(text, McpJsonContext.Default.String), + cancellationToken) + .ConfigureAwait(false); + } + + public ValueTask ClearScreenAsync(CancellationToken cancellationToken) => + ValueTask.CompletedTask; + + public ValueTask DispatchAsync( + InteractionRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + return request switch + { + WriteNoticeRequest notice => CompleteBuiltInDispatchAsync( + SendFeedbackAsync( + LoggingLevel.Info, + JsonSerializer.SerializeToElement(notice.Text, McpJsonContext.Default.String), + cancellationToken)), + WriteWarningRequest warning => CompleteBuiltInDispatchAsync( + SendFeedbackAsync( + LoggingLevel.Warning, + JsonSerializer.SerializeToElement(warning.Text, McpJsonContext.Default.String), + cancellationToken)), + WriteProblemRequest problem => CompleteBuiltInDispatchAsync( + SendFeedbackAsync( + LoggingLevel.Error, + SerializeProblem(problem), + cancellationToken)), + _ => throw new NotSupportedException( + $"No MCP interaction handler is registered for '{request.GetType().Name}'."), + }; + } + + private async ValueTask SendFeedbackAsync( + LoggingLevel level, + JsonElement data, + CancellationToken cancellationToken) { if (_server is null) { @@ -216,21 +260,38 @@ await _server.SendNotificationAsync( NotificationMethods.LoggingMessageNotification, new LoggingMessageNotificationParams { - Level = LoggingLevel.Info, - Data = JsonSerializer.SerializeToElement(text, McpJsonContext.Default.String), + Level = level, + Logger = "repl.interaction", + Data = data, }, cancellationToken: cancellationToken).ConfigureAwait(false); } - public ValueTask ClearScreenAsync(CancellationToken cancellationToken) => - ValueTask.CompletedTask; + private static JsonElement SerializeProblem(WriteProblemRequest problem) + { + var payload = new JsonObject + { + ["summary"] = problem.Summary, + }; - public ValueTask DispatchAsync( - InteractionRequest request, - CancellationToken cancellationToken) => - throw new NotSupportedException( - "Custom interaction dispatch is not supported in MCP mode. " + - "Consider marking the command as AutomationHidden()."); + if (!string.IsNullOrWhiteSpace(problem.Details)) + { + payload["details"] = problem.Details; + } + + if (!string.IsNullOrWhiteSpace(problem.Code)) + { + payload["code"] = problem.Code; + } + + return JsonSerializer.SerializeToElement(payload, McpJsonContext.Default.JsonObject); + } + + private static async ValueTask CompleteBuiltInDispatchAsync(ValueTask operation) + { + await operation.ConfigureAwait(false); + return (TResult)(object)true; + } // ── Elicitation (Tier 2) ─────────────────────────────────────────── diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index ce85ac5..15de0b0 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -535,6 +535,7 @@ private ServerCapabilities BuildCapabilities() { var capabilities = new ServerCapabilities { + Logging = new LoggingCapability(), Tools = new ToolsCapability { ListChanged = true }, Resources = new ResourcesCapability { ListChanged = true }, Prompts = new PromptsCapability { ListChanged = true }, diff --git a/src/Repl.Mcp/README.md b/src/Repl.Mcp/README.md index 21a67a4..6031b5c 100644 --- a/src/Repl.Mcp/README.md +++ b/src/Repl.Mcp/README.md @@ -17,6 +17,12 @@ myapp mcp serve # AI agents connect here myapp # still a CLI / interactive REPL ``` +`IReplInteractionChannel` user feedback maps to MCP-native transports: +- progress -> progress notifications +- notice / warning / problem feedback -> MCP message notifications + +Keep operator logging on `ILogger`; do not rely on user-facing interaction as a logging sink. + ## MCP Apps Repl.Mcp can also expose MCP Apps UI resources: @@ -57,5 +63,6 @@ MCP Apps host support varies. VS Code currently renders MCP Apps inline; hosts t ## Learn more -- [Full documentation](https://github.com/yllibed/repl/blob/main/docs/mcp-server.md) — annotations, interaction degradation, client compatibility matrix, agent configuration, NuGet publishing +- [Getting started](https://github.com/yllibed/repl/blob/main/docs/mcp-overview.md) — quick start, annotations, mental model +- [Full reference](https://github.com/yllibed/repl/blob/main/docs/mcp-reference.md) — interaction degradation, client compatibility, agent configuration, NuGet publishing - [Sample app](https://github.com/yllibed/repl/tree/main/samples/08-mcp-server) — resources, tools, prompts, annotations, and a minimal MCP Apps UI in action diff --git a/src/Repl.McpTests/Given_McpIntegration.cs b/src/Repl.McpTests/Given_McpIntegration.cs index 75a2cca..d2d25d9 100644 --- a/src/Repl.McpTests/Given_McpIntegration.cs +++ b/src/Repl.McpTests/Given_McpIntegration.cs @@ -46,6 +46,18 @@ public void When_UseMcpServer_Then_McpContextIsHidden() model.Commands.Should().NotContain(c => string.Equals(c.Path, "mcp serve", StringComparison.Ordinal)); } + [TestMethod] + [Description("MCP server options advertise logging capability because interaction feedback can be routed through MCP notifications.")] + public void When_BuildingMcpOptions_Then_LoggingCapabilityIsAdvertised() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + + var options = app.BuildMcpServerOptions(); + + options.Capabilities!.Logging.Should().NotBeNull(); + } + [TestMethod] [Description("Commands marked AutomationHidden are excluded from MCP tool candidates.")] public void When_CommandIsAutomationHidden_Then_ExcludedFromToolCandidates() diff --git a/src/Repl.McpTests/Given_McpInteractionChannel.cs b/src/Repl.McpTests/Given_McpInteractionChannel.cs index 8086686..c8b8676 100644 --- a/src/Repl.McpTests/Given_McpInteractionChannel.cs +++ b/src/Repl.McpTests/Given_McpInteractionChannel.cs @@ -271,16 +271,31 @@ public async Task When_ClearScreen_Then_NoOp() // ── DispatchAsync ────────────────────────────────────────────────── [TestMethod] - [Description("DispatchAsync throws NotSupportedException.")] - public void When_Dispatch_Then_ThrowsNotSupported() + [Description("Built-in feedback dispatch completes even when no MCP server is attached.")] + public async Task When_DispatchingBuiltInFeedbackWithoutServer_Then_Completes() { var channel = CreateChannel(); - // DispatchAsync requires an InteractionRequest subclass, but the throw - // happens synchronously before any async work so we test the throw path. - var act = () => channel.DispatchAsync(null!, CancellationToken.None); + _ = await channel.DispatchAsync( + new WriteNoticeRequest("Connected"), + CancellationToken.None); + _ = await channel.DispatchAsync( + new WriteWarningRequest("Token expires soon"), + CancellationToken.None); + _ = await channel.DispatchAsync( + new WriteProblemRequest("Sync failed", "Retry later.", "sync_failed"), + CancellationToken.None); + } - act.Should().Throw(); + [TestMethod] + [Description("Custom interaction requests still fail explicitly in MCP mode.")] + public async Task When_DispatchingCustomRequest_Then_ThrowsNotSupported() + { + var channel = CreateChannel(); + + Func act = () => channel.DispatchAsync(new TestInteractionRequest("custom", "Prompt"), CancellationToken.None).AsTask(); + + await act.Should().ThrowAsync(); } // ── Helpers ───────────────────────────────────────────────────────── @@ -291,4 +306,7 @@ private static McpInteractionChannel CreateChannel( new( prefills ?? new Dictionary(StringComparer.OrdinalIgnoreCase), mode); + + private sealed record TestInteractionRequest(string Name, string Prompt) + : InteractionRequest(Name, Prompt); } diff --git a/src/Repl.McpTests/Given_McpUserFeedback.cs b/src/Repl.McpTests/Given_McpUserFeedback.cs new file mode 100644 index 0000000..01073f8 --- /dev/null +++ b/src/Repl.McpTests/Given_McpUserFeedback.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using Repl.Interaction; + +namespace Repl.McpTests; + +[TestClass] +public sealed class Given_McpUserFeedback +{ + [TestMethod] + [Description("Interaction-based user feedback is routed as MCP logging notifications with the expected severities.")] + public async Task When_ToolEmitsUserFeedback_Then_McpReceivesNotifications() + { + var notifications = new List<(LoggingLevel Level, string Data)>(); + var captureState = new NotificationCaptureState(notifications); + NotificationCaptureState.Current = captureState; + try + { + await using var fixture = await CreateFeedbackFixtureAsync(clientOptions: CreateClientOptions()).ConfigureAwait(false); + + var result = await fixture.Client.CallToolAsync( + toolName: "feedback", + arguments: new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + + AssertFeedbackResult(result: result, notifications: notifications); + } + finally + { + NotificationCaptureState.Current = null; + } + } + + private static async Task CreateFeedbackFixtureAsync(McpClientOptions clientOptions) => + await McpTestFixture.CreateAsync( + app => + { + app.Map("feedback", static async (IReplInteractionChannel interaction, CancellationToken cancellationToken) => + { + await interaction.WriteNoticeAsync(text: "Connected", cancellationToken: cancellationToken).ConfigureAwait(false); + await interaction.WriteWarningAsync(text: "Token expires soon", cancellationToken: cancellationToken).ConfigureAwait(false); + await interaction.WriteProblemAsync( + summary: "Sync failed", + details: "Retry later.", + code: "sync_failed", + cancellationToken: cancellationToken).ConfigureAwait(false); + return "done"; + }); + }, + configureOptions: null, + clientOptions: clientOptions).ConfigureAwait(false); + + private static McpClientOptions CreateClientOptions() => + new() + { + Handlers = new McpClientHandlers + { + NotificationHandlers = + [ + new KeyValuePair>( + NotificationMethods.LoggingMessageNotification, + HandleLoggingNotificationAsync), + ], + }, + }; + + private static ValueTask HandleLoggingNotificationAsync(JsonRpcNotification notification, CancellationToken cancellationToken) + { + _ = cancellationToken; + var state = NotificationCaptureState.Current + ?? throw new InvalidOperationException("Notification capture state was not initialized."); + var payload = notification.Params?.Deserialize() + ?? throw new InvalidOperationException("Expected logging notification parameters."); + lock (state.Notifications) + { + state.Notifications.Add(( + payload.Level, + payload.Data.ValueKind == JsonValueKind.String + ? payload.Data.GetString() ?? string.Empty + : payload.Data.GetRawText())); + } + + return ValueTask.CompletedTask; + } + + private static void AssertFeedbackResult(CallToolResult result, List<(LoggingLevel Level, string Data)> notifications) + { + result.IsError.Should().BeFalse(); + notifications.Should().Contain(entry => entry.Level == LoggingLevel.Info && string.Equals(entry.Data, "Connected", StringComparison.Ordinal)); + notifications.Should().Contain(entry => entry.Level == LoggingLevel.Warning && string.Equals(entry.Data, "Token expires soon", StringComparison.Ordinal)); + notifications.Should().Contain(entry => entry.Level == LoggingLevel.Error && entry.Data.Contains("\"summary\":\"Sync failed\"", StringComparison.Ordinal)); + } + + private sealed record NotificationCaptureState(List<(LoggingLevel Level, string Data)> Notifications) + { + public static NotificationCaptureState? Current { get; set; } + } +} From 0d61ee91280a5fd237ac92d9039ff813cd927ef4 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 18 Apr 2026 12:49:27 -0400 Subject: [PATCH 04/16] core(interaction): add structured user feedback for terminal and MCP --- .../Console/ConsoleInteractionChannel.cs | 18 ++- .../ConsoleReplInteractionPresenter.cs | 98 ++++++++++++- src/Repl.Core/CoreReplApp.Execution.cs | 21 +++ .../Documentation/DocumentationEngine.cs | 3 +- .../Interaction/AdvancedProgressMode.cs | 22 +++ .../Interaction/InteractionProgressFactory.cs | 13 +- .../ReplInteractionChannelExtensions.cs | 78 ++++++++++ .../Interaction/ReplProgressEvent.cs | 4 +- .../Interaction/ReplProgressState.cs | 32 +++++ .../Interaction/WriteProgressRequest.cs | 2 + src/Repl.Core/InteractionOptions.cs | 5 + .../Internal/Options/OptionSchemaBuilder.cs | 3 +- .../Terminal/TerminalCapabilities.cs | 3 + .../TerminalCapabilitiesClassifier.cs | 15 +- .../Given_InteractionChannel.cs | 72 ++++++++++ src/Repl.Mcp/IMcpFeedback.cs | 38 +++++ src/Repl.Mcp/McpFeedbackService.cs | 115 +++++++++++++++ src/Repl.Mcp/McpInteractionChannel.cs | 81 ++++++++++- src/Repl.Mcp/McpServerHandler.cs | 4 + src/Repl.Mcp/McpToolAdapter.cs | 5 +- .../Given_McpAgentCapabilities.cs | 23 +++ src/Repl.McpTests/Given_McpUserFeedback.cs | 133 +++++++++++++++++- ...plInteractionPresenter_AdvancedProgress.cs | 68 +++++++++ .../Given_TerminalCapabilitiesClassifier.cs | 17 +++ 24 files changed, 852 insertions(+), 21 deletions(-) create mode 100644 src/Repl.Core/Interaction/AdvancedProgressMode.cs create mode 100644 src/Repl.Core/Interaction/ReplProgressState.cs create mode 100644 src/Repl.Mcp/IMcpFeedback.cs create mode 100644 src/Repl.Mcp/McpFeedbackService.cs create mode 100644 src/Repl.Tests/Given_ConsoleReplInteractionPresenter_AdvancedProgress.cs create mode 100644 src/Repl.Tests/Given_TerminalCapabilitiesClassifier.cs diff --git a/src/Repl.Core/Console/ConsoleInteractionChannel.cs b/src/Repl.Core/Console/ConsoleInteractionChannel.cs index 20f8774..e8f103e 100644 --- a/src/Repl.Core/Console/ConsoleInteractionChannel.cs +++ b/src/Repl.Core/Console/ConsoleInteractionChannel.cs @@ -90,6 +90,17 @@ await _presenter.PresentAsync( .ConfigureAwait(false); return InteractionResult.Success(value: true); + case WriteProgressRequest progress: + await _presenter.PresentAsync( + new ReplProgressEvent( + progress.Label, + Percent: progress.Percent, + State: progress.State, + Details: progress.Details), + cancellationToken) + .ConfigureAwait(false); + return InteractionResult.Success(value: true); + default: return InteractionResult.Unhandled; } @@ -123,7 +134,12 @@ public async ValueTask WriteProgressAsync( : label; cancellationToken.ThrowIfCancellationRequested(); - var dispatched = await TryDispatchAsync(new WriteProgressRequest(label, percent, cancellationToken), cancellationToken) + var dispatched = await TryDispatchAsync( + new WriteProgressRequest( + Label: label, + Percent: percent, + CancellationToken: cancellationToken), + cancellationToken) .ConfigureAwait(false); if (dispatched.Handled) { diff --git a/src/Repl.Core/Console/ConsoleReplInteractionPresenter.cs b/src/Repl.Core/Console/ConsoleReplInteractionPresenter.cs index 71e981f..1e3d227 100644 --- a/src/Repl.Core/Console/ConsoleReplInteractionPresenter.cs +++ b/src/Repl.Core/Console/ConsoleReplInteractionPresenter.cs @@ -6,6 +6,8 @@ internal sealed class ConsoleReplInteractionPresenter( InteractionOptions options, OutputOptions? outputOptions = null) : IReplInteractionPresenter { + private const string OscPrefix = "\x1b]9;4;"; + private const string Bell = "\x07"; private readonly InteractionOptions _options = options; private readonly bool _rewriteProgress = !Console.IsOutputRedirected || ReplSessionIO.IsSessionActive; private readonly bool _useAnsi = outputOptions?.IsAnsiEnabled() ?? false; @@ -73,16 +75,24 @@ public async ValueTask PresentAsync(ReplInteractionEvent evt, CancellationToken private async ValueTask WriteProgressAsync(ReplProgressEvent progress) { + if (progress.State == ReplProgressState.Clear) + { + await CloseProgressLineIfNeededAsync().ConfigureAwait(false); + await TryWriteAdvancedProgressAsync(progress).ConfigureAwait(false); + return; + } + var percent = progress.ResolvePercent(); - var payload = FormatProgress(progress.Label, percent); - if (!_rewriteProgress || percent is null) + var payload = FormatProgress(progress, percent); + await TryWriteAdvancedProgressAsync(progress).ConfigureAwait(false); + if (!_rewriteProgress) { await CloseProgressLineIfNeededAsync().ConfigureAwait(false); await ReplSessionIO.Output.WriteLineAsync(Styled(payload, _palette?.ProgressStyle)).ConfigureAwait(false); return; } - var styledPayload = Styled(payload, _palette?.ProgressStyle); + var styledPayload = Styled(payload, ResolveProgressStyle(progress.State)); var paddingWidth = Math.Max(_lastProgressLength, payload.Length); var paddedPayload = _useAnsi ? styledPayload + new string(' ', Math.Max(0, paddingWidth - payload.Length)) @@ -90,20 +100,27 @@ private async ValueTask WriteProgressAsync(ReplProgressEvent progress) await ReplSessionIO.Output.WriteAsync($"\r{paddedPayload}").ConfigureAwait(false); _lastProgressLength = payload.Length; _hasOpenProgressLine = true; - if (percent >= 100d) + if (progress.State != ReplProgressState.Indeterminate && percent >= 100d) { await CloseProgressLineIfNeededAsync().ConfigureAwait(false); } } - private string FormatProgress(string label, double? percent) + private string FormatProgress(ReplProgressEvent progress, double? percent) { + if (progress.State == ReplProgressState.Indeterminate) + { + return string.IsNullOrWhiteSpace(progress.Details) + ? progress.Label + : $"{progress.Label}: {progress.Details}"; + } + var template = string.IsNullOrWhiteSpace(_options.ProgressTemplate) ? "{label}: {percent:0}%" : _options.ProgressTemplate; - var safeLabel = string.IsNullOrWhiteSpace(label) + var safeLabel = string.IsNullOrWhiteSpace(progress.Label) ? _options.DefaultProgressLabel - : label; + : progress.Label; var resolvedPercent = percent ?? 0d; var percentText = resolvedPercent.ToString("0.###", CultureInfo.InvariantCulture); var percentOneDecimalText = resolvedPercent.ToString("0.0", CultureInfo.InvariantCulture); @@ -116,6 +133,73 @@ private string FormatProgress(string label, double? percent) .Replace("{percent}", percentText, StringComparison.Ordinal); } + private async ValueTask TryWriteAdvancedProgressAsync(ReplProgressEvent progress) + { + if (!ShouldEmitAdvancedProgress()) + { + return; + } + + var sequence = BuildAdvancedProgressSequence(progress); + if (sequence is null) + { + return; + } + + await ReplSessionIO.Output.WriteAsync(sequence).ConfigureAwait(false); + } + + private bool ShouldEmitAdvancedProgress() + { + if (ReplSessionIO.IsProtocolPassthrough || !_useAnsi || !IsInteractiveTerminalSession()) + { + return false; + } + + return _options.AdvancedProgressMode switch + { + AdvancedProgressMode.Always => true, + AdvancedProgressMode.Never => false, + _ => true, + }; + } + + private static bool IsInteractiveTerminalSession() => + (!Console.IsOutputRedirected || ReplSessionIO.IsSessionActive) + && !ReplSessionIO.IsProtocolPassthrough; + + private static string? BuildAdvancedProgressSequence(ReplProgressEvent progress) + { + var stateCode = progress.State switch + { + ReplProgressState.Normal => 1, + ReplProgressState.Warning => 4, + ReplProgressState.Error => 2, + ReplProgressState.Indeterminate => 3, + ReplProgressState.Clear => 0, + _ => 1, + }; + + if (progress.State is ReplProgressState.Indeterminate or ReplProgressState.Clear) + { + return $"{OscPrefix}{stateCode};0{Bell}"; + } + + var percent = (int)Math.Clamp( + Math.Round(progress.ResolvePercent() ?? 0d, MidpointRounding.AwayFromZero), + 0, + 100); + return $"{OscPrefix}{stateCode};{percent}{Bell}"; + } + + private string? ResolveProgressStyle(ReplProgressState state) => + state switch + { + ReplProgressState.Warning => _palette?.WarningStyle, + ReplProgressState.Error => _palette?.ProblemStyle, + _ => _palette?.ProgressStyle, + }; + private async ValueTask CloseProgressLineIfNeededAsync() { if (!_hasOpenProgressLine) diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index 20c46a9..eea383b 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -504,6 +504,7 @@ await TryRenderCommandBannerAsync(match.Route.Command, globalOptions.OutputForma serviceProvider, cancellationToken) .ConfigureAwait(false); + await TryClearProgressAsync(serviceProvider).ConfigureAwait(false); if (TupleDecomposer.IsTupleResult(result, out var tuple)) { @@ -530,16 +531,19 @@ await TryRenderCommandBannerAsync(match.Route.Command, globalOptions.OutputForma } catch (OperationCanceledException) { + await TryClearProgressAsync(serviceProvider).ConfigureAwait(false); throw; } catch (InvalidOperationException ex) { + await TryClearProgressAsync(serviceProvider).ConfigureAwait(false); _ = await RenderOutputAsync(Results.Validation(ex.Message), globalOptions.OutputFormat, cancellationToken) .ConfigureAwait(false); return (1, false); } catch (Exception ex) { + await TryClearProgressAsync(serviceProvider).ConfigureAwait(false); var errorMessage = ex is TargetInvocationException { InnerException: not null } tie ? tie.InnerException?.Message ?? ex.Message : ex.Message; @@ -552,6 +556,23 @@ await TryRenderCommandBannerAsync(match.Route.Command, globalOptions.OutputForma } } + private static async ValueTask TryClearProgressAsync(IServiceProvider serviceProvider) + { + if (serviceProvider.GetService(typeof(IReplInteractionChannel)) is not IReplInteractionChannel interaction) + { + return; + } + + try + { + await interaction.ClearProgressAsync().ConfigureAwait(false); + } + catch (NotSupportedException) + { + // Ignore channels that do not implement progress dispatch. + } + } + private async ValueTask<(int ExitCode, bool EnterInteractive)> RenderTupleResultAsync( ITuple tuple, List? scopeTokens, diff --git a/src/Repl.Core/Documentation/DocumentationEngine.cs b/src/Repl.Core/Documentation/DocumentationEngine.cs index 4598abf..6247590 100644 --- a/src/Repl.Core/Documentation/DocumentationEngine.cs +++ b/src/Repl.Core/Documentation/DocumentationEngine.cs @@ -308,7 +308,8 @@ private static bool IsFrameworkInjectedParameter(Type parameterType) => || parameterType == typeof(IReplKeyReader) || string.Equals(parameterType.FullName, "Repl.Mcp.IMcpClientRoots", StringComparison.Ordinal) || string.Equals(parameterType.FullName, "Repl.Mcp.IMcpSampling", StringComparison.Ordinal) - || string.Equals(parameterType.FullName, "Repl.Mcp.IMcpElicitation", StringComparison.Ordinal); + || string.Equals(parameterType.FullName, "Repl.Mcp.IMcpElicitation", StringComparison.Ordinal) + || string.Equals(parameterType.FullName, "Repl.Mcp.IMcpFeedback", StringComparison.Ordinal); private static bool IsRequiredParameter(ParameterInfo parameter) { diff --git a/src/Repl.Core/Interaction/AdvancedProgressMode.cs b/src/Repl.Core/Interaction/AdvancedProgressMode.cs new file mode 100644 index 0000000..74c20d8 --- /dev/null +++ b/src/Repl.Core/Interaction/AdvancedProgressMode.cs @@ -0,0 +1,22 @@ +namespace Repl.Interaction; + +/// +/// Controls whether advanced terminal progress sequences are emitted. +/// +public enum AdvancedProgressMode +{ + /// + /// Emit advanced progress sequences automatically in interactive ANSI sessions. + /// + Auto, + + /// + /// Force advanced progress sequences in interactive ANSI sessions. + /// + Always, + + /// + /// Never emit advanced progress sequences. + /// + Never, +} diff --git a/src/Repl.Core/Interaction/InteractionProgressFactory.cs b/src/Repl.Core/Interaction/InteractionProgressFactory.cs index 644fc12..08c3f5c 100644 --- a/src/Repl.Core/Interaction/InteractionProgressFactory.cs +++ b/src/Repl.Core/Interaction/InteractionProgressFactory.cs @@ -55,9 +55,18 @@ private sealed class StructuredProgress( { public void Report(ReplProgressEvent value) { - var percent = value.ResolvePercent(); #pragma warning disable VSTHRD002 // IProgress.Report is sync by contract; we bridge to async channel intentionally. - channel.WriteProgressAsync(value.Label, percent, cancellationToken).AsTask().GetAwaiter().GetResult(); + channel.DispatchAsync( + new WriteProgressRequest( + value.Label, + value.ResolvePercent(), + value.State, + value.Details, + cancellationToken), + cancellationToken) + .AsTask() + .GetAwaiter() + .GetResult(); #pragma warning restore VSTHRD002 } } diff --git a/src/Repl.Core/Interaction/ReplInteractionChannelExtensions.cs b/src/Repl.Core/Interaction/ReplInteractionChannelExtensions.cs index 040f6e1..a0e0fcb 100644 --- a/src/Repl.Core/Interaction/ReplInteractionChannelExtensions.cs +++ b/src/Repl.Core/Interaction/ReplInteractionChannelExtensions.cs @@ -58,6 +58,84 @@ public static async ValueTask WriteProblemAsync( .ConfigureAwait(false); } + /// + /// Writes a structured progress update. + /// + public static async ValueTask WriteProgressAsync( + this IReplInteractionChannel channel, + ReplProgressEvent progress, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(channel); + ArgumentNullException.ThrowIfNull(progress); + _ = await channel.DispatchAsync( + new WriteProgressRequest( + progress.Label, + progress.ResolvePercent(), + progress.State, + progress.Details, + cancellationToken), + cancellationToken) + .ConfigureAwait(false); + } + + /// + /// Writes an indeterminate progress update. + /// + public static ValueTask WriteIndeterminateProgressAsync( + this IReplInteractionChannel channel, + string label, + string? details = null, + CancellationToken cancellationToken = default) => + channel.WriteProgressAsync( + new ReplProgressEvent(label, State: ReplProgressState.Indeterminate, Details: details), + cancellationToken); + + /// + /// Writes a warning-state progress update. + /// + public static ValueTask WriteWarningProgressAsync( + this IReplInteractionChannel channel, + string label, + double? percent = null, + string? details = null, + CancellationToken cancellationToken = default) => + channel.WriteProgressAsync( + new ReplProgressEvent(label, Percent: percent, State: ReplProgressState.Warning, Details: details), + cancellationToken); + + /// + /// Writes an error-state progress update. + /// + public static ValueTask WriteErrorProgressAsync( + this IReplInteractionChannel channel, + string label, + double? percent = null, + string? details = null, + CancellationToken cancellationToken = default) => + channel.WriteProgressAsync( + new ReplProgressEvent(label, Percent: percent, State: ReplProgressState.Error, Details: details), + cancellationToken); + + /// + /// Clears any currently visible progress indicator. + /// + public static async ValueTask ClearProgressAsync( + this IReplInteractionChannel channel, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(channel); + _ = await channel.DispatchAsync( + new WriteProgressRequest( + Label: string.Empty, + Percent: null, + State: ReplProgressState.Clear, + Details: null, + CancellationToken: cancellationToken), + cancellationToken) + .ConfigureAwait(false); + } + /// /// Prompts the user to select a value from an enum type. /// Uses or names when present, diff --git a/src/Repl.Core/Interaction/ReplProgressEvent.cs b/src/Repl.Core/Interaction/ReplProgressEvent.cs index 8453ae6..de0cda0 100644 --- a/src/Repl.Core/Interaction/ReplProgressEvent.cs +++ b/src/Repl.Core/Interaction/ReplProgressEvent.cs @@ -8,7 +8,9 @@ public sealed record ReplProgressEvent( double? Percent = null, int? Current = null, int? Total = null, - string? Unit = null) + string? Unit = null, + ReplProgressState State = ReplProgressState.Normal, + string? Details = null) : ReplInteractionEvent(DateTimeOffset.UtcNow) { /// diff --git a/src/Repl.Core/Interaction/ReplProgressState.cs b/src/Repl.Core/Interaction/ReplProgressState.cs new file mode 100644 index 0000000..e5c9679 --- /dev/null +++ b/src/Repl.Core/Interaction/ReplProgressState.cs @@ -0,0 +1,32 @@ +namespace Repl.Interaction; + +/// +/// Describes the semantic state of a progress update. +/// +public enum ReplProgressState +{ + /// + /// Normal in-flight progress. + /// + Normal, + + /// + /// Progress update in a warning state. + /// + Warning, + + /// + /// Progress update in an error state. + /// + Error, + + /// + /// Operation is active but no percentage is currently known. + /// + Indeterminate, + + /// + /// Clears any currently displayed progress indicator. + /// + Clear, +} diff --git a/src/Repl.Core/Interaction/WriteProgressRequest.cs b/src/Repl.Core/Interaction/WriteProgressRequest.cs index fe1ec4c..9000671 100644 --- a/src/Repl.Core/Interaction/WriteProgressRequest.cs +++ b/src/Repl.Core/Interaction/WriteProgressRequest.cs @@ -6,4 +6,6 @@ namespace Repl.Interaction; public sealed record WriteProgressRequest( string Label, double? Percent, + ReplProgressState State = ReplProgressState.Normal, + string? Details = null, CancellationToken CancellationToken = default) : InteractionRequest("__progress__", Label); diff --git a/src/Repl.Core/InteractionOptions.cs b/src/Repl.Core/InteractionOptions.cs index 869b846..6b8bd7f 100644 --- a/src/Repl.Core/InteractionOptions.cs +++ b/src/Repl.Core/InteractionOptions.cs @@ -20,6 +20,11 @@ public sealed class InteractionOptions /// public string ProgressTemplate { get; set; } = "{label}: {percent:0}%"; + /// + /// Gets or sets whether advanced terminal progress sequences should be emitted. + /// + public AdvancedProgressMode AdvancedProgressMode { get; set; } = AdvancedProgressMode.Auto; + /// /// Gets or sets fallback behavior for unanswered non-interactive prompts. /// diff --git a/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs b/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs index 672ba11..9f45d22 100644 --- a/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs +++ b/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs @@ -200,7 +200,8 @@ private static bool IsFrameworkInjectedParameter(ParameterInfo parameter) => || parameter.ParameterType == typeof(IReplKeyReader) || string.Equals(parameter.ParameterType.FullName, "Repl.Mcp.IMcpClientRoots", StringComparison.Ordinal) || string.Equals(parameter.ParameterType.FullName, "Repl.Mcp.IMcpSampling", StringComparison.Ordinal) - || string.Equals(parameter.ParameterType.FullName, "Repl.Mcp.IMcpElicitation", StringComparison.Ordinal); + || string.Equals(parameter.ParameterType.FullName, "Repl.Mcp.IMcpElicitation", StringComparison.Ordinal) + || string.Equals(parameter.ParameterType.FullName, "Repl.Mcp.IMcpFeedback", StringComparison.Ordinal); private static ReplArity ResolveArity(ParameterInfo parameter, ReplOptionAttribute? optionAttribute) { diff --git a/src/Repl.Core/Terminal/TerminalCapabilities.cs b/src/Repl.Core/Terminal/TerminalCapabilities.cs index 0ea3b9e..936f46f 100644 --- a/src/Repl.Core/Terminal/TerminalCapabilities.cs +++ b/src/Repl.Core/Terminal/TerminalCapabilities.cs @@ -20,4 +20,7 @@ public enum TerminalCapabilities /// Terminal sends VT-style input sequences (arrows/home/end/etc.). VtInput = 1 << 3, + + /// Terminal supports advanced progress reporting sequences. + ProgressReporting = 1 << 4, } diff --git a/src/Repl.Core/Terminal/TerminalCapabilitiesClassifier.cs b/src/Repl.Core/Terminal/TerminalCapabilitiesClassifier.cs index 57ea3a1..973528d 100644 --- a/src/Repl.Core/Terminal/TerminalCapabilitiesClassifier.cs +++ b/src/Repl.Core/Terminal/TerminalCapabilitiesClassifier.cs @@ -22,16 +22,29 @@ public static TerminalCapabilities InferFromIdentity(string? terminalIdentity) || normalized.Contains("tmux", StringComparison.Ordinal) || normalized.Contains("wezterm", StringComparison.Ordinal) || normalized.Contains("iterm", StringComparison.Ordinal) + || normalized.Contains("ghostty", StringComparison.Ordinal) + || normalized.Contains("conemu", StringComparison.Ordinal) + || normalized.Contains("windows terminal", StringComparison.Ordinal) || normalized.Contains("alacritty", StringComparison.Ordinal) || normalized.Contains("rxvt", StringComparison.Ordinal) || normalized.Contains("konsole", StringComparison.Ordinal) || normalized.Contains("gnome", StringComparison.Ordinal) || normalized.Contains("linux", StringComparison.Ordinal)) { - return TerminalCapabilities.IdentityReporting + var capabilities = TerminalCapabilities.IdentityReporting | TerminalCapabilities.Ansi | TerminalCapabilities.ResizeReporting | TerminalCapabilities.VtInput; + if (normalized.Contains("wezterm", StringComparison.Ordinal) + || normalized.Contains("iterm", StringComparison.Ordinal) + || normalized.Contains("ghostty", StringComparison.Ordinal) + || normalized.Contains("conemu", StringComparison.Ordinal) + || normalized.Contains("windows terminal", StringComparison.Ordinal)) + { + capabilities |= TerminalCapabilities.ProgressReporting; + } + + return capabilities; } return TerminalCapabilities.IdentityReporting; diff --git a/src/Repl.IntegrationTests/Given_InteractionChannel.cs b/src/Repl.IntegrationTests/Given_InteractionChannel.cs index fd14309..a6bc285 100644 --- a/src/Repl.IntegrationTests/Given_InteractionChannel.cs +++ b/src/Repl.IntegrationTests/Given_InteractionChannel.cs @@ -81,6 +81,67 @@ public void When_HandlerUsesInjectedStructuredProgress_Then_ProgressIsRendered() output.Text.Should().Contain("Importing: 75%"); } + [TestMethod] + [Description("Regression guard: verifies indeterminate progress helpers render a user-facing message while the command continues.")] + public void When_HandlerUsesIndeterminateProgressHelper_Then_MessageIsRendered() + { + var sut = ReplApp.Create(); + sut.Map("sync", async (IReplInteractionChannel channel, CancellationToken ct) => + { + await channel.WriteIndeterminateProgressAsync("Waiting", "Remote side", ct).ConfigureAwait(false); + return "done"; + }); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["sync", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("Waiting: Remote side"); + output.Text.Should().Contain("done"); + } + + [TestMethod] + [Description("Regression guard: verifies progress is cleared after successful execution so stale progress indicators do not linger.")] + public void When_CommandCompletesAfterProgress_Then_ProgressIsCleared() + { + var sut = ReplApp.Create(); + var observer = new RecordingObserver(); + sut.Core.ExecutionObserver = observer; + sut.Map("sync", async (IReplInteractionChannel channel, CancellationToken ct) => + { + await channel.WriteProgressAsync("Syncing", 25, ct).ConfigureAwait(false); + return "done"; + }); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["sync", "--no-logo"])); + + output.ExitCode.Should().Be(0); + observer.Interactions.OfType() + .Select(evt => evt.State) + .Should().ContainInOrder(ReplProgressState.Normal, ReplProgressState.Clear); + } + + [TestMethod] + [Description("Regression guard: verifies progress is cleared after handler failures so stale progress does not survive exceptions.")] + public void When_CommandFailsAfterProgress_Then_ProgressIsCleared() + { + var sut = ReplApp.Create(); + var observer = new RecordingObserver(); + sut.Core.ExecutionObserver = observer; + sut.Map("sync", async (IReplInteractionChannel channel, CancellationToken ct) => + { + await channel.WriteIndeterminateProgressAsync("Waiting", "Remote side", ct).ConfigureAwait(false); + throw new InvalidOperationException("boom"); + }); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["sync", "--no-logo"])); + + output.ExitCode.Should().Be(1); + output.Text.Should().Contain("boom"); + observer.Interactions.OfType() + .Select(evt => evt.State) + .Should().ContainInOrder(ReplProgressState.Indeterminate, ReplProgressState.Clear); + } + [TestMethod] [Description("Regression guard: verifies progress template and default label options are applied to injected progress rendering.")] public void When_ProgressTemplateAndDefaultLabelConfigured_Then_InjectedProgressUsesConfiguredFormatting() @@ -217,6 +278,17 @@ public void When_MultiChoiceAnswerIsPrefilled_Then_PrefilledIndicesAreUsed() output.Text.Should().Contain("0,2"); // 1-based "1,3" maps to 0-based [0,2] } + private sealed class RecordingObserver : IReplExecutionObserver + { + public List Interactions { get; } = []; + + public void OnResult(object? result) + { + } + + public void OnInteractionEvent(ReplInteractionEvent evt) => Interactions.Add(evt); + } + [TestMethod] [Description("Regression guard: verifies prefilled multi-choice answer with choice names.")] public void When_MultiChoiceAnswerIsPrefilledByName_Then_MatchingIndicesAreUsed() diff --git a/src/Repl.Mcp/IMcpFeedback.cs b/src/Repl.Mcp/IMcpFeedback.cs new file mode 100644 index 0000000..3fff6a1 --- /dev/null +++ b/src/Repl.Mcp/IMcpFeedback.cs @@ -0,0 +1,38 @@ +using ModelContextProtocol.Protocol; +using Repl.Interaction; + +namespace Repl.Mcp; + +/// +/// Provides direct access to MCP progress and message notifications from the connected client. +/// Inject this interface into command handlers when you need MCP-specific runtime feedback beyond +/// the portable abstraction. +/// +public interface IMcpFeedback +{ + /// + /// Gets a value indicating whether the connected MCP client can receive progress updates + /// for the current tool invocation. + /// + bool IsProgressSupported { get; } + + /// + /// Gets a value indicating whether the connected MCP client can receive logging/message notifications. + /// + bool IsLoggingSupported { get; } + + /// + /// Reports a structured progress update to the connected MCP client. + /// + ValueTask ReportProgressAsync( + ReplProgressEvent progress, + CancellationToken cancellationToken = default); + + /// + /// Sends a structured MCP message notification to the connected client. + /// + ValueTask SendMessageAsync( + LoggingLevel level, + object? data, + CancellationToken cancellationToken = default); +} diff --git a/src/Repl.Mcp/McpFeedbackService.cs b/src/Repl.Mcp/McpFeedbackService.cs new file mode 100644 index 0000000..1147e9b --- /dev/null +++ b/src/Repl.Mcp/McpFeedbackService.cs @@ -0,0 +1,115 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Repl.Interaction; + +namespace Repl.Mcp; + +/// +/// Internal implementation of backed by a live session. +/// +internal sealed class McpFeedbackService : IMcpFeedback +{ + private const string LoggerName = "repl.interaction"; + + private readonly AsyncLocal _progressToken = new(); + private McpServer? _server; + + public bool IsProgressSupported => _server is not null && _progressToken.Value is not null; + + public bool IsLoggingSupported => _server is not null; + + public async ValueTask ReportProgressAsync( + ReplProgressEvent progress, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(progress); + + if (!IsProgressSupported || progress.State == ReplProgressState.Clear || _progressToken.Value is not { } progressToken) + { + return; + } + + var percent = progress.ResolvePercent(); + await _server!.NotifyProgressAsync( + progressToken, + new ProgressNotificationValue + { + Progress = (float)Math.Clamp(percent ?? 0d, 0d, 100d), + Total = progress.State == ReplProgressState.Indeterminate || percent is null ? null : 100f, + Message = BuildProgressMessage(progress), + }, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async ValueTask SendMessageAsync( + LoggingLevel level, + object? data, + CancellationToken cancellationToken = default) + { + if (!IsLoggingSupported) + { + return; + } + + await _server!.SendNotificationAsync( + NotificationMethods.LoggingMessageNotification, + new LoggingMessageNotificationParams + { + Level = level, + Logger = LoggerName, + Data = SerializeData(data), + }, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + internal void AttachServer(McpServer server) => _server = server; + + internal IDisposable PushProgressToken(ProgressToken? progressToken) => + new ProgressTokenScope(_progressToken, progressToken); + + private static JsonElement SerializeData(object? data) => + data switch + { + null => JsonSerializer.SerializeToElement((string?)null, McpJsonContext.Default.String), + string text => JsonSerializer.SerializeToElement(text, McpJsonContext.Default.String), + bool value => JsonSerializer.SerializeToElement(value, McpJsonContext.Default.Boolean), + JsonElement element => element, + JsonObject value => JsonSerializer.SerializeToElement(value, McpJsonContext.Default.JsonObject), + _ => JsonSerializer.SerializeToElement(data.ToString(), McpJsonContext.Default.String), + }; + + private static string BuildProgressMessage(ReplProgressEvent progress) => + string.IsNullOrWhiteSpace(progress.Details) + ? progress.Label + : $"{progress.Label}: {progress.Details}"; + + private sealed class ProgressTokenScope : IDisposable + { + private readonly AsyncLocal _progressTokenSlot; + private readonly ProgressToken? _previousToken; + private bool _disposed; + + public ProgressTokenScope( + AsyncLocal progressTokenSlot, + ProgressToken? progressToken) + { + _progressTokenSlot = progressTokenSlot; + _previousToken = progressTokenSlot.Value; + _progressTokenSlot.Value = progressToken; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _progressTokenSlot.Value = _previousToken; + _disposed = true; + } + } +} diff --git a/src/Repl.Mcp/McpInteractionChannel.cs b/src/Repl.Mcp/McpInteractionChannel.cs index c1482ac..a62fe67 100644 --- a/src/Repl.Mcp/McpInteractionChannel.cs +++ b/src/Repl.Mcp/McpInteractionChannel.cs @@ -17,17 +17,20 @@ internal sealed class McpInteractionChannel : IReplInteractionChannel private readonly InteractivityMode _mode; private readonly McpServer? _server; private readonly ProgressToken? _progressToken; + private readonly IMcpFeedback? _feedback; public McpInteractionChannel( IReadOnlyDictionary prefillAnswers, InteractivityMode mode, McpServer? server = null, - ProgressToken? progressToken = null) + ProgressToken? progressToken = null, + IMcpFeedback? feedback = null) { _prefillAnswers = prefillAnswers; _mode = mode; _server = server; _progressToken = progressToken; + _feedback = feedback; } public async ValueTask AskChoiceAsync( @@ -192,6 +195,15 @@ public async ValueTask> AskMultiChoiceAsync( public async ValueTask WriteProgressAsync(string label, double? percent, CancellationToken cancellationToken) { + if (_feedback is not null) + { + await _feedback.ReportProgressAsync( + new ReplProgressEvent(label, Percent: percent), + cancellationToken) + .ConfigureAwait(false); + return; + } + if (_server is not null && _progressToken is { } token) { await _server.NotifyProgressAsync( @@ -226,6 +238,8 @@ public ValueTask DispatchAsync( return request switch { + WriteProgressRequest progress => CompleteBuiltInDispatchAsync( + WriteStructuredProgressAsync(progress, cancellationToken)), WriteNoticeRequest notice => CompleteBuiltInDispatchAsync( SendFeedbackAsync( LoggingLevel.Info, @@ -251,6 +265,12 @@ private async ValueTask SendFeedbackAsync( JsonElement data, CancellationToken cancellationToken) { + if (_feedback is not null) + { + await _feedback.SendMessageAsync(level, data, cancellationToken).ConfigureAwait(false); + return; + } + if (_server is null) { return; @@ -267,6 +287,65 @@ await _server.SendNotificationAsync( cancellationToken: cancellationToken).ConfigureAwait(false); } + private async ValueTask WriteStructuredProgressAsync( + WriteProgressRequest progress, + CancellationToken cancellationToken) + { + if (_feedback is not null) + { + await _feedback.ReportProgressAsync( + new ReplProgressEvent( + progress.Label, + Percent: progress.Percent, + State: progress.State, + Details: progress.Details), + cancellationToken) + .ConfigureAwait(false); + + if (progress.State == ReplProgressState.Warning) + { + await _feedback.SendMessageAsync( + LoggingLevel.Warning, + BuildProgressPayload(progress), + cancellationToken) + .ConfigureAwait(false); + } + else if (progress.State == ReplProgressState.Error) + { + await _feedback.SendMessageAsync( + LoggingLevel.Error, + BuildProgressPayload(progress), + cancellationToken) + .ConfigureAwait(false); + } + + return; + } + + await WriteProgressAsync(progress.Label, progress.Percent, cancellationToken).ConfigureAwait(false); + } + + private static JsonElement BuildProgressPayload(WriteProgressRequest progress) + { + var payload = new JsonObject + { + ["label"] = progress.Label, + ["state"] = progress.State.ToString(), + }; + + if (progress.Percent is { } percent) + { + payload["percent"] = percent; + } + + if (!string.IsNullOrWhiteSpace(progress.Details)) + { + payload["details"] = progress.Details; + } + + return JsonSerializer.SerializeToElement(payload, McpJsonContext.Default.JsonObject); + } + private static JsonElement SerializeProblem(WriteProblemRequest problem) { var payload = new JsonObject diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index 15de0b0..aff21ab 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -28,6 +28,7 @@ internal sealed class McpServerHandler private readonly McpClientRootsService _roots; private readonly McpSamplingService _sampling; private readonly McpElicitationService _elicitation; + private readonly McpFeedbackService _feedback; private readonly IServiceProvider _sessionServices; private readonly SemaphoreSlim _snapshotGate = new(initialCount: 1, maxCount: 1); private readonly Lock _refreshLock = new(); @@ -56,6 +57,7 @@ public McpServerHandler( _roots = new McpClientRootsService(app); _sampling = new McpSamplingService(); _elicitation = new McpElicitationService(); + _feedback = new McpFeedbackService(); _sessionServices = new McpServiceProviderOverlay( services, new Dictionary @@ -63,6 +65,7 @@ public McpServerHandler( [typeof(IMcpClientRoots)] = _roots, [typeof(IMcpSampling)] = _sampling, [typeof(IMcpElicitation)] = _elicitation, + [typeof(IMcpFeedback)] = _feedback, }); } @@ -418,6 +421,7 @@ private void AttachServer(McpServer? server) _roots.AttachServer(server); _sampling.AttachServer(server); _elicitation.AttachServer(server); + _feedback.AttachServer(server); EnsureRoutingSubscription(); EnsureRootsNotificationHandler(server); } diff --git a/src/Repl.Mcp/McpToolAdapter.cs b/src/Repl.Mcp/McpToolAdapter.cs index 0a04121..061db90 100644 --- a/src/Repl.Mcp/McpToolAdapter.cs +++ b/src/Repl.Mcp/McpToolAdapter.cs @@ -112,14 +112,17 @@ private async Task ExecuteThroughPipelineAsync( var outputWriter = new StringWriter(); var inputReader = new StringReader(string.Empty); + var feedback = _services.GetService(typeof(IMcpFeedback)) as IMcpFeedback; var interactionChannel = new McpInteractionChannel( - prefills, _options.InteractivityMode, server, progressToken); + prefills, _options.InteractivityMode, server, progressToken, feedback); var mcpServices = new McpServiceProviderOverlay( _services, new Dictionary { [typeof(IReplInteractionChannel)] = interactionChannel, }); + using var feedbackScope = (_services.GetService(typeof(IMcpFeedback)) as McpFeedbackService) + ?.PushProgressToken(progressToken); // Force JSON output — agents consume structured data, not human tables/banners. var effectiveTokens = new List(tokens.Count + 1) { "--output:json" }; diff --git a/src/Repl.McpTests/Given_McpAgentCapabilities.cs b/src/Repl.McpTests/Given_McpAgentCapabilities.cs index c8bc80a..b87ef8d 100644 --- a/src/Repl.McpTests/Given_McpAgentCapabilities.cs +++ b/src/Repl.McpTests/Given_McpAgentCapabilities.cs @@ -228,6 +228,19 @@ public async Task When_ToolInjectsElicitation_Then_SchemaHasNoElicitationParamet schema.Should().NotContain("elicitation", because: "IMcpElicitation is a framework-injected parameter and must not appear in the tool schema"); } + [TestMethod] + [Description("IMcpFeedback parameter does not appear in the tool's JSON schema.")] + public async Task When_ToolInjectsFeedback_Then_SchemaHasNoFeedbackParameter() + { + await using var fixture = await McpTestFixture.CreateAsync( + app => app.MapModule(new FeedbackModule())); + + var tools = await fixture.Client.ListToolsAsync().ConfigureAwait(false); + var tool = tools.Single(t => string.Equals(t.Name, "feedback_test", StringComparison.Ordinal)); + var schema = tool.JsonSchema.GetRawText(); + schema.Should().NotContain("feedback", because: "IMcpFeedback is a framework-injected parameter and must not appear in the tool schema"); + } + // ── Helpers ─────────────────────────────────────────────────────── private static McpClientOptions CreateElicitationClientOptions( @@ -304,4 +317,14 @@ public void Map(IReplMap app) }).ReadOnly(); } } + + private sealed class FeedbackModule : IReplModule + { + public void Map(IReplMap app) + { + app.Map( + "feedback test", + (IMcpFeedback feedback) => feedback.IsLoggingSupported.ToString()).ReadOnly(); + } + } } diff --git a/src/Repl.McpTests/Given_McpUserFeedback.cs b/src/Repl.McpTests/Given_McpUserFeedback.cs index 01073f8..75fe7bf 100644 --- a/src/Repl.McpTests/Given_McpUserFeedback.cs +++ b/src/Repl.McpTests/Given_McpUserFeedback.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using ModelContextProtocol; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using Repl.Interaction; @@ -23,6 +24,7 @@ public async Task When_ToolEmitsUserFeedback_Then_McpReceivesNotifications() toolName: "feedback", arguments: new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + await WaitForConditionAsync(() => notifications.Count >= 3).ConfigureAwait(false); AssertFeedbackResult(result: result, notifications: notifications); } finally @@ -31,8 +33,36 @@ public async Task When_ToolEmitsUserFeedback_Then_McpReceivesNotifications() } } - private static async Task CreateFeedbackFixtureAsync(McpClientOptions clientOptions) => - await McpTestFixture.CreateAsync( + [TestMethod] + [Description("Structured progress feedback is routed through MCP progress notifications and warning/error logging notifications.")] + public async Task When_ToolEmitsStructuredProgress_Then_McpReceivesProgressAndMessages() + { + var notifications = new List<(LoggingLevel Level, string Data)>(); + var progressUpdates = new List(); + var captureState = new NotificationCaptureState(notifications); + NotificationCaptureState.Current = captureState; + try + { + await using var fixture = await CreateStructuredProgressFixtureAsync(CreateClientOptions()).ConfigureAwait(false); + var progressHandler = CreateProgressCollector(progressUpdates); + + var result = await fixture.Client.CallToolAsync( + toolName: "feedback_progress", + arguments: new Dictionary(StringComparer.Ordinal), + progress: progressHandler).ConfigureAwait(false); + + await WaitForConditionAsync(() => progressUpdates.Count >= 4 && notifications.Count >= 2).ConfigureAwait(false); + AssertStructuredProgressResult(result, progressUpdates, notifications); + } + finally + { + NotificationCaptureState.Current = null; + } + } + + private static async Task CreateFeedbackFixtureAsync( + McpClientOptions clientOptions) => + await CreateFeedbackFixtureAsync( app => { app.Map("feedback", static async (IReplInteractionChannel interaction, CancellationToken cancellationToken) => @@ -47,9 +77,44 @@ await interaction.WriteProblemAsync( return "done"; }); }, + clientOptions).ConfigureAwait(false); + + private static async Task CreateFeedbackFixtureAsync( + Action configure, + McpClientOptions clientOptions) => + await McpTestFixture.CreateAsync( + configure, configureOptions: null, clientOptions: clientOptions).ConfigureAwait(false); + private static Task CreateStructuredProgressFixtureAsync(McpClientOptions clientOptions) => + CreateFeedbackFixtureAsync( + app => + { + app.Map("feedback progress", static async (IReplInteractionChannel interaction, CancellationToken cancellationToken) => + { + await interaction.WriteProgressAsync( + new ReplProgressEvent("Loading", Percent: 25), + cancellationToken).ConfigureAwait(false); + await interaction.WriteIndeterminateProgressAsync( + label: "Waiting", + details: "Remote side", + cancellationToken: cancellationToken).ConfigureAwait(false); + await interaction.WriteWarningProgressAsync( + label: "Retrying", + percent: 60, + details: "Transient issue", + cancellationToken: cancellationToken).ConfigureAwait(false); + await interaction.WriteErrorProgressAsync( + label: "Failed", + percent: 80, + details: "Permanent issue", + cancellationToken: cancellationToken).ConfigureAwait(false); + return "done"; + }); + }, + clientOptions); + private static McpClientOptions CreateClientOptions() => new() { @@ -83,12 +148,70 @@ private static ValueTask HandleLoggingNotificationAsync(JsonRpcNotification noti return ValueTask.CompletedTask; } + private static Progress CreateProgressCollector(List progressUpdates) => + new Progress(value => + { + lock (progressUpdates) + { + progressUpdates.Add(value); + } + }); + + private static async Task WaitForConditionAsync(Func predicate, int timeoutMs = 1000) + { + var started = Environment.TickCount64; + while (!predicate()) + { + if (Environment.TickCount64 - started > timeoutMs) + { + break; + } + + await Task.Delay(25).ConfigureAwait(false); + } + } + private static void AssertFeedbackResult(CallToolResult result, List<(LoggingLevel Level, string Data)> notifications) { result.IsError.Should().BeFalse(); - notifications.Should().Contain(entry => entry.Level == LoggingLevel.Info && string.Equals(entry.Data, "Connected", StringComparison.Ordinal)); - notifications.Should().Contain(entry => entry.Level == LoggingLevel.Warning && string.Equals(entry.Data, "Token expires soon", StringComparison.Ordinal)); - notifications.Should().Contain(entry => entry.Level == LoggingLevel.Error && entry.Data.Contains("\"summary\":\"Sync failed\"", StringComparison.Ordinal)); + notifications.Exists(entry => entry.Level == LoggingLevel.Info && entry.Data.Contains("Connected", StringComparison.Ordinal)) + .Should().BeTrue(); + notifications.Exists(entry => entry.Level == LoggingLevel.Warning && entry.Data.Contains("Token expires soon", StringComparison.Ordinal)) + .Should().BeTrue(); + notifications.Exists(entry => entry.Level == LoggingLevel.Error && entry.Data.Contains("\"summary\":\"Sync failed\"", StringComparison.Ordinal)) + .Should().BeTrue(); + } + + private static void AssertStructuredProgressResult( + CallToolResult result, + List progressUpdates, + List<(LoggingLevel Level, string Data)> notifications) + { + result.IsError.Should().BeFalse(); + progressUpdates.Exists(update => + update.Progress == 25f + && update.Total == 100f + && string.Equals(update.Message, "Loading", StringComparison.Ordinal)).Should().BeTrue(); + progressUpdates.Exists(update => + update.Progress == 0f + && update.Total == null + && string.Equals(update.Message, "Waiting: Remote side", StringComparison.Ordinal)).Should().BeTrue(); + progressUpdates.Exists(update => + update.Progress == 60f + && update.Total == 100f + && string.Equals(update.Message, "Retrying: Transient issue", StringComparison.Ordinal)).Should().BeTrue(); + progressUpdates.Exists(update => + update.Progress == 80f + && update.Total == 100f + && string.Equals(update.Message, "Failed: Permanent issue", StringComparison.Ordinal)).Should().BeTrue(); + notifications.Should().Contain(entry => + entry.Level == LoggingLevel.Warning + && entry.Data.Contains("\"state\":\"Warning\"", StringComparison.Ordinal) + && entry.Data.Contains("\"details\":\"Transient issue\"", StringComparison.Ordinal)); + notifications.Should().Contain(entry => + entry.Level == LoggingLevel.Error + && entry.Data.Contains("\"state\":\"Error\"", StringComparison.Ordinal) + && entry.Data.Contains("\"details\":\"Permanent issue\"", StringComparison.Ordinal)); } private sealed record NotificationCaptureState(List<(LoggingLevel Level, string Data)> Notifications) diff --git a/src/Repl.Tests/Given_ConsoleReplInteractionPresenter_AdvancedProgress.cs b/src/Repl.Tests/Given_ConsoleReplInteractionPresenter_AdvancedProgress.cs new file mode 100644 index 0000000..9289394 --- /dev/null +++ b/src/Repl.Tests/Given_ConsoleReplInteractionPresenter_AdvancedProgress.cs @@ -0,0 +1,68 @@ +using Repl.Tests.TerminalSupport; + +namespace Repl.Tests; + +[TestClass] +public sealed class Given_ConsoleReplInteractionPresenter_AdvancedProgress +{ + [TestMethod] + [Description("Advanced progress mode emits OSC 9;4 sequences for the supported progress states while preserving text output.")] + public async Task When_AdvancedProgressAlways_Then_PresenterEmitsOscSequences() + { + var harness = new TerminalHarness(cols: 80, rows: 12); + var presenter = new ConsoleReplInteractionPresenter( + new InteractionOptions { AdvancedProgressMode = AdvancedProgressMode.Always }, + new OutputOptions { AnsiMode = AnsiMode.Always }); + + using var session = ReplSessionIO.SetSession( + output: harness.Writer, + input: TextReader.Null, + ansiMode: AnsiMode.Always); + + await presenter.PresentAsync( + new ReplProgressEvent("Downloading", Percent: 42), + CancellationToken.None); + await presenter.PresentAsync( + new ReplProgressEvent("Retrying", Percent: 60, State: ReplProgressState.Warning, Details: "Transient issue"), + CancellationToken.None); + await presenter.PresentAsync( + new ReplProgressEvent("Failed", Percent: 80, State: ReplProgressState.Error, Details: "Permanent issue"), + CancellationToken.None); + await presenter.PresentAsync( + new ReplProgressEvent("Waiting", State: ReplProgressState.Indeterminate, Details: "Remote side"), + CancellationToken.None); + await presenter.PresentAsync( + new ReplProgressEvent(string.Empty, State: ReplProgressState.Clear), + CancellationToken.None); + + harness.RawOutput.Should().Contain("\u001b]9;4;1;42\u0007"); + harness.RawOutput.Should().Contain("\u001b]9;4;4;60\u0007"); + harness.RawOutput.Should().Contain("\u001b]9;4;2;80\u0007"); + harness.RawOutput.Should().Contain("\u001b]9;4;3;0\u0007"); + harness.RawOutput.Should().Contain("\u001b]9;4;0;0\u0007"); + harness.RawOutput.Should().Contain("Downloading: 42%"); + harness.RawOutput.Should().Contain("Waiting: Remote side"); + } + + [TestMethod] + [Description("Advanced progress mode can be disabled without suppressing the textual progress fallback.")] + public async Task When_AdvancedProgressNever_Then_TextRendersWithoutOscSequence() + { + var harness = new TerminalHarness(cols: 80, rows: 12); + var presenter = new ConsoleReplInteractionPresenter( + new InteractionOptions { AdvancedProgressMode = AdvancedProgressMode.Never }, + new OutputOptions { AnsiMode = AnsiMode.Always }); + + using var session = ReplSessionIO.SetSession( + output: harness.Writer, + input: TextReader.Null, + ansiMode: AnsiMode.Always); + + await presenter.PresentAsync( + new ReplProgressEvent("Downloading", Percent: 42), + CancellationToken.None); + + harness.RawOutput.Should().Contain("Downloading: 42%"); + harness.RawOutput.Should().NotContain("\u001b]9;4;"); + } +} diff --git a/src/Repl.Tests/Given_TerminalCapabilitiesClassifier.cs b/src/Repl.Tests/Given_TerminalCapabilitiesClassifier.cs new file mode 100644 index 0000000..51c212b --- /dev/null +++ b/src/Repl.Tests/Given_TerminalCapabilitiesClassifier.cs @@ -0,0 +1,17 @@ +namespace Repl.Tests; + +[TestClass] +public sealed class Given_TerminalCapabilitiesClassifier +{ + [TestMethod] + [Description("Known terminals with OSC progress support are tagged with ProgressReporting.")] + public void When_IdentitySupportsProgress_Then_ProgressCapabilityIsInferred() + { + TerminalCapabilitiesClassifier.InferFromIdentity("Windows Terminal") + .Should().HaveFlag(TerminalCapabilities.ProgressReporting); + TerminalCapabilitiesClassifier.InferFromIdentity("wezterm") + .Should().HaveFlag(TerminalCapabilities.ProgressReporting); + TerminalCapabilitiesClassifier.InferFromIdentity("iTerm2") + .Should().HaveFlag(TerminalCapabilities.ProgressReporting); + } +} From 818a8f35bacd4cd6f07782649871ab7d30c601ef Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 18 Apr 2026 12:49:42 -0400 Subject: [PATCH 05/16] sample(hosted): showcase user feedback in remote sessions --- samples/05-hosting-remote/Program.cs | 5 + samples/05-hosting-remote/README.md | 25 +- samples/05-hosting-remote/RemoteModule.cs | 76 ++++ samples/05-hosting-remote/wwwroot/index.html | 350 ++++++++++++++++++- 4 files changed, 439 insertions(+), 17 deletions(-) diff --git a/samples/05-hosting-remote/Program.cs b/samples/05-hosting-remote/Program.cs index bd4757a..38b5ce6 100644 --- a/samples/05-hosting-remote/Program.cs +++ b/samples/05-hosting-remote/Program.cs @@ -20,6 +20,11 @@ builder.Services.AddRepl((sp, replApp) => { replApp.UseEmbeddedConsoleProfile(); + replApp.Options(options => + { + options.Interaction.AdvancedProgressMode = AdvancedProgressMode.Auto; + options.Interaction.ProgressTemplate = "{label}: {percent:0}%"; + }); replApp.WithDescription("Remote REPL sample — settings, messaging, session tracking."); replApp.MapModule(new RemoteModule( sp.GetRequiredService(), diff --git a/samples/05-hosting-remote/README.md b/samples/05-hosting-remote/README.md index 23bd2a1..68c02e1 100644 --- a/samples/05-hosting-remote/README.md +++ b/samples/05-hosting-remote/README.md @@ -6,7 +6,7 @@ Remote-hosted REPL sample running over three transports: - Telnet-over-WebSocket - SignalR -It demonstrates shared state, session visibility, and transport-aware terminal metadata in one app. +It demonstrates shared state, session visibility, transport-aware terminal metadata, and hosted user feedback in one app. ## Architecture snapshot @@ -27,9 +27,11 @@ Browser terminal (xterm.js / VT-compatible, raw mode) | Session names | `who` | Quick list of connected session identifiers | | Session details | `sessions` | Transport, remote peer, terminal, screen, connected/idle durations | | Runtime diagnostics | `status` | Screen, terminal identity/capabilities, transport, runtime | -| Terminal capabilities | `debug` | Structured status rows showing ANSI support, key reading, window size, palette | +| Terminal capabilities | `debug` | Structured status rows showing ANSI support, progress reporting, window size, terminal, transport | | Interactive configuration | `configure` | Multi-choice interactive menu (rich arrow-key menu or text fallback) | | Maintenance actions | `maintenance` | Single-choice interactive menu with mnemonic shortcuts (`_Abort`, `_Retry`, `_Fail`) | +| Hosted feedback | `feedback demo`, `feedback fail` | Shows progress, warning, error, and indeterminate states over hosted transports | +| Browser feedback mirror | Panel above the terminal | Mirrors hosted `OSC 9;4` progress signals into a badge + progress bar | ## Run @@ -62,6 +64,8 @@ Try these commands directly in the REPL: status sessions who +feedback demo +feedback fail settings show maintenance settings set maintenance on watch @@ -73,14 +77,27 @@ send hello 1. Open two browser tabs on the sample page. 2. In tab A, connect and run: `watch` 3. In tab B, connect and run: `send hello from tab-b` -4. In tab B (or a third tab), run: `sessions` -5. Run: `status` in each tab and compare transport/terminal/screen values. +4. In tab B, run: `feedback demo` +5. In tab C, connect with `Plain (no ANSI)` and run: `feedback demo` +6. In tab B (or a third tab), run: `sessions` +7. Run: `status` or `debug` in each tab and compare transport/terminal/screen/feedback values. + +## Feedback Walkthrough + +1. Connect with `WebSocket`, `Telnet`, or `SignalR`. +2. Run `feedback demo`. +3. Watch the terminal render normal progress, an indeterminate state, a warning state, then a successful completion. +4. Watch the browser's **Feedback Mirror** panel update from the same hosted `OSC 9;4` stream. +5. Run `feedback fail` to see an error-state progress update and a final problem result. +6. Switch to `Plain (no ANSI)` and repeat. The terminal still shows the text fallback, but the panel stays idle because that client does not advertise `ProgressReporting`. ## Notes - `sessions` focuses on active server-side connections tracked by the sample. - `status` reflects metadata of the current REPL session. +- `debug` makes `ProgressReporting` explicit so you can tell whether advanced progress is negotiated for this client. - Telnet mode performs NAWS/terminal-type negotiation automatically in the browser client script. - This sample intentionally mixes in-band and out-of-band metadata paths for demonstration. - Use `--ansi:never` to force Plain mode (no ANSI), which demonstrates the text fallback for interactive prompts. +- The browser panel is a teaching aid: it parses the same hosted `OSC 9;4` progress signals that a richer terminal could consume for native UI integration. - Canonical support matrix and precedence order live in [`docs/terminal-metadata.md`](../../docs/terminal-metadata.md). diff --git a/samples/05-hosting-remote/RemoteModule.cs b/samples/05-hosting-remote/RemoteModule.cs index a3787cd..d5478d5 100644 --- a/samples/05-hosting-remote/RemoteModule.cs +++ b/samples/05-hosting-remote/RemoteModule.cs @@ -130,12 +130,79 @@ void Handler(string sender, string msg) => return Results.Ok("Cancelled."); }); + map.Context( + "feedback", + [Description("Demonstrate hosted user feedback states")] + (IReplMap m) => + { + m.Map( + "demo", + [Description("Run a successful feedback sequence with progress, warning, and indeterminate states")] + async (IReplInteractionChannel channel, IReplSessionInfo session, CancellationToken ct) => + { + await channel.WriteNoticeAsync( + session.TerminalCapabilities.HasFlag(TerminalCapabilities.ProgressReporting) + ? "Advanced progress reporting is available for this hosted session." + : "This client is using the text fallback for progress updates.", + ct).ConfigureAwait(false); + await channel.WriteProgressAsync("Preparing session", 10, ct).ConfigureAwait(false); + await DelayFeedbackStepAsync(ct).ConfigureAwait(false); + await channel.WriteIndeterminateProgressAsync( + "Waiting for remote worker", + "Negotiating with upstream services", + ct).ConfigureAwait(false); + await DelayFeedbackStepAsync(ct).ConfigureAwait(false); + await channel.WriteWarningProgressAsync( + "Retrying sync", + 55, + "Transient network jitter", + ct).ConfigureAwait(false); + await DelayFeedbackStepAsync(ct).ConfigureAwait(false); + await channel.WriteProgressAsync("Finalizing", 85, ct).ConfigureAwait(false); + await DelayFeedbackStepAsync(ct).ConfigureAwait(false); + await channel.WriteNoticeAsync("Feedback demo completed.", ct).ConfigureAwait(false); + return Results.Success("Feedback demo completed."); + }); + + m.Map( + "fail", + [Description("Run a failing feedback sequence with warning, error, and problem output")] + async (IReplInteractionChannel channel, CancellationToken ct) => + { + await channel.WriteNoticeAsync("Starting the failing feedback demo.", ct).ConfigureAwait(false); + await channel.WriteProgressAsync("Preparing session", 15, ct).ConfigureAwait(false); + await DelayFeedbackStepAsync(ct).ConfigureAwait(false); + await channel.WriteWarningProgressAsync( + "Retrying sync", + 45, + "Remote worker timed out", + ct).ConfigureAwait(false); + await DelayFeedbackStepAsync(ct).ConfigureAwait(false); + await channel.WriteErrorProgressAsync( + "Remote job failed", + 80, + "Final retry exhausted", + ct).ConfigureAwait(false); + await DelayFeedbackStepAsync(ct).ConfigureAwait(false); + await channel.WriteProblemAsync( + "Remote feedback demo failed", + "The remote worker stayed unavailable after several retries.", + "remote_feedback_failed", + ct).ConfigureAwait(false); + return Results.Error("remote_feedback_failed", "Remote feedback demo failed."); + }); + }); + map.Map( "debug", [Description("Show terminal capabilities for this session")] (IReplSessionInfo session) => new StatusRow[] { new("AnsiSupported", session.AnsiSupported.ToString(), session.AnsiSupported ? "ok" : "warning"), + new( + "ProgressReporting", + session.TerminalCapabilities.HasFlag(TerminalCapabilities.ProgressReporting) ? "supported" : "text fallback", + session.TerminalCapabilities.HasFlag(TerminalCapabilities.ProgressReporting) ? "ok" : "idle"), new("Capabilities", session.TerminalCapabilities.ToString(), "ok"), new("WindowSize", session.WindowSize is { } sz ? $"{sz.Width}x{sz.Height}" : "unknown", "ok"), new("Terminal", session.TerminalIdentity ?? "unknown", "ok"), @@ -168,6 +235,12 @@ void Handler(string sender, string msg) => new("Maintenance", settings.Get("maintenance") ?? "unknown", settings.Get("maintenance") == "on" ? "warning" : "ok"), new("Uptime", FormatUptime(), "ok"), new("Screen", session.WindowSize is { } sz ? $"{sz.Width}x{sz.Height}" : "unknown", "ok"), + new( + "Feedback", + session.TerminalCapabilities.HasFlag(TerminalCapabilities.ProgressReporting) + ? "advanced VT progress" + : "text fallback", + session.TerminalCapabilities.HasFlag(TerminalCapabilities.ProgressReporting) ? "ok" : "idle"), new("Transport", FormatTransport(session), "ok"), new("Terminal", FormatTerminal(session), "ok"), new("Server", Environment.MachineName, "ok"), @@ -282,4 +355,7 @@ private static async Task DisplayMessagesAsync( // Expected when user presses Enter. } } + + private static Task DelayFeedbackStepAsync(CancellationToken cancellationToken) => + Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); } diff --git a/samples/05-hosting-remote/wwwroot/index.html b/samples/05-hosting-remote/wwwroot/index.html index 0a45382..7db812f 100644 --- a/samples/05-hosting-remote/wwwroot/index.html +++ b/samples/05-hosting-remote/wwwroot/index.html @@ -196,6 +196,150 @@ padding: 4px 7px; } + .feedback-panel { + display: grid; + gap: 12px; + padding: 12px 14px; + } + + .feedback-header { + display: flex; + align-items: start; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + } + + .feedback-header h2 { + margin: 0; + font-size: 0.86rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--accent); + } + + .feedback-header p { + margin: 8px 0 0; + color: var(--muted); + font-size: 0.8rem; + line-height: 1.45; + max-width: 70ch; + } + + .feedback-state { + padding: 7px 10px; + border-radius: 999px; + border: 1px solid rgba(148, 163, 184, 0.28); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + background: rgba(15, 23, 42, 0.62); + color: var(--muted); + white-space: nowrap; + } + + .feedback-state.normal { + color: #082f49; + background: rgba(102, 217, 239, 0.92); + border-color: rgba(34, 211, 238, 0.7); + } + + .feedback-state.warning { + color: #3d2f00; + background: rgba(250, 204, 21, 0.9); + border-color: rgba(234, 179, 8, 0.7); + } + + .feedback-state.error { + color: #450a0a; + background: rgba(248, 113, 113, 0.92); + border-color: rgba(239, 68, 68, 0.7); + } + + .feedback-state.indeterminate { + color: #ecfeff; + background: rgba(14, 116, 144, 0.92); + border-color: rgba(6, 182, 212, 0.7); + } + + .feedback-track { + position: relative; + height: 12px; + border-radius: 999px; + overflow: hidden; + background: rgba(15, 23, 42, 0.82); + border: 1px solid rgba(100, 116, 139, 0.32); + } + + .feedback-fill { + position: absolute; + inset: 0 auto 0 0; + width: 0%; + border-radius: inherit; + transition: width 180ms ease; + background: linear-gradient(90deg, rgba(56, 189, 248, 0.92), rgba(102, 217, 239, 0.82)); + } + + .feedback-fill.warning { + background: linear-gradient(90deg, rgba(250, 204, 21, 0.95), rgba(245, 158, 11, 0.86)); + } + + .feedback-fill.error { + background: linear-gradient(90deg, rgba(248, 113, 113, 0.95), rgba(239, 68, 68, 0.86)); + } + + .feedback-fill.indeterminate { + width: 34%; + background: linear-gradient(90deg, rgba(34, 211, 238, 0.88), rgba(8, 145, 178, 0.78)); + animation: feedbackLoop 1.1s ease-in-out infinite; + } + + .feedback-copy { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + color: var(--muted); + font-size: 0.79rem; + line-height: 1.45; + } + + .feedback-copy strong { + color: var(--text); + font-size: 0.8rem; + } + + .feedback-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + } + + .feedback-actions button { + border: 1px solid rgba(102, 217, 239, 0.35); + background: rgba(8, 47, 73, 0.48); + color: #dbeafe; + border-radius: 10px; + padding: 7px 10px; + font-family: inherit; + font-size: 0.76rem; + font-weight: 700; + cursor: pointer; + } + + .feedback-actions button:hover { + border-color: rgba(102, 217, 239, 0.7); + background: rgba(14, 116, 144, 0.35); + } + + @keyframes feedbackLoop { + 0% { transform: translateX(-110%); } + 50% { transform: translateX(95%); } + 100% { transform: translateX(240%); } + } + .terminal-shell { overflow: hidden; border-radius: 16px; @@ -254,6 +398,11 @@ .terminal-shell { min-height: 260px; } + + .feedback-copy { + flex-direction: column; + align-items: start; + } } @@ -264,8 +413,9 @@

Remote REPL Playground

Multi-transport sample for WebSocket, Telnet-over-WebSocket, and SignalR. - Try status, sessions, who, watch, and send hello - to explore shared state and live session activity. + Try status, sessions, feedback demo, feedback fail, + watch, and send hello to explore shared state, live session activity, + and hosted progress feedback.

Disconnected
@@ -286,12 +436,15 @@

What You Can Do

Inspect the runtime with status, list active clients with sessions, try interactive menus with configure and maintenance, - update shared settings, and broadcast messages across tabs. + then run feedback demo and feedback fail to mirror OSC 9;4 progress + signals in the panel below.

status sessions who + feedback demo + feedback fail configure maintenance settings show maintenance @@ -301,6 +454,34 @@

What You Can Do

+ +
Terminal websocket
@@ -339,10 +520,19 @@

What You Can Do

const btn = document.getElementById('go'); const connStatus = document.getElementById('connStatus'); const modeBadge = document.getElementById('modeBadge'); + const feedbackState = document.getElementById('feedbackState'); + const feedbackFill = document.getElementById('feedbackFill'); + const feedbackSummary = document.getElementById('feedbackSummary'); + const feedbackDetail = document.getElementById('feedbackDetail'); + const feedbackPercent = document.getElementById('feedbackPercent'); + const quickActionButtons = Array.from(document.querySelectorAll('.feedback-actions button')); const query = new URLSearchParams(location.search); + const textDecoder = new TextDecoder(); + const oscProgressPrefix = '\u001b]9;4;'; let send = null; let resizeHandler = null; let inputHandler = null; + let progressChunkBuffer = ''; function setConnectionState(state) { connStatus.classList.remove('connected', 'connecting'); @@ -357,6 +547,139 @@

What You Can Do

} } + function resetFeedbackMirror() { + progressChunkBuffer = ''; + feedbackState.className = 'feedback-state idle'; + feedbackState.textContent = 'Idle'; + feedbackFill.className = 'feedback-fill idle'; + feedbackFill.style.width = '0%'; + feedbackSummary.textContent = 'Waiting for a feedback command.'; + feedbackDetail.textContent = 'Run feedback demo to watch the hosted session update this panel.'; + feedbackPercent.textContent = '0%'; + } + + function updateFeedbackMirror(state, percent, summary, detail) { + const clamped = Math.max(0, Math.min(100, Math.round(percent ?? 0))); + const label = state === 'normal' + ? 'Running' + : state === 'warning' + ? 'Warning' + : state === 'error' + ? 'Error' + : state === 'indeterminate' + ? 'Working' + : 'Idle'; + + feedbackState.className = `feedback-state ${state}`; + feedbackState.textContent = label; + feedbackFill.className = `feedback-fill ${state}`; + feedbackFill.style.width = state === 'indeterminate' ? '34%' : `${clamped}%`; + feedbackSummary.textContent = summary; + feedbackDetail.textContent = detail; + feedbackPercent.textContent = state === 'indeterminate' ? '...' : `${clamped}%`; + } + + function applyProgressSequence(sequence) { + const match = /\u001b\]9;4;(\d+);(\d+)\u0007/.exec(sequence); + if (!match) { + return; + } + + const stateCode = Number(match[1]); + const percent = Number(match[2]); + if (stateCode === 0) { + resetFeedbackMirror(); + return; + } + + if (stateCode === 1) { + updateFeedbackMirror( + 'normal', + percent, + 'Remote work is progressing.', + `The hosted session reported ${percent}% completion through OSC 9;4.`); + } else if (stateCode === 2) { + updateFeedbackMirror( + 'error', + percent, + 'The remote step reported an error.', + `The hosted session entered an error state at ${percent}% progress.`); + } else if (stateCode === 3) { + updateFeedbackMirror( + 'indeterminate', + 0, + 'The host is waiting on remote work.', + 'This state is indeterminate, so the hosted session reports activity without a fixed percentage.'); + } else if (stateCode === 4) { + updateFeedbackMirror( + 'warning', + percent, + 'The host is retrying after a warning.', + `The hosted session raised a warning state at ${percent}% progress.`); + } + } + + function splitTailCandidate(text) { + const max = Math.min(text.length, oscProgressPrefix.length - 1); + for (let length = max; length > 0; length--) { + if (oscProgressPrefix.startsWith(text.slice(-length))) { + return { + visible: text.slice(0, text.length - length), + remainder: text.slice(-length) + }; + } + } + + return { visible: text, remainder: '' }; + } + + function consumeTerminalChunk(chunk) { + progressChunkBuffer += chunk; + let visible = ''; + + while (progressChunkBuffer.length > 0) { + const start = progressChunkBuffer.indexOf(oscProgressPrefix); + if (start < 0) { + const split = splitTailCandidate(progressChunkBuffer); + visible += split.visible; + progressChunkBuffer = split.remainder; + break; + } + + visible += progressChunkBuffer.slice(0, start); + const end = progressChunkBuffer.indexOf('\u0007', start); + if (end < 0) { + progressChunkBuffer = progressChunkBuffer.slice(start); + break; + } + + applyProgressSequence(progressChunkBuffer.slice(start, end + 1)); + progressChunkBuffer = progressChunkBuffer.slice(end + 1); + } + + return visible; + } + + function writeTerminalOutput(chunk) { + const visible = consumeTerminalChunk(chunk); + if (visible) { + term.write(visible); + } + } + + function runQuickAction(command) { + if (!send) { + return; + } + + send(`${command}\r`); + term.focus(); + } + + quickActionButtons.forEach(button => { + button.addEventListener('click', () => runQuickAction(button.dataset.command)); + }); + function normalizeTransport(value) { const token = (value ?? '').trim().toLowerCase(); if (token === 'ws' || token === 'websocket') return 'ws'; @@ -372,6 +695,7 @@

What You Can Do

btn.disabled = true; setConnectionState('connecting'); term.clear(); + resetFeedbackMirror(); if (resizeHandler) { resizeHandler.dispose(); resizeHandler = null; } if (inputHandler) { inputHandler.dispose(); inputHandler = null; } if (mode === 'ws') connectWS(); @@ -398,7 +722,7 @@

What You Can Do

term.cols, term.rows, true, - ['Ansi', 'ResizeReporting', 'IdentityReporting', 'VtInput']); + ['Ansi', 'ResizeReporting', 'IdentityReporting', 'VtInput', 'ProgressReporting']); } function buildResize(cols, rows) { @@ -411,7 +735,7 @@

What You Can Do

cols: String(term.cols), rows: String(term.rows), ansi: 'true', - capabilities: 'Ansi,ResizeReporting,IdentityReporting,VtInput' + capabilities: 'Ansi,ResizeReporting,IdentityReporting,VtInput,ProgressReporting' }); return `?${params.toString()}`; } @@ -425,8 +749,8 @@

What You Can Do

function connectWS() { const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/repl${buildConnectionQuery()}`); - ws.onmessage = e => term.write(e.data); - ws.onclose = ws.onerror = () => { send = null; setConnectionState('disconnected'); term.writeln('\r\n[disconnected]'); btn.disabled = false; }; + ws.onmessage = e => writeTerminalOutput(e.data); + ws.onclose = ws.onerror = () => { send = null; setConnectionState('disconnected'); resetFeedbackMirror(); term.writeln('\r\n[disconnected]'); btn.disabled = false; }; send = d => { if (ws.readyState === 1) ws.send(d); }; inputHandler = term.onData(send); // Send initial DTTERM size so the VT probe detects it. @@ -451,12 +775,12 @@

What You Can Do

const ws = new WebSocket(`${wsProto}://${location.host}/ws/telnet${buildConnectionQuery()}`); ws.binaryType = 'arraybuffer'; - ws.onclose = ws.onerror = () => { send = null; setConnectionState('disconnected'); term.writeln('\r\n[disconnected]'); btn.disabled = false; }; + ws.onclose = ws.onerror = () => { send = null; setConnectionState('disconnected'); resetFeedbackMirror(); term.writeln('\r\n[disconnected]'); btn.disabled = false; }; ws.onmessage = e => { const raw = new Uint8Array(e.data); const vt = telnetParse(raw); - if (vt.length > 0) term.write(vt); + if (vt.length > 0) writeTerminalOutput(textDecoder.decode(vt)); }; send = d => { @@ -560,8 +884,8 @@

What You Can Do

capabilities: '' }); const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/repl?${params.toString()}`); - ws.onmessage = e => term.write(e.data); - ws.onclose = ws.onerror = () => { send = null; setConnectionState('disconnected'); term.writeln('\r\n[disconnected]'); btn.disabled = false; }; + ws.onmessage = e => writeTerminalOutput(e.data); + ws.onclose = ws.onerror = () => { send = null; setConnectionState('disconnected'); resetFeedbackMirror(); term.writeln('\r\n[disconnected]'); btn.disabled = false; }; send = d => { if (ws.readyState === 1) ws.send(d); }; inputHandler = term.onData(send); // Send hello with ansi=false so server knows this is a dumb terminal @@ -574,8 +898,8 @@

What You Can Do

function connectSR() { const hub = new signalR.HubConnectionBuilder().withUrl(`/hub/repl${buildConnectionQuery()}`).withAutomaticReconnect().build(); - hub.on('Output', t => term.write(t)); - hub.onclose(() => { send = null; setConnectionState('disconnected'); term.writeln('\r\n[disconnected]'); btn.disabled = false; }); + hub.on('Output', t => writeTerminalOutput(t)); + hub.onclose(() => { send = null; setConnectionState('disconnected'); resetFeedbackMirror(); term.writeln('\r\n[disconnected]'); btn.disabled = false; }); send = d => { if (hub.state === signalR.HubConnectionState.Connected) hub.invoke('OnInput', d); }; inputHandler = term.onData(send); // DTTERM in-band VT resize sequence. From b23a5b8c406162b19091ecb2be7cee158f6990cd Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 18 Apr 2026 12:49:51 -0400 Subject: [PATCH 06/16] sample(mcp): showcase progress and feedback notifications --- samples/08-mcp-server/Program.cs | 133 +++++++++++++++++++++++++++---- samples/08-mcp-server/README.md | 25 +++++- 2 files changed, 142 insertions(+), 16 deletions(-) diff --git a/samples/08-mcp-server/Program.cs b/samples/08-mcp-server/Program.cs index b57a653..7bfb5be 100644 --- a/samples/08-mcp-server/Program.cs +++ b/samples/08-mcp-server/Program.cs @@ -2,6 +2,7 @@ using System.Net; using Microsoft.Extensions.DependencyInjection; using Repl; +using Repl.Interaction; using Repl.Mcp; // ── A Repl app exposed as an MCP server for AI agents ────────────── @@ -115,7 +116,8 @@ The email must be unique across all contacts. Repl.Interaction.IReplInteractionChannel interaction, CancellationToken ct) => { // ── Phase 1: Read the file ───────────────────────────────── - await interaction.WriteProgressAsync("Reading CSV...", 0, ct); + await interaction.WriteNoticeAsync($"Starting import for '{file}'.", ct); + await interaction.WriteProgressAsync("Reading CSV...", 5, ct); var (headers, rawRows) = ContactCsvParser.ReadRaw(file); // ... validate file structure, reject malformed rows ... @@ -127,7 +129,10 @@ The email must be unique across all contacts. if (sampling.IsSupported) { - await interaction.WriteProgressAsync("Identifying columns...", 15, ct); + await interaction.WriteIndeterminateProgressAsync( + "Identifying columns...", + "Waiting for the connected MCP client to complete sampling.", + ct); var sampleRows = string.Join("\n", rawRows.Take(3).Select( row => string.Join(", ", row.Select((cell, i) => $"[{i}] \"{cell}\"")))); @@ -142,12 +147,21 @@ The email must be unique across all contacts. cancellationToken: ct); (nameCol, emailCol) = ContactCsvParser.ParseColumnMapping(mapping, nameCol, emailCol); + await interaction.WriteProgressAsync("Columns identified", 25, ct); + } + else + { + await interaction.WriteWarningProgressAsync( + "Sampling unavailable", + 25, + "Falling back to the default name/email column positions.", + ct); } var rows = ContactCsvParser.MapRows(rawRows, nameCol, emailCol); // ── Phase 3: Duplicate detection ─────────────────────────── - await interaction.WriteProgressAsync("Checking duplicates...", 40, ct); + await interaction.WriteProgressAsync("Checking duplicates...", 45, ct); var duplicates = ContactMatcher.FindCandidates(rows, contacts.All); // ── Phase 4: Conflict resolution (elicitation) ───────────── @@ -155,27 +169,54 @@ The email must be unique across all contacts. // Ask the user how to handle them through a structured form. var remaining = duplicates; - if (remaining.Count > 0 && elicitation.IsSupported) + if (remaining.Count > 0) { - string[] strategies = ["skip", "overwrite", "keep-both"]; - - var choice = await elicitation.ElicitChoiceAsync( - $"{remaining.Count} contact(s) may already exist. How should they be handled?", - strategies, + await interaction.WriteWarningProgressAsync( + "Possible duplicates detected", + 60, + $"{remaining.Count} potential contact matches need review.", ct); - if (choice is null) + if (elicitation.IsSupported) { - return Results.Cancelled("Import cancelled during conflict resolution."); - } + string[] strategies = ["skip", "overwrite", "keep-both"]; - rows = ContactMatcher.ApplyStrategy(rows, remaining, strategies[choice.Value]); + var choice = await elicitation.ElicitChoiceAsync( + $"{remaining.Count} contact(s) may already exist. How should they be handled?", + strategies, + ct); + + if (choice is null) + { + await interaction.WriteErrorProgressAsync( + "Import cancelled", + 60, + "The user cancelled during conflict resolution.", + ct); + await interaction.WriteProblemAsync( + "Import cancelled during conflict resolution.", + "No contacts were imported because the duplicate-handling step was cancelled.", + "import_cancelled", + ct); + return Results.Cancelled("Import cancelled during conflict resolution."); + } + + rows = ContactMatcher.ApplyStrategy(rows, remaining, strategies[choice.Value]); + await interaction.WriteProgressAsync("Conflicts resolved", 72, ct); + } + else + { + await interaction.WriteWarningAsync( + "Elicitation is unavailable, so duplicate handling falls back to the current rows.", + ct); + } } - // ── Phase 4: Commit ──────────────────────────────────────── - await interaction.WriteProgressAsync("Importing...", 70, ct); + // ── Phase 5: Commit ──────────────────────────────────────── + await interaction.WriteProgressAsync("Importing...", 85, ct); var imported = contacts.Import(rows); await interaction.WriteProgressAsync("Done", 100, ct); + await interaction.WriteNoticeAsync($"Imported {imported} of {rows.Count} contacts.", ct); return Results.Success($"Imported {imported} of {rows.Count} contacts."); }) @@ -189,6 +230,62 @@ Both steps are optional — the import falls back to positional columns and skip .LongRunning() .OpenWorld(); +app.Context("feedback", feedback => +{ + feedback.Map("demo", + async (Repl.Interaction.IReplInteractionChannel interaction, CancellationToken ct) => + { + await interaction.WriteNoticeAsync("Starting the MCP feedback demo.", ct); + await interaction.WriteProgressAsync("Preparing import workspace", 10, ct); + await FeedbackDemo.DelayAsync(ct); + await interaction.WriteIndeterminateProgressAsync( + "Waiting for agent review", + "Sampling or prompting may still be in progress.", + ct); + await FeedbackDemo.DelayAsync(ct); + await interaction.WriteWarningProgressAsync( + "Potential conflict detected", + 55, + "A duplicate contact may need user review.", + ct); + await FeedbackDemo.DelayAsync(ct); + await interaction.WriteProgressAsync("Finalizing import plan", 90, ct); + await FeedbackDemo.DelayAsync(ct); + await interaction.WriteNoticeAsync("Feedback demo completed.", ct); + return Results.Success("Feedback demo completed."); + }) + .WithDescription("Run a deterministic feedback sequence for MCP Inspector demos") + .LongRunning(); + + feedback.Map("fail", + async (Repl.Interaction.IReplInteractionChannel interaction, CancellationToken ct) => + { + await interaction.WriteNoticeAsync("Starting the failing MCP feedback demo.", ct); + await interaction.WriteProgressAsync("Preparing import workspace", 15, ct); + await FeedbackDemo.DelayAsync(ct); + await interaction.WriteWarningProgressAsync( + "Retrying remote validation", + 50, + "The validation worker timed out.", + ct); + await FeedbackDemo.DelayAsync(ct); + await interaction.WriteErrorProgressAsync( + "Validation failed", + 80, + "The worker stayed unavailable after the retry window.", + ct); + await FeedbackDemo.DelayAsync(ct); + await interaction.WriteProblemAsync( + "Feedback demo failed.", + "The sample emitted an error-state progress update before returning the tool error.", + "feedback_demo_failed", + ct); + return Results.Error("feedback_demo_failed", "Feedback demo failed."); + }) + .WithDescription("Run a failing feedback sequence that emits warning and error notifications") + .LongRunning(); +}); + // ── Interactive-only commands ────────────────────────────────────── app.Map("clear", async (Repl.Interaction.IReplInteractionChannel interaction, CancellationToken ct) => @@ -288,3 +385,9 @@ public static List ApplyStrategy( // ... apply user's chosen strategy (skip, overwrite, keep-both) ... rows; } + +internal static class FeedbackDemo +{ + public static Task DelayAsync(CancellationToken cancellationToken) => + Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); +} diff --git a/samples/08-mcp-server/README.md b/samples/08-mcp-server/README.md index e88089a..e827adf 100644 --- a/samples/08-mcp-server/README.md +++ b/samples/08-mcp-server/README.md @@ -5,13 +5,16 @@ Expose a Repl command graph as an MCP server for AI agents, including a minimal ## What this sample shows - `app.UseMcpServer()` — one line to enable MCP stdio server +- `IReplInteractionChannel` in MCP mode — portable notices, warnings, problems, and progress updates +- `feedback demo` / `feedback fail` — deterministic progress sequences that are easy to inspect in MCP Inspector - `.ReadOnly()` / `.Destructive()` / `.OpenWorld()` — behavioral annotations - `.AsResource()` — mark data-to-consult commands as MCP resources - `.AsPrompt()` — mark commands as MCP prompt sources - `.AsMcpAppResource()` — mark a command as a generated HTML MCP App resource - `.WithMcpAppBorder()` / `.WithMcpAppDisplayMode(...)` — add MCP Apps presentation preferences - `.AutomationHidden()` — hide interactive-only commands from agents -- `.WithDetails()` — rich descriptions that serve both `--help` and agents +- `.WithDetails()` — rich descriptions consumed by agents and documentation tools (not displayed in `--help`) +- `import {file}` — a realistic workflow that combines progress reporting, sampling, elicitation, and duplicate review ## Running @@ -37,6 +40,26 @@ Clients with MCP Apps support render the `contacts dashboard` tool's generated ` In the current Repl.Mcp version, MCP Apps are experimental and the UI handler returns generated HTML as a string. Future versions may add richer return types and asset helpers. +## Demo workflow + +In the interactive REPL, try: + +- `feedback demo` to emit a successful sequence with normal, indeterminate, and warning progress states +- `feedback fail` to emit warning and error progress, then finish with a problem result +- `import contacts.csv` to see the realistic workflow that uses sampling and elicitation when the connected client supports them + +In MCP Inspector: + +1. Start the sample in MCP mode. +2. Call `feedback_demo`. +3. Watch the tool emit `notifications/progress` during the run. +4. Call `feedback_fail`. +5. Watch the warning/error feedback arrive before the final tool error result. +6. Call `import` with any file name to see the longer workflow: + the tool reports progress while reading, column-mapping, duplicate review, and commit. + +The deterministic `feedback_*` tools make it easy to verify the host's notification rendering without depending on a real CSV file. + ## Agent configuration ### Claude Desktop From 5f0772dc4c3fa0fbfa53bf8829bb938f902890e8 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 18 Apr 2026 12:50:04 -0400 Subject: [PATCH 07/16] docs: document user feedback across interaction and MCP --- docs/interaction.md | 52 +++++ docs/mcp-agent-capabilities.md | 125 ++++++++-- docs/mcp-reference.md | 410 +++++++++++++++++++++++++++++++++ 3 files changed, 568 insertions(+), 19 deletions(-) create mode 100644 docs/mcp-reference.md diff --git a/docs/interaction.md b/docs/interaction.md index 7ab0b9a..b7cd75c 100644 --- a/docs/interaction.md +++ b/docs/interaction.md @@ -179,6 +179,58 @@ app.Map("import", async (IProgress progress, CancellationToke }); ``` +### Progress states and helpers + +When you need richer user feedback, use the `IReplInteractionChannel` progress helpers instead of treating progress like logs. + +```csharp +await channel.WriteProgressAsync("Preparing import", 10, cancellationToken); +await channel.WriteIndeterminateProgressAsync( + "Waiting for agent review", + "Sampling is still running.", + cancellationToken); +await channel.WriteWarningProgressAsync( + "Retrying duplicate check", + 55, + "The remote worker timed out once.", + cancellationToken); +await channel.WriteErrorProgressAsync( + "Import failed", + 80, + "The final retry window was exhausted.", + cancellationToken); +await channel.ClearProgressAsync(cancellationToken); +``` + +`ReplProgressEvent` now carries a `State` value: + +| State | Meaning | +|---|---| +| `Normal` | Regular progress update | +| `Warning` | Work is continuing, but the user should pay attention | +| `Error` | The current workflow has entered an error state | +| `Indeterminate` | Work is active but there is no meaningful percentage yet | +| `Clear` | Clear any visible progress indicator | + +Notes: + +- `WriteProgressAsync(string, double?)` remains the simple, backward-compatible API. +- `percent: null` does **not** imply indeterminate mode. Use `WriteIndeterminateProgressAsync(...)` or `State = Indeterminate` explicitly. +- Hosts can render these states differently. The built-in console presenter keeps the text fallback and, when enabled, also emits advanced terminal progress sequences. +- The framework clears visible progress automatically when a command completes, fails, or is cancelled. + +### Advanced terminal progress + +`InteractionOptions.AdvancedProgressMode` controls whether hosts should emit advanced progress sequences in addition to the normal text feedback: + +| Value | Behavior | +|---|---| +| `Auto` | Emit advanced progress when the host is interactive and the terminal looks compatible | +| `Always` | Always emit advanced progress when the host can write terminal control sequences | +| `Never` | Disable advanced progress and keep the text-only fallback | + +The built-in console presenter maps progress states to `OSC 9;4` when advanced terminal progress is enabled. This is intended for user-facing execution feedback such as taskbar progress bars or mirrored hosted-session UI, not for application logging. + --- ## Prefill with `--answer:*` diff --git a/docs/mcp-agent-capabilities.md b/docs/mcp-agent-capabilities.md index 42a4966..36b898f 100644 --- a/docs/mcp-agent-capabilities.md +++ b/docs/mcp-agent-capabilities.md @@ -1,26 +1,26 @@ -# Agent Capabilities: Sampling and Elicitation +# Agent Capabilities: Sampling, Elicitation, and Feedback -Use MCP [sampling](https://modelcontextprotocol.io/specification/2025-11-05/client/sampling) and [elicitation](https://modelcontextprotocol.io/specification/2025-11-05/client/elicitation) directly from your command handlers to interact with the connected agent client. +> **This page is for you if** you want your commands to call the LLM, ask the user for structured input, or send MCP-specific runtime feedback during execution. +> +> **Purpose:** Direct MCP sampling, elicitation, and feedback from command handlers. +> **Prerequisite:** [MCP overview](mcp-overview.md) +> **Related:** [Reference](mcp-reference.md) · [Advanced patterns](mcp-advanced.md) · [Interaction degradation](mcp-reference.md#interaction-in-mcp-mode) -See also: [sample 08-mcp-server](../samples/08-mcp-server/) for a working example that uses both in a CSV import workflow. - -Related guides: - -- [mcp-server.md](mcp-server.md) — interaction degradation (prefill/elicitation/sampling via `IReplInteractionChannel`) -- [mcp-advanced.md](mcp-advanced.md) — roots, dynamic tools, MCP Apps +See also: [sample 08-mcp-server](../samples/08-mcp-server/) for a working example that uses all three in a CSV import and feedback workflow. ## Overview -Repl provides two MCP capabilities as injectable interfaces: +Repl provides three MCP-oriented injectable interfaces: | Interface | MCP capability | What it does | |---|---|---| | `IMcpSampling` | [Sampling](https://modelcontextprotocol.io/specification/2025-11-05/client/sampling) | Ask the connected LLM to generate a completion | | `IMcpElicitation` | [Elicitation](https://modelcontextprotocol.io/specification/2025-11-05/client/elicitation) | Ask the user for structured input through the agent client | +| `IMcpFeedback` | Progress + logging/message notifications | Send MCP-specific runtime feedback during a tool call | -Both work like `IMcpClientRoots` — inject them into any command handler, check `IsSupported`, and use them. They are automatically excluded from MCP tool schemas. +They work like `IMcpClientRoots` — inject them into any command handler, check capability flags, and use them. They are automatically excluded from MCP tool schemas. -> **Note:** These interfaces give your commands _direct_ access to MCP capabilities. This is different from `IReplInteractionChannel`, which uses sampling and elicitation _internally_ as part of its [interaction degradation](mcp-server.md#interaction-in-mcp-mode) strategy. Use `IReplInteractionChannel` when you want portable prompts that work across CLI, REPL, and MCP. Use `IMcpSampling` / `IMcpElicitation` when you want MCP-specific behavior that only makes sense when an agent is connected. +> **Note:** These interfaces give your commands _direct_ access to MCP capabilities. This is different from `IReplInteractionChannel`, which uses sampling and elicitation _internally_ as part of its [interaction degradation](mcp-reference.md#interaction-in-mcp-mode) strategy and now maps user-facing notices/progress to MCP feedback automatically. Use `IReplInteractionChannel` when you want portable prompts and feedback that work across CLI, REPL, hosted sessions, and MCP. Use `IMcpSampling`, `IMcpElicitation`, or `IMcpFeedback` when you want behavior that only makes sense while an agent is connected. ## When to use these @@ -134,7 +134,7 @@ public interface IMcpElicitation bool IsSupported { get; } ValueTask ElicitTextAsync(string message, CancellationToken ct = default); ValueTask ElicitBooleanAsync(string message, CancellationToken ct = default); - ValueTask ElicitChoiceAsync(string message, IReadOnlyList choices, CancellationToken ct = default); + ValueTask ElicitChoiceAsync(string message, IReadOnlyList choices, CancellationToken ct = default); ValueTask ElicitNumberAsync(string message, CancellationToken ct = default); } ``` @@ -221,9 +221,90 @@ The most powerful pattern uses sampling and elicitation as successive steps: the Both steps are optional — the command works without them but produces better results when the agent supports them. +## Feedback + +`IMcpFeedback` gives you direct access to MCP progress and message notifications during a tool invocation. Unlike `IReplInteractionChannel`, this interface is intentionally MCP-specific. + +### API + +```csharp +public interface IMcpFeedback +{ + bool IsProgressSupported { get; } + bool IsLoggingSupported { get; } + + ValueTask ReportProgressAsync( + ReplProgressEvent progress, + CancellationToken cancellationToken = default); + + ValueTask SendMessageAsync( + LoggingLevel level, + object? data, + CancellationToken cancellationToken = default); +} +``` + +Use it when: + +- you need to control MCP progress/message notifications directly +- the behavior is meaningful only during an MCP tool call +- you do not need the same code path to render nicely in console or hosted sessions + +### Example: MCP-only feedback + +```csharp +app.Map("sync contacts", + async (IMcpFeedback feedback, CancellationToken ct) => +{ + if (feedback.IsLoggingSupported) + { + await feedback.SendMessageAsync(LoggingLevel.Info, "Starting sync.", ct); + } + + if (feedback.IsProgressSupported) + { + await feedback.ReportProgressAsync( + new ReplProgressEvent("Syncing", Percent: 25), + ct); + await feedback.ReportProgressAsync( + new ReplProgressEvent( + "Waiting for approval", + State: ReplProgressState.Indeterminate, + Details: "The remote agent is still reviewing the batch."), + ct); + } + + return Results.Success("Sync completed."); +}); +``` + +### Prefer the portable path first + +In most cases, this is better: + +```csharp +await interaction.WriteNoticeAsync("Starting sync", ct); +await interaction.WriteProgressAsync("Syncing", 25, ct); +await interaction.WriteIndeterminateProgressAsync( + "Waiting for approval", + "The remote agent is still reviewing the batch.", + ct); +``` + +That single code path works in: + +- console REPL sessions +- hosted remote sessions +- MCP clients + +So the rule of thumb is simple: + +- use `IReplInteractionChannel` for user-facing execution feedback +- use `IMcpFeedback` only when the behavior should exist in MCP and nowhere else + ## Graceful degradation -Both interfaces return `null` when the client does not support the capability. Design commands so the capability is an **enhancement**, not a requirement: +Design commands so these capabilities are **enhancements**, not hard requirements: ```csharp // Best: optional enhancement — command works either way @@ -238,6 +319,12 @@ if (!elicitation.IsSupported) return Results.Error("needs-elicitation", "This command requires elicitation support."); ``` +For `IMcpFeedback`, the same idea applies: + +- check `IsProgressSupported` before sending MCP-only progress directly +- check `IsLoggingSupported` before sending MCP-only messages directly +- prefer `IReplInteractionChannel` when the feedback should still render well outside MCP + ## Client compatibility Not all MCP clients support sampling and elicitation. The table below lists agents with **confirmed support** — agents not listed either do not support these capabilities or have not been validated. @@ -248,12 +335,12 @@ Not all MCP clients support sampling and elicitation. The table below lists agen Check [mcp-availability.com](https://mcp-availability.com/) for the latest data. Support is expanding rapidly — design your commands to degrade gracefully so they work everywhere even when a capability is missing. -## IMcpSampling vs IReplInteractionChannel +## Direct MCP interfaces vs IReplInteractionChannel -| | `IMcpSampling` / `IMcpElicitation` | `IReplInteractionChannel` | +| | `IMcpSampling` / `IMcpElicitation` / `IMcpFeedback` | `IReplInteractionChannel` | |---|---|---| -| **Purpose** | Direct MCP capability access for data processing and user input | Portable user interaction (prompts, confirmations) | +| **Purpose** | Direct MCP capability access for data processing, user input, or runtime feedback | Portable user interaction and execution feedback | | **Works in CLI/REPL** | No (`IsSupported` = false) | Yes (renders console prompts) | -| **Works in MCP** | When client supports the capability | Always (with [degradation tiers](mcp-server.md#interaction-in-mcp-mode)) | -| **Who answers** | Sampling: the LLM. Elicitation: the user. | The user (or LLM as fallback in MCP) | -| **Use when** | The command needs AI processing or rich structured input as part of a workflow | You need a simple confirmation, choice, or text prompt that works everywhere | +| **Works in MCP** | When the capability is available during the current tool call | Always (with [degradation tiers](mcp-reference.md#interaction-in-mcp-mode)) | +| **Who answers** | Sampling: the LLM. Elicitation: the user. Feedback: the tool emits notifications. | The user (or LLM as fallback in MCP), plus portable notices/progress from the tool | +| **Use when** | The command needs MCP-only behavior as part of a workflow | You need prompts or user-facing feedback that should work everywhere | diff --git a/docs/mcp-reference.md b/docs/mcp-reference.md new file mode 100644 index 0000000..2890ef8 --- /dev/null +++ b/docs/mcp-reference.md @@ -0,0 +1,410 @@ +# MCP Server Reference + +> **This page is for you if** you already have an MCP server working and need to look up specific features, options, or behaviors. +> +> **Purpose:** Complete reference for MCP server features. Consult, don't read end-to-end. +> **Prerequisite:** [MCP overview](mcp-overview.md) +> **Related:** [Advanced patterns](mcp-advanced.md) · [Sampling & elicitation](mcp-agent-capabilities.md) · [Transports](mcp-transports.md) + +## Rich descriptions + +`WithDetails()` provides extended markdown content for agent tool descriptions: + +```csharp +app.Map("deploy {env}", handler) + .WithDescription("Deploy the application") // short summary (shown everywhere) + .WithDetails(""" + Deploys to the specified environment. + + Prerequisites: + - Valid credentials in ~/.config/deploy + - Target environment must be provisioned + """); +``` + +`Description` is visible in help and tool listings. `Details` is consumed by agents and documentation tools — **not** displayed in terminal `--help`. + +## How commands map to MCP primitives + +| Command markers | Tool? | Resource? | Prompt? | +|---|---|---|---| +| _(none)_ | Yes | No | No | +| `.ReadOnly()` | Yes | Yes (auto-promoted) | No | +| `.ReadOnly()` + `AutoPromoteReadOnlyToResources = false` | Yes | No | No | +| `.AsResource()` | No | Yes | No | +| `.AsResource()` + `ResourceFallbackToTools = true` | Yes | Yes | No | +| `.ReadOnly().AsResource()` | Yes | Yes | No | +| `.AsPrompt()` | No | No | Yes | +| `.AsPrompt()` + `PromptFallbackToTools = true` | Yes | No | Yes | +| `.AsMcpAppResource()` | Yes (launcher text) | Yes (`ui://` HTML resource) | No | +| `.AutomationHidden()` | No | No | No | + +> **Compatibility fallback:** Only ~39% of clients support resources and ~38% support prompts. Enable `ResourceFallbackToTools` and/or `PromptFallbackToTools` to also expose them as tools: +> +> ```csharp +> app.UseMcpServer(o => +> { +> o.ResourceFallbackToTools = true; +> o.PromptFallbackToTools = true; +> o.AutoPromoteReadOnlyToResources = false; // opt out of ReadOnly → resource +> }); +> ``` + +## MCP Apps + +`AsMcpAppResource()` maps a command as a `ui://` HTML resource with metadata for capable hosts: + +```csharp +app.Map("contacts dashboard", (IContactDb contacts) => BuildHtml(contacts)) + .WithDescription("Open the contacts dashboard") + .AsMcpAppResource() + .WithMcpAppBorder(); +``` + +| Behavior | Detail | +|---|---| +| Tool calls return | Launcher text (not HTML) | +| `resources/read` returns | `text/html;profile=mcp-app` | +| CSP, permissions, borders | Emitted as `_meta.ui` on the UI resource content | +| Clients without MCP Apps | Receive the tool's normal text result | + +> **Experimental:** `AsMcpAppResource()` handlers should return `string`, `Task`, or `ValueTask`. Richer return types and asset helpers may be added later. + +### CSP metadata + +For HTML that loads external assets: + +```csharp +app.Map("contacts dashboard", (IContactDb contacts) => BuildHtml(contacts)) + .AsMcpAppResource() + .WithMcpAppCsp(new McpAppCsp + { + ResourceDomains = ["https://cdn.example.com"], + ConnectDomains = ["https://api.example.com"], + }); +``` + +### Explicit URIs + +Pass an explicit URI for a stable custom value: + +```csharp +.AsMcpAppResource("ui://contacts/summary"); +``` + +When omitted, Repl generates a `ui://` template from the route path (e.g., `viewer session {id:int} attach` → `ui://viewer/session/{id}/attach`). Route constraints are validated at dispatch time, not in the URI template. + +### Raw UI resources + +For UI not backed by a Repl command: + +```csharp +app.UseMcpServer(o => +{ + o.UiResource("ui://custom/app", () => "..."); +}); +``` + +## JSON Schema generation + +Route constraints and handler parameter types produce typed JSON Schema: + +| Repl type | JSON Schema | Format | +|---|---|---| +| `string` | `string` | — | +| `int` | `integer` | — | +| `long` | `integer` | — | +| `bool` | `boolean` | — | +| `double`, `decimal` | `number` | — | +| `enum` | `string` + `enum: [...]` | — | +| `List` | `array` + `items` | — | +| `{x:email}` | `string` | `email` | +| `{x:guid}` | `string` | `uuid` | +| `{x:date}` | `string` | `date` | +| `{x:datetime}` | `string` | `date-time` | +| `{x:uri}` | `string` | `uri` | +| `{x:time}` | `string` | `time` | +| `{x:timespan}` | `string` | `duration` | + +Route arguments → **required** properties. Options with defaults → **optional** properties. `[Description]` attributes → schema `description` field. + +## Tool naming + +MCP tools are flat. Context segments are flattened: + +| Route | Tool name | +|---|---| +| `greet` | `greet` | +| `contact add` | `contact_add` | +| `contact {id} notes` | `contact_notes` (`id` → required property) | +| `project {pid} task {tid}` | `project_task` (both → required properties) | + +Separator is configurable: + +```csharp +app.UseMcpServer(o => o.ToolNamingSeparator = ToolNamingSeparator.Slash); +// contact add → contact/add +``` + +Duplicate flattened names cause a startup error suggesting a different separator. + +## Interaction in MCP mode + +Commands using runtime prompts (`AskChoiceAsync`, `AskConfirmationAsync`, etc.) degrade through tiers: + +| Tier | Mechanism | When | +|---|---|---| +| 1. Prefill | Values from tool arguments (`answer.confirm=yes`) | Always tried first | +| 2. Elicitation | Structured form request to user through agent client | `PrefillThenElicitation` + client supports it | +| 3. Sampling | LLM answers on behalf of user | `PrefillThenElicitation` or `PrefillThenSampling` + client supports it | +| 4. Default/Fail | Use default value or fail with descriptive error | Fallback | + +`AskSecretAsync` is **always prefill-only**. + +| Mode | Behavior | +|---|---| +| `PrefillThenFail` (default) | Prefill or fail — safest, works with all clients | +| `PrefillThenDefaults` | Prefill, then use prompt defaults | +| `PrefillThenElicitation` | Prefill → elicitation → sampling → fail — best UX | +| `PrefillThenSampling` | Prefill → sampling → fail | + +```csharp +app.UseMcpServer(o => o.InteractivityMode = InteractivityMode.PrefillThenElicitation); +``` + +> Commands can also use sampling and elicitation directly via `IMcpSampling` and `IMcpElicitation`. See [mcp-agent-capabilities.md](mcp-agent-capabilities.md). + +## Output rules + +| Method | Where it goes | Use? | +|---|---|---| +| **Return value** | `CallToolResult.Content` (JSON) | **Yes.** Preferred for all data. | +| **`IReplInteractionChannel`** | MCP primitives (progress, prompts, user-facing notices/problems) | **Yes.** Portable feedback that also works outside MCP. | +| **`IMcpFeedback`** | MCP progress and logging/message notifications | **Yes.** MCP-specific feedback when you need direct control. | +| **`ReplSessionIO.Output`** | Session output | Advanced cases only. | +| **`Console.WriteLine`** | Bypasses Repl abstraction | **No.** Anti-pattern in MCP. | +| **`Console.OpenStandardOutput()`** | MCP stdio transport directly | **Never.** Corrupts JSON-RPC. | + +> **Why this matters:** Console-style writes blur the boundary between result data, progress, logs, and protocol traffic. In MCP, this ranges from confusing agent behavior to protocol corruption. + +`WriteProgressAsync` maps to MCP progress notifications. `WriteStatusAsync` maps to log messages (`level: info`): + +```csharp +app.Map("import", async (IReplInteractionChannel interaction, CancellationToken ct) => +{ + await interaction.WriteProgressAsync("Importing...", 0, ct); + // ... work ... + await interaction.WriteProgressAsync("Done", 100, ct); + return Results.Success("Imported."); +}); +``` + +### Runtime feedback mapping + +The interaction channel is the preferred API when the feedback should stay portable across console, hosted sessions, and MCP. + +| Repl API | MCP behavior | +|---|---| +| `WriteProgressAsync("Label", 40)` | `notifications/progress` with `progress = 40`, `total = 100` | +| `WriteIndeterminateProgressAsync(...)` | `notifications/progress` with a message and no `total` | +| `WriteWarningProgressAsync(...)` | `notifications/progress` plus a warning-level message notification | +| `WriteErrorProgressAsync(...)` | `notifications/progress` plus an error-level message notification | +| `WriteNoticeAsync(...)` | info-level message notification | +| `WriteWarningAsync(...)` | warning-level message notification | +| `WriteProblemAsync(...)` | error-level message notification | + +Notes: + +- `ClearProgressAsync()` clears local host rendering. MCP clients typically just stop receiving progress updates and then see the final tool result. +- The final tool result still comes from the handler's return value (`Results.Success(...)`, `Results.Error(...)`, and so on). Progress and message notifications are intermediate feedback, not replacements for the result. +- Use `IMcpFeedback` only when the behavior is intentionally MCP-specific. Prefer `IReplInteractionChannel` when the same command should behave well in console and hosted sessions too. + +## Controlling which commands are exposed + +| Strategy | Granularity | Example | +|---|---|---| +| `.AutomationHidden()` | Per-command | Interactive-only commands | +| `.Hidden()` | Per-command | Hidden from all surfaces | +| `CommandFilter` | App-level | `o.CommandFilter = c => !c.Path.StartsWith("admin")` | +| Module presence + `Programmatic` | Per-module | Entire feature areas | + +```csharp +app.UseMcpServer(o => +{ + o.CommandFilter = cmd => !cmd.Path.StartsWith("debug", StringComparison.OrdinalIgnoreCase); +}); +``` + +## Configuration options + +```csharp +app.UseMcpServer(o => +{ + o.ServerName = "MyApp"; // MCP initialize response + o.ServerVersion = "1.0.0"; // MCP initialize response + o.ContextName = "mcp"; // myapp {ContextName} serve + o.ToolNamingSeparator = ToolNamingSeparator.Underscore; // contact_add + o.ResourceUriScheme = "repl"; // resource URIs: repl://path + o.InteractivityMode = InteractivityMode.PrefillThenFail; // interaction degradation + o.ResourceFallbackToTools = false; // also expose resources as tools + o.PromptFallbackToTools = false; // also expose prompts as tools + o.DynamicToolCompatibility = DynamicToolCompatibilityMode.Disabled; // shim for weak clients + o.EnableApps = false; // usually auto-enabled by MCP App mappings + o.CommandFilter = cmd => true; // filter which commands become tools + o.Prompt("summarize", (string topic) => ...); // explicit prompt registration + o.UiResource("ui://custom/app", () => "..."); // raw MCP App HTML resource +}); +``` + +## Agent configuration + +### Claude Desktop + +**File:** `~/.config/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows) + +```json +{ + "mcpServers": { + "myapp": { + "command": "myapp", + "args": ["mcp", "serve"] + } + } +} +``` + +### Claude Code + +**File:** `~/.claude.json` or project `.claude/settings.json` + +```json +{ + "mcpServers": { + "myapp": { + "command": "myapp", + "args": ["mcp", "serve"] + } + } +} +``` + +### VS Code (GitHub Copilot) + +**File:** `.vscode/mcp.json` (workspace) or `~/.mcp.json` (global) + +```json +{ + "servers": { + "myapp": { + "type": "stdio", + "command": "myapp", + "args": ["mcp", "serve"] + } + } +} +``` + +### Cursor + +**File:** `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global) + +```json +{ + "mcpServers": { + "myapp": { + "command": "myapp", + "args": ["mcp", "serve"] + } + } +} +``` + +### Debugging with MCP Inspector + +```bash +npx @modelcontextprotocol/inspector myapp mcp serve +``` + +## Client compatibility + +Feature support varies across agents. Check [mcp-availability.com](https://mcp-availability.com/) for current data. + +| Feature | Claude Desktop | Claude Code | Codex | VS Code Copilot | Cursor | Continue | +|---|---|---|---|---|---|---| +| Tools | Yes | Yes | Yes | Yes | Yes | Yes | +| Resources | Yes | — | — | Yes | Yes | — | +| Prompts | Yes | — | — | Yes | — | Yes | +| Discovery (`list_changed`) | — | Yes | — | — | — | — | +| Sampling | — | — | — | Yes | — | — | +| Elicitation | — | — | — | Yes | — | — | + +### MCP Apps host compatibility + +| Host | MCP Apps UI | `fullscreen` | `pip` | Notes | +|---|---|---|---|---| +| VS Code Copilot | Yes | No | No | Inline in chat only; see [VS Code MCP developer guide](https://code.visualstudio.com/api/extension-guides/ai/mcp) | +| Microsoft 365 Copilot | Yes | Yes | No | Supports fullscreen widgets; see [M365 Copilot UI widgets](https://learn.microsoft.com/en-us/microsoft-365/copilot/extensibility/declarative-agent-ui-widgets) | +| Other hosts | Varies | Varies | Varies | Check `availableDisplayModes` or fall back to inline | + +## Known limitations + +- **Collection parameters** (`List`, `int[]`): MCP passes JSON arrays as a single element. The CLI binding layer expects repeated values (`--tag vip --tag priority`), so collection parameters are not correctly bound from MCP tool calls yet. Use string parameters with custom parsing as a workaround. +- **Parameterized resources**: Commands with route parameters (e.g. `config {env}`) marked `.AsResource()` are exposed as MCP resource templates with URI variables (e.g. `repl://config/{env}`). Agents read them via `resources/read` with the concrete URI. + +## Publishing as a dotnet tool MCP server + +Repl apps can be published as NuGet tools for zero-config agent discovery. + +### Project configuration + +```xml + + true + myapp + McpServer + +``` + +### `.mcp/server.json` + +Include this file in the package to enable registry-based discovery: + +```json +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", + "name": "io.github.myorg/myapp", + "description": "My application MCP server", + "version": "1.0.0", + "packages": [ + { + "registryType": "nuget", + "registryBaseUrl": "https://api.nuget.org", + "identifier": "MyApp", + "version": "1.0.0", + "transport": { "type": "stdio" }, + "packageArguments": [ + { "type": "positional", "value": "mcp" }, + { "type": "positional", "value": "serve" } + ], + "environmentVariables": [] + } + ] +} +``` + +### Publishing and installation + +```bash +# Publish +dotnet pack -c Release +dotnet nuget push bin/Release/MyApp.1.0.0.nupkg --source https://api.nuget.org/v3/index.json + +# Install and run +dotnet tool install -g MyApp +myapp mcp serve + +# Run without install +dnx -y MyApp -- mcp serve +``` + +NuGet.org discovery: `nuget.org/packages?packagetype=mcpserver` From af38b8c5c9f356fe4c4deb5df37baec37064f645 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 18 Apr 2026 15:20:14 -0400 Subject: [PATCH 08/16] fix: address feedback review findings and CI failures --- samples/08-mcp-server/Program.cs | 5 +++-- .../ConsoleReplInteractionPresenter.cs | 4 ++-- src/Repl.Mcp/README.md | 1 + src/Repl.McpTests/Given_McpUserFeedback.cs | 19 ++++++++++++------- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/samples/08-mcp-server/Program.cs b/samples/08-mcp-server/Program.cs index 7bfb5be..5ed64a0 100644 --- a/samples/08-mcp-server/Program.cs +++ b/samples/08-mcp-server/Program.cs @@ -343,9 +343,10 @@ public static (int NameCol, int EmailCol) ParseColumnMapping(string? llmResponse var nameCol = defaultName; var emailCol = defaultEmail; - foreach (var part in llmResponse.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + foreach (var kv in llmResponse + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Select(part => part.Split('='))) { - var kv = part.Split('='); if (kv.Length == 2 && int.TryParse(kv[1], out var idx)) { if (kv[0].Contains("name", StringComparison.OrdinalIgnoreCase)) diff --git a/src/Repl.Core/Console/ConsoleReplInteractionPresenter.cs b/src/Repl.Core/Console/ConsoleReplInteractionPresenter.cs index 1e3d227..7f65d7f 100644 --- a/src/Repl.Core/Console/ConsoleReplInteractionPresenter.cs +++ b/src/Repl.Core/Console/ConsoleReplInteractionPresenter.cs @@ -85,10 +85,10 @@ private async ValueTask WriteProgressAsync(ReplProgressEvent progress) var percent = progress.ResolvePercent(); var payload = FormatProgress(progress, percent); await TryWriteAdvancedProgressAsync(progress).ConfigureAwait(false); - if (!_rewriteProgress) + if (!_rewriteProgress || (progress.State == ReplProgressState.Normal && percent is null)) { await CloseProgressLineIfNeededAsync().ConfigureAwait(false); - await ReplSessionIO.Output.WriteLineAsync(Styled(payload, _palette?.ProgressStyle)).ConfigureAwait(false); + await ReplSessionIO.Output.WriteLineAsync(Styled(payload, ResolveProgressStyle(progress.State))).ConfigureAwait(false); return; } diff --git a/src/Repl.Mcp/README.md b/src/Repl.Mcp/README.md index 6031b5c..4cf495c 100644 --- a/src/Repl.Mcp/README.md +++ b/src/Repl.Mcp/README.md @@ -18,6 +18,7 @@ myapp # still a CLI / interactive REPL ``` `IReplInteractionChannel` user feedback maps to MCP-native transports: + - progress -> progress notifications - notice / warning / problem feedback -> MCP message notifications diff --git a/src/Repl.McpTests/Given_McpUserFeedback.cs b/src/Repl.McpTests/Given_McpUserFeedback.cs index 75fe7bf..7d91b1a 100644 --- a/src/Repl.McpTests/Given_McpUserFeedback.cs +++ b/src/Repl.McpTests/Given_McpUserFeedback.cs @@ -187,22 +187,24 @@ private static void AssertStructuredProgressResult( List progressUpdates, List<(LoggingLevel Level, string Data)> notifications) { + const float epsilon = 0.0001f; + result.IsError.Should().BeFalse(); progressUpdates.Exists(update => - update.Progress == 25f - && update.Total == 100f + NearlyEqual(update.Progress, 25f, epsilon) + && update.Total is float total && NearlyEqual(total, 100f, epsilon) && string.Equals(update.Message, "Loading", StringComparison.Ordinal)).Should().BeTrue(); progressUpdates.Exists(update => - update.Progress == 0f + NearlyEqual(update.Progress, 0f, epsilon) && update.Total == null && string.Equals(update.Message, "Waiting: Remote side", StringComparison.Ordinal)).Should().BeTrue(); progressUpdates.Exists(update => - update.Progress == 60f - && update.Total == 100f + NearlyEqual(update.Progress, 60f, epsilon) + && update.Total is float total && NearlyEqual(total, 100f, epsilon) && string.Equals(update.Message, "Retrying: Transient issue", StringComparison.Ordinal)).Should().BeTrue(); progressUpdates.Exists(update => - update.Progress == 80f - && update.Total == 100f + NearlyEqual(update.Progress, 80f, epsilon) + && update.Total is float total && NearlyEqual(total, 100f, epsilon) && string.Equals(update.Message, "Failed: Permanent issue", StringComparison.Ordinal)).Should().BeTrue(); notifications.Should().Contain(entry => entry.Level == LoggingLevel.Warning @@ -214,6 +216,9 @@ private static void AssertStructuredProgressResult( && entry.Data.Contains("\"details\":\"Permanent issue\"", StringComparison.Ordinal)); } + private static bool NearlyEqual(float actual, float expected, float epsilon) => + MathF.Abs(actual - expected) <= epsilon; + private sealed record NotificationCaptureState(List<(LoggingLevel Level, string Data)> Notifications) { public static NotificationCaptureState? Current { get; set; } From 76ebbf04b469c4cc060eb63fa44988d65e5f2a2b Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 18 Apr 2026 16:04:18 -0400 Subject: [PATCH 09/16] fix: stabilize MCP feedback tests and harden interaction routing --- .github/workflows/ci.yml | 3 +- .../Console/ConsoleInteractionChannel.cs | 32 +++++++++--- .../ConsoleReplInteractionPresenter.cs | 10 +++- src/Repl.Core/CoreReplApp.Execution.cs | 4 +- .../Interaction/InteractionProgressFactory.cs | 11 ++--- src/Repl.Mcp/McpElicitationService.cs | 30 ++++++++++-- src/Repl.Mcp/McpInteractionChannel.cs | 5 ++ src/Repl.McpTests/Given_McpUserFeedback.cs | 49 ++++++++++++++----- 8 files changed, 107 insertions(+), 37 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 792de6a..31dde96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -202,6 +202,7 @@ jobs: } - name: Pack + id: pack shell: pwsh run: | if ($env:PUBLIC_RELEASE -eq 'true') { @@ -212,7 +213,7 @@ jobs: } - name: Package readiness report (non-blocking) - if: always() + if: always() && steps.pack.conclusion == 'success' shell: pwsh run: | Add-Type -AssemblyName System.IO.Compression diff --git a/src/Repl.Core/Console/ConsoleInteractionChannel.cs b/src/Repl.Core/Console/ConsoleInteractionChannel.cs index e8f103e..b320d8b 100644 --- a/src/Repl.Core/Console/ConsoleInteractionChannel.cs +++ b/src/Repl.Core/Console/ConsoleInteractionChannel.cs @@ -62,6 +62,14 @@ private async ValueTask TryHandleBuiltInDispatchAsync( { switch (request) { + case WriteStatusRequest status: + await PresentFeedbackAsync( + status.Text, + new ReplStatusEvent(status.Text), + cancellationToken) + .ConfigureAwait(false); + return InteractionResult.Success(value: true); + case WriteNoticeRequest notice: await PresentFeedbackAsync( notice.Text, @@ -79,14 +87,7 @@ await PresentFeedbackAsync( return InteractionResult.Success(value: true); case WriteProblemRequest problem: - if (string.IsNullOrWhiteSpace(problem.Summary)) - { - throw new ArgumentException("Problem summary cannot be empty.", nameof(request)); - } - - await _presenter.PresentAsync( - new ReplProblemEvent(problem.Summary, problem.Details, problem.Code), - cancellationToken) + await PresentProblemAsync(problem, cancellationToken) .ConfigureAwait(false); return InteractionResult.Success(value: true); @@ -119,6 +120,21 @@ private async ValueTask PresentFeedbackAsync( await _presenter.PresentAsync(interactionEvent, cancellationToken).ConfigureAwait(false); } + private async ValueTask PresentProblemAsync( + WriteProblemRequest request, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Summary)) + { + throw new ArgumentException("Problem summary cannot be empty.", nameof(request)); + } + + await _presenter.PresentAsync( + new ReplProblemEvent(request.Summary, request.Details, request.Code), + cancellationToken) + .ConfigureAwait(false); + } + /// /// Sets the ambient per-command token. Called by the framework before each command dispatch. /// diff --git a/src/Repl.Core/Console/ConsoleReplInteractionPresenter.cs b/src/Repl.Core/Console/ConsoleReplInteractionPresenter.cs index 7f65d7f..cf0c0a8 100644 --- a/src/Repl.Core/Console/ConsoleReplInteractionPresenter.cs +++ b/src/Repl.Core/Console/ConsoleReplInteractionPresenter.cs @@ -126,11 +126,19 @@ private string FormatProgress(ReplProgressEvent progress, double? percent) var percentOneDecimalText = resolvedPercent.ToString("0.0", CultureInfo.InvariantCulture); var percentZeroDecimalText = resolvedPercent.ToString("0", CultureInfo.InvariantCulture); - return template + var formatted = template .Replace("{label}", safeLabel, StringComparison.Ordinal) .Replace("{percent:0.0}", percentOneDecimalText, StringComparison.Ordinal) .Replace("{percent:0}", percentZeroDecimalText, StringComparison.Ordinal) .Replace("{percent}", percentText, StringComparison.Ordinal); + + if (progress.State is ReplProgressState.Warning or ReplProgressState.Error + && !string.IsNullOrWhiteSpace(progress.Details)) + { + return $"{formatted}: {progress.Details}"; + } + + return formatted; } private async ValueTask TryWriteAdvancedProgressAsync(ReplProgressEvent progress) diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index eea383b..2d3f75f 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -567,9 +567,9 @@ private static async ValueTask TryClearProgressAsync(IServiceProvider servicePro { await interaction.ClearProgressAsync().ConfigureAwait(false); } - catch (NotSupportedException) + catch (Exception) { - // Ignore channels that do not implement progress dispatch. + // Clearing progress is best-effort cleanup and must not hide the primary result. } } diff --git a/src/Repl.Core/Interaction/InteractionProgressFactory.cs b/src/Repl.Core/Interaction/InteractionProgressFactory.cs index 08c3f5c..35cf94c 100644 --- a/src/Repl.Core/Interaction/InteractionProgressFactory.cs +++ b/src/Repl.Core/Interaction/InteractionProgressFactory.cs @@ -1,3 +1,5 @@ +using Repl.Interaction; + namespace Repl; internal static class InteractionProgressFactory @@ -56,14 +58,7 @@ private sealed class StructuredProgress( public void Report(ReplProgressEvent value) { #pragma warning disable VSTHRD002 // IProgress.Report is sync by contract; we bridge to async channel intentionally. - channel.DispatchAsync( - new WriteProgressRequest( - value.Label, - value.ResolvePercent(), - value.State, - value.Details, - cancellationToken), - cancellationToken) + channel.WriteProgressAsync(value, cancellationToken) .AsTask() .GetAwaiter() .GetResult(); diff --git a/src/Repl.Mcp/McpElicitationService.cs b/src/Repl.Mcp/McpElicitationService.cs index 48b57f6..9e948bf 100644 --- a/src/Repl.Mcp/McpElicitationService.cs +++ b/src/Repl.Mcp/McpElicitationService.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -31,7 +32,9 @@ internal sealed class McpElicitationService : IMcpElicitation new ElicitRequestParams.StringSchema(), cancellationToken).ConfigureAwait(false); - return result?.Content?[FieldName].GetString(); + return TryGetContentValue(result?.Content, out var value) + ? value.GetString() + : null; } public async ValueTask ElicitBooleanAsync( @@ -43,7 +46,9 @@ internal sealed class McpElicitationService : IMcpElicitation new ElicitRequestParams.BooleanSchema(), cancellationToken).ConfigureAwait(false); - return result?.Content?[FieldName].GetBoolean(); + return TryGetContentValue(result?.Content, out var value) + ? value.GetBoolean() + : null; } public async ValueTask ElicitChoiceAsync( @@ -59,7 +64,9 @@ internal sealed class McpElicitationService : IMcpElicitation }, cancellationToken).ConfigureAwait(false); - var selected = result?.Content?[FieldName].GetString(); + var selected = TryGetContentValue(result?.Content, out var value) + ? value.GetString() + : null; if (selected is null) { return null; @@ -87,7 +94,9 @@ internal sealed class McpElicitationService : IMcpElicitation new ElicitRequestParams.NumberSchema(), cancellationToken).ConfigureAwait(false); - return result?.Content?[FieldName].GetDouble(); + return TryGetContentValue(result?.Content, out var value) + ? value.GetDouble() + : null; } internal void AttachServer(McpServer server) => _server = server; @@ -123,4 +132,17 @@ internal sealed class McpElicitationService : IMcpElicitation return result; } + + private static bool TryGetContentValue( + IDictionary? content, + out JsonElement value) + { + if (content?.TryGetValue(FieldName, out value) is true) + { + return true; + } + + value = default; + return false; + } } diff --git a/src/Repl.Mcp/McpInteractionChannel.cs b/src/Repl.Mcp/McpInteractionChannel.cs index a62fe67..1d8ea66 100644 --- a/src/Repl.Mcp/McpInteractionChannel.cs +++ b/src/Repl.Mcp/McpInteractionChannel.cs @@ -238,6 +238,11 @@ public ValueTask DispatchAsync( return request switch { + WriteStatusRequest status => CompleteBuiltInDispatchAsync( + SendFeedbackAsync( + LoggingLevel.Info, + JsonSerializer.SerializeToElement(status.Text, McpJsonContext.Default.String), + cancellationToken)), WriteProgressRequest progress => CompleteBuiltInDispatchAsync( WriteStructuredProgressAsync(progress, cancellationToken)), WriteNoticeRequest notice => CompleteBuiltInDispatchAsync( diff --git a/src/Repl.McpTests/Given_McpUserFeedback.cs b/src/Repl.McpTests/Given_McpUserFeedback.cs index 7d91b1a..33ed0d7 100644 --- a/src/Repl.McpTests/Given_McpUserFeedback.cs +++ b/src/Repl.McpTests/Given_McpUserFeedback.cs @@ -38,21 +38,21 @@ public async Task When_ToolEmitsUserFeedback_Then_McpReceivesNotifications() public async Task When_ToolEmitsStructuredProgress_Then_McpReceivesProgressAndMessages() { var notifications = new List<(LoggingLevel Level, string Data)>(); - var progressUpdates = new List(); + var progressCapture = new ProgressCapture(); var captureState = new NotificationCaptureState(notifications); NotificationCaptureState.Current = captureState; try { await using var fixture = await CreateStructuredProgressFixtureAsync(CreateClientOptions()).ConfigureAwait(false); - var progressHandler = CreateProgressCollector(progressUpdates); + var progressHandler = progressCapture; var result = await fixture.Client.CallToolAsync( toolName: "feedback_progress", arguments: new Dictionary(StringComparer.Ordinal), progress: progressHandler).ConfigureAwait(false); - await WaitForConditionAsync(() => progressUpdates.Count >= 4 && notifications.Count >= 2).ConfigureAwait(false); - AssertStructuredProgressResult(result, progressUpdates, notifications); + await WaitForConditionAsync(() => progressCapture.Count >= 4 && notifications.Count >= 2, timeoutMs: 5000).ConfigureAwait(false); + AssertStructuredProgressResult(result, progressCapture.Snapshot(), notifications); } finally { @@ -148,15 +148,6 @@ private static ValueTask HandleLoggingNotificationAsync(JsonRpcNotification noti return ValueTask.CompletedTask; } - private static Progress CreateProgressCollector(List progressUpdates) => - new Progress(value => - { - lock (progressUpdates) - { - progressUpdates.Add(value); - } - }); - private static async Task WaitForConditionAsync(Func predicate, int timeoutMs = 1000) { var started = Environment.TickCount64; @@ -219,6 +210,38 @@ private static void AssertStructuredProgressResult( private static bool NearlyEqual(float actual, float expected, float epsilon) => MathF.Abs(actual - expected) <= epsilon; + private sealed class ProgressCapture : IProgress + { + private readonly List _updates = []; + + public int Count + { + get + { + lock (_updates) + { + return _updates.Count; + } + } + } + + public void Report(ProgressNotificationValue value) + { + lock (_updates) + { + _updates.Add(value); + } + } + + public List Snapshot() + { + lock (_updates) + { + return [.. _updates]; + } + } + } + private sealed record NotificationCaptureState(List<(LoggingLevel Level, string Data)> Notifications) { public static NotificationCaptureState? Current { get; set; } From 9d22d5b5efc246d4f091551293c1a544da38b67e Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 18 Apr 2026 16:40:09 -0400 Subject: [PATCH 10/16] fix: harden MCP feedback assertions and cleanup semantics --- src/Repl.Core/CoreReplApp.Execution.cs | 12 +++- src/Repl.Mcp/McpElicitationService.cs | 2 +- src/Repl.McpTests/Given_McpUserFeedback.cs | 72 ++++++++++++++++------ 3 files changed, 63 insertions(+), 23 deletions(-) diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index 2d3f75f..493ac18 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -567,9 +567,17 @@ private static async ValueTask TryClearProgressAsync(IServiceProvider servicePro { await interaction.ClearProgressAsync().ConfigureAwait(false); } - catch (Exception) + catch (OperationCanceledException) + { + throw; + } + catch (ObjectDisposedException) + { + // Clearing progress is best-effort cleanup and may happen after the channel is disposed. + } + catch (InvalidOperationException) { - // Clearing progress is best-effort cleanup and must not hide the primary result. + // Clearing progress is best-effort cleanup and may race with teardown. } } diff --git a/src/Repl.Mcp/McpElicitationService.cs b/src/Repl.Mcp/McpElicitationService.cs index 9e948bf..12de521 100644 --- a/src/Repl.Mcp/McpElicitationService.cs +++ b/src/Repl.Mcp/McpElicitationService.cs @@ -75,7 +75,7 @@ internal sealed class McpElicitationService : IMcpElicitation var index = -1; for (var i = 0; i < choices.Count; i++) { - if (string.Equals(choices[i], selected, StringComparison.Ordinal)) + if (string.Equals(choices[i], selected, StringComparison.OrdinalIgnoreCase)) { index = i; break; diff --git a/src/Repl.McpTests/Given_McpUserFeedback.cs b/src/Repl.McpTests/Given_McpUserFeedback.cs index 33ed0d7..fa98c47 100644 --- a/src/Repl.McpTests/Given_McpUserFeedback.cs +++ b/src/Repl.McpTests/Given_McpUserFeedback.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Globalization; using ModelContextProtocol; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -51,7 +52,11 @@ public async Task When_ToolEmitsStructuredProgress_Then_McpReceivesProgressAndMe arguments: new Dictionary(StringComparer.Ordinal), progress: progressHandler).ConfigureAwait(false); - await WaitForConditionAsync(() => progressCapture.Count >= 4 && notifications.Count >= 2, timeoutMs: 5000).ConfigureAwait(false); + await WaitForConditionAsync(() => + { + var progressUpdates = progressCapture.Snapshot(); + return HasExpectedProgressSequence(progressUpdates) && notifications.Count >= 2; + }, timeoutMs: 5000).ConfigureAwait(false); AssertStructuredProgressResult(result, progressCapture.Snapshot(), notifications); } finally @@ -178,38 +183,65 @@ private static void AssertStructuredProgressResult( List progressUpdates, List<(LoggingLevel Level, string Data)> notifications) { - const float epsilon = 0.0001f; + var progressDump = DescribeProgressUpdates(progressUpdates); + var notificationDump = DescribeNotifications(notifications); result.IsError.Should().BeFalse(); - progressUpdates.Exists(update => - NearlyEqual(update.Progress, 25f, epsilon) - && update.Total is float total && NearlyEqual(total, 100f, epsilon) - && string.Equals(update.Message, "Loading", StringComparison.Ordinal)).Should().BeTrue(); - progressUpdates.Exists(update => - NearlyEqual(update.Progress, 0f, epsilon) - && update.Total == null - && string.Equals(update.Message, "Waiting: Remote side", StringComparison.Ordinal)).Should().BeTrue(); - progressUpdates.Exists(update => - NearlyEqual(update.Progress, 60f, epsilon) - && update.Total is float total && NearlyEqual(total, 100f, epsilon) - && string.Equals(update.Message, "Retrying: Transient issue", StringComparison.Ordinal)).Should().BeTrue(); - progressUpdates.Exists(update => - NearlyEqual(update.Progress, 80f, epsilon) - && update.Total is float total && NearlyEqual(total, 100f, epsilon) - && string.Equals(update.Message, "Failed: Permanent issue", StringComparison.Ordinal)).Should().BeTrue(); + if (!HasExpectedProgressSequence(progressUpdates)) + { + Assert.Fail($"Unexpected progress updates: {progressDump}"); + } notifications.Should().Contain(entry => entry.Level == LoggingLevel.Warning && entry.Data.Contains("\"state\":\"Warning\"", StringComparison.Ordinal) - && entry.Data.Contains("\"details\":\"Transient issue\"", StringComparison.Ordinal)); + && entry.Data.Contains("\"details\":\"Transient issue\"", StringComparison.Ordinal), $"notifications were: {notificationDump}"); notifications.Should().Contain(entry => entry.Level == LoggingLevel.Error && entry.Data.Contains("\"state\":\"Error\"", StringComparison.Ordinal) - && entry.Data.Contains("\"details\":\"Permanent issue\"", StringComparison.Ordinal)); + && entry.Data.Contains("\"details\":\"Permanent issue\"", StringComparison.Ordinal), $"notifications were: {notificationDump}"); + } + + private static bool HasExpectedProgressSequence(List progressUpdates) => + ContainsProgressUpdate(progressUpdates, 25f, 100f, "Loading") + && ContainsProgressUpdate(progressUpdates, 0f, expectedTotal: null, "Waiting: Remote side") + && ContainsProgressUpdate(progressUpdates, 60f, 100f, "Retrying: Transient issue") + && ContainsProgressUpdate(progressUpdates, 80f, 100f, "Failed: Permanent issue"); + + private static bool ContainsProgressUpdate( + List progressUpdates, + float expectedProgress, + float? expectedTotal, + string expectedMessage) + { + const float epsilon = 0.0001f; + + return progressUpdates.Exists(update => + NearlyEqual(update.Progress, expectedProgress, epsilon) + && TotalsMatch(update.Total, expectedTotal, epsilon) + && string.Equals(update.Message, expectedMessage, StringComparison.Ordinal)); } + private static bool TotalsMatch(float? actual, float? expected, float epsilon) => + expected is null + ? actual is null + : actual is float total && NearlyEqual(total, expected.Value, epsilon); + private static bool NearlyEqual(float actual, float expected, float epsilon) => MathF.Abs(actual - expected) <= epsilon; + private static string DescribeProgressUpdates(List progressUpdates) => + progressUpdates.Count == 0 + ? "" + : string.Join( + " | ", + progressUpdates.Select(update => + $"Progress={update.Progress.ToString(CultureInfo.InvariantCulture)}, Total={(update.Total?.ToString(CultureInfo.InvariantCulture) ?? "null")}, Message={update.Message ?? ""}")); + + private static string DescribeNotifications(List<(LoggingLevel Level, string Data)> notifications) => + notifications.Count == 0 + ? "" + : string.Join(" | ", notifications.Select(entry => $"{entry.Level}: {entry.Data}")); + private sealed class ProgressCapture : IProgress { private readonly List _updates = []; From 578328c42de981038374877a320de64f08521a83 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 18 Apr 2026 16:47:54 -0400 Subject: [PATCH 11/16] test: capture MCP progress notifications directly --- src/Repl.McpTests/Given_McpUserFeedback.cs | 69 ++++++++++------------ 1 file changed, 30 insertions(+), 39 deletions(-) diff --git a/src/Repl.McpTests/Given_McpUserFeedback.cs b/src/Repl.McpTests/Given_McpUserFeedback.cs index fa98c47..0a8bd85 100644 --- a/src/Repl.McpTests/Given_McpUserFeedback.cs +++ b/src/Repl.McpTests/Given_McpUserFeedback.cs @@ -39,25 +39,23 @@ public async Task When_ToolEmitsUserFeedback_Then_McpReceivesNotifications() public async Task When_ToolEmitsStructuredProgress_Then_McpReceivesProgressAndMessages() { var notifications = new List<(LoggingLevel Level, string Data)>(); - var progressCapture = new ProgressCapture(); - var captureState = new NotificationCaptureState(notifications); + var progressUpdates = new List(); + var captureState = new NotificationCaptureState(notifications, progressUpdates); NotificationCaptureState.Current = captureState; try { await using var fixture = await CreateStructuredProgressFixtureAsync(CreateClientOptions()).ConfigureAwait(false); - var progressHandler = progressCapture; var result = await fixture.Client.CallToolAsync( toolName: "feedback_progress", arguments: new Dictionary(StringComparer.Ordinal), - progress: progressHandler).ConfigureAwait(false); + progress: new Progress(_ => { })).ConfigureAwait(false); await WaitForConditionAsync(() => { - var progressUpdates = progressCapture.Snapshot(); return HasExpectedProgressSequence(progressUpdates) && notifications.Count >= 2; }, timeoutMs: 5000).ConfigureAwait(false); - AssertStructuredProgressResult(result, progressCapture.Snapshot(), notifications); + AssertStructuredProgressResult(result, progressUpdates, notifications); } finally { @@ -130,6 +128,9 @@ private static McpClientOptions CreateClientOptions() => new KeyValuePair>( NotificationMethods.LoggingMessageNotification, HandleLoggingNotificationAsync), + new KeyValuePair>( + NotificationMethods.ProgressNotification, + HandleProgressNotificationAsync), ], }, }; @@ -153,6 +154,26 @@ private static ValueTask HandleLoggingNotificationAsync(JsonRpcNotification noti return ValueTask.CompletedTask; } + private static ValueTask HandleProgressNotificationAsync(JsonRpcNotification notification, CancellationToken cancellationToken) + { + _ = cancellationToken; + var state = NotificationCaptureState.Current + ?? throw new InvalidOperationException("Notification capture state was not initialized."); + if (state.ProgressUpdates is null) + { + return ValueTask.CompletedTask; + } + + var payload = notification.Params?.Deserialize() + ?? throw new InvalidOperationException("Expected progress notification parameters."); + lock (state.ProgressUpdates) + { + state.ProgressUpdates.Add(payload.Progress); + } + + return ValueTask.CompletedTask; + } + private static async Task WaitForConditionAsync(Func predicate, int timeoutMs = 1000) { var started = Environment.TickCount64; @@ -242,39 +263,9 @@ private static string DescribeNotifications(List<(LoggingLevel Level, string Dat ? "" : string.Join(" | ", notifications.Select(entry => $"{entry.Level}: {entry.Data}")); - private sealed class ProgressCapture : IProgress - { - private readonly List _updates = []; - - public int Count - { - get - { - lock (_updates) - { - return _updates.Count; - } - } - } - - public void Report(ProgressNotificationValue value) - { - lock (_updates) - { - _updates.Add(value); - } - } - - public List Snapshot() - { - lock (_updates) - { - return [.. _updates]; - } - } - } - - private sealed record NotificationCaptureState(List<(LoggingLevel Level, string Data)> Notifications) + private sealed record NotificationCaptureState( + List<(LoggingLevel Level, string Data)> Notifications, + List? ProgressUpdates = null) { public static NotificationCaptureState? Current { get; set; } } From e9660414fb234c53f4593df631d2051fd58b42ab Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 18 Apr 2026 16:54:09 -0400 Subject: [PATCH 12/16] fix: keep progress cleanup best-effort on cancellation --- src/Repl.Core/CoreReplApp.Execution.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index 493ac18..02e1f48 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -569,7 +569,7 @@ private static async ValueTask TryClearProgressAsync(IServiceProvider servicePro } catch (OperationCanceledException) { - throw; + // Clearing progress is best-effort cleanup and may race with cancellation. } catch (ObjectDisposedException) { From e8a442091261c682699014ec36c7d704fde10ef5 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 18 Apr 2026 22:32:36 -0400 Subject: [PATCH 13/16] sample(hosted): refine connection profiles and feedback demo --- samples/05-hosting-remote/RemoteModule.cs | 201 ++- samples/05-hosting-remote/wwwroot/index.html | 1097 +++++++++++------ .../ConsoleReplInteractionPresenter.cs | 30 +- src/Repl.Mcp/McpFeedbackService.cs | 3 + ...plInteractionPresenter_AdvancedProgress.cs | 83 ++ 5 files changed, 1039 insertions(+), 375 deletions(-) diff --git a/samples/05-hosting-remote/RemoteModule.cs b/samples/05-hosting-remote/RemoteModule.cs index d5478d5..23bc3f7 100644 --- a/samples/05-hosting-remote/RemoteModule.cs +++ b/samples/05-hosting-remote/RemoteModule.cs @@ -145,21 +145,46 @@ await channel.WriteNoticeAsync( ? "Advanced progress reporting is available for this hosted session." : "This client is using the text fallback for progress updates.", ct).ConfigureAwait(false); - await channel.WriteProgressAsync("Preparing session", 10, ct).ConfigureAwait(false); - await DelayFeedbackStepAsync(ct).ConfigureAwait(false); + await BeginFeedbackDemoAsync( + channel, + "Press Enter to run a smooth feedback demo. You will see normal progress, a waiting phase, a warning phase, and a clean finish to 100%.", + "feedback-demo-start", + ct).ConfigureAwait(false); + await AnimateProgressAsync( + channel, + "Preparing session", + startPercent: 0, + endPercent: 30, + FeedbackStepDuration.InitialProgress, + ct).ConfigureAwait(false); await channel.WriteIndeterminateProgressAsync( "Waiting for remote worker", "Negotiating with upstream services", ct).ConfigureAwait(false); - await DelayFeedbackStepAsync(ct).ConfigureAwait(false); - await channel.WriteWarningProgressAsync( + await DelayFeedbackStepAsync(FeedbackStepDuration.Indeterminate, ct).ConfigureAwait(false); + await AnimateWarningProgressAsync( + channel, "Retrying sync", - 55, + startPercent: 44, + endPercent: 72, "Transient network jitter", + FeedbackStepDuration.Warning, + ct).ConfigureAwait(false); + await AnimateProgressAsync( + channel, + "Finalizing", + startPercent: 73, + endPercent: 96, + FeedbackStepDuration.NormalProgress, ct).ConfigureAwait(false); - await DelayFeedbackStepAsync(ct).ConfigureAwait(false); - await channel.WriteProgressAsync("Finalizing", 85, ct).ConfigureAwait(false); - await DelayFeedbackStepAsync(ct).ConfigureAwait(false); + await AnimateProgressAsync( + channel, + "Completed", + startPercent: 97, + endPercent: 100, + FeedbackStepDuration.CompletedRamp, + ct).ConfigureAwait(false); + await DelayFeedbackStepAsync(FeedbackStepDuration.Completed, ct).ConfigureAwait(false); await channel.WriteNoticeAsync("Feedback demo completed.", ct).ConfigureAwait(false); return Results.Success("Feedback demo completed."); }); @@ -169,27 +194,46 @@ await channel.WriteWarningProgressAsync( [Description("Run a failing feedback sequence with warning, error, and problem output")] async (IReplInteractionChannel channel, CancellationToken ct) => { - await channel.WriteNoticeAsync("Starting the failing feedback demo.", ct).ConfigureAwait(false); - await channel.WriteProgressAsync("Preparing session", 15, ct).ConfigureAwait(false); - await DelayFeedbackStepAsync(ct).ConfigureAwait(false); - await channel.WriteWarningProgressAsync( + await channel.WriteNoticeAsync( + "Starting an error-state demo. This run is expected to end in a simulated failure state.", + ct).ConfigureAwait(false); + await BeginFeedbackDemoAsync( + channel, + "Press Enter to run a demo that intentionally ends in an error state. You will see normal progress, then a warning, then a final simulated failure.", + "feedback-fail-start", + ct).ConfigureAwait(false); + await AnimateProgressAsync( + channel, + "Preparing session", + startPercent: 0, + endPercent: 28, + FeedbackStepDuration.InitialProgress, + ct).ConfigureAwait(false); + await AnimateWarningProgressAsync( + channel, "Retrying sync", - 45, + startPercent: 36, + endPercent: 58, "Remote worker timed out", + FeedbackStepDuration.Warning, ct).ConfigureAwait(false); - await DelayFeedbackStepAsync(ct).ConfigureAwait(false); - await channel.WriteErrorProgressAsync( + await AnimateErrorProgressAsync( + channel, "Remote job failed", - 80, + startPercent: 66, + endPercent: 82, "Final retry exhausted", + FeedbackStepDuration.Error, ct).ConfigureAwait(false); - await DelayFeedbackStepAsync(ct).ConfigureAwait(false); await channel.WriteProblemAsync( "Remote feedback demo failed", "The remote worker stayed unavailable after several retries.", "remote_feedback_failed", ct).ConfigureAwait(false); - return Results.Error("remote_feedback_failed", "Remote feedback demo failed."); + await channel.WriteNoticeAsync( + "Error-state demo complete. The failure shown above was intentional.", + ct).ConfigureAwait(false); + return Results.Success("Error-state demo completed. The failure shown above was intentional."); }); }); @@ -356,6 +400,123 @@ private static async Task DisplayMessagesAsync( } } - private static Task DelayFeedbackStepAsync(CancellationToken cancellationToken) => - Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); + private static Task DelayFeedbackStepAsync(TimeSpan duration, CancellationToken cancellationToken) => + Task.Delay(duration, cancellationToken); + + private static async Task AnimateProgressAsync( + IReplInteractionChannel channel, + string label, + double startPercent, + double endPercent, + TimeSpan duration, + CancellationToken cancellationToken) + { + await AnimateProgressCoreAsync( + startPercent, + endPercent, + duration, + static (progressChannel, progressLabel, percent, _, ct) => progressChannel.WriteProgressAsync(progressLabel, percent, ct), + channel, + label, + details: null, + cancellationToken).ConfigureAwait(false); + } + + private static async Task AnimateWarningProgressAsync( + IReplInteractionChannel channel, + string label, + double startPercent, + double endPercent, + string details, + TimeSpan duration, + CancellationToken cancellationToken) + { + await AnimateProgressCoreAsync( + startPercent, + endPercent, + duration, + static (progressChannel, progressLabel, percent, progressDetails, ct) => + progressChannel.WriteWarningProgressAsync(progressLabel, percent, progressDetails!, ct), + channel, + label, + details, + cancellationToken).ConfigureAwait(false); + } + + private static async Task AnimateErrorProgressAsync( + IReplInteractionChannel channel, + string label, + double startPercent, + double endPercent, + string details, + TimeSpan duration, + CancellationToken cancellationToken) + { + await AnimateProgressCoreAsync( + startPercent, + endPercent, + duration, + static (progressChannel, progressLabel, percent, progressDetails, ct) => + progressChannel.WriteErrorProgressAsync(progressLabel, percent, progressDetails!, ct), + channel, + label, + details, + cancellationToken).ConfigureAwait(false); + } + + private static async Task BeginFeedbackDemoAsync( + IReplInteractionChannel channel, + string instructions, + string promptName, + CancellationToken cancellationToken) + { + await channel.WriteStatusAsync(instructions, cancellationToken).ConfigureAwait(false); + await channel.AskTextAsync(promptName, "Press Enter to start").ConfigureAwait(false); + } + + private static async Task AnimateProgressCoreAsync( + double startPercent, + double endPercent, + TimeSpan duration, + Func writer, + IReplInteractionChannel channel, + string label, + string? details, + CancellationToken cancellationToken) + { + var clampedStart = Math.Clamp(startPercent, 0d, 100d); + var clampedEnd = Math.Clamp(endPercent, 0d, 100d); + var direction = clampedEnd >= clampedStart ? 1 : -1; + var range = Math.Abs(clampedEnd - clampedStart); + var stepCount = Math.Max(1, (int)Math.Ceiling(range / 1.5d)); + var interval = TimeSpan.FromMilliseconds(Math.Max(45d, duration.TotalMilliseconds / stepCount)); + var stepSize = stepCount == 0 ? range : range / stepCount; + var percent = clampedStart; + + while (true) + { + await writer(channel, label, percent, details, cancellationToken).ConfigureAwait(false); + if (Math.Abs(percent - clampedEnd) < double.Epsilon) + { + break; + } + + await Task.Delay(interval, cancellationToken).ConfigureAwait(false); + var nextPercent = percent + (stepSize * direction); + percent = direction > 0 + ? Math.Min(clampedEnd, nextPercent) + : Math.Max(clampedEnd, nextPercent); + } + } + + private static class FeedbackStepDuration + { + public static readonly TimeSpan InitialProgress = TimeSpan.FromSeconds(1.4); + public static readonly TimeSpan Indeterminate = TimeSpan.FromSeconds(1.4); + public static readonly TimeSpan Warning = TimeSpan.FromSeconds(1.4); + public static readonly TimeSpan NormalProgress = TimeSpan.FromSeconds(1.2); + public static readonly TimeSpan CompletedRamp = TimeSpan.FromSeconds(0.4); + public static readonly TimeSpan Completed = TimeSpan.FromSeconds(1.2); + public static readonly TimeSpan Error = TimeSpan.FromSeconds(1.2); + } } diff --git a/samples/05-hosting-remote/wwwroot/index.html b/samples/05-hosting-remote/wwwroot/index.html index 7db812f..9da1408 100644 --- a/samples/05-hosting-remote/wwwroot/index.html +++ b/samples/05-hosting-remote/wwwroot/index.html @@ -48,7 +48,7 @@ gap: 14px; flex: 1; min-height: calc(100dvh - 28px); - grid-template-rows: auto auto auto minmax(320px, 1fr); + grid-template-rows: auto auto minmax(420px, 1fr); } .panel { @@ -61,62 +61,103 @@ .hero { display: flex; - gap: 14px; - align-items: start; + gap: 10px; + align-items: center; justify-content: space-between; - padding: 14px 16px; + padding: 10px 14px; } .hero h1 { margin: 0; - font-size: 1.08rem; + font-size: 1rem; font-weight: 700; letter-spacing: 0.02em; } .hero p { - margin: 6px 0 0; + margin: 4px 0 0; color: var(--muted); - font-size: 0.85rem; - line-height: 1.5; - max-width: 72ch; + font-size: 0.78rem; + line-height: 1.4; + max-width: 58ch; } - .status-pill { - padding: 7px 10px; - border-radius: 999px; - border: 1px solid rgba(148, 163, 184, 0.3); - font-size: 0.75rem; - font-weight: 700; - letter-spacing: 0.03em; - text-transform: uppercase; + .controls { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + padding: 16px 16px; + transition: padding 180ms ease, gap 180ms ease; + } + + .controls-grid { + display: grid; + gap: 14px; + transition: gap 180ms ease; + } + + .connection-summary { + display: none; + gap: 10px; + align-content: center; + } + + .connection-summary strong { + color: var(--text); + font-size: 0.82rem; + } + + .connection-summary-text { color: var(--muted); - background: rgba(15, 23, 42, 0.72); - white-space: nowrap; + font-size: 0.74rem; + line-height: 1.45; } - .status-pill.connected { - color: #052e16; - background: rgba(74, 222, 128, 0.92); - border-color: rgba(34, 197, 94, 0.7); + .summary-pills { + display: flex; + flex-wrap: wrap; + gap: 8px; } - .status-pill.connecting { - color: #3d2f00; - background: rgba(250, 204, 21, 0.9); - border-color: rgba(234, 179, 8, 0.7); + .summary-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 9px; + border-radius: 999px; + border: 1px solid rgba(102, 217, 239, 0.28); + color: #dbeafe; + background: rgba(8, 47, 73, 0.35); + font-size: 0.72rem; + font-weight: 700; } - .controls { + .control-row { display: flex; gap: 12px; - align-items: center; - justify-content: space-between; - padding: 10px 12px; + align-items: flex-start; flex-wrap: wrap; } - .mode-group { + .control-group { + display: grid; + gap: 7px; + min-width: 0; + } + + .control-label { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: none; + } + + .mode-group, + .feature-grid { display: flex; gap: 8px; flex-wrap: wrap; @@ -142,95 +183,151 @@ background: rgba(15, 50, 72, 0.66); } + .feature-toggle { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 6px 10px; + border-radius: 10px; + border: 1px solid rgba(148, 163, 184, 0.25); + color: var(--muted); + background: rgba(15, 23, 42, 0.56); + cursor: pointer; + font-size: 0.77rem; + } + + .feature-toggle input { + margin: 0; + } + + .feature-toggle:has(input:checked) { + color: #e0f2fe; + border-color: rgba(102, 217, 239, 0.55); + background: rgba(15, 50, 72, 0.42); + } + + .feature-toggle:has(input:disabled) { + opacity: 0.45; + cursor: not-allowed; + } + #go { border: 1px solid rgba(102, 217, 239, 0.7); background: linear-gradient(180deg, rgba(56, 189, 248, 0.35), rgba(8, 47, 73, 0.85)); color: #e6faff; - border-radius: 10px; - padding: 7px 14px; + border-radius: 999px; + padding: 8px 14px; + min-width: 132px; + align-self: center; font-family: inherit; font-weight: 700; font-size: 0.82rem; letter-spacing: 0.03em; cursor: pointer; + text-transform: uppercase; + transition: background 180ms ease, border-color 180ms ease, color 180ms ease, padding 180ms ease, min-width 180ms ease; } #go:disabled { - opacity: 0.58; cursor: default; + opacity: 1; } - .help { - padding: 12px 14px; + #go.connected { + color: #052e16; + background: rgba(74, 222, 128, 0.92); + border-color: rgba(34, 197, 94, 0.7); } - .help h2 { - margin: 0; - font-size: 0.86rem; - text-transform: uppercase; - letter-spacing: 0.04em; + #go.connecting { + color: #3d2f00; + background: rgba(250, 204, 21, 0.9); + border-color: rgba(234, 179, 8, 0.7); + } + + .inline-info { + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + border: 1px solid rgba(102, 217, 239, 0.35); color: var(--accent); + background: rgba(8, 47, 73, 0.35); + font-size: 0.72rem; + font-weight: 700; + line-height: 1; + cursor: help; + user-select: none; } - .help p { - margin: 8px 0 0; + .connection-copy { + margin: 0; color: var(--muted); - font-size: 0.8rem; - line-height: 1.5; + font-size: 0.75rem; + line-height: 1.45; } - .chips { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin-top: 10px; + .connection-copy strong { + color: var(--text); } - .chips code { - font-family: inherit; - font-size: 0.75rem; - color: #dbeafe; - background: rgba(2, 132, 199, 0.14); - border: 1px solid rgba(56, 189, 248, 0.35); - border-radius: 7px; - padding: 4px 7px; + body.connected-session .controls { + padding: 9px 12px; + gap: 10px; } - .feedback-panel { + body.connected-session .controls-grid { + gap: 10px; + } + + body.connected-session .control-row, + body.connected-session .connection-copy { + display: none; + } + + body.connected-session .connection-summary { display: grid; - gap: 12px; - padding: 12px 14px; } - .feedback-header { + body.connected-session #go { + min-width: 118px; + padding: 7px 12px; + } + + .activity-bar { display: flex; - align-items: start; - justify-content: space-between; + align-items: center; gap: 12px; + padding: 10px 12px; + border-bottom: 1px solid rgba(100, 116, 139, 0.28); + background: rgba(10, 17, 35, 0.72); flex-wrap: wrap; } - .feedback-header h2 { - margin: 0; - font-size: 0.86rem; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--accent); + .activity-title { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.8rem; + font-weight: 700; + color: var(--text); } - .feedback-header p { - margin: 8px 0 0; - color: var(--muted); - font-size: 0.8rem; - line-height: 1.45; - max-width: 70ch; + .activity-main { + display: flex; + align-items: center; + gap: 10px; + flex: 1 1 360px; + min-width: 260px; } .feedback-state { - padding: 7px 10px; + padding: 5px 8px; border-radius: 999px; border: 1px solid rgba(148, 163, 184, 0.28); - font-size: 0.75rem; + font-size: 0.69rem; font-weight: 700; letter-spacing: 0.03em; text-transform: uppercase; @@ -239,6 +336,45 @@ white-space: nowrap; } + .feedback-progress { + flex: 1 1 200px; + min-width: 120px; + height: 10px; + display: block; + border: none; + border-radius: 999px; + overflow: hidden; + accent-color: var(--accent); + background: rgba(15, 23, 42, 0.82); + } + + .feedback-progress, + .feedback-progress::-webkit-progress-value, + .feedback-progress::-moz-progress-bar { + transition: accent-color 180ms ease, opacity 180ms ease; + } + + .activity-copy { + min-width: 180px; + flex: 1 1 220px; + color: var(--muted); + font-size: 0.75rem; + line-height: 1.35; + } + + .activity-copy strong { + display: block; + color: var(--text); + font-size: 0.77rem; + font-weight: 700; + } + + .activity-detail { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .feedback-state.normal { color: #082f49; background: rgba(102, 217, 239, 0.92); @@ -263,56 +399,43 @@ border-color: rgba(6, 182, 212, 0.7); } - .feedback-track { - position: relative; - height: 12px; - border-radius: 999px; - overflow: hidden; + .feedback-progress::-webkit-progress-bar { background: rgba(15, 23, 42, 0.82); border: 1px solid rgba(100, 116, 139, 0.32); + border-radius: 999px; } - .feedback-fill { - position: absolute; - inset: 0 auto 0 0; - width: 0%; - border-radius: inherit; - transition: width 180ms ease; - background: linear-gradient(90deg, rgba(56, 189, 248, 0.92), rgba(102, 217, 239, 0.82)); + .feedback-progress::-webkit-progress-value { + border-radius: 999px; } - .feedback-fill.warning { - background: linear-gradient(90deg, rgba(250, 204, 21, 0.95), rgba(245, 158, 11, 0.86)); + .feedback-progress::-moz-progress-bar { + border-radius: 999px; } - .feedback-fill.error { - background: linear-gradient(90deg, rgba(248, 113, 113, 0.95), rgba(239, 68, 68, 0.86)); + .feedback-progress.warning { + accent-color: var(--warn); } - .feedback-fill.indeterminate { - width: 34%; - background: linear-gradient(90deg, rgba(34, 211, 238, 0.88), rgba(8, 145, 178, 0.78)); - animation: feedbackLoop 1.1s ease-in-out infinite; + .feedback-progress.error { + accent-color: var(--danger); } - .feedback-copy { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - flex-wrap: wrap; - color: var(--muted); - font-size: 0.79rem; - line-height: 1.45; + .feedback-progress.indeterminate { + accent-color: var(--accent); } - .feedback-copy strong { - color: var(--text); - font-size: 0.8rem; + .feedback-percent { + min-width: 38px; + text-align: right; + color: var(--muted); + font-size: 0.74rem; + font-weight: 700; } .feedback-actions { display: flex; + align-items: center; gap: 8px; flex-wrap: wrap; } @@ -322,9 +445,9 @@ background: rgba(8, 47, 73, 0.48); color: #dbeafe; border-radius: 10px; - padding: 7px 10px; + padding: 6px 9px; font-family: inherit; - font-size: 0.76rem; + font-size: 0.72rem; font-weight: 700; cursor: pointer; } @@ -334,12 +457,6 @@ background: rgba(14, 116, 144, 0.35); } - @keyframes feedbackLoop { - 0% { transform: translateX(-110%); } - 50% { transform: translateX(95%); } - 100% { transform: translateX(240%); } - } - .terminal-shell { overflow: hidden; border-radius: 16px; @@ -350,7 +467,15 @@ background: rgba(2, 6, 23, 0.92); display: flex; flex-direction: column; - min-height: 320px; + min-height: 58vh; + } + + body.disconnected-session .terminal-shell { + display: none; + } + + body[data-progress-visible="false"] .activity-bar { + display: none; } .terminal-head { @@ -376,14 +501,56 @@ #term { flex: 1 1 auto; height: 100%; - min-height: 300px; + min-height: 52vh; padding: 10px; } + .tips { + margin: 10px 12px 12px; + border-radius: 10px; + border: 1px solid rgba(148, 163, 184, 0.2); + background: rgba(8, 14, 29, 0.62); + color: var(--muted); + font-size: 0.76rem; + } + + .tips summary { + cursor: pointer; + list-style: none; + padding: 10px 12px; + color: var(--text); + font-weight: 700; + } + + .tips summary::-webkit-details-marker { + display: none; + } + + .tips-content { + padding: 0 12px 12px; + display: grid; + gap: 10px; + } + + .tips-content p { + margin: 0; + line-height: 1.45; + } + + .tips-content code { + font-family: inherit; + font-size: 0.73rem; + color: #dbeafe; + background: rgba(2, 132, 199, 0.14); + border: 1px solid rgba(56, 189, 248, 0.35); + border-radius: 7px; + padding: 3px 6px; + } + @media (max-width: 780px) { .page { min-height: calc(100dvh - 20px); - grid-template-rows: auto auto auto minmax(260px, 1fr); + grid-template-rows: auto auto minmax(260px, 1fr); } .hero { @@ -391,103 +558,141 @@ align-items: stretch; } + .controls { + grid-template-columns: 1fr; + } + + #go { + justify-self: start; + } + + .activity-main { + min-width: 0; + } + + .activity-copy { + min-width: 0; + } + #term { - min-height: 240px; + min-height: 42vh; } .terminal-shell { - min-height: 260px; + min-height: 46vh; } - .feedback-copy { - flex-direction: column; - align-items: start; + .activity-detail { + white-space: normal; } } - +

Remote REPL Playground

- Multi-transport sample for WebSocket, Telnet-over-WebSocket, and SignalR. - Try status, sessions, feedback demo, feedback fail, - watch, and send hello to explore shared state, live session activity, - and hosted progress feedback. + Try a hosted REPL over WebSocket, Telnet, or SignalR, then watch live session activity in the terminal.

-
Disconnected
-
- - - - +
+
+
+
+ Transport + i +
+
+ + + +
+
+
+
+
+
+ Terminal Profile + i +
+
+ + + + +
+
+
+
+
+
+ Advertised Features + i +
+
+ + + + + +
+
+
+

+ Basic stays text-only. Styled keeps ANSI formatting, and Rich adds VT input plus hosted progress reporting. +

+
+ Session summary +
+
+
-
-

What You Can Do

-

- Inspect the runtime with status, list active clients with sessions, - try interactive menus with configure and maintenance, - then run feedback demo and feedback fail to mirror OSC 9;4 progress - signals in the panel below. -

-
- status - sessions - who - feedback demo - feedback fail - configure - maintenance - settings show maintenance - settings set maintenance on - watch - send hello -
-
- -
@@ -632,19 +642,19 @@

Remote REPL Playground

Advertised Features - i + i
- - - - - + + + + +

- Basic stays text-only. Styled keeps ANSI formatting, and Rich adds VT input plus hosted progress reporting. + Basic is plain text. Styled keeps ANSI on. Rich adds VT input and external progress. The checkboxes below can still refine any preset.

Session summary @@ -838,7 +848,7 @@

Remote REPL Playground

? 'Styled' : profile === 'basic' ? 'Basic' - : 'Custom'; + : 'Modified'; } function setFeatureValues(featureMap) { @@ -873,10 +883,9 @@

Remote REPL Playground

function syncProfileSelection() { updateFeatureAvailability(); const profileName = resolveProfileName(getFeatureValues()); - const radio = document.querySelector(`input[name="profile"][value="${profileName}"]`); - if (radio) { - radio.checked = true; - } + profileRadios.forEach(radio => { + radio.checked = radio.value === profileName; + }); } function applyProfile(profileName) { @@ -931,7 +940,7 @@

Remote REPL Playground

function applyTerminalAppearance(config) { term.options.theme = config.ansi ? richTheme : plainTheme; const profileLabel = config.profile === 'custom' - ? 'custom' + ? 'modified' : config.profile; const transportLabel = config.transport === 'ws' ? 'websocket' @@ -949,7 +958,7 @@

Remote REPL Playground

if (config.features.vtinput) featureLabels.push('VT Input'); if (config.features.progress) featureLabels.push('Progress'); - connectionSummaryText.textContent = `${formatTransportLabel(config.transport)} with the ${formatProfileLabel(config.profile)} profile. ${featureLabels.length > 0 ? `Features: ${featureLabels.join(', ')}.` : 'No extra features advertised.'}`; + connectionSummaryText.textContent = `${formatTransportLabel(config.transport)} · ${formatProfileLabel(config.profile)}${featureLabels.length > 0 ? ` · ${featureLabels.join(', ')}` : ''}`; connectionSummaryPills.innerHTML = [ `${formatTransportLabel(config.transport)}`, `${formatProfileLabel(config.profile)}`, From 7678007b684be79bfc1dc5fe7c9c4d951c885042 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 18 Apr 2026 22:54:08 -0400 Subject: [PATCH 15/16] docs(interaction): align AdvancedProgressMode Auto description with new allowlist The Auto case in ConsoleReplInteractionPresenter now uses a conservative allowlist (WT_SESSION, ConEmuANSI, WezTerm) and explicitly stays silent under tmux/screen. Update the doc table to match, and add a note that Always is safe on modern terminals because unknown OSC sequences are typically ignored silently. --- docs/interaction.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/interaction.md b/docs/interaction.md index b7cd75c..c2aedc4 100644 --- a/docs/interaction.md +++ b/docs/interaction.md @@ -225,12 +225,14 @@ Notes: | Value | Behavior | |---|---| -| `Auto` | Emit advanced progress when the host is interactive and the terminal looks compatible | +| `Auto` | Emit advanced progress only for a conservative allowlist of known-compatible terminals; stay text-only for multiplexers such as `tmux`/`screen` and for unknown terminals | | `Always` | Always emit advanced progress when the host can write terminal control sequences | | `Never` | Disable advanced progress and keep the text-only fallback | The built-in console presenter maps progress states to `OSC 9;4` when advanced terminal progress is enabled. This is intended for user-facing execution feedback such as taskbar progress bars or mirrored hosted-session UI, not for application logging. +In practice, `Always` is usually safe on modern terminals because unknown `OSC` sequences are typically ignored silently. The main caveat is very old or non-conformant terminals, which may render unsupported control sequences literally instead of ignoring them. + --- ## Prefill with `--answer:*` From 9782346cdc81a2e1e20297f30572f35567fe7598 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 18 Apr 2026 22:54:22 -0400 Subject: [PATCH 16/16] sample(hosted): add section banners and intent comments to index.html The sample grew to ~1300 lines across CSS and JS. Add visual section banners to both and targeted "why" comments at non-obvious points: - OSC 9;4 sequence format and state-code mapping (0..4) - profilePresets (basic/styled/rich) with "custom" as fallback label - vtinput/progress require ANSI (feature coupling) - partial-chunk tail buffering in consumeTerminalChunk - body.connected-session layout-compaction trick No behavior change, pure editorial cleanup for comprehension. --- samples/05-hosting-remote/wwwroot/index.html | 69 ++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/samples/05-hosting-remote/wwwroot/index.html b/samples/05-hosting-remote/wwwroot/index.html index c187d24..e62d491 100644 --- a/samples/05-hosting-remote/wwwroot/index.html +++ b/samples/05-hosting-remote/wwwroot/index.html @@ -9,6 +9,9 @@