mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-20 05:42:19 +03:00
feat(dashboard): orchestration canvas fase 2 — repeat action + A2A memory hits (2.6/2.7) (#12508)
* feat(api): conductor task creation route (repeat support)
* feat(a2a): record memoryHits consulted per task (observability, 2.7)
* feat(dashboard): repeat action in orchestration drawer (2.6 — repeat only)
* fix(dashboard): require every field each repeat contract needs before enabling the action
* feat(dashboard): memory-used drawer section + fase2 i18n/changelog (2.7)
* fix(dashboard): validate memoryHits shape before rendering + locale wording fixes
* fix(dashboard,a2a): stop memoryHits leaking into repeats, harden drawer guard + status clamp
Final whole-branch review fix wave for the Orchestration Canvas Fase 2 PR-C.
- a2a: `createTask` stores a COPY of `input.metadata` instead of aliasing it, so the
observability `memoryHits` written by `executeA2ATaskWithState` no longer leak into
`task.input.metadata`, into the persisted `a2a_tasks.input_json`, or into the drawer's
"Repeat" body (a repeated task was born carrying the previous run's memory snippets,
even with the `OMNIROUTE_A2A_MEMORY_HITS=0` kill-switch on).
- dashboard: `repeatReqFor` strips `memoryHits` from the a2a repeat metadata, so tasks
persisted before the copy-fix do not propagate them either.
- dashboard: the "Memory used" section now requires all four rendered fields (id, key,
type, snippet) to be strings — `{ id: "x", key: { a: 1 } }` used to throw "Objects are
not valid as a React child" and take the whole drawer down.
- dashboard: an `/a2a` action answered with a JSON-RPC error under HTTP 200 is reported as
a failure (`RPC <code>`, code only — never the upstream message) instead of a success
toast; the secured-deployment rejection keeps surfacing the sanitized `HTTP 400`.
- api: the conductor task-creation route clamps a hub status outside 400-599 to 502, so an
out-of-range status can no longer turn a hub refusal into a `RangeError`.
- dashboard: the History tab's `onActionDone` keeps the drawer mounted (and refreshes the
range) instead of closing it, so the repeat/cancel confirmation is actually visible.
- a2a: documented the recall owner-id limitation — `task.owner` is a SHA-256 key prefix
while memory rows are keyed by the DB api-key id, and no hash-to-id lookup exists today,
so recall only resolves under the keyless posture.
* refactor(dashboard): split drawer repeat helpers and test file under the size/complexity gates
---------
Co-authored-by: Markus Hartung <diegosouzapw@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
86e83d4138
commit
a628d28898
331
tests/unit/a2a-memory-hits.test.ts
Normal file
331
tests/unit/a2a-memory-hits.test.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Task D2 (Orchestration Canvas Fase 2, PR-C): `collectMemoryHits` records WHICH memories were
|
||||
* consulted for an A2A task, as pure observability — the hits are never injected into the
|
||||
* skill's prompt or behavior, only mirrored into `task.metadata.memoryHits` and a
|
||||
* `memory_hits` history event.
|
||||
*
|
||||
* Uses FAKE `MemoryHitsDeps` throughout (no real memory backend, no SQLite) — the DI seam
|
||||
* exists precisely so this suite needs neither.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
collectMemoryHits,
|
||||
executeA2ATaskWithState,
|
||||
type MemoryHit,
|
||||
type MemoryHitsDeps,
|
||||
} from "../../src/lib/a2a/taskExecution.ts";
|
||||
import {
|
||||
A2ATaskManager,
|
||||
type A2APersistence,
|
||||
type A2ATask,
|
||||
} from "../../src/lib/a2a/taskManager.ts";
|
||||
|
||||
function makeTask(overrides: Partial<A2ATask> = {}): A2ATask {
|
||||
return {
|
||||
id: "task-1",
|
||||
skill: "smart-routing",
|
||||
state: "working",
|
||||
input: {
|
||||
skill: "smart-routing",
|
||||
messages: [{ role: "user", content: "what is the cheapest gpt-4 provider?" }],
|
||||
},
|
||||
artifacts: [],
|
||||
events: [],
|
||||
metadata: {},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const ENV_KEY = "OMNIROUTE_A2A_MEMORY_HITS";
|
||||
|
||||
function withEnv(value: string | undefined, fn: () => Promise<void>) {
|
||||
const original = process.env[ENV_KEY];
|
||||
if (value === undefined) delete process.env[ENV_KEY];
|
||||
else process.env[ENV_KEY] = value;
|
||||
return fn().finally(() => {
|
||||
if (original === undefined) delete process.env[ENV_KEY];
|
||||
else process.env[ENV_KEY] = original;
|
||||
});
|
||||
}
|
||||
|
||||
test("collectMemoryHits maps search results and truncates snippet to 200 chars", async () => {
|
||||
const longContent = "x".repeat(250);
|
||||
const searchCalls: Array<{ query: string; apiKeyId: string; limit?: number }> = [];
|
||||
const deps: MemoryHitsDeps = {
|
||||
search: async (cfg) => {
|
||||
searchCalls.push(cfg);
|
||||
return [
|
||||
{ id: "m1", key: "k1", type: "factual", content: longContent },
|
||||
{ id: "m2", key: "k2", type: "episodic", content: "short" },
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
const task = makeTask();
|
||||
const hits = await collectMemoryHits(task, deps);
|
||||
|
||||
assert.equal(searchCalls.length, 1);
|
||||
assert.equal(searchCalls[0].query, "what is the cheapest gpt-4 provider?");
|
||||
assert.equal(searchCalls[0].apiKeyId, "mcp");
|
||||
|
||||
assert.deepEqual(hits, [
|
||||
{ id: "m1", key: "k1", type: "factual", snippet: longContent.slice(0, 200) },
|
||||
{ id: "m2", key: "k2", type: "episodic", snippet: "short" },
|
||||
] satisfies MemoryHit[]);
|
||||
assert.equal(hits[0].snippet.length, 200);
|
||||
});
|
||||
|
||||
test("collectMemoryHits uses task.owner as apiKeyId when present", async () => {
|
||||
let seenApiKeyId: string | undefined;
|
||||
const deps: MemoryHitsDeps = {
|
||||
search: async (cfg) => {
|
||||
seenApiKeyId = cfg.apiKeyId;
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
const task = makeTask({ owner: "owner-123" });
|
||||
await collectMemoryHits(task, deps);
|
||||
|
||||
assert.equal(seenApiKeyId, "owner-123");
|
||||
});
|
||||
|
||||
test("collectMemoryHits uses the LAST user message as the query", async () => {
|
||||
let seenQuery: string | undefined;
|
||||
const deps: MemoryHitsDeps = {
|
||||
search: async (cfg) => {
|
||||
seenQuery = cfg.query;
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
const task = makeTask({
|
||||
input: {
|
||||
skill: "smart-routing",
|
||||
messages: [
|
||||
{ role: "user", content: "first question" },
|
||||
{ role: "assistant", content: "an answer" },
|
||||
{ role: "user", content: "second question" },
|
||||
],
|
||||
},
|
||||
});
|
||||
await collectMemoryHits(task, deps);
|
||||
|
||||
assert.equal(seenQuery, "second question");
|
||||
});
|
||||
|
||||
test("collectMemoryHits returns [] and never calls search when there is no user message", async () => {
|
||||
let called = false;
|
||||
const deps: MemoryHitsDeps = {
|
||||
search: async () => {
|
||||
called = true;
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
const task = makeTask({
|
||||
input: { skill: "smart-routing", messages: [{ role: "assistant", content: "hi" }] },
|
||||
});
|
||||
const hits = await collectMemoryHits(task, deps);
|
||||
|
||||
assert.deepEqual(hits, []);
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test("collectMemoryHits returns [] when search throws — never fails the caller", async () => {
|
||||
const deps: MemoryHitsDeps = {
|
||||
search: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
};
|
||||
|
||||
const task = makeTask();
|
||||
const hits = await collectMemoryHits(task, deps);
|
||||
|
||||
assert.deepEqual(hits, []);
|
||||
});
|
||||
|
||||
test("collectMemoryHits kill-switch (OMNIROUTE_A2A_MEMORY_HITS=0) returns [] without calling search", async () => {
|
||||
await withEnv("0", async () => {
|
||||
let called = false;
|
||||
const deps: MemoryHitsDeps = {
|
||||
search: async () => {
|
||||
called = true;
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
const task = makeTask();
|
||||
const hits = await collectMemoryHits(task, deps);
|
||||
|
||||
assert.deepEqual(hits, []);
|
||||
assert.equal(called, false);
|
||||
});
|
||||
});
|
||||
|
||||
test("executeA2ATaskWithState sets task.metadata.memoryHits and appends a memory_hits event when there are hits", async () => {
|
||||
const appendEventCalls: Array<{ taskId: string; eventType: string; dataJson?: string }> = [];
|
||||
const deps: MemoryHitsDeps = {
|
||||
search: async () => [{ id: "m1", key: "k1", type: "factual", content: "hello" }],
|
||||
appendEvent: (taskId, eventType, dataJson) => {
|
||||
appendEventCalls.push({ taskId, eventType, dataJson });
|
||||
},
|
||||
};
|
||||
|
||||
const updateTaskCalls: unknown[] = [];
|
||||
const tm = {
|
||||
updateTask: (...args: unknown[]) => {
|
||||
updateTaskCalls.push(args);
|
||||
},
|
||||
};
|
||||
|
||||
const task = makeTask();
|
||||
const result = await executeA2ATaskWithState(
|
||||
tm,
|
||||
task,
|
||||
async () => ({ artifacts: [], metadata: {} }),
|
||||
deps
|
||||
);
|
||||
|
||||
assert.deepEqual(result.artifacts, []);
|
||||
assert.deepEqual(task.metadata.memoryHits, [
|
||||
{ id: "m1", key: "k1", type: "factual", snippet: "hello" },
|
||||
]);
|
||||
assert.equal(appendEventCalls.length, 1);
|
||||
assert.equal(appendEventCalls[0].taskId, "task-1");
|
||||
assert.equal(appendEventCalls[0].eventType, "memory_hits");
|
||||
assert.deepEqual(JSON.parse(appendEventCalls[0].dataJson ?? "[]"), [
|
||||
{ id: "m1", key: "k1", type: "factual", snippet: "hello" },
|
||||
]);
|
||||
assert.equal(updateTaskCalls.length, 1);
|
||||
});
|
||||
|
||||
test("executeA2ATaskWithState does not set metadata.memoryHits or append an event when there are no hits", async () => {
|
||||
const appendEventCalls: unknown[] = [];
|
||||
const deps: MemoryHitsDeps = {
|
||||
search: async () => [],
|
||||
appendEvent: (...args: unknown[]) => {
|
||||
appendEventCalls.push(args);
|
||||
},
|
||||
};
|
||||
|
||||
const tm = { updateTask: () => {} };
|
||||
const task = makeTask();
|
||||
await executeA2ATaskWithState(tm, task, async () => ({ artifacts: [], metadata: {} }), deps);
|
||||
|
||||
assert.equal("memoryHits" in task.metadata, false);
|
||||
assert.equal(appendEventCalls.length, 0);
|
||||
});
|
||||
|
||||
test("executeA2ATaskWithState completes the task normally even when memory recall throws", async () => {
|
||||
const deps: MemoryHitsDeps = {
|
||||
search: async () => {
|
||||
throw new Error("recall backend down");
|
||||
},
|
||||
};
|
||||
|
||||
let completedState: string | undefined;
|
||||
const tm = {
|
||||
updateTask: (_taskId: string, state: string) => {
|
||||
completedState = state;
|
||||
},
|
||||
};
|
||||
|
||||
const task = makeTask();
|
||||
const result = await executeA2ATaskWithState(
|
||||
tm,
|
||||
task,
|
||||
async () => ({ artifacts: [{ type: "text", content: "ok" }], metadata: {} }),
|
||||
deps
|
||||
);
|
||||
|
||||
assert.equal(completedState, "completed");
|
||||
assert.deepEqual(result.artifacts, [{ type: "text", content: "ok" }]);
|
||||
assert.equal("memoryHits" in task.metadata, false);
|
||||
});
|
||||
|
||||
test("executeA2ATaskWithState swallows a throwing appendEvent (best-effort) and still completes", async () => {
|
||||
const deps: MemoryHitsDeps = {
|
||||
search: async () => [{ id: "m1", key: "k1", type: "factual", content: "hello" }],
|
||||
appendEvent: () => {
|
||||
throw new Error("db unavailable");
|
||||
},
|
||||
};
|
||||
|
||||
let completedState: string | undefined;
|
||||
const tm = {
|
||||
updateTask: (_taskId: string, state: string) => {
|
||||
completedState = state;
|
||||
},
|
||||
};
|
||||
|
||||
const task = makeTask();
|
||||
await executeA2ATaskWithState(tm, task, async () => ({ artifacts: [], metadata: {} }), deps);
|
||||
|
||||
assert.equal(completedState, "completed");
|
||||
assert.deepEqual(task.metadata.memoryHits, [
|
||||
{ id: "m1", key: "k1", type: "factual", snippet: "hello" },
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression (whole-branch review, Important 1): `createTask` used to store the CALLER's
|
||||
* `input.metadata` object as the task's own `metadata`, so the `memoryHits` written above
|
||||
* landed inside `task.input.metadata` too — from where it was serialized into
|
||||
* `a2a_tasks.input_json` and echoed back by the drawer's "Repeat" body, making the repeated
|
||||
* task be born carrying the previous run's memory snippets (visible even with the
|
||||
* `OMNIROUTE_A2A_MEMORY_HITS=0` kill-switch on). `metadata` must be a COPY.
|
||||
*/
|
||||
test("executeA2ATaskWithState never leaks memoryHits into task.input.metadata or the persisted input", async () => {
|
||||
const upsertCalls: Array<{ inputJson: string | null }> = [];
|
||||
const persistence: A2APersistence = {
|
||||
upsert: ((row: { inputJson: string | null }) => {
|
||||
upsertCalls.push(row);
|
||||
}) as A2APersistence["upsert"],
|
||||
appendEvent: (() => {}) as A2APersistence["appendEvent"],
|
||||
purge: ((): number => 0) as A2APersistence["purge"],
|
||||
};
|
||||
const tm = new A2ATaskManager(5, persistence);
|
||||
try {
|
||||
const callerMetadata = { role: "general" };
|
||||
const task = tm.createTask({
|
||||
skill: "smart-routing",
|
||||
messages: [{ role: "user", content: "route this please" }],
|
||||
metadata: callerMetadata,
|
||||
});
|
||||
|
||||
await executeA2ATaskWithState(
|
||||
{ updateTask: () => {} },
|
||||
task,
|
||||
async () => ({ artifacts: [], metadata: {} }),
|
||||
{
|
||||
search: async () => [{ id: "m1", key: "k1", type: "factual", content: "hello" }],
|
||||
appendEvent: () => {},
|
||||
}
|
||||
);
|
||||
|
||||
// The hits ARE recorded on the task's runtime metadata …
|
||||
assert.deepEqual(task.metadata.memoryHits, [
|
||||
{ id: "m1", key: "k1", type: "factual", snippet: "hello" },
|
||||
]);
|
||||
// … but never on the immutable record of what the caller sent.
|
||||
assert.equal("memoryHits" in (task.input.metadata ?? {}), false);
|
||||
assert.deepEqual(task.input.metadata, { role: "general" });
|
||||
// … nor on the caller's own object (no aliasing in either direction).
|
||||
assert.deepEqual(callerMetadata, { role: "general" });
|
||||
|
||||
// A persist AFTER the hits were recorded must still write a clean input_json.
|
||||
tm.updateTask(task.id, "working");
|
||||
assert.ok(upsertCalls.length >= 2);
|
||||
for (const row of upsertCalls) {
|
||||
assert.ok(!String(row.inputJson).includes("memoryHits"), "input_json carries no memoryHits");
|
||||
}
|
||||
} finally {
|
||||
tm.destroy();
|
||||
}
|
||||
});
|
||||
155
tests/unit/conductor-create-route.test.ts
Normal file
155
tests/unit/conductor-create-route.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createServer, type Server } from "node:http";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-conductor-create-route-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const createRoute = await import("../../src/app/api/conductor/tasks/route.ts");
|
||||
|
||||
const servers: Server[] = [];
|
||||
|
||||
function fakeHub(routes: Record<string, { status: number; body: unknown }>): Promise<string> {
|
||||
const server = createServer((req, res) => {
|
||||
const hit = Object.entries(routes).find(([p]) => (req.url ?? "").startsWith(p));
|
||||
res.writeHead(hit ? hit[1].status : 404, { "content-type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify(hit ? hit[1].body : { error: "hub: segredo interno que NÃO pode vazar" })
|
||||
);
|
||||
});
|
||||
servers.push(server);
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address();
|
||||
resolve(`http://127.0.0.1:${typeof addr === "object" && addr ? addr.port : 0}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function postJson(body: unknown): Request {
|
||||
return new Request("http://localhost/api/conductor/tasks", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
delete process.env.CONDUCTOR_HUB_URL;
|
||||
delete process.env.CONDUCTOR_HUB_TOKEN;
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
delete process.env.CONDUCTOR_HUB_URL;
|
||||
delete process.env.CONDUCTOR_HUB_TOKEN;
|
||||
while (servers.length > 0) {
|
||||
const s = servers.pop();
|
||||
await new Promise((resolve) => s?.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test("route: requireManagementAuth antes de criar a task no hub", () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(process.cwd(), "src/app/api/conductor/tasks/route.ts"),
|
||||
"utf8"
|
||||
);
|
||||
const authAt = src.indexOf("requireManagementAuth(");
|
||||
assert.ok(authAt > 0, "handler chama requireManagementAuth");
|
||||
assert.match(src, /if \(authError\) return authError;/, "curto-circuito no erro de auth");
|
||||
const proxyAt = src.indexOf("createConductorTask(");
|
||||
assert.ok(proxyAt > authAt, "proxy ao hub só depois do gate de auth");
|
||||
assert.ok(
|
||||
!src.includes("CONDUCTOR_HUB_TOKEN"),
|
||||
"token nunca manuseado na rota (vive no hubProxy)"
|
||||
);
|
||||
});
|
||||
|
||||
test("POST /api/conductor/tasks: body válido + hub ok → 201 {task_id}", async () => {
|
||||
process.env.CONDUCTOR_HUB_URL = await fakeHub({
|
||||
"/v1/tasks": { status: 201, body: { id: "t_repeat_1" } },
|
||||
});
|
||||
process.env.CONDUCTOR_HUB_TOKEN = "tok";
|
||||
|
||||
const res = await createRoute.POST(
|
||||
postJson({ repoUrl: "https://git.x/repo", prompt: "refaça isso" })
|
||||
);
|
||||
assert.equal(res.status, 201);
|
||||
assert.deepEqual(await res.json(), { task_id: "t_repeat_1" });
|
||||
});
|
||||
|
||||
test("POST /api/conductor/tasks: hub recusa (502) → status espelhado, sem corpo upstream", async () => {
|
||||
process.env.CONDUCTOR_HUB_URL = await fakeHub({
|
||||
"/v1/tasks": { status: 502, body: { error: "segredo interno que NÃO pode vazar" } },
|
||||
});
|
||||
process.env.CONDUCTOR_HUB_TOKEN = "tok";
|
||||
|
||||
const res = await createRoute.POST(
|
||||
postJson({ repoUrl: "https://git.x/repo", prompt: "refaça isso" })
|
||||
);
|
||||
assert.equal(res.status, 502);
|
||||
const text = await res.text();
|
||||
assert.ok(!text.includes("segredo interno"), "corpo do hub NUNCA repassado (HR#12)");
|
||||
});
|
||||
|
||||
test("POST /api/conductor/tasks: body inválido (sem prompt) → 400", async () => {
|
||||
const res = await createRoute.POST(postJson({ repoUrl: "https://git.x/repo" }));
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
test("POST /api/conductor/tasks: body inválido (sem repoUrl) → 400", async () => {
|
||||
const res = await createRoute.POST(postJson({ prompt: "refaça isso" }));
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
test("POST /api/conductor/tasks: JSON malformado → 400", async () => {
|
||||
const res = await createRoute.POST(
|
||||
new Request("http://localhost/api/conductor/tasks", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "{not json",
|
||||
})
|
||||
);
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
/**
|
||||
* Review finding (Minor A): the hub's status was forwarded verbatim as OUR response status.
|
||||
* `Response.json()` throws a `RangeError` for anything outside 200-599, and a 3xx/2xx is
|
||||
* meaningless as an error status anyway — so anything outside 400-599 must become a 502
|
||||
* instead of an unhandled throw. A 302 with no `Location` is returned as-is by fetch (there
|
||||
* is nothing to follow), which reproduces the out-of-band status without a fake fetch impl.
|
||||
*/
|
||||
test("POST /api/conductor/tasks: status fora de 400-599 vindo do hub é clampado para 502", async () => {
|
||||
process.env.CONDUCTOR_HUB_URL = await fakeHub({
|
||||
"/v1/tasks": { status: 302, body: { error: "segredo interno que NÃO pode vazar" } },
|
||||
});
|
||||
process.env.CONDUCTOR_HUB_TOKEN = "tok";
|
||||
|
||||
const res = await createRoute.POST(
|
||||
postJson({ repoUrl: "https://git.x/repo", prompt: "refaça isso" })
|
||||
);
|
||||
assert.equal(res.status, 502);
|
||||
const text = await res.text();
|
||||
assert.ok(!text.includes("segredo interno"), "corpo do hub NUNCA repassado (HR#12)");
|
||||
});
|
||||
|
||||
test("POST /api/conductor/tasks: status 4xx/5xx legítimo do hub continua espelhado", async () => {
|
||||
process.env.CONDUCTOR_HUB_URL = await fakeHub({
|
||||
"/v1/tasks": { status: 429, body: { error: "rate limited" } },
|
||||
});
|
||||
process.env.CONDUCTOR_HUB_TOKEN = "tok";
|
||||
|
||||
const res = await createRoute.POST(
|
||||
postJson({ repoUrl: "https://git.x/repo", prompt: "refaça isso" })
|
||||
);
|
||||
assert.equal(res.status, 429);
|
||||
});
|
||||
786
tests/unit/ui/orchestrationDrawerRepeat.test.tsx
Normal file
786
tests/unit/ui/orchestrationDrawerRepeat.test.tsx
Normal file
@@ -0,0 +1,786 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (k: string, v?: Record<string, unknown>) =>
|
||||
v ? `${k}:${JSON.stringify(v)}` : k,
|
||||
}));
|
||||
|
||||
import { OrchestrationDrawer } from "@/app/(dashboard)/dashboard/orchestration/drawer/OrchestrationDrawer";
|
||||
import { repeatReqFor } from "@/app/(dashboard)/dashboard/orchestration/drawer/useDrawerDetail";
|
||||
|
||||
function render(el: React.ReactElement) {
|
||||
const c = document.createElement("div");
|
||||
document.body.appendChild(c);
|
||||
const root = createRoot(c);
|
||||
act(() => root.render(el));
|
||||
return {
|
||||
c,
|
||||
cleanup: () => {
|
||||
act(() => root.unmount());
|
||||
c.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("OrchestrationDrawer memory section", () => {
|
||||
it("renders the memory-used section (type/key/snippet) when an a2a task carries metadata.memoryHits", async () => {
|
||||
const a2aTask = {
|
||||
id: "1",
|
||||
skill: "smart-routing",
|
||||
state: "working",
|
||||
input: { skill: "smart-routing", messages: [{ role: "user", content: "route this please" }] },
|
||||
artifacts: [],
|
||||
events: [],
|
||||
metadata: {
|
||||
memoryHits: [
|
||||
{ id: "m1", key: "user-pref-model", type: "preference", snippet: "prefers claude" },
|
||||
],
|
||||
},
|
||||
createdAt: "x",
|
||||
updatedAt: "y",
|
||||
expiresAt: "z",
|
||||
};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ task: a2aTask }) }))
|
||||
);
|
||||
const node = { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "x" };
|
||||
const { c, cleanup } = render(
|
||||
<OrchestrationDrawer node={node as never} onClose={() => {}} onActionDone={() => {}} />
|
||||
);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(c.textContent).toContain("drawerMemory");
|
||||
expect(c.textContent).toContain("preference");
|
||||
expect(c.textContent).toContain("user-pref-model");
|
||||
expect(c.textContent).toContain("prefers claude");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("omits the memory-used section when an a2a task has no memoryHits", async () => {
|
||||
const a2aTask = {
|
||||
id: "1",
|
||||
skill: "smart-routing",
|
||||
state: "working",
|
||||
input: { skill: "smart-routing", messages: [{ role: "user", content: "route this please" }] },
|
||||
artifacts: [],
|
||||
events: [],
|
||||
metadata: {},
|
||||
createdAt: "x",
|
||||
updatedAt: "y",
|
||||
expiresAt: "z",
|
||||
};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ task: a2aTask }) }))
|
||||
);
|
||||
const node = { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "x" };
|
||||
const { c, cleanup } = render(
|
||||
<OrchestrationDrawer node={node as never} onClose={() => {}} onActionDone={() => {}} />
|
||||
);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(c.textContent).not.toContain("drawerMemory");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders nothing and does not throw when metadata.memoryHits is a malformed, non-array shape (a string, not an array of hits)", async () => {
|
||||
const a2aTask = {
|
||||
id: "1",
|
||||
skill: "smart-routing",
|
||||
state: "working",
|
||||
input: { skill: "smart-routing", messages: [{ role: "user", content: "route this please" }] },
|
||||
artifacts: [],
|
||||
events: [],
|
||||
metadata: { memoryHits: "boom" },
|
||||
createdAt: "x",
|
||||
updatedAt: "y",
|
||||
expiresAt: "z",
|
||||
};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ task: a2aTask }) }))
|
||||
);
|
||||
const node = { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "x" };
|
||||
const { c, cleanup } = render(
|
||||
<OrchestrationDrawer node={node as never} onClose={() => {}} onActionDone={() => {}} />
|
||||
);
|
||||
await expect(
|
||||
act(async () => {
|
||||
await Promise.resolve();
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
expect(c.textContent).not.toContain("drawerMemory");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("filters out malformed entries in metadata.memoryHits (an array of junk) without throwing", async () => {
|
||||
const a2aTask = {
|
||||
id: "1",
|
||||
skill: "smart-routing",
|
||||
state: "working",
|
||||
input: { skill: "smart-routing", messages: [{ role: "user", content: "route this please" }] },
|
||||
artifacts: [],
|
||||
events: [],
|
||||
// `{ id: "x", key: {...} }` is the dangerous shape: a VALID string id next to an
|
||||
// object field that the section renders as a React child — a guard that only checks
|
||||
// `id` lets it through and React throws "Objects are not valid as a React child",
|
||||
// taking the whole drawer down. Every one of the four rendered fields must be a string.
|
||||
metadata: {
|
||||
memoryHits: [
|
||||
{ notId: "x" },
|
||||
"nope",
|
||||
123,
|
||||
null,
|
||||
{ id: "x", key: { a: 1 }, type: "factual", snippet: "s" },
|
||||
{ id: "y", key: "k", type: ["nope"], snippet: "s" },
|
||||
{ id: "z", key: "k", type: "factual", snippet: { toString: "boom" } },
|
||||
{ id: "w", key: "k", type: "factual" },
|
||||
],
|
||||
},
|
||||
createdAt: "x",
|
||||
updatedAt: "y",
|
||||
expiresAt: "z",
|
||||
};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ task: a2aTask }) }))
|
||||
);
|
||||
const node = { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "x" };
|
||||
const { c, cleanup } = render(
|
||||
<OrchestrationDrawer node={node as never} onClose={() => {}} onActionDone={() => {}} />
|
||||
);
|
||||
await expect(
|
||||
act(async () => {
|
||||
await Promise.resolve();
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
expect(c.textContent).not.toContain("drawerMemory");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("never shows the memory-used section for non-a2a sources, even with attacker-shaped raw data", async () => {
|
||||
const detail = {
|
||||
data: {
|
||||
id: "t1",
|
||||
providerId: "devin",
|
||||
status: "succeeded",
|
||||
prompt: "x",
|
||||
source: { repoName: "r", repoUrl: "https://x" },
|
||||
options: {},
|
||||
activities: [],
|
||||
metadata: {
|
||||
memoryHits: [{ id: "m1", key: "k", type: "t", snippet: "s" }],
|
||||
},
|
||||
createdAt: "x",
|
||||
updatedAt: "y",
|
||||
},
|
||||
};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(detail) }))
|
||||
);
|
||||
const node = {
|
||||
id: "cloud-agent:t1",
|
||||
kind: "work",
|
||||
source: "cloud-agent",
|
||||
state: "succeeded",
|
||||
label: "x",
|
||||
};
|
||||
const { c, cleanup } = render(
|
||||
<OrchestrationDrawer node={node as never} onClose={() => {}} onActionDone={() => {}} />
|
||||
);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(c.textContent).not.toContain("drawerMemory");
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe("repeatReqFor", () => {
|
||||
it("builds the cloud-agent repeat request from the loaded detail (CreateCloudAgentTaskSchema shape)", () => {
|
||||
const node = {
|
||||
id: "cloud-agent:t1",
|
||||
kind: "work",
|
||||
source: "cloud-agent",
|
||||
state: "succeeded",
|
||||
label: "x",
|
||||
};
|
||||
const detail = {
|
||||
id: "t1",
|
||||
providerId: "devin",
|
||||
prompt: "do the thing",
|
||||
source: { repoName: "r", repoUrl: "https://x" },
|
||||
options: { autoCreatePr: true },
|
||||
activities: [],
|
||||
};
|
||||
const req = repeatReqFor(node as never, detail);
|
||||
expect(req?.url).toBe("/api/v1/agents/tasks");
|
||||
expect(req?.init.method).toBe("POST");
|
||||
expect(JSON.parse(String(req?.init.body))).toEqual({
|
||||
providerId: "devin",
|
||||
prompt: "do the thing",
|
||||
source: { repoName: "r", repoUrl: "https://x" },
|
||||
options: { autoCreatePr: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for cloud-agent when neither providerId nor prompt is recoverable", () => {
|
||||
const node = {
|
||||
id: "cloud-agent:t1",
|
||||
kind: "work",
|
||||
source: "cloud-agent",
|
||||
state: "succeeded",
|
||||
label: "x",
|
||||
};
|
||||
expect(repeatReqFor(node as never, { source: {}, options: {}, activities: [] })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for cloud-agent when providerId is the only missing field", () => {
|
||||
const node = {
|
||||
id: "cloud-agent:t1",
|
||||
kind: "work",
|
||||
source: "cloud-agent",
|
||||
state: "succeeded",
|
||||
label: "x",
|
||||
};
|
||||
const detail = {
|
||||
prompt: "do the thing",
|
||||
source: { repoName: "r", repoUrl: "https://x" },
|
||||
options: {},
|
||||
activities: [],
|
||||
};
|
||||
expect(repeatReqFor(node as never, detail)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for cloud-agent when prompt is the only missing field", () => {
|
||||
const node = {
|
||||
id: "cloud-agent:t1",
|
||||
kind: "work",
|
||||
source: "cloud-agent",
|
||||
state: "succeeded",
|
||||
label: "x",
|
||||
};
|
||||
const detail = {
|
||||
providerId: "devin",
|
||||
source: { repoName: "r", repoUrl: "https://x" },
|
||||
options: {},
|
||||
activities: [],
|
||||
};
|
||||
expect(repeatReqFor(node as never, detail)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for cloud-agent when source is the only missing field (CreateCloudAgentTaskSchema also requires it — the field this fix started checking)", () => {
|
||||
const node = {
|
||||
id: "cloud-agent:t1",
|
||||
kind: "work",
|
||||
source: "cloud-agent",
|
||||
state: "succeeded",
|
||||
label: "x",
|
||||
};
|
||||
const detail = {
|
||||
providerId: "devin",
|
||||
prompt: "do the thing",
|
||||
options: {},
|
||||
activities: [],
|
||||
};
|
||||
expect(repeatReqFor(node as never, detail)).toBeNull();
|
||||
});
|
||||
|
||||
it("builds the a2a repeat request as a message/send JSON-RPC call from detail.input", () => {
|
||||
const node = { id: "a2a:1", kind: "work", source: "a2a", state: "succeeded", label: "x" };
|
||||
const detail = {
|
||||
input: {
|
||||
skill: "smart-routing",
|
||||
messages: [{ role: "user", content: "route this please" }],
|
||||
metadata: { role: "general" },
|
||||
},
|
||||
};
|
||||
const req = repeatReqFor(node as never, detail);
|
||||
expect(req?.url).toBe("/a2a");
|
||||
expect(JSON.parse(String(req?.init.body))).toEqual({
|
||||
jsonrpc: "2.0",
|
||||
id: "a2a:1",
|
||||
method: "message/send",
|
||||
params: {
|
||||
skill: "smart-routing",
|
||||
messages: [{ role: "user", content: "route this please" }],
|
||||
metadata: { role: "general" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("strips memoryHits from the a2a repeat metadata (never re-sends the previous run's memory)", () => {
|
||||
// `memoryHits` is observability written by the PREVIOUS run, never caller input. Tasks
|
||||
// persisted before the createTask copy-fix still carry it inside `input.metadata`, so the
|
||||
// repeat path has to drop it — otherwise the new task is born with the old run's snippets
|
||||
// and shows them in the drawer even with `OMNIROUTE_A2A_MEMORY_HITS=0`.
|
||||
const node = { id: "a2a:1", kind: "work", source: "a2a", state: "succeeded", label: "x" };
|
||||
const detail = {
|
||||
input: {
|
||||
skill: "smart-routing",
|
||||
messages: [{ role: "user", content: "route this please" }],
|
||||
metadata: {
|
||||
role: "general",
|
||||
memoryHits: [{ id: "m1", key: "k1", type: "factual", snippet: "leaked" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const body = JSON.parse(String(repeatReqFor(node as never, detail)?.init.body));
|
||||
expect(body.params.metadata).toEqual({ role: "general" });
|
||||
expect(JSON.stringify(body)).not.toContain("memoryHits");
|
||||
});
|
||||
|
||||
it("omits metadata entirely when the a2a detail carries none (or a non-object one)", () => {
|
||||
const node = { id: "a2a:1", kind: "work", source: "a2a", state: "succeeded", label: "x" };
|
||||
const messages = [{ role: "user", content: "route this please" }];
|
||||
const bare = JSON.parse(
|
||||
String(repeatReqFor(node as never, { input: { skill: "s", messages } })?.init.body)
|
||||
);
|
||||
expect("metadata" in bare.params).toBe(false);
|
||||
const junk = JSON.parse(
|
||||
String(
|
||||
repeatReqFor(node as never, { input: { skill: "s", messages, metadata: "boom" } })?.init
|
||||
.body
|
||||
)
|
||||
);
|
||||
expect("metadata" in junk.params).toBe(false);
|
||||
});
|
||||
|
||||
it("returns null for a2a when input.messages is empty or missing", () => {
|
||||
const node = { id: "a2a:1", kind: "work", source: "a2a", state: "succeeded", label: "x" };
|
||||
expect(repeatReqFor(node as never, { input: { skill: "s", messages: [] } })).toBeNull();
|
||||
expect(repeatReqFor(node as never, {})).toBeNull();
|
||||
});
|
||||
|
||||
it("builds the conductor repeat request against the D1 task-creation route", () => {
|
||||
const node = {
|
||||
id: "conductor:task:1",
|
||||
kind: "work",
|
||||
source: "conductor",
|
||||
state: "succeeded",
|
||||
label: "x",
|
||||
};
|
||||
const detail = {
|
||||
repo: "https://github.com/x/y",
|
||||
prompt: "fix the bug",
|
||||
base_ref: "main",
|
||||
mode: "auto",
|
||||
};
|
||||
const req = repeatReqFor(node as never, detail);
|
||||
expect(req?.url).toBe("/api/conductor/tasks");
|
||||
expect(JSON.parse(String(req?.init.body))).toEqual({
|
||||
repoUrl: "https://github.com/x/y",
|
||||
prompt: "fix the bug",
|
||||
baseRef: "main",
|
||||
mode: "auto",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for conductor when neither repo nor prompt is recoverable", () => {
|
||||
const node = {
|
||||
id: "conductor:task:1",
|
||||
kind: "work",
|
||||
source: "conductor",
|
||||
state: "succeeded",
|
||||
label: "x",
|
||||
};
|
||||
expect(repeatReqFor(node as never, { mode: "auto" })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for conductor when prompt is the only missing field (a hub task with repo but no spec.prompt must not POST prompt:null — HTTP 400)", () => {
|
||||
const node = {
|
||||
id: "conductor:task:1",
|
||||
kind: "work",
|
||||
source: "conductor",
|
||||
state: "succeeded",
|
||||
label: "x",
|
||||
};
|
||||
const detail = { repo: "https://github.com/x/y", prompt: null, base_ref: "main", mode: "auto" };
|
||||
expect(repeatReqFor(node as never, detail)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for conductor when repo is the only missing field", () => {
|
||||
const node = {
|
||||
id: "conductor:task:1",
|
||||
kind: "work",
|
||||
source: "conductor",
|
||||
state: "succeeded",
|
||||
label: "x",
|
||||
};
|
||||
const detail = { repo: null, prompt: "fix the bug", base_ref: "main", mode: "auto" };
|
||||
expect(repeatReqFor(node as never, detail)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a source with no known repeat contract (runner/overflow)", () => {
|
||||
const node = { id: "overflow:1", kind: "overflow", state: "succeeded", label: "x" };
|
||||
expect(repeatReqFor(node as never, {})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OrchestrationDrawer repeat action (two-click confirm)", () => {
|
||||
const A2A_TASK = {
|
||||
id: "1",
|
||||
skill: "smart-routing",
|
||||
state: "working",
|
||||
input: {
|
||||
skill: "smart-routing",
|
||||
messages: [{ role: "user", content: "route this please" }],
|
||||
},
|
||||
artifacts: [],
|
||||
events: [],
|
||||
metadata: {},
|
||||
createdAt: "x",
|
||||
updatedAt: "y",
|
||||
expiresAt: "z",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function findRepeatButton(c: HTMLElement): HTMLButtonElement {
|
||||
return Array.from(c.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.includes("actionRepeat") || b.textContent?.includes("repeatConfirm")
|
||||
) as HTMLButtonElement;
|
||||
}
|
||||
|
||||
it("disables the repeat button with the repeatUnavailable tooltip when the input cannot be recovered", async () => {
|
||||
const unrecoverable = { ...A2A_TASK, input: { skill: "smart-routing", messages: [] } };
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({ task: unrecoverable }) })
|
||||
)
|
||||
);
|
||||
const node = { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "x" };
|
||||
const { c, cleanup } = render(
|
||||
<OrchestrationDrawer node={node as never} onClose={() => {}} onActionDone={() => {}} />
|
||||
);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
const btn = findRepeatButton(c);
|
||||
expect(btn.disabled).toBe(true);
|
||||
expect(btn.getAttribute("title")).toBe("repeatUnavailable");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("first click arms the confirm label without posting; second click within the window posts and reports success", async () => {
|
||||
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
|
||||
if (init?.method === "POST") {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ task: A2A_TASK }) });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
let done = false;
|
||||
const node = { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "x" };
|
||||
const { c, cleanup } = render(
|
||||
<OrchestrationDrawer
|
||||
node={node as never}
|
||||
onClose={() => {}}
|
||||
onActionDone={() => {
|
||||
done = true;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findRepeatButton(c).click();
|
||||
});
|
||||
expect(fetchMock.mock.calls.some(([, init]) => (init as RequestInit)?.method === "POST")).toBe(
|
||||
false
|
||||
);
|
||||
expect(findRepeatButton(c).textContent).toContain("repeatConfirm");
|
||||
|
||||
await act(async () => {
|
||||
findRepeatButton(c).click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
const post = fetchMock.mock.calls.find(([, init]) => (init as RequestInit)?.method === "POST");
|
||||
expect(post).toBeTruthy();
|
||||
expect(post![0]).toBe("/a2a");
|
||||
expect(JSON.parse(String((post![1] as RequestInit).body))).toEqual({
|
||||
jsonrpc: "2.0",
|
||||
id: "a2a:1",
|
||||
method: "message/send",
|
||||
params: {
|
||||
skill: "smart-routing",
|
||||
messages: [{ role: "user", content: "route this please" }],
|
||||
},
|
||||
});
|
||||
expect(done).toBe(true);
|
||||
expect(c.textContent).toContain("repeatDone");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("resets the confirm label back to actionRepeat after 3s with no second click", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ task: A2A_TASK }) }))
|
||||
);
|
||||
const node = { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "x" };
|
||||
const { c, cleanup } = render(
|
||||
<OrchestrationDrawer node={node as never} onClose={() => {}} onActionDone={() => {}} />
|
||||
);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findRepeatButton(c).click();
|
||||
});
|
||||
expect(findRepeatButton(c).textContent).toContain("repeatConfirm");
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
});
|
||||
expect(findRepeatButton(c).textContent).toContain("actionRepeat");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("a click after the 3s window expired re-arms the confirm instead of posting (it is a fresh first click, not a stale second click)", async () => {
|
||||
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
|
||||
if (init?.method === "POST") {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ task: A2A_TASK }) });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const node = { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "x" };
|
||||
const { c, cleanup } = render(
|
||||
<OrchestrationDrawer node={node as never} onClose={() => {}} onActionDone={() => {}} />
|
||||
);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findRepeatButton(c).click();
|
||||
});
|
||||
expect(findRepeatButton(c).textContent).toContain("repeatConfirm");
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
});
|
||||
expect(findRepeatButton(c).textContent).toContain("actionRepeat");
|
||||
|
||||
// The window has expired — this click must be treated as a fresh first click
|
||||
// (arm + wait), never as the stale second click that would fire the POST.
|
||||
await act(async () => {
|
||||
findRepeatButton(c).click();
|
||||
});
|
||||
expect(fetchMock.mock.calls.some(([, init]) => (init as RequestInit)?.method === "POST")).toBe(
|
||||
false
|
||||
);
|
||||
expect(findRepeatButton(c).textContent).toContain("repeatConfirm");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("clears the pending 3s confirm timer on unmount so it can never fire after teardown", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ task: A2A_TASK }) }))
|
||||
);
|
||||
const node = { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "x" };
|
||||
const { c, cleanup } = render(
|
||||
<OrchestrationDrawer node={node as never} onClose={() => {}} onActionDone={() => {}} />
|
||||
);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findRepeatButton(c).click();
|
||||
});
|
||||
expect(vi.getTimerCount()).toBeGreaterThan(0);
|
||||
|
||||
cleanup();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("shows actionFailed when the repeat POST fails, without touching onActionDone", async () => {
|
||||
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
|
||||
if (init?.method === "POST") {
|
||||
return Promise.resolve({ ok: false, status: 500, json: () => Promise.resolve({}) });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ task: A2A_TASK }) });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
let done = false;
|
||||
const node = { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "x" };
|
||||
const { c, cleanup } = render(
|
||||
<OrchestrationDrawer
|
||||
node={node as never}
|
||||
onClose={() => {}}
|
||||
onActionDone={() => {
|
||||
done = true;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findRepeatButton(c).click();
|
||||
});
|
||||
await act(async () => {
|
||||
findRepeatButton(c).click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(c.textContent).toContain("actionFailed");
|
||||
expect(done).toBe(false);
|
||||
cleanup();
|
||||
});
|
||||
it("does not send the previous run's memoryHits in the repeat POST body", async () => {
|
||||
const withHits = {
|
||||
...A2A_TASK,
|
||||
input: {
|
||||
...A2A_TASK.input,
|
||||
metadata: {
|
||||
role: "general",
|
||||
memoryHits: [{ id: "m1", key: "k1", type: "factual", snippet: "leaked" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
|
||||
if (init?.method === "POST") {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ task: withHits }) });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const node = { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "x" };
|
||||
const { c, cleanup } = render(
|
||||
<OrchestrationDrawer node={node as never} onClose={() => {}} onActionDone={() => {}} />
|
||||
);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findRepeatButton(c).click();
|
||||
});
|
||||
await act(async () => {
|
||||
findRepeatButton(c).click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
const post = fetchMock.mock.calls.find(([, init]) => (init as RequestInit)?.method === "POST");
|
||||
expect(post).toBeTruthy();
|
||||
const body = JSON.parse(String((post![1] as RequestInit).body));
|
||||
expect(body.params.metadata).toEqual({ role: "general" });
|
||||
expect(String((post![1] as RequestInit).body)).not.toContain("memoryHits");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("treats a JSON-RPC error answered with HTTP 200 as a failure, never as a success toast", async () => {
|
||||
// `/a2a` maps most JSON-RPC error codes to `status: 200` (src/app/a2a/route.ts), so
|
||||
// `res.ok` alone would report a run that never happened as done.
|
||||
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
|
||||
if (init?.method === "POST") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
jsonrpc: "2.0",
|
||||
id: "a2a:1",
|
||||
error: { code: -32602, message: "segredo interno que NAO pode vazar" },
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ task: A2A_TASK }) });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
let done = false;
|
||||
const node = { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "x" };
|
||||
const { c, cleanup } = render(
|
||||
<OrchestrationDrawer
|
||||
node={node as never}
|
||||
onClose={() => {}}
|
||||
onActionDone={() => {
|
||||
done = true;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findRepeatButton(c).click();
|
||||
});
|
||||
await act(async () => {
|
||||
findRepeatButton(c).click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(done).toBe(false);
|
||||
expect(c.textContent).not.toContain("repeatDone");
|
||||
expect(c.textContent).toContain("actionFailed");
|
||||
expect(c.textContent).toContain("RPC -32602");
|
||||
expect(c.textContent).not.toContain("segredo interno");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("surfaces the sanitized HTTP status when a secured deployment rejects the a2a repeat (HTTP 400)", async () => {
|
||||
// With REQUIRE_API_KEY / OMNIROUTE_API_KEY set, `/a2a` answers -32600 => HTTP 400 to a
|
||||
// dashboard-session caller. The drawer must say so instead of pretending success.
|
||||
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
|
||||
if (init?.method === "POST") {
|
||||
return Promise.resolve({ ok: false, status: 400, json: () => Promise.resolve({}) });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ task: A2A_TASK }) });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const node = { id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "x" };
|
||||
const { c, cleanup } = render(
|
||||
<OrchestrationDrawer node={node as never} onClose={() => {}} onActionDone={() => {}} />
|
||||
);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
findRepeatButton(c).click();
|
||||
});
|
||||
await act(async () => {
|
||||
findRepeatButton(c).click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(c.textContent).toContain("actionFailed");
|
||||
expect(c.textContent).toContain("HTTP 400");
|
||||
expect(c.textContent).not.toContain("repeatDone");
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
@@ -250,6 +250,36 @@ describe("HistoryTab", () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("keeps the drawer open on onActionDone so its success toast is visible, and refetches", async () => {
|
||||
// Review finding (Minor B): this tab used to pass `onActionDone={() => setSelected(null)}`,
|
||||
// which unmounted the drawer BEFORE it rendered the repeat/cancel confirmation — the
|
||||
// operator saw the action silently do nothing. The callback must keep the drawer mounted
|
||||
// (and re-sample the range so the new run shows up).
|
||||
const fetchMock = mockFetch({ a2aTasks: [a2aTask()] });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const { c, cleanup } = render(<HistoryTab />);
|
||||
await flush();
|
||||
|
||||
const cell = c.querySelector('button[aria-label*="smart-routing"]') as HTMLButtonElement;
|
||||
act(() => {
|
||||
cell.click();
|
||||
});
|
||||
expect((drawerCalls.at(-1) as { node: unknown }).node).toBeTruthy();
|
||||
const callsBefore = fetchMock.mock.calls.length;
|
||||
|
||||
await act(async () => {
|
||||
(drawerCalls.at(-1) as { onActionDone: () => void }).onActionDone();
|
||||
});
|
||||
await flush();
|
||||
|
||||
// Still open on the same node …
|
||||
const last = drawerCalls.at(-1) as { node: { id: string } | null };
|
||||
expect(last.node?.id).toBe("a2a:t1");
|
||||
// … and the history was refetched.
|
||||
expect(fetchMock.mock.calls.length).toBeGreaterThan(callsBefore);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("shows a source-failed warning for A2A while Cloud Agent rows still render", async () => {
|
||||
vi.stubGlobal("fetch", mockFetch({ a2aFail: true, cloudAgentTasks: [cloudAgentTask()] }));
|
||||
const { c, cleanup } = render(<HistoryTab />);
|
||||
|
||||
Reference in New Issue
Block a user