Skip to content
Merged
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## [Unreleased]

### Phase 6 — Study Buddy endpoint (SSE + run history) (2026-06-15)

Phase 6 **AI-037, slice b** — the reader can now run the agent and watch it work. The panel UI is AI-038.

- **`POST /me/books/{editionId}/studybuddy`** — authenticated; runs `StudyBuddyAgent` on a highlighted passage and streams its progress over SSE: a **`step`** event per recorded step (index / kind / payload), a **`done`** event with the final answer (+ iterations + cost), or a terminal **`error`** event when the agent fails or exhausts its budget. The run is **persisted** (AI-036) on completion with the right status — a budget-exhausted run keeps its partial transcript. `X-Accel-Buffering: no` for the Cloudflare tunnel; client disconnect propagates untraced; rate-limited (`studybuddy`, 8/min/IP — runs are several LLM calls each).
- **`GET /me/studybuddy/runs/{runId}`** — returns a persisted run (scoped to the user) with its step transcript parsed from jsonb, for the "show steps" view.
- Tests: `StreamRunAsync` over the real agent + loop + scripted LLM — direct answer → `step`→`done` + a persisted `completed` run; never-terminating model → partial `step`s → terminal `error` + a persisted `budget_exhausted` run that keeps its transcript. Live-API integration (skip-friendly): no-auth → 401, empty passage → 400, unknown run → 404.

### Phase 6 — streaming agent loop (2026-06-14)

Phase 6 **AI-037, slice a** — the loop streams its steps so the reader can watch the agent work. The SSE endpoint + run persistence + `GET` are slice b.
Expand Down
181 changes: 181 additions & 0 deletions backend/src/Api/Endpoints/StudyBuddyEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
using System.Net.ServerSentEvents;
using System.Runtime.CompilerServices;
using System.Text.Json;
using Api.Extensions;
using Api.Sites;
using Application.Agents;
using Application.Auth;
using Application.Common.Interfaces;
using Contracts.Agents;
using Microsoft.EntityFrameworkCore;
using TextStack.Ai.Agents;
using TextStack.Ai.Core;

namespace Api.Endpoints;

/// <summary>
/// Study Buddy agent endpoints (Phase 6, AI-037b). <c>POST /me/books/{editionId}/studybuddy</c> runs
/// the agent on a highlighted passage and streams its steps over SSE (<c>step</c>* → <c>done</c> |
/// <c>error</c>), persisting the run (AI-036) when it finishes. <c>GET /me/studybuddy/runs/{id}</c>
/// returns a persisted run for the "show steps" UI. Authenticated; rate-limited (agent runs are
/// several LLM calls each).
/// </summary>
public static class StudyBuddyEndpoints
{
private const string Agent = "studybuddy";
private const int MaxPassageLength = 4000;

public static void MapStudyBuddyEndpoints(this WebApplication app)
{
app.MapPost("/me/books/{editionId:guid}/studybuddy", Run)
.WithTags("Agents")
.RequireRateLimiting("studybuddy");

app.MapGet("/me/studybuddy/runs/{runId:guid}", GetRun)
.WithTags("Agents");
}

private static async Task<IResult> Run(
Guid editionId,
StudyBuddyRequest request,
HttpContext httpContext,
AuthService authService,
IAppDbContext db,
StudyBuddyAgent agent,
IAgentRunWriter writer,
CancellationToken ct)
{
var userId = httpContext.GetUserId(authService);
if (userId is null) return Results.Unauthorized();

if (string.IsNullOrWhiteSpace(request.Passage))
return Results.BadRequest(new { error = "Passage is required." });
if (request.Passage.Length > MaxPassageLength)
return Results.BadRequest(new { error = $"Passage exceeds {MaxPassageLength} chars." });

var siteId = httpContext.GetSiteId();
var editionExists = await db.Editions.AnyAsync(e => e.Id == editionId && e.SiteId == siteId, ct);
if (!editionExists) return Results.NotFound("Edition not found");

// Cloudflare/nginx must not buffer the stream (same Phase 5 risk as Explain SSE).
httpContext.Response.Headers["X-Accel-Buffering"] = "no";
httpContext.Response.Headers.CacheControl = "no-cache";

var runId = Guid.NewGuid();
var input = new StudyBuddyInput(request.Passage, editionId, request.ChapterNumber);

return TypedResults.ServerSentEvents(StreamRunAsync(
agent, writer, runId, userId.Value, editionId, input, httpContext.RequestServices,
httpContext.RequestAborted));
}

/// <summary>
/// Streams the agent's steps as SSE and persists the run on completion. <c>step</c> per recorded
/// step, <c>done</c> with the final answer, or <c>error</c> when the agent fails / exhausts its
/// budget — the run is persisted with the right status either way (a budget-exhausted run keeps
/// its partial transcript, AI-036). Client disconnect propagates untraced.
/// </summary>
public static async IAsyncEnumerable<SseItem<string>> StreamRunAsync(
StudyBuddyAgent agent,
IAgentRunWriter writer,
Guid runId,
Guid userId,
Guid editionId,
StudyBuddyInput input,
IServiceProvider requestServices,
[EnumeratorCancellation] CancellationToken ct)
{
var ctx = new AgentContext(userId, editionId, runId, requestServices);

AgentResult<string>? result = null;
AgentBudgetExhaustedException? budgetExhausted = null;
Exception? failure = null;

await using var e = agent.StreamAsync(input, ctx, ct).GetAsyncEnumerator(ct);
while (true)
{
AgentEvent ev;
try
{
if (!await e.MoveNextAsync())
break;
ev = e.Current;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw; // client disconnected — nothing to emit or persist
}
catch (AgentBudgetExhaustedException ex)
{
budgetExhausted = ex;
break;
}
catch (Exception ex)
{
failure = ex;
break;
}

if (ev.Step is { } step)
yield return new SseItem<string>(SerializeStep(step), "step");
else if (ev.Result is { } r)
{
result = r;
yield return new SseItem<string>(SerializeDone(runId, r), "done");
}
}

var record = result is not null
? AgentRunRecordFactory.Completed(runId, Agent, userId, editionId, input.Passage, result)
: budgetExhausted is not null
? AgentRunRecordFactory.BudgetExhausted(runId, Agent, userId, editionId, input.Passage, budgetExhausted)
: AgentRunRecordFactory.Failed(runId, Agent, userId, editionId, input.Passage,
failure ?? new InvalidOperationException("Agent stream ended without a result."));

// Best-effort persistence — a failed write must not break the response the client already got.
var persisted = true;
try
{
await writer.WriteAsync(record, ct);
}
catch
{
persisted = false;
}

if (result is null)
yield return new SseItem<string>(SerializeError(runId, record.Error, persisted), "error");
}

private static async Task<IResult> GetRun(
Guid runId, HttpContext httpContext, AuthService authService, IAppDbContext db, CancellationToken ct)
{
var userId = httpContext.GetUserId(authService);
if (userId is null) return Results.Unauthorized();

var run = await db.AgentRuns
.Where(r => r.Id == runId && r.UserId == userId)
.FirstOrDefaultAsync(ct);
if (run is null) return Results.NotFound();

using var steps = JsonDocument.Parse(run.StepsJson);
return Results.Ok(new StudyBuddyRunDto(
run.Id, run.Agent, run.Status, run.Output, steps.RootElement.Clone(),
run.Iterations, run.CostUsd, run.LatencyMs, run.Error, run.CreatedAt));
}

private static string SerializeStep(AgentStep step) =>
JsonSerializer.Serialize(new { index = step.Index, kind = step.Kind, payload = step.Payload, at = step.At });

private static string SerializeDone(Guid runId, AgentResult<string> result) =>
JsonSerializer.Serialize(new
{
runId,
answer = result.Output,
iterations = result.Usage.Iterations,
costUsd = result.Usage.CostUsdTotal,
});

private static string SerializeError(Guid runId, string? message, bool persisted) =>
JsonSerializer.Serialize(new { runId, error = message ?? "Agent run failed", persisted });
}
12 changes: 12 additions & 0 deletions backend/src/Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,17 @@
QueueLimit = 0,
});
});
// Study Buddy agent (AI-037): each run is several LLM calls, so a tighter per-IP limit.
options.AddPolicy("studybuddy", httpContext =>
{
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(ip, _ => new FixedWindowRateLimiterOptions
{
Window = TimeSpan.FromMinutes(1),
PermitLimit = 8,
QueueLimit = 0,
});
});
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
// Emit Retry-After so clients can back off intelligently instead of
// hammering in a tight retry loop. RateLimiter exposes the metadata
Expand Down Expand Up @@ -493,6 +504,7 @@
app.MapAdminAiQualityEndpoints();
app.MapAdminRagEndpoints();
app.MapAskEndpoints();
app.MapStudyBuddyEndpoints();
app.MapVocabularyEndpoints();
app.MapTtsEndpoints();
app.MapExportEndpoints();
Expand Down
22 changes: 22 additions & 0 deletions backend/src/Contracts/Agents/StudyBuddyDtos.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System.Text.Json;

namespace Contracts.Agents;

/// <summary>Request to run the Study Buddy agent on a highlighted passage (Phase 6, AI-037).</summary>
public record StudyBuddyRequest(string Passage, int? ChapterNumber);

/// <summary>
/// A persisted Study Buddy run for the "show steps" view (<c>GET /me/studybuddy/runs/{id}</c>).
/// <see cref="Steps"/> is the transcript parsed from the stored jsonb.
/// </summary>
public record StudyBuddyRunDto(
Guid Id,
string Agent,
string Status,
string? Output,
JsonElement Steps,
int Iterations,
decimal CostUsd,
int LatencyMs,
string? Error,
DateTimeOffset CreatedAt);
69 changes: 69 additions & 0 deletions tests/TextStack.IntegrationTests/StudyBuddyEndpointTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System.Net;
using System.Net.Http.Json;

namespace TextStack.IntegrationTests;

/// <summary>
/// Integration tests for the Study Buddy agent endpoints (AI-037), against the live API. The no-auth /
/// validation / not-found paths run without a key (they're rejected before any agent work); a real
/// streamed run needs a user session + OpenAI key + corpus, so it isn't asserted here.
/// </summary>
public class StudyBuddyEndpointTests : IClassFixture<AuthenticatedApiFixture>
{
private static readonly Guid SomeEdition = Guid.Parse("11111111-2222-3333-4444-555555555555");
private readonly AuthenticatedApiFixture _fixture;

public StudyBuddyEndpointTests(AuthenticatedApiFixture fixture) => _fixture = fixture;

[Fact]
public async Task Run_NoAuth_Unauthorized()
{
var request = new HttpRequestMessage(HttpMethod.Post, $"/me/books/{SomeEdition}/studybuddy")
{
Content = JsonContent.Create(new { passage = "A confusing passage." }),
};
request.Headers.Host = LiveApiFixture.TestHost;

var response = await _fixture.Client.SendAsync(request, TestContext.Current.CancellationToken);

Assert.SkipWhen(response.StatusCode is HttpStatusCode.NotFound, "endpoint not deployed");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}

[Fact]
public async Task Run_AuthedEmptyPassage_BadRequest()
{
Assert.SkipUnless(_fixture.IsAuthenticated, "auth unavailable");

var request = _fixture.CreateRequest(HttpMethod.Post, $"/me/books/{SomeEdition}/studybuddy");
request.Content = JsonContent.Create(new { passage = "" });

var response = await _fixture.Client.SendAsync(request, TestContext.Current.CancellationToken);

Assert.SkipWhen(response.StatusCode is HttpStatusCode.NotFound, "endpoint not deployed");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}

[Fact]
public async Task GetRun_NoAuth_Unauthorized()
{
var request = new HttpRequestMessage(HttpMethod.Get, $"/me/studybuddy/runs/{Guid.NewGuid()}");
request.Headers.Host = LiveApiFixture.TestHost;

var response = await _fixture.Client.SendAsync(request, TestContext.Current.CancellationToken);

Assert.SkipWhen(response.StatusCode is HttpStatusCode.NotFound, "endpoint not deployed");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}

[Fact]
public async Task GetRun_AuthedUnknownRun_NotFound()
{
Assert.SkipUnless(_fixture.IsAuthenticated, "auth unavailable");

var request = _fixture.CreateRequest(HttpMethod.Get, $"/me/studybuddy/runs/{Guid.NewGuid()}");
var response = await _fixture.Client.SendAsync(request, TestContext.Current.CancellationToken);

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