diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Domain/Agent.cs b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Agent.cs new file mode 100644 index 0000000..3795b96 --- /dev/null +++ b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Agent.cs @@ -0,0 +1,54 @@ +using TeamUp.SharedKernel.Access; +using TeamUp.SharedKernel.Domain; + +namespace TeamUp.Modules.OrgBoard.Domain; + +/// +/// The AI staffing an open seat: identity (name, monogram) + matched skill atoms + autonomy + +/// the model config + docs. References Skills by key and the BYOK ApiConfig by id — never reaches +/// into those modules' tables. One agent per seat. +/// +internal sealed class Agent : Entity +{ + public Guid SeatId { get; private set; } + public string Name { get; private set; } = null!; + public string? Monogram { get; private set; } + public Autonomy Autonomy { get; private set; } + public Guid ApiConfigId { get; private set; } + public Guid? FallbackApiConfigId { get; private set; } + public List SkillKeys { get; private set; } = []; + public List Docs { get; private set; } = []; + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } + + private Agent() + { + } + + public Agent(Guid seatId, DateTimeOffset createdAtUtc) + { + SeatId = seatId; + CreatedAtUtc = createdAtUtc; + UpdatedAtUtc = createdAtUtc; + } + + public void Configure( + string name, + string? monogram, + Autonomy autonomy, + Guid apiConfigId, + Guid? fallbackApiConfigId, + List skillKeys, + List docs, + DateTimeOffset nowUtc) + { + Name = name; + Monogram = monogram; + Autonomy = autonomy; + ApiConfigId = apiConfigId; + FallbackApiConfigId = fallbackApiConfigId; + SkillKeys = skillKeys; + Docs = docs; + UpdatedAtUtc = nowUtc; + } +} diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Domain/Seat.cs b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Seat.cs index 6ddd707..de5850c 100644 --- a/src/Modules/TeamUp.Modules.OrgBoard/Domain/Seat.cs +++ b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Seat.cs @@ -38,4 +38,11 @@ internal sealed class Seat : Entity AgentId = null; State = SeatState.Open; } + + public void AssignAgent(Guid agentId) + { + AgentId = agentId; + MemberId = null; + State = SeatState.Ai; + } } diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardDtos.cs b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardDtos.cs index 77933a1..dd58e90 100644 --- a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardDtos.cs +++ b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardDtos.cs @@ -1,4 +1,5 @@ using TeamUp.Modules.OrgBoard.Domain; +using TeamUp.SharedKernel.Access; namespace TeamUp.Modules.OrgBoard.Endpoints; @@ -30,3 +31,27 @@ internal sealed record TaskResponse( internal sealed record BoardColumn(string Status, IReadOnlyList Items); internal sealed record BoardResponse(Guid TeamId, IReadOnlyList Columns); + +internal sealed record CreateSeatRequest(Guid TeamId, string RoleName); + +internal sealed record SeatResponse(Guid Id, Guid TeamId, string RoleName, string State, Guid? MemberId, Guid? AgentId); + +internal sealed record ConfigureAgentRequest( + string Name, + string? Monogram, + Autonomy Autonomy, + Guid ApiConfigId, + Guid? FallbackApiConfigId, + List SkillKeys, + List Docs); + +internal sealed record AgentResponse( + Guid Id, + Guid SeatId, + string Name, + string? Monogram, + string Autonomy, + Guid ApiConfigId, + Guid? FallbackApiConfigId, + List SkillKeys, + List Docs); diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs index aa46d03..c6b0eeb 100644 --- a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs +++ b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs @@ -25,6 +25,11 @@ internal static class OrgBoardEndpoints group.MapPatch("/tasks/{id:guid}/move", MoveTask).RequireAuthorization(); group.MapPatch("/tasks/{id:guid}/assign", AssignTask).RequireAuthorization(); group.MapGet("/cartable", Cartable).RequireAuthorization(); + + group.MapPost("/seats", CreateSeat).RequireAuthorization(); + group.MapGet("/seats", ListSeats).RequireAuthorization(); + group.MapPost("/seats/{id:guid}/agent", ConfigureAgent).RequireAuthorization(); + group.MapGet("/seats/{id:guid}/agent", GetAgent).RequireAuthorization(); } private static TaskResponse ToResponse(WorkItem item) => new( @@ -225,4 +230,127 @@ internal static class OrgBoardEndpoints return (item, team, null); } + + private static SeatResponse ToSeat(Seat seat) => + new(seat.Id, seat.TeamId, seat.RoleName, seat.State.ToString(), seat.MemberId, seat.AgentId); + + private static AgentResponse ToAgent(Agent agent) => new( + agent.Id, agent.SeatId, agent.Name, agent.Monogram, agent.Autonomy.ToString(), + agent.ApiConfigId, agent.FallbackApiConfigId, agent.SkillKeys, agent.Docs); + + private static async Task CreateSeat( + CreateSeatRequest request, ICurrentUser user, IPermissionService permissions, + IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct) + { + var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == request.TeamId, ct); + if (team is null) + { + return Results.NotFound("Team not found."); + } + + if (!permissions.Has(Capability.ConfigureAgents, ScopeRef.Team(team.Id), ScopeRef.Org(team.OrganizationId))) + { + return Results.Forbid(); + } + + if (string.IsNullOrWhiteSpace(request.RoleName)) + { + return Results.BadRequest("RoleName is required."); + } + + var seat = new Seat(team.Id, request.RoleName.Trim(), SeatState.Open, clock.GetUtcNow()); + db.Seats.Add(seat); + await db.SaveChangesAsync(ct); + await audit.WriteAsync(new AuditEvent("seat.created", "Seat", seat.Id, user.MemberId, request.RoleName), ct); + return Results.Ok(ToSeat(seat)); + } + + private static async Task ListSeats( + Guid teamId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct) + { + var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == teamId, ct); + if (team is null) + { + return Results.NotFound("Team not found."); + } + + if (!permissions.Has(Capability.ViewBoard, ScopeRef.Team(team.Id), ScopeRef.Org(team.OrganizationId))) + { + return Results.Forbid(); + } + + var seats = await db.Seats.Where(s => s.TeamId == teamId).OrderBy(s => s.CreatedAtUtc).ToListAsync(ct); + return Results.Ok(seats.Select(ToSeat).ToList()); + } + + private static async Task ConfigureAgent( + Guid id, ConfigureAgentRequest request, ICurrentUser user, IPermissionService permissions, + IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct) + { + var seat = await db.Seats.FirstOrDefaultAsync(s => s.Id == id, ct); + if (seat is null) + { + return Results.NotFound("Seat not found."); + } + + var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == seat.TeamId, ct); + if (team is null) + { + return Results.NotFound("Team not found."); + } + + if (!permissions.Has(Capability.ConfigureAgents, ScopeRef.Team(team.Id), ScopeRef.Org(team.OrganizationId))) + { + return Results.Forbid(); + } + + if (string.IsNullOrWhiteSpace(request.Name) || request.ApiConfigId == Guid.Empty) + { + return Results.BadRequest("Name and apiConfigId are required."); + } + + var now = clock.GetUtcNow(); + var agent = await db.Agents.FirstOrDefaultAsync(a => a.SeatId == seat.Id, ct); + var isNew = agent is null; + agent ??= new Agent(seat.Id, now); + agent.Configure( + request.Name.Trim(), request.Monogram, request.Autonomy, request.ApiConfigId, + request.FallbackApiConfigId, request.SkillKeys ?? [], request.Docs ?? [], now); + + if (isNew) + { + db.Agents.Add(agent); + } + + seat.AssignAgent(agent.Id); + await db.SaveChangesAsync(ct); + await audit.WriteAsync(new AuditEvent("agent.configured", "Agent", agent.Id, user.MemberId, agent.Name), ct); + return Results.Ok(ToAgent(agent)); + } + + private static async Task GetAgent( + Guid id, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct) + { + var seat = await db.Seats.FirstOrDefaultAsync(s => s.Id == id, ct); + if (seat is null) + { + return Results.NotFound("Seat not found."); + } + + var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == seat.TeamId, ct); + if (team is null) + { + return Results.NotFound("Team not found."); + } + + if (!permissions.Has(Capability.ViewBoard, ScopeRef.Team(team.Id), ScopeRef.Org(team.OrganizationId))) + { + return Results.Forbid(); + } + + var agent = await db.Agents.FirstOrDefaultAsync(a => a.SeatId == seat.Id, ct); + return agent is null + ? Results.NotFound("Seat has no agent configured.") + : Results.Ok(ToAgent(agent)); + } } diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609200923_AddAgents.Designer.cs b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609200923_AddAgents.Designer.cs new file mode 100644 index 0000000..0c93bf9 --- /dev/null +++ b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609200923_AddAgents.Designer.cs @@ -0,0 +1,217 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeamUp.Modules.OrgBoard.Persistence; + +#nullable disable + +namespace TeamUp.Modules.OrgBoard.Persistence.Migrations +{ + [DbContext(typeof(OrgBoardDbContext))] + [Migration("20260609200923_AddAgents")] + partial class AddAgents + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("orgboard") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Agent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApiConfigId") + .HasColumnType("uuid"); + + b.Property("Autonomy") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Docs") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("FallbackApiConfigId") + .HasColumnType("uuid"); + + b.Property("Monogram") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeatId") + .HasColumnType("uuid"); + + b.PrimitiveCollection>("SkillKeys") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SeatId") + .IsUnique(); + + b.ToTable("agents", "orgboard"); + }); + + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.ToTable("organizations", "orgboard"); + }); + + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Seat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MemberId") + .HasColumnType("uuid"); + + b.Property("RoleName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TeamId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TeamId"); + + b.ToTable("seats", "orgboard"); + }); + + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Team", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("teams", "orgboard"); + }); + + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssigneeId") + .HasColumnType("uuid"); + + b.Property("AssigneeKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByMemberId") + .HasColumnType("uuid"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TeamId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TeamId"); + + b.HasIndex("AssigneeKind", "AssigneeId"); + + b.ToTable("work_items", "orgboard"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609200923_AddAgents.cs b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609200923_AddAgents.cs new file mode 100644 index 0000000..2780fc3 --- /dev/null +++ b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609200923_AddAgents.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeamUp.Modules.OrgBoard.Persistence.Migrations +{ + /// + public partial class AddAgents : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "agents", + schema: "orgboard", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + SeatId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + Monogram = table.Column(type: "character varying(8)", maxLength: 8, nullable: true), + Autonomy = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + ApiConfigId = table.Column(type: "uuid", nullable: false), + FallbackApiConfigId = table.Column(type: "uuid", nullable: true), + SkillKeys = table.Column>(type: "text[]", nullable: false), + Docs = table.Column>(type: "text[]", nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_agents", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_agents_SeatId", + schema: "orgboard", + table: "agents", + column: "SeatId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "agents", + schema: "orgboard"); + } + } +} diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/OrgBoardDbContextModelSnapshot.cs b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/OrgBoardDbContextModelSnapshot.cs index dd27b6e..66ac1b1 100644 --- a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/OrgBoardDbContextModelSnapshot.cs +++ b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/OrgBoardDbContextModelSnapshot.cs @@ -1,5 +1,6 @@ // using System; +using System.Collections.Generic; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -23,6 +24,57 @@ namespace TeamUp.Modules.OrgBoard.Persistence.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Agent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApiConfigId") + .HasColumnType("uuid"); + + b.Property("Autonomy") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Docs") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("FallbackApiConfigId") + .HasColumnType("uuid"); + + b.Property("Monogram") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeatId") + .HasColumnType("uuid"); + + b.PrimitiveCollection>("SkillKeys") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SeatId") + .IsUnique(); + + b.ToTable("agents", "orgboard"); + }); + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Organization", b => { b.Property("Id") diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/OrgBoardDbContext.cs b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/OrgBoardDbContext.cs index 298de91..3af4055 100644 --- a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/OrgBoardDbContext.cs +++ b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/OrgBoardDbContext.cs @@ -10,6 +10,7 @@ internal sealed class OrgBoardDbContext(DbContextOptions opti public DbSet Organizations => Set(); public DbSet Teams => Set(); public DbSet Seats => Set(); + public DbSet Agents => Set(); public DbSet WorkItems => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -40,6 +41,16 @@ internal sealed class OrgBoardDbContext(DbContextOptions opti seat.HasIndex(s => s.TeamId); }); + modelBuilder.Entity(agent => + { + agent.ToTable("agents"); + agent.HasKey(a => a.Id); + agent.Property(a => a.Name).HasMaxLength(120).IsRequired(); + agent.Property(a => a.Monogram).HasMaxLength(8); + agent.Property(a => a.Autonomy).HasConversion().HasMaxLength(20); + agent.HasIndex(a => a.SeatId).IsUnique(); + }); + modelBuilder.Entity(workItem => { workItem.ToTable("work_items"); diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index 32fd14a..200503c 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -12,7 +12,7 @@ - $(NoWarn);CA1707;CA1711;xUnit1051 + $(NoWarn);CA1707;CA1711;CA1861;xUnit1051 diff --git a/tests/TeamUp.IntegrationTests/SeatConfigTests.cs b/tests/TeamUp.IntegrationTests/SeatConfigTests.cs new file mode 100644 index 0000000..35bb467 --- /dev/null +++ b/tests/TeamUp.IntegrationTests/SeatConfigTests.cs @@ -0,0 +1,110 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Xunit; + +namespace TeamUp.IntegrationTests; + +/// +/// 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. +/// +public sealed class SeatConfigTests(PostgresFixture postgres) : IClassFixture +{ + 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 SkillKeys, List 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(client, "/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-byok-secret", + }); + + // Create an open seat, then configure an AI agent on it. + var seat = await PostOk(client, "/api/orgboard/seats", + new { teamId = team.Id, roleName = "Product Owner" }); + Assert.Equal("Open", seat.State); + + var agent = await PostOk(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($"/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>($"/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 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(); + 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 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!; + } +}