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,54 @@
using TeamUp.SharedKernel.Access;
using TeamUp.SharedKernel.Domain;
namespace TeamUp.Modules.OrgBoard.Domain;
/// <summary>
/// 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.
/// </summary>
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<string> SkillKeys { get; private set; } = [];
public List<string> 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<string> skillKeys,
List<string> docs,
DateTimeOffset nowUtc)
{
Name = name;
Monogram = monogram;
Autonomy = autonomy;
ApiConfigId = apiConfigId;
FallbackApiConfigId = fallbackApiConfigId;
SkillKeys = skillKeys;
Docs = docs;
UpdatedAtUtc = nowUtc;
}
}
@@ -38,4 +38,11 @@ internal sealed class Seat : Entity
AgentId = null; AgentId = null;
State = SeatState.Open; State = SeatState.Open;
} }
public void AssignAgent(Guid agentId)
{
AgentId = agentId;
MemberId = null;
State = SeatState.Ai;
}
} }
@@ -1,4 +1,5 @@
using TeamUp.Modules.OrgBoard.Domain; using TeamUp.Modules.OrgBoard.Domain;
using TeamUp.SharedKernel.Access;
namespace TeamUp.Modules.OrgBoard.Endpoints; namespace TeamUp.Modules.OrgBoard.Endpoints;
@@ -30,3 +31,27 @@ internal sealed record TaskResponse(
internal sealed record BoardColumn(string Status, IReadOnlyList<TaskResponse> Items); internal sealed record BoardColumn(string Status, IReadOnlyList<TaskResponse> Items);
internal sealed record BoardResponse(Guid TeamId, IReadOnlyList<BoardColumn> Columns); internal sealed record BoardResponse(Guid TeamId, IReadOnlyList<BoardColumn> 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<string> SkillKeys,
List<string> Docs);
internal sealed record AgentResponse(
Guid Id,
Guid SeatId,
string Name,
string? Monogram,
string Autonomy,
Guid ApiConfigId,
Guid? FallbackApiConfigId,
List<string> SkillKeys,
List<string> Docs);
@@ -25,6 +25,11 @@ internal static class OrgBoardEndpoints
group.MapPatch("/tasks/{id:guid}/move", MoveTask).RequireAuthorization(); group.MapPatch("/tasks/{id:guid}/move", MoveTask).RequireAuthorization();
group.MapPatch("/tasks/{id:guid}/assign", AssignTask).RequireAuthorization(); group.MapPatch("/tasks/{id:guid}/assign", AssignTask).RequireAuthorization();
group.MapGet("/cartable", Cartable).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( private static TaskResponse ToResponse(WorkItem item) => new(
@@ -225,4 +230,127 @@ internal static class OrgBoardEndpoints
return (item, team, null); 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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));
}
} }
@@ -0,0 +1,217 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ApiConfigId")
.HasColumnType("uuid");
b.Property<string>("Autonomy")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<List<string>>("Docs")
.IsRequired()
.HasColumnType("text[]");
b.Property<Guid?>("FallbackApiConfigId")
.HasColumnType("uuid");
b.Property<string>("Monogram")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<Guid>("SeatId")
.HasColumnType("uuid");
b.PrimitiveCollection<List<string>>("SkillKeys")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AgentId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("MemberId")
.HasColumnType("uuid");
b.Property<string>("RoleName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("TeamId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TeamId");
b.ToTable("seats", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Team", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.ToTable("teams", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssigneeId")
.HasColumnType("uuid");
b.Property<string>("AssigneeKind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedByMemberId")
.HasColumnType("uuid");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("TeamId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTimeOffset>("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
}
}
}
@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddAgents : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "agents",
schema: "orgboard",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
SeatId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
Monogram = table.Column<string>(type: "character varying(8)", maxLength: 8, nullable: true),
Autonomy = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
ApiConfigId = table.Column<Guid>(type: "uuid", nullable: false),
FallbackApiConfigId = table.Column<Guid>(type: "uuid", nullable: true),
SkillKeys = table.Column<List<string>>(type: "text[]", nullable: false),
Docs = table.Column<List<string>>(type: "text[]", nullable: false),
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAtUtc = table.Column<DateTimeOffset>(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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "agents",
schema: "orgboard");
}
}
}
@@ -1,5 +1,6 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -23,6 +24,57 @@ namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Agent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ApiConfigId")
.HasColumnType("uuid");
b.Property<string>("Autonomy")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<List<string>>("Docs")
.IsRequired()
.HasColumnType("text[]");
b.Property<Guid?>("FallbackApiConfigId")
.HasColumnType("uuid");
b.Property<string>("Monogram")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<Guid>("SeatId")
.HasColumnType("uuid");
b.PrimitiveCollection<List<string>>("SkillKeys")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("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 => modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Organization", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -10,6 +10,7 @@ internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> opti
public DbSet<Organization> Organizations => Set<Organization>(); public DbSet<Organization> Organizations => Set<Organization>();
public DbSet<Team> Teams => Set<Team>(); public DbSet<Team> Teams => Set<Team>();
public DbSet<Seat> Seats => Set<Seat>(); public DbSet<Seat> Seats => Set<Seat>();
public DbSet<Agent> Agents => Set<Agent>();
public DbSet<WorkItem> WorkItems => Set<WorkItem>(); public DbSet<WorkItem> WorkItems => Set<WorkItem>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
@@ -40,6 +41,16 @@ internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> opti
seat.HasIndex(s => s.TeamId); seat.HasIndex(s => s.TeamId);
}); });
modelBuilder.Entity<Agent>(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<string>().HasMaxLength(20);
agent.HasIndex(a => a.SeatId).IsUnique();
});
modelBuilder.Entity<WorkItem>(workItem => modelBuilder.Entity<WorkItem>(workItem =>
{ {
workItem.ToTable("work_items"); workItem.ToTable("work_items");
+1 -1
View File
@@ -12,7 +12,7 @@
<!-- Analyzer rules that fight idiomatic test code: <!-- Analyzer rules that fight idiomatic test code:
CA1707 underscored test names · CA1711 xUnit [CollectionDefinition] "Collection" suffix CA1707 underscored test names · CA1711 xUnit [CollectionDefinition] "Collection" suffix
· xUnit1051 TestContext cancellation token (not needed for these short tests). --> · xUnit1051 TestContext cancellation token (not needed for these short tests). -->
<NoWarn>$(NoWarn);CA1707;CA1711;xUnit1051</NoWarn> <NoWarn>$(NoWarn);CA1707;CA1711;CA1861;xUnit1051</NoWarn>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -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!;
}
}