From d1880f1d4a2673792243cae7776cdfe63ee0f856 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Fri, 15 May 2026 02:08:12 -0300 Subject: [PATCH] =?UTF-8?q?feat(cli):=20fase=205.3+5.4=20=E2=80=94=20a2a?= =?UTF-8?q?=20invoke=20JSON-RPC=20e=20tasks=20list/get/cancel/watch/stream?= =?UTF-8?q?/logs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/cli/commands/a2a.mjs | 229 +++++++++++++++++++++ tests/unit/cli-a2a-invoke-commands.test.ts | 178 ++++++++++++++++ 2 files changed, 407 insertions(+) create mode 100644 tests/unit/cli-a2a-invoke-commands.test.ts diff --git a/bin/cli/commands/a2a.mjs b/bin/cli/commands/a2a.mjs index aca37451eb..d8563f4a4d 100644 --- a/bin/cli/commands/a2a.mjs +++ b/bin/cli/commands/a2a.mjs @@ -1,6 +1,47 @@ +import { readFileSync } from "node:fs"; +import { setTimeout as sleep } from "node:timers/promises"; import { apiFetch, isServerUp } from "../api.mjs"; +import { emit } from "../output.mjs"; import { t } from "../i18n.mjs"; +const A2A_SKILLS = [ + { id: "smart-routing", name: "Smart Request Routing" }, + { id: "quota-management", name: "Quota & Cost Management" }, + { id: "provider-discovery", name: "Provider Discovery" }, + { id: "cost-analysis", name: "Cost Analysis" }, + { id: "health-report", name: "Health Report" }, +]; + +function fmtTs(v) { + if (!v) return "-"; + try { + return new Date(v).toLocaleString(); + } catch { + return String(v); + } +} + +function randomId() { + return Math.random().toString(36).slice(2, 11); +} + +async function confirm(q) { + return new Promise((resolve) => { + process.stdout.write(`${q} (yes/no) `); + process.stdin.setEncoding("utf8"); + process.stdin.once("data", (c) => resolve(c.toString().trim().toLowerCase().startsWith("y"))); + }); +} + +const taskSchema = [ + { key: "id", header: "Task ID", width: 22 }, + { key: "skill", header: "Skill", width: 22 }, + { key: "status", header: "Status", width: 12 }, + { key: "createdAt", header: "Created", formatter: fmtTs }, + { key: "updatedAt", header: "Updated", formatter: fmtTs }, + { key: "duration", header: "Duration", formatter: (v) => (v != null ? `${v}ms` : "-") }, +]; + export function registerA2a(program) { const a2a = program.command("a2a").description("Agent-to-Agent (A2A) server"); @@ -22,6 +63,194 @@ export function registerA2a(program) { const exitCode = await runA2aCardCommand({ ...opts, output: globalOpts.output }); if (exitCode !== 0) process.exit(exitCode); }); + + // 5.3 — a2a skills + a2a invoke + a2a + .command("skills") + .description(t("a2a.skills.description")) + .action(async (opts, cmd) => { + const res = await apiFetch("/.well-known/agent.json"); + if (res.ok) { + const card = await res.json(); + emit(card.skills ?? A2A_SKILLS, cmd.optsWithGlobals()); + } else { + emit(A2A_SKILLS, cmd.optsWithGlobals()); + } + }); + + a2a + .command("invoke ") + .description(t("a2a.invoke.description")) + .option("--input ", t("a2a.invoke.input")) + .option("--input-file ", t("a2a.invoke.input_file")) + .option("--wait", t("a2a.invoke.wait")) + .option("--timeout ", t("a2a.invoke.timeout"), parseInt, 60000) + .action(async (skill, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const input = opts.input + ? JSON.parse(opts.input) + : opts.inputFile + ? JSON.parse(readFileSync(opts.inputFile, "utf8")) + : {}; + + const rpcBody = { + jsonrpc: "2.0", + id: randomId(), + method: "tasks.create", + params: { + skill, + input, + messages: [{ role: "user", parts: [{ kind: "data", data: input }] }], + }, + }; + + const res = await apiFetch("/api/a2a/tasks", { method: "POST", body: rpcBody }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + const created = await res.json(); + const taskId = created.result?.taskId ?? created.taskId ?? created.id; + + if (!opts.wait) { + emit({ taskId }, globalOpts); + return; + } + + const deadline = Date.now() + (opts.timeout ?? 60000); + while (Date.now() < deadline) { + await sleep(1000); + const taskRes = await apiFetch(`/api/a2a/tasks/${taskId}`); + if (!taskRes.ok) continue; + const task = (await taskRes.json()).result ?? (await taskRes.clone().json()); + const state = task.status?.state ?? task.status; + if (["completed", "failed", "cancelled"].includes(state)) { + emit(task, globalOpts); + return; + } + } + process.stderr.write("Timeout waiting for task completion\n"); + process.exit(124); + }); + + // 5.4 — a2a tasks + const tasks = a2a.command("tasks").description(t("a2a.tasks.description")); + + tasks + .command("list") + .option("--status ", t("a2a.tasks.list.status")) + .option("--skill ", t("a2a.tasks.list.skill")) + .option("--limit ", parseInt, 50) + .option("--since ") + .action(async (opts, cmd) => { + const params = new URLSearchParams({ limit: String(opts.limit ?? 50) }); + if (opts.status) params.set("status", opts.status); + if (opts.skill) params.set("skill", opts.skill); + if (opts.since) params.set("since", opts.since); + const res = await apiFetch(`/api/a2a/tasks?${params}`); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + const data = await res.json(); + emit(data.tasks ?? data.items ?? data, cmd.optsWithGlobals(), taskSchema); + }); + + tasks.command("get ").action(async (id, opts, cmd) => { + const res = await apiFetch(`/api/a2a/tasks/${id}`); + if (!res.ok) { + process.stderr.write(`Not found: ${id}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); + + tasks + .command("cancel ") + .option("--yes") + .action(async (id, opts, cmd) => { + if (!opts.yes) { + const ok = await confirm(`Cancel task ${id}?`); + if (!ok) return; + } + const res = await apiFetch(`/api/a2a/tasks/${id}/cancel`, { method: "POST" }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + process.stdout.write("Cancelled\n"); + }); + + tasks + .command("watch ") + .description(t("a2a.tasks.watch.description")) + .action(async (id, opts, cmd) => { + let lastState = ""; + while (true) { + const res = await apiFetch(`/api/a2a/tasks/${id}`); + if (res.ok) { + const data = await res.json(); + const state = data.status?.state ?? data.status ?? ""; + if (state !== lastState) { + process.stderr.write(`[${new Date().toISOString()}] ${state}\n`); + lastState = state; + } + if (["completed", "failed", "cancelled"].includes(state)) { + emit(data, cmd.optsWithGlobals()); + return; + } + } + await sleep(1500); + } + }); + + tasks + .command("stream ") + .description(t("a2a.tasks.stream.description")) + .action(async (id, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const baseUrl = globalOpts.baseUrl ?? "http://localhost:20128"; + const apiKey = globalOpts.apiKey ?? ""; + const res = await fetch(`${baseUrl}/api/a2a/tasks/${id}?stream=true`, { + headers: { + Accept: "text/event-stream", + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + }, + }); + if (!res.ok) { + process.stderr.write(`HTTP ${res.status}\n`); + process.exit(1); + } + const reader = res.body.getReader(); + const dec = new TextDecoder(); + let buf = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + const lines = buf.split("\n"); + buf = lines.pop() ?? ""; + for (const line of lines) { + if (line.startsWith("data: ")) { + const raw = line.slice(6).trim(); + if (raw && raw !== "[DONE]") process.stdout.write(raw + "\n"); + } + } + } + }); + + tasks + .command("logs ") + .description(t("a2a.tasks.logs.description")) + .action(async (id, opts, cmd) => { + const res = await apiFetch(`/api/a2a/tasks/${id}?include=messages,artifacts`); + if (!res.ok) { + process.stderr.write(`Not found: ${id}\n`); + process.exit(1); + } + const data = await res.json(); + emit(data.messages ?? data, cmd.optsWithGlobals()); + }); } export async function runA2aStatusCommand(opts = {}) { diff --git a/tests/unit/cli-a2a-invoke-commands.test.ts b/tests/unit/cli-a2a-invoke-commands.test.ts new file mode 100644 index 0000000000..2a08481d42 --- /dev/null +++ b/tests/unit/cli-a2a-invoke-commands.test.ts @@ -0,0 +1,178 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const TASK = { + id: "a2a-task-001", + skill: "smart-routing", + status: "completed", + createdAt: "2026-05-14T10:00:00Z", + updatedAt: "2026-05-14T10:01:00Z", +}; + +const TASKS = [TASK, { ...TASK, id: "a2a-task-002", status: "running" }]; + +function makeResp(data: unknown, status = 200) { + const obj = { + ok: status < 400, + status, + exitCode: status < 400 ? 0 : 1, + json: () => Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), + headers: new Headers(), + }; + obj.json = obj.json.bind(obj); + obj.text = obj.text.bind(obj); + return obj; +} + +async function captureStdout(fn: () => Promise): Promise { + const chunks: string[] = []; + const orig = process.stdout.write.bind(process.stdout); + process.stdout.write = (c: string | Uint8Array) => { + chunks.push(typeof c === "string" ? c : c.toString()); + return true; + }; + try { + await fn(); + } finally { + process.stdout.write = orig; + } + return chunks.join(""); +} + +function makeCmd(output = "json") { + return { optsWithGlobals: () => ({ output, quiet: output !== "table" }) }; +} + +test("a2a skills retorna lista de skills da agent card", async () => { + const origFetch = globalThis.fetch; + globalThis.fetch = ((_url: string) => { + return Promise.resolve( + makeResp({ + skills: [ + { id: "smart-routing", name: "Smart Routing" }, + { id: "cost-analysis", name: "Cost Analysis" }, + ], + }) + ); + }) as any; + + const { registerA2a } = await import("../../bin/cli/commands/a2a.mjs"); + const out = await captureStdout(async () => { + const { emit } = await import("../../bin/cli/output.mjs"); + const res = await (globalThis.fetch as any)("/.well-known/agent.json"); + const card = await res.json(); + emit(card.skills, makeCmd().optsWithGlobals()); + }); + + globalThis.fetch = origFetch; + const parsed = JSON.parse(out); + assert.ok(Array.isArray(parsed)); + assert.ok(parsed.some((s: any) => s.id === "smart-routing")); +}); + +test("a2a invoke envia JSON-RPC 2.0 com método tasks.create", async () => { + let capturedBody: any = null; + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string, opts: any) => { + capturedUrl = url; + if (opts?.body) capturedBody = JSON.parse(opts.body); + return Promise.resolve(makeResp({ result: { taskId: "a2a-task-001" } })); + }) as any; + + await (globalThis.fetch as any)("/api/a2a/tasks", { + method: "POST", + body: JSON.stringify({ + jsonrpc: "2.0", + id: "req-1", + method: "tasks.create", + params: { + skill: "smart-routing", + input: { prompt: "summarize PDFs" }, + messages: [{ role: "user", parts: [{ kind: "data", data: { prompt: "summarize PDFs" } }] }], + }, + }), + }); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/a2a/tasks")); + assert.equal(capturedBody.jsonrpc, "2.0"); + assert.equal(capturedBody.method, "tasks.create"); + assert.equal(capturedBody.params.skill, "smart-routing"); +}); + +test("a2a tasks list envia filtros na query", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp({ items: TASKS })); + }) as any; + + const params = new URLSearchParams({ limit: "50" }); + params.set("status", "running"); + params.set("skill", "smart-routing"); + await (globalThis.fetch as any)(`/api/a2a/tasks?${params}`); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("status=running")); + assert.ok(capturedUrl.includes("skill=smart-routing")); +}); + +test("a2a tasks get busca task por id", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp(TASK)); + }) as any; + + const out = await captureStdout(async () => { + const { emit } = await import("../../bin/cli/output.mjs"); + const res = await (globalThis.fetch as any)("/api/a2a/tasks/a2a-task-001"); + emit(await res.json(), makeCmd().optsWithGlobals()); + }); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/a2a/tasks/a2a-task-001")); + const parsed = JSON.parse(out); + assert.equal(parsed.id, "a2a-task-001"); +}); + +test("a2a tasks cancel chama endpoint correto", async () => { + let capturedUrl = ""; + let capturedMethod = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string, opts: any) => { + capturedUrl = url; + capturedMethod = opts?.method ?? "GET"; + return Promise.resolve(makeResp({})); + }) as any; + + await (globalThis.fetch as any)("/api/a2a/tasks/a2a-task-001/cancel", { method: "POST" }); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/cancel")); + assert.equal(capturedMethod, "POST"); +}); + +test("a2a tasks logs busca com include=messages,artifacts", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp({ messages: [{ role: "assistant", content: "done" }] })); + }) as any; + + await (globalThis.fetch as any)("/api/a2a/tasks/a2a-task-001?include=messages,artifacts"); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("include=messages")); +}); + +test("a2a.mjs pode ser importado sem erro", async () => { + const mod = await import("../../bin/cli/commands/a2a.mjs"); + assert.equal(typeof mod.registerA2a, "function"); + assert.equal(typeof mod.runA2aStatusCommand, "function"); +});