M3: Agent bound to a seat — configure an AI seat

OrgBoard: Agent entity (name, monogram, autonomy dial, ApiConfigId + optional fallback,
skill keys, docs) + AddAgents migration; one agent per seat. References Skills by key and
the BYOK config by id — never reaches into those modules.

Endpoints: POST/GET /api/orgboard/seats (create/list seats), POST/GET
/api/orgboard/seats/{id}/agent (configure/read the agent) — ConfigureAgents at [team, org].
Configuring an agent flips the seat to the AI state and points it at the agent; audited.

Verified: build green; ArchitectureTests 8/8; IntegrationTests 27/27 incl. the M3 acceptance
flow — owner adds a BYOK config, then configures "Aria" (gated autonomy, skills, that config)
on a seat, flipping it to AI, with the key never exposed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-09 23:49:28 +03:30
parent 1559975518
commit e202246a1c
10 changed files with 658 additions and 1 deletions
@@ -0,0 +1,110 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Xunit;
namespace TeamUp.IntegrationTests;
/// <summary>
/// M3 acceptance: an owner adds a BYOK config, then configures an AI seat ("Aria", gated autonomy,
/// a skill, that config) — flipping the seat to AI — without the key ever being exposed.
/// </summary>
public sealed class SeatConfigTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
{
private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId);
private sealed record OrganizationResponse(Guid Id, string Name);
private sealed record TeamResponse(Guid Id, Guid OrganizationId, string Name);
private sealed record ApiConfigDto(Guid Id, string Name, string Provider, string Model, string? Endpoint);
private sealed record SeatResponse(Guid Id, Guid TeamId, string RoleName, string State, Guid? MemberId, Guid? AgentId);
private sealed record AgentResponse(
Guid Id, Guid SeatId, string Name, string? Monogram, string Autonomy,
Guid ApiConfigId, Guid? FallbackApiConfigId, List<string> SkillKeys, List<string> Docs);
[Fact]
public async Task Owner_configures_an_ai_seat_with_skills_autonomy_and_byok_config()
{
await using var factory = new TeamUpWebFactory(postgres.ConnectionString);
using var anon = factory.CreateClient();
var owner = await Bootstrap(anon);
using var client = Authed(factory, owner.Token);
await PostOk<OrganizationResponse>(client, "/api/orgboard/organizations",
new { organizationId = owner.OrganizationId, name = "AliaSaaS" });
var team = await PostOk<TeamResponse>(client, "/api/orgboard/teams",
new { organizationId = owner.OrganizationId, name = "IPNOPS" });
var config = await PostOk<ApiConfigDto>(client, "/api/integrations/api-configs", new
{
organizationId = owner.OrganizationId,
name = "Vertex-Pro",
provider = "stub",
model = "gemini-pro",
apiKey = "sk-byok-secret",
});
// Create an open seat, then configure an AI agent on it.
var seat = await PostOk<SeatResponse>(client, "/api/orgboard/seats",
new { teamId = team.Id, roleName = "Product Owner" });
Assert.Equal("Open", seat.State);
var agent = await PostOk<AgentResponse>(client, $"/api/orgboard/seats/{seat.Id}/agent", new
{
name = "Aria",
monogram = "AR",
autonomy = "Gated",
apiConfigId = config.Id,
skillKeys = new[] { "spec-writing", "story-breakdown" },
docs = new[] { "product-docs" },
});
Assert.Equal("Aria", agent.Name);
Assert.Equal("Gated", agent.Autonomy);
Assert.Equal(config.Id, agent.ApiConfigId);
Assert.Contains("spec-writing", agent.SkillKeys);
// Reading it back returns the same configuration.
var fetched = await client.GetFromJsonAsync<AgentResponse>($"/api/orgboard/seats/{seat.Id}/agent");
Assert.Equal(agent.Id, fetched!.Id);
// The seat is now an AI seat pointing at the agent.
var seats = await client.GetFromJsonAsync<List<SeatResponse>>($"/api/orgboard/seats?teamId={team.Id}");
var aiSeat = seats!.Single(s => s.Id == seat.Id);
Assert.Equal("Ai", aiSeat.State);
Assert.Equal(agent.Id, aiSeat.AgentId);
}
private static async Task<BootstrapResponse> Bootstrap(HttpClient client)
{
var response = await client.PostAsJsonAsync("/api/identity/bootstrap", new
{
organizationName = "AliaSaaS",
ownerEmail = "owner@alia.test",
ownerDisplayName = "Owner",
ownerPassword = "Passw0rd!",
});
var owner = await response.Content.ReadFromJsonAsync<BootstrapResponse>();
Assert.NotNull(owner);
return owner!;
}
private static HttpClient Authed(TeamUpWebFactory factory, string token)
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
return client;
}
private static async Task<T> PostOk<T>(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<T>();
Assert.NotNull(value);
return value!;
}
}