mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 10:52:17 +03:00
feat(api): hydrate memoryHits from the persisted history event
`GET /api/a2a/tasks/[id]` falls back to the persisted history row once a task
leaves the in-memory TTL window, and `reconstituteHistoricalTask` hard-coded
`metadata: {}` — so the drawer's "Memory used" section vanished for any
historical task, even though `executeA2ATaskWithState` had already written a
`memory_hits` event with the hits.
The fallback now reads that event: `data_json` is parsed and, when it yields at
least one well-formed hit, exposed as `metadata.memoryHits`. The event itself is
filtered out of `events` — it is observability, not a state transition, and
without the filter it leaked into the timeline as a duplicate of the row's
current state.
Reading is defensive throughout, mirroring `DrawerMemory`'s own validation: the
payload is caller-influenced and unvalidated end to end, so `JSON.parse` runs
inside `safeJsonParse`, non-arrays are rejected, and each entry must carry `id`,
`key`, `type` and `snippet` as strings (a non-string field would be rendered as
a React child and take the drawer down). Malformed input degrades to
`metadata: {}` and a 200 — never a 500.
Refs #12639
This commit is contained in:
@@ -20,6 +20,40 @@ function safeJsonParse<T>(json: string | null | undefined, fallback: T): T {
|
||||
}
|
||||
|
||||
const STATE_EVENT_PREFIX = "state:";
|
||||
/** Event type written by `collectMemoryHits` (src/lib/a2a/taskExecution.ts). */
|
||||
const MEMORY_HITS_EVENT_TYPE = "memory_hits";
|
||||
const MEMORY_HIT_FIELDS = ["id", "key", "type", "snippet"] as const;
|
||||
|
||||
interface MemoryHit {
|
||||
id: string;
|
||||
key: string;
|
||||
type: string;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a persisted `memory_hits` event's `data_json` into the hits the drawer renders.
|
||||
* Mirrors `DrawerMemory`'s validation in
|
||||
* `src/app/(dashboard)/dashboard/orchestration/drawer/OrchestrationDrawer.tsx`: `metadata` is
|
||||
* caller-supplied and unvalidated end to end, so what got persisted can be anything —
|
||||
* a bare string (`"boom"`, whose `.length` is truthy), an object, or an array carrying entries
|
||||
* with a non-string `key`/`type`/`snippet` (rendered as React children, so an object there
|
||||
* would throw "Objects are not valid as a React child" and take the whole drawer down).
|
||||
* Never throws: a malformed payload degrades to an empty list, so the route answers 200
|
||||
* with `metadata: {}` instead of 500.
|
||||
*/
|
||||
function parseMemoryHits(dataJson: string | null | undefined): MemoryHit[] {
|
||||
const raw = safeJsonParse<unknown>(dataJson, null);
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter(
|
||||
(hit): hit is MemoryHit =>
|
||||
!!hit &&
|
||||
typeof hit === "object" &&
|
||||
MEMORY_HIT_FIELDS.every(
|
||||
(field) => typeof (hit as Record<string, unknown>)[field] === "string"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitute the in-memory `A2ATask` shape (src/lib/a2a/taskManager.ts) from a persisted
|
||||
@@ -29,22 +63,32 @@ const STATE_EVENT_PREFIX = "state:";
|
||||
* state each event represents is recovered by stripping that prefix.
|
||||
*/
|
||||
function reconstituteHistoricalTask(row: A2ATaskHistoryRow) {
|
||||
const input = safeJsonParse<{ skill: string; messages: Array<{ role: string; content: string }> }>(
|
||||
row.input_json,
|
||||
{ skill: row.skill_id ?? "", messages: [] }
|
||||
);
|
||||
const input = safeJsonParse<{
|
||||
skill: string;
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
}>(row.input_json, { skill: row.skill_id ?? "", messages: [] });
|
||||
const artifacts = safeJsonParse<unknown[]>(row.output_json, []);
|
||||
const events = listA2ATaskEvents(row.id).map((event) => {
|
||||
const data = safeJsonParse<{ message?: string } | null>(event.data_json, null);
|
||||
const state = event.event_type.startsWith(STATE_EVENT_PREFIX)
|
||||
? event.event_type.slice(STATE_EVENT_PREFIX.length)
|
||||
: row.state;
|
||||
return {
|
||||
timestamp: event.created_at,
|
||||
state,
|
||||
...(data?.message !== undefined ? { message: data.message } : {}),
|
||||
};
|
||||
});
|
||||
const eventRows = listA2ATaskEvents(row.id);
|
||||
|
||||
// `memory_hits` is observability, not a state transition — it is hydrated into `metadata`
|
||||
// (Fase 3, Task C1) and kept out of the timeline the drawer renders.
|
||||
const memoryHits = eventRows
|
||||
.filter((event) => event.event_type === MEMORY_HITS_EVENT_TYPE)
|
||||
.flatMap((event) => parseMemoryHits(event.data_json));
|
||||
|
||||
const events = eventRows
|
||||
.filter((event) => event.event_type !== MEMORY_HITS_EVENT_TYPE)
|
||||
.map((event) => {
|
||||
const data = safeJsonParse<{ message?: string } | null>(event.data_json, null);
|
||||
const state = event.event_type.startsWith(STATE_EVENT_PREFIX)
|
||||
? event.event_type.slice(STATE_EVENT_PREFIX.length)
|
||||
: row.state;
|
||||
return {
|
||||
timestamp: event.created_at,
|
||||
state,
|
||||
...(data?.message !== undefined ? { message: data.message } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -53,7 +97,7 @@ function reconstituteHistoricalTask(row: A2ATaskHistoryRow) {
|
||||
input,
|
||||
artifacts,
|
||||
events,
|
||||
metadata: {},
|
||||
metadata: memoryHits.length > 0 ? { memoryHits } : {},
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
expiresAt: row.updated_at,
|
||||
|
||||
@@ -259,3 +259,107 @@ test("GET /api/a2a/tasks/[id] still 404s when the task is absent from both memor
|
||||
});
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
|
||||
/**
|
||||
* Task C1 (Orchestration Canvas Fase 3, PR-C): the history fallback hydrates
|
||||
* `metadata.memoryHits` from the persisted `memory_hits` event that `executeA2ATaskWithState`
|
||||
* writes (src/lib/a2a/taskExecution.ts), so the drawer's "Memory used" section survives a task
|
||||
* leaving the in-memory TTL window. `data_json` is persisted JSON — every read is defensive:
|
||||
* a malformed payload degrades to `metadata: {}` and NEVER a 500.
|
||||
*/
|
||||
test("GET /api/a2a/tasks/[id] hydrates metadata.memoryHits from the persisted memory_hits event", async () => {
|
||||
seedRow({ id: "history-memory" });
|
||||
a2aTasksDb.appendA2ATaskEvent("history-memory", "state:submitted");
|
||||
a2aTasksDb.appendA2ATaskEvent(
|
||||
"history-memory",
|
||||
"memory_hits",
|
||||
JSON.stringify([
|
||||
{ id: "m1", key: "user.name", type: "factual", snippet: "Diego" },
|
||||
{ id: "m2", key: "user.tz", type: "factual", snippet: "UTC-3" },
|
||||
])
|
||||
);
|
||||
a2aTasksDb.appendA2ATaskEvent("history-memory", "state:completed");
|
||||
|
||||
const res = await detailRoute.GET(
|
||||
new Request("http://localhost/api/a2a/tasks/history-memory", {
|
||||
headers: AUTH_HEADERS,
|
||||
}) as never,
|
||||
{ params: Promise.resolve({ id: "history-memory" }) }
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json()) as {
|
||||
task: {
|
||||
metadata: { memoryHits?: unknown };
|
||||
events: Array<{ state: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
assert.deepEqual(body.task.metadata.memoryHits, [
|
||||
{ id: "m1", key: "user.name", type: "factual", snippet: "Diego" },
|
||||
{ id: "m2", key: "user.tz", type: "factual", snippet: "UTC-3" },
|
||||
]);
|
||||
// The memory event is not a state transition — it must never reach the timeline.
|
||||
assert.equal(body.task.events.length, 2);
|
||||
assert.deepEqual(
|
||||
body.task.events.map((e) => e.state),
|
||||
["submitted", "completed"]
|
||||
);
|
||||
});
|
||||
|
||||
test("GET /api/a2a/tasks/[id] drops malformed memoryHits entries and keeps the valid ones", async () => {
|
||||
seedRow({ id: "history-memory-partial" });
|
||||
a2aTasksDb.appendA2ATaskEvent(
|
||||
"history-memory-partial",
|
||||
"memory_hits",
|
||||
JSON.stringify([
|
||||
{ id: "ok", key: "k", type: "t", snippet: "s" },
|
||||
{ id: "no-snippet", key: "k", type: "t" },
|
||||
{ id: "object-key", key: { a: 1 }, type: "t", snippet: "s" },
|
||||
null,
|
||||
"boom",
|
||||
42,
|
||||
])
|
||||
);
|
||||
|
||||
const res = await detailRoute.GET(
|
||||
new Request("http://localhost/api/a2a/tasks/history-memory-partial", {
|
||||
headers: AUTH_HEADERS,
|
||||
}) as never,
|
||||
{ params: Promise.resolve({ id: "history-memory-partial" }) }
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json()) as { task: { metadata: { memoryHits?: unknown } } };
|
||||
assert.deepEqual(body.task.metadata.memoryHits, [
|
||||
{ id: "ok", key: "k", type: "t", snippet: "s" },
|
||||
]);
|
||||
});
|
||||
|
||||
for (const [label, dataJson] of [
|
||||
["unparseable JSON", "{not-json"],
|
||||
["a bare JSON string", JSON.stringify("boom")],
|
||||
["a JSON object instead of an array", JSON.stringify({ id: "m1" })],
|
||||
["an array whose every entry is malformed", JSON.stringify([{ id: "m1" }, null, 7])],
|
||||
["an empty array", JSON.stringify([])],
|
||||
] as const) {
|
||||
test(`GET /api/a2a/tasks/[id] answers 200 with metadata {} when memory_hits carries ${label}`, async () => {
|
||||
const id = `history-memory-${label.replace(/\W+/g, "-")}`;
|
||||
seedRow({ id });
|
||||
a2aTasksDb.appendA2ATaskEvent(id, "state:submitted");
|
||||
a2aTasksDb.appendA2ATaskEvent(id, "memory_hits", dataJson);
|
||||
|
||||
const res = await detailRoute.GET(
|
||||
new Request(`http://localhost/api/a2a/tasks/${id}`, { headers: AUTH_HEADERS }) as never,
|
||||
{ params: Promise.resolve({ id }) }
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json()) as {
|
||||
task: { metadata: Record<string, unknown>; events: Array<{ state: string }> };
|
||||
};
|
||||
assert.deepEqual(body.task.metadata, {});
|
||||
// Even a malformed memory event stays out of the timeline.
|
||||
assert.deepEqual(
|
||||
body.task.events.map((e) => e.state),
|
||||
["submitted"]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user