feat(cli): fase 5.3+5.4 — a2a invoke JSON-RPC e tasks list/get/cancel/watch/stream/logs

This commit is contained in:
diegosouzapw
2026-05-15 02:08:12 -03:00
parent 3cfba85461
commit d1880f1d4a
2 changed files with 407 additions and 0 deletions

View File

@@ -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 <skill>")
.description(t("a2a.invoke.description"))
.option("--input <json>", t("a2a.invoke.input"))
.option("--input-file <path>", t("a2a.invoke.input_file"))
.option("--wait", t("a2a.invoke.wait"))
.option("--timeout <ms>", 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 <s>", t("a2a.tasks.list.status"))
.option("--skill <s>", t("a2a.tasks.list.skill"))
.option("--limit <n>", parseInt, 50)
.option("--since <ts>")
.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 <id>").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 <id>")
.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 <id>")
.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 <id>")
.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 <id>")
.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 = {}) {

View File

@@ -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<void>): Promise<string> {
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");
});