d9f9349117
SharedKernel contracts (so Assembler stays decoupled): IAgentRunContextProvider (agent + task) and ISkillCatalog (skill prompts by key). Implemented by OrgBoard (AgentRunContextProvider) and Skills (SkillCatalog). Assembler: - PromptAssembler builds house-style + identity + the agent's skill bodies + the task, and derives the primary action + risk from the agent's first skill. RAG/working-memory join at M6. - AgentRunExecutor (real): resolve context + skills → assemble → resolve BYOK config (with fallback) → call IModelClient → parse into action + risk → capture all on the AgentRun. Verified: build green; ArchitectureTests 8/8; IntegrationTests 29/29 — incl. the M4 acceptance: assigning a Spec task to Aria (PO, gated, stub BYOK) yields a Completed run with the assembled prompt (skill body + task title), action "write-spec", risk "Draft", and model output. Nothing executes — the gate is M5. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
142 lines
5.9 KiB
C#
142 lines
5.9 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// M4 acceptance: assigning a task to an AI seat (Aria) produces an AgentRun whose assembled
|
|
/// context (house style + skills + task) and reasoning are captured, the model is called (BYOK,
|
|
/// stub provider), and the output is parsed into an action + risk tag. Nothing executes (gate is M5).
|
|
/// </summary>
|
|
public sealed class AssemblerRunTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
|
|
{
|
|
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);
|
|
|
|
[Fact]
|
|
public async Task Assigning_a_task_to_an_AI_seat_produces_a_parsed_run()
|
|
{
|
|
var settings = new Dictionary<string, string?>
|
|
{
|
|
["GitSource:Provider"] = "filesystem",
|
|
["GitSource:Root"] = LocateSkillsDirectory(),
|
|
};
|
|
|
|
await using var factory = new TeamUpWebFactory(postgres.ConnectionString, settings);
|
|
using var anon = factory.CreateClient();
|
|
|
|
var bootstrap = await anon.PostAsJsonAsync("/api/identity/bootstrap", new
|
|
{
|
|
organizationName = "AliaSaaS",
|
|
ownerEmail = "owner@alia.test",
|
|
ownerDisplayName = "Owner",
|
|
ownerPassword = "Passw0rd!",
|
|
});
|
|
var owner = await bootstrap.Content.ReadFromJsonAsync<BootstrapResponse>();
|
|
Assert.NotNull(owner);
|
|
|
|
using var client = factory.CreateClient();
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", owner!.Token);
|
|
|
|
await client.PostAsJsonAsync("/api/orgboard/organizations", new { organizationId = owner.OrganizationId, name = "AliaSaaS" });
|
|
var team = await PostOk<TeamResponse>(client, "/api/orgboard/teams", new { organizationId = owner.OrganizationId, name = "IPNOPS" });
|
|
|
|
// A BYOK model connection (stub provider → no network).
|
|
var config = await PostOk<IdResponse>(client, "/api/integrations/api-configs", new
|
|
{
|
|
organizationId = owner.OrganizationId,
|
|
name = "Vertex-Pro",
|
|
provider = "stub",
|
|
model = "gemini-pro",
|
|
apiKey = "sk-demo-key",
|
|
});
|
|
|
|
// Index the skill atoms so the assembler has their bodies.
|
|
var sync = await PostOk<SyncResult>(client, "/api/skills/sync", new { });
|
|
Assert.True(sync.Indexed >= 2);
|
|
|
|
// Configure Aria (PO) on a seat: gated, with the PO skills and the stub config.
|
|
var seat = await PostOk<SeatResponse>(client, "/api/orgboard/seats", new { teamId = team.Id, roleName = "Product Owner" });
|
|
await PostOk<JsonElementShim>(client, $"/api/orgboard/seats/{seat.Id}/agent", new
|
|
{
|
|
name = "Aria",
|
|
monogram = "AR",
|
|
autonomy = "Gated",
|
|
apiConfigId = config.Id,
|
|
skillKeys = new[] { "spec-writing", "story-breakdown" },
|
|
docs = Array.Empty<string>(),
|
|
});
|
|
|
|
// A feature task for Aria.
|
|
var task = await PostOk<IdResponse>(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",
|
|
});
|
|
|
|
// Dispatch the task to the AI seat → a queued run.
|
|
var run = await PostOk<RunResponse>(client, "/api/assembler/runs", new { seatId = seat.Id, workItemId = task.Id });
|
|
Assert.Equal("Queued", run.Status);
|
|
|
|
// Drain it exactly as the worker does.
|
|
await using (var scope = factory.Services.CreateAsyncScope())
|
|
{
|
|
var queue = scope.ServiceProvider.GetRequiredService<JobQueue>();
|
|
var job = await queue.ClaimNextAsync("test-worker");
|
|
Assert.NotNull(job);
|
|
await scope.ServiceProvider.GetRequiredService<AgentRunExecutor>().ProcessAsync(job!);
|
|
}
|
|
|
|
// The run completed with assembled context + a parsed action/risk.
|
|
var done = await client.GetFromJsonAsync<RunResponse>($"/api/assembler/runs/{run.Id}");
|
|
Assert.Equal("Completed", done!.Status);
|
|
Assert.NotNull(done.AgentId); // the run resolved the configured agent
|
|
Assert.Equal("write-spec", done.ActionType); // spec-writing's primary action
|
|
Assert.Equal("Draft", done.ActionRisk);
|
|
Assert.Contains("Spec Writing", done.Prompt); // the skill body was assembled in
|
|
Assert.Contains("Add a logout button", done.Prompt); // the task title
|
|
Assert.False(string.IsNullOrWhiteSpace(done.Output));
|
|
}
|
|
|
|
private sealed record JsonElementShim;
|
|
|
|
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!;
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|