Files
AgenticCode/openspec/exports/chat-agent-full-spec.md
local 5b027eb0db feat: add extraction schema, sidebar nav, few-shot prompting, and prompt settings
Overhaul extraction pipeline with new TradeItem model, conversation flow,
and dedicated extraction endpoint. Add sidebar navigation with NavMenu
component and landing page. Introduce few-shot prompting service and
tests. Add prompt settings and email upload specs. Update OpenSpec
tooling with improved export-spec and extract-feature commands. Archive
completed changes and export full specs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:39:23 +01:00

12 KiB

Feature: AI Chat Agent with Streaming & Rich Text

Target: Existing MudBlazor app (.NET 9 / Blazor WASM + ASP.NET Core API)

Includes: basic-chat-interface, wire-responses-api, migrate-to-semantic-kernel, multi-turn-conversations, add-test-coverage, enable-rich-text-display

Skipped: migrate-claude-md-to-openspec (project scaffolding only)

Integration Rule

This feature is additive only. Existing applicationX code takes precedence over this spec in all cases.

  • DO NOT modify existing files, components, layouts, services, or patterns in the target
  • DO NOT replace existing patterns (e.g., if the target uses a different HttpClient pattern, use theirs)
  • DO add new files, new nav links, new routes, new DI registrations
  • DO conform to the target's existing code style, naming, and project structure
  • If the target already has an equivalent service (markdown renderer, HTTP wrapper, etc.), use theirs
  • If a task conflicts with existing target code, stop and notify the user — do not skip silently; the user decides whether to skip, adapt, or redesign

Assumes

  • .NET 9 solution with Blazor WASM client + ASP.NET Core API projects + Shared class library
  • MudBlazor 9.2.0 installed and configured (AddMudServices, providers in MainLayout)
  • MudLayout with MudAppBar and MudMainContent in MainLayout.razor

Target Layout (CRC app)

|------ ~220px ------|-------------- fluid ---------------|
|                    | ≡  CRC  0.0.0    APR-CRC-PROD-... | ← MudAppBar (regular, ~64px)
| Home               |------------------------------------|
| Pricer             |                                    |
| Market Data        |  MudMainContent (@Body)            |
| XVA Statistics     |  ← Chat page renders here          |
| Sales              |                                    |
| Sales Assistant ★  |                                    |
|                    |                                    |
| MudDrawer (~220px) |                                    |
|---------------------|-----------------------------------|
  • MudAppBar: regular height (~64px), not Dense — hamburger toggle, title + version, env badge
  • MudDrawer: left, ~220px, toggled by hamburger, contains MudNavMenu
  • MudMainContent: fluid width, pages render via @Body
  • "Sales Assistant" added as new MudNavLink in existing MudNavMenu
  • Chat page renders inside MudMainContent — must not set its own AppBar or layout
  • Available viewport: height: calc(100vh - 64px), width: fluid minus drawer when open

Packages

API project:

  • Microsoft.SemanticKernel 1.74.0
  • Microsoft.SemanticKernel.Connectors.OpenAI 1.74.0

Client project:

  • Microsoft.Extensions.Http 9.0.4
  • Markdig 1.1.1

Test projects (xUnit):

  • xunit, xunit.runner.visualstudio, Microsoft.NET.Test.Sdk
  • API tests: Moq, Microsoft.AspNetCore.Mvc.Testing
  • Client tests: Moq

Architecture

Three-project solution: WASM client sends chat requests to ASP.NET Core API, which processes them through Semantic Kernel (pointed at an OpenAI-compatible endpoint) and streams token deltas back as SSE. Client renders assistant markdown as sanitized HTML. An extraction plugin demonstrates SK tool calling.

Shared Models

ChatMessage — Shared/Models/

  • string Role ("user" | "assistant"), string Content, DateTime Timestamp

ChatRequest — Shared/Models/

  • List<ChatMessage> Messages

HealthResponse — Shared/Models/

  • string Status, DateTime Timestamp

ExtractedFields — Shared/Models/

  • Required: string? Client, string? Project, decimal? Hours, decimal? Rate, string? Currency, string? Date
  • Optional: string? Description, string? PoNumber

ValidationResult — Shared/Models/

  • bool IsValid, List<string> Errors

Components

API: Program.cs

  • Register AddControllers(), AddOpenAIChatCompletion() with configurable endpoint, AddKernel(), ExtractionPlugin singleton
  • CORS policy allowing Blazor client origin, any header/method
  • Config keys: ResponsesApi:BaseUrl (default http://localhost:8317/v1), ResponsesApi:Model, ResponsesApi:ApiKey
  • IMPORTANT: Base URL must include /v1 — the OpenAI SDK appends chat/completions directly
  • Add public partial class Program { } for test accessibility

API: ChatController — Controllers/

  • POST /api/chat accepts ChatRequest, returns text/event-stream
  • Get IChatCompletionService from Kernel, convert messages to ChatHistory
  • Import ExtractionPlugin from DI: _kernel.ImportPluginFromObject(plugin, "Extraction")
  • Use OpenAIPromptExecutionSettings with FunctionChoiceBehavior.Auto()
  • Stream via GetStreamingChatMessageContentsAsync(), emit non-empty chunks as SSE
  • SSE format: data: {"text":"<delta>"}\n\n, completion: data: [DONE]\n\n, error: data: {"error":"<msg>"}\n\n
  • Catch HttpRequestException → error SSE event, TaskCanceledException → silent (client disconnect)

API: HealthController — Controllers/

  • GET /api/health → returns HealthResponse with status "Healthy" and UTC timestamp

API: ExtractionPlugin — Plugins/

  • [KernelFunction("validate_extracted_fields")] method accepting JSON string
  • Deserialize to ExtractedFields, validate required fields non-null, Hours/Rate > 0
  • Return ValidationResult as JSON string

Client: Program.cs

  • Register AddMudServices(), MarkdownService (singleton)
  • Register AddHttpClient<ChatApiClient> with API base URL from config
  • Config: ApiBaseUrl_Https, ApiBaseUrl_Http in wwwroot/appsettings.json
  • Auto-detect HTTPS from HostEnvironment.BaseAddress

Client: ChatApiClient — Services/

  • Typed HttpClient wrapper
  • GetHealthAsync() → GET api/health, returns HealthResponse?
  • SendChatStreamingAsync(ChatRequest)IAsyncEnumerable<string>
    • Build HttpRequestMessage manually
    • Call httpRequest.SetBrowserResponseStreamingEnabled(true) (Blazor WASM extension)
    • Send with HttpCompletionOption.ResponseHeadersRead
    • Parse SSE line-by-line: extract "text" field, throw on "error" field, stop on [DONE]
    • SSE loop: see "Critical Patterns" section below — do NOT use reader.EndOfStream

Client: MarkdownService — Services/

  • Singleton wrapping Markdig with UseAdvancedExtensions() pipeline
  • ConvertToHtml(string markdown) → sanitized HTML string
  • Two-pass sanitization:
    1. Strip <script> and <style> blocks with content (regex)
    2. Filter tags against allowlist: p, h1-h6, strong, em, code, pre, ul, ol, li, a, table, thead, tbody, tr, th, td, br, blockquote
  • Only href attribute allowed (on <a> only)

Client: SalesAssistant.razor — Pages/, route /sales-assistant

  • Page title: "Sales Assistant"
  • Add MudNavLink in existing sidebar NavMenu: icon Icons.Material.Filled.SmartToy, href /sales-assistant
  • Message list with MudPaper bubbles: user (right-aligned, primary bg), assistant (left-aligned, surface bg)
  • MudTextField with send icon adornment, Enter key handler, disabled during streaming
  • Empty state with centered title text
  • "New Chat" button (MudButton) above input, visible when messages exist, disabled during streaming
  • Streaming: append tokens to assistant message, call StateHasChanged() + auto-scroll per token
  • Assistant messages: render via (MarkupString)GetRenderedHtml(message) inside .markdown-body div
  • Rendered HTML cache (Dictionary<ChatMessage, string>) — cache on stream completion, render fresh during streaming
  • Auto-scroll via JS interop: document.querySelector('.message-list').scrollTop = .scrollHeight
  • Thinking indicator: MudProgressCircular when assistant content is empty and streaming
  • Multi-turn: send all messages with non-empty content (excludes empty assistant placeholder)
  • User messages: plain MudText, no markdown

Client: SalesAssistant.razor.css — scoped styles

  • .chat-container: flex column, height: calc(100vh - 64px), max-width 800px, centered within MudMainContent
  • .message-list: flex 1, overflow-y auto, flex column, gap 0.75rem
  • Message alignment: .message-user flex-end, .message-assistant flex-start
  • ::deep .bubble-user: primary color bg, white text, rounded except bottom-right
  • ::deep .bubble-assistant: surface bg, border, rounded except bottom-left
  • ::deep .markdown-body styles: code blocks (gray bg, monospace), tables (bordered), blockquotes (left border primary), headings (scaled down), links (primary color, underline)

Wiring (dependency order)

  1. Shared project: Create all models (ChatMessage, ChatRequest, HealthResponse, ExtractedFields, ValidationResult)
  2. API Program.cs: AddControllers → AddOpenAIChatCompletion → AddKernel → AddSingleton → AddCors → Build → UseCors → UseAuthorization → MapControllers
  3. Client Program.cs: AddMudServices → AddSingleton → AddHttpClient → Build → RunAsync
  4. API appsettings.json: {"ResponsesApi": {"BaseUrl": "http://localhost:8317/v1", "Model": "claude-sonnet-4-6"}}
  5. Client wwwroot/appsettings.json: {"ApiBaseUrl_Http": "http://localhost:7000", "ApiBaseUrl_Https": "https://localhost:7100"}

Critical Patterns (copy these, do not improvise)

SSE read loop in Blazor WASM

// DO NOT use reader.EndOfStream — it does a synchronous peek,
// which Blazor WASM's fetch-backed stream rejects at runtime.
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
    if (!line.StartsWith("data: ")) continue;
    var data = line.Substring(6);
    if (data == "[DONE]") yield break;
    // parse {"text":"..."} or {"error":"..."}
}

Chat container height

/* 64px = MudAppBar regular height in the CRC app.
   The chat page renders inside MudMainContent which already
   sits below the AppBar, so use 100% of the parent height
   rather than calculating from viewport. If MudMainContent
   does not have height set, fall back to calc(100vh - 64px). */
.chat-container {
    height: calc(100vh - 64px);
    /* MudDrawer width is handled by MudLayout — the chat container
       is fluid within MudMainContent, no horizontal calc needed. */
}

Behavior (non-obvious)

  • Base URL must include /v1 — SK's OpenAI connector appends chat/completions directly
  • WASM streaming requires both SetBrowserResponseStreamingEnabled(true) and ResponseHeadersRead
  • Only emit SSE chunks where !string.IsNullOrEmpty(chunk.Content) — tool call chunks have no text
  • Cache rendered HTML for completed messages to avoid re-running Markdig on every StateHasChanged
  • public partial class Program { } at bottom of API Program.cs for WebApplicationFactory<Program> in tests
  • ExtractionPlugin is imported per-request via ImportPluginFromObject (not at Kernel build time)
  • Chat sends all messages with non-empty content — filters out the empty assistant placeholder
  • Chat container height assumes MudAppBar Dense (48px) — if your AppBar height differs, adjust the calc() or use a CSS variable
  • On mobile viewports, 100vh includes the browser address bar — use 100dvh if targeting mobile

Test Coverage

  • HealthControllerTests: GET /api/health returns 200 with valid HealthResponse
  • ChatControllerTests: Mock IChatCompletionService, verify SSE text deltas + [DONE]; verify error handling; use WebApplicationFactory<Program>
  • ExtractionPluginTests: Valid fields → IsValid; missing required → errors; invalid JSON → error; zero hours → error
  • ChatApiClientTests: Mock HttpMessageHandler with canned SSE streams; verify delta parsing order; verify error event throws
  • MarkdownServiceTests: Verify rendering (bold, italic, code, lists, tables, links, headings); verify sanitization (script/style stripping, event handler removal, tag allowlist)