diff --git a/src/app/.well-known/agent.json/route.ts b/src/app/.well-known/agent.json/route.ts index d0a9ba034c..e36ab63607 100644 --- a/src/app/.well-known/agent.json/route.ts +++ b/src/app/.well-known/agent.json/route.ts @@ -102,6 +102,15 @@ export async function GET() { "Summarize active rate limits and lockouts", ], }, + { + id: "list-capabilities", + name: "List Capabilities", + description: + "Returns the full catalog of 42 OmniRoute agent skills (22 API + 20 CLI) " + + "with raw URLs for the SKILL.md docs.", + tags: ["discovery", "capabilities"], + examples: ["What can you do?", "List your skills", "Show capabilities"], + }, ], authentication: { schemes: ["api-key"], diff --git a/src/lib/a2a/skills/listCapabilities.ts b/src/lib/a2a/skills/listCapabilities.ts new file mode 100644 index 0000000000..7520a6d3b5 --- /dev/null +++ b/src/lib/a2a/skills/listCapabilities.ts @@ -0,0 +1,72 @@ +/** + * A2A Skill: List Capabilities + * + * Returns the full catalog of 42 OmniRoute agent skills (22 API + 20 CLI) + * as a markdown table with raw SKILL.md URLs for orchestrating agents. + */ + +import type { A2ATask, TaskArtifact } from "../taskManager"; +import { getCatalog, computeCoverage } from "@/lib/agentSkills/catalog"; +import type { AgentSkill } from "@/lib/agentSkills/types"; + +export interface ListCapabilitiesResult { + artifacts: TaskArtifact[]; + metadata: { + coverage: { + api: { have: number; total: 22 }; + cli: { have: number; total: 20 }; + }; + totalSkills: 42; + generatedAt: string; + source: "agent-skills-catalog"; + }; +} + +function buildMarkdownTable(skills: AgentSkill[]): string { + const header = "| ID | Name | Category | Area | Endpoints/Commands | Raw URL |"; + const separator = "| --- | --- | --- | --- | --- | --- |"; + + const rows = skills.map((skill) => { + const endpointsOrCommands = + skill.category === "api" + ? (skill.endpoints ?? []).join(", ") || "—" + : (skill.cliCommands ?? []).join(", ") || "—"; + + return `| ${skill.id} | ${skill.name} | ${skill.category} | ${skill.area} | ${endpointsOrCommands} | ${skill.rawUrl} |`; + }); + + return [header, separator, ...rows].join("\n"); +} + +export async function executeListCapabilities(_task: A2ATask): Promise { + const catalog = getCatalog(); + const coverage = computeCoverage(); + + const table = buildMarkdownTable(catalog); + + const content = [ + `# OmniRoute Agent Skills Catalog`, + ``, + `Total: ${catalog.length} skills (${coverage.api.total} API + ${coverage.cli.total} CLI)`, + ``, + table, + ].join("\n"); + + return { + artifacts: [ + { + type: "text", + content, + }, + ], + metadata: { + coverage: { + api: { have: coverage.api.have, total: 22 }, + cli: { have: coverage.cli.have, total: 20 }, + }, + totalSkills: 42, + generatedAt: coverage.generatedAt, + source: "agent-skills-catalog", + }, + }; +} diff --git a/src/lib/a2a/taskExecution.ts b/src/lib/a2a/taskExecution.ts index 39851955ac..421a36bb95 100644 --- a/src/lib/a2a/taskExecution.ts +++ b/src/lib/a2a/taskExecution.ts @@ -37,6 +37,10 @@ export const A2A_SKILL_HANDLERS: Record = { const skillModule = await import("./skills/healthReport"); return skillModule.executeHealthReport(task); }, + "list-capabilities": async (task) => { + const skillModule = await import("./skills/listCapabilities"); + return skillModule.executeListCapabilities(task); + }, }; export async function executeA2ATaskWithState( diff --git a/tests/unit/agent-card-route.test.ts b/tests/unit/agent-card-route.test.ts new file mode 100644 index 0000000000..2dc714eeac --- /dev/null +++ b/tests/unit/agent-card-route.test.ts @@ -0,0 +1,84 @@ +/** + * Unit tests for GET /.well-known/agent.json (Agent Card endpoint). + * + * Verifies: + * - Response includes 6 skills after the list-capabilities addition + * - list-capabilities entry has the required id, tags, and examples + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +const { GET } = await import("../../src/app/.well-known/agent.json/route.js"); + +interface AgentSkillEntry { + id: string; + name: string; + description: string; + tags: string[]; + examples: string[]; +} + +interface AgentCard { + name: string; + version: string; + skills: AgentSkillEntry[]; +} + +test("GET /.well-known/agent.json returns 6 skills", async () => { + const response = await GET(); + assert.equal(response.status, 200, "Expected HTTP 200"); + + const body = (await response.json()) as AgentCard; + assert.ok(Array.isArray(body.skills), "skills is an array"); + assert.equal(body.skills.length, 6, "Expected exactly 6 skills"); +}); + +test("Agent Card includes list-capabilities skill entry", async () => { + const response = await GET(); + const body = (await response.json()) as AgentCard; + + const skill = body.skills.find((s) => s.id === "list-capabilities"); + assert.ok(skill, "list-capabilities skill must be present in Agent Card"); +}); + +test("list-capabilities entry has required tags [discovery, capabilities]", async () => { + const response = await GET(); + const body = (await response.json()) as AgentCard; + + const skill = body.skills.find((s) => s.id === "list-capabilities"); + assert.ok(skill, "list-capabilities skill must be present"); + assert.ok(Array.isArray(skill.tags), "tags is an array"); + assert.ok(skill.tags.includes("discovery"), "tags includes 'discovery'"); + assert.ok(skill.tags.includes("capabilities"), "tags includes 'capabilities'"); +}); + +test("list-capabilities entry has at least one example question", async () => { + const response = await GET(); + const body = (await response.json()) as AgentCard; + + const skill = body.skills.find((s) => s.id === "list-capabilities"); + assert.ok(skill, "list-capabilities skill must be present"); + assert.ok(Array.isArray(skill.examples), "examples is an array"); + assert.ok(skill.examples.length > 0, "examples has at least one entry"); +}); + +test("Agent Card includes all 5 original skills", async () => { + const response = await GET(); + const body = (await response.json()) as AgentCard; + + const originalIds = [ + "smart-routing", + "quota-management", + "provider-discovery", + "cost-analysis", + "health-report", + ]; + + for (const id of originalIds) { + assert.ok( + body.skills.some((s) => s.id === id), + `Original skill '${id}' must be present in Agent Card`, + ); + } +}); diff --git a/tests/unit/listCapabilities-a2a.test.ts b/tests/unit/listCapabilities-a2a.test.ts new file mode 100644 index 0000000000..16892070f6 --- /dev/null +++ b/tests/unit/listCapabilities-a2a.test.ts @@ -0,0 +1,83 @@ +/** + * Unit tests for the A2A list-capabilities skill. + * + * Verifies: + * - Return shape matches §3.7 contract + * - Markdown table contains all 42 skill IDs + * - Coverage bounds are within declared totals + * - metadata.source === "agent-skills-catalog" + * - metadata.generatedAt is an ISO datetime string + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { A2ATask } from "../../src/lib/a2a/taskManager.js"; +import { executeListCapabilities } from "../../src/lib/a2a/skills/listCapabilities.js"; +import { API_SKILL_IDS, CLI_SKILL_IDS } from "../../src/lib/agentSkills/catalog.js"; + +// Minimal stub — executeListCapabilities only receives the task arg but does not use it +const stubTask = {} as A2ATask; + +test("executeListCapabilities returns shape matching §3.7 contract", async () => { + const result = await executeListCapabilities(stubTask); + + // artifacts array with exactly 1 text artifact + assert.ok(Array.isArray(result.artifacts), "artifacts is an array"); + assert.equal(result.artifacts.length, 1, "exactly 1 artifact"); + assert.equal(result.artifacts[0].type, "text", "artifact type is 'text'"); + assert.ok(typeof result.artifacts[0].content === "string", "artifact content is a string"); + + // metadata shape + const { metadata } = result; + assert.ok(metadata, "metadata exists"); + assert.equal(metadata.source, "agent-skills-catalog", "metadata.source matches"); + assert.equal(metadata.totalSkills, 42, "metadata.totalSkills === 42"); + assert.ok(metadata.coverage, "metadata.coverage exists"); + assert.ok(metadata.coverage.api, "metadata.coverage.api exists"); + assert.ok(metadata.coverage.cli, "metadata.coverage.cli exists"); + assert.equal(metadata.coverage.api.total, 22, "api.total === 22"); + assert.equal(metadata.coverage.cli.total, 20, "cli.total === 20"); +}); + +test("executeListCapabilities markdown table contains all 42 skill IDs", async () => { + const result = await executeListCapabilities(stubTask); + const content = result.artifacts[0].content; + + const allIds = [...API_SKILL_IDS, ...CLI_SKILL_IDS] as string[]; + assert.equal(allIds.length, 42, "catalog declares 42 skill IDs"); + + for (const id of allIds) { + assert.ok(content.includes(id), `Markdown table missing skill ID: ${id}`); + } +}); + +test("metadata.coverage.api.have is within [0, 22]", async () => { + const result = await executeListCapabilities(stubTask); + const { api } = result.metadata.coverage; + assert.ok(api.have >= 0, "api.have >= 0"); + assert.ok(api.have <= 22, "api.have <= 22"); +}); + +test("metadata.coverage.cli.have is within [0, 20]", async () => { + const result = await executeListCapabilities(stubTask); + const { cli } = result.metadata.coverage; + assert.ok(cli.have >= 0, "cli.have >= 0"); + assert.ok(cli.have <= 20, "cli.have <= 20"); +}); + +test("metadata.generatedAt is a valid ISO datetime", async () => { + const result = await executeListCapabilities(stubTask); + const { generatedAt } = result.metadata; + assert.ok(typeof generatedAt === "string", "generatedAt is a string"); + const parsed = new Date(generatedAt); + assert.ok(!isNaN(parsed.getTime()), `generatedAt is not a valid date: ${generatedAt}`); + assert.ok(generatedAt.includes("T"), "generatedAt contains 'T' (ISO format)"); +}); + +test("list-capabilities is registered in A2A_SKILL_HANDLERS", async () => { + const { A2A_SKILL_HANDLERS } = await import("../../src/lib/a2a/taskExecution.js"); + assert.ok( + "list-capabilities" in A2A_SKILL_HANDLERS, + "list-capabilities must be in A2A_SKILL_HANDLERS", + ); +});