Files
Teamup/tests/TeamUp.IntegrationTests/SkillRunScopingTests.cs
T
soroush.asadi 428eae9643 Fix (review): seat picker must surface the skill the run resolves
Adversarial review found a display-vs-run mismatch: the seat picker collapses the library to the
first row per key, but ListSkills ordered by version only — so for a key the org authored alongside
a higher-versioned builtin, the picker showed/flagged the builtin while the run injected the org's
own skill. ListSkills now orders the same way the run-time catalog resolves (Published-first,
org-owned-over-builtin, then latest version with the same Ordinal comparison), computed in-memory so
the version tiebreak can't diverge from SkillCatalog. The run itself was already correct; this aligns
what the operator sees with what executes. No client change needed.

SkillRunScopingTests now also asserts the library's first row for a key the org authored is the
org-owned Published row, not the builtin.

Verified: skills test subset 4/4 (full suite green pre-merge).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 18:30:12 +03:30

195 lines
8.3 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 TeamUp.Modules.Skills.Domain;
using TeamUp.Modules.Skills.Indexing;
using Xunit;
namespace TeamUp.IntegrationTests;
/// <summary>
/// Closes the library → delivery loop: when an AI seat runs a task, the skill bodies assembled into
/// its prompt are resolved within the org's namespace only — the org's own (authored/forked) skill is
/// preferred over the shared builtin of the same key, and another org's same-key skill never leaks in.
/// </summary>
public sealed class SkillRunScopingTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
{
private const string OrgABody = "ACME_ORG_A_CUSTOM_IMPL_BODY — our house implementation steps.";
private const string OrgBPoison = "ORG_B_POISON_BODY_MUST_NEVER_APPEAR_IN_ANOTHER_ORGS_PROMPT.";
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 TaskResponse(Guid Id);
private sealed record SyncResult(int Indexed);
private sealed record SkillSummary(string SkillKey, string Name, string Version, string Status);
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 A_run_assembles_the_orgs_own_skill_over_builtin_and_never_another_orgs()
{
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 owner = await PostOk<BootstrapResponse>(anon, "/api/identity/bootstrap", new
{
organizationName = "OrgA",
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 = "OrgA" });
var team = await PostOk<TeamResponse>(client, "/api/orgboard/teams", new { organizationId = owner.OrganizationId, name = "IPNOPS" });
var config = await PostOk<IdResponse>(client, "/api/integrations/api-configs", new
{
organizationId = owner.OrganizationId,
name = "Stub",
provider = "stub",
model = "gemini-pro",
apiKey = "sk-demo-key",
});
// The shared builtin "code-implementation" exists (Published) after a sync.
var sync = await PostOk<SyncResult>(client, "/api/skills/sync", new { });
Assert.True(sync.Indexed >= 8);
// Org A authors its OWN "code-implementation" (same key as the builtin) with a distinctive body.
await PostOk<object>(client, "/api/skills/authored", new
{
organizationId = owner.OrganizationId,
skillKey = "code-implementation",
name = "ACME Code Impl",
version = "1.0.0",
summary = "Our house implementation skill.",
roles = new[] { "engineer" },
inputs = (string?)null,
outputs = (string?)null,
actions = new[] { new { name = "implement-code", risk = "draft", description = (string?)null } },
tools = Array.Empty<string>(),
context = Array.Empty<string>(),
visibility = "private",
minTier = "free",
body = OrgABody,
goldenTests = new[] { new { input = "task", expected = "code" } },
});
// A DIFFERENT org publishes its own "code-implementation" — it must never reach org A's prompt.
await SeedSkillAsync(factory, Guid.NewGuid(), "code-implementation", OrgBPoison);
var seat = await PostOk<SeatResponse>(client, "/api/orgboard/seats", new { teamId = team.Id, roleName = "Backend Engineer" });
await client.PostAsJsonAsync($"/api/orgboard/seats/{seat.Id}/agent", new
{
name = "Edison",
monogram = "ED",
autonomy = "Gated",
apiConfigId = config.Id,
skillKeys = new[] { "code-implementation" },
docs = Array.Empty<string>(),
});
var task = await PostOk<TaskResponse>(client, "/api/orgboard/tasks", new
{
teamId = team.Id,
title = "Implement the logout endpoint",
description = "POST /logout clears the session.",
type = "Story",
});
var run = await PostOk<RunResponse>(client, "/api/assembler/runs", new { seatId = seat.Id, workItemId = task.Id });
await DrainOneJob(factory);
var done = await client.GetFromJsonAsync<RunResponse>($"/api/assembler/runs/{run.Id}");
Assert.Equal("Completed", done!.Status);
Assert.Equal("implement-code", done.ActionType);
// The org's own skill body was assembled in — preferred over the builtin of the same key…
Assert.Contains(OrgABody, done.Prompt);
// …and another org's same-key skill never leaked across the tenant boundary.
Assert.DoesNotContain(OrgBPoison, done.Prompt);
// The seat picker collapses to the first row per key — that row must be the one the run
// resolved (the org's own, not the builtin), so seat configuration matches execution.
var library = await client.GetFromJsonAsync<List<SkillSummary>>(
$"/api/skills/?organizationId={owner.OrganizationId}");
var firstImpl = library!.First(s => s.SkillKey == "code-implementation");
Assert.Equal("ACME Code Impl", firstImpl.Name);
Assert.Equal("Published", firstImpl.Status);
}
private static async Task SeedSkillAsync(TeamUpWebFactory factory, Guid orgId, string key, string body)
{
using var scope = factory.Services.CreateScope();
var indexer = scope.ServiceProvider.GetRequiredService<SkillIndexer>();
var manifest = new SkillManifest
{
Id = key,
Name = key,
Version = "1.0.0",
Summary = "another org's skill",
Roles = ["engineer"],
Visibility = "public",
GoldenTests = [new GoldenExample { Input = "task", Expected = "code" }],
};
await indexer.IndexAsync(manifest, body, SkillOwnership.Authored(orgId, Guid.NewGuid()));
}
private static async Task DrainOneJob(TeamUpWebFactory factory)
{
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!);
}
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<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");
}
}