using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using TeamUp.Modules.Assembler.Queue;
using TeamUp.Modules.Assembler.Runtime;
using Xunit;
namespace TeamUp.IntegrationTests;
///
/// M6 acceptance — the proof of the bet: a dev marks a story done → Quill (QA) wakes via the
/// handoff trigger, drafts a test plan → it waits in review → approve → analytics show edit
/// distance and approval rate for Aria and Quill. Plus: working memory is written on approval
/// and read back at the next assembly, and the trigger guardrails hold (no self-cascade, at
/// most one handoff per task).
///
public sealed class TwoRoleLoopTests(PostgresFixture postgres) : IClassFixture
{
private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId);
private sealed record IdResponse(Guid Id);
private sealed record TeamResponse(Guid Id, Guid OrganizationId, string Name);
private sealed record SeatResponse(Guid Id, Guid TeamId, string RoleName, string State, Guid? MemberId, Guid? AgentId);
private sealed record SyncResult(int Indexed);
private sealed record RunResponse(
Guid Id, Guid SeatId, Guid WorkItemId, Guid? AgentId, string Status,
string? ActionType, string? ActionRisk, string? Prompt, string? Output, string? Error);
private sealed record ReviewItemResponse(
Guid Id, Guid OrganizationId, Guid TeamId, Guid AgentRunId, Guid AgentId, Guid WorkItemId,
string ActionKind, string Risk, string Title, string Content, List ChildTitles,
string? Trace, string Status, string? Decision, double? EditDistance, DateTimeOffset CreatedAtUtc);
private sealed record TaskResponse(
Guid Id, Guid TeamId, string Title, string? Description, string Type,
string Status, string AssigneeKind, Guid? AssigneeId, Guid? ParentId);
private sealed record BoardColumn(string Status, List Items);
private sealed record BoardResponse(Guid TeamId, List Columns);
private sealed record MemoryHitResponse(Guid Id, string Kind, string Content, DateTimeOffset CreatedAtUtc);
private sealed record EditDistancePoint(DateTimeOffset DecidedAtUtc, double Distance);
private sealed record AgentAnalytics(
Guid AgentId, string Name, int Reviews, double? ApprovalRate, double? AvgEditDistance,
List Trend);
private sealed record AnalyticsResponse(
int TasksDone, int PendingReviews, int Decided, int Approved, int SentBack,
double? ApprovalRate, double? AvgEditDistance, List Agents);
private sealed record AuditEntryResponse(
Guid Id, string Action, string EntityType, Guid EntityId,
Guid? ActorMemberId, string? Details, DateTimeOffset OccurredAtUtc);
[Fact]
public async Task The_two_role_loop_runs_end_to_end_and_is_measurable()
{
var settings = new Dictionary
{
["GitSource:Provider"] = "filesystem",
["GitSource:Root"] = LocateSkillsDirectory(),
};
await using var factory = new TeamUpWebFactory(postgres.ConnectionString, settings);
using var anon = factory.CreateClient();
// --- Setup: owner, org, team, stub BYOK, skills, Aria (PO) + Quill (QA), both gated ---
var owner = await PostOk(anon, "/api/identity/bootstrap", new
{
organizationName = "AliaSaaS",
ownerEmail = "owner@alia.test",
ownerDisplayName = "Owner",
ownerPassword = "Passw0rd!",
});
using var client = Authed(factory, owner.Token);
await client.PostAsJsonAsync("/api/orgboard/organizations", new { organizationId = owner.OrganizationId, name = "AliaSaaS" });
var team = await PostOk(client, "/api/orgboard/teams", new { organizationId = owner.OrganizationId, name = "IPNOPS" });
var config = await PostOk(client, "/api/integrations/api-configs", new
{
organizationId = owner.OrganizationId,
name = "Vertex-Pro",
provider = "stub",
model = "gemini-pro",
apiKey = "sk-demo-key",
});
await PostOk(client, "/api/skills/sync", new { });
var poSeat = await PostOk(client, "/api/orgboard/seats", new { teamId = team.Id, roleName = "Product Owner" });
await client.PostAsJsonAsync($"/api/orgboard/seats/{poSeat.Id}/agent", new
{
name = "Aria",
monogram = "AR",
autonomy = "Gated",
apiConfigId = config.Id,
skillKeys = new[] { "spec-writing", "story-breakdown" },
docs = Array.Empty(),
});
var qaSeat = await PostOk(client, "/api/orgboard/seats", new { teamId = team.Id, roleName = "QA" });
await client.PostAsJsonAsync($"/api/orgboard/seats/{qaSeat.Id}/agent", new
{
name = "Quill",
monogram = "QU",
autonomy = "Gated",
apiConfigId = config.Id,
skillKeys = new[] { "test-plan-generation", "diff-review" },
docs = Array.Empty(),
});
// --- Aria proposes a spec; the owner corrects it on approval → memory is written ---
var specTask = await PostOk(client, "/api/orgboard/tasks", new
{
teamId = team.Id,
title = "Add a logout button to the header",
description = "Users need a way to end their session.",
type = "Spec",
});
await PostOk(client, "/api/assembler/runs", new { seatId = poSeat.Id, workItemId = specTask.Id });
await DrainOneJob(factory);
var ariaHeld = Assert.Single((await client.GetFromJsonAsync>(
$"/api/governance/reviews?organizationId={owner.OrganizationId}"))!);
await PostOk(client, $"/api/governance/reviews/{ariaHeld.Id}/approve", new
{
content = "Spec: a logout button in the header ends the session and returns to sign-in.",
childTitles = new[] { "Add the logout button", "Clear the session on click" },
});
// Working memory was written and is searchable.
var hits = await client.GetFromJsonAsync>(
$"/api/memory/search?teamId={team.Id}&q=logout%20header%20session");
Assert.NotEmpty(hits!);
Assert.Contains(hits!, h => h.Kind == "Correction" && h.Content.Contains("logout button"));
// --- Memory is read back at the NEXT assembly: Aria's second run carries "# Team memory" ---
var secondTask = await PostOk(client, "/api/orgboard/tasks", new
{
teamId = team.Id,
title = "Add a logout link to the mobile header",
description = (string?)null,
type = "Spec",
});
var secondRun = await PostOk(client, "/api/assembler/runs", new { seatId = poSeat.Id, workItemId = secondTask.Id });
await DrainOneJob(factory);
var secondDone = await client.GetFromJsonAsync($"/api/assembler/runs/{secondRun.Id}");
Assert.Equal("Completed", secondDone!.Status);
Assert.Contains("# Team memory", secondDone.Prompt);
Assert.Contains("[correction] write-spec", secondDone.Prompt);
// --- THE TRIGGER: a dev marks a story done → Quill wakes with a QA task ---
var story = await PostOk(client, "/api/orgboard/tasks", new
{
teamId = team.Id,
title = "Build the login screen",
description = "Implements the approved spec.",
type = "Story",
});
await PatchOk(client, $"/api/orgboard/tasks/{story.Id}/move", new { status = "Done" });
var board = await client.GetFromJsonAsync($"/api/orgboard/board?teamId={team.Id}");
var qaTask = Assert.Single(board!.Columns.SelectMany(c => c.Items), i => i.ParentId == story.Id);
Assert.Equal("Test", qaTask.Type);
Assert.StartsWith("QA:", qaTask.Title);
Assert.Equal("Agent", qaTask.AssigneeKind); // assigned to Quill — humans and AI share one task model
// Quill's run was dispatched by the trigger; drain it → the test plan waits in review.
await DrainOneJob(factory);
var pending = await client.GetFromJsonAsync>(
$"/api/governance/reviews?organizationId={owner.OrganizationId}");
var quillHeld = Assert.Single(pending!, r => r.WorkItemId == qaTask.Id);
Assert.Equal("write-test-plan", quillHeld.ActionKind);
// Approve Quill's plan with a small edit → the second agent's edit distance is recorded.
var quillApproved = await PostOk(client, $"/api/governance/reviews/{quillHeld.Id}/approve", new
{
content = "Test plan: 1. logout ends the session. 2. protected routes redirect after logout.",
childTitles = Array.Empty(),
});
Assert.True(quillApproved.EditDistance > 0);
// --- Guardrails: QA tasks never re-trigger; a story hands off at most once ---
await PatchOk(client, $"/api/orgboard/tasks/{qaTask.Id}/move", new { status = "Done" });
await PatchOk(client, $"/api/orgboard/tasks/{story.Id}/move", new { status = "InProgress" });
await PatchOk(client, $"/api/orgboard/tasks/{story.Id}/move", new { status = "Done" });
var after = await client.GetFromJsonAsync($"/api/orgboard/board?teamId={team.Id}");
var allTasks = after!.Columns.SelectMany(c => c.Items).ToList();
Assert.Single(allTasks, i => i.ParentId == story.Id); // still exactly one handoff
Assert.DoesNotContain(allTasks, i => i.ParentId == qaTask.Id); // QA's done never cascaded
// --- ANALYTICS: the bet is measurable — edit distance + approval rate for Aria AND Quill ---
var analytics = await client.GetFromJsonAsync(
$"/api/governance/analytics?organizationId={owner.OrganizationId}");
Assert.True(analytics!.TasksDone >= 2); // the story + the QA task
Assert.Equal(2, analytics.Decided);
Assert.Equal(2, analytics.Approved);
Assert.Equal(1.0, analytics.ApprovalRate);
Assert.True(analytics.AvgEditDistance > 0);
Assert.Equal(1, analytics.PendingReviews); // Aria's second (memory-aware) spec still waiting
var aria = Assert.Single(analytics.Agents, a => a.Name == "Aria");
var quill = Assert.Single(analytics.Agents, a => a.Name == "Quill");
Assert.True(aria.AvgEditDistance > 0);
Assert.True(quill.AvgEditDistance > 0);
Assert.NotEmpty(aria.Trend);
Assert.NotEmpty(quill.Trend);
// The handoff itself is on the audit trail.
var audit = await client.GetFromJsonAsync>(
$"/api/governance/audit?organizationId={owner.OrganizationId}&take=300");
Assert.Contains(audit!, e => e.Action == "handoff.triggered" && e.EntityId == qaTask.Id);
}
private static async Task DrainOneJob(TeamUpWebFactory factory)
{
await using var scope = factory.Services.CreateAsyncScope();
var queue = scope.ServiceProvider.GetRequiredService();
var job = await queue.ClaimNextAsync("test-worker");
Assert.NotNull(job);
await scope.ServiceProvider.GetRequiredService().ProcessAsync(job!);
}
private static HttpClient Authed(TeamUpWebFactory factory, string token)
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
client.DefaultRequestHeaders.Add("X-Skills-Admin-Key", TeamUpWebFactory.PlatformAdminKey);
return client;
}
private static async Task PostOk(HttpClient client, string url, object body)
{
var response = await client.PostAsJsonAsync(url, body);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var value = await response.Content.ReadFromJsonAsync();
Assert.NotNull(value);
return value!;
}
private static async Task PatchOk(HttpClient client, string url, object body)
{
var response = await client.PatchAsJsonAsync(url, body);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var value = await response.Content.ReadFromJsonAsync();
Assert.NotNull(value);
return value!;
}
private static string LocateSkillsDirectory()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "TeamUp.slnx")))
{
dir = dir.Parent;
}
Assert.NotNull(dir);
return Path.Combine(dir!.FullName, "skills");
}
}