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/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..c2aedc4 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 @@ -163,6 +179,60 @@ 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 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:*` @@ -263,8 +333,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/docs/mcp-agent-capabilities.md b/docs/mcp-agent-capabilities.md new file mode 100644 index 0000000..36b898f --- /dev/null +++ b/docs/mcp-agent-capabilities.md @@ -0,0 +1,346 @@ +# Agent Capabilities: Sampling, Elicitation, and Feedback + +> **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 all three in a CSV import and feedback workflow. + +## Overview + +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 | + +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-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 + +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. + +## 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 + +Design commands so these capabilities are **enhancements**, not hard requirements: + +```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."); +``` + +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. + +| 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. + +## Direct MCP interfaces vs IReplInteractionChannel + +| | `IMcpSampling` / `IMcpElicitation` / `IMcpFeedback` | `IReplInteractionChannel` | +|---|---|---| +| **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 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` 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/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..23bc3f7 100644 --- a/samples/05-hosting-remote/RemoteModule.cs +++ b/samples/05-hosting-remote/RemoteModule.cs @@ -130,12 +130,123 @@ 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 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(FeedbackStepDuration.Indeterminate, ct).ConfigureAwait(false); + await AnimateWarningProgressAsync( + channel, + "Retrying sync", + 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 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."); + }); + + m.Map( + "fail", + [Description("Run a failing feedback sequence with warning, error, and problem output")] + async (IReplInteractionChannel channel, CancellationToken ct) => + { + 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", + startPercent: 36, + endPercent: 58, + "Remote worker timed out", + FeedbackStepDuration.Warning, + ct).ConfigureAwait(false); + await AnimateErrorProgressAsync( + channel, + "Remote job failed", + startPercent: 66, + endPercent: 82, + "Final retry exhausted", + FeedbackStepDuration.Error, + ct).ConfigureAwait(false); + await channel.WriteProblemAsync( + "Remote feedback demo failed", + "The remote worker stayed unavailable after several retries.", + "remote_feedback_failed", + ct).ConfigureAwait(false); + 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."); + }); + }); + 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 +279,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 +399,124 @@ private static async Task DisplayMessagesAsync( // Expected when user presses Enter. } } + + 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 0a45382..e62d491 100644 --- a/samples/05-hosting-remote/wwwroot/index.html +++ b/samples/05-hosting-remote/wwwroot/index.html @@ -9,6 +9,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 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 is plain text. Styled keeps ANSI on. Rich adds VT input and external progress. The checkboxes below can still refine any preset. +

+
+ Session summary +
+
+
-
-

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. -

-
- status - sessions - who - configure - maintenance - settings show maintenance - settings set maintenance on - watch - send hello -
-
-
+
+
+ Current Activity + i +
+
+ + +
+ Waiting for a feedback command. +
Run feedback demo to watch the hosted session update this bar.
+
+ +
+ +
Terminal websocket
Type help to discover commands
+
+ Help & Tips +
+

+ Start with status, sessions, feedback demo, and feedback fail. + Then try watch, send hello, configure, or maintenance. +

+

+ Use Basic when you want a text-only fallback. Switch to Rich to advertise VT input and progress reporting, or choose Custom to fine-tune features yourself. +

+
+
@@ -314,104 +734,483 @@

What You Can Do

diff --git a/samples/08-mcp-server/Program.cs b/samples/08-mcp-server/Program.cs index 36d4f84..5ed64a0 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 ────────────── @@ -104,6 +105,187 @@ 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.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 ... + + // ── 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.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}\"")))); + + 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); + 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...", 45, 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) + { + await interaction.WriteWarningProgressAsync( + "Possible duplicates detected", + 60, + $"{remaining.Count} potential contact matches need review.", + ct); + + if (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) + { + 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 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."); + }) + .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(); + +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) => @@ -122,7 +304,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 +312,83 @@ 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 kv in llmResponse + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Select(part => 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; +} + +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 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/Console/ConsoleInteractionChannel.cs b/src/Repl.Core/Console/ConsoleInteractionChannel.cs index a934142..b320d8b 100644 --- a/src/Repl.Core/Console/ConsoleInteractionChannel.cs +++ b/src/Repl.Core/Console/ConsoleInteractionChannel.cs @@ -47,10 +47,94 @@ 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 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, + 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: + await PresentProblemAsync(problem, cancellationToken) + .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; + } + } + + 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); + } + + 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. /// @@ -66,7 +150,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 c7000ea..590d8ff 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; @@ -28,6 +30,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); @@ -50,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 || (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; } - 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)) @@ -67,32 +100,142 @@ 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); 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) + { + 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, + _ => SessionAdvertisesAdvancedProgress() || IsKnownAdvancedProgressTerminal(), + }; } + private static bool IsInteractiveTerminalSession() => + (!Console.IsOutputRedirected || ReplSessionIO.IsSessionActive) + && !ReplSessionIO.IsProtocolPassthrough; + + private static bool IsKnownAdvancedProgressTerminal() + { + if (IsTerminalMultiplexerSession()) + { + return false; + } + + return !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("WT_SESSION")) + || string.Equals(Environment.GetEnvironmentVariable("ConEmuANSI"), "ON", StringComparison.OrdinalIgnoreCase) + || string.Equals(Environment.GetEnvironmentVariable("TERM_PROGRAM"), "WezTerm", StringComparison.OrdinalIgnoreCase); + } + + private static bool SessionAdvertisesAdvancedProgress() => + ReplSessionIO.IsSessionActive + && ReplSessionIO.TerminalCapabilities.HasFlag(TerminalCapabilities.ProgressReporting); + + private static bool IsTerminalMultiplexerSession() + { + if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("TMUX"))) + { + return true; + } + + var term = Environment.GetEnvironmentVariable("TERM"); + return term?.StartsWith("screen", StringComparison.OrdinalIgnoreCase) is true + || term?.StartsWith("tmux", StringComparison.OrdinalIgnoreCase) is true; + } + + 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 6f302a6..02e1f48 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( @@ -502,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)) { @@ -528,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; @@ -550,6 +556,31 @@ 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 (OperationCanceledException) + { + // Clearing progress is best-effort cleanup and may race with cancellation. + } + 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 may race with teardown. + } + } + 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 4ffcf36..6247590 100644 --- a/src/Repl.Core/Documentation/DocumentationEngine.cs +++ b/src/Repl.Core/Documentation/DocumentationEngine.cs @@ -306,7 +306,10 @@ 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) + || 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..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 @@ -55,9 +57,11 @@ 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.WriteProgressAsync(value, 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 81e13ba..a0e0fcb 100644 --- a/src/Repl.Core/Interaction/ReplInteractionChannelExtensions.cs +++ b/src/Repl.Core/Interaction/ReplInteractionChannelExtensions.cs @@ -11,6 +11,131 @@ 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); + } + + /// + /// 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/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/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/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/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/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/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 26f47ed..9f45d22 100644 --- a/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs +++ b/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs @@ -198,7 +198,10 @@ 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) + || string.Equals(parameter.ParameterType.FullName, "Repl.Mcp.IMcpFeedback", StringComparison.Ordinal); private static ReplArity ResolveArity(ParameterInfo parameter, ReplOptionAttribute? optionAttribute) { 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/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.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.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.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_InteractionChannel.cs b/src/Repl.IntegrationTests/Given_InteractionChannel.cs index e01a100..a6bc285 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() @@ -58,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() @@ -194,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.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.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.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.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/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/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..12de521 --- /dev/null +++ b/src/Repl.Mcp/McpElicitationService.cs @@ -0,0 +1,148 @@ +using System.Text.Json; +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 TryGetContentValue(result?.Content, out var value) + ? value.GetString() + : null; + } + + public async ValueTask ElicitBooleanAsync( + string message, + CancellationToken cancellationToken = default) + { + var result = await ElicitSingleFieldAsync( + message, + new ElicitRequestParams.BooleanSchema(), + cancellationToken).ConfigureAwait(false); + + return TryGetContentValue(result?.Content, out var value) + ? value.GetBoolean() + : null; + } + + 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 = TryGetContentValue(result?.Content, out var value) + ? value.GetString() + : null; + if (selected is null) + { + return null; + } + + var index = -1; + for (var i = 0; i < choices.Count; i++) + { + if (string.Equals(choices[i], selected, StringComparison.OrdinalIgnoreCase)) + { + 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 TryGetContentValue(result?.Content, out var value) + ? value.GetDouble() + : null; + } + + 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; + } + + 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/McpFeedbackService.cs b/src/Repl.Mcp/McpFeedbackService.cs new file mode 100644 index 0000000..92a3a2a --- /dev/null +++ b/src/Repl.Mcp/McpFeedbackService.cs @@ -0,0 +1,118 @@ +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(); + // This service is created per McpServerHandler/session and overlaid into the + // per-connection service provider, so the attached server reference is not shared + // across concurrent MCP connections. + 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 4f8eefc..1d8ea66 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; @@ -16,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( @@ -191,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( @@ -207,6 +220,62 @@ 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 + { + WriteStatusRequest status => CompleteBuiltInDispatchAsync( + SendFeedbackAsync( + LoggingLevel.Info, + JsonSerializer.SerializeToElement(status.Text, McpJsonContext.Default.String), + cancellationToken)), + WriteProgressRequest progress => CompleteBuiltInDispatchAsync( + WriteStructuredProgressAsync(progress, cancellationToken)), + 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 (_feedback is not null) + { + await _feedback.SendMessageAsync(level, data, cancellationToken).ConfigureAwait(false); + return; + } + if (_server is null) { return; @@ -216,21 +285,97 @@ 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 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); + } - 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()."); + 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 + { + ["summary"] = problem.Summary, + }; + + 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/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..aff21ab 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -26,6 +26,9 @@ 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 McpFeedbackService _feedback; private readonly IServiceProvider _sessionServices; private readonly SemaphoreSlim _snapshotGate = new(initialCount: 1, maxCount: 1); private readonly Lock _refreshLock = new(); @@ -52,11 +55,17 @@ 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(); + _feedback = new McpFeedbackService(); _sessionServices = new McpServiceProviderOverlay( services, new Dictionary { [typeof(IMcpClientRoots)] = _roots, + [typeof(IMcpSampling)] = _sampling, + [typeof(IMcpElicitation)] = _elicitation, + [typeof(IMcpFeedback)] = _feedback, }); } @@ -410,6 +419,9 @@ private void AttachServer(McpServer? server) _server = server; _roots.AttachServer(server); + _sampling.AttachServer(server); + _elicitation.AttachServer(server); + _feedback.AttachServer(server); EnsureRoutingSubscription(); EnsureRootsNotificationHandler(server); } @@ -527,6 +539,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/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.Mcp/README.md b/src/Repl.Mcp/README.md index 21a67a4..4cf495c 100644 --- a/src/Repl.Mcp/README.md +++ b/src/Repl.Mcp/README.md @@ -17,6 +17,13 @@ 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 +64,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_McpAgentCapabilities.cs b/src/Repl.McpTests/Given_McpAgentCapabilities.cs new file mode 100644 index 0000000..b87ef8d --- /dev/null +++ b/src/Repl.McpTests/Given_McpAgentCapabilities.cs @@ -0,0 +1,330 @@ +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"); + } + + [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( + 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(); + } + } + + 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_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..0a8bd85 --- /dev/null +++ b/src/Repl.McpTests/Given_McpUserFeedback.cs @@ -0,0 +1,272 @@ +using System.Text.Json; +using System.Globalization; +using ModelContextProtocol; +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); + + await WaitForConditionAsync(() => notifications.Count >= 3).ConfigureAwait(false); + AssertFeedbackResult(result: result, notifications: notifications); + } + finally + { + NotificationCaptureState.Current = null; + } + } + + [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, progressUpdates); + NotificationCaptureState.Current = captureState; + try + { + await using var fixture = await CreateStructuredProgressFixtureAsync(CreateClientOptions()).ConfigureAwait(false); + + var result = await fixture.Client.CallToolAsync( + toolName: "feedback_progress", + arguments: new Dictionary(StringComparer.Ordinal), + progress: new Progress(_ => { })).ConfigureAwait(false); + + await WaitForConditionAsync(() => + { + return HasExpectedProgressSequence(progressUpdates) && notifications.Count >= 2; + }, timeoutMs: 5000).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) => + { + 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"; + }); + }, + 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() + { + Handlers = new McpClientHandlers + { + NotificationHandlers = + [ + new KeyValuePair>( + NotificationMethods.LoggingMessageNotification, + HandleLoggingNotificationAsync), + new KeyValuePair>( + NotificationMethods.ProgressNotification, + HandleProgressNotificationAsync), + ], + }, + }; + + 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 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; + 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.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) + { + var progressDump = DescribeProgressUpdates(progressUpdates); + var notificationDump = DescribeNotifications(notifications); + + result.IsError.Should().BeFalse(); + 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), $"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), $"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 record NotificationCaptureState( + List<(LoggingLevel Level, string Data)> Notifications, + List? ProgressUpdates = null) + { + public static NotificationCaptureState? Current { get; set; } + } +} diff --git a/src/Repl.Tests/Given_ConsoleReplInteractionPresenter_AdvancedProgress.cs b/src/Repl.Tests/Given_ConsoleReplInteractionPresenter_AdvancedProgress.cs new file mode 100644 index 0000000..d05ffa9 --- /dev/null +++ b/src/Repl.Tests/Given_ConsoleReplInteractionPresenter_AdvancedProgress.cs @@ -0,0 +1,151 @@ +using Repl.Tests.TerminalSupport; + +namespace Repl.Tests; + +[TestClass] +[DoNotParallelize] +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;"); + } + + [TestMethod] + [Description("Auto mode stays silent for advanced progress when the terminal session is running under tmux.")] + public async Task When_AdvancedProgressAuto_And_TmuxDetected_Then_TextRendersWithoutOscSequence() + { + using var env = new EnvironmentVariableScope( + ("TMUX", "/tmp/tmux-1000/default,123,0"), + ("TERM", "tmux-256color"), + ("WT_SESSION", null), + ("ConEmuANSI", null), + ("TERM_PROGRAM", null)); + + var harness = new TerminalHarness(cols: 80, rows: 12); + var presenter = new ConsoleReplInteractionPresenter( + new InteractionOptions { AdvancedProgressMode = AdvancedProgressMode.Auto }, + 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;"); + } + + [TestMethod] + [Description("Auto mode emits advanced progress for known terminals such as Windows Terminal.")] + public async Task When_AdvancedProgressAuto_And_WindowsTerminalDetected_Then_PresenterEmitsOscSequence() + { + using var env = new EnvironmentVariableScope( + ("TMUX", null), + ("TERM", null), + ("WT_SESSION", "test-session"), + ("ConEmuANSI", null), + ("TERM_PROGRAM", null)); + + var harness = new TerminalHarness(cols: 80, rows: 12); + var presenter = new ConsoleReplInteractionPresenter( + new InteractionOptions { AdvancedProgressMode = AdvancedProgressMode.Auto }, + 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("\u001b]9;4;1;42\u0007"); + } + + private sealed class EnvironmentVariableScope : IDisposable + { + private readonly (string Name, string? PreviousValue)[] _previousValues; + + public EnvironmentVariableScope(params (string Name, string? Value)[] variables) + { + _previousValues = variables + .Select(static variable => (variable.Name, Environment.GetEnvironmentVariable(variable.Name))) + .ToArray(); + + foreach (var (name, value) in variables) + { + Environment.SetEnvironmentVariable(name, value); + } + } + + public void Dispose() + { + foreach (var (name, previousValue) in _previousValues) + { + Environment.SetEnvironmentVariable(name, previousValue); + } + } + } +} 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.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); + } +} 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 @@ +