mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
feat(cli): fase 5.3+5.4 — a2a invoke JSON-RPC e tasks list/get/cancel/watch/stream/logs
This commit is contained in:
@@ -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 = {}) {
|
||||
|
||||
178
tests/unit/cli-a2a-invoke-commands.test.ts
Normal file
178
tests/unit/cli-a2a-invoke-commands.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user