Merge: skills wired into agent runs (org-scoped resolution) + picker alignment fix

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-13 18:30:12 +03:30
7 changed files with 236 additions and 13 deletions
+8 -3
View File
@@ -105,7 +105,12 @@ export function SeatsPage() {
if (!organizationId) return if (!organizationId) return
void run(async () => { void run(async () => {
setTeams(await api.get<Team[]>(`/api/orgboard/teams?organizationId=${organizationId}`)) setTeams(await api.get<Team[]>(`/api/orgboard/teams?organizationId=${organizationId}`))
setSkills(await api.get<Skill[]>('/api/skills/')) // The org's library = shared builtins + its own authored/installed skills. The API returns
// every version (newest first per key); collapse to one selectable entry per key.
const lib = await api.get<Skill[]>(`/api/skills/?organizationId=${organizationId}`)
const byKey = new Map<string, Skill>()
for (const s of lib) if (!byKey.has(s.skillKey)) byKey.set(s.skillKey, s)
setSkills([...byKey.values()])
await loadConfigs() await loadConfigs()
}) })
}, [organizationId, loadConfigs, run]) }, [organizationId, loadConfigs, run])
@@ -359,9 +364,9 @@ export function SeatsPage() {
)} )}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{skills.map((skill) => ( {skills.map((skill) => (
<button key={skill.skillKey} onClick={() => toggleSkill(skill.skillKey)}> <button key={skill.skillKey} onClick={() => toggleSkill(skill.skillKey)} title={skill.status !== 'Published' ? 'Draft — add roles + a golden test to publish before an agent can run it' : undefined}>
<Badge variant={agent.skillKeys.includes(skill.skillKey) ? 'default' : 'outline'}> <Badge variant={agent.skillKeys.includes(skill.skillKey) ? 'default' : 'outline'}>
{skill.name} {skill.name}{skill.status !== 'Published' ? ' · draft' : ''}
</Badge> </Badge>
</button> </button>
))} ))}
@@ -39,7 +39,7 @@ internal sealed class AgentRunExecutor(
var context = await contextProvider.GetAsync(run.SeatId, run.WorkItemId, cancellationToken) var context = await contextProvider.GetAsync(run.SeatId, run.WorkItemId, cancellationToken)
?? throw new InvalidOperationException("Agent or task not found for the run."); ?? throw new InvalidOperationException("Agent or task not found for the run.");
var skills = await skillCatalog.GetByKeysAsync(context.SkillKeys, cancellationToken); var skills = await skillCatalog.GetByKeysAsync(context.OrganizationId, context.SkillKeys, cancellationToken);
// Working memory: recall the team's most relevant decisions/corrections for this task. // Working memory: recall the team's most relevant decisions/corrections for this task.
var memories = await teamMemory.SearchAsync( var memories = await teamMemory.SearchAsync(
@@ -35,7 +35,7 @@ internal static class PromptAssembler
builder.AppendLine("# Skills"); builder.AppendLine("# Skills");
foreach (var skill in ordered) foreach (var skill in ordered)
{ {
builder.AppendLine("## " + skill.Name).AppendLine(skill.Body).AppendLine(); builder.AppendLine("## " + skill.Name + " (v" + skill.Version + ")").AppendLine(skill.Body).AppendLine();
} }
if (context.Docs.Count > 0) if (context.Docs.Count > 0)
@@ -69,7 +69,7 @@ internal static class PromptAssembler
{ {
agent = context.AgentName, agent = context.AgentName,
autonomy = context.Autonomy.ToString(), autonomy = context.Autonomy.ToString(),
skills = ordered.Select(s => s.Key).ToArray(), skills = ordered.Select(s => s.Key + "@" + s.Version).ToArray(),
docs = context.Docs, docs = context.Docs,
memories = memories.Count, memories = memories.Count,
apiConfigId = context.ApiConfigId, apiConfigId = context.ApiConfigId,
@@ -9,6 +9,7 @@ namespace TeamUp.Modules.Skills.Catalog;
internal sealed class SkillCatalog(SkillsDbContext db) : ISkillCatalog internal sealed class SkillCatalog(SkillsDbContext db) : ISkillCatalog
{ {
public async Task<IReadOnlyList<SkillPrompt>> GetByKeysAsync( public async Task<IReadOnlyList<SkillPrompt>> GetByKeysAsync(
Guid organizationId,
IReadOnlyCollection<string> keys, IReadOnlyCollection<string> keys,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
@@ -18,17 +19,28 @@ internal sealed class SkillCatalog(SkillsDbContext db) : ISkillCatalog
} }
var wanted = keys.ToHashSet(); var wanted = keys.ToHashSet();
var skills = await db.Skills.Where(s => wanted.Contains(s.SkillKey)).ToListAsync(cancellationToken);
// Org-scoped: only this org's own skills or shared builtins (null org) — never another org's.
// Published-only, so a run never executes an ungated (Draft) skill.
var skills = await db.Skills
.Where(s => wanted.Contains(s.SkillKey)
&& s.Status == SkillStatus.Published
&& (s.OrganizationId == organizationId || s.OrganizationId == null))
.ToListAsync(cancellationToken);
return skills return skills
.GroupBy(s => s.SkillKey) .GroupBy(s => s.SkillKey)
.Select(group => group.OrderByDescending(s => s.Version, StringComparer.Ordinal).First()) .Select(group => group
.OrderByDescending(s => s.OrganizationId == organizationId) // a forked/authored org skill beats the builtin
.ThenByDescending(s => s.Version, StringComparer.Ordinal) // then the latest version in that scope
.First())
.Select(s => .Select(s =>
{ {
var primary = s.Actions.Count > 0 ? s.Actions[0] : null; var primary = s.Actions.Count > 0 ? s.Actions[0] : null;
return new SkillPrompt( return new SkillPrompt(
s.SkillKey, s.SkillKey,
s.Name, s.Name,
s.Version,
s.Body, s.Body,
primary?.Name ?? "respond", primary?.Name ?? "respond",
(primary?.Risk ?? ActionRisk.Draft).ToString(), (primary?.Risk ?? ActionRisk.Draft).ToString(),
@@ -95,10 +95,16 @@ internal static class SkillsEndpoints
query = query.Where(s => s.Visibility == vis); query = query.Where(s => s.Visibility == vis);
} }
var skills = await query // Order so the FIRST row per key is exactly the one the run-time catalog resolves
.OrderBy(s => s.SkillKey) // (SkillCatalog.GetByKeysAsync): a Published row beats a Draft, the org's own beats the
.ThenByDescending(s => s.Version) // shared builtin, then the latest version — using the same Ordinal version comparison.
.ToListAsync(ct); // The seat picker collapses to the first row per key, so display matches what will run.
var skills = (await query.ToListAsync(ct))
.OrderBy(s => s.SkillKey, StringComparer.Ordinal)
.ThenByDescending(s => s.Status == SkillStatus.Published)
.ThenByDescending(s => s.OrganizationId == organizationId)
.ThenByDescending(s => s.Version, StringComparer.Ordinal)
.ToList();
return Results.Ok(skills.Select(ToSummary).ToList()); return Results.Ok(skills.Select(ToSummary).ToList());
} }
@@ -4,15 +4,21 @@ namespace TeamUp.SharedKernel.Ai;
public sealed record SkillPrompt( public sealed record SkillPrompt(
string Key, string Key,
string Name, string Name,
string Version,
string Body, string Body,
string PrimaryAction, string PrimaryAction,
string PrimaryActionRisk, string PrimaryActionRisk,
IReadOnlyList<string> Roles); IReadOnlyList<string> Roles);
/// <summary>Resolves skill prompts by key (latest version). Implemented by the Skills module.</summary> /// <summary>
/// Resolves skill prompts for one org's agent run. Each key resolves within that org's namespace
/// only — the org's own (published) skill, else a shared builtin — never another org's; latest
/// version, org-owned preferred over builtin. Implemented by the Skills module.
/// </summary>
public interface ISkillCatalog public interface ISkillCatalog
{ {
Task<IReadOnlyList<SkillPrompt>> GetByKeysAsync( Task<IReadOnlyList<SkillPrompt>> GetByKeysAsync(
Guid organizationId,
IReadOnlyCollection<string> keys, IReadOnlyCollection<string> keys,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
} }
@@ -0,0 +1,194 @@
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");
}
}