diff --git a/webapp/src/lib/rpc.test.ts b/webapp/src/lib/rpc.test.ts index 07c59112..9d96cfa8 100644 --- a/webapp/src/lib/rpc.test.ts +++ b/webapp/src/lib/rpc.test.ts @@ -29,6 +29,30 @@ test('rpc posts the envelope to /proxy/rpc with target + bearer headers and unwr expect(sent.id).toMatch(/^ui-\d+$/) }) +test('rpc unwraps the {items} envelope for kb.list_* results', async () => { + mockFetch(200, { id: 'x', ok: true, result: { items: [{ id: 'a' }, { id: 'b' }], _meta: { deprecation: {} } } }) + const out = await rpc<{ id: string }[]>(conn, 'kb.list_pending') + expect(out).toEqual([{ id: 'a' }, { id: 'b' }]) +}) + +test('rpc passes a bare-array kb.list_* result through unchanged (old server)', async () => { + mockFetch(200, { id: 'x', ok: true, result: [{ id: 'a' }] }) + const out = await rpc<{ id: string }[]>(conn, 'kb.list_claims') + expect(out).toEqual([{ id: 'a' }]) +}) + +test('rpc leaves kb.list_sessions {sessions} envelope untouched', async () => { + mockFetch(200, { id: 'x', ok: true, result: { sessions: [{ session_id: 's1' }] } }) + const out = await rpc<{ sessions: { session_id: string }[] }>(conn, 'kb.list_sessions') + expect(out).toEqual({ sessions: [{ session_id: 's1' }] }) +}) + +test('rpc does not unwrap items for non-list methods', async () => { + mockFetch(200, { id: 'x', ok: true, result: { items: [1, 2, 3] } }) + const out = await rpc<{ items: number[] }>(conn, 'kb.synthesize') + expect(out).toEqual({ items: [1, 2, 3] }) +}) + test('rpc omits authorization header when no token', async () => { const fn = mockFetch(200, { id: 'x', ok: true, result: {} }) await rpc({ endpoint: 'http://127.0.0.1:8731' }, 'kb.status') diff --git a/webapp/src/lib/rpc.ts b/webapp/src/lib/rpc.ts index c3ee7d00..fb73f731 100644 --- a/webapp/src/lib/rpc.ts +++ b/webapp/src/lib/rpc.ts @@ -44,7 +44,28 @@ export async function rpc( if (!body.ok || body.error) { throw new VouchRpcError(body.error?.code ?? 'unknown', body.error?.message ?? 'unknown error') } - return body.result as T + return unwrapListEnvelope(method, body.result) as T +} + +/** + * `kb.list_*` results moved from a bare array to a `{ items, _meta }` dict + * envelope (server deprecation, remove_in 1.4.0). Consumers still type these + * as flat arrays, so unwrap `items` here and keep tolerating the old shape — + * one place, so the fan-out, optimistic caches, and views are untouched. + * `kb.list_sessions` keeps its own `{ sessions }` key and has no `items`, so it + * passes through unchanged. + */ +function unwrapListEnvelope(method: string, result: T): T { + if ( + method.startsWith('kb.list_') && + result !== null && + typeof result === 'object' && + !Array.isArray(result) && + Array.isArray((result as { items?: unknown }).items) + ) { + return (result as unknown as { items: T }).items + } + return result } export async function fetchHealth(conn: VouchConnectionInfo): Promise {