diff --git a/samples/client/lit/shell/client.ts b/samples/client/lit/shell/client.ts index 165da4df4..93314f62c 100644 --- a/samples/client/lit/shell/client.ts +++ b/samples/client/lit/shell/client.ts @@ -19,6 +19,99 @@ import { A2AClient } from "@a2a-js/sdk/client"; import { v0_8 } from "@a2ui/lit"; const A2UI_MIME_TYPE = "application/json+a2ui"; +const TEXT_FALLBACK_SURFACE_ID = "@default"; +const TEXT_FALLBACK_ROOT_ID = "__a2ui_text_fallback_root"; + +function textFallbackComponentId(index: number) { + return `__a2ui_text_fallback_${index}`; +} + +export function convertA2APartsToMessages( + parts: Part[] +): v0_8.Types.ServerToClientMessage[] { + const dataMessages: v0_8.Types.ServerToClientMessage[] = []; + const textMessages: string[] = []; + + for (const part of parts) { + if (part.kind === "data") { + dataMessages.push(part.data as v0_8.Types.ServerToClientMessage); + continue; + } + + if (part.kind === "text" && part.text.trim().length > 0) { + textMessages.push(part.text); + } + } + + if (dataMessages.length > 0 || textMessages.length === 0) { + return dataMessages; + } + + if (textMessages.length === 1) { + const rootId = textFallbackComponentId(0); + return [ + { + beginRendering: { + root: rootId, + surfaceId: TEXT_FALLBACK_SURFACE_ID, + }, + }, + { + surfaceUpdate: { + surfaceId: TEXT_FALLBACK_SURFACE_ID, + components: [ + { + id: rootId, + component: { + Text: { + usageHint: "body" as const, + text: { literalString: textMessages[0] }, + }, + }, + }, + ], + }, + }, + ]; + } + + const childIds = textMessages.map((_, index) => textFallbackComponentId(index)); + + return [ + { + beginRendering: { + root: TEXT_FALLBACK_ROOT_ID, + surfaceId: TEXT_FALLBACK_SURFACE_ID, + }, + }, + { + surfaceUpdate: { + surfaceId: TEXT_FALLBACK_SURFACE_ID, + components: [ + { + id: TEXT_FALLBACK_ROOT_ID, + component: { + Column: { + children: { + explicitList: childIds, + }, + }, + }, + }, + ...textMessages.map((text, index) => ({ + id: childIds[index], + component: { + Text: { + usageHint: "body" as const, + text: { literalString: text }, + }, + }, + })), + ], + }, + }, + ]; +} export class A2UIClient { #serverUrl: string; @@ -98,13 +191,7 @@ export class A2UIClient { const result = (response as SendMessageSuccessResponse).result as Task; if (result.kind === "task" && result.status.message?.parts) { - const messages: v0_8.Types.ServerToClientMessage[] = []; - for (const part of result.status.message.parts) { - if (part.kind === 'data') { - messages.push(part.data as v0_8.Types.ServerToClientMessage); - } - } - return messages; + return convertA2APartsToMessages(result.status.message.parts); } return []; diff --git a/samples/client/lit/shell/tests/client.test.ts b/samples/client/lit/shell/tests/client.test.ts new file mode 100644 index 000000000..99baa5875 --- /dev/null +++ b/samples/client/lit/shell/tests/client.test.ts @@ -0,0 +1,85 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { v0_8 } from "@a2ui/lit"; +import { convertA2APartsToMessages } from "../client.js"; +import type { Part } from "@a2a-js/sdk"; + +test("convertA2APartsToMessages preserves A2UI data parts", () => { + const beginRendering: v0_8.Types.ServerToClientMessage = { + beginRendering: { + root: "root", + surfaceId: "@default", + }, + }; + + const messages = convertA2APartsToMessages([ + { + kind: "data", + data: beginRendering as unknown as Record, + mimeType: "application/json+a2ui", + } as Part, + { + kind: "text", + text: "This should not replace a valid A2UI response.", + } as Part, + ]); + + assert.deepEqual(messages, [beginRendering]); +}); + +test("convertA2APartsToMessages converts a text-only fallback into renderable messages", () => { + const messages = convertA2APartsToMessages([ + { + kind: "text", + text: "Oops, I couldn't find anything you requested.", + } as Part, + ]); + + assert.equal(messages.length, 2); + assert.deepEqual(messages[0], { + beginRendering: { + root: "__a2ui_text_fallback_0", + surfaceId: "@default", + }, + }); + + const processor = new v0_8.Data.A2uiMessageProcessor(); + processor.processMessages(messages); + + const surface = processor.getSurfaces().get("@default"); + assert.ok(surface); + assert.equal(surface.rootComponentId, "__a2ui_text_fallback_0"); + assert.ok(surface.components.has("__a2ui_text_fallback_0")); +}); + +test("convertA2APartsToMessages stacks multiple text fallbacks into a column", () => { + const messages = convertA2APartsToMessages([ + { kind: "text", text: "First fallback" } as Part, + { kind: "text", text: "Second fallback" } as Part, + ]); + + const processor = new v0_8.Data.A2uiMessageProcessor(); + processor.processMessages(messages); + + const surface = processor.getSurfaces().get("@default"); + assert.ok(surface); + assert.equal(surface.rootComponentId, "__a2ui_text_fallback_root"); + assert.ok(surface.components.has("__a2ui_text_fallback_0")); + assert.ok(surface.components.has("__a2ui_text_fallback_1")); +});