From da8014442debd2eca7a326472ab704aa86aefba1 Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:12:59 -0300 Subject: [PATCH] feat(api): hydrate memoryHits from the persisted history event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- src/app/api/a2a/tasks/[id]/route.ts | 76 +++++++++++++++----- tests/unit/a2a-history-route.test.ts | 104 +++++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 16 deletions(-) diff --git a/src/app/api/a2a/tasks/[id]/route.ts b/src/app/api/a2a/tasks/[id]/route.ts index 34f408fa57..8733fa1ded 100644 --- a/src/app/api/a2a/tasks/[id]/route.ts +++ b/src/app/api/a2a/tasks/[id]/route.ts @@ -20,6 +20,40 @@ function safeJsonParse(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(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)[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(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, diff --git a/tests/unit/a2a-history-route.test.ts b/tests/unit/a2a-history-route.test.ts index c44995075f..a4748bdf63 100644 --- a/tests/unit/a2a-history-route.test.ts +++ b/tests/unit/a2a-history-route.test.ts @@ -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; 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"] + ); + }); +}