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:
diegosouzapw
2026-09-07 16:12:59 -03:00
parent d6f315018a
commit da8014442d
2 changed files with 164 additions and 16 deletions

View File

@@ -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,

View File

@@ -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"]
);
});
}