Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

AI-powered chat web app with Microsoft Entra ID authentication and Azure AI Foundry Agent Service integration. Single-container deployment on Azure Container Apps.

## Commands

### Frontend (from `frontend/`)
```bash
npm install --legacy-peer-deps # Install (--legacy-peer-deps required for React 19)
npm run dev # Vite dev server on :5173, proxies /api to :8080
npm run build # TypeScript check + Vite production build
npm run lint # ESLint (no auto-fix)
```

### Backend (from `backend/WebApp.Api/`)
```bash
dotnet watch run # Dev server on :8080 with hot reload
dotnet build # Build
dotnet test # Run tests (from backend/)
```

### Full Stack Local Dev
```bash
# Option 1: VS Code compound task (Ctrl+Shift+B → "Start Dev")
# Option 2: PowerShell script
.\deployment\scripts\start-local-dev.ps1
# Option 3: Run both manually in separate terminals
```

### Deployment (Azure Developer CLI)
```bash
azd up # Full deploy: infra + code (~10-12 min)
azd deploy # Code-only deploy (~3-5 min)
azd provision # Infrastructure only / update RBAC
azd down --force --purge # Teardown all Azure resources
```

## Architecture

| Layer | Tech | Port | Entry Point |
|-------|------|------|-------------|
| Frontend | React 19 + TypeScript + Vite 7 | 5173 | `frontend/src/App.tsx` |
| Backend | ASP.NET Core 9 Minimal APIs | 8080 | `backend/WebApp.Api/Program.cs` |
| Auth | MSAL.js (PKCE) → JWT Bearer | — | `frontend/src/config/authConfig.ts` |
| AI SDK | Azure.AI.Projects + Agent Framework | — | `backend/.../Services/AgentFrameworkService.cs` |
| Deploy | Azure Container Apps (single container) | — | `infra/main.bicep` |

**Key flow**: React SPA → MSAL token → `POST /api/chat/stream` → AI Foundry Agent → SSE chunks → UI

### Single-Container Pattern
Production: Vite builds React to static files → ASP.NET Core serves them from `wwwroot` alongside the API. Both live in one Docker image (`deployment/docker/frontend.Dockerfile` — multi-stage: Node build → .NET build → runtime).

### Frontend Architecture
- **State**: Centralized `AppContext` with `useReducer` + typed discriminated-union actions (`frontend/src/reducers/appReducer.ts`). Dev-mode logging middleware logs state diffs.
- **Components**: Container/Presentation split — `AgentPreview.tsx` (state wiring) → `ChatInterface.tsx` (stateless UI) → chat subcomponents in `frontend/src/components/chat/`.
- **Streaming**: `ChatService` class (`frontend/src/services/chatService.ts`) uses `fetch` + `ReadableStream` reader for SSE. Supports `AbortController` cancellation and retry with exponential backoff.
- **Path alias**: `~` maps to `frontend/src/` in Vite config.
- **UI library**: Fluent UI v9 + `@fluentui-copilot` components.

### Backend Architecture
- **Minimal API** endpoints defined inline in `Program.cs` (no controllers).
- **SSE streaming**: Writes `text/event-stream` events directly to `HttpResponse` with manual flush. Event types: `conversationId`, `chunk`, `annotations`, `mcpApprovalRequest`, `usage`, `done`, `error`.
- **Auth**: `Microsoft.Identity.Web` validates JWT with dual audience (`api://{clientId}` and `{clientId}`). Policy `RequireChatScope` requires `Chat.ReadWrite` scope.
- **AI integration**: `AgentFrameworkService` wraps `Azure.AI.Projects` SDK (beta) for agent loading and streaming. Uses `ChainedTokenCredential` (AzureCLI locally, ManagedIdentity in production).

### API Endpoints
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/api/chat/stream` | POST | Send message, receive SSE streaming response |
| `/api/agent` | GET | Get agent metadata (name, description, starter prompts) |
| `/api/agent/info` | GET | Debug info about loaded agent |
| `/api/health` | GET | Authenticated health check |

### Environment Variables
**Backend** (`.env` in project root, auto-generated by `azd up`):
- `ENTRA_SPA_CLIENT_ID`, `ENTRA_TENANT_ID` — Auth config
- `AI_AGENT_ENDPOINT`, `AI_AGENT_ID` — AI Foundry connection

**Frontend** (`frontend/.env.local`, auto-generated by `azd up`):
- `VITE_ENTRA_SPA_CLIENT_ID`, `VITE_ENTRA_TENANT_ID` — Build-time only (accessed via `import.meta.env.VITE_*`)

Vite config also reads from `.azure/{envName}/.env` if present (azd environment store).

## Key Dependencies
- `Azure.AI.Projects` v1.2.0-beta.5 and `Microsoft.Agents.AI.AzureAI` v1.0.0-preview — beta SDKs, APIs may change
- React 19 requires `--legacy-peer-deps` for npm install; explicitly add peer deps like `yjs` in `package.json`
- Office documents (DOCX/PPTX/XLSX) are not supported for upload

## Infrastructure
Bicep templates in `infra/` deploy: Azure Container Apps, ACR (Basic), Log Analytics, system-assigned Managed Identity. Deployment hooks in `deployment/hooks/` handle Entra app registration, AI Foundry discovery, and RBAC assignment via PowerShell.
300 changes: 300 additions & 0 deletions backend/WebApp.Api.Tests/Endpoints/AgentEndpointsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Moq;
using WebApp.Api.Models;

namespace WebApp.Api.Tests.Endpoints;

public class CreateAgentEndpointTests : IClassFixture<TestWebApplicationFactory>
{
private readonly HttpClient _client;
private readonly Mock<Services.IAgentCrudService> _mockService;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};

public CreateAgentEndpointTests(TestWebApplicationFactory factory)
{
_mockService = factory.MockAgentCrudService;
_client = factory.CreateClient();
}

[Fact]
public async Task CreateAgent_ValidRequest_Returns201()
{
var request = new CreateAgentRequest
{
Name = "test-agent",
Model = "gpt-4o",
Instructions = "You are a helpful assistant."
};

var expectedResponse = new CreateAgentResponse
{
Name = "test-agent",
Version = "1.0",
Description = null,
Model = "gpt-4o",
Instructions = "You are a helpful assistant.",
CreatedAt = 1700000000
};

_mockService.Setup(s => s.CreateAgentAsync(
It.Is<CreateAgentRequest>(r => r.Name == "test-agent"),
It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedResponse);

var response = await _client.PostAsJsonAsync("/api/agents", request, JsonOptions);

Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.Equal("/api/agents/test-agent", response.Headers.Location?.OriginalString);

var body = await response.Content.ReadFromJsonAsync<CreateAgentResponse>(JsonOptions);
Assert.NotNull(body);
Assert.Equal("test-agent", body.Name);
Assert.Equal("1.0", body.Version);
Assert.Equal("gpt-4o", body.Model);
}

[Fact]
public async Task CreateAgent_WithAllFields_Returns201()
{
var request = new CreateAgentRequest
{
Name = "full-agent",
Model = "gpt-4o",
Instructions = "You are helpful.",
Description = "Full test agent",
Temperature = 0.7f,
TopP = 0.9f,
Metadata = new Dictionary<string, string> { ["env"] = "test" }
};

var expectedResponse = new CreateAgentResponse
{
Name = "full-agent",
Version = "1.0",
Description = "Full test agent",
Model = "gpt-4o",
Instructions = "You are helpful.",
CreatedAt = 1700000000,
Metadata = new Dictionary<string, string> { ["env"] = "test" }
};

_mockService.Setup(s => s.CreateAgentAsync(
It.Is<CreateAgentRequest>(r => r.Name == "full-agent" && r.Temperature == 0.7f),
It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedResponse);

var response = await _client.PostAsJsonAsync("/api/agents", request, JsonOptions);

Assert.Equal(HttpStatusCode.Created, response.StatusCode);

var body = await response.Content.ReadFromJsonAsync<CreateAgentResponse>(JsonOptions);
Assert.NotNull(body);
Assert.Equal("Full test agent", body.Description);
Assert.Equal(1700000000, body.CreatedAt);
Assert.NotNull(body.Metadata);
Assert.Equal("test", body.Metadata["env"]);
}

[Fact]
public async Task CreateAgent_EmptyName_Returns400()
{
var request = new CreateAgentRequest
{
Name = "",
Model = "gpt-4o",
Instructions = "test"
};

var response = await _client.PostAsJsonAsync("/api/agents", request, JsonOptions);

Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);

var body = await response.Content.ReadAsStringAsync();
Assert.Contains("Name is required", body);
}

[Fact]
public async Task CreateAgent_WhitespaceName_Returns400()
{
var request = new CreateAgentRequest
{
Name = " ",
Model = "gpt-4o",
Instructions = "test"
};

var response = await _client.PostAsJsonAsync("/api/agents", request, JsonOptions);

Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}

[Fact]
public async Task CreateAgent_EmptyModel_Returns400()
{
var request = new CreateAgentRequest
{
Name = "test",
Model = "",
Instructions = "test"
};

var response = await _client.PostAsJsonAsync("/api/agents", request, JsonOptions);

Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);

var body = await response.Content.ReadAsStringAsync();
Assert.Contains("Model is required", body);
}

[Fact]
public async Task CreateAgent_EmptyInstructions_Returns400()
{
var request = new CreateAgentRequest
{
Name = "test",
Model = "gpt-4o",
Instructions = ""
};

var response = await _client.PostAsJsonAsync("/api/agents", request, JsonOptions);

Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);

var body = await response.Content.ReadAsStringAsync();
Assert.Contains("Instructions are required", body);
}

[Fact]
public async Task CreateAgent_ServiceThrows_Returns500()
{
var request = new CreateAgentRequest
{
Name = "error-agent",
Model = "gpt-4o",
Instructions = "test"
};

_mockService.Setup(s => s.CreateAgentAsync(
It.Is<CreateAgentRequest>(r => r.Name == "error-agent"),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Azure SDK error"));

var response = await _client.PostAsJsonAsync("/api/agents", request, JsonOptions);

Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
}

public class ListAgentsEndpointTests : IClassFixture<TestWebApplicationFactory>
{
private readonly HttpClient _client;
private readonly Mock<Services.IAgentCrudService> _mockService;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};

public ListAgentsEndpointTests(TestWebApplicationFactory factory)
{
_mockService = factory.MockAgentCrudService;
_client = factory.CreateClient();
}

[Fact]
public async Task ListAgents_ReturnsAgents()
{
var expectedResponse = new AgentListResponse
{
Agents = new List<AgentListItem>
{
new() { Name = "agent-1", Id = "id1", Model = "gpt-4o", CreatedAt = 100, Description = "First" },
new() { Name = "agent-2", Id = "id2", Model = "gpt-4o-mini", CreatedAt = 200 }
},
TotalCount = 2
};

_mockService.Setup(s => s.ListAgentsAsync(
null,
It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedResponse);

var response = await _client.GetAsync("/api/agents");

Assert.Equal(HttpStatusCode.OK, response.StatusCode);

var body = await response.Content.ReadFromJsonAsync<AgentListResponse>(JsonOptions);
Assert.NotNull(body);
Assert.Equal(2, body.TotalCount);
Assert.Equal(2, body.Agents.Count);
Assert.Equal("agent-1", body.Agents[0].Name);
Assert.Equal("agent-2", body.Agents[1].Name);
}

[Fact]
public async Task ListAgents_WithLimit_PassesLimitToService()
{
var expectedResponse = new AgentListResponse
{
Agents = new List<AgentListItem>
{
new() { Name = "agent-1", Id = "id1", Model = "gpt-4o", CreatedAt = 100 }
},
TotalCount = 1
};

_mockService.Setup(s => s.ListAgentsAsync(
5,
It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedResponse);

var response = await _client.GetAsync("/api/agents?limit=5");

Assert.Equal(HttpStatusCode.OK, response.StatusCode);

var body = await response.Content.ReadFromJsonAsync<AgentListResponse>(JsonOptions);
Assert.NotNull(body);
Assert.Single(body.Agents);
}

[Fact]
public async Task ListAgents_EmptyResult_ReturnsEmptyList()
{
var expectedResponse = new AgentListResponse
{
Agents = new List<AgentListItem>(),
TotalCount = 0
};

_mockService.Setup(s => s.ListAgentsAsync(
It.IsAny<int?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedResponse);

var response = await _client.GetAsync("/api/agents");

Assert.Equal(HttpStatusCode.OK, response.StatusCode);

var body = await response.Content.ReadFromJsonAsync<AgentListResponse>(JsonOptions);
Assert.NotNull(body);
Assert.Empty(body.Agents);
Assert.Equal(0, body.TotalCount);
}

[Fact]
public async Task ListAgents_ServiceThrows_Returns500()
{
_mockService.Setup(s => s.ListAgentsAsync(
It.IsAny<int?>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Connection failed"));

var response = await _client.GetAsync("/api/agents");

Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
}
Loading