Run skill revision tests

This commit is contained in:
ZacharyZhang-NY committed 2026-05-23 22:56:06 -04:00
1 parent 16855b696a
commit 216d485c58
12 files changed
+245 -7

No files matched your search

@@ -171,6 +171,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
ipcMain.handle(IPC_CHANNELS.importSkillDirectory, (_event, input) => mutate(() => skills.importDirectory(input)));
ipcMain.handle(IPC_CHANNELS.bindSkill, (_event, input) => mutate(() => skills.bind(input)));
ipcMain.handle(IPC_CHANNELS.resolveAgentSkills, (_event, input) => mutate(() => skills.resolveRuntimeSnapshot(input)));
ipcMain.handle(IPC_CHANNELS.runSkillTests, (_event, input) => mutate(() => skills.runTests(input)));
ipcMain.handle(IPC_CHANNELS.createSocialAccount, (_event, input) => mutate(() => social.createAccount(input)));
ipcMain.handle(IPC_CHANNELS.createScheduledPost, (_event, input) => mutate(() => social.createScheduledPost(input)));
ipcMain.handle(IPC_CHANNELS.setScheduledPostState, (_event, input) => mutate(() => social.setScheduledPostState(input)));
@@ -1,6 +1,8 @@
import { existsSync, readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { normalize } from "node:path";
import type { DatabaseSync } from "node:sqlite";
import type { BindSkillInput, ImportSkillDirectoryInput, ResolveAgentSkillsInput } from "../../shared/commands";
import type { BindSkillInput, ImportSkillDirectoryInput, ResolveAgentSkillsInput, RunSkillTestsInput } from "../../shared/commands";
import { SLO_SIGNAL_KEY } from "../../shared/domain";
import { createId } from "../../shared/ids";
import { AuditService } from "./audit-service";
@@ -181,6 +183,53 @@ export class SkillService {
return snapshotId;
}
runTests(input: RunSkillTestsInput): string {
const revision = this.db.prepare(`
select sr.id, sr.version_semver, sd.slug
from skill_revisions sr
join skill_definitions sd on sd.id = sr.skill_id
where sr.id = ? and sd.org_id = ?
`).get(input.revisionId, input.orgId) as SkillRevisionTestRow | undefined;
if (!revision) throw new Error("Skill revision was not found.");
const tests = this.db.prepare(`
select id, test_type, expected_output_ref
from skill_tests
where revision_id = ?
order by expected_output_ref asc
`).all(input.revisionId) as unknown as SkillTestRow[];
const artifacts = this.db.prepare(`
select artifact_type, object_key, sha256, size_bytes
from skill_artifacts
where revision_id = ?
order by artifact_type asc, object_key asc
`).all(input.revisionId) as unknown as SkillArtifactRow[];
const structureFailure = verifyRevisionStructure(artifacts);
if (structureFailure && tests.length === 0) throw new Error(structureFailure.failure ?? "Skill revision structure check failed.");
const now = new Date().toISOString();
let passed = 0;
let failed = 0;
for (const test of tests) {
const result = structureFailure ?? runManifestArtifactTest(test, artifacts);
if (result.status === "passed") passed += 1;
if (result.status === "failed") failed += 1;
this.db.prepare("update skill_tests set last_result = ?, last_run_at = ? where id = ?")
.run(JSON.stringify(result), now, test.id);
}
this.audit.record({
orgId: input.orgId,
entityType: "skill_revision",
entityId: revision.id,
actorType: "system",
actorId: null,
action: "tests_run",
payload: { slug: revision.slug, version: revision.version_semver, total: tests.length, passed, failed },
});
return revision.id;
}
private verifyBindingTargets(input: BindSkillInput): void {
const agent = this.db.prepare("select id from agents where id = ? and org_id = ?")
.get(input.agentId, input.orgId) as { id: string } | undefined;
@@ -207,3 +256,103 @@ interface RuntimeSkillRow {
manifest_json: string;
object_key: string;
}
interface SkillRevisionTestRow {
id: string;
version_semver: string;
slug: string;
}
interface SkillTestRow {
id: string;
test_type: string;
expected_output_ref: string;
}
interface SkillArtifactRow {
artifact_type: string;
object_key: string;
sha256: string;
size_bytes: number;
}
interface SkillTestResult {
status: "passed" | "failed";
testType: string;
expectedOutputRef: string;
checks: Array<{ name: string; status: "passed" | "failed"; detail: string }>;
artifactSha256?: string;
failure?: string;
}
function verifyRevisionStructure(artifacts: SkillArtifactRow[]): SkillTestResult | null {
const requiredTypes = ["manifest", "entrypoint"];
for (const type of requiredTypes) {
if (!artifacts.some((artifact) => artifact.artifact_type === type)) {
return failedSkillTest("revision_structure", type, `Skill revision artifact is missing: ${type}`);
}
}
for (const artifact of artifacts) {
try {
verifyArtifactIntegrity(artifact);
} catch (error) {
return failedSkillTest("artifact_integrity", artifact.object_key, error instanceof Error ? error.message : String(error));
}
}
return null;
}
function runManifestArtifactTest(test: SkillTestRow, artifacts: SkillArtifactRow[]): SkillTestResult {
if (test.test_type !== "manifest") {
return failedSkillTest(test.test_type, test.expected_output_ref, `Unsupported skill test type: ${test.test_type}`);
}
const testArtifact = artifacts.find((artifact) => (
artifact.artifact_type === "test" && artifactMatchesExpectedRef(artifact.object_key, test.expected_output_ref)
));
if (!testArtifact) return failedSkillTest(test.test_type, test.expected_output_ref, "Skill test artifact was not found.");
try {
const content = verifyArtifactIntegrity(testArtifact).toString("utf8");
if (!content.trim()) throw new Error("Skill test artifact is empty.");
return {
status: "passed",
testType: test.test_type,
expectedOutputRef: test.expected_output_ref,
artifactSha256: testArtifact.sha256,
checks: [
{ name: "manifest_test_artifact", status: "passed", detail: "Artifact exists, checksum matches, and content is non-empty." },
],
};
} catch (error) {
return failedSkillTest(test.test_type, test.expected_output_ref, error instanceof Error ? error.message : String(error));
}
}
function verifyArtifactIntegrity(artifact: SkillArtifactRow): Buffer {
if (!existsSync(artifact.object_key)) throw new Error(`Skill artifact is missing: ${artifact.object_key}`);
const body = readFileSync(artifact.object_key);
const sha256 = createHash("sha256").update(body).digest("hex");
if (sha256 !== artifact.sha256) throw new Error(`Skill artifact checksum mismatch: ${artifact.object_key}`);
if (body.byteLength !== artifact.size_bytes) throw new Error(`Skill artifact size mismatch: ${artifact.object_key}`);
return body;
}
function artifactMatchesExpectedRef(objectKey: string, expectedRef: string): boolean {
const expected = normalizeSkillPath(expectedRef);
const actual = normalizeSkillPath(objectKey);
return actual === expected || actual.endsWith(`/${expected}`);
}
function normalizeSkillPath(value: string): string {
return normalize(value).replaceAll("\\", "/");
}
function failedSkillTest(testType: string, expectedOutputRef: string, failure: string): SkillTestResult {
return {
status: "failed",
testType,
expectedOutputRef,
checks: [{ name: "skill_test", status: "failed", detail: failure }],
failure,
};
}
@@ -115,6 +115,7 @@ export class SnapshotService {
skillRevisions: list(this.db, "skill_revisions", "published_at desc"),
skillBindings,
skillRuntimeSnapshots: list(this.db, "skill_runtime_snapshots", "created_at desc"),
skillTests: list(this.db, "skill_tests", "last_run_at desc, id asc"),
socialAccounts: list(this.db, "social_accounts", "created_at asc"),
socialAccountGrants: list(this.db, "social_account_grants", "created_at asc"),
browserSessionContainers: list<AppSnapshot["browserSessionContainers"][number]>(this.db, "browser_session_containers", "created_at asc")
+1
View File
@@ -37,6 +37,7 @@ const api: DesktopApi = {
importSkillDirectory: (input) => invoke(IPC_CHANNELS.importSkillDirectory, input),
bindSkill: (input) => invoke(IPC_CHANNELS.bindSkill, input),
resolveAgentSkills: (input) => invoke(IPC_CHANNELS.resolveAgentSkills, input),
runSkillTests: (input) => invoke(IPC_CHANNELS.runSkillTests, input),
createSocialAccount: (input) => invoke(IPC_CHANNELS.createSocialAccount, input),
launchSocialLogin: (input) => invoke(IPC_CHANNELS.launchSocialLogin, input),
createScheduledPost: (input) => invoke(IPC_CHANNELS.createScheduledPost, input),
@@ -71,6 +71,10 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
const skillIds = new Set(skills.map((skill) => skill.id));
return snapshot.skillRevisions.filter((revision) => skillIds.has(revision.skillId));
}, [snapshot.skillRevisions, skills]);
const skillTests = useMemo(() => {
const revisionIds = new Set(skillRevisions.map((revision) => revision.id));
return snapshot.skillTests.filter((test) => revisionIds.has(test.revisionId));
}, [snapshot.skillTests, skillRevisions]);
const skillBindings = useMemo(() => snapshot.skillBindings.filter((item) => item.orgId === orgId), [snapshot.skillBindings, orgId]);
const policies = useMemo(() => snapshot.policyRules.filter((item) => item.orgId === orgId), [snapshot.policyRules, orgId]);
const workspaces = useMemo(() => snapshot.workspaces.filter((item) => item.orgId === orgId), [snapshot.workspaces, orgId]);
@@ -147,6 +151,7 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
skills={skills}
revisions={skillRevisions}
bindings={skillBindings}
tests={skillTests}
agents={agents}
actions={actions}
/>
@@ -6,6 +6,7 @@ import type {
SkillBindingRecord,
SkillDefinitionRecord,
SkillRevisionRecord,
SkillTestRecord,
} from "../../../../shared/domain";
import type { SnapshotActions } from "../../app/useSnapshot";
@@ -14,11 +15,12 @@ interface SkillsPanelProps {
skills: SkillDefinitionRecord[];
revisions: SkillRevisionRecord[];
bindings: SkillBindingRecord[];
tests: SkillTestRecord[];
agents: AgentRecord[];
actions: SnapshotActions;
}
export function SkillsPanel({ orgId, skills, revisions, bindings, agents, actions }: SkillsPanelProps) {
export function SkillsPanel({ orgId, skills, revisions, bindings, tests, agents, actions }: SkillsPanelProps) {
const [importOpen, setImportOpen] = useState(false);
const importSkill = (event: FormEvent<HTMLFormElement>) => {
@@ -37,6 +39,10 @@ export function SkillsPanel({ orgId, skills, revisions, bindings, agents, action
void actions.run(() => window.agentCompany.bindSkill({ orgId, agentId, skillId: skill.id, revisionId: latest.id }));
};
const runTests = (revisionId: string) => {
void actions.run(() => window.agentCompany.runSkillTests({ orgId, revisionId }));
};
return (
<div className="page">
<div className="row" style={{ marginBottom: 24 }}>
@@ -54,31 +60,36 @@ export function SkillsPanel({ orgId, skills, revisions, bindings, agents, action
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div
className="table-row head"
style={{ gridTemplateColumns: "minmax(0, 1.6fr) minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1.6fr)" }}
style={{ gridTemplateColumns: "minmax(0, 1.4fr) 92px 132px minmax(0, 1.2fr) minmax(220px, 1.4fr)" }}
>
<div>Skill</div><div>Version</div><div>Bindings</div><div>Bind to agent</div>
<div>Skill</div><div>Version</div><div>Tests</div><div>Bindings</div><div>Actions</div>
</div>
{skills.map((skill, index) => {
const last = index === skills.length - 1;
const latest = revisions.find((revision) => revision.skillId === skill.id);
const skillBindings = bindings.filter((binding) => binding.skillId === skill.id && binding.enabled);
const revisionTests = latest ? tests.filter((test) => test.revisionId === latest.id) : [];
return (
<div
key={skill.id}
className="table-row"
style={{ gridTemplateColumns: "minmax(0, 1.6fr) minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1.6fr)", borderBottom: last ? "none" : "1px solid var(--border-soft)" }}
style={{ gridTemplateColumns: "minmax(0, 1.4fr) 92px 132px minmax(0, 1.2fr) minmax(220px, 1.4fr)", borderBottom: last ? "none" : "1px solid var(--border-soft)" }}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{skill.name}</div>
<div style={{ color: "var(--text-3)", fontSize: 11 }}>{skill.slug}</div>
</div>
<Pill>{latest ? latest.versionSemver : "—"}</Pill>
<Pill>{testSummary(revisionTests)}</Pill>
<div className="row" style={{ gap: 6, flexWrap: "wrap" }}>
{skillBindings.map((binding) => (
<Pill key={binding.id}>{agentName(binding.agentId, agents)}</Pill>
))}
</div>
<BindAgent skill={skill} agents={agents} onBind={(agentId) => bind(skill, agentId)} disabled={!latest} />
<div className="row" style={{ gap: 8, minWidth: 0 }}>
<Button size="small" onClick={() => { if (latest && revisionTests.length > 0) runTests(latest.id); }} disabled={!latest || revisionTests.length === 0}>Run tests</Button>
<BindAgent skill={skill} agents={agents} onBind={(agentId) => bind(skill, agentId)} disabled={!latest} />
</div>
</div>
);
})}
@@ -124,3 +135,23 @@ function BindAgent({
function agentName(agentId: string, agents: AgentRecord[]): string {
return agents.find((agent) => agent.id === agentId)?.name ?? agentId;
}
function testSummary(tests: SkillTestRecord[]): string {
if (tests.length === 0) return "No tests";
const completed = tests.filter((test) => test.lastResult);
if (completed.length === 0) return "Never run";
const passed = completed.filter((test) => testStatus(test) === "passed").length;
const failed = completed.length - passed;
if (failed > 0) return `${failed} failed`;
return `${passed}/${tests.length} passed`;
}
function testStatus(test: SkillTestRecord): "passed" | "failed" {
if (!test.lastResult) return "failed";
try {
const result = JSON.parse(test.lastResult) as { status?: unknown };
return result.status === "passed" ? "passed" : "failed";
} catch {
return "failed";
}
}
+5
View File
@@ -223,6 +223,11 @@ export interface ResolveAgentSkillsInput {
runId: string;
}
export interface RunSkillTestsInput {
orgId: string;
revisionId: string;
}
export interface CreateSocialAccountInput {
orgId: string;
platform: SocialPlatform;
@@ -35,6 +35,7 @@ import type {
RunChaosDrillInput,
RunEvalSuiteInput,
RunSdlcReviewInput,
RunSkillTestsInput,
RunTaskInput,
RevokeProviderCredentialInput,
SaveProviderCredentialInput,
@@ -90,6 +91,7 @@ export interface DesktopApi {
importSkillDirectory(input: ImportSkillDirectoryInput): Promise<DesktopResult<AppSnapshot>>;
bindSkill(input: BindSkillInput): Promise<DesktopResult<AppSnapshot>>;
resolveAgentSkills(input: ResolveAgentSkillsInput): Promise<DesktopResult<AppSnapshot>>;
runSkillTests(input: RunSkillTestsInput): Promise<DesktopResult<AppSnapshot>>;
createSocialAccount(input: CreateSocialAccountInput): Promise<DesktopResult<AppSnapshot>>;
launchSocialLogin(input: LaunchSocialLoginInput): Promise<DesktopResult<null>>;
createScheduledPost(input: CreateScheduledPostInput): Promise<DesktopResult<AppSnapshot>>;
+10
View File
@@ -398,6 +398,15 @@ export interface SkillRuntimeSnapshotRecord {
createdAt: string;
}
export interface SkillTestRecord {
id: string;
revisionId: string;
testType: string;
expectedOutputRef: string;
lastResult: string | null;
lastRunAt: string | null;
}
export interface AuditEventRecord {
id: string;
orgId: string;
@@ -464,6 +473,7 @@ export interface AppSnapshot {
skillRevisions: SkillRevisionRecord[];
skillBindings: SkillBindingRecord[];
skillRuntimeSnapshots: SkillRuntimeSnapshotRecord[];
skillTests: SkillTestRecord[];
socialAccounts: SocialAccountRecord[];
socialAccountGrants: SocialAccountGrantRecord[];
browserSessionContainers: BrowserSessionContainerRecord[];
+1
View File
@@ -29,6 +29,7 @@ export const IPC_CHANNELS = {
importSkillDirectory: "agentcompany:import-skill-directory",
bindSkill: "agentcompany:bind-skill",
resolveAgentSkills: "agentcompany:resolve-agent-skills",
runSkillTests: "agentcompany:run-skill-tests",
createSocialAccount: "agentcompany:create-social-account",
launchSocialLogin: "agentcompany:launch-social-login",
createScheduledPost: "agentcompany:create-scheduled-post",
+29 -1
View File
@@ -1,5 +1,5 @@
import { _electron as electron, expect, test, type Page } from "@playwright/test";
import { mkdirSync } from "node:fs";
import { mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { randomUUID } from "node:crypto";
@@ -133,6 +133,14 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
await page.getByLabel("OpenAI monthly request limit").fill("25");
await page.getByRole("button", { name: "Save OpenAI policy" }).click();
await expect(page.getByText("0 / 25").first()).toBeVisible();
await page.getByRole("button", { name: "Skills" }).click();
await page.getByRole("button", { name: "Import" }).click();
await page.getByLabel("Directory path").fill(createSkillDirectory());
await page.locator(".modal").getByRole("button", { name: "Import" }).click();
await expect(page.getByText("Release Gate").first()).toBeVisible();
await expect(page.getByText("Never run").first()).toBeVisible();
await page.getByRole("button", { name: "Run tests" }).click();
await expect(page.getByText("1/1 passed").first()).toBeVisible();
await page.getByRole("button", { name: "Compliance" }).click();
await page.getByRole("button", { name: "Create" }).click();
await expect(page.getByText("gdpr export · employee").first()).toBeVisible();
@@ -167,6 +175,26 @@ function createLegacyDatabase(): string {
return legacyPath;
}
function createSkillDirectory(): string {
const root = join(tmpdir(), `agentcompany-skill-${randomUUID()}`);
mkdirSync(join(root, "checks"), { recursive: true });
writeFileSync(join(root, "SKILL.md"), "Review release evidence and produce a concise gate decision.\n", "utf8");
writeFileSync(join(root, "checks", "acceptance.txt"), "release evidence accepted\n", "utf8");
writeFileSync(join(root, "skill.manifest.json"), JSON.stringify({
name: "Release Gate",
version: "1.0.0",
entrypoint: "SKILL.md",
capabilities: ["release-review"],
required_tools: ["git"],
compatible_models: ["gpt-5.4"],
risk_level: "low",
tests: ["checks/acceptance.txt"],
inputs: {},
outputs: {},
}, null, 2), "utf8");
return root;
}
async function verifyPagesDoNotOverflow(page: Page): Promise<void> {
for (const size of [{ width: 1280, height: 860 }, { width: 980, height: 700 }]) {
await page.setViewportSize(size);
@@ -145,6 +145,7 @@ describe("domain services", () => {
.get(orgId, "review-routes") as { id: string };
skills.bind({ orgId, agentId, skillId: skill.id, revisionId });
rmSync(skillDir, { recursive: true, force: true });
skills.runTests({ orgId, revisionId });
skills.resolveRuntimeSnapshot({ orgId, agentId, runId: "run-review-1" });
});
@@ -153,8 +154,11 @@ describe("domain services", () => {
expect(state.skillRevisions[0]?.versionSemver).toBe("1.0.0");
expect(state.skillRevisions[0]?.immutableChecksum).toHaveLength(64);
expect(state.skillRevisions[0]?.manifestJson).toContain("architecture-review");
expect(state.skillTests[0]?.lastResult).toContain("\"status\":\"passed\"");
expect(state.skillTests[0]?.lastRunAt).toBeTruthy();
expect(state.skillRuntimeSnapshots[0]?.sourceRevisionIds).toContain(revisionId);
expect(state.skillRuntimeSnapshots[0]?.compiledPrompt).toContain("# Review Routes");
expect(state.auditEvents.some((event) => event.entityType === "skill_revision" && event.action === "tests_run")).toBe(true);
connection.close();
});