diff --git a/.prettierignore b/.prettierignore index 796a04e4c9..860d0a1b29 100644 --- a/.prettierignore +++ b/.prettierignore @@ -41,6 +41,9 @@ package/src/theme/generated/ perf/gauntlet/out/ perf/gauntlet/runtime/generated/ +# A2UI's conformance cases, kept as upstream wrote them +package/__fixtures__/a2ui-conformance/ + # Workflow / tooling configs not owned by this project's Prettier .github/ .husky/ diff --git a/.yarnrc.yml b/.yarnrc.yml index e37b3de9cf..c1af242b7d 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -22,6 +22,8 @@ npmPreapprovedPackages: - '@stream-io/state-store' - '@stream-io/worker-timer' - '@stream-io/ws' + - '@stream-io/calypso' + - '@a2ui/web_core' npmPublishProvenance: true diff --git a/AGENTS.md b/AGENTS.md index 886d53c370..1cee69414d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -219,6 +219,8 @@ Native code shared by both wrappers lives in `package/shared-native/{ios,android - the main entry points must never reach `src/ai-components` or the AI-only packages (`react-syntax-highlighter`, `@khanacademy/simple-markdown`, `zod`, `victory-native`, Skia). `@stream-io/state-store` and `lodash` are shared: the main entry imports them too (the stores in `state-store/`, the Poll contexts); - the AI entry points may only reach `src/ai-components` plus an explicit allowlist (`src/native.ts`, the native shimmer). The wrappers' `ai-components/index.js` registers the native pieces the AI code needs itself and must never import the wrappers' `src/index.js`. +`AIConversation` and its list sit behind a third entry point, `ai-components/conversation` (`package/ai-components/conversation/`, `package/{native,expo}-package/ai-components/conversation/`), because `@legendapp/list/keyboard` requires `react-native-keyboard-controller` unguarded. The isolation check keeps the `ai-components` and `a2ui` entry points from reaching `src/ai-components/conversation` or `@legendapp/list/keyboard`, and lets them load keyboard-controller only through the guarded `src/ai-components/internal/keyboard.tsx`. The conversation's state layer (`AIConversationController`, its types, `useAIConversationState`) lives in `src/ai-components/conversation-state` and stays in the plain entry. + Do not add AI slots to `DEFAULT_COMPONENTS`; core `require`s the defaults, which would break isolation. Skia and `victory-native` are optional peers (native libraries must be owned by the app); the JS-only AI dependencies are regular dependencies. Native pieces live in `package/shared-native/`: the Android-only `PerfText` Fabric view (Kotlin plus a C++ shadow node in `shared-native/android/jni`, built through each wrapper's `android/src/main/jni/CMakeLists.txt`, registered with `cmakeListsPath`/`componentDescriptors` in `react-native.config.js`) and the `AIDictation` TurboModule. **The iOS dictation sources are excluded from the pod unless the app opts in** (`$StreamChatEnableDictation = true` in the Podfile, or the `stream-chat-expo` config plugin in `package/expo-package/plugin`), because referencing the Speech framework makes App Store Connect demand a speech recognition usage description from every app. Never declare `RECORD_AUDIO` in a library manifest. The dictation codegen spec is duplicated into each wrapper's `src/native/` (codegen only reads a package's own `jsSrcsDir`); the isolation check keeps the copies identical. diff --git a/examples/ExpoChatGPTSample/.env.example b/examples/ExpoChatGPTSample/.env.example new file mode 100644 index 0000000000..0a0a5fcc3a --- /dev/null +++ b/examples/ExpoChatGPTSample/.env.example @@ -0,0 +1,15 @@ +# Copy to .env.local (gitignored) and fill in. Public values only: Expo inlines every EXPO_PUBLIC_ +# variable into the app bundle. The Stream secret stays with the token backend. + +# The Stream app the Vision-Agents router runs in. +EXPO_PUBLIC_STREAM_API_KEY=YOUR_STREAM_API_KEY + +# The router and its token backend (Vision-Agents' `go run ./backend`). On an Android emulator, +# `adb reverse tcp:8080 tcp:8080 && adb reverse tcp:8098 tcp:8098` makes localhost reach them. +EXPO_PUBLIC_AGENT_ROUTER_URL=http://localhost:8080 +EXPO_PUBLIC_AGENT_TOKEN_URL=http://localhost:8098 + +# The customer and agent `go run ./configure` stored, and who this app is. +EXPO_PUBLIC_AGENT_CUSTOMER_ID=examples +EXPO_PUBLIC_AGENT_NAME=swift_assistant +EXPO_PUBLIC_AGENT_USER_ID=rn-ai-sample-user diff --git a/examples/ExpoChatGPTSample/README.md b/examples/ExpoChatGPTSample/README.md index 7f68eeacef..eb804dbc29 100644 --- a/examples/ExpoChatGPTSample/README.md +++ b/examples/ExpoChatGPTSample/README.md @@ -9,17 +9,31 @@ Ported from the `ExpoChatGPTSample` of the deprecated `@stream-io/chat-react-nat ## How it works -- The drawer lists the user's conversations; `+` starts a new one. -- A new conversation is created on its first message. Its title is summarized from that message by the sample backend. -- Before sending, the app asks the sample backend to add an AI agent to the channel (`http/requests.ts`). The agent - replies with streamed `ai_generated` messages, rendered by `StreamingMessageView`. - -The Stream app, user and backend in `chatConfig.ts` and `http/requests.ts` are Stream's public demo setup. To use your -own, replace them and point `http/requests.ts` at your own AI agent server. +The agent runs on a [Vision-Agents](https://github.com/GetStream/Vision-Agents) router, which the app talks to through +[`@stream-io/calypso`](https://www.npmjs.com/package/@stream-io/calypso) (`agent/`). + +- Each conversation is a router session, kept in an `agent` channel. The drawer lists the user's `agent` channels. `+` + starts a new session, and one that is never asked anything is deleted when you leave it. +- The conversation is `AIConversation`, fed from `agent/agentChat.ts`: a small store that watches the channel, copies + its messages on every `message.*` event, loads older pages, and keeps each reply's full reasoning from its live + updates. There's no ``, so the store also watches the channel again after `connection.recovered`, to pick up + what was written while the connection was down. +- Asking goes through the session, not the chat composer. The router writes the question and the streamed reply into + the channel, the reply's steps as `ai_reasoning` and `ai_tool_call` attachments, and titles the conversation itself. + A plain question goes to `respond` with this install's id, so the agent can call the phone's tools + (`agent/deviceTools.ts`). A question with photos or "Think harder" goes to `responses.create`, which takes neither. +- The keyboard is handled by the AI components through `react-native-keyboard-controller`; the app only wraps itself + in `KeyboardProvider`. The starters and the voice button sit in the composer's `header`, so they ride the keyboard. ## Running -From the repository root: +1. Run a Vision-Agents router locally, with its `swift_assistant` agent and token backend, as in + [`examples/voice_agents/swift_assistant`](https://github.com/GetStream/Vision-Agents/tree/accelerate/examples/voice_agents/swift_assistant): + `docker compose up` (with `ROUTER_AUTH_MODE=proxy`), `go run ./configure`, then `go run ./backend`. The chat model + is Grok, so the router needs `XAI_API_KEY`. +2. Copy `.env.example` to `.env.local` and set `EXPO_PUBLIC_STREAM_API_KEY` to the Stream app the router runs in. +3. On an Android emulator, forward the ports: `adb reverse tcp:8080 tcp:8080 && adb reverse tcp:8098 tcp:8098`. +4. From the repository root: ```bash yarn install @@ -28,3 +42,6 @@ yarn workspace expochatgptsample ios # or: android `ios` and `android` sync the SDK's shared native code, run `expo prebuild` and build the app. The native `ios` and `android` folders are generated and not committed. + +On iOS, if the app crashes at launch in `dyld` with a missing symbol, reinstall the pods without Expo's +precompiled modules: `cd ios && EXPO_USE_PRECOMPILED_MODULES=0 pod install`, then build again. diff --git a/examples/ExpoChatGPTSample/agent/UIComponents.json b/examples/ExpoChatGPTSample/agent/UIComponents.json new file mode 100644 index 0000000000..869bd82218 --- /dev/null +++ b/examples/ExpoChatGPTSample/agent/UIComponents.json @@ -0,0 +1,76 @@ +[ + { + "name": "place_card", + "description": "One place of a plan, with its photo and a short summary, which the phone loads from Wikipedia, so it needs no image URL. Use one per sight, beach, monastery, village or day trip.", + "parameters": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "The place's exact English Wikipedia article title, such as \"Church of St. John at Kaneo\"" + }, + "note": { + "type": "string", + "description": "One short line about it for this plan, such as \"Best at sunset\" or \"2 hours\"" + } + }, + "required": ["title"], + "x-order": ["title", "note"] + }, + "events": [ + { + "name": "ask_about", + "description": "Tapping the card asks you about the place." + } + ] + }, + { + "name": "trip_map", + "description": "A map of a plan with a pin for each place, drawn on the phone. Use one near the top of every trip plan.", + "parameters": { + "type": "object", + "properties": { + "places": { + "type": "array", + "description": "Every place of the plan, with its coordinates", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "latitude": { "type": "number" }, + "longitude": { "type": "number" } + }, + "required": ["name", "latitude", "longitude"], + "x-order": ["name", "latitude", "longitude"] + } + } + }, + "required": ["places"], + "x-order": ["places"] + }, + "events": [ + { + "name": "select_place", + "description": "Tapping a pin asks you about that place." + } + ] + }, + { + "name": "calendar_check", + "description": "The person's own calendar for the days of a plan, read on the phone. You never see it: use it whenever a plan has dates, so they can see what the trip would clash with.", + "parameters": { + "type": "object", + "properties": { + "start": { + "type": "string", + "format": "date", + "description": "The first day, as YYYY-MM-DD" + }, + "end": { "type": "string", "format": "date", "description": "The last day, as YYYY-MM-DD" } + }, + "required": ["start", "end"], + "x-order": ["start", "end"] + }, + "events": [] + } +] diff --git a/examples/ExpoChatGPTSample/agent/agentChat.ts b/examples/ExpoChatGPTSample/agent/agentChat.ts new file mode 100644 index 0000000000..78aec9c6a5 --- /dev/null +++ b/examples/ExpoChatGPTSample/agent/agentChat.ts @@ -0,0 +1,154 @@ +import { LiveReasoning, type Session } from '@stream-io/calypso'; +import { StateStore } from '@stream-io/state-store'; +import type { Channel, Event, LocalMessage } from 'stream-chat'; + +/** How many messages are read at a time. */ +const PAGE_SIZE = 30; + +export type AgentChatState = { + /** + * The channel the router keeps the conversation in, once it is followed. Its `messagePaginator` + * holds the messages. + */ + channel: Channel | undefined; + /** The session the conversation is held in, once something here needed it. */ + session: Session | undefined; +}; + +/** + * Opens a conversation's session: carries on `sessionId`, or starts a new conversation without one. + */ +export type OpenSession = (sessionId: string | undefined) => Promise; + +/** + * A written conversation as the AI components want it: the channel the router keeps it in, and the + * whole of each reply's thinking. + * + * The router writes everything there. Asking goes through the session, which records the question + * in the channel and answers it there: the reply's text, and its steps (each round of reasoning and + * each tool call) as attachments. + * + * The channel and the session open apart. Reading a conversation needs only its channel, which + * Stream serves in a fraction of the time the session takes to come back, and carrying a session + * on marks the conversation active, which moves it up the router's list. So the session opens only + * once something needs it, such as a question, and a new conversation has none until it is asked + * something: the router never lists one that was only looked at. + */ +export class AgentChat { + /** Each reply's thinking in full, from its live updates. A stored step only keeps its opening. */ + readonly reasoning = new LiveReasoning(); + + /** Whether the person asked something here, which may not have arrived in `messages` yet. */ + wasAsked = false; + + readonly state = new StateStore({ channel: undefined, session: undefined }); + + private stops: (() => void)[] = []; + private opening: Promise | undefined; + private closed = false; + + /** + * @param key Tells this conversation apart from the one shown before it, such as for a React key. + * @param sessionId The conversation's session, or `undefined` for a new conversation. + */ + constructor( + readonly key: string, + private readonly sessionId: string | undefined, + private readonly openSession: OpenSession, + ) {} + + /** The conversation's session id: the one it was opened with, or the one asking created. */ + get id() { + return this.state.getLatestValue().session?.id ?? this.sessionId; + } + + /** Whether this is a new conversation, which has no messages to wait for. */ + get isNew() { + return this.sessionId === undefined; + } + + /** The messages loaded, oldest first. */ + get messages(): readonly LocalMessage[] { + return this.state.getLatestValue().channel?.messagePaginator.state.getLatestValue().items ?? []; + } + + /** + * Watches the channel and loads its latest messages, as the chat SDK's `` does. While it + * is followed the channel is active, so the client reloads it after a reconnect. + */ + async follow(channel: Channel) { + if (this.state.getLatestValue().channel || this.closed) return; + // Every event rather than the paginator's messages: each live update carries its own window of + // the reply's thinking, and folding them into one would lose all but the last. + const subscription = channel.on((event: Event) => { + if (event.type === 'message.updated' && event.message) this.reasoning.read(event.message); + }); + this.stops.push(() => subscription.unsubscribe(), channel.activate()); + this.state.partialNext({ channel }); + + const paginator = channel.messagePaginator; + paginator.pageSize = PAGE_SIZE; + await channel.watch(); + if (!paginator.state.getLatestValue().items?.length) await paginator.reload(); + } + + /** + * The conversation's session, opened the first time it is needed. A new conversation is created + * then, and its channel followed. Rejects when it can't open, to be asked again. + */ + session(): Promise { + this.opening ??= this.open().catch((error: unknown) => { + this.opening = undefined; + throw error; + }); + return this.opening; + } + + /** + * Deletes the session a new conversation was given when the question that created it failed + * before anything was written, so the router doesn't list an empty conversation. The next + * question creates another. + */ + async discardUnusedSession() { + const { session } = this.state.getLatestValue(); + if (!this.isNew || !session || this.messages.length > 0) return; + this.opening = undefined; + this.state.partialNext({ session: undefined }); + await session.delete().catch(() => {}); + } + + /** Loads the messages before the oldest one loaded. Throws when it can't, to be asked again. */ + loadOlderMessages = async () => { + const paginator = this.state.getLatestValue().channel?.messagePaginator; + if (!paginator?.hasMoreTail) return; + await paginator.toTail(); + // The paginator keeps a failure rather than throwing it. + if (paginator.lastQueryError) throw paginator.lastQueryError; + }; + + /** Stops following the channel, and lets the session go. */ + async close() { + this.closed = true; + for (const stop of this.stops.splice(0)) stop(); + const opened = await this.opening?.catch(() => undefined); + await opened?.close(); + } + + private async open() { + const session = await this.openSession(this.sessionId); + if (this.closed) { + // The person moved on while it opened. A conversation created for them was never used. + await (this.isNew ? session.delete() : session.close()).catch(() => {}); + throw new Error('The conversation was closed'); + } + if (!this.state.getLatestValue().channel) { + const { channel } = await session.chat(); + await this.follow(channel as Channel); + } + this.state.partialNext({ session }); + return session; + } +} + +/** A reply the agent wrote. */ +export const isAgentReply = (message: LocalMessage) => message.custom?.ai_generated === true; diff --git a/examples/ExpoChatGPTSample/agent/agentTransport.ts b/examples/ExpoChatGPTSample/agent/agentTransport.ts new file mode 100644 index 0000000000..f694725fe6 --- /dev/null +++ b/examples/ExpoChatGPTSample/agent/agentTransport.ts @@ -0,0 +1,123 @@ +import type { ConversationSnapshot, Session, UIAction } from '@stream-io/calypso'; +import type { Channel } from 'stream-chat'; +import { + type AIAgentActivity, + AIAgentActivityStates, + type AIConversationTransport, + createFromStreamMessage, +} from 'stream-chat-expo/ai-components'; + +import type { AgentChat } from './agentChat'; +import { toDataUri } from './photos'; + +import { THINK_HARDER } from '../chatOptions'; + +/** What the person did in a reply's UI, which a request carries in its `custom` data. */ +export type AgentRequestCustom = { uiAction?: UIAction }; + +export type AgentTransportOptions = { + /** Asks the agent in `session`, with photos as data URIs, a reasoning effort and a UI action. */ + respond: ( + session: Session, + text: string, + options: { images: { url: string }[]; reasoningEffort?: string; uiAction?: UIAction }, + ) => Promise; + /** Called once asking created the conversation, so the drawer lists it. */ + onCreated: () => void; +}; + +// What the agent is doing, from the session's socket: thinking while it responds, and the skills +// it runs while it works. +const activityOf = (snapshot: ConversationSnapshot): AIAgentActivity | undefined => { + if (snapshot.kind === 'responding') return { state: AIAgentActivityStates.thinking }; + if (snapshot.kind === 'working') { + return { + state: AIAgentActivityStates.checkingExternalSources, + text: `Working on ${snapshot.skills.map((skill) => skill.replace(/_/g, ' ')).join(' and ')}`, + }; + } + return undefined; +}; + +/** + * The router's conversation as an `AIConversationController` follows it: the messages of the + * channel `AgentChat` reads, and what the agent is doing from the session's socket. Asking goes + * through the session, which records the question in the channel and answers it there. The + * router has no regenerate or edit of its own: those ask again, as a new question. + */ +export const createAgentTransport = ( + chat: AgentChat, + { onCreated, respond }: AgentTransportOptions, +): AIConversationTransport => ({ + connect: (feed) => { + // The same message object for each Stream message that didn't change, so only the messages + // an update touched render again. The live thinking is read through `chat.reasoning`. + const fromStreamMessage = createFromStreamMessage({ + reasoningText: (messageId, stepId) => chat.reasoning.get(messageId, stepId), + }); + let followedChannel: Channel | undefined; + let stopFollowingMessages: (() => void) | undefined; + let followedSession: Session | undefined; + let stopFollowingSession: (() => void) | undefined; + const unsubscribe = chat.state.subscribe(({ channel, session }) => { + if (channel !== followedChannel) { + stopFollowingMessages?.(); + followedChannel = channel; + // The messages as the channel's paginator holds them, oldest first: a new array on each + // change, in which only the messages that changed are new objects. + stopFollowingMessages = channel?.messagePaginator.state.subscribeWithSelector( + ({ hasMoreTail, items }) => ({ hasMoreTail, items }), + ({ hasMoreTail, items }) => { + if (!items) return; + feed.updateMessages(items.map(fromStreamMessage)); + feed.updateHasOlderMessages(hasMoreTail); + }, + ); + } + if (session === followedSession) return; + stopFollowingSession?.(); + followedSession = session; + stopFollowingSession = session?.state.subscribe(() => + feed.updateActivity(activityOf(session.state.get())), + ); + if (session) feed.updateActivity(activityOf(session.state.get())); + }); + return () => { + unsubscribe(); + stopFollowingMessages?.(); + stopFollowingSession?.(); + }; + }, + loadOlderMessages: () => chat.loadOlderMessages(), + send: async ({ attachments, chatOption, custom, text }) => { + // From now on the conversation is used, even before Stream's copy of the question arrives. + chat.wasAsked = true; + const photos = attachments + .filter((asset) => !asset.type || asset.type.startsWith('image')) + .map((asset) => asset.uri); + try { + // A new conversation is created here, with its first question. + const wasNew = chat.id === undefined; + const [session, images] = await Promise.all([ + chat.session(), + Promise.all(photos.map(async (uri) => ({ url: await toDataUri(uri) }))), + ]); + if (wasNew) onCreated(); + await respond(session, text, { + images, + reasoningEffort: chatOption?.id === THINK_HARDER.id ? 'high' : undefined, + uiAction: (custom as AgentRequestCustom | undefined)?.uiAction, + }); + } catch (error) { + // A conversation created for this question, which it never reached, isn't left listed. + await chat.discardUnusedSession(); + throw error; + } + }, + // A reply that was being written when the conversation was opened stops through its session, + // which opens for it. + stop: async () => { + const session = await chat.session(); + await session.interrupt(); + }, +}); diff --git a/examples/ExpoChatGPTSample/agent/agents.ts b/examples/ExpoChatGPTSample/agent/agents.ts new file mode 100644 index 0000000000..b7b6c55ea9 --- /dev/null +++ b/examples/ExpoChatGPTSample/agent/agents.ts @@ -0,0 +1,32 @@ +import { Client } from '@stream-io/calypso'; +// Lets `session.chat()` open the conversation's channel on the app's Stream Chat client. +import '@stream-io/calypso/chat'; + +import { AgentConfig } from './config'; + +/** The router, as this app's user. */ +export const agents = new Client({ + apiKey: AgentConfig.streamApiKey, + customerId: AgentConfig.customerId, + url: AgentConfig.routerUrl, +}); + +/** The agent's conversations: opening a new one and carrying an old one on. */ +export const agentSessions = () => agents.agent(AgentConfig.agentName).sessions; + +/** Asks the token backend for this user's Stream token, which both the chat and the router take. */ +export const streamToken = async (): Promise => { + const response = await fetch(`${AgentConfig.tokenUrl}/stream-token`, { + body: JSON.stringify({ user_id: AgentConfig.user.id }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + if (!response.ok) { + throw new Error(`The token backend answered ${response.status}: is \`go run ./backend\` up?`); + } + const { token } = (await response.json()) as { token?: string }; + if (!token) { + throw new Error('The token backend answered without a token.'); + } + return token; +}; diff --git a/examples/ExpoChatGPTSample/agent/config.ts b/examples/ExpoChatGPTSample/agent/config.ts new file mode 100644 index 0000000000..772084b3c6 --- /dev/null +++ b/examples/ExpoChatGPTSample/agent/config.ts @@ -0,0 +1,32 @@ +/** + * Where the Vision-Agents router and its token backend are, and who this app is. Set in + * `.env.local`, which `.env.example` describes. Only public values belong there: Expo inlines every + * `EXPO_PUBLIC_` variable into the bundle, so the Stream secret stays with the token backend. + */ + +const streamApiKey = process.env.EXPO_PUBLIC_STREAM_API_KEY; + +if (!streamApiKey) { + throw new Error( + 'EXPO_PUBLIC_STREAM_API_KEY is not set: copy .env.example to .env.local and fill it in.', + ); +} + +export const AgentConfig = { + /** The Stream app the router runs in. Its channels hold the conversations. */ + streamApiKey, + /** The router. On an Android emulator, `adb reverse tcp:8080 tcp:8080` makes localhost reach it. */ + routerUrl: process.env.EXPO_PUBLIC_AGENT_ROUTER_URL || 'http://localhost:8080', + /** Mints this user's Stream token (Vision-Agents' `go run ./backend`). */ + tokenUrl: process.env.EXPO_PUBLIC_AGENT_TOKEN_URL || 'http://localhost:8098', + /** The customer `go run ./configure` stored the agent under. */ + customerId: process.env.EXPO_PUBLIC_AGENT_CUSTOMER_ID || 'examples', + /** The agent, by the name in its `agent.yaml`. */ + agentName: process.env.EXPO_PUBLIC_AGENT_NAME || 'swift_assistant', + /** A model that streams its reasoning, so replies have reasoning steps. */ + chatModel: 'xai/grok-4.7', + user: { + id: process.env.EXPO_PUBLIC_AGENT_USER_ID || 'rn-ai-sample-user', + name: 'Alex', + }, +} as const; diff --git a/examples/ExpoChatGPTSample/agent/deviceTools.ts b/examples/ExpoChatGPTSample/agent/deviceTools.ts new file mode 100644 index 0000000000..205bbe8890 --- /dev/null +++ b/examples/ExpoChatGPTSample/agent/deviceTools.ts @@ -0,0 +1,129 @@ +import { Tools } from '@stream-io/calypso'; + +import { + type AIClientTool, + type AIClientToolResult, + AIClientToolRunner, + type AIToolCallPart, + completedToolResult, +} from 'stream-chat-expo/ai-components'; + +/** + * What only this phone knows: its date, clock, time zone and language. The same tool, by the same + * name, as the iOS, Android and web assistants, whose instructions call it before a plan with + * dates. + */ +const localContext: AIClientTool = { + definition: { + annotations: { readOnlyHint: true }, + description: + "The person's current date, local time, timezone and language. Call it before answering anything that depends on today or the time.", + inputSchema: { properties: {}, type: 'object' }, + name: 'get_local_context', + }, + run: () => { + const now = new Date(); + const { locale, timeZone } = Intl.DateTimeFormat().resolvedOptions(); + return completedToolResult( + { language: locale, local: now.toString(), timeZone, utc: now.toISOString() }, + 'Shared the local time', + ); + }, +}; + +const DEVICE_TOOLS = [localContext]; + +// What a call says it is doing on the reply's step, by tool. +const DISPLAY_TITLES: Record = { get_local_context: "Checking your phone's time" }; + +/** + * The results the runner reported, by call, for the session's socket to answer with. A call can + * reach the socket before its step shows it waiting for this phone, or after. + */ +class Reports { + private readonly reported = new Map(); + private readonly waiting = new Map void>(); + + report(callId: string, result: AIClientToolResult) { + const waiting = this.waiting.get(callId); + this.waiting.delete(callId); + if (waiting) waiting(result); + else this.reported.set(callId, result); + } + + wait(callId: string, signal: AbortSignal) { + return new Promise((resolve, reject) => { + const reported = this.reported.get(callId); + this.reported.delete(callId); + if (reported) return resolve(reported); + this.waiting.set(callId, resolve); + signal.addEventListener('abort', () => { + this.waiting.delete(callId); + reject(signal.reason); + }); + }); + } +} + +/** + * The phone's tools, run by the AI components' `AIClientToolRunner` from each reply's steps, as + * an app whose backend holds the session would run them. + * + * Here the phone holds the session too. The router sends it each call over the session's socket + * and, for a question that named this install, shows the call on the reply as waiting for it. The + * runner runs it from the reply, and the socket answers the router with what the runner reported. + * A call no step addresses to this phone, from a question that couldn't name the install, is run + * by the socket itself. + */ +export const createDeviceTools = ({ clientId, userId }: { clientId: string; userId: string }) => { + const reports = new Reports(); + const runner = new AIClientToolRunner({ clientId, tools: DEVICE_TOOLS, userId }); + + const runUnaddressed = async (name: string, call: AIToolCallPart) => { + const tool = DEVICE_TOOLS.find(({ definition }) => definition.name === name); + try { + return (await tool?.run(call)) ?? { failure: `This phone has no ${name}` }; + } catch { + return { failure: "The tool couldn't run" }; + } + }; + + const deviceTools = { + /** Whether the latest question named this install, so its calls wait for this phone. */ + addressed: false, + runner, + /** Hands the runner's result to the session's socket. */ + send: (callId: string, result: AIClientToolResult) => { + reports.report(callId, result); + return Promise.resolve(); + }, + /** How the router is told of the tools: declared on the session, with the socket's answer. */ + sessionTools: () => { + const tools = new Tools(); + for (const { description, name, parameters } of runner.registrations) { + tools.register({ + description, + displayTitle: DISPLAY_TITLES[name], + executor: 'client', + name, + parameters, + run: async (input, signal, call) => { + const { failure, output } = deviceTools.addressed + ? await reports.wait(call.id, signal) + : await runUnaddressed(name, { + arguments: input, + executor: 'client', + id: call.id, + name, + status: 'running', + }); + if (failure) throw new Error(failure); + return output; + }, + }); + } + return tools; + }, + }; + return deviceTools; +}; diff --git a/examples/ExpoChatGPTSample/agent/photos.ts b/examples/ExpoChatGPTSample/agent/photos.ts new file mode 100644 index 0000000000..3dc3d8d0bf --- /dev/null +++ b/examples/ExpoChatGPTSample/agent/photos.ts @@ -0,0 +1,14 @@ +import { manipulateAsync, SaveFormat } from 'expo-image-manipulator'; + +// The router takes photos inline, so they are shrunk first. +const MAX_IMAGE_WIDTH = 1280; + +/** A photo the person picked, as the data URI the router takes. */ +export const toDataUri = async (uri: string) => { + const { base64 } = await manipulateAsync(uri, [{ resize: { width: MAX_IMAGE_WIDTH } }], { + base64: true, + compress: 0.7, + format: SaveFormat.JPEG, + }); + return `data:image/jpeg;base64,${base64}`; +}; diff --git a/examples/ExpoChatGPTSample/agent/polyfills.ts b/examples/ExpoChatGPTSample/agent/polyfills.ts new file mode 100644 index 0000000000..988a998f4f --- /dev/null +++ b/examples/ExpoChatGPTSample/agent/polyfills.ts @@ -0,0 +1,53 @@ +// Hermes has no `crypto.randomUUID`. Ported from stream-chat-js's `generateUUIDv4`. +/* eslint-disable no-bitwise -- RFC 4122 version and variant bits */ + +type CryptoLike = { + getRandomValues?: (bytes: Uint8Array) => Uint8Array; + randomUUID?: () => string; +}; + +const globalWithCrypto = globalThis as { crypto?: CryptoLike }; + +const getRandomValuesWithMathRandom = (bytes: Uint8Array) => { + const max = Math.pow(2, (8 * bytes.byteLength) / bytes.length); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Math.random() * max; + } + return bytes; +}; + +const hex = (bytes: Uint8Array) => { + let s = ''; + for (let i = 0; i < bytes.length; i++) { + s += bytes[i].toString(16).padStart(2, '0'); + } + return s; +}; + +// https://tools.ietf.org/html/rfc4122 +const generateUUIDv4 = () => { + const getRandomValues = + globalWithCrypto.crypto?.getRandomValues?.bind(globalWithCrypto.crypto) ?? + getRandomValuesWithMathRandom; + const bytes = getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version + bytes[8] = (bytes[8] & 0xbf) | 0x80; // variant + + return ( + hex(bytes.subarray(0, 4)) + + '-' + + hex(bytes.subarray(4, 6)) + + '-' + + hex(bytes.subarray(6, 8)) + + '-' + + hex(bytes.subarray(8, 10)) + + '-' + + hex(bytes.subarray(10, 16)) + ); +}; + +if (typeof globalWithCrypto.crypto?.randomUUID !== 'function') { + globalWithCrypto.crypto = Object.assign(globalWithCrypto.crypto ?? {}, { + randomUUID: generateUUIDv4, + }); +} diff --git a/examples/ExpoChatGPTSample/agent/useAgentMessages.ts b/examples/ExpoChatGPTSample/agent/useAgentMessages.ts new file mode 100644 index 0000000000..9de35641a3 --- /dev/null +++ b/examples/ExpoChatGPTSample/agent/useAgentMessages.ts @@ -0,0 +1,30 @@ +import type { MessagePaginatorState } from 'stream-chat'; +import { useStateStore } from 'stream-chat-expo'; + +import type { AgentChat, AgentChatState } from './agentChat'; + +const channelSelector = ({ channel }: AgentChatState) => ({ channel }); + +/** + * Selects from the messages of `chat`'s channel, as its paginator holds them. `undefined` until the + * channel is followed. `selector` follows `useStateStore`'s rules: keep it stable. + */ +export const useAgentMessages = >>( + chat: AgentChat | undefined, + selector: (state: MessagePaginatorState) => T, +) => { + const channel = useStateStore(chat?.state, channelSelector)?.channel; + return useStateStore(channel?.messagePaginator.state, selector); +}; + +const loadedSelector = ({ items }: MessagePaginatorState) => ({ + isEmpty: !items?.length, + isLoaded: items !== undefined, +}); + +/** + * Whether the conversation's first page is in, and whether it holds no messages. A new + * conversation has none to wait for. + */ +export const useAgentChatLoaded = (chat: AgentChat) => + useAgentMessages(chat, loadedSelector) ?? { isEmpty: chat.isNew, isLoaded: chat.isNew }; diff --git a/examples/ExpoChatGPTSample/app.json b/examples/ExpoChatGPTSample/app.json index 7310f41c67..6607bf3c99 100644 --- a/examples/ExpoChatGPTSample/app.json +++ b/examples/ExpoChatGPTSample/app.json @@ -2,7 +2,7 @@ "expo": { "name": "ExpoChatGPTSample", "slug": "ExpoChatGPTSample", - "scheme": "expochatgptsample", + "scheme": ["expochatgptsample", "io.getstream.expochatgptsample"], "ios": { "bundleIdentifier": "io.getstream.expochatgptsample", "appleTeamId": "EHV7XZLAHA" diff --git a/examples/ExpoChatGPTSample/app/+native-intent.tsx b/examples/ExpoChatGPTSample/app/+native-intent.tsx new file mode 100644 index 0000000000..a54ac3b988 --- /dev/null +++ b/examples/ExpoChatGPTSample/app/+native-intent.tsx @@ -0,0 +1,5 @@ +// A sign-in opened in a Custom Tab comes back to the app on its scheme (`io.getstream.expochatgptsample:// +// sign-in?…`), which the SDK's sign-in reads itself. Stay on the screen it was opened from rather +// than routing to a page that doesn't exist. +export const redirectSystemPath = ({ path }: { initial: boolean; path: string | null }) => + path?.includes('sign-in') ? null : path; diff --git a/examples/ExpoChatGPTSample/app/_layout.tsx b/examples/ExpoChatGPTSample/app/_layout.tsx index f08b3c34d6..dc55854fa6 100644 --- a/examples/ExpoChatGPTSample/app/_layout.tsx +++ b/examples/ExpoChatGPTSample/app/_layout.tsx @@ -1,90 +1,58 @@ -import React, { useEffect } from 'react'; +import '../agent/polyfills'; + +import React from 'react'; import { StyleSheet } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { KeyboardProvider } from 'react-native-keyboard-controller'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { Drawer } from 'expo-router/drawer'; -import type { LocalMessage, OperationRequestFn, StreamChat } from 'stream-chat'; +import type { LocalMessage } from 'stream-chat'; import { Chat, OverlayProvider, useCreateChatClient } from 'stream-chat-expo'; import { StreamTheme } from 'stream-chat-expo/ai-components'; -import { chatApiKey, chatUserId, chatUserName, chatUserToken } from '../chatConfig'; +import { streamToken } from '../agent/agents'; +import { AgentConfig } from '../agent/config'; import { AppProvider } from '../contexts/AppContext'; -import { startAI, summarize } from '../http/requests'; import { MenuDrawer } from '../screens/MenuDrawer'; const isMessageAIGenerated = (message: LocalMessage) => !!message.custom?.ai_generated; -// Creates the channel on its first message, names it after a summary of that message, and makes -// sure the AI bot is watching before the message goes out. -const createSendMessageRequest = - (client: StreamChat): OperationRequestFn<'send'> => - async (params, defaultRequest) => { - const { localMessage } = params; - const [type, id] = localMessage.cid.split(':'); - const channel = client.channel(type, id); - - if (!channel.initialized) { - await channel.watch(); - summarize(localMessage.text ?? '').then((response) => { - // `post` resolves to undefined when the request fails; the channel then keeps its id. - const summary = (response as { summary?: string } | undefined)?.summary; - if (summary) { - channel.updatePartial({ set: { name: summary } }); - } - }); - } - - if ( - !Object.keys(channel.state.watchers).some((watcher) => watcher.startsWith('ai-bot')) && - channel.id - ) { - await startAI(channel.id); - } - - return defaultRequest(params); - }; - export default function Layout() { const chatClient = useCreateChatClient({ - apiKey: chatApiKey, - tokenOrProvider: chatUserToken, - userData: { id: chatUserId, name: chatUserName }, + apiKey: AgentConfig.streamApiKey, + tokenOrProvider: streamToken, + userData: AgentConfig.user, }); - useEffect(() => { - if (!chatClient) { - return; - } - chatClient.config.set({ - channel: { requestHandlers: { sendMessageRequest: createSendMessageRequest(chatClient) } }, - messageComposer: { attachments: { pendingUploadsEnabled: true } }, - }); - }, [chatClient]); - if (!chatClient) { return null; } return ( - - - - - - - - - - - - - + + + + + + + + + + + + + + + ); } diff --git a/examples/ExpoChatGPTSample/bottomSheetOptions.tsx b/examples/ExpoChatGPTSample/bottomSheetOptions.tsx deleted file mode 100644 index b83226ad5d..0000000000 --- a/examples/ExpoChatGPTSample/bottomSheetOptions.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import { Alert } from 'react-native'; - -import { Copy, Edit, FilePickerIcon, Flag, IconProps, Picture } from 'stream-chat-expo'; - -// The sheet renders each icon without props, and some SDK icons (e.g. `Flag`, `Copy`) only draw -// with an explicit stroke, so every icon gets the same colour here. -const withStroke = (Icon: React.ComponentType) => { - const StrokedIcon = () => ; - StrokedIcon.displayName = `Stroked(${Icon.displayName ?? Icon.name})`; - return StrokedIcon; -}; - -export const bottomSheetOptions = [ - { - title: 'Create Image', - subtitle: 'Visualize anything', - action: () => Alert.alert('Pressed on Create Image !'), - Icon: withStroke(Picture), - }, - { - title: 'Thinking', - subtitle: 'Think longer for better answers', - action: () => Alert.alert('Pressed on Thinking !'), - Icon: withStroke(Flag), - }, - { - title: 'Deep research', - subtitle: 'Get a detailed report', - action: () => Alert.alert('Pressed on Deep research !'), - Icon: withStroke(FilePickerIcon), - }, - { - title: 'Web search', - subtitle: 'Find real-time news and info', - action: () => Alert.alert('Pressed on Web search !'), - Icon: withStroke(Copy), - }, - { - title: 'Study and learn', - subtitle: 'Learn a new concept', - action: () => Alert.alert('Pressed on Study and learn !'), - Icon: withStroke(Edit), - }, -]; diff --git a/examples/ExpoChatGPTSample/chatConfig.ts b/examples/ExpoChatGPTSample/chatConfig.ts deleted file mode 100644 index e385f5f427..0000000000 --- a/examples/ExpoChatGPTSample/chatConfig.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const chatApiKey = 'zcgvnykxsfm8'; -export const chatUserId = 'rn-ai-test-user-3'; -export const chatUserToken = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoicm4tYWktdGVzdC11c2VyLTMifQ.kOg3zLkzHc-b_5IyckQr-RixbebN-GDs7N3c570oA5A'; -export const chatUserName = 'Bob'; diff --git a/examples/ExpoChatGPTSample/chatOptions.ts b/examples/ExpoChatGPTSample/chatOptions.ts new file mode 100644 index 0000000000..03943899c8 --- /dev/null +++ b/examples/ExpoChatGPTSample/chatOptions.ts @@ -0,0 +1,19 @@ +import type { AIChatOption } from 'stream-chat-expo/ai-components'; + +/** Asks with more time to reason. */ +export const THINK_HARDER: AIChatOption = { + description: 'More time to reason before answering', + id: 'think_harder', + shortTitle: 'Think', + title: 'Think harder', +}; + +export const chatOptions = [THINK_HARDER]; + +/** Starters for an empty conversation. */ +export const STARTERS = [ + "What's on my calendar today?", + 'Remind me to call mum at 6pm', + 'Plan a weekend in Lisbon', + 'What should I cook tonight?', +]; diff --git a/examples/ExpoChatGPTSample/components/TripComponents.tsx b/examples/ExpoChatGPTSample/components/TripComponents.tsx new file mode 100644 index 0000000000..1d62b0807a --- /dev/null +++ b/examples/ExpoChatGPTSample/components/TripComponents.tsx @@ -0,0 +1,165 @@ +import { useEffect, useState } from 'react'; +import { Image, Pressable, StyleSheet, Text, View } from 'react-native'; + +import { + type AIUIComponent, + type AIUIComponentDefinition, + defineAIUIComponent, + parseAIUIComponentDefinition, +} from 'stream-chat-expo/ai-components'; + +import uiComponents from '../agent/UIComponents.json'; + +// The trip planner's components, as the Swift, Android and web assistants describe them, from the +// same `UIComponents.json` the agent's instructions are written from. +const definitionOf = (name: string): AIUIComponentDefinition => { + const definition = parseAIUIComponentDefinition( + uiComponents.find((component) => component.name === name), + ); + if (!definition) throw new Error(`UIComponents.json has no ${name}`); + return definition; +}; + +type Summary = { extract?: string; thumbnail?: { source: string } }; + +// A place's photo and first lines from Wikipedia, loaded on the phone, so the agent writes no +// image links. +const useWikipediaSummary = (title: string) => { + const [summary, setSummary] = useState(); + useEffect(() => { + const controller = new AbortController(); + fetch( + `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title.replace(/ /g, '_'))}`, + { signal: controller.signal }, + ) + .then((response) => (response.ok ? response.json() : undefined)) + .then((json: Summary | undefined) => setSummary(json)) + .catch(() => undefined); + return () => controller.abort(); + }, [title]); + return summary; +}; + +const PlaceCard = defineAIUIComponent<{ title: string; note?: string }>( + definitionOf('place_card'), + ({ args, context }) => { + const summary = useWikipediaSummary(args.title); + return ( + + context.emit('ask_about', `Tell me more about ${args.title}`, { title: args.title }) + } + style={({ pressed }) => [styles.card, pressed && styles.pressed]} + > + {summary?.thumbnail ? ( + + ) : ( + + )} + + + {args.title} + + {args.note ? {args.note} : null} + {summary?.extract ? ( + + {summary.extract} + + ) : null} + + + ); + }, +); + +type Place = { latitude: number; longitude: number; name: string }; + +const TripMap = defineAIUIComponent<{ places: Place[] }>( + definitionOf('trip_map'), + ({ args, context }) => ( + + {args.places.map((place, index) => ( + + context.emit('select_place', `Tell me more about ${place.name}`, { name: place.name }) + } + style={({ pressed }) => [styles.pin, pressed && styles.pressed]} + > + {index + 1} + + {place.name} + + + {place.latitude.toFixed(3)}, {place.longitude.toFixed(3)} + + + ))} + + ), +); + +const dayOf = (date: Date) => + date.toLocaleDateString(undefined, { day: 'numeric', month: 'short', weekday: 'short' }); + +const CalendarCheck = defineAIUIComponent<{ end: Date; start: Date }>( + definitionOf('calendar_check'), + ({ args }) => ( + + + {dayOf(args.start)} – {dayOf(args.end)} + + Your calendar stays on this phone. + + ), +); + +/** The app's components the agent can place in a reply's UI. */ +export const tripComponents: AIUIComponent[] = [PlaceCard, TripMap, CalendarCheck]; + +const styles = StyleSheet.create({ + calendar: { + backgroundColor: '#ffffff', + borderColor: '#ececec', + borderRadius: 12, + borderWidth: 1, + gap: 2, + padding: 12, + }, + card: { + alignItems: 'center', + backgroundColor: '#ffffff', + borderColor: '#ececec', + borderRadius: 12, + borderWidth: 1, + flexDirection: 'row', + gap: 12, + padding: 8, + }, + cardText: { flex: 1, gap: 2 }, + coordinates: { color: '#7a7a7a', fontSize: 12 }, + extract: { color: '#4b4b4b', fontSize: 13 }, + map: { backgroundColor: '#eef4ff', borderRadius: 12, gap: 4, padding: 8 }, + note: { color: '#7a7a7a', fontSize: 13 }, + photo: { borderRadius: 8, height: 64, width: 64 }, + photoPlaceholder: { backgroundColor: '#f2f2f2' }, + pin: { alignItems: 'center', flexDirection: 'row', gap: 8, paddingVertical: 6 }, + pinIndex: { + backgroundColor: '#005fff', + borderRadius: 10, + color: '#ffffff', + fontSize: 12, + fontWeight: '700', + height: 20, + lineHeight: 20, + overflow: 'hidden', + textAlign: 'center', + width: 20, + }, + pinName: { flex: 1, fontSize: 15 }, + pressed: { opacity: 0.7 }, + title: { fontSize: 15, fontWeight: '600' }, +}); diff --git a/examples/ExpoChatGPTSample/components/VoiceMode/VoiceMode.tsx b/examples/ExpoChatGPTSample/components/VoiceMode/VoiceMode.tsx index d1a0984bff..935778df95 100644 --- a/examples/ExpoChatGPTSample/components/VoiceMode/VoiceMode.tsx +++ b/examples/ExpoChatGPTSample/components/VoiceMode/VoiceMode.tsx @@ -6,6 +6,7 @@ import Svg, { Line } from 'react-native-svg'; import { StreamTheme, useDictationAudioLevel, + useTheme, type VoiceAgentState, VoiceGlow, VoiceWave, @@ -63,8 +64,8 @@ const VoiceModeOverlay = ({ onClose }: { onClose: () => void }) => { * A static waveform icon. The button stays still: a live `VoiceGlow` here would redraw every frame * for as long as the chat is open. */ -const VoiceIcon = () => ( - +const VoiceIcon = ({ color, size }: { color: string; size: number }) => ( + {[ [3, 8, 14], [7, 4, 18], @@ -74,7 +75,7 @@ const VoiceIcon = () => ( ].map(([x, y1, y2]) => ( ( ); -/** Opens a full-screen voice conversation with the AI agent. */ +/** + * Opens a full-screen voice conversation with the AI agent: a button after the composer, the size + * of the attach button before it. + */ export const VoiceMode = () => { const [open, setOpen] = useState(false); + const { + theme: { composer }, + } = useTheme(); + const size = composer.inputPillHeight; return ( <> - - setOpen(true)} - style={styles.button} - > - - - + setOpen(true)} + style={({ pressed }) => [ + styles.button, + { borderRadius: size / 2, height: size, width: size }, + pressed && styles.buttonPressed, + ]} + > + + setOpen(false)} visible={open}> {open ? setOpen(false)} /> : null} @@ -114,15 +124,12 @@ const styles = StyleSheet.create({ alignItems: 'center', backgroundColor: '#ffffff', borderColor: '#ececec', - borderRadius: 24, borderWidth: 1, - height: 48, justifyContent: 'center', overflow: 'hidden', - width: 48, }, + buttonPressed: { opacity: 0.8 }, close: { color: '#ffffff', fontSize: 22 }, - entry: { alignItems: 'flex-end', paddingHorizontal: 16, paddingBottom: 8 }, glow: { aspectRatio: 1, width: '100%' }, glowContainer: { alignSelf: 'stretch', flex: 1, justifyContent: 'center' }, header: { alignItems: 'flex-end', paddingHorizontal: 20 }, diff --git a/examples/ExpoChatGPTSample/components/VoiceMode/useVoiceConversation.ts b/examples/ExpoChatGPTSample/components/VoiceMode/useVoiceConversation.ts index d635f1e7d0..3c6d461d09 100644 --- a/examples/ExpoChatGPTSample/components/VoiceMode/useVoiceConversation.ts +++ b/examples/ExpoChatGPTSample/components/VoiceMode/useVoiceConversation.ts @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import * as Speech from 'expo-speech'; -import { AIStates, useAIState, useChannelContext, useStableCallback } from 'stream-chat-expo'; +import { useStableCallback } from 'stream-chat-expo'; import { dictationStore, @@ -12,13 +12,12 @@ import { type VoiceAgentState, } from 'stream-chat-expo/ai-components'; +import { useAppContext } from '../../contexts/AppContext'; import { useSendAIMessage } from '../../hooks/useSendAIMessage'; /** A turn the agent is never seen working on ends after this long. */ const TURN_TIMEOUT_MS = 20_000; -const WORKING_STATES: string[] = [AIStates.Thinking, AIStates.Generating, AIStates.ExternalSources]; - // Marks voice mode's dictation as its own, so the composer underneath ignores it. const DICTATION_SOURCE = { source: 'voice-mode' }; @@ -45,8 +44,9 @@ export const useVoiceConversation = (active: boolean) => { const { state: dictationState, error } = useDictationState(); const { transcript } = useDictationTranscript(); const send = useSendAIMessage(); - const { channel } = useChannelContext(); - const { aiState } = useAIState(channel); + const { agentState, chat } = useAppContext(); + // The agent answering, from the session's socket. + const isWorking = agentState.kind === 'responding' || agentState.kind === 'working'; // The messages already in the channel when the question was sent, so the reply is found by // identity rather than by comparing the server's timestamps with the device's clock. @@ -65,7 +65,7 @@ export const useVoiceConversation = (active: boolean) => { const latestReply = useStableCallback(() => { const seenIds = turn.current?.seenIds ?? new Set(); - const messages = channel?.messagePaginator.items ?? []; + const messages = chat?.messages ?? []; for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i]; if (seenIds.has(message.id)) break; @@ -129,25 +129,25 @@ export const useVoiceConversation = (active: boolean) => { } turn.current = { sawWorking: false, - seenIds: new Set((channel?.messagePaginator.items ?? []).map((message) => message.id)), + seenIds: new Set((chat?.messages ?? []).map((message) => message.id)), }; setPhase('thinking'); void send({ text }); timeout.current = setTimeout(() => { if (!turn.current?.sawWorking) finishTurn(); }, TURN_TIMEOUT_MS); - }, [active, channel, dictationState, error, finishTurn, listen, phase, send, transcript]); + }, [active, chat, dictationState, error, finishTurn, listen, phase, send, transcript]); // Follows the agent: its turn ends once it has been seen working and is done. useEffect(() => { const current = turn.current; if (!active || !current) return; - if (WORKING_STATES.includes(aiState)) { + if (isWorking) { current.sawWorking = true; } else if (current.sawWorking) { finishTurn(); } - }, [active, aiState, finishTurn]); + }, [active, isWorking, finishTurn]); return { // Only an error from the current attempt, not one left over while dictation restarts. diff --git a/examples/ExpoChatGPTSample/contexts/AppContext.tsx b/examples/ExpoChatGPTSample/contexts/AppContext.tsx index f838b13ada..ace90acbe1 100644 --- a/examples/ExpoChatGPTSample/contexts/AppContext.tsx +++ b/examples/ExpoChatGPTSample/contexts/AppContext.tsx @@ -1,70 +1,264 @@ import type { PropsWithChildren } from 'react'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { ChannelPaginator } from 'stream-chat'; -import type { Channel, SortParamRequest, StreamChat } from 'stream-chat'; +import { type ConversationSnapshot, type Session, type UIAction } from '@stream-io/calypso'; +import type { MessagePaginatorState, StreamChat } from 'stream-chat'; +import { useStableCallback, useStateStore } from 'stream-chat-expo'; +import { + AIClientIdentity, + AIConversationController, + type AIToolApprover, + createAuthSessionAuthorizer, + parseMessageParts, +} from 'stream-chat-expo/ai-components'; -import { chatUserId } from '../chatConfig'; +import { AgentChat, type AgentChatState, isAgentReply, type OpenSession } from '../agent/agentChat'; +import { agents, agentSessions, streamToken } from '../agent/agents'; +import { createAgentTransport } from '../agent/agentTransport'; +import { AgentConfig } from '../agent/config'; +import { createDeviceTools } from '../agent/deviceTools'; +import { useAgentMessages } from '../agent/useAgentMessages'; -const channelListFilters = { members: { $in: [chatUserId] } }; -const channelListSort: SortParamRequest[] = [{ direction: -1, field: 'last_updated' }]; +const IDLE: ConversationSnapshot = { kind: 'idle', skills: [] }; + +// This install, for the tool calls addressed to it. +const clientId = AIClientIdentity.installId(); + +// The tools this phone runs for the agent, such as telling it the local time. +const deviceTools = createDeviceTools({ clientId, userId: AgentConfig.user.id }); + +// Where a sign-in comes back to: the app's own scheme, which the web auth session hands back. +// The router takes only https or a native app's own scheme in reverse domain form (RFC 8252). +const SIGN_IN_SCHEME = 'io.getstream.expochatgptsample'; +const SIGN_IN_RETURN_URL = `${SIGN_IN_SCHEME}://sign-in`; + +// The person's answer to a call that asks first. calypso's `decide` answers only a call this +// session was asked on its own socket, and leaves any other alone, so a question shown from the +// channel, as after reopening the conversation or reloading the app, would wait forever. Such a +// call is answered with the frames `decide` sends, which the router matches by the call's id: an +// allowed client tool's call then waits for this phone, whose runner runs it. +const answerApproval = (session: Session, toolCallId: string, allowed: boolean) => { + if (session.approvals.some((request) => request.id === toolCallId)) { + session.decide(toolCallId, { allowed }); + return; + } + const socket = ( + session as unknown as { held(): { send(frame: Record): void } } + ).held(); + socket.send({ allowed, tool_call_id: toolCallId, type: 'tool_approval' }); + if (!allowed) { + socket.send({ + error: 'The person declined this call.', + tool_call_id: toolCallId, + type: 'tool_result', + }); + } +}; + +// Asks, with calypso's `responses.create`, which takes photos, a reasoning effort and what the +// person did in a reply's UI. The router's `respond`, which addresses a client tool's call to the +// install a question came from, is only for an app's backend, and this is a phone: the calls reach +// the device's tools without an install, and the phone runs them itself. +const respond = async ( + session: Session, + text: string, + options: { images: { url: string }[]; reasoningEffort?: string; uiAction?: UIAction }, +) => { + deviceTools.addressed = false; + await session.responses.create(text, { + ...(options.images.length > 0 ? { images: options.images } : {}), + ...(options.reasoningEffort ? { reasoningEffort: options.reasoningEffort } : {}), + ...(options.uiAction ? { uiAction: options.uiAction } : {}), + }); +}; + +// Answers go over the session's socket, which opens for them in a conversation that was only +// read so far. Each throws when it can't send, so the card lets the person answer again. +const approverFor = (chat: AgentChat): AIToolApprover => ({ + authorizer: createAuthSessionAuthorizer({ + callbackScheme: SIGN_IN_SCHEME, + decline: async (call) => (await chat.session()).declineLogin(call.id), + }), + clientId, + decide: async (call, allowed) => answerApproval(await chat.session(), call.id, allowed), + userId: AgentConfig.user.id, +}); export type AppContextValue = { - channel: Channel | undefined; - channelListPaginator: ChannelPaginator | undefined; - setChannel: (channel: Channel) => void; + /** What the agent is doing in the open conversation, from the session's socket. */ + agentState: ConversationSnapshot; + /** The open conversation's messages and live reasoning. */ + chat: AgentChat | undefined; + /** The open conversation's state, and what the person does in it. */ + conversation: AIConversationController | undefined; + /** Bumped when the drawer's list of conversations may have changed. */ + conversationsVersion: number; + /** Why the last conversation couldn't be opened. */ + failure: string | undefined; + /** The open conversation's session id, or `undefined` for a new one that wasn't asked anything. */ + sessionId: string | undefined; + /** + * Carries on a conversation from the drawer, by its session id. Its messages show as soon as its + * channel, `type:id` as the router lists it, is read. + */ + openConversation: (sessionId: string, conversationId?: string) => void; + /** Starts a new conversation. The router creates it with its first question. */ + startConversation: () => void; }; export const AppContext = React.createContext({ - setChannel: () => {}, - channel: undefined, - channelListPaginator: undefined, + agentState: IDLE, + chat: undefined, + conversation: undefined, + conversationsVersion: 0, + failure: undefined, + openConversation: () => {}, + sessionId: undefined, + startConversation: () => {}, }); -const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz-'; +const messageOf = (error: unknown) => (error instanceof Error ? error.message : String(error)); -export const nanoid = (size: number = 21): string => { - let id = ''; - for (let i = 0; i < size; i++) { - const r = Math.floor(Math.random() * ALPHABET.length); - id += ALPHABET[r]; - } - return id; -}; +// What the provider follows of the messages: the latest reply, whose calls this phone runs, and +// the session, once one is open. +const sessionSelector = ({ session }: AgentChatState) => ({ session }); -export const createChannel = (client: StreamChat) => - client.channel('messaging', nanoid(), { - members: [{ user_id: chatUserId }], - }); +const lastReplySelector = ({ items }: MessagePaginatorState) => ({ + lastReply: [...(items ?? [])].reverse().find(isAgentReply), +}); + +// Carries a conversation on, or creates one, with the phone's tools declared on it. +const openSession: OpenSession = (sessionId) => + sessionId + ? agentSessions().resume(sessionId, { tools: deviceTools.sessionTools() }) + : agentSessions().create({ + modelOverwrites: { llm: AgentConfig.chatModel }, + return_url: SIGN_IN_RETURN_URL, + tools: deviceTools.sessionTools(), + }); + +let chats = 0; +const newChat = (sessionId?: string) => new AgentChat(`chat-${++chats}`, sessionId, openSession); + +type Opened = { chat: AgentChat; conversation: AIConversationController }; export const AppProvider = ({ client, children }: PropsWithChildren<{ client: StreamChat }>) => { - const [channel, setChannel] = useState(() => createChannel(client)); - - // The app owns the channel list's paginator. It lives as long as the client, so the list keeps its - // channels when the drawer unmounts. `disconnectUser` empties the registered lists but keeps them - // registered, so it is removed when the client goes away. - const channelListPaginator = useMemo( - () => - new ChannelPaginator({ - client, - filters: channelListFilters, - id: 'channel-list', - sort: channelListSort, + const [opened, setOpened] = useState(); + const [failure, setFailure] = useState(); + const [agentState, setAgentState] = useState(IDLE); + const [conversationsVersion, setConversationsVersion] = useState(0); + const current = useRef(undefined); + const chat = opened?.chat; + + const session = useStateStore(chat?.state, sessionSelector)?.session; + + // Runs the calls of the latest reply that wait for this phone, each once. + const lastReply = useAgentMessages(chat, lastReplySelector)?.lastReply; + useEffect(() => { + if (!lastReply) return; + deviceTools.runner.run(parseMessageParts(lastReply.attachments), (call, result) => + deviceTools.send(call.id, result), + ); + }, [lastReply]); + + // Shows a conversation in place of the one before it, at once, and lets the one before go. + const show = (next: AgentChat) => { + const previous = current.current; + const conversation = new AIConversationController({ + // The agents router keeps the conversation in Stream Chat and can't rewind it, so a reply + // can't be written again or a message edited: either would only be asked again as new. + actions: { edit: false, regenerate: false }, + approver: approverFor(next), + transport: createAgentTransport(next, { + onCreated: () => setConversationsVersion((version) => version + 1), + respond, }), - [client], - ); + }); + current.current = { chat: next, conversation }; + setFailure(undefined); + setOpened(current.current); + previous?.conversation.dispose(); + void previous?.chat.close().catch(() => {}); + }; + + // A new conversation is only shown: the router creates it with its first question, so one + // that is never asked anything isn't left in the drawer. + const startConversation = useStableCallback(() => show(newChat())); + + const openConversation = useStableCallback((sessionId: string, conversationId?: string) => { + if (sessionId === current.current?.chat.id) { + return; + } + const next = newChat(sessionId); + show(next); + // The channel, when the drawer named it: reading it is quick, and carrying the session on, + // which waits for something to need it, would also mark the conversation active. + const [type, ...id] = (conversationId ?? '').split(':'); + const following = conversationId + ? next.follow(client.channel(type || 'agent', id.join(':'))) + : next.session(); + void following.catch((error: unknown) => { + if (current.current?.chat === next) setFailure(messageOf(error)); + }); + }); + + // The open conversation: what the agent is doing, from the session's socket, once one is open. + useEffect(() => { + if (!session) { + setAgentState(IDLE); + return; + } + setAgentState(session.state.get()); + return session.state.subscribe(() => { + const state = session.state.get(); + setAgentState(state); + // The router titles a conversation as it goes. + if (state.kind === 'idle') setConversationsVersion((version) => version + 1); + }); + }, [session]); useEffect(() => { - client.channelManager.insertPaginator({ paginator: channelListPaginator }); + let cancelled = false; + (async () => { + try { + // The router and Stream Chat as the same user, on the app's own chat client. + await agents.setUser(AgentConfig.user, streamToken); + agents.use({ chat: client }); + if (!cancelled) { + startConversation(); + } + } catch (error) { + setFailure(messageOf(error)); + } + })(); return () => { - client.channelManager.removePaginator(channelListPaginator); - channelListPaginator.dispose(); + cancelled = true; }; - }, [channelListPaginator, client]); + }, [client, startConversation]); + const sessionId = session?.id ?? chat?.id; + const conversation = opened?.conversation; const contextValue = useMemo( - () => ({ channel, channelListPaginator, setChannel }), - [channel, channelListPaginator], + () => ({ + agentState, + chat, + conversation, + conversationsVersion, + failure, + openConversation, + sessionId, + startConversation, + }), + [ + agentState, + chat, + conversation, + conversationsVersion, + failure, + openConversation, + sessionId, + startConversation, + ], ); return {children}; diff --git a/examples/ExpoChatGPTSample/custom-types.d.ts b/examples/ExpoChatGPTSample/custom-types.d.ts index af1dc3dcdb..e8aceb7b9b 100644 --- a/examples/ExpoChatGPTSample/custom-types.d.ts +++ b/examples/ExpoChatGPTSample/custom-types.d.ts @@ -10,6 +10,7 @@ import { DefaultThreadData, DefaultUserData, } from 'stream-chat-expo'; +import type { AIUIActionJSON, AIUIDocumentJSON } from 'stream-chat-expo/ai-components'; declare module 'stream-chat' { // Module augmentation: the empty interfaces are how the default data types are applied. @@ -27,6 +28,12 @@ declare module 'stream-chat' { interface CustomMessageData extends DefaultMessageData { ai_generated?: boolean; + /** Set by the router while the agent is still writing a reply. */ + generating?: boolean; + /** The UI the agent wrote for a reply, which the router moves out of its text. */ + ai_ui?: AIUIDocumentJSON; + /** What the person did in a reply's UI, on their message that says so. */ + ai_ui_action?: AIUIActionJSON; } interface CustomPollOptionData extends DefaultPollOptionData {} diff --git a/examples/ExpoChatGPTSample/hooks/useSendAIMessage.ts b/examples/ExpoChatGPTSample/hooks/useSendAIMessage.ts index c60df86cb8..a7ece9afe9 100644 --- a/examples/ExpoChatGPTSample/hooks/useSendAIMessage.ts +++ b/examples/ExpoChatGPTSample/hooks/useSendAIMessage.ts @@ -1,35 +1,20 @@ -import { useMessageComposer, useMessageInputContext, useStableCallback } from 'stream-chat-expo'; +import { useStableCallback } from 'stream-chat-expo'; import type { ComposerViewProps } from 'stream-chat-expo/ai-components'; -export type AIMessage = Parameters[0]; +import { useAppContext } from '../contexts/AppContext'; + +export type AIMessage = Parameters>[0]; /** - * Sends a message to the AI channel through the channel's composer, with its attachments. Shared - * by the composer and voice mode, so both go through the client's `sendMessageRequest` handler - * (which starts the agent on the first message). + * Asks the agent through the open conversation, as the composer would: the question shows at once, + * and the router writes it and its reply into the conversation's channel. Used by voice mode. */ export const useSendAIMessage = () => { - const messageComposer = useMessageComposer(); - const { sendMessage } = useMessageInputContext(); - - return useStableCallback(async ({ text, attachments }: AIMessage) => { - messageComposer.textComposer.setText(text); - if (attachments && attachments.length > 0) { - const localAttachments = await Promise.all( - attachments.map((asset) => - messageComposer.attachmentManager.fileToLocalUploadAttachment({ - ...asset, - // The picker can leave these out (e.g. some iOS photos have no file name). - name: asset.name ?? asset.uri.split('/').pop() ?? 'image', - size: asset.size ?? 0, - type: asset.type ?? 'image/jpeg', - }), - ), - ); - messageComposer.attachmentManager.upsertAttachments(localAttachments); - } + const { conversation } = useAppContext(); - await sendMessage(); + return useStableCallback((message: AIMessage) => { + conversation?.send(message); + return Promise.resolve(); }); }; diff --git a/examples/ExpoChatGPTSample/http/api.ts b/examples/ExpoChatGPTSample/http/api.ts deleted file mode 100644 index 4ff8fba92f..0000000000 --- a/examples/ExpoChatGPTSample/http/api.ts +++ /dev/null @@ -1,19 +0,0 @@ -export const post = async (url: string, data: unknown = {}) => { - try { - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(data), - }); - - if (!response.ok) { - throw new Error(`An HTTP Error has occurred. Status: ${response.status}`); - } - - return await response.json(); - } catch (error) { - console.error('Error:', error); - } -}; diff --git a/examples/ExpoChatGPTSample/http/requests.ts b/examples/ExpoChatGPTSample/http/requests.ts deleted file mode 100644 index fce4cd3941..0000000000 --- a/examples/ExpoChatGPTSample/http/requests.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { post } from './api'; - -// The AI provider the sample backend uses, as in the React SDK's ai-chatbot example. -const PLATFORM = 'openai'; - -export const startAI = (channelId: string) => - post('https://ai-sdk-server-0f347d455e2e.herokuapp.com/start-ai-agent', { - channel_id: channelId, - platform: PLATFORM, - }); -export const stopAI = (channelId: string) => - post('https://ai-sdk-server-0f347d455e2e.herokuapp.com/stop-ai-agent', { - channel_id: channelId, - }); -export const summarize = (text: string) => - post('https://ai-sdk-server-0f347d455e2e.herokuapp.com/summarize', { - platform: PLATFORM, - text, - }); diff --git a/examples/ExpoChatGPTSample/package.json b/examples/ExpoChatGPTSample/package.json index 9ec164fe7f..475f8de31b 100644 --- a/examples/ExpoChatGPTSample/package.json +++ b/examples/ExpoChatGPTSample/package.json @@ -12,6 +12,8 @@ "dependencies": { "@react-native-community/netinfo": "12.0.1", "@shopify/react-native-skia": "^2.14.0", + "@stream-io/calypso": "^0.3.0", + "@stream-io/state-store": "^1.2.0", "expo": "^58.0.8", "expo-clipboard": "~58.0.3", "expo-constants": "~58.0.11", @@ -23,6 +25,7 @@ "react": "19.3.0", "react-native": "0.88.0", "react-native-gesture-handler": "~3.4.0", + "react-native-keyboard-controller": "1.22.9", "react-native-reanimated": "4.7.1", "react-native-safe-area-context": "~5.10.1", "react-native-screens": "~4.29.0", @@ -32,6 +35,7 @@ "stream-chat": "^10.0.1", "stream-chat-expo": "workspace:^", "stream-chat-react-native-core": "workspace:^", + "use-sync-external-store": "^1.7.0", "victory-native": "^42.0.1" }, "devDependencies": { diff --git a/examples/ExpoChatGPTSample/screens/ChatContent.tsx b/examples/ExpoChatGPTSample/screens/ChatContent.tsx index 7a98628935..a8f865e846 100644 --- a/examples/ExpoChatGPTSample/screens/ChatContent.tsx +++ b/examples/ExpoChatGPTSample/screens/ChatContent.tsx @@ -1,101 +1,55 @@ -import { useCallback, useMemo } from 'react'; -import { Platform, StatusBar, StyleSheet, Text, View } from 'react-native'; +import { useMemo, useState } from 'react'; +import { + ActivityIndicator, + Platform, + Pressable, + StatusBar, + StyleSheet, + Text, + View, +} from 'react-native'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useHeaderHeight } from 'expo-router/react-navigation'; - -import type { MessageProps } from 'stream-chat-expo'; -import { - AIStates, - Channel, - isLocalUrl, - mergeThemes, - Message, - MessageList, - ThemeProvider, - useAIState, - useChannelContext, - useMessageContext, - useTheme, - WithComponents, -} from 'stream-chat-expo'; - +import { createA2UIRenderer } from 'stream-chat-expo/a2ui'; import { + type AIConversationController, + type AIConversationControllerState, + AISuggestions, AITypingIndicatorView, + aiUIActionJSON, + AIUIHost, ComposerView, - StreamingMessageView, + isAIConversationAnswering, + useAIConversationState, } from 'stream-chat-expo/ai-components'; +import { AIConversation } from 'stream-chat-expo/ai-components/conversation'; -import { bottomSheetOptions } from '../bottomSheetOptions'; +import type { AgentChat } from '../agent/agentChat'; +import type { AgentRequestCustom } from '../agent/agentTransport'; +import { useAgentChatLoaded } from '../agent/useAgentMessages'; +import { chatOptions, STARTERS } from '../chatOptions'; +import { tripComponents } from '../components/TripComponents'; import { VoiceMode } from '../components/VoiceMode'; import { useAppContext } from '../contexts/AppContext'; -import { useSendAIMessage } from '../hooks/useSendAIMessage'; - -const CustomMessage = (props: MessageProps) => { - const { theme } = useTheme(); - const { message } = props; - const isFromBot = message.custom?.ai_generated; - const hasPendingAttachments = useMemo( - () => - (message.attachments ?? []).some( - (attachment) => - (attachment.image_url && isLocalUrl(attachment.image_url)) || - (attachment.asset_url && isLocalUrl(attachment.asset_url)), - ), - [message.attachments], - ); - - const modifiedTheme = useMemo( - () => - mergeThemes({ - theme, - style: { - messageItemView: isFromBot - ? { - content: { - containerInner: { - backgroundColor: 'transparent', - borderRadius: 0, - borderColor: 'transparent', - }, - textContainer: { maxWidth: '100%' }, - }, - } - : { - container: { - opacity: hasPendingAttachments ? 0.5 : 1, - }, - }, - }, - }), - [theme, isFromBot, hasPendingAttachments], - ); - - return ( - - - - ); -}; - -const CustomStreamingMessageView = () => { - const { message } = useMessageContext(); - return ( - - - - ); -}; - -const CustomComposerView = () => { - const { channel } = useChannelContext(); - - const { aiState } = useAIState(channel); - - const stopGenerating = useCallback(() => channel?.stopAIResponse(), [channel]); - const isGenerating = aiState === AIStates.Thinking || aiState === AIStates.Generating; +const errorSelector = ({ error }: AIConversationControllerState) => ({ error }); + +/** + * The composer, with what rides the keyboard above it: why something failed, with a way to try + * again, and the starters, which share the composer's draft and picked option. And the way into + * voice mode beside it. + */ +const ComposerArea = ({ + chat, + conversation, + failure, +}: { + chat: AgentChat; + conversation: AIConversationController; + failure: string | undefined; +}) => { + const { error } = useAIConversationState(conversation, errorSelector); const safeAreaInsets = useSafeAreaInsets(); const insets = useMemo( @@ -108,98 +62,125 @@ const CustomComposerView = () => { [safeAreaInsets], ); - const serializeToMessage = useSendAIMessage(); - return ( + {failure ? {failure} : null} + {error ? ( + conversation.retry()}> + {error.message} + Try again + + ) : null} + + + } + trailing={} /> ); }; -const EmptyStateIndicator = () => ( - - What can I help with ? - -); +const EmptyState = () => What can I help with ?; -const CustomAITypingIndicatorView = () => { - const { channel } = useChannelContext(); - const { aiState } = useAIState(channel); +const pendingSelector = ({ pendingMessage }: AIConversationControllerState) => ({ + pendingMessage, +}); - const allowedStates = { - [AIStates.Thinking]: 'Thinking about the question...', - [AIStates.Generating]: 'Generating a response...', - [AIStates.ExternalSources]: 'Checking external sources...', +/** + * Starters, above the composer, until the conversation has a first question. One is asked with the + * option picked in the composer, as the composer would ask it. + */ +const Starters = ({ + chat, + conversation, +}: { + chat: AgentChat; + conversation: AIConversationController; +}) => { + // A conversation still loading isn't empty: it shows no starters meanwhile. + const { isEmpty, isLoaded } = useAgentChatLoaded(chat); + const { pendingMessage } = useAIConversationState(conversation, pendingSelector); + if (!isLoaded || !isEmpty || pendingMessage) return null; + const onSelect = (text: string) => { + const { chatOption } = conversation.composer.getLatestValue(); + conversation.send({ chatOption: chatOption ?? undefined, text }); }; + return ; +}; - const indicatorText = allowedStates[aiState as keyof typeof allowedStates]; +/** The open conversation, as its controller has it. */ +const Conversation = ({ + chat, + conversation, +}: { + chat: AgentChat; + conversation: AIConversationController; +}) => { + const { agentState } = useAppContext(); + const { isLoaded } = useAgentChatLoaded(chat); + + // The UI the agent writes for its replies, in A2UI, with the trip planner's own components. One + // host per conversation, for as long as it shows, so what a reply's UI holds survives scrolling. + // What the person does in it goes to the agent as their next message. + const [ui] = useState( + () => + new AIUIHost({ + components: tripComponents, + renderers: [createA2UIRenderer()], + send: (action) => { + if (isAIConversationAnswering(conversation.state.getLatestValue().status)) { + throw new Error('The agent is still answering'); + } + const custom: AgentRequestCustom = { uiAction: aiUIActionJSON(action) }; + conversation.send({ custom, text: action.label }); + }, + }), + ); - if (!indicatorText) { - return null; - } + // What the agent is doing while it runs skills, under the latest turn. + const footer = + agentState.kind === 'working' ? ( + skill.replace(/_/g, ' ')).join(' and ')}`} + /> + ) : undefined; return ( - - - + : } + footer={footer} + ui={ui} + /> ); }; -const RenderNull = () => null; - -const additionalFlatListProps = { - maintainVisibleContentPosition: { - minIndexForVisible: 0, - autoscrollToTopThreshold: 0, - }, - ListHeaderComponent: undefined, -}; - export const ChatContent = () => { - const { channel } = useAppContext(); + const { chat, conversation, failure } = useAppContext(); const { bottom } = useSafeAreaInsets(); - // The channel sits below the drawer header, so the keyboard offset has to account for it - // (without it, Android keeps the composer behind the keyboard). - const headerHeight = useHeaderHeight(); - if (!channel) { - return null; + if (!chat || !conversation) { + return ( + + {failure ? {failure} : } + + ); } return ( - - - - - - - - + {/* The keyboard is the SDK's: the conversation makes room for it, the composer rides it. */} + + ); }; @@ -213,10 +194,13 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', }, - emptyContainerText: { fontSize: 24, fontWeight: 'bold' }, - streamingMessageViewWrapper: { width: '100%', paddingHorizontal: 16 }, - aiTypingIndicatorWrapper: { - paddingHorizontal: 24, - paddingVertical: 12, + emptyText: { fontSize: 24, fontWeight: 'bold' }, + failureText: { color: '#B00020', fontSize: 15, paddingHorizontal: 24, textAlign: 'center' }, + retryText: { + color: '#005FFF', + fontSize: 15, + fontWeight: '600', + paddingTop: 4, + textAlign: 'center', }, }); diff --git a/examples/ExpoChatGPTSample/screens/MenuDrawer.tsx b/examples/ExpoChatGPTSample/screens/MenuDrawer.tsx index 12783c765f..20faf89cf2 100644 --- a/examples/ExpoChatGPTSample/screens/MenuDrawer.tsx +++ b/examples/ExpoChatGPTSample/screens/MenuDrawer.tsx @@ -1,85 +1,96 @@ -import React from 'react'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; +import React, { useEffect, useState } from 'react'; +import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import type { Schemas } from '@stream-io/calypso'; import type { DrawerContentComponentProps } from 'expo-router/drawer'; -import type { Channel as ChannelClass } from 'stream-chat'; -import { - ChannelList, - ChannelPreviewProps, - Plus, - useChannelsContext, - useChatContext, - useStableCallback, - WithComponents, -} from 'stream-chat-expo'; -import { createChannel, useAppContext } from '../contexts/AppContext'; +import { Plus, useStableCallback } from 'stream-chat-expo'; -const ChannelPreview = (props: ChannelPreviewProps) => { - const channel = props.channel; - const { onSelect } = useChannelsContext(); - const onPress = useStableCallback(() => { - onSelect?.(channel); - }); - return ( - ({ - paddingVertical: 8, - paddingHorizontal: 12, - opacity: pressed ? 0.6 : 1, - })} - onPress={onPress} - > - - {channel.data?.custom?.name ?? channel.cid} - - - ); -}; +import { agentSessions } from '../agent/agents'; +import { useAppContext } from '../contexts/AppContext'; + +type SessionRow = Schemas['Session']; + +const keyExtractor = (session: SessionRow) => session.id; +// When the conversation was last answered, or else started. The router lists conversations by when +// they were last touched, which opening one to read it does too, so a conversation looked at would +// jump to the top. When the agent last answered moves only when it is used. +const lastUsed = (session: SessionRow) => + Date.parse(session.last_response_at ?? session.created_at ?? '') || 0; + +const byLastUsed = (sessions: readonly SessionRow[]) => + [...sessions].sort((a, b) => lastUsed(b) - lastUsed(a)); + +/** + * The agent's conversations, most recently used first. Only those kept in writing: a session that + * was only ever a call has no channel to read back. + */ export const MenuDrawer = ({ navigation }: DrawerContentComponentProps) => { - const { client } = useChatContext(); - const { channelListPaginator, setChannel } = useAppContext(); + const { conversationsVersion, openConversation, sessionId, startConversation } = useAppContext(); + const [sessions, setSessions] = useState([]); + + useEffect(() => { + let cancelled = false; + agentSessions() + .query({ limit: 50 }) + .then((page) => { + if (!cancelled) setSessions(byLastUsed(page.items.filter((item) => item.conversation_id))); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [conversationsVersion]); - const onSelect = useStableCallback((channel: ChannelClass) => { - setChannel(channel); + const onSelect = useStableCallback((session: SessionRow) => { + openConversation(session.id, session.conversation_id ?? undefined); navigation.closeDrawer(); }); const onCreateNewChat = useStableCallback(() => { - setChannel(createChannel(client)); + startConversation(); navigation.closeDrawer(); }); + const renderItem = ({ item }: { item: SessionRow }) => ( + onSelect(item)} + style={({ pressed }) => [styles.row, { opacity: pressed ? 0.6 : 1 }]} + > + + {item.title || 'New conversation'} + + + ); + return ( Conversations - + - - {channelListPaginator ? ( - - ) : null} - + ); }; const styles = StyleSheet.create({ - wrapper: { flex: 1, backgroundColor: '#FFFFFF' }, container: { + borderBottomColor: 'grey', + borderBottomWidth: 1, flexDirection: 'row', justifyContent: 'space-between', marginHorizontal: 12, paddingVertical: 16, - borderBottomWidth: 1, - borderBottomColor: 'grey', }, - title: { fontSize: 15, fontWeight: 'bold' }, + current: { color: '#005FFF' }, previewText: { fontSize: 15, fontWeight: 'bold' }, + row: { paddingHorizontal: 12, paddingVertical: 8 }, + title: { fontSize: 15, fontWeight: 'bold' }, + wrapper: { backgroundColor: '#FFFFFF', flex: 1 }, }); diff --git a/examples/ExpoChatGPTSample/tsconfig.json b/examples/ExpoChatGPTSample/tsconfig.json index d4352f5a2e..af8edb52a3 100644 --- a/examples/ExpoChatGPTSample/tsconfig.json +++ b/examples/ExpoChatGPTSample/tsconfig.json @@ -4,7 +4,9 @@ "paths": { // Same as ExpoMessaging: pins the whole program to this workspace's copy of React Native. "react-native": ["./node_modules/react-native/types_generated/index.d.ts"], - "react-native/*": ["./node_modules/react-native/*"] + "react-native/*": ["./node_modules/react-native/*"], + // The same for the state stores: AgentChat's and the SDK's are then one class to TypeScript. + "@stream-io/state-store": ["./node_modules/@stream-io/state-store"] } }, "include": ["**/*.ts", "**/*.tsx"] diff --git a/examples/ExpoCrewAssistant/.env.example b/examples/ExpoCrewAssistant/.env.example new file mode 100644 index 0000000000..8edd276f0c --- /dev/null +++ b/examples/ExpoCrewAssistant/.env.example @@ -0,0 +1,22 @@ +# Copy to .env.local (gitignored) and fill in. Public values only: Expo inlines every EXPO_PUBLIC_ +# variable into the app bundle. The Stream secret stays with the crew backend. + +# The Stream app the agent runs in. +EXPO_PUBLIC_STREAM_API_KEY=YOUR_STREAM_API_KEY + +# The crew backend (crew_assistant's `go run .` in backend/). On an Android emulator, +# `adb reverse tcp:8097 tcp:8097` makes localhost reach it. +EXPO_PUBLIC_CREW_BACKEND_URL=http://localhost:8097 + +# The agent `getstream agents sync` stored, and who this app is. +EXPO_PUBLIC_AGENT_NAME=crew_assistant +EXPO_PUBLIC_AGENT_USER_ID=crew-jordan + +# Who the team lead view signs in as. The crew backend keeps each person's crew role in their Stream +# user's custom data, and only a leader reads the leaders' inbox. +EXPO_PUBLIC_AGENT_LEADER_ID=crew-maya + +# Only for a router of your own rather than Stream's hosted one, such as a local router without keys +# (`adb reverse tcp:8080 tcp:8080` on an emulator): +# EXPO_PUBLIC_AGENT_ROUTER_URL=http://localhost:8080 +# EXPO_PUBLIC_AGENT_CUSTOMER_ID=examples diff --git a/examples/ExpoCrewAssistant/.gitignore b/examples/ExpoCrewAssistant/.gitignore new file mode 100644 index 0000000000..5f1ef56382 --- /dev/null +++ b/examples/ExpoCrewAssistant/.gitignore @@ -0,0 +1,40 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +ios/ +android/ +dist/ +web-build/ +expo-env.d.ts + +# Native +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo + +app-example diff --git a/examples/ExpoCrewAssistant/README.md b/examples/ExpoCrewAssistant/README.md new file mode 100644 index 0000000000..b1e99bc4c5 --- /dev/null +++ b/examples/ExpoCrewAssistant/README.md @@ -0,0 +1,103 @@ +# Crew assistant (Expo) + +An AI assistant for a restaurant's team members, built on the AI components and the Vision-Agents +router. A team member opens it before, during or after a shift. The home screen lists what it +helps with, and each flow starts a conversation with the agent: `AIConversation` for the chat, +A2UI for checklists, quizzes and forms, and approval cards for what the agent does in your name. + +The backend is `examples/voice_agents/crew_assistant` in Vision-Agents, forked from +`swift_assistant`. It stands in for the restaurant's schedule, handbook, equipment and leaders' +inbox, hosts their tools on the router, signs each person's Stream token, and makes the agent +speak first when something happens, such as a clock-in. Everything the app asks it carries the +user's Stream token, and the backend acts for that user alone. + +## What it shows + +| Flow | How it starts | What to look at | +| ---------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Clock in** | the agent speaks first | A fit-check checklist and the focus for the shift, as interactive UI. Tick the boxes and tap "I'm ready". | +| **Call out sick** | a question | Schedule and call-out policy as tool steps. With a cold it offers cover, the swap asks you first, and the shift leader gets a note. With a fever it says stay home. | +| **Ask the handbook** | a question | The answer comes from `search_handbook`, and the passages show as **Sources** under the reply. | +| **Equipment problem** | a question, a photo or a scan | The quick fix for the asset and code. "Still broken" drafts a repair ticket, which asks you first. Send a photo with "+" to have it look first. | +| **Back from time off** | the agent speaks first | What you missed since your time off started, grouped. | +| **I'm new here** | the agent speaks first | Who's who, a menu quiz as interactive UI, and booking a check-in with the trainer. | +| **Interview practice** | a question | A mock shift leader interview, one question at a time, with feedback. Try it in voice mode (the button beside the composer). | +| **Clock out** | the agent speaks first | An end-of-shift reflection form. What it saves shows in the team lead view, without your name. | + +**Team lead** (top right) is what the shift leaders and the owner see: call-out notes, swap +requests, drafted tickets, booked check-ins and the team's pulse from reflections. It signs in as +a leader (`EXPO_PUBLIC_AGENT_LEADER_ID`, Maya by default), since the backend answers the leaders' +inbox only to a user whose Stream user has `crew_role: "leader"`. It refreshes every few seconds, +so keep it open next to a flow. + +**Scanning a machine.** A QR code on a machine holds a link such as +`crewassistant://equipment?asset=FRY-02&code=E-41`. Opening it starts a conversation about that +machine. On an emulator, open it with: + +```bash +adb shell am start -a android.intent.action.VIEW -d "crewassistant://equipment?asset=FRY-02\&code=E-41" +``` + +Known assets and codes: `FRY-02` (`E-41`, `E-07`), `POS-1` (`E-NET`), `ICE-1` (`BIN`). + +## Run it + +The agent runs on Stream's hosted Agents. + +1. Sync the agent and start its backend, as Vision-Agents' `crew_assistant` README says: with the + beta Stream CLI (`curl -fsSL https://getstream.io/cli/beta/install.sh | bash`) and your Stream + app's key and secret, from the Vision-Agents root: + + ```bash + export STREAM_API_KEY= STREAM_API_SECRET= + getstream agents sync ./examples/voice_agents/crew_assistant + (cd examples/voice_agents/crew_assistant/configure && GOWORK=off go run . -dir ..) + (cd examples/voice_agents/crew_assistant/backend && GOWORK=off go run .) + ``` + +2. Copy `.env.example` to `.env.local` and set `EXPO_PUBLIC_STREAM_API_KEY` to the same Stream app. + +3. Build and run the app: + + ```bash + yarn install + cd examples/ExpoCrewAssistant + npx expo prebuild + yarn android # or yarn ios + ``` + + On an Android emulator, `adb reverse tcp:8097 tcp:8097` lets the app reach the backend on + `localhost`. To use a router of your own instead of the hosted one, set + `EXPO_PUBLIC_AGENT_ROUTER_URL`, as `.env.example` shows. + +## How it's built + +- `contexts/AppContext.tsx` opens conversations: new ones, ones from the drawer, ones that start + with a question (`startConversationWith`), and ones the agent opens about an event + (`startConversationAbout`), which asks the backend's `POST /events`. +- `agent/crewTools.ts` declares the phone's tools: its clock (`get_local_context`), and + `request_shift_swap`, `create_support_ticket` and `book_check_in`, which ask first with the AI + components' approval card, then call the backend. The router asks for a yes only on a tool the + phone declares, so those stay here; the restaurant's other tools run on the backend. + `DISPLAY_TITLES` titles every tool's step, the backend's too (`agent/agentTransport.ts`). +- `agent/agents.ts` signs in with the backend and asks it with the user's Stream token + (`crewBackend`). +- The handbook is the agent's knowledge base on the router, filled from `crew_assistant/knowledge/`: + Markdown files and documents such as PDFs. `search_handbook` answers with the passages that + match as `{"status": "answered", "citations": [...]}`, which the router adds to the reply's + `support_message.sources`. `screens/ChatContent.tsx` shows them under the reply. +- `app/leader.tsx` is the team lead view, and `app/equipment.tsx` the scanned-code link. + +**Your calendar.** Booking a check-in ("Book a check-in with Priya on Thursday afternoon when I'm +free") first reads your own Cal.com. The first time, the reply asks you to **Connect Cal.com**, the +same sign-in the AI components show for any connector: Connect opens Cal.com's sign-in, and the +booking carries on once you're connected. Not now books the time you asked for without it. + +**Voice memos.** The red mic beside the composer records a memo with the chat SDK's own recorder +(`AudioRecorderManager` and `AudioRecordingInProgress`). Send uploads it to the conversation's +channel with `channel.sendFile`, then asks the router with `audio: { url }`: the router transcribes +it, answers it and shows it on your message as a voice recording, which plays with the chat SDK's +`AudioAttachment`. At clock-out, a memo is your reflection. The router transcribes recordings with +Deepgram's batch model only, so it needs a `DEEPGRAM_API_KEY` with credit. + +Not here yet: approvals answered by someone other than the person chatting. diff --git a/examples/ExpoCrewAssistant/agent/agentChat.ts b/examples/ExpoCrewAssistant/agent/agentChat.ts new file mode 100644 index 0000000000..ac4b6ca59a --- /dev/null +++ b/examples/ExpoCrewAssistant/agent/agentChat.ts @@ -0,0 +1,185 @@ +import { AppState } from 'react-native'; + +import { LiveReasoning, type Session } from '@stream-io/calypso'; +import { StateStore } from '@stream-io/state-store'; +import type { Channel, Event, LocalMessage } from 'stream-chat'; + +/** What was last asked of the agent here, so it can be asked again when it couldn't answer. */ +export type AgentRequest = + | { text: string; custom?: Record } + | { event: string } + | { voiceMemoUrl: string }; + +/** How many messages are read at a time. */ +const PAGE_SIZE = 30; + +export type AgentChatState = { + /** + * The channel the router keeps the conversation in, once it is followed. Its `messagePaginator` + * holds the messages. + */ + channel: Channel | undefined; + /** The session the conversation is held in, once something here needed it. */ + session: Session | undefined; + /** Whether the agent was asked to open the conversation and hasn't written yet. */ + awaitingAgent: boolean; +}; + +/** + * Opens a conversation's session: carries on `sessionId`, or starts a new conversation without one. + */ +export type OpenSession = (sessionId: string | undefined) => Promise; + +/** + * A written conversation as the AI components want it: the channel the router keeps it in, and the + * whole of each reply's thinking. + * + * The router writes everything there. Asking goes through the session, which records the question + * in the channel and answers it there: the reply's text, and its steps (each round of reasoning and + * each tool call) as attachments. + * + * The channel and the session open apart. Reading a conversation needs only its channel, which + * Stream serves in a fraction of the time the session takes to come back, and carrying a session + * on marks the conversation active, which moves it up the router's list. So the session opens only + * once something needs it, such as a question, and a new conversation has none until it is asked + * something: the router never lists one that was only looked at. + */ +export class AgentChat { + /** Each reply's thinking in full, from its live updates. A stored step only keeps its opening. */ + readonly reasoning = new LiveReasoning(); + + /** Whether the person asked something here, which may not have arrived in `messages` yet. */ + wasAsked = false; + + /** What was last asked of the agent here. */ + lastRequest: AgentRequest | undefined; + + readonly state = new StateStore({ + awaitingAgent: false, + channel: undefined, + session: undefined, + }); + + private stops: (() => void)[] = []; + private opening: Promise | undefined; + private closed = false; + + /** + * @param key Tells this conversation apart from the one shown before it, such as for a React key. + * @param sessionId The conversation's session, or `undefined` for a new conversation. + */ + constructor( + readonly key: string, + private readonly sessionId: string | undefined, + private readonly openSession: OpenSession, + ) {} + + /** The conversation's session id: the one it was opened with, or the one asking created. */ + get id() { + return this.state.getLatestValue().session?.id ?? this.sessionId; + } + + /** Whether this is a new conversation, which has no messages to wait for. */ + get isNew() { + return this.sessionId === undefined; + } + + /** The messages loaded, oldest first. */ + get messages(): readonly LocalMessage[] { + return this.state.getLatestValue().channel?.messagePaginator.state.getLatestValue().items ?? []; + } + + /** + * Watches the channel and loads its latest messages, as the chat SDK's `` does. While it + * is followed the channel is active, so the client reloads it after a reconnect. + */ + async follow(channel: Channel) { + if (this.state.getLatestValue().channel || this.closed) return; + // Every event rather than the paginator's messages: each live update carries its own window of + // the reply's thinking, and folding them into one would lose all but the last. + const subscription = channel.on((event: Event) => { + if (event.type === 'message.updated' && event.message) this.reasoning.read(event.message); + }); + this.stops.push(() => subscription.unsubscribe(), channel.activate()); + + const foregrounded = AppState.addEventListener('change', (state) => { + if (state === 'active' && this.dropped()) void this.session().catch(() => {}); + }); + this.stops.push(() => foregrounded.remove()); + this.state.partialNext({ channel }); + + const paginator = channel.messagePaginator; + paginator.pageSize = PAGE_SIZE; + await channel.watch(); + if (!paginator.state.getLatestValue().items?.length) await paginator.reload(); + } + + /** + * The conversation's session, opened the first time it is needed. A new conversation is created + * then, and its channel followed. A session whose socket dropped, as it does once the app has been + * in the background, is carried on in a new one. Rejects when it can't open, to be asked again. + */ + session(): Promise { + if (this.dropped()) this.opening = undefined; + this.opening ??= this.open().catch((error: unknown) => { + this.opening = undefined; + throw error; + }); + return this.opening; + } + + /** + * Deletes the session a new conversation was given when the question that created it failed + * before anything was written, so the router doesn't list an empty conversation. The next + * question creates another. + */ + async discardUnusedSession() { + const { session } = this.state.getLatestValue(); + if (!this.isNew || !session || this.messages.length > 0) return; + this.opening = undefined; + this.state.partialNext({ session: undefined }); + await session.delete().catch(() => {}); + } + + /** Loads the messages before the oldest one loaded. Throws when it can't, to be asked again. */ + loadOlderMessages = async () => { + const paginator = this.state.getLatestValue().channel?.messagePaginator; + if (!paginator?.hasMoreTail) return; + await paginator.toTail(); + // The paginator keeps a failure rather than throwing it. + if (paginator.lastQueryError) throw paginator.lastQueryError; + }; + + /** Stops following the channel, and lets the session go. */ + async close() { + this.closed = true; + for (const stop of this.stops.splice(0)) stop(); + const opened = await this.opening?.catch(() => undefined); + await opened?.close(); + } + + /** Whether the open session's socket has stopped carrying the conversation. */ + private dropped() { + const { session } = this.state.getLatestValue(); + return !this.closed && session !== undefined && !session.live; + } + + private async open() { + const opened = this.id; + const session = await this.openSession(opened); + if (this.closed) { + // The person moved on while it opened. A conversation created for them was never used. + await (opened === undefined ? session.delete() : session.close()).catch(() => {}); + throw new Error('The conversation was closed'); + } + if (!this.state.getLatestValue().channel) { + const { channel } = await session.chat(); + await this.follow(channel as Channel); + } + this.state.partialNext({ session }); + return session; + } +} + +/** A reply the agent wrote. */ +export const isAgentReply = (message: LocalMessage) => message.custom?.ai_generated === true; diff --git a/examples/ExpoCrewAssistant/agent/agentTransport.ts b/examples/ExpoCrewAssistant/agent/agentTransport.ts new file mode 100644 index 0000000000..7749568915 --- /dev/null +++ b/examples/ExpoCrewAssistant/agent/agentTransport.ts @@ -0,0 +1,161 @@ +import type { ConversationSnapshot, Session, UIAction } from '@stream-io/calypso'; +import type { Channel } from 'stream-chat'; +import { + type AIAgentActivity, + AIAgentActivityStates, + type AIConversationTransport, + type AIMessage, + type AIMessagePartData, + type AIStreamMessage, + createFromStreamMessage, +} from 'stream-chat-expo/ai-components'; + +import type { AgentChat } from './agentChat'; +import { DISPLAY_TITLES } from './crewTools'; +import { toDataUri } from './photos'; + +/** What the person did in a reply's UI, which a request carries in its `custom` data. */ +export type AgentRequestCustom = { uiAction?: UIAction }; + +export type AgentTransportOptions = { + /** Asks the agent in `session`, with photos as data URIs, a reasoning effort and a UI action. */ + respond: ( + session: Session, + text: string, + options: { images: { url: string }[]; reasoningEffort?: string; uiAction?: UIAction }, + ) => Promise; + /** Called once asking created the conversation, so the drawer lists it. */ + onCreated: () => void; +}; + +// What the agent is doing, from the session's socket: thinking while it responds, and the skills +// it runs while it works. +const activityOf = (snapshot: ConversationSnapshot): AIAgentActivity | undefined => { + if (snapshot.kind === 'responding') return { state: AIAgentActivityStates.thinking }; + if (snapshot.kind === 'working') { + return { + state: AIAgentActivityStates.checkingExternalSources, + text: `Working on ${snapshot.skills.map((skill) => skill.replace(/_/g, ' ')).join(' and ')}`, + }; + } + return undefined; +}; + +/** + * Titles the steps of the backend's tools, which the router shows without one, from + * `DISPLAY_TITLES`. The same message gives back the same object, as `adapt` does. + */ +const withTitles = (adapt: (message: AIStreamMessage) => AIMessage) => { + const titled = new WeakMap(); + return (message: AIStreamMessage) => { + const adapted = adapt(message); + let shown = titled.get(adapted); + if (!shown) { + const untitled = (part: AIMessagePartData) => + part.toolCall && !part.toolCall.displayTitle && DISPLAY_TITLES[part.toolCall.name]; + shown = adapted.parts?.some(untitled) + ? { + ...adapted, + parts: adapted.parts.map((part) => + untitled(part) && part.toolCall + ? { + ...part, + toolCall: { + ...part.toolCall, + displayTitle: DISPLAY_TITLES[part.toolCall.name], + }, + } + : part, + ), + } + : adapted; + titled.set(adapted, shown); + } + return shown; + }; +}; + +/** + * The router's conversation as an `AIConversationController` follows it: the messages of the + * channel `AgentChat` reads, and what the agent is doing from the session's socket. Asking goes + * through the session, which records the question in the channel and answers it there. The + * router has no regenerate or edit of its own: those ask again, as a new question. + */ +export const createAgentTransport = ( + chat: AgentChat, + { onCreated, respond }: AgentTransportOptions, +): AIConversationTransport => ({ + connect: (feed) => { + // The same message object for each Stream message that didn't change, so only the messages + // an update touched render again. The live thinking is read through `chat.reasoning`. + const fromStreamMessage = withTitles( + createFromStreamMessage({ + reasoningText: (messageId, stepId) => chat.reasoning.get(messageId, stepId), + }), + ); + let followedChannel: Channel | undefined; + let stopFollowingMessages: (() => void) | undefined; + let followedSession: Session | undefined; + let stopFollowingSession: (() => void) | undefined; + const unsubscribe = chat.state.subscribe(({ channel, session }) => { + if (channel !== followedChannel) { + stopFollowingMessages?.(); + followedChannel = channel; + // The messages as the channel's paginator holds them, oldest first: a new array on each + // change, in which only the messages that changed are new objects. + stopFollowingMessages = channel?.messagePaginator.state.subscribeWithSelector( + ({ hasMoreTail, items }) => ({ hasMoreTail, items }), + ({ hasMoreTail, items }) => { + if (!items) return; + feed.updateMessages(items.map(fromStreamMessage)); + feed.updateHasOlderMessages(hasMoreTail); + }, + ); + } + if (session === followedSession) return; + stopFollowingSession?.(); + followedSession = session; + stopFollowingSession = session?.state.subscribe(() => + feed.updateActivity(activityOf(session.state.get())), + ); + if (session) feed.updateActivity(activityOf(session.state.get())); + }); + return () => { + unsubscribe(); + stopFollowingMessages?.(); + stopFollowingSession?.(); + }; + }, + loadOlderMessages: () => chat.loadOlderMessages(), + send: async ({ attachments, custom, text }) => { + // From now on the conversation is used, even before Stream's copy of the question arrives. + chat.wasAsked = true; + chat.lastRequest = { custom: custom as Record | undefined, text }; + const photos = attachments + .filter((asset) => !asset.type || asset.type.startsWith('image')) + .map((asset) => asset.uri); + try { + // A new conversation is created here, with its first question. + const wasNew = chat.id === undefined; + const [session, images] = await Promise.all([ + chat.session(), + Promise.all(photos.map(async (uri) => ({ url: await toDataUri(uri) }))), + ]); + if (wasNew) onCreated(); + await respond(session, text, { + images, + uiAction: (custom as AgentRequestCustom | undefined)?.uiAction, + }); + } catch (error) { + // A conversation created for this question, which it never reached, isn't left listed. + await chat.discardUnusedSession(); + throw error; + } + }, + // A reply that was being written when the conversation was opened stops through its session, + // which opens for it. + stop: async () => { + const session = await chat.session(); + await session.interrupt(); + }, +}); diff --git a/examples/ExpoCrewAssistant/agent/agents.ts b/examples/ExpoCrewAssistant/agent/agents.ts new file mode 100644 index 0000000000..e3e73c672f --- /dev/null +++ b/examples/ExpoCrewAssistant/agent/agents.ts @@ -0,0 +1,73 @@ +import { Client } from '@stream-io/calypso'; +// Lets `session.chat()` open the conversation's channel on the app's Stream Chat client. +import '@stream-io/calypso/chat'; + +import { AgentConfig } from './config'; + +/** The router, as this app's user. */ +export const agents = new Client({ + apiKey: AgentConfig.streamApiKey, + customerId: AgentConfig.customerId, + url: AgentConfig.routerUrl, +}); + +/** The agent's conversations: opening a new one and carrying an old one on. */ +export const agentSessions = () => agents.agent(AgentConfig.agentName).sessions; + +/** + * Signs in as `userId` with the crew backend, which answers the user's Stream token. The chat, + * the router and the backend itself all take it. It stands in for the restaurant's own sign-in. + */ +export const streamTokenFor = async (userId: string): Promise => { + const response = await fetch(`${AgentConfig.backendUrl}/stream-token`, { + body: JSON.stringify({ user_id: userId }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + if (!response.ok) { + throw new Error(`The token backend answered ${response.status}: is \`go run ./backend\` up?`); + } + const { token } = (await response.json()) as { token?: string }; + if (!token) { + throw new Error('The token backend answered without a token.'); + } + return token; +}; + +/** This app's user's Stream token. */ +export const streamToken = () => streamTokenFor(AgentConfig.user.id); + +// A token is used for 50 minutes of its hour, so one never expires on the way. +const TOKEN_REUSED_FOR_MS = 50 * 60 * 1000; +const signedIn = new Map }>(); + +const tokenOf = (userId: string) => { + const held = signedIn.get(userId); + if (held && held.expiresAt > Date.now()) return held.token; + const token = streamTokenFor(userId); + signedIn.set(userId, { expiresAt: Date.now() + TOKEN_REUSED_FOR_MS, token }); + token.catch(() => signedIn.delete(userId)); + return token; +}; + +/** + * Asks the crew backend as `userId`, this app's user unless another is named, with their Stream + * token. It answers the JSON the backend answered. + */ +export const crewBackend = async ( + path: string, + { body, userId = AgentConfig.user.id }: { body?: unknown; userId?: string } = {}, +) => { + const response = await fetch(`${AgentConfig.backendUrl}/${path}`, { + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + headers: { + Authorization: `Bearer ${await tokenOf(userId)}`, + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + method: body === undefined ? 'GET' : 'POST', + }); + if (!response.ok) { + throw new Error(`The crew backend answered ${response.status}: ${await response.text()}`); + } + return (await response.json()) as Record; +}; diff --git a/examples/ExpoCrewAssistant/agent/config.ts b/examples/ExpoCrewAssistant/agent/config.ts new file mode 100644 index 0000000000..e7f66eed3a --- /dev/null +++ b/examples/ExpoCrewAssistant/agent/config.ts @@ -0,0 +1,38 @@ +/** + * Where the Vision-Agents router and the crew backend are, and who this app is. Set in + * `.env.local`, which `.env.example` describes. Only public values belong there: Expo inlines every + * `EXPO_PUBLIC_` variable into the bundle, so the Stream secret stays with the backend. + */ + +const streamApiKey = process.env.EXPO_PUBLIC_STREAM_API_KEY; + +if (!streamApiKey) { + throw new Error( + 'EXPO_PUBLIC_STREAM_API_KEY is not set: copy .env.example to .env.local and fill it in.', + ); +} + +export const AgentConfig = { + /** The Stream app the router runs in. Its channels hold the conversations. */ + streamApiKey, + /** A router of your own. Unset, the app talks to Stream's hosted router. */ + routerUrl: process.env.EXPO_PUBLIC_AGENT_ROUTER_URL || undefined, + /** + * The crew backend (`crew_assistant/backend`): it mints this user's Stream token, holds the + * restaurant's schedule, handbook, equipment and leaders' inbox, and makes the agent speak first. + */ + backendUrl: process.env.EXPO_PUBLIC_CREW_BACKEND_URL || 'http://localhost:8097', + /** The customer a local router without keys stores the agent under. Unset on the hosted router. */ + customerId: process.env.EXPO_PUBLIC_AGENT_CUSTOMER_ID || undefined, + /** The agent, by the name in its `agent.yaml`. */ + agentName: process.env.EXPO_PUBLIC_AGENT_NAME || 'crew_assistant', + user: { + id: process.env.EXPO_PUBLIC_AGENT_USER_ID || 'crew-jordan', + name: 'Jordan Lee', + }, + /** Who the team lead view signs in as: a user whose crew role on the backend is leader. */ + leader: { + id: process.env.EXPO_PUBLIC_AGENT_LEADER_ID || 'crew-maya', + name: 'Maya Chen', + }, +} as const; diff --git a/examples/ExpoCrewAssistant/agent/crewTools.ts b/examples/ExpoCrewAssistant/agent/crewTools.ts new file mode 100644 index 0000000000..7093e61aef --- /dev/null +++ b/examples/ExpoCrewAssistant/agent/crewTools.ts @@ -0,0 +1,142 @@ +import type { ToolApproval } from '@stream-io/calypso'; + +import { type AIClientTool, completedToolResult } from 'stream-chat-expo/ai-components'; + +import { crewBackend } from './agents'; + +type Schema = Record; + +const object = (properties: Record, required: string[] = []): Schema => ({ + properties, + required, + type: 'object', +}); +const text = (description: string): Schema => ({ description, type: 'string' }); + +const tool = ( + name: string, + description: string, + inputSchema: Schema, + run: (input: Record) => Promise, + summary: string, + readOnly = true, +): AIClientTool => ({ + definition: { + annotations: { readOnlyHint: readOnly }, + description, + inputSchema, + name, + }, + run: async (call) => + completedToolResult(await run((call.arguments ?? {}) as Record), summary), +}); + +const localContext: AIClientTool = { + definition: { + annotations: { readOnlyHint: true }, + description: + "The person's current date, local time, timezone and language. Call it before answering anything that depends on today or the time.", + inputSchema: { properties: {}, type: 'object' }, + name: 'get_local_context', + }, + run: () => { + const now = new Date(); + const { locale, timeZone } = Intl.DateTimeFormat().resolvedOptions(); + return completedToolResult( + { language: locale, local: now.toString(), timeZone, utc: now.toISOString() }, + 'Shared the local time', + ); + }, +}; + +/** + * The phone's tools: its clock, and the three actions the person says yes to here before they + * run. The restaurant's other tools run on the crew backend, which hosts them on the router. + */ +export const CREW_TOOLS: AIClientTool[] = [ + localContext, + tool( + 'request_shift_swap', + 'Asks a teammate to take one of the person’s shifts. The phone asks the person first.', + object( + { + note: text('A short note to the teammate'), + shift_id: text('The shift to give away'), + teammate_id: text('The teammate, from find_coverage'), + }, + ['shift_id', 'teammate_id'], + ), + (body) => crewBackend('crew/swaps', { body }), + 'Asked for a swap', + false, + ), + tool( + 'create_support_ticket', + 'Drafts a repair ticket for the owner to review and submit. The phone asks the person first.', + object( + { + asset: text('The asset id'), + details: text('What they saw and already tried'), + priority: { enum: ['low', 'normal', 'urgent'], type: 'string' }, + summary: text('The problem, in a short line'), + }, + ['summary', 'details'], + ), + (body) => crewBackend('crew/tickets', { body }), + 'Drafted a ticket', + false, + ), + tool( + 'book_check_in', + 'Books a 1-on-1 check-in with a leader or trainer. The phone asks the person first.', + object( + { + topic: text('What it is about'), + when: text('When, such as "Thursday 14:00"'), + with: text('Who, by id or name, from get_team'), + }, + ['with', 'when'], + ), + (body) => crewBackend('crew/check-ins', { body }), + 'Booked a check-in', + false, + ), +]; + +/** + * What a call says it is doing on the reply's step, by tool: the phone's own tools declare theirs, + * and the conversation gives the backend's theirs. + */ +export const DISPLAY_TITLES: Record = { + book_check_in: 'Booking a check-in', + calcom__call_tool: 'Using your Cal.com', + calcom__list_tools: 'Opening your Cal.com', + check_callout: 'Checking the call-out policy', + create_support_ticket: 'Drafting a repair ticket', + find_coverage: 'Finding who can cover', + get_equipment_guide: 'Looking up the quick fix', + get_local_context: "Checking your phone's time", + get_menu: 'Reading the menu', + get_my_schedule: 'Reading your schedule', + get_team: 'Looking up the team', + get_updates_since: 'Reading the announcements', + notify_shift_leader: 'Telling the shift leader', + request_shift_swap: 'Asking for a swap', + save_shift_reflection: 'Saving your reflection', + search_handbook: 'Searching the handbook', +}; + +/** What the person is asked before a call runs, by tool. */ +export const APPROVALS: Record = { + book_check_in: { allowTitle: 'Book it', title: 'Book this check-in?' }, + create_support_ticket: { + allowTitle: 'Send for review', + message: 'The owner reviews it before it goes to repairs.', + title: 'Draft this repair ticket?', + }, + request_shift_swap: { + allowTitle: 'Send request', + message: 'Your teammate and the shift leader see it.', + title: 'Ask a teammate to take your shift?', + }, +}; diff --git a/examples/ExpoCrewAssistant/agent/deviceTools.ts b/examples/ExpoCrewAssistant/agent/deviceTools.ts new file mode 100644 index 0000000000..7c4d551db0 --- /dev/null +++ b/examples/ExpoCrewAssistant/agent/deviceTools.ts @@ -0,0 +1,108 @@ +import { Tools } from '@stream-io/calypso'; + +import { + type AIClientToolResult, + AIClientToolRunner, + type AIToolCallPart, +} from 'stream-chat-expo/ai-components'; + +import { APPROVALS, CREW_TOOLS, DISPLAY_TITLES } from './crewTools'; + +const DEVICE_TOOLS = CREW_TOOLS; + +// A call that asks first waits for the person, which takes longer than a call should. +const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000; + +/** + * The results the runner reported, by call, for the session's socket to answer with. A call can + * reach the socket before its step shows it waiting for this phone, or after. + */ +class Reports { + private readonly reported = new Map(); + private readonly waiting = new Map void>(); + + report(callId: string, result: AIClientToolResult) { + const waiting = this.waiting.get(callId); + this.waiting.delete(callId); + if (waiting) waiting(result); + else this.reported.set(callId, result); + } + + wait(callId: string, signal: AbortSignal) { + return new Promise((resolve, reject) => { + const reported = this.reported.get(callId); + this.reported.delete(callId); + if (reported) return resolve(reported); + this.waiting.set(callId, resolve); + signal.addEventListener('abort', () => { + this.waiting.delete(callId); + reject(signal.reason); + }); + }); + } +} + +/** + * The phone's tools, run by the AI components' `AIClientToolRunner` from each reply's steps, as + * an app whose backend holds the session would run them. + * + * Here the phone holds the session too. The router sends it each call over the session's socket + * and, for a question that named this install, shows the call on the reply as waiting for it. The + * runner runs it from the reply, and the socket answers the router with what the runner reported. + * A call no step addresses to this phone, from a question that couldn't name the install, is run + * by the socket itself. + */ +export const createDeviceTools = ({ clientId, userId }: { clientId: string; userId: string }) => { + const reports = new Reports(); + const runner = new AIClientToolRunner({ clientId, tools: DEVICE_TOOLS, userId }); + + const runUnaddressed = async (name: string, call: AIToolCallPart) => { + const tool = DEVICE_TOOLS.find(({ definition }) => definition.name === name); + try { + return (await tool?.run(call)) ?? { failure: `This phone has no ${name}` }; + } catch { + return { failure: "The tool couldn't run" }; + } + }; + + const deviceTools = { + /** Whether the latest question named this install, so its calls wait for this phone. */ + addressed: false, + runner, + /** Hands the runner's result to the session's socket. */ + send: (callId: string, result: AIClientToolResult) => { + reports.report(callId, result); + return Promise.resolve(); + }, + /** How the router is told of the tools: declared on the session, with the socket's answer. */ + sessionTools: () => { + const tools = new Tools(); + for (const { description, name, parameters } of runner.registrations) { + tools.register({ + approval: APPROVALS[name], + ...(APPROVALS[name] ? { timeoutMs: APPROVAL_TIMEOUT_MS } : {}), + description, + displayTitle: DISPLAY_TITLES[name], + executor: 'client', + name, + parameters, + run: async (input, signal, call) => { + const { failure, output } = deviceTools.addressed + ? await reports.wait(call.id, signal) + : await runUnaddressed(name, { + arguments: input, + executor: 'client', + id: call.id, + name, + status: 'running', + }); + if (failure) throw new Error(failure); + return output; + }, + }); + } + return tools; + }, + }; + return deviceTools; +}; diff --git a/examples/ExpoCrewAssistant/agent/photos.ts b/examples/ExpoCrewAssistant/agent/photos.ts new file mode 100644 index 0000000000..3dc3d8d0bf --- /dev/null +++ b/examples/ExpoCrewAssistant/agent/photos.ts @@ -0,0 +1,14 @@ +import { manipulateAsync, SaveFormat } from 'expo-image-manipulator'; + +// The router takes photos inline, so they are shrunk first. +const MAX_IMAGE_WIDTH = 1280; + +/** A photo the person picked, as the data URI the router takes. */ +export const toDataUri = async (uri: string) => { + const { base64 } = await manipulateAsync(uri, [{ resize: { width: MAX_IMAGE_WIDTH } }], { + base64: true, + compress: 0.7, + format: SaveFormat.JPEG, + }); + return `data:image/jpeg;base64,${base64}`; +}; diff --git a/examples/ExpoCrewAssistant/agent/polyfills.ts b/examples/ExpoCrewAssistant/agent/polyfills.ts new file mode 100644 index 0000000000..988a998f4f --- /dev/null +++ b/examples/ExpoCrewAssistant/agent/polyfills.ts @@ -0,0 +1,53 @@ +// Hermes has no `crypto.randomUUID`. Ported from stream-chat-js's `generateUUIDv4`. +/* eslint-disable no-bitwise -- RFC 4122 version and variant bits */ + +type CryptoLike = { + getRandomValues?: (bytes: Uint8Array) => Uint8Array; + randomUUID?: () => string; +}; + +const globalWithCrypto = globalThis as { crypto?: CryptoLike }; + +const getRandomValuesWithMathRandom = (bytes: Uint8Array) => { + const max = Math.pow(2, (8 * bytes.byteLength) / bytes.length); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Math.random() * max; + } + return bytes; +}; + +const hex = (bytes: Uint8Array) => { + let s = ''; + for (let i = 0; i < bytes.length; i++) { + s += bytes[i].toString(16).padStart(2, '0'); + } + return s; +}; + +// https://tools.ietf.org/html/rfc4122 +const generateUUIDv4 = () => { + const getRandomValues = + globalWithCrypto.crypto?.getRandomValues?.bind(globalWithCrypto.crypto) ?? + getRandomValuesWithMathRandom; + const bytes = getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version + bytes[8] = (bytes[8] & 0xbf) | 0x80; // variant + + return ( + hex(bytes.subarray(0, 4)) + + '-' + + hex(bytes.subarray(4, 6)) + + '-' + + hex(bytes.subarray(6, 8)) + + '-' + + hex(bytes.subarray(8, 10)) + + '-' + + hex(bytes.subarray(10, 16)) + ); +}; + +if (typeof globalWithCrypto.crypto?.randomUUID !== 'function') { + globalWithCrypto.crypto = Object.assign(globalWithCrypto.crypto ?? {}, { + randomUUID: generateUUIDv4, + }); +} diff --git a/examples/ExpoCrewAssistant/agent/useAgentMessages.ts b/examples/ExpoCrewAssistant/agent/useAgentMessages.ts new file mode 100644 index 0000000000..9de35641a3 --- /dev/null +++ b/examples/ExpoCrewAssistant/agent/useAgentMessages.ts @@ -0,0 +1,30 @@ +import type { MessagePaginatorState } from 'stream-chat'; +import { useStateStore } from 'stream-chat-expo'; + +import type { AgentChat, AgentChatState } from './agentChat'; + +const channelSelector = ({ channel }: AgentChatState) => ({ channel }); + +/** + * Selects from the messages of `chat`'s channel, as its paginator holds them. `undefined` until the + * channel is followed. `selector` follows `useStateStore`'s rules: keep it stable. + */ +export const useAgentMessages = >>( + chat: AgentChat | undefined, + selector: (state: MessagePaginatorState) => T, +) => { + const channel = useStateStore(chat?.state, channelSelector)?.channel; + return useStateStore(channel?.messagePaginator.state, selector); +}; + +const loadedSelector = ({ items }: MessagePaginatorState) => ({ + isEmpty: !items?.length, + isLoaded: items !== undefined, +}); + +/** + * Whether the conversation's first page is in, and whether it holds no messages. A new + * conversation has none to wait for. + */ +export const useAgentChatLoaded = (chat: AgentChat) => + useAgentMessages(chat, loadedSelector) ?? { isEmpty: chat.isNew, isLoaded: chat.isNew }; diff --git a/examples/ExpoCrewAssistant/app.json b/examples/ExpoCrewAssistant/app.json new file mode 100644 index 0000000000..9891a3cb72 --- /dev/null +++ b/examples/ExpoCrewAssistant/app.json @@ -0,0 +1,38 @@ +{ + "expo": { + "name": "Crew", + "slug": "ExpoCrewAssistant", + "scheme": ["crewassistant", "io.getstream.expocrewassistant"], + "ios": { + "bundleIdentifier": "io.getstream.expocrewassistant", + "appleTeamId": "EHV7XZLAHA" + }, + "android": { + "permissions": ["android.permission.RECORD_AUDIO"], + "package": "io.getstream.expocrewassistant" + }, + "plugins": [ + "expo-router", + [ + "expo-audio", + { + "microphonePermission": "$(PRODUCT_NAME) records voice memos you send to Crew." + } + ], + [ + "expo-image-picker", + { + "photosPermission": "The app accesses your photos to let them share with others.", + "cameraPermission": "The app accesses your camera to let you take photos and share with others." + } + ], + [ + "stream-chat-expo", + { + "dictationMicrophoneUsageDescription": "$(PRODUCT_NAME) would like to access your microphone to capture your voice.", + "dictationSpeechRecognitionUsageDescription": "$(PRODUCT_NAME) would like to access speech recognition to transcribe your voice." + } + ] + ] + } +} diff --git a/examples/ExpoCrewAssistant/app/+native-intent.tsx b/examples/ExpoCrewAssistant/app/+native-intent.tsx new file mode 100644 index 0000000000..8f36edd902 --- /dev/null +++ b/examples/ExpoCrewAssistant/app/+native-intent.tsx @@ -0,0 +1,5 @@ +// A sign-in opened in a Custom Tab comes back to the app on its scheme (`io.getstream.expocrewassistant:// +// sign-in?…`), which the SDK's sign-in reads itself. Stay on the screen it was opened from rather +// than routing to a page that doesn't exist. +export const redirectSystemPath = ({ path }: { initial: boolean; path: string | null }) => + path?.includes('sign-in') ? null : path; diff --git a/examples/ExpoCrewAssistant/app/_layout.tsx b/examples/ExpoCrewAssistant/app/_layout.tsx new file mode 100644 index 0000000000..dd0c47aa8a --- /dev/null +++ b/examples/ExpoCrewAssistant/app/_layout.tsx @@ -0,0 +1,89 @@ +import '../agent/polyfills'; + +import React from 'react'; + +import { LogBox, Pressable, StyleSheet, Text } from 'react-native'; + +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { KeyboardProvider } from 'react-native-keyboard-controller'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; + +import { router } from 'expo-router'; +import { Drawer } from 'expo-router/drawer'; + +import type { LocalMessage } from 'stream-chat'; +import { Chat, OverlayProvider, useCreateChatClient } from 'stream-chat-expo'; + +import { StreamTheme } from 'stream-chat-expo/ai-components'; + +import { streamToken } from '../agent/agents'; +import { AgentConfig } from '../agent/config'; +import { AppProvider } from '../contexts/AppContext'; + +import { MenuDrawer } from '../screens/MenuDrawer'; + +LogBox.ignoreAllLogs(); + +const isMessageAIGenerated = (message: LocalMessage) => !!message.custom?.ai_generated; + +export default function Layout() { + const chatClient = useCreateChatClient({ + apiKey: AgentConfig.streamApiKey, + tokenOrProvider: streamToken, + userData: AgentConfig.user, + }); + + if (!chatClient) { + return null; + } + return ( + + + + + + + + + + + + + + + + + + + + ); +} + +const TeamLeadButton = () => ( + router.push('/leader')} + style={styles.teamLead} + testID='team-lead-view' + > + Team lead + +); + +const styles = StyleSheet.create({ + container: { flex: 1 }, + teamLead: { paddingHorizontal: 16, paddingVertical: 8 }, + teamLeadText: { color: '#C8102E', fontSize: 15, fontWeight: '700' }, +}); diff --git a/examples/ExpoCrewAssistant/app/equipment.tsx b/examples/ExpoCrewAssistant/app/equipment.tsx new file mode 100644 index 0000000000..2e9e0db93c --- /dev/null +++ b/examples/ExpoCrewAssistant/app/equipment.tsx @@ -0,0 +1,36 @@ +import { useEffect, useRef } from 'react'; +import { ActivityIndicator, StyleSheet, View } from 'react-native'; + +import { useLocalSearchParams, useRouter } from 'expo-router'; + +import { useAppContext } from '../contexts/AppContext'; + +/** + * Where a scanned equipment code leads: a QR code on a machine holds + * `crewassistant://equipment?asset=FRY-02&code=E-41`, which opens the app here. It starts a new + * conversation about that machine and its error, and goes back to the chat. + */ +export default function Equipment() { + const { asset, code } = useLocalSearchParams<{ asset?: string; code?: string }>(); + const { isReady, startConversationWith } = useAppContext(); + const router = useRouter(); + const started = useRef(false); + + useEffect(() => { + if (!isReady || started.current) return; + started.current = true; + const showing = code ? ` It shows error ${code}.` : ''; + startConversationWith(`I scanned the code on ${asset ?? 'a machine'}.${showing}`); + router.replace('/'); + }, [asset, code, isReady, router, startConversationWith]); + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + container: { alignItems: 'center', flex: 1, justifyContent: 'center' }, +}); diff --git a/examples/ExpoCrewAssistant/app/index.tsx b/examples/ExpoCrewAssistant/app/index.tsx new file mode 100644 index 0000000000..062b54b1a5 --- /dev/null +++ b/examples/ExpoCrewAssistant/app/index.tsx @@ -0,0 +1,6 @@ +import { ChatContent } from '../screens/ChatContent'; + +const Page = () => { + return ; +}; +export default Page; diff --git a/examples/ExpoCrewAssistant/app/leader.tsx b/examples/ExpoCrewAssistant/app/leader.tsx new file mode 100644 index 0000000000..5e7db80a80 --- /dev/null +++ b/examples/ExpoCrewAssistant/app/leader.tsx @@ -0,0 +1,151 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'; + +import { crewBackend } from '../agent/agents'; +import { AgentConfig } from '../agent/config'; + +type InboxItem = { + id: string; + kind: 'alert' | 'swap' | 'ticket' | 'check_in'; + from: string; + title: string; + details: string; + status: string; + at: string; +}; + +type Pulse = { + reflections: number; + average_energy?: number; + themes: Record; + wins: string[]; +}; + +const LABELS: Record = { + alert: '🚨 Call-out', + check_in: '📅 Check-in', + swap: '🔁 Shift swap', + ticket: '🛠️ Repair ticket', +}; + +/** + * What the shift leaders and the owner see of what the assistant did: call-out alerts, swap + * requests, drafted repair tickets and booked check-ins, and the team's pulse from end-of-shift + * reflections, without names. It signs in as a leader, since the crew backend answers the + * leaders' inbox only to a user whose crew role is leader. + */ +export default function LeaderView() { + const [inbox, setInbox] = useState([]); + const [pulse, setPulse] = useState(); + const [failure, setFailure] = useState(); + const [refreshing, setRefreshing] = useState(false); + + const load = useCallback(async () => { + try { + const read = (await crewBackend('crew/leader', { userId: AgentConfig.leader.id })) as { + inbox: InboxItem[]; + pulse: Pulse; + }; + setInbox(read.inbox); + setPulse(read.pulse); + setFailure(undefined); + } catch (error) { + setFailure(error instanceof Error ? error.message : String(error)); + } + }, []); + + useEffect(() => { + void load(); + const timer = setInterval(() => void load(), 3000); + return () => clearInterval(timer); + }, [load]); + + const refresh = async () => { + setRefreshing(true); + await load(); + setRefreshing(false); + }; + + const themes = Object.entries(pulse?.themes ?? {}).sort(([, a], [, b]) => b - a); + + return ( + } + > + Signed in as {AgentConfig.leader.name}, shift leader + {failure ? {failure} : null} + Inbox + {inbox.length === 0 ? ( + Nothing yet. Call-outs, swaps and tickets show up here. + ) : ( + inbox.map((item) => ( + + {LABELS[item.kind] ?? item.kind} + {item.title} + {item.details ? {item.details} : null} + + {item.from} · {new Date(item.at).toLocaleTimeString([], { timeStyle: 'short' })} ·{' '} + {item.status} + + + )) + )} + + Team pulse + {!pulse || pulse.reflections === 0 ? ( + No end-of-shift reflections yet. + ) : ( + + + {pulse.reflections} reflection{pulse.reflections === 1 ? '' : 's'} · energy{' '} + {pulse.average_energy?.toFixed(1)}/5 + + + {themes.map(([theme, count]) => ( + + {theme} {count > 1 ? `×${count}` : ''} + + ))} + + {pulse.wins.map((win) => ( + + “{win}” + + ))} + Shared without names. + + )} + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: '#FFFFFF', + borderColor: '#E6E8EC', + borderRadius: 14, + borderWidth: 1, + gap: 4, + marginBottom: 10, + padding: 14, + }, + container: { backgroundColor: '#F7F8FA', flexGrow: 1, padding: 16 }, + details: { color: '#33394A', fontSize: 14 }, + empty: { color: '#5C6370', fontSize: 14, marginBottom: 12 }, + failure: { color: '#C8102E', fontSize: 13, marginTop: 6 }, + heading: { color: '#0B1220', fontSize: 20, fontWeight: '800', marginBottom: 10, marginTop: 8 }, + kind: { color: '#C8102E', fontSize: 12, fontWeight: '700' }, + meta: { color: '#8A919E', fontSize: 12, marginTop: 4 }, + theme: { + backgroundColor: '#FDECEE', + borderRadius: 10, + color: '#C8102E', + fontSize: 13, + overflow: 'hidden', + paddingHorizontal: 8, + paddingVertical: 3, + }, + themes: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginVertical: 4 }, + title: { color: '#0B1220', fontSize: 15, fontWeight: '700' }, +}); diff --git a/examples/ExpoCrewAssistant/babel.config.js b/examples/ExpoCrewAssistant/babel.config.js new file mode 100644 index 0000000000..5b84251884 --- /dev/null +++ b/examples/ExpoCrewAssistant/babel.config.js @@ -0,0 +1,12 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + plugins: [ + // Needed by `stream-chat-expo/ai-components`: its `zod` dependency ships `export * as`. + '@babel/plugin-transform-export-namespace-from', + // react-native-worklets/plugin has to be the last one + 'react-native-worklets/plugin', + ], + }; +}; diff --git a/examples/ExpoCrewAssistant/components/VoiceMode/VoiceMode.tsx b/examples/ExpoCrewAssistant/components/VoiceMode/VoiceMode.tsx new file mode 100644 index 0000000000..935778df95 --- /dev/null +++ b/examples/ExpoCrewAssistant/components/VoiceMode/VoiceMode.tsx @@ -0,0 +1,139 @@ +import { useState } from 'react'; +import { Modal, Pressable, StyleSheet, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import Svg, { Line } from 'react-native-svg'; + +import { + StreamTheme, + useDictationAudioLevel, + useTheme, + type VoiceAgentState, + VoiceGlow, + VoiceWave, +} from 'stream-chat-expo/ai-components'; + +import { useVoiceConversation } from './useVoiceConversation'; + +const BACKDROP = '#0b0b10'; +const COLORS = ['#005fff', '#7b5cff', '#00c2ff']; + +const LABELS: Record = { + connecting: 'Connecting…', + idle: 'Tap the glow to talk', + listening: 'Listening', + speaking: 'Speaking', + thinking: 'Thinking', +}; + +const VoiceModeOverlay = ({ onClose }: { onClose: () => void }) => { + const { top, bottom } = useSafeAreaInsets(); + const { error, listen, phase, userIsTalking } = useVoiceConversation(true); + const micLevel = useDictationAudioLevel(); + + return ( + + + + + ✕ + + + + + + + {error ? 'Couldn’t hear you. Tap the glow to try again.' : LABELS[phase]} + + + + + ); +}; + +/** + * A static waveform icon. The button stays still: a live `VoiceGlow` here would redraw every frame + * for as long as the chat is open. + */ +const VoiceIcon = ({ color, size }: { color: string; size: number }) => ( + + {[ + [3, 8, 14], + [7, 4, 18], + [11, 2, 20], + [15, 5, 17], + [19, 9, 13], + ].map(([x, y1, y2]) => ( + + ))} + +); + +/** + * Opens a full-screen voice conversation with the AI agent: a button after the composer, the size + * of the attach button before it. + */ +export const VoiceMode = () => { + const [open, setOpen] = useState(false); + const { + theme: { composer }, + } = useTheme(); + const size = composer.inputPillHeight; + + return ( + <> + setOpen(true)} + style={({ pressed }) => [ + styles.button, + { borderRadius: size / 2, height: size, width: size }, + pressed && styles.buttonPressed, + ]} + > + + + setOpen(false)} visible={open}> + {open ? setOpen(false)} /> : null} + + + ); +}; + +const styles = StyleSheet.create({ + button: { + alignItems: 'center', + backgroundColor: '#ffffff', + borderColor: '#ececec', + borderWidth: 1, + justifyContent: 'center', + overflow: 'hidden', + }, + buttonPressed: { opacity: 0.8 }, + close: { color: '#ffffff', fontSize: 22 }, + glow: { aspectRatio: 1, width: '100%' }, + glowContainer: { alignSelf: 'stretch', flex: 1, justifyContent: 'center' }, + header: { alignItems: 'flex-end', paddingHorizontal: 20 }, + label: { color: '#c9cbd3', fontSize: 17, marginVertical: 16, textAlign: 'center' }, + overlay: { backgroundColor: BACKDROP, flex: 1 }, + wave: { alignSelf: 'stretch', height: 140 }, +}); diff --git a/examples/ExpoCrewAssistant/components/VoiceMode/index.ts b/examples/ExpoCrewAssistant/components/VoiceMode/index.ts new file mode 100644 index 0000000000..abe97f1086 --- /dev/null +++ b/examples/ExpoCrewAssistant/components/VoiceMode/index.ts @@ -0,0 +1 @@ +export * from './VoiceMode'; diff --git a/examples/ExpoCrewAssistant/components/VoiceMode/useVoiceConversation.ts b/examples/ExpoCrewAssistant/components/VoiceMode/useVoiceConversation.ts new file mode 100644 index 0000000000..3c6d461d09 --- /dev/null +++ b/examples/ExpoCrewAssistant/components/VoiceMode/useVoiceConversation.ts @@ -0,0 +1,159 @@ +import { useEffect, useRef, useState } from 'react'; + +import * as Speech from 'expo-speech'; + +import { useStableCallback } from 'stream-chat-expo'; + +import { + dictationStore, + useDictation, + useDictationState, + useDictationTranscript, + type VoiceAgentState, +} from 'stream-chat-expo/ai-components'; + +import { useAppContext } from '../../contexts/AppContext'; +import { useSendAIMessage } from '../../hooks/useSendAIMessage'; + +/** A turn the agent is never seen working on ends after this long. */ +const TURN_TIMEOUT_MS = 20_000; + +// Marks voice mode's dictation as its own, so the composer underneath ignores it. +const DICTATION_SOURCE = { source: 'voice-mode' }; + +/** Markdown read aloud as plain words. */ +const toSpeech = (text: string) => + text + .replace(/```[\s\S]*?```/g, ' ') + .replace(/!\[[^\]]*]\([^)]*\)/g, ' ') + .replace(/\[([^\]]+)]\([^)]*\)/g, '$1') + .replace(/[*_#>`~|]/g, '') + .replace(/\s+/g, ' ') + .trim(); + +/** + * A hands-free conversation with the AI agent: it listens with dictation, sends what it heard, + * waits for the agent to answer, reads the answer aloud and listens again. + * + * Returns what the agent is doing, for `VoiceGlow`, and whether the user is being listened to, + * for `VoiceWave`. + */ +export const useVoiceConversation = (active: boolean) => { + const [phase, setPhase] = useState('idle'); + const { start, cancel } = useDictation(undefined, DICTATION_SOURCE); + const { state: dictationState, error } = useDictationState(); + const { transcript } = useDictationTranscript(); + const send = useSendAIMessage(); + const { agentState, chat } = useAppContext(); + // The agent answering, from the session's socket. + const isWorking = agentState.kind === 'responding' || agentState.kind === 'working'; + + // The messages already in the channel when the question was sent, so the reply is found by + // identity rather than by comparing the server's timestamps with the device's clock. + const turn = useRef<{ seenIds: Set; sawWorking: boolean } | null>(null); + const timeout = useRef | null>(null); + // An error left over from an earlier turn, ignored until dictation starts again and clears it. + const staleError = useRef(dictationStore.getLatestValue().error); + + const listen = useStableCallback(() => { + if (!active) return; + turn.current = null; + staleError.current = dictationStore.getLatestValue().error; + setPhase('connecting'); + void start(); + }); + + const latestReply = useStableCallback(() => { + const seenIds = turn.current?.seenIds ?? new Set(); + const messages = chat?.messages ?? []; + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (seenIds.has(message.id)) break; + if (message.custom?.ai_generated && message.text) return message.text; + } + return null; + }); + + /** Ends the agent's turn: reads its answer aloud if there is one, then listens again. */ + const finishTurn = useStableCallback(() => { + if (timeout.current) clearTimeout(timeout.current); + timeout.current = null; + const reply = latestReply(); + turn.current = null; + if (!active) return; + if (!reply) { + listen(); + return; + } + setPhase('speaking'); + Speech.speak(toSpeech(reply), { + onDone: listen, + onError: listen, + // Its own audio session on iOS, so the reply is heard whatever dictation left the app's in. + useApplicationAudioSession: false, + }); + }); + + // Starts listening when voice mode opens, and stops everything when it closes. + useEffect(() => { + if (!active) return; + listen(); + return () => { + if (timeout.current) clearTimeout(timeout.current); + timeout.current = null; + turn.current = null; + cancel(); + void Speech.stop(); + setPhase('idle'); + }; + }, [active, cancel, listen]); + + // Follows dictation: listening while it records, and sends what it heard once it stops. + useEffect(() => { + if (!active || turn.current) return; + if (dictationState === 'listening') { + setPhase('listening'); + return; + } + if (dictationState !== 'idle' || (phase !== 'listening' && phase !== 'connecting')) return; + if (error && error !== staleError.current) { + setPhase('idle'); + return; + } + if (phase === 'connecting') return; + const text = transcript.trim(); + if (!text) { + // Nothing was heard; keep listening. + listen(); + return; + } + turn.current = { + sawWorking: false, + seenIds: new Set((chat?.messages ?? []).map((message) => message.id)), + }; + setPhase('thinking'); + void send({ text }); + timeout.current = setTimeout(() => { + if (!turn.current?.sawWorking) finishTurn(); + }, TURN_TIMEOUT_MS); + }, [active, chat, dictationState, error, finishTurn, listen, phase, send, transcript]); + + // Follows the agent: its turn ends once it has been seen working and is done. + useEffect(() => { + const current = turn.current; + if (!active || !current) return; + if (isWorking) { + current.sawWorking = true; + } else if (current.sawWorking) { + finishTurn(); + } + }, [active, isWorking, finishTurn]); + + return { + // Only an error from the current attempt, not one left over while dictation restarts. + error: phase === 'idle' ? error : null, + listen, + phase, + userIsTalking: phase === 'listening', + }; +}; diff --git a/examples/ExpoCrewAssistant/contexts/AppContext.tsx b/examples/ExpoCrewAssistant/contexts/AppContext.tsx new file mode 100644 index 0000000000..5e8577f533 --- /dev/null +++ b/examples/ExpoCrewAssistant/contexts/AppContext.tsx @@ -0,0 +1,397 @@ +import type { PropsWithChildren } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; + +import { type ConversationSnapshot, type Session, type UIAction } from '@stream-io/calypso'; +import type { MessagePaginatorState, StreamChat } from 'stream-chat'; +import { useStableCallback, useStateStore } from 'stream-chat-expo'; +import { + AIClientIdentity, + AIConversationController, + type AIToolApprover, + createAuthSessionAuthorizer, + parseMessageParts, +} from 'stream-chat-expo/ai-components'; + +import { AgentChat, type AgentChatState, isAgentReply, type OpenSession } from '../agent/agentChat'; +import { agents, agentSessions, crewBackend, streamToken } from '../agent/agents'; +import { createAgentTransport } from '../agent/agentTransport'; +import { AgentConfig } from '../agent/config'; +import { createDeviceTools } from '../agent/deviceTools'; +import { useAgentMessages } from '../agent/useAgentMessages'; + +const IDLE: ConversationSnapshot = { kind: 'idle', skills: [] }; + +// This install, for the tool calls addressed to it. +const clientId = AIClientIdentity.installId(); + +// The tools this phone runs for the agent, such as telling it the local time. +const deviceTools = createDeviceTools({ clientId, userId: AgentConfig.user.id }); + +// Where a sign-in comes back to: the app's own scheme, which the web auth session hands back. +// The router takes only https or a native app's own scheme in reverse domain form (RFC 8252). +const SIGN_IN_SCHEME = 'io.getstream.expocrewassistant'; +const SIGN_IN_RETURN_URL = `${SIGN_IN_SCHEME}://sign-in`; + +// The person's answer to a call that asks first. calypso's `decide` answers only a call this +// session was asked on its own socket, and leaves any other alone, so a question shown from the +// channel, as after reopening the conversation or reloading the app, would wait forever. Such a +// call is answered with the frames `decide` sends, which the router matches by the call's id: an +// allowed client tool's call then waits for this phone, whose runner runs it. +const answerApproval = (session: Session, toolCallId: string, allowed: boolean) => { + if (session.approvals.some((request) => request.id === toolCallId)) { + session.decide(toolCallId, { allowed }); + return; + } + const socket = ( + session as unknown as { held(): { send(frame: Record): void } } + ).held(); + socket.send({ allowed, tool_call_id: toolCallId, type: 'tool_approval' }); + if (!allowed) { + socket.send({ + error: 'The person declined this call.', + tool_call_id: toolCallId, + type: 'tool_result', + }); + } +}; + +// Asks, with calypso's `responses.create`, which takes photos, a reasoning effort and what the +// person did in a reply's UI. The router's `respond`, which addresses a client tool's call to the +// install a question came from, is only for an app's backend, and this is a phone: the calls reach +// the device's tools without an install, and the phone runs them itself. +const respond = async ( + session: Session, + text: string, + options: { images: { url: string }[]; reasoningEffort?: string; uiAction?: UIAction }, +) => { + deviceTools.addressed = false; + await session.responses.create(text, { + ...(options.images.length > 0 ? { images: options.images } : {}), + ...(options.reasoningEffort ? { reasoningEffort: options.reasoningEffort } : {}), + ...(options.uiAction ? { uiAction: options.uiAction } : {}), + }); +}; + +// Answers go over the session's socket, which opens for them in a conversation that was only +// read so far. Each throws when it can't send, so the card lets the person answer again. +const approverFor = (chat: AgentChat): AIToolApprover => ({ + authorizer: createAuthSessionAuthorizer({ + callbackScheme: SIGN_IN_SCHEME, + decline: async (call) => (await chat.session()).declineLogin(call.id), + }), + clientId, + decide: async (call, allowed) => answerApproval(await chat.session(), call.id, allowed), + userId: AgentConfig.user.id, +}); + +export type AppContextValue = { + /** What the agent is doing in the open conversation, from the session's socket. */ + agentState: ConversationSnapshot; + /** The open conversation's messages and live reasoning. */ + chat: AgentChat | undefined; + /** The open conversation's state, and what the person does in it. */ + conversation: AIConversationController | undefined; + /** Bumped when the drawer's list of conversations may have changed. */ + conversationsVersion: number; + /** Why the last conversation couldn't be opened. */ + failure: string | undefined; + /** Whether the router knows who this is, so conversations can start. */ + isReady: boolean; + /** The open conversation's session id, or `undefined` for a new one that wasn't asked anything. */ + sessionId: string | undefined; + /** + * Carries on a conversation from the drawer, by its session id. Its messages show as soon as its + * channel, `type:id` as the router lists it, is read. + */ + openConversation: (sessionId: string, conversationId?: string) => void; + /** Starts a new conversation. The router creates it with its first question. */ + startConversation: () => void; + /** + * Asks with a voice memo recorded on the phone: it goes up to the conversation's channel, and the + * router transcribes it, answers it and shows it on the person's message. + */ + sendVoiceMemo: (uri: string) => Promise; + /** Asks again what the agent couldn't answer: the last question, or the event it was told of. */ + retry: () => void; + /** Starts a new conversation and asks it `text`, as a flow or a scanned code does. */ + startConversationWith: (text: string) => void; + /** + * Starts a new conversation that the agent opens, about something that happened, such as a + * clock-in: the restaurant's systems would tell the backend, here the app does. + */ + startConversationAbout: (event: CrewEvent) => void; +}; + +export const AppContext = React.createContext({ + agentState: IDLE, + chat: undefined, + conversation: undefined, + conversationsVersion: 0, + failure: undefined, + isReady: false, + openConversation: () => {}, + retry: () => {}, + sendVoiceMemo: () => Promise.resolve(), + sessionId: undefined, + startConversation: () => {}, + startConversationAbout: () => {}, + startConversationWith: () => {}, +}); + +/** What the restaurant's systems tell the agent about, so it speaks first. */ +export type CrewEvent = 'clock_in' | 'clock_out' | 'back_from_time_off' | 'onboarding'; + +// Tells the backend what happened, which has the agent open the conversation about it. +const tellBackend = (kind: CrewEvent, sessionId: string) => + crewBackend('events', { + body: { + kind, + session_id: sessionId, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }, + }); + +// Asks with a voice memo at `url`. calypso's `responses.create` has no voice note yet, so this is +// the request it makes, with the voice note the router takes beside the text. +const askWithVoiceMemo = async (session: Session, url: string) => { + deviceTools.addressed = false; + await agents.post('/v1/agents/sessions/{id}/responses', { + body: { audio: { url }, request_id: crypto.randomUUID(), text: '' }, + path: { id: session.id }, + }); +}; + +// What the recorder wrote, by its file's extension. +const audioTypeOf = (uri: string) => (/\.(m4a|mp4)$/i.test(uri) ? 'audio/mp4' : 'audio/aac'); + +const messageOf = (error: unknown) => (error instanceof Error ? error.message : String(error)); + +// What the provider follows of the messages: the latest reply, whose calls this phone runs, and +// the session, once one is open. +const sessionSelector = ({ session }: AgentChatState) => ({ session }); + +const lastReplySelector = ({ items }: MessagePaginatorState) => ({ + lastReply: [...(items ?? [])].reverse().find(isAgentReply), +}); + +// Carries a conversation on, or creates one, with the phone's tools declared on it. +const openSession: OpenSession = (sessionId) => + sessionId + ? agentSessions().resume(sessionId, { tools: deviceTools.sessionTools() }) + : agentSessions().create({ + return_url: SIGN_IN_RETURN_URL, + tools: deviceTools.sessionTools(), + }); + +let chats = 0; +const newChat = (sessionId?: string) => new AgentChat(`chat-${++chats}`, sessionId, openSession); + +type Opened = { chat: AgentChat; conversation: AIConversationController }; + +export const AppProvider = ({ client, children }: PropsWithChildren<{ client: StreamChat }>) => { + const [opened, setOpened] = useState(); + const [failure, setFailure] = useState(); + const [isReady, setIsReady] = useState(false); + const [agentState, setAgentState] = useState(IDLE); + const [conversationsVersion, setConversationsVersion] = useState(0); + const current = useRef(undefined); + const chat = opened?.chat; + + const session = useStateStore(chat?.state, sessionSelector)?.session; + + // Runs the calls of the latest reply that wait for this phone, each once. + const lastReply = useAgentMessages(chat, lastReplySelector)?.lastReply; + useEffect(() => { + if (!lastReply) return; + deviceTools.runner.run(parseMessageParts(lastReply.attachments), (call, result) => + deviceTools.send(call.id, result), + ); + }, [lastReply]); + + // Shows a conversation in place of the one before it, at once, and lets the one before go. + const show = (next: AgentChat) => { + const previous = current.current; + const conversation = new AIConversationController({ + // The agents router keeps the conversation in Stream Chat and can't rewind it, so a reply + // can't be written again or a message edited: either would only be asked again as new. + actions: { edit: false, regenerate: false }, + approver: approverFor(next), + transport: createAgentTransport(next, { + onCreated: () => setConversationsVersion((version) => version + 1), + respond: (...asked) => { + setFailure(undefined); + return respond(...asked); + }, + }), + }); + current.current = { chat: next, conversation }; + setFailure(undefined); + setOpened(current.current); + previous?.conversation.dispose(); + void previous?.chat.close().catch(() => {}); + }; + + // A new conversation is only shown: the router creates it with its first question, so one + // that is never asked anything isn't left in the drawer. + const startConversation = useStableCallback(() => show(newChat())); + + const startConversationWith = useStableCallback((text: string) => { + const next = newChat(); + show(next); + void current.current?.conversation.send({ text }); + }); + + const startConversationAbout = useStableCallback((kind: CrewEvent) => { + const next = newChat(); + show(next); + next.state.partialNext({ awaitingAgent: true }); + next.lastRequest = { event: kind }; + void (async () => { + try { + const opened = await next.session(); + setConversationsVersion((version) => version + 1); + await tellBackend(kind, opened.id); + } catch (error) { + next.state.partialNext({ awaitingAgent: false }); + if (current.current?.chat === next) setFailure(messageOf(error)); + } + })(); + }); + + const sendVoiceMemo = useStableCallback(async (uri: string) => { + const opened = current.current; + if (!opened) return; + const { chat } = opened; + setFailure(undefined); + try { + const wasNew = chat.id === undefined; + const session = await chat.session(); + if (wasNew) setConversationsVersion((version) => version + 1); + const { channel } = chat.state.getLatestValue(); + if (!channel) throw new Error('The conversation has no channel yet'); + const name = uri.split('/').pop() ?? 'voice-memo.aac'; + const { file } = await channel.uploadChannelFile({ + file: { name, type: audioTypeOf(uri), uri }, + }); + if (!file) throw new Error('The voice memo did not upload'); + chat.wasAsked = true; + chat.lastRequest = { voiceMemoUrl: file }; + await askWithVoiceMemo(session, file); + } catch (error) { + setFailure(messageOf(error)); + } + }); + + const retry = useStableCallback(() => { + const opened = current.current; + const last = opened?.chat.lastRequest; + if (!opened || !last) return; + setFailure(undefined); + if ('voiceMemoUrl' in last) { + void opened.chat + .session() + .then((session) => askWithVoiceMemo(session, last.voiceMemoUrl)) + .catch((error: unknown) => setFailure(messageOf(error))); + return; + } + if ('event' in last) { + void opened.chat + .session() + .then((session) => tellBackend(last.event as CrewEvent, session.id)) + .catch((error: unknown) => setFailure(messageOf(error))); + return; + } + void opened.conversation.send({ custom: last.custom, text: last.text }); + }); + + const openConversation = useStableCallback((sessionId: string, conversationId?: string) => { + if (sessionId === current.current?.chat.id) { + return; + } + const next = newChat(sessionId); + show(next); + // The channel, when the drawer named it: reading it is quick, and carrying the session on, + // which waits for something to need it, would also mark the conversation active. + const [type, ...id] = (conversationId ?? '').split(':'); + const following = conversationId + ? next.follow(client.channel(type || 'agent', id.join(':'))) + : next.session(); + void following.catch((error: unknown) => { + if (current.current?.chat === next) setFailure(messageOf(error)); + }); + }); + + // The open conversation: what the agent is doing, from the session's socket, once one is open. + useEffect(() => { + if (!session) { + setAgentState(IDLE); + return; + } + setAgentState(session.state.get()); + return session.state.subscribe(() => { + const state = session.state.get(); + setAgentState(state); + // The router titles a conversation as it goes. + if (state.kind === 'idle') setConversationsVersion((version) => version + 1); + }); + }, [session]); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + // The router and Stream Chat as the same user, on the app's own chat client. + await agents.setUser(AgentConfig.user, streamToken); + agents.use({ chat: client }); + if (!cancelled) { + startConversation(); + setIsReady(true); + } + } catch (error) { + setFailure(messageOf(error)); + } + })(); + return () => { + cancelled = true; + }; + }, [client, startConversation]); + + const sessionId = session?.id ?? chat?.id; + const conversation = opened?.conversation; + const contextValue = useMemo( + () => ({ + agentState, + chat, + conversation, + conversationsVersion, + failure, + isReady, + openConversation, + retry, + sendVoiceMemo, + sessionId, + startConversation, + startConversationAbout, + startConversationWith, + }), + [ + agentState, + chat, + conversation, + conversationsVersion, + failure, + isReady, + openConversation, + retry, + sendVoiceMemo, + sessionId, + startConversation, + startConversationAbout, + startConversationWith, + ], + ); + + return {children}; +}; + +export const useAppContext = () => React.useContext(AppContext); diff --git a/examples/ExpoCrewAssistant/custom-types.d.ts b/examples/ExpoCrewAssistant/custom-types.d.ts new file mode 100644 index 0000000000..12dfe77c24 --- /dev/null +++ b/examples/ExpoCrewAssistant/custom-types.d.ts @@ -0,0 +1,56 @@ +import { + DefaultAttachmentData, + DefaultChannelData, + DefaultCommandData, + DefaultEventData, + DefaultMemberData, + DefaultMessageData, + DefaultPollData, + DefaultPollOptionData, + DefaultReactionData, + DefaultThreadData, + DefaultUserData, +} from 'stream-chat-expo'; +import type { AIUIActionJSON, AIUIDocumentJSON } from 'stream-chat-expo/ai-components'; + +declare module 'stream-chat' { + // Module augmentation: the empty interfaces are how the default data types are applied. + /* eslint-disable @typescript-eslint/no-empty-object-type */ + + interface CustomAttachmentData extends DefaultAttachmentData { + /** A voice memo's length in seconds, which the router writes on it. */ + duration?: number; + } + + interface CustomChannelData extends DefaultChannelData {} + + interface CustomCommandData extends DefaultCommandData {} + + interface CustomEventData extends DefaultEventData {} + + interface CustomMemberData extends DefaultMemberData {} + + interface CustomUserData extends DefaultUserData {} + + interface CustomMessageData extends DefaultMessageData { + ai_generated?: boolean; + /** Set by the router while the agent is still writing a reply. */ + generating?: boolean; + /** The UI the agent wrote for a reply, which the router moves out of its text. */ + ai_ui?: AIUIDocumentJSON; + /** What the person did in a reply's UI, on their message that says so. */ + ai_ui_action?: AIUIActionJSON; + /** The handbook sources a reply drew on, which the backend writes on it. */ + support_message?: { + sources?: { citation?: string; id: string; title: string; url: string }[]; + }; + } + + interface CustomPollOptionData extends DefaultPollOptionData {} + + interface CustomPollData extends DefaultPollData {} + + interface CustomReactionData extends DefaultReactionData {} + + interface CustomThreadData extends DefaultThreadData {} +} diff --git a/examples/ExpoCrewAssistant/flows.ts b/examples/ExpoCrewAssistant/flows.ts new file mode 100644 index 0000000000..6feeaae8a9 --- /dev/null +++ b/examples/ExpoCrewAssistant/flows.ts @@ -0,0 +1,72 @@ +import type { CrewEvent } from './contexts/AppContext'; + +/** One of the things the assistant helps with, as the home screen offers it. */ +export type Flow = { + id: string; + title: string; + subtitle: string; + emoji: string; +} & ({ ask: string } | { event: CrewEvent }); + +/** + * The flows, each started by asking the agent or by an event the restaurant's systems would send, + * which has the agent speak first. + */ +export const FLOWS: Flow[] = [ + { + emoji: '🕕', + event: 'clock_in', + id: 'clock-in', + subtitle: 'Fit check and focus for the shift', + title: 'Clock in', + }, + { + ask: "I'm not feeling well and need to call out of my next shift.", + emoji: '🤒', + id: 'call-out', + subtitle: 'Policy, cover and the shift leader', + title: 'Call out sick', + }, + { + ask: 'What are the uniform rules, and can I wear my own shoes?', + emoji: '📘', + id: 'handbook', + subtitle: 'Answers from the handbook, with sources', + title: 'Ask the handbook', + }, + { + ask: 'I think some equipment is broken and I need help!', + emoji: '🛠️', + id: 'equipment', + subtitle: 'Quick fix, then a ticket if needed', + title: 'Equipment problem', + }, + { + emoji: '🏖️', + event: 'back_from_time_off', + id: 'back', + subtitle: 'What you missed, summarized', + title: 'Back from time off', + }, + { + emoji: '👋', + event: 'onboarding', + id: 'onboarding', + subtitle: 'Your first 30 days: team, menu, check-ins', + title: "I'm new here", + }, + { + ask: "Let's practice for my shift leader interview. Ask me the first question.", + emoji: '🎯', + id: 'interview', + subtitle: 'Mock interview with feedback', + title: 'Interview practice', + }, + { + emoji: '🌙', + event: 'clock_out', + id: 'clock-out', + subtitle: 'Reflect on the shift', + title: 'Clock out', + }, +]; diff --git a/examples/ExpoCrewAssistant/hooks/useSendAIMessage.ts b/examples/ExpoCrewAssistant/hooks/useSendAIMessage.ts new file mode 100644 index 0000000000..a7ece9afe9 --- /dev/null +++ b/examples/ExpoCrewAssistant/hooks/useSendAIMessage.ts @@ -0,0 +1,20 @@ +import { useStableCallback } from 'stream-chat-expo'; + +import type { ComposerViewProps } from 'stream-chat-expo/ai-components'; + +import { useAppContext } from '../contexts/AppContext'; + +export type AIMessage = Parameters>[0]; + +/** + * Asks the agent through the open conversation, as the composer would: the question shows at once, + * and the router writes it and its reply into the conversation's channel. Used by voice mode. + */ +export const useSendAIMessage = () => { + const { conversation } = useAppContext(); + + return useStableCallback((message: AIMessage) => { + conversation?.send(message); + return Promise.resolve(); + }); +}; diff --git a/examples/ExpoCrewAssistant/metro.config.js b/examples/ExpoCrewAssistant/metro.config.js new file mode 100644 index 0000000000..5fa02acffa --- /dev/null +++ b/examples/ExpoCrewAssistant/metro.config.js @@ -0,0 +1,74 @@ +const { getDefaultConfig } = require('@expo/metro-config'); +const { exclusionList, resolveUniqueModule } = require('@rnx-kit/metro-config'); + +/** + * Metro configuration + * https://facebook.github.io/metro/docs/configuration + * + * @type {import('metro-config').MetroConfig} + */ +const config = getDefaultConfig(__dirname); + +const PATH = require('path'); +const packageDirPath = PATH.resolve(__dirname, '../../package'); +const expoPackageDirPath = PATH.resolve(__dirname, '../../package/expo-package'); + +const symlinked = { + 'stream-chat-expo': expoPackageDirPath, + 'stream-chat-react-native-core': packageDirPath, +}; + +// find what all modules need to be unique for the app (mainly react and react-native) +// note: we filter the symlinked modules as they are already unique +// and as they dont follow the workspace pattern the auto-generated path to the module is incorrect +const dependencyPackageNames = Object.keys(require('./package.json').dependencies); + +const uniqueModules = dependencyPackageNames.map((packageName) => { + if (symlinked[packageName]) { + const modulePath = symlinked[packageName]; + const escapedPackageName = PATH.normalize(packageName).replace(/\\/g, '\\\\'); + + // exclude the symlinked package from being resolved from node_modules + // example: .*\/node_modules\/stream-chat-react-native-core\/.* + // the above would avoid native-package to resolve core from its own node_modules + const exclusionRE = new RegExp( + `.*${PATH.sep}node_modules\\${PATH.sep}${escapedPackageName}\\${PATH.sep}.*`, + ); + + return { + packageName, // name of the package + modulePath, + blockPattern: exclusionRE, // paths that match this pattern will be blocked from being resolved + }; + } + const [modulePath, blockPattern] = resolveUniqueModule(packageName, __dirname); + // resolveUniqueModule emits a regex ending in `/` (package-dir boundary). metro-config's + // exclusionList anchors every alternation branch with `$`, which would require file paths to + // literally end with `/node_modules//`. Append `.*` so the anchor lands at end-of-file + // instead of end-of-directory, otherwise the blocklist silently matches nothing. + const fixedBlockPattern = new RegExp(blockPattern.source + '.*'); + return { + packageName, // name of the package + modulePath, // actual path to the module in the project's node modules + blockPattern: fixedBlockPattern, // paths that match this pattern will be blocked from being resolved + }; +}); + +// block the other paths for unique modules from being resolved +const blockList = uniqueModules.map(({ blockPattern }) => blockPattern); + +// provide the path for the unique modules +const extraNodeModules = uniqueModules.reduce((acc, item) => { + acc[item.packageName] = item.modulePath; + return acc; +}, {}); + +config.resolver.blockList = exclusionList(blockList); +config.resolver.extraNodeModules = extraNodeModules; + +config.resolver.nodeModulesPaths = [PATH.resolve(__dirname, 'node_modules')]; + +// add the package dir for metro to access the package folder +config.watchFolders = [packageDirPath]; + +module.exports = config; diff --git a/examples/ExpoCrewAssistant/package.json b/examples/ExpoCrewAssistant/package.json new file mode 100644 index 0000000000..e7a9abbbf5 --- /dev/null +++ b/examples/ExpoCrewAssistant/package.json @@ -0,0 +1,50 @@ +{ + "name": "expocrewassistant", + "version": "1.0.0", + "main": "expo-router/entry", + "scripts": { + "sync-native": "bash ../../package/scripts/reconcile-shared-native.sh expo-package && bash ../../package/scripts/sync-shared-native.sh expo-package", + "start": "yarn sync-native && expo start --dev-client", + "android": "yarn sync-native && expo run:android", + "ios": "yarn sync-native && expo run:ios", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@react-native-community/netinfo": "12.0.1", + "@shopify/react-native-skia": "^2.14.0", + "@stream-io/calypso": "^0.3.0", + "@stream-io/state-store": "^1.2.0", + "expo": "^58.0.8", + "expo-audio": "patch:expo-audio@npm%3A58.0.9#~/.yarn/patches/expo-audio-npm-58.0.9-5c313f7a3f.patch", + "expo-clipboard": "~58.0.3", + "expo-constants": "~58.0.11", + "expo-image-manipulator": "~58.0.14", + "expo-image-picker": "~58.0.10", + "expo-linking": "~58.0.12", + "expo-router": "~58.0.18", + "expo-speech": "~58.0.3", + "react": "19.3.0", + "react-native": "0.88.0", + "react-native-gesture-handler": "~3.4.0", + "react-native-keyboard-controller": "1.22.9", + "react-native-reanimated": "4.7.1", + "react-native-safe-area-context": "~5.10.1", + "react-native-screens": "~4.29.0", + "react-native-svg": "15.15.5", + "react-native-teleport": "^1.2.2", + "react-native-worklets": "0.13.0", + "stream-chat": "^10.0.1", + "stream-chat-expo": "workspace:^", + "stream-chat-react-native-core": "workspace:^", + "use-sync-external-store": "^1.7.0", + "victory-native": "^42.0.1" + }, + "devDependencies": { + "@babel/plugin-transform-export-namespace-from": "^7.27.1", + "@rnx-kit/metro-config": "^2.1.2", + "@stream-io/typescript-config": "workspace:^", + "@types/react": "~19.3.0", + "typescript": "6.0.3" + }, + "private": true +} diff --git a/examples/ExpoCrewAssistant/screens/AgentNotices.tsx b/examples/ExpoCrewAssistant/screens/AgentNotices.tsx new file mode 100644 index 0000000000..9eeb5997a8 --- /dev/null +++ b/examples/ExpoCrewAssistant/screens/AgentNotices.tsx @@ -0,0 +1,88 @@ +import { useSyncExternalStore } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import type { Session, ToolApprovalRequest } from '@stream-io/calypso'; +import { useStateStore } from 'stream-chat-expo'; +import { AIToolApprovalCard } from 'stream-chat-expo/ai-components'; + +import type { AgentChat, AgentChatState } from '../agent/agentChat'; +import { useAppContext } from '../contexts/AppContext'; + +const NONE: readonly ToolApprovalRequest[] = []; + +const sessionSelector = ({ session }: AgentChatState) => ({ session }); + +// What the call would do, in a line, from the arguments the model wrote. +const detailsOf = ({ arguments: written }: ToolApprovalRequest) => { + try { + const parsed = JSON.parse(written) as Record; + return Object.entries(parsed) + .filter(([, value]) => typeof value === 'string' || typeof value === 'number') + .map(([key, value]) => `${key.replace(/_/g, ' ')}: ${String(value)}`) + .join(' · '); + } catch { + return undefined; + } +}; + +const Failure = ({ session }: { session: Session }) => { + const { retry } = useAppContext(); + const failure = useSyncExternalStore( + session.conversation.subscribe, + () => session.conversation.get().failure, + ); + if (!failure) return null; + return ( + + Crew couldn't answer that. The model may be busy. + Try again + + ); +}; + +const Requests = ({ session }: { session: Session }) => { + const approvals = useSyncExternalStore( + session.conversation.subscribe, + () => session.conversation.get().approvals ?? NONE, + ); + if (approvals.length === 0) return null; + return ( + + {approvals.map((request) => ( + session.decide(request.id, { allowed })} + /> + ))} + + ); +}; + +/** + * What the open conversation's session needs the person for, above the composer: why the agent + * couldn't answer, with a way to ask again, and the calls of the phone's tools that ask first. The + * phone asks through `responses.create`, so those wait on its session rather than on the reply's + * step, and the person answers them here. + */ +export const AgentNotices = ({ chat }: { chat: AgentChat }) => { + const { session } = useStateStore(chat.state, sessionSelector); + return session ? ( + <> + + + + ) : null; +}; + +const styles = StyleSheet.create({ + container: { gap: 8, paddingBottom: 8, paddingHorizontal: 16 }, + failure: { alignItems: 'center', paddingBottom: 8 }, + failureText: { color: '#B00020', fontSize: 14 }, + retryText: { color: '#C8102E', fontSize: 15, fontWeight: '700', paddingTop: 2 }, +}); diff --git a/examples/ExpoCrewAssistant/screens/ChatContent.tsx b/examples/ExpoCrewAssistant/screens/ChatContent.tsx new file mode 100644 index 0000000000..96ef64755d --- /dev/null +++ b/examples/ExpoCrewAssistant/screens/ChatContent.tsx @@ -0,0 +1,589 @@ +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { + ActivityIndicator, + Alert, + Linking, + Platform, + Pressable, + ScrollView, + StatusBar, + StyleSheet, + Text, + View, +} from 'react-native'; +import Animated, { + Easing, + FadeIn, + FadeOut, + useAnimatedStyle, + useSharedValue, + withSpring, + withTiming, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import type { Attachment, LocalMessage, MessagePaginatorState } from 'stream-chat'; +import { AudioAttachment, AudioPlayerProvider, useStateStore } from 'stream-chat-expo'; + +import { createA2UIRenderer } from 'stream-chat-expo/a2ui'; +import { + AIAssistantMessage, + type AIConversationController, + type AIConversationControllerState, + AITypingIndicatorView, + type AIUIAction, + aiUIActionJSON, + AIUIHost, + AIUserMessage, + ComposerView, + isAIConversationAnswering, + useAIConversationState, +} from 'stream-chat-expo/ai-components'; +import { + AIConversation, + type AIConversationAssistantMessageProps, + type AIConversationUserMessageProps, +} from 'stream-chat-expo/ai-components/conversation'; + +import { AgentNotices } from './AgentNotices'; +import { VoiceMemoBar, VoiceMemoButton } from './VoiceMemo'; + +import type { AgentChat, AgentChatState } from '../agent/agentChat'; +import { crewBackend } from '../agent/agents'; +import type { AgentRequestCustom } from '../agent/agentTransport'; +import { useAgentChatLoaded, useAgentMessages } from '../agent/useAgentMessages'; +import { VoiceMode } from '../components/VoiceMode'; +import { useAppContext } from '../contexts/AppContext'; +import { type Flow, FLOWS } from '../flows'; + +const errorSelector = ({ error }: AIConversationControllerState) => ({ error }); + +/** + * The composer, with what rides the keyboard above it: the questions of calls that ask first, and + * why something failed, with a way to try again. And voice mode beside it. + */ +const ComposerArea = ({ + chat, + conversation, + failure, +}: { + chat: AgentChat; + conversation: AIConversationController; + failure: string | undefined; +}) => { + const { error } = useAIConversationState(conversation, errorSelector); + + const safeAreaInsets = useSafeAreaInsets(); + const insets = useMemo( + () => ({ + ...safeAreaInsets, + bottom: + safeAreaInsets.bottom + + (Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) * 2 : 0), + }), + [safeAreaInsets], + ); + + return ( + + + + {failure ? {failure} : null} + {error ? ( + conversation.retry()}> + {error.message} + Try again + + ) : null} + + } + SpeechToTextButton={null} + trailing={ + + + + + } + /> + ); +}; + +const pendingSelector = ({ pendingMessage }: AIConversationControllerState) => ({ + pendingMessage, +}); + +/** What the assistant helps with, for an empty conversation: each starts a new one. */ +const Flows = ({ conversation }: { conversation: AIConversationController }) => { + const { startConversationAbout } = useAppContext(); + const { pendingMessage } = useAIConversationState(conversation, pendingSelector); + if (pendingMessage) return null; + const start = (flow: Flow) => + 'event' in flow ? startConversationAbout(flow.event) : conversation.send({ text: flow.ask }); + return ( + + Hi Jordan 👋 + What do you need before, during or after your shift? + + {FLOWS.map((flow) => ( + start(flow)} + style={({ pressed }) => [styles.flow, pressed && styles.pressed]} + testID={`flow-${flow.id}`} + > + {flow.emoji} + {flow.title} + {flow.subtitle} + + ))} + + + ); +}; + +const awaitingSelector = ({ awaitingAgent }: AgentChatState) => ({ awaitingAgent }); + +const EmptyContent = ({ + chat, + conversation, +}: { + chat: AgentChat; + conversation: AIConversationController; +}) => { + const { awaitingAgent } = useStateStore(chat.state, awaitingSelector); + const { isLoaded } = useAgentChatLoaded(chat); + if (awaitingAgent) { + return ( + + + + ); + } + return isLoaded ? : ; +}; + +type Source = { id: string; title: string; url: string; citation?: string }; + +const NO_SOURCES: Source[] = []; + +const sourcesOf = (message: LocalMessage | undefined): Source[] => + message?.custom?.support_message?.sources ?? NO_SOURCES; + +/** The chat the open conversation is kept in, for the views of its messages. */ +const ConversationChatContext = createContext(undefined); + +/** The message of the conversation with `messageId`, following only that message. */ +const useChatMessage = (messageId: string | undefined) => { + const chat = useContext(ConversationChatContext); + const selector = useMemo( + () => + ({ items }: MessagePaginatorState) => ({ + message: items?.find(({ id }) => id === messageId), + }), + [messageId], + ); + return useAgentMessages(chat, selector)?.message; +}; + +const SOURCES_OPEN = { duration: 300, easing: Easing.out(Easing.ease) }; + +/** Opens what it holds from nothing as it shows, so what is below it moves down rather than jumps. */ +const Opening = ({ children }: { children: ReactNode }) => { + const height = useSharedValue(0); + const progress = useSharedValue(0); + const style = useAnimatedStyle(() => ({ + height: height.value * progress.value, + opacity: progress.value, + })); + return ( + + { + height.value = nativeEvent.layout.height; + if (progress.value === 0) progress.value = withTiming(1, SOURCES_OPEN); + }} + style={styles.openingContent} + > + {children} + + + ); +}; + +/** The handbook passages and pages a reply was answered from, under it. */ +const ReplySources = ({ + messageId, + opens, +}: { + messageId: string; + /** Whether it opens as it shows, rather than show at once. */ + opens: boolean; +}) => { + const sources = sourcesOf(useChatMessage(messageId)); + if (sources.length === 0) return null; + const card = ( + + Sources + {sources.map((source, index) => ( + void Linking.openURL(source.url)} + style={styles.source} + > + {index + 1} + + {source.title} + {source.citation ? ( + + {source.citation} + + ) : null} + + + ))} + + ); + return opens ? {card} : card; +}; + +// One player pool for the conversation, as a channel has: starting one memo pauses another. +const AUDIO_PLAYER = { allowConcurrentAudioPlayback: false }; + +const voiceNotesOf = (message: LocalMessage | undefined) => + (message?.attachments ?? []).filter( + (attachment): attachment is Attachment & { asset_url: string } => + attachment.type === 'voiceRecording' && !!attachment.asset_url, + ); + +/** The voice memos on a person's message, played with the chat SDK's own voice recording view. */ +const VoiceNotes = ({ + message, + notes, +}: { + message: LocalMessage; + notes: ReturnType; +}) => ( + + {notes.map((note, index) => ( + + ))} + +); + +// The spring the agent's reasoning folds with. +const FOLD_SPRING = { damping: 26.5, mass: 1, stiffness: 195 }; + +/** The transcript, springing open and folded with `isOpen`. */ +const TranscriptFold = ({ isOpen, text }: { isOpen: boolean; text: string }) => { + const height = useSharedValue(0); + const progress = useSharedValue(0); + useEffect(() => { + progress.value = withSpring(isOpen ? 1 : 0, FOLD_SPRING); + }, [isOpen, progress]); + const style = useAnimatedStyle(() => ({ + height: height.value * progress.value, + opacity: progress.value, + })); + return ( + + { + height.value = nativeEvent.layout.height; + }} + style={styles.transcriptPanel} + > + {text} + + + ); +}; + +/** What the router heard in a voice memo, folded away until asked for. */ +const Transcript = ({ text }: { text: string }) => { + const [isOpen, setIsOpen] = useState(false); + const [hasOpened, setHasOpened] = useState(false); + return ( + + { + setHasOpened(true); + setIsOpen(!isOpen); + }} + testID='voice-memo-transcript-toggle' + > + + {isOpen ? 'Hide transcript' : 'Show transcript'} + + + {hasOpened ? : null} + + ); +}; + +/** + * The person's message as the AI components show it. One sent as a voice memo shows the memo, and + * the words the router heard in it only when asked for. + */ +const UserMessage = ({ attachments, message, onEdit, text }: AIConversationUserMessageProps) => { + const stored = useChatMessage(message?.id); + const notes = useMemo(() => voiceNotesOf(stored), [stored]); + if (stored && notes.length > 0) { + return ( + + + {text ? : null} + {attachments.length > 0 ? : null} + + ); + } + return text || attachments.length > 0 ? ( + + ) : null; +}; + +/** The agent's reply as the AI components show it, then the sources it was answered from. */ +const AssistantMessage = ({ + approver, + attachments, + isGenerating, + message, + onWritingChange, + parts, + reasoningText, + text, +}: AIConversationAssistantMessageProps) => { + const [watched, setWatched] = useState(); + if (isGenerating && watched !== message.id) setWatched(message.id); + const [writing, setWriting] = useState(); + const onWriting = useCallback( + (isWriting: boolean) => { + setWriting(isWriting ? message.id : undefined); + onWritingChange?.(isWriting); + }, + [message.id, onWritingChange], + ); + return ( + + + {isGenerating || writing === message.id ? null : ( + + )} + + ); +}; + +/** The event the agent's clock-out form sends when the person saves their reflection. */ +const REFLECTION_SAVED = 'reflection_done'; + +/** + * Saves a reflection from the agent's clock-out form with the backend, and gives back the action + * to send the agent, which says it is saved. + */ +const saveReflection = async (action: AIUIAction): Promise => { + const context = (action.payload.context ?? {}) as Record; + const challenges = Array.isArray(context.challenges) ? context.challenges : []; + await crewBackend('crew/reflections', { + body: { + energy: Math.round(Number(context.energy)), + themes: challenges + .filter((challenge): challenge is string => typeof challenge === 'string') + .map((challenge) => challenge.replace(/_/g, ' ')), + win: typeof context.win === 'string' ? context.win.trim() : '', + }, + }); + return { ...action, payload: { ...action.payload, context: { ...context, saved: true } } }; +}; + +/** The open conversation, as its controller has it. */ +const Conversation = ({ + chat, + conversation, +}: { + chat: AgentChat; + conversation: AIConversationController; +}) => { + const { agentState } = useAppContext(); + + // The UI the agent writes for its replies, in A2UI: checklists, quizzes and forms. One host per + // conversation, so what a reply's UI holds survives scrolling. What the person does in it goes + // to the agent as their next message. + const [ui] = useState( + () => + new AIUIHost({ + renderers: [createA2UIRenderer()], + send: async (action) => { + if (isAIConversationAnswering(conversation.state.getLatestValue().status)) { + throw new Error('Crew is still answering'); + } + let sent = action; + if (action.name === REFLECTION_SAVED) { + try { + sent = await saveReflection(action); + } catch (error) { + Alert.alert( + "Your reflection wasn't saved", + error instanceof Error ? error.message : String(error), + ); + throw error; + } + } + const custom: AgentRequestCustom = { uiAction: aiUIActionJSON(sent) }; + conversation.send({ custom, text: sent.label }); + }, + }), + ); + + const footer = + agentState.kind === 'working' ? ( + skill.replace(/_/g, ' ')).join(' and ')}`} + /> + ) : undefined; + + return ( + + + } + footer={footer} + ui={ui} + UserMessage={UserMessage} + /> + + + ); +}; + +export const ChatContent = () => { + const { chat, conversation, failure } = useAppContext(); + const { bottom } = useSafeAreaInsets(); + + if (!chat || !conversation) { + return ( + + {failure ? {failure} : } + + ); + } + + return ( + + + + + ); +}; + +const styles = StyleSheet.create({ + emptyContainer: { + alignItems: 'center', + backgroundColor: 'transparent', + flex: 1, + justifyContent: 'center', + width: '100%', + }, + failureText: { color: '#B00020', fontSize: 15, paddingHorizontal: 24, textAlign: 'center' }, + flow: { + backgroundColor: '#FFFFFF', + borderColor: '#E6E8EC', + borderRadius: 16, + borderWidth: 1, + gap: 4, + padding: 14, + width: '48%', + }, + flowEmoji: { fontSize: 24 }, + flowSubtitle: { color: '#5C6370', fontSize: 13 }, + flowTitle: { color: '#0B1220', fontSize: 15, fontWeight: '700' }, + flows: { paddingHorizontal: 16, paddingVertical: 24 }, + grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, justifyContent: 'space-between' }, + hello: { color: '#0B1220', fontSize: 26, fontWeight: '800' }, + helloSub: { color: '#5C6370', fontSize: 15, marginBottom: 20, marginTop: 4 }, + pressed: { opacity: 0.6 }, + retryText: { + color: '#C8102E', + fontSize: 15, + fontWeight: '600', + paddingTop: 4, + textAlign: 'center', + }, + source: { flexDirection: 'row', gap: 10, paddingVertical: 6 }, + sourceBody: { flex: 1 }, + sourceCitation: { color: '#5C6370', fontSize: 13 }, + sourceIndex: { + backgroundColor: '#FDECEE', + borderRadius: 10, + color: '#C8102E', + fontSize: 12, + fontWeight: '700', + height: 20, + lineHeight: 20, + textAlign: 'center', + width: 20, + }, + sourceTitle: { color: '#0B1220', fontSize: 14, fontWeight: '600' }, + opening: { overflow: 'hidden' }, + openingContent: { left: 0, position: 'absolute', right: 0, top: 0 }, + sources: { + backgroundColor: '#F7F8FA', + borderRadius: 12, + marginHorizontal: 16, + marginTop: 4, + padding: 12, + }, + sourcesTitle: { color: '#5C6370', fontSize: 12, fontWeight: '700', marginBottom: 4 }, + trailing: { alignItems: 'center', flexDirection: 'row', gap: 8 }, + transcript: { alignItems: 'flex-end', paddingHorizontal: 20, paddingTop: 6 }, + transcriptFold: { alignSelf: 'stretch', overflow: 'hidden' }, + transcriptPanel: { alignItems: 'flex-end', paddingTop: 4, position: 'absolute', width: '100%' }, + transcriptText: { color: '#5C6370', fontSize: 14, lineHeight: 20 }, + transcriptToggle: { color: '#C8102E', fontSize: 13, fontWeight: '600' }, + voiceNotes: { alignItems: 'flex-end', gap: 6, paddingHorizontal: 16, paddingTop: 8 }, + wrapper: { backgroundColor: '#FCFCFC', flex: 1 }, +}); diff --git a/examples/ExpoCrewAssistant/screens/MenuDrawer.tsx b/examples/ExpoCrewAssistant/screens/MenuDrawer.tsx new file mode 100644 index 0000000000..bbe3e3e367 --- /dev/null +++ b/examples/ExpoCrewAssistant/screens/MenuDrawer.tsx @@ -0,0 +1,218 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Pressable, SectionList, StyleSheet, Text, View } from 'react-native'; + +import { SafeAreaView } from 'react-native-safe-area-context'; + +import type { Schemas } from '@stream-io/calypso'; +import { router } from 'expo-router'; +import { type DrawerContentComponentProps, useDrawerStatus } from 'expo-router/drawer'; + +import { Plus, useStableCallback } from 'stream-chat-expo'; + +import { agentSessions } from '../agent/agents'; +import { AgentConfig } from '../agent/config'; +import { useAppContext } from '../contexts/AppContext'; + +type SessionRow = Schemas['Session']; + +type Section = { data: SessionRow[]; title: string }; + +const keyExtractor = (session: SessionRow) => session.id; + +// When the conversation was last answered, or else started. The router lists conversations by when +// they were last touched, which opening one to read it does too, so a conversation looked at would +// jump to the top. When the agent last answered moves only when it is used. +const lastUsed = (session: SessionRow) => + Date.parse(session.last_response_at ?? session.created_at ?? '') || 0; + +const DAY = 24 * 60 * 60 * 1000; + +// Most recently used first, under Today, Yesterday, Previous 7 days and Older. +const sectionsOf = (sessions: readonly SessionRow[]): Section[] => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const startOfToday = today.getTime(); + const sections: Section[] = [ + { data: [], title: 'Today' }, + { data: [], title: 'Yesterday' }, + { data: [], title: 'Previous 7 days' }, + { data: [], title: 'Older' }, + ]; + [...sessions] + .sort((a, b) => lastUsed(b) - lastUsed(a)) + .forEach((session) => { + const at = lastUsed(session); + const index = + at >= startOfToday + ? 0 + : at >= startOfToday - DAY + ? 1 + : at >= startOfToday - 7 * DAY + ? 2 + : 3; + sections[index].data.push(session); + }); + return sections.filter((section) => section.data.length > 0); +}; + +const initialsOf = (name: string) => + name + .split(' ') + .map((part) => part[0]) + .join('') + .slice(0, 2) + .toUpperCase(); + +/** + * The agent's conversations, most recently used first. Only those kept in writing: a session that + * was only ever a call has no channel to read back. + */ +export const MenuDrawer = ({ navigation }: DrawerContentComponentProps) => { + const { conversationsVersion, isReady, openConversation, sessionId, startConversation } = + useAppContext(); + const [sessions, setSessions] = useState([]); + const isOpen = useDrawerStatus() === 'open'; + const sections = useMemo(() => sectionsOf(sessions), [sessions]); + + useEffect(() => { + if (!isReady || !isOpen) return; + let cancelled = false; + agentSessions() + .query({ limit: 50 }) + .then((page) => { + if (!cancelled) setSessions(page.items.filter((item) => item.conversation_id)); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [conversationsVersion, isOpen, isReady]); + + const onSelect = useStableCallback((session: SessionRow) => { + openConversation(session.id, session.conversation_id ?? undefined); + router.navigate('/'); + navigation.closeDrawer(); + }); + + const onCreateNewChat = useStableCallback(() => { + startConversation(); + router.navigate('/'); + navigation.closeDrawer(); + }); + + const renderItem = ({ item }: { item: SessionRow }) => { + const isCurrent = item.id === sessionId; + return ( + onSelect(item)} + style={({ pressed }) => [ + styles.row, + isCurrent ? styles.rowCurrent : pressed ? styles.rowPressed : null, + ]} + > + + {item.title || 'New conversation'} + + + ); + }; + + const renderSectionHeader = ({ section }: { section: Section }) => ( + + {section.title} + + ); + + return ( + + + + Conversations + + [styles.newButton, pressed && styles.newButtonPressed]} + > + + New conversation + + + + + + {initialsOf(AgentConfig.user.name)} + + + {AgentConfig.user.name} + + + + ); +}; + +const ACCENT = '#C8102E'; +const ACCENT_TINT = '#FDECEE'; +const BORDER = '#E6E8EC'; +const TEXT = '#0B1220'; +const TEXT_SECONDARY = '#5C6370'; + +const styles = StyleSheet.create({ + avatar: { + alignItems: 'center', + backgroundColor: ACCENT_TINT, + borderRadius: 18, + height: 36, + justifyContent: 'center', + width: 36, + }, + avatarText: { color: ACCENT, fontSize: 14, fontWeight: '700' }, + footer: { + alignItems: 'center', + borderTopColor: BORDER, + borderTopWidth: StyleSheet.hairlineWidth, + flexDirection: 'row', + gap: 12, + paddingHorizontal: 20, + paddingVertical: 14, + }, + footerName: { color: TEXT, flex: 1, fontSize: 15, fontWeight: '600' }, + header: { gap: 16, paddingBottom: 8, paddingHorizontal: 20, paddingTop: 16 }, + list: { paddingBottom: 16, paddingHorizontal: 12 }, + newButton: { + alignItems: 'center', + alignSelf: 'stretch', + backgroundColor: ACCENT_TINT, + borderRadius: 14, + flexDirection: 'row', + gap: 8, + paddingHorizontal: 14, + paddingVertical: 12, + }, + newButtonPressed: { opacity: 0.7 }, + newButtonText: { color: ACCENT, fontSize: 15, fontWeight: '600' }, + row: { borderRadius: 12, paddingHorizontal: 12, paddingVertical: 11 }, + rowCurrent: { backgroundColor: ACCENT_TINT }, + rowPressed: { backgroundColor: '#F2F3F5' }, + rowText: { color: TEXT, fontSize: 15 }, + rowTextCurrent: { color: ACCENT, fontWeight: '600' }, + sectionTitle: { + color: TEXT_SECONDARY, + fontSize: 13, + fontWeight: '600', + paddingBottom: 6, + paddingHorizontal: 12, + paddingTop: 18, + }, + title: { color: TEXT, fontSize: 22, fontWeight: '700' }, + wrapper: { backgroundColor: '#FFFFFF', flex: 1 }, +}); diff --git a/examples/ExpoCrewAssistant/screens/VoiceMemo.tsx b/examples/ExpoCrewAssistant/screens/VoiceMemo.tsx new file mode 100644 index 0000000000..d4453e3ef2 --- /dev/null +++ b/examples/ExpoCrewAssistant/screens/VoiceMemo.tsx @@ -0,0 +1,126 @@ +import React, { useState } from 'react'; +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'; + +import { + AudioRecordingInProgress, + MessageInputContext, + type MessageInputContextValue, + useComponentsContext, + useStateStore, +} from 'stream-chat-expo'; +import { + AudioRecorderManager, + type AudioRecorderManagerState, +} from 'stream-chat-react-native-core/src/state-store/audio-recorder-manager'; + +import { useAppContext } from '../contexts/AppContext'; + +// One recorder for the app: the button beside the composer starts it, the bar above stops it. +const recorder = new AudioRecorderManager(); + +// AudioRecordingInProgress reads only the recorder from the composer's context. +const recorderContext = { audioRecorderManager: recorder } as unknown as MessageInputContextValue; + +const statusSelector = ({ isStarting, status }: AudioRecorderManagerState) => ({ + isStarting, + status, +}); + +const uriOf = ({ recording }: AudioRecorderManagerState) => + typeof recording === 'string' ? recording : recording?.getURI(); + +/** Starts a voice memo, beside the composer. */ +export const VoiceMemoButton = () => { + const { icons } = useComponentsContext(); + const { isStarting, status } = useStateStore(recorder.state, statusSelector); + if (status === 'recording' || isStarting) return null; + return ( + void recorder.startRecording()} + style={({ pressed }) => [styles.button, pressed && styles.pressed]} + testID='voice-memo-start' + > + + + ); +}; + +/** + * The memo being recorded, above the composer: its time and waveform, from the chat SDK's own + * recording view, with a way to throw it away and to send it. + */ +export const VoiceMemoBar = () => { + const { sendVoiceMemo } = useAppContext(); + const { status } = useStateStore(recorder.state, statusSelector); + const [isSending, setIsSending] = useState(false); + if (status !== 'recording' && !isSending) return null; + + const cancel = () => void recorder.stopRecording(true); + const send = async () => { + setIsSending(true); + try { + await recorder.stopRecording(); + const uri = uriOf(recorder.state.getLatestValue()); + recorder.reset(); + if (uri) await sendVoiceMemo(uri); + } finally { + setIsSending(false); + } + }; + + return ( + + {isSending ? ( + + + Sending your memo + + ) : ( + <> + + Cancel + + + + + + + + Send + + + )} + + ); +}; + +const styles = StyleSheet.create({ + bar: { + alignItems: 'center', + backgroundColor: '#FFFFFF', + borderColor: '#E6E8EC', + borderRadius: 24, + borderWidth: 1, + flexDirection: 'row', + gap: 8, + marginBottom: 8, + marginHorizontal: 16, + paddingHorizontal: 14, + }, + button: { + alignItems: 'center', + backgroundColor: '#FDECEE', + borderRadius: 22, + height: 44, + justifyContent: 'center', + width: 44, + }, + cancel: { color: '#5C6370', fontSize: 15, fontWeight: '600' }, + pressed: { opacity: 0.6 }, + recording: { flex: 1, overflow: 'hidden' }, + send: { color: '#C8102E', fontSize: 15, fontWeight: '700' }, + sending: { alignItems: 'center', flexDirection: 'row', gap: 8, paddingVertical: 14 }, + sendingText: { color: '#5C6370', fontSize: 15 }, +}); diff --git a/examples/ExpoCrewAssistant/tsconfig.json b/examples/ExpoCrewAssistant/tsconfig.json new file mode 100644 index 0000000000..af8edb52a3 --- /dev/null +++ b/examples/ExpoCrewAssistant/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": ["expo/tsconfig.base", "@stream-io/typescript-config/base.json"], + "compilerOptions": { + "paths": { + // Same as ExpoMessaging: pins the whole program to this workspace's copy of React Native. + "react-native": ["./node_modules/react-native/types_generated/index.d.ts"], + "react-native/*": ["./node_modules/react-native/*"], + // The same for the state stores: AgentChat's and the SDK's are then one class to TypeScript. + "@stream-io/state-store": ["./node_modules/@stream-io/state-store"] + } + }, + "include": ["**/*.ts", "**/*.tsx"] +} diff --git a/examples/SampleApp/fastlane/Fastfile b/examples/SampleApp/fastlane/Fastfile index 42baf1e14c..7c1ddf7000 100644 --- a/examples/SampleApp/fastlane/Fastfile +++ b/examples/SampleApp/fastlane/Fastfile @@ -542,10 +542,12 @@ lane :frameworks_sizes do sh('yarn build') sh('yarn minify-bundle') sh('yarn minify-ai-components-bundle') + sh('yarn minify-a2ui-bundle') { js_bundle_size: file_size(path: 'package/lib/module/bundle.min.js'), - ai_components_size: file_size(path: 'package/lib/ai-components.min.js') + ai_components_size: file_size(path: 'package/lib/ai-components.min.js'), + a2ui_size: file_size(path: 'package/lib/a2ui.min.js') } end end diff --git a/package.json b/package.json index ee702cd07b..0bc9616000 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "examples/SampleApp", "examples/E2ETestApp", "examples/ExpoMessaging", - "examples/ExpoChatGPTSample" + "examples/ExpoChatGPTSample", + "examples/ExpoCrewAssistant" ], "resolutions": { "@types/react": "^19.3.0" @@ -72,12 +73,13 @@ "lint-fix": "prettier --write . && eslint . --fix --max-warnings 0", "prettier": "prettier --list-different .", "prettier-fix": "prettier --write .", - "typecheck": "yarn workspaces foreach -A --parallel --include 'stream-chat-react-native-core' --include 'stream-chat-react-native' --include 'stream-chat-expo' --include 'sampleapp' --include 'e2etestapp' --include 'ExpoMessaging' --include 'expochatgptsample' run typecheck", + "typecheck": "yarn workspaces foreach -A --parallel --include 'stream-chat-react-native-core' --include 'stream-chat-react-native' --include 'stream-chat-expo' --include 'sampleapp' --include 'e2etestapp' --include 'ExpoMessaging' --include 'expochatgptsample' --include 'expocrewassistant' run typecheck", "build": "yarn workspace stream-chat-react-native-core build", "test:coverage": "yarn workspace stream-chat-react-native-core test:coverage", "test:unit": "yarn workspace stream-chat-react-native-core test:unit", - "minify-bundle": "uglifyjs package/lib/module/*.js package/lib/module/!(ai-components)/**/*.js -o package/lib/module/bundle.min.js -c -m", - "minify-ai-components-bundle": "uglifyjs package/lib/module/ai-components/**/*.js -o package/lib/ai-components.min.js -c -m" + "minify-bundle": "uglifyjs package/lib/module/*.js package/lib/module/!(ai-components|a2ui)/**/*.js -o package/lib/module/bundle.min.js -c -m", + "minify-ai-components-bundle": "uglifyjs package/lib/module/ai-components/**/*.js -o package/lib/ai-components.min.js -c -m", + "minify-a2ui-bundle": "uglifyjs package/lib/module/a2ui/**/*.js -o package/lib/a2ui.min.js -c -m" }, "packageManager": "yarn@4.15.0" } diff --git a/package/__fixtures__/a2ui-conformance/NOTICE.md b/package/__fixtures__/a2ui-conformance/NOTICE.md new file mode 100644 index 0000000000..a2472d4e53 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/NOTICE.md @@ -0,0 +1,6 @@ +# A2UI conformance suite + +The cases in `core/` and the catalogs in `catalogs/` are the conformance suite of the A2UI +specification, from [a2ui-project/a2ui](https://github.com/a2ui-project/a2ui) at commit `d378d20`, +licensed under the Apache License, Version 2.0. `src/a2ui/__tests__/conformance.test.ts` runs them. +Copied as they are: update them by copying a newer commit's `conformance/` over these. diff --git a/package/__fixtures__/a2ui-conformance/catalogs/agent_functions_catalog_v1_0.json b/package/__fixtures__/a2ui-conformance/catalogs/agent_functions_catalog_v1_0.json new file mode 100644 index 0000000000..48af1e761b --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/catalogs/agent_functions_catalog_v1_0.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/conformance/test_data/catalogs/agent_functions_catalog_v1_0.json", + "protocolVersion": "1.0", + "title": "Conformance Agent Functions Catalog (v1.0)", + "description": "A catalog whose function the agent may call, for the cases that compile a callRendererFunction. The functions of the other fixtures declare no allowedCallers, so only the renderer may call them.", + "catalogId": "conformance/agent_functions", + "components": {}, + "functions": { + "notify": { + "type": "object", + "description": "Shows the user a short message.", + "returnType": "void", + "allowedCallers": "rendererOrAgent", + "properties": { + "@call": { + "const": "notify" + }, + "args": { + "type": "object", + "properties": { + "message": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "message" + ], + "unevaluatedProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + }, + "$defs": { + "anyFunction": { + "oneOf": [ + { + "$ref": "#/functions/notify" + } + ] + } + } +} diff --git a/package/__fixtures__/a2ui-conformance/catalogs/allowed_callers_catalog_v1_0.json b/package/__fixtures__/a2ui-conformance/catalogs/allowed_callers_catalog_v1_0.json new file mode 100644 index 0000000000..cabfeef8cc --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/catalogs/allowed_callers_catalog_v1_0.json @@ -0,0 +1,64 @@ +{ + "protocolVersion": "1.0", + "catalogId": "https://a2ui.org/catalogs/v10/allowed_callers", + "components": { + "Spacer": { + "type": "object", + "allowedParents": [ + "Surface", + "Spacer" + ], + "allowedChildren": [ + "Spacer" + ], + "properties": { + "component": { + "const": "Spacer" + } + }, + "required": [ + "component" + ] + } + }, + "functions": { + "readSessionToken": { + "type": "object", + "description": "Returns the renderer's session token to the agent.", + "returnType": "string", + "allowedCallers": "agentOnly", + "properties": { + "@call": { + "const": "readSessionToken" + }, + "args": { + "type": "object", + "properties": {}, + "unevaluatedProperties": false + } + }, + "required": [ + "@call" + ] + } + }, + "$defs": { + "anyComponent": { + "oneOf": [ + { + "$ref": "#/components/Spacer" + } + ], + "discriminator": { + "propertyName": "component" + } + }, + "anyFunction": { + "oneOf": [ + { + "$ref": "#/functions/readSessionToken" + } + ] + } + } +} diff --git a/package/__fixtures__/a2ui-conformance/catalogs/component_metadata_catalog_v1_0.json b/package/__fixtures__/a2ui-conformance/catalogs/component_metadata_catalog_v1_0.json new file mode 100644 index 0000000000..c472e201bb --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/catalogs/component_metadata_catalog_v1_0.json @@ -0,0 +1,215 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/conformance/test_data/catalogs/component_metadata_catalog_v1_0.json", + "protocolVersion": "1.0", + "title": "Conformance Component Metadata Catalog (v1.0)", + "description": "A copy of the simplified catalog whose component definitions carry ComponentDefinition.metadata extension envelopes, for the catalog metadata round-trip cases.", + "catalogId": "conformance/component-metadata", + "components": { + "Text": { + "type": "object", + "description": "Displays a run of text.", + "properties": { + "component": { + "const": "Text" + }, + "text": { + "$ref": "common_types.json#/$defs/DynamicString" + }, + "variant": { + "type": "string", + "enum": [ + "body", + "caption" + ], + "default": "body" + } + }, + "required": [ + "component", + "text" + ], + "metadata": { + "extensions": { + "com_example_telemetry": { + "track": true + }, + "a2ui_experimental": "preview" + } + } + }, + "Button": { + "type": "object", + "description": "A control that emits an action when pressed.", + "properties": { + "component": { + "const": "Button" + }, + "child": { + "$ref": "common_types.json#/$defs/Child" + }, + "action": { + "$ref": "common_types.json#/$defs/Action" + } + }, + "required": [ + "component", + "child", + "action" + ], + "metadata": { + "extensions": { + "com_example_policy": "interactive" + } + } + }, + "Card": { + "type": "object", + "description": "A container holding a single child.", + "properties": { + "component": { + "const": "Card" + }, + "child": { + "$ref": "common_types.json#/$defs/Child" + } + }, + "required": [ + "component", + "child" + ] + }, + "Column": { + "type": "object", + "description": "A layout that stacks its children vertically.", + "properties": { + "component": { + "const": "Column" + }, + "children": { + "$ref": "common_types.json#/$defs/ChildList" + } + }, + "required": [ + "component", + "children" + ] + } + }, + "functions": { + "required": { + "type": "object", + "description": "Checks that the value is not null, undefined or empty.", + "returnType": "validationResult", + "properties": { + "@call": { + "const": "required" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "unevaluatedProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "formatString": { + "type": "object", + "description": "Interpolates data model values into a template string.", + "returnType": "string", + "properties": { + "@call": { + "const": "formatString" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "value" + ], + "unevaluatedProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "openUrl": { + "type": "object", + "description": "Opens a URL in the renderer's browser or handler.", + "returnType": "void", + "requiresUserActivation": true, + "properties": { + "@call": { + "const": "openUrl" + }, + "args": { + "type": "object", + "properties": { + "url": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "url" + ], + "unevaluatedProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + }, + "$defs": { + "anyComponent": { + "oneOf": [ + { + "$ref": "#/components/Text" + }, + { + "$ref": "#/components/Button" + }, + { + "$ref": "#/components/Card" + }, + { + "$ref": "#/components/Column" + } + ], + "discriminator": { + "propertyName": "component" + } + }, + "anyFunction": { + "oneOf": [ + { + "$ref": "#/functions/required" + }, + { + "$ref": "#/functions/formatString" + }, + { + "$ref": "#/functions/openUrl" + } + ], + "discriminator": { + "propertyName": "@call" + } + } + } +} \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/catalogs/component_metadata_invalid_key_catalog_v1_0.json b/package/__fixtures__/a2ui-conformance/catalogs/component_metadata_invalid_key_catalog_v1_0.json new file mode 100644 index 0000000000..3b9ce6c06a --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/catalogs/component_metadata_invalid_key_catalog_v1_0.json @@ -0,0 +1,220 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/conformance/test_data/catalogs/component_metadata_invalid_key_catalog_v1_0.json", + "protocolVersion": "1.0", + "title": "Conformance Component Metadata Catalog With Invalid Key (v1.0)", + "description": "Like component_metadata_catalog_v1_0.json, but Card.metadata.extensions has a key that is not a UAX 31 identifier.", + "catalogId": "conformance/component-metadata-invalid", + "components": { + "Text": { + "type": "object", + "description": "Displays a run of text.", + "properties": { + "component": { + "const": "Text" + }, + "text": { + "$ref": "common_types.json#/$defs/DynamicString" + }, + "variant": { + "type": "string", + "enum": [ + "body", + "caption" + ], + "default": "body" + } + }, + "required": [ + "component", + "text" + ], + "metadata": { + "extensions": { + "com_example_telemetry": { + "track": true + }, + "a2ui_experimental": "preview" + } + } + }, + "Button": { + "type": "object", + "description": "A control that emits an action when pressed.", + "properties": { + "component": { + "const": "Button" + }, + "child": { + "$ref": "common_types.json#/$defs/Child" + }, + "action": { + "$ref": "common_types.json#/$defs/Action" + } + }, + "required": [ + "component", + "child", + "action" + ], + "metadata": { + "extensions": { + "com_example_policy": "interactive" + } + } + }, + "Card": { + "type": "object", + "description": "A container holding a single child.", + "properties": { + "component": { + "const": "Card" + }, + "child": { + "$ref": "common_types.json#/$defs/Child" + } + }, + "required": [ + "component", + "child" + ], + "metadata": { + "extensions": { + "com.example.policy": "dotted-key" + } + } + }, + "Column": { + "type": "object", + "description": "A layout that stacks its children vertically.", + "properties": { + "component": { + "const": "Column" + }, + "children": { + "$ref": "common_types.json#/$defs/ChildList" + } + }, + "required": [ + "component", + "children" + ] + } + }, + "functions": { + "required": { + "type": "object", + "description": "Checks that the value is not null, undefined or empty.", + "returnType": "validationResult", + "properties": { + "@call": { + "const": "required" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "unevaluatedProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "formatString": { + "type": "object", + "description": "Interpolates data model values into a template string.", + "returnType": "string", + "properties": { + "@call": { + "const": "formatString" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "value" + ], + "unevaluatedProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "openUrl": { + "type": "object", + "description": "Opens a URL in the renderer's browser or handler.", + "returnType": "void", + "requiresUserActivation": true, + "properties": { + "@call": { + "const": "openUrl" + }, + "args": { + "type": "object", + "properties": { + "url": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "url" + ], + "unevaluatedProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + }, + "$defs": { + "anyComponent": { + "oneOf": [ + { + "$ref": "#/components/Text" + }, + { + "$ref": "#/components/Button" + }, + { + "$ref": "#/components/Card" + }, + { + "$ref": "#/components/Column" + } + ], + "discriminator": { + "propertyName": "component" + } + }, + "anyFunction": { + "oneOf": [ + { + "$ref": "#/functions/required" + }, + { + "$ref": "#/functions/formatString" + }, + { + "$ref": "#/functions/openUrl" + } + ], + "discriminator": { + "propertyName": "@call" + } + } + } +} \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/catalogs/composition_catalog_v1_0.json b/package/__fixtures__/a2ui-conformance/catalogs/composition_catalog_v1_0.json new file mode 100644 index 0000000000..e65fcab540 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/catalogs/composition_catalog_v1_0.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/conformance/test_data/catalogs/composition_catalog_v1_0.json", + "protocolVersion": "1.0", + "title": "Conformance Composition Catalog (v1.0)", + "description": "A two component catalog whose components declare allowedParents and allowedChildren, for the timing cases of core/message_processor_scaling.yaml.", + "catalogId": "conformance/composition", + "components": { + "Text": { + "type": "object", + "description": "Displays a run of text.", + "allowedParents": ["Column"], + "properties": { + "component": { "const": "Text" }, + "text": { "$ref": "common_types.json#/$defs/DynamicString" } + }, + "required": ["component", "text"] + }, + "Column": { + "type": "object", + "description": "A layout that stacks its children vertically.", + "allowedParents": ["Surface", "Column"], + "allowedChildren": ["Text", "Column"], + "properties": { + "component": { "const": "Column" }, + "children": { "$ref": "common_types.json#/$defs/ChildList" } + }, + "required": ["component", "children"] + } + }, + "$defs": { + "anyComponent": { + "oneOf": [ + { "$ref": "#/components/Text" }, + { "$ref": "#/components/Column" } + ], + "discriminator": { "propertyName": "component" } + } + } +} diff --git a/package/__fixtures__/a2ui-conformance/catalogs/custom_catalog_v1_0.json b/package/__fixtures__/a2ui-conformance/catalogs/custom_catalog_v1_0.json new file mode 100644 index 0000000000..a86a0ee17b --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/catalogs/custom_catalog_v1_0.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/conformance/test_data/catalogs/custom_catalog_v1_0.json", + "protocolVersion": "1.0", + "title": "Conformance Custom Catalog (v1.0)", + "description": "A second catalog used by the multi catalog and capability negotiation cases. Its component and function names do not overlap with the basic catalog, so a case can tell the two apart by name alone.", + "catalogId": "conformance/custom", + "components": { + "Chart": { + "type": "object", + "description": "Plots a series of numbers.", + "properties": { + "component": {"const": "Chart"}, + "values": {"type": "array", "items": {"type": "number"}}, + "caption": {"$ref": "common_types.json#/$defs/DynamicString"} + }, + "required": ["component", "values"] + }, + "Gauge": { + "type": "object", + "description": "Shows a single value on a scale.", + "properties": { + "component": {"const": "Gauge"}, + "value": {"type": "number"} + }, + "required": ["component", "value"] + } + }, + "functions": { + "percent": { + "type": "object", + "description": "Renders a ratio as a percentage string.", + "returnType": "string", + "properties": { + "@call": {"const": "percent"}, + "args": { + "type": "object", + "properties": {"value": {"type": "number"}}, + "required": ["value"], + "unevaluatedProperties": false + } + }, + "required": ["@call", "args"] + } + }, + "$defs": { + "anyComponent": { + "oneOf": [{"$ref": "#/components/Chart"}, {"$ref": "#/components/Gauge"}] + }, + "anyFunction": { + "oneOf": [{"$ref": "#/functions/percent"}] + } + } +} diff --git a/package/__fixtures__/a2ui-conformance/catalogs/derive_source_catalog_v1_0.json b/package/__fixtures__/a2ui-conformance/catalogs/derive_source_catalog_v1_0.json new file mode 100644 index 0000000000..7728c5115e --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/catalogs/derive_source_catalog_v1_0.json @@ -0,0 +1,79 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/catalogs/v10/derive_source.json", + "title": "Derive source", + "description": "The catalog the derive cases start from.", + "catalogId": "https://a2ui.org/catalogs/v10/derive_source", + "protocolVersion": "1.0", + "instructions": "Write components of https://a2ui.org/catalogs/v10/derive_source.", + "components": { + "Text": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "Image": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": [ + "url" + ] + }, + "Button": { + "type": "object", + "properties": { + "label": { + "type": "string" + } + } + } + }, + "functions": { + "upper": { + "type": "object", + "properties": { + "call": { + "const": "upper" + }, + "args": { + "type": "object" + }, + "returnType": { + "const": "string" + } + } + }, + "lower": { + "type": "object", + "properties": { + "call": { + "const": "lower" + }, + "args": { + "type": "object" + }, + "returnType": { + "const": "string" + } + } + } + }, + "theme": { + "type": "object", + "properties": { + "primaryColor": { + "type": "string" + } + } + } +} diff --git a/package/__fixtures__/a2ui-conformance/catalogs/forms_catalog_v1_0.json b/package/__fixtures__/a2ui-conformance/catalogs/forms_catalog_v1_0.json new file mode 100644 index 0000000000..c604346cc8 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/catalogs/forms_catalog_v1_0.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/conformance/test_data/catalogs/forms_catalog_v1_0.json", + "protocolVersion": "1.0", + "title": "Conformance Forms Catalog (v1.0)", + "description": "An input catalog for the cases the simplified catalog cannot express: a component with enough optional properties to skip one in the middle, and a component that takes validation checks. Common types come from the protocol's common_types.json.", + "catalogId": "conformance/forms", + "components": { + "TextField": { + "type": "object", + "description": "A single line text input.", + "properties": { + "component": { "const": "TextField" }, + "label": { "$ref": "common_types.json#/$defs/DynamicString" }, + "value": { "$ref": "common_types.json#/$defs/DataBinding" }, + "placeholder": { "$ref": "common_types.json#/$defs/DynamicString" }, + "checks": { + "type": "array", + "items": { "$ref": "common_types.json#/$defs/CheckRule" } + } + }, + "required": ["component", "label"] + } + }, + "functions": { + "required": { + "type": "object", + "description": "Checks that the value is not null, undefined or empty.", + "returnType": "validationResult", + "properties": { + "@call": { "const": "required" }, + "args": { + "type": "object", + "properties": { "value": {} }, + "required": ["value"], + "unevaluatedProperties": false + } + }, + "required": ["@call", "args"] + }, + "regex": { + "type": "object", + "description": "Checks the value against a regular expression.", + "returnType": "validationResult", + "properties": { + "@call": { "const": "regex" }, + "args": { + "type": "object", + "properties": { + "value": {}, + "pattern": { "type": "string" } + }, + "required": ["value", "pattern"], + "unevaluatedProperties": false + } + }, + "required": ["@call", "args"] + } + }, + "$defs": { + "anyComponent": { + "oneOf": [{ "$ref": "#/components/TextField" }], + "discriminator": { "propertyName": "component" } + }, + "anyFunction": { + "oneOf": [ + { "$ref": "#/functions/required" }, + { "$ref": "#/functions/regex" } + ], + "discriminator": { "propertyName": "@call" } + } + } +} diff --git a/package/__fixtures__/a2ui-conformance/catalogs/function_metadata_catalog_v1_0.json b/package/__fixtures__/a2ui-conformance/catalogs/function_metadata_catalog_v1_0.json new file mode 100644 index 0000000000..d9210ad835 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/catalogs/function_metadata_catalog_v1_0.json @@ -0,0 +1,73 @@ +{ + "protocolVersion": "1.0", + "catalogId": "https://a2ui.org/catalogs/v10/function_metadata", + "components": { + "Spacer": { + "type": "object", + "allowedParents": [ + "Surface", + "Spacer" + ], + "allowedChildren": [ + "Spacer" + ], + "properties": { + "component": { + "const": "Spacer" + } + }, + "required": [ + "component" + ] + } + }, + "functions": { + "launch": { + "type": "object", + "description": "Launches a URL.", + "returnType": "void", + "allowedCallers": "rendererOnly", + "requiresUserActivation": true, + "properties": { + "@call": { + "const": "launch" + }, + "args": { + "type": "object", + "properties": { + "url": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "url" + ], + "unevaluatedProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + }, + "$defs": { + "anyComponent": { + "oneOf": [ + { + "$ref": "#/components/Spacer" + } + ], + "discriminator": { + "propertyName": "component" + } + }, + "anyFunction": { + "oneOf": [ + { + "$ref": "#/functions/launch" + } + ] + } + } +} diff --git a/package/__fixtures__/a2ui-conformance/catalogs/list_item_action_catalog_v1_0.json b/package/__fixtures__/a2ui-conformance/catalogs/list_item_action_catalog_v1_0.json new file mode 100644 index 0000000000..9db00f3100 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/catalogs/list_item_action_catalog_v1_0.json @@ -0,0 +1,77 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/conformance/test_data/catalogs/list_item_action_catalog_v1_0.json", + "protocolVersion": "1.0", + "title": "Conformance List Item Action Catalog (v1.0)", + "description": "A catalog whose component carries actions inside list items, for testing that an Express function call in a nested action slot compiles to a functionCall action.", + "catalogId": "conformance/list_item_action", + "components": { + "Text": { + "type": "object", + "description": "Displays a run of text.", + "properties": { + "component": { "const": "Text" }, + "text": { "$ref": "common_types.json#/$defs/DynamicString" } + }, + "required": ["component", "text"] + }, + "Links": { + "type": "object", + "description": "A list of labelled links, each firing an action when chosen.", + "properties": { + "component": { "const": "Links" }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { "type": "string" }, + "action": { "$ref": "common_types.json#/$defs/Action" } + }, + "required": ["label", "action"] + } + }, + "primary": { "$ref": "common_types.json#/$defs/Action" } + }, + "required": ["component", "items"] + }, + "Tabs": { + "type": "object", + "description": "A tabbed view whose tabItems carry a title and a Child componentId.", + "properties": { + "component": { "const": "Tabs" }, + "tabItems": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { "$ref": "common_types.json#/$defs/DynamicString" }, + "child": { "$ref": "common_types.json#/$defs/Child" } + }, + "required": ["title", "child"] + } + } + }, + "required": ["component", "tabItems"] + } + }, + "functions": { + "openUrl": { + "type": "object", + "description": "Opens a URL.", + "returnType": "void", + "properties": { + "@call": { "const": "openUrl" }, + "args": { + "type": "object", + "properties": { + "url": { "type": "string" } + }, + "required": ["url"], + "unevaluatedProperties": false + } + }, + "required": ["@call", "args"] + } + } +} diff --git a/package/__fixtures__/a2ui-conformance/catalogs/literal_map_catalog_v1_0.json b/package/__fixtures__/a2ui-conformance/catalogs/literal_map_catalog_v1_0.json new file mode 100644 index 0000000000..dfc2c4ab16 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/catalogs/literal_map_catalog_v1_0.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/conformance/test_data/catalogs/literal_map_catalog_v1_0.json", + "protocolVersion": "1.0", + "title": "Conformance Literal Map Catalog (v1.0)", + "description": "A catalog for testing that Express map literals keep their keys, including keys that share a name with an Express construct such as variable, path, call or check.", + "catalogId": "conformance/literal_map", + "components": { + "Probe": { + "type": "object", + "description": "Carries an arbitrary object payload.", + "properties": { + "component": { "const": "Probe" }, + "payload": { "type": "object" } + }, + "required": ["component", "payload"] + } + }, + "functions": { + "series": { + "type": "object", + "description": "Repeats an item a number of times, naming the loop variable.", + "returnType": "array", + "properties": { + "@call": { "const": "series" }, + "args": { + "type": "object", + "properties": { + "count": { "type": "integer" }, + "variable": { "type": "string" }, + "item": { "type": "object" } + }, + "required": ["count", "item"], + "unevaluatedProperties": false + } + }, + "required": ["@call", "args"] + } + } +} diff --git a/package/__fixtures__/a2ui-conformance/catalogs/simplified_catalog_v1_0.json b/package/__fixtures__/a2ui-conformance/catalogs/simplified_catalog_v1_0.json new file mode 100644 index 0000000000..bd577e6741 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/catalogs/simplified_catalog_v1_0.json @@ -0,0 +1,123 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/conformance/test_data/catalogs/simplified_catalog_v1_0.json", + "protocolVersion": "1.0", + "title": "Conformance Simplified Catalog (v1.0)", + "description": "A four component catalog for the agent SDK conformance suites. Like a published catalog it takes its common types from the protocol's common_types.json and defines only anyComponent and anyFunction itself.", + "catalogId": "conformance/simplified", + "components": { + "Text": { + "type": "object", + "description": "Displays a run of text.", + "properties": { + "component": { "const": "Text" }, + "text": { "$ref": "common_types.json#/$defs/DynamicString" }, + "variant": { + "type": "string", + "enum": ["body", "caption"], + "default": "body" + } + }, + "required": ["component", "text"] + }, + "Button": { + "type": "object", + "description": "A control that emits an action when pressed.", + "properties": { + "component": { "const": "Button" }, + "child": { "$ref": "common_types.json#/$defs/Child" }, + "action": { "$ref": "common_types.json#/$defs/Action" } + }, + "required": ["component", "child", "action"] + }, + "Card": { + "type": "object", + "description": "A container holding a single child.", + "properties": { + "component": { "const": "Card" }, + "child": { "$ref": "common_types.json#/$defs/Child" } + }, + "required": ["component", "child"] + }, + "Column": { + "type": "object", + "description": "A layout that stacks its children vertically.", + "properties": { + "component": { "const": "Column" }, + "children": { "$ref": "common_types.json#/$defs/ChildList" } + }, + "required": ["component", "children"] + } + }, + "functions": { + "required": { + "type": "object", + "description": "Checks that the value is not null, undefined or empty.", + "returnType": "validationResult", + "properties": { + "@call": { "const": "required" }, + "args": { + "type": "object", + "properties": { "value": {} }, + "required": ["value"], + "unevaluatedProperties": false + } + }, + "required": ["@call", "args"] + }, + "formatString": { + "type": "object", + "description": "Interpolates data model values into a template string.", + "returnType": "string", + "properties": { + "@call": { "const": "formatString" }, + "args": { + "type": "object", + "properties": { + "value": { "$ref": "common_types.json#/$defs/DynamicString" } + }, + "required": ["value"], + "unevaluatedProperties": false + } + }, + "required": ["@call", "args"] + }, + "openUrl": { + "type": "object", + "description": "Opens a URL in the renderer's browser or handler.", + "returnType": "void", + "requiresUserActivation": true, + "properties": { + "@call": { "const": "openUrl" }, + "args": { + "type": "object", + "properties": { + "url": { "$ref": "common_types.json#/$defs/DynamicString" } + }, + "required": ["url"], + "unevaluatedProperties": false + } + }, + "required": ["@call", "args"] + } + }, + "$defs": { + "anyComponent": { + "oneOf": [ + { "$ref": "#/components/Text" }, + { "$ref": "#/components/Button" }, + { "$ref": "#/components/Card" }, + { "$ref": "#/components/Column" } + ], + "discriminator": { "propertyName": "component" } + }, + "anyFunction": { + "oneOf": [ + { "$ref": "#/functions/required" }, + { "$ref": "#/functions/formatString" }, + { "$ref": "#/functions/openUrl" } + ], + "discriminator": { "propertyName": "@call" } + } + } +} diff --git a/package/__fixtures__/a2ui-conformance/catalogs/weighted_catalog_v1_0.json b/package/__fixtures__/a2ui-conformance/catalogs/weighted_catalog_v1_0.json new file mode 100644 index 0000000000..af7c5974b1 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/catalogs/weighted_catalog_v1_0.json @@ -0,0 +1,145 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/conformance/test_data/catalogs/weighted_catalog_v1_0.json", + "protocolVersion": "1.0", + "title": "Conformance Weighted Catalog (v1.0)", + "description": "A catalog for testing positional numeric argument placement when optional enum properties precede a numeric property such as weight.", + "catalogId": "conformance/weighted", + "components": { + "Text": { + "type": "object", + "description": "Displays a run of text.", + "properties": { + "component": { "const": "Text" }, + "text": { "$ref": "common_types.json#/$defs/DynamicString" }, + "variant": { + "type": "string", + "enum": ["body", "caption"], + "default": "body" + }, + "weight": { "type": "number" } + }, + "required": ["component", "text"] + }, + "Column": { + "type": "object", + "description": "A layout that stacks its children vertically.", + "properties": { + "component": { "const": "Column" }, + "children": { "$ref": "common_types.json#/$defs/ChildList" }, + "justify": { + "type": "string", + "enum": ["start", "center", "end"], + "default": "start" + }, + "align": { + "type": "string", + "enum": ["start", "center", "end"], + "default": "start" + }, + "weight": { "type": "number" } + }, + "required": ["component", "children"] + }, + "Image": { + "type": "object", + "description": "Displays an image.", + "properties": { + "component": { "const": "Image" }, + "url": { "$ref": "common_types.json#/$defs/DynamicString" }, + "description": { "$ref": "common_types.json#/$defs/DynamicString" }, + "weight": { "type": "number" } + }, + "required": ["component", "url"] + }, + "TextField": { + "type": "object", + "description": "A single-line text input.", + "properties": { + "component": { "const": "TextField" }, + "label": { "$ref": "common_types.json#/$defs/DynamicString" }, + "value": { "$ref": "common_types.json#/$defs/DataBinding" }, + "placeholder": { "$ref": "common_types.json#/$defs/DynamicString" }, + "weight": { "type": "number" } + }, + "required": ["component", "label"] + }, + "Slider": { + "type": "object", + "description": "A slider input whose value is a DynamicNumber ref.", + "properties": { + "component": { "const": "Slider" }, + "orientation": { + "type": "string", + "enum": ["horizontal", "vertical"], + "default": "horizontal" + }, + "value": { "$ref": "common_types.json#/$defs/DynamicNumber" } + }, + "required": ["component"] + }, + "DualMetric": { + "type": "object", + "description": "A component with two later numeric properties.", + "properties": { + "component": { "const": "DualMetric" }, + "variant": { + "type": "string", + "enum": ["a", "b"], + "default": "a" + }, + "min": { "type": "number" }, + "max": { "type": "number" } + }, + "required": ["component"] + }, + "Status": { + "type": "object", + "description": "A status component with a required enum property.", + "properties": { + "component": { "const": "Status" }, + "state": { + "type": "string", + "enum": ["active", "inactive"] + }, + "priority": { "type": "number" } + }, + "required": ["component", "state"] + }, + "Meter": { + "type": "object", + "description": "A meter component with title, optional unit enum, numeric level, and optional description.", + "properties": { + "component": { "const": "Meter" }, + "label": { "$ref": "common_types.json#/$defs/DynamicString" }, + "unit": { + "type": "string", + "enum": ["percent", "ratio"], + "default": "percent" + }, + "level": { "type": "number" }, + "description": { "type": "string" } + }, + "required": ["component", "label"] + } + }, + "functions": {}, + "$defs": { + "anyComponent": { + "oneOf": [ + { "$ref": "#/components/Text" }, + { "$ref": "#/components/Column" }, + { "$ref": "#/components/Image" }, + { "$ref": "#/components/TextField" }, + { "$ref": "#/components/Slider" }, + { "$ref": "#/components/DualMetric" }, + { "$ref": "#/components/Status" }, + { "$ref": "#/components/Meter" } + ], + "discriminator": { "propertyName": "component" } + }, + "anyFunction": { + "not": {} + } + } +} diff --git a/package/__fixtures__/a2ui-conformance/core/accessibility.json b/package/__fixtures__/a2ui-conformance/core/accessibility.json new file mode 100644 index 0000000000..54ccd426f4 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/accessibility.json @@ -0,0 +1,722 @@ +[ + { + "name": "test_accessibility_attributes_plumbing", + "description": "Verifies plumbing of AccessibilityAttributes (label, description, live, hidden) to accessibility node trees and axe-core rules.", + "protocolVersion": "1.0", + "catalogPath": "catalogs/basic/v1/catalog.json", + "action": "accessibility_check", + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "a11y_surface", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "btn1", + "status_text" + ] + }, + { + "id": "btn1", + "component": "Button", + "child": "btn1_label", + "action": { + "event": { + "name": "tap" + } + }, + "accessibility": { + "label": "Mute Notifications", + "description": "Silences notifications about this conversation" + } + }, + { + "id": "btn1_label", + "component": "Text", + "text": "Mute" + }, + { + "id": "status_text", + "component": "Text", + "text": "Status: Active", + "accessibility": { + "live": "polite" + } + } + ] + } + } + ], + "surface": { + "id": "root", + "component": "Column", + "children": [ + "btn1", + "status_text" + ], + "components": { + "btn1": { + "component": "Button", + "child": "btn1_label", + "title": "Mute", + "action": { + "event": { + "name": "tap" + } + }, + "accessibility": { + "label": "Mute Notifications", + "description": "Silences notifications about this conversation" + } + }, + "btn1_label": { + "component": "Text", + "text": "Mute" + }, + "status_text": { + "component": "Text", + "text": "Status: Active", + "accessibility": { + "live": "polite" + } + } + } + }, + "assertions": { + "axeCore": [ + "button-name", + "aria-valid-attr-value", + "aria-allowed-attr" + ], + "accessibilityTree": { + "btn1": { + "label": "Mute Notifications", + "description": "Silences notifications about this conversation" + }, + "status_text": { + "live": "polite" + } + } + } + }, + { + "name": "test_accessibility_live_region_assertive", + "description": "Verifies assertive live region announcement for critical alert banner updates.", + "protocolVersion": "1.0", + "catalogPath": "catalogs/basic/v1/catalog.json", + "action": "accessibility_check", + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "a11y_surface", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "components": [ + { + "id": "root", + "component": "Card", + "child": "alert_box" + }, + { + "id": "alert_box", + "component": "Text", + "text": "Connection Lost!", + "accessibility": { + "label": "Critical Alert", + "live": "assertive" + } + } + ] + } + } + ], + "surface": { + "id": "root", + "component": "Card", + "child": "alert_box", + "components": { + "alert_box": { + "component": "Text", + "text": "Connection Lost!", + "accessibility": { + "label": "Critical Alert", + "live": "assertive" + } + } + } + }, + "assertions": { + "axeCore": [ + "aria-valid-attr-value" + ], + "accessibilityTree": { + "alert_box": { + "label": "Critical Alert", + "live": "assertive" + } + } + } + }, + { + "name": "test_accessibility_hidden_subtree", + "description": "Verifies that setting hidden=true hides the component and its children from assistive technologies.", + "protocolVersion": "1.0", + "catalogPath": "catalogs/basic/v1/catalog.json", + "action": "accessibility_check", + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "a11y_surface", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "components": [ + { + "id": "root", + "component": "Column", + "accessibility": { + "hidden": true + }, + "children": [ + "visible_text", + "decorative_icon" + ] + }, + { + "id": "visible_text", + "component": "Text", + "text": "Visible Content" + }, + { + "id": "decorative_icon", + "component": "Icon", + "name": "star" + } + ] + } + } + ], + "surface": { + "id": "root", + "component": "Column", + "accessibility": { + "hidden": true + }, + "children": [ + "visible_text", + "decorative_icon" + ], + "components": { + "visible_text": { + "component": "Text", + "text": "Visible Content" + }, + "decorative_icon": { + "component": "Icon", + "name": "star" + } + } + }, + "assertions": { + "axeCore": [ + "aria-hidden-focus" + ], + "accessibilityTree": { + "root": { + "hidden": true + } + } + } + }, + { + "name": "test_accessibility_implicit_label_inference", + "description": "Verifies default screen reader semantic inference from visible text properties when explicit accessibility attributes are omitted.", + "protocolVersion": "1.0", + "catalogPath": "catalogs/basic/v1/catalog.json", + "action": "accessibility_check", + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "a11y_surface", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "submit_btn" + ] + }, + { + "id": "submit_btn", + "component": "Button", + "child": "submit_btn_label", + "action": { + "event": { + "name": "tap" + } + } + }, + { + "id": "submit_btn_label", + "component": "Text", + "text": "Submit Form" + } + ] + } + } + ], + "surface": { + "id": "root", + "component": "Column", + "children": [ + "submit_btn" + ], + "components": { + "submit_btn": { + "component": "Button", + "child": "submit_btn_label", + "title": "Submit Form", + "action": { + "event": { + "name": "tap" + } + } + }, + "submit_btn_label": { + "component": "Text", + "text": "Submit Form" + } + } + }, + "assertions": { + "axeCore": [ + "button-name" + ], + "accessibilityTree": { + "submit_btn": { + "label": "Submit Form" + } + } + } + }, + { + "name": "test_accessibility_interactive_roles_and_states", + "description": "Verifies accessibility role mapping and ARIA states for Button, TextField, CheckBox, ChoicePicker.", + "protocolVersion": "1.0", + "catalogPath": "catalogs/basic/v1/catalog.json", + "action": "accessibility_check", + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "a11y_surface", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "btn", + "text_input", + "check_box", + "choice_picker" + ] + }, + { + "id": "btn", + "component": "Button", + "child": "btn_label", + "action": { + "event": { + "name": "tap" + } + }, + "accessibility": { + "label": "Submit Feedback" + } + }, + { + "id": "btn_label", + "component": "Text", + "text": "Submit" + }, + { + "id": "text_input", + "component": "TextField", + "label": "Your Email", + "value": "", + "accessibility": { + "description": "Enter a valid email address" + } + }, + { + "id": "check_box", + "component": "CheckBox", + "label": "I agree to terms", + "value": true, + "accessibility": { + "label": "Accept Terms and Conditions" + } + }, + { + "id": "choice_picker", + "component": "ChoicePicker", + "variant": "mutuallyExclusive", + "value": [ + "1" + ], + "options": [ + { + "label": "Option 1", + "value": "1" + }, + { + "label": "Option 2", + "value": "2" + } + ], + "accessibility": { + "label": "Select Preference" + } + } + ] + } + } + ], + "surface": { + "id": "root", + "component": "Column", + "children": [ + "btn", + "text_input", + "check_box", + "choice_picker" + ], + "components": { + "btn": { + "component": "Button", + "child": "btn_label", + "title": "Submit", + "action": { + "event": { + "name": "tap" + } + }, + "accessibility": { + "label": "Submit Feedback" + } + }, + "btn_label": { + "component": "Text", + "text": "Submit" + }, + "text_input": { + "component": "TextField", + "label": "Your Email", + "value": "", + "accessibility": { + "description": "Enter a valid email address" + } + }, + "check_box": { + "component": "CheckBox", + "label": "I agree to terms", + "value": true, + "checked": true, + "accessibility": { + "label": "Accept Terms and Conditions" + } + }, + "choice_picker": { + "component": "ChoicePicker", + "variant": "mutuallyExclusive", + "value": [ + "1" + ], + "options": [ + { + "label": "Option 1", + "value": "1" + }, + { + "label": "Option 2", + "value": "2" + } + ], + "selectedIndex": 0, + "accessibility": { + "label": "Select Preference" + } + } + } + }, + "assertions": { + "axeCore": [ + "button-name", + "aria-valid-attr-value", + "aria-allowed-attr" + ], + "accessibilityTree": { + "btn": { + "label": "Submit Feedback", + "role": "button" + }, + "text_input": { + "description": "Enter a valid email address", + "role": "textbox" + }, + "check_box": { + "label": "Accept Terms and Conditions", + "role": "checkbox", + "checked": true + }, + "choice_picker": { + "label": "Select Preference", + "role": "radiogroup" + } + } + } + }, + { + "name": "test_accessibility_dynamic_label_and_hidden_binding", + "description": "Verifies dynamic data bindings in accessibility attributes (label, hidden) reactively update accessibility trees.", + "protocolVersion": "1.0", + "catalogPath": "catalogs/basic/v1/catalog.json", + "action": "accessibility_check", + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "a11y_surface", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "dataModel": { + "buttonLabel": "Mute Audio", + "isButtonHidden": false, + "isStatusHidden": true + }, + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "dynamic_btn", + "dynamic_status" + ] + }, + { + "id": "dynamic_btn", + "component": "Button", + "child": "dynamic_btn_label", + "action": { + "event": { + "name": "tap" + } + }, + "accessibility": { + "label": { + "@path": "/buttonLabel" + }, + "hidden": { + "@path": "/isButtonHidden" + } + } + }, + { + "id": "dynamic_btn_label", + "component": "Text", + "text": "Dynamic Action" + }, + { + "id": "dynamic_status", + "component": "Text", + "text": "Status", + "accessibility": { + "hidden": { + "@path": "/isStatusHidden" + } + } + } + ] + } + } + ], + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "a11y_surface", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "dataModel": { + "buttonLabel": "Mute Audio", + "isButtonHidden": false, + "isStatusHidden": true + }, + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "dynamic_btn", + "dynamic_status" + ] + }, + { + "id": "dynamic_btn", + "component": "Button", + "child": "dynamic_btn_label", + "action": { + "event": { + "name": "tap" + } + }, + "accessibility": { + "label": { + "@path": "/buttonLabel" + }, + "hidden": { + "@path": "/isButtonHidden" + } + } + }, + { + "id": "dynamic_btn_label", + "component": "Text", + "text": "Dynamic Action" + }, + { + "id": "dynamic_status", + "component": "Text", + "text": "Status", + "accessibility": { + "hidden": { + "@path": "/isStatusHidden" + } + } + } + ] + } + } + ], + "assertions": { + "axeCore": [ + "button-name", + "aria-hidden-focus" + ], + "accessibilityTree": { + "dynamic_btn": { + "label": "Mute Audio", + "hidden": false + }, + "dynamic_status": { + "hidden": true + } + } + } + }, + { + "messages": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "a11y_surface", + "path": "/buttonLabel", + "value": "Unmute Audio" + } + }, + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "a11y_surface", + "path": "/isButtonHidden", + "value": true + } + } + ], + "assertions": { + "axeCore": [ + "button-name", + "aria-hidden-focus" + ], + "accessibilityTree": { + "dynamic_btn": { + "label": "Unmute Audio", + "hidden": true + }, + "dynamic_status": { + "hidden": true + } + } + } + } + ], + "surface": { + "id": "root", + "component": "Column", + "children": [ + "dynamic_btn", + "dynamic_status" + ], + "components": { + "dynamic_btn": { + "component": "Button", + "child": "dynamic_btn_label", + "title": "Dynamic Action", + "action": { + "event": { + "name": "tap" + } + }, + "accessibility": { + "label": { + "@path": "/buttonLabel" + }, + "hidden": { + "@path": "/isButtonHidden" + } + } + }, + "dynamic_btn_label": { + "component": "Text", + "text": "Dynamic Action" + }, + "dynamic_status": { + "component": "Text", + "text": "Status", + "accessibility": { + "hidden": { + "@path": "/isStatusHidden" + } + } + } + } + }, + "assertions": { + "axeCore": [ + "button-name", + "aria-hidden-focus" + ], + "accessibilityTree": { + "dynamic_btn": { + "label": { + "path": "/buttonLabel" + }, + "hidden": { + "path": "/isButtonHidden" + } + }, + "dynamic_status": { + "hidden": { + "path": "/isStatusHidden" + } + } + } + } + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/actions.json b/package/__fixtures__/a2ui-conformance/core/actions.json new file mode 100644 index 0000000000..2570480951 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/actions.json @@ -0,0 +1,458 @@ +[ + { + "name": "test_action_event_basic", + "description": "Verifies dispatching a basic server event with name only.", + "action": "dispatch_action", + "actionPayload": { + "event": { + "name": "submit" + } + }, + "expectDispatched": { + "name": "submit" + } + }, + { + "name": "test_action_event_with_literal_context", + "description": "Verifies event action preserves literal context properties.", + "action": "dispatch_action", + "actionPayload": { + "event": { + "name": "filter_change", + "context": { + "query": "books", + "limit": 10, + "active": true + } + } + }, + "expectDispatched": { + "name": "filter_change", + "context": { + "query": "books", + "limit": 10, + "active": true + } + } + }, + { + "name": "test_action_event_context_literal_keys_path_and_call", + "description": "Verifies context keys literally named 'path' and 'call' are preserved as literal keys (D1).", + "action": "dispatch_action", + "actionPayload": { + "event": { + "name": "save", + "context": { + "path": "/custom/file.txt", + "call": "external", + "regular": "data" + } + } + }, + "expectDispatched": { + "name": "save", + "context": { + "path": "/custom/file.txt", + "call": "external", + "regular": "data" + } + } + }, + { + "name": "test_action_event_with_data_binding", + "description": "Verifies event action dynamically resolves bound context values from DataModel.", + "action": "dispatch_action", + "dataModel": { + "user": { + "id": "u_123", + "role": "admin" + } + }, + "actionPayload": { + "event": { + "name": "user_action", + "context": { + "userId": { + "path": "/user/id" + }, + "role": { + "path": "/user/role" + }, + "staticKey": "constant" + } + } + }, + "expectDispatched": { + "name": "user_action", + "context": { + "userId": "u_123", + "role": "admin", + "staticKey": "constant" + } + } + }, + { + "name": "test_action_event_with_relative_scope", + "description": "Verifies event action resolves relative data bindings against the provided scope.", + "action": "dispatch_action", + "scope": "/items/1", + "dataModel": { + "items": [ + { + "title": "Item 0", + "id": "id_0" + }, + { + "title": "Item 1", + "id": "id_1" + } + ] + }, + "actionPayload": { + "event": { + "name": "select_item", + "context": { + "selectedId": { + "path": "id" + }, + "selectedTitle": { + "path": "title" + } + } + } + }, + "expectDispatched": { + "name": "select_item", + "context": { + "selectedId": "id_1", + "selectedTitle": "Item 1" + } + } + }, + { + "name": "test_action_event_with_user_message", + "description": "Verifies event action preserves and dispatches userMessage.", + "action": "dispatch_action", + "actionPayload": { + "event": { + "name": "feedback", + "userMessage": "User requested additional details", + "context": { + "score": 5 + } + } + }, + "expectDispatched": { + "name": "feedback", + "userMessage": "User requested additional details", + "context": { + "score": 5 + } + } + }, + { + "name": "test_action_direct_name_shorthand", + "description": "Verifies v0.9 direct action name shorthand is dispatched as an event.", + "action": "dispatch_action", + "actionPayload": { + "name": "click", + "context": { + "target": "btn1" + } + }, + "expectDispatched": { + "name": "click", + "context": { + "target": "btn1" + } + } + }, + { + "name": "test_action_event_with_dispatch_metadata", + "description": "Verifies that metadata passed to dispatchAction is attached to the dispatched action as action.metadata (v1.0 extension envelope).", + "action": "dispatch_action", + "protocolVersion": "1.0", + "actionPayload": { + "event": { + "name": "submit", + "context": { + "form": "signup" + } + } + }, + "metadata": { + "extensions": { + "com_example_attestation": "signed", + "a2ui_experimental": { + "trace": 7 + } + } + }, + "expectDispatched": { + "name": "submit", + "context": { + "form": "signup" + }, + "metadata": { + "extensions": { + "com_example_attestation": "signed", + "a2ui_experimental": { + "trace": 7 + } + } + } + } + }, + { + "name": "test_action_event_without_dispatch_metadata_has_none", + "description": "Verifies that an action dispatched without metadata carries no action.metadata envelope.", + "action": "dispatch_action", + "protocolVersion": "1.0", + "actionPayload": { + "event": { + "name": "submit" + } + }, + "expectDispatched": { + "name": "submit", + "metadata": null + } + }, + { + "name": "test_action_event_dispatch_metadata_invalid_uax31_key_error", + "description": "Verifies that dispatchAction rejects metadata whose extensions key is not a UAX 31 identifier and dispatches nothing.", + "action": "dispatch_action", + "protocolVersion": "1.0", + "actionPayload": { + "event": { + "name": "submit" + } + }, + "metadata": { + "extensions": { + "com-example-attestation": "signed" + } + }, + "expectError": { + "category": "ValidationError" + } + }, + { + "name": "test_action_v10_event_with_at_path_and_at_call_context", + "description": "Verifies v1.0 @path and @call context bindings, @@ literal prefix unescaping, and @index inside template scope.", + "action": "dispatch_action", + "protocolVersion": "1.0", + "surfaceId": "main_surface", + "sourceComponentId": "row_btn", + "scope": "/items/1", + "dataModel": { + "items": [ + { + "title": "First" + }, + { + "title": "Second" + } + ] + }, + "actionPayload": { + "event": { + "name": "item_selected", + "context": { + "itemTitle": { + "@path": "title" + }, + "upperTitle": { + "@call": "formatString", + "args": { + "value": "Selected: ${title}" + } + }, + "itemIndex": { + "@call": "@index" + }, + "oneBasedIndex": { + "@call": "@index", + "args": { + "offset": 1 + } + }, + "escapedKey": { + "@@path": "/literal/not/bound" + } + } + } + }, + "expectDispatched": { + "name": "item_selected", + "surfaceId": "main_surface", + "sourceComponentId": "row_btn", + "timestampIso8601": true, + "context": { + "itemTitle": "Second", + "upperTitle": "Selected: Second", + "itemIndex": 1, + "oneBasedIndex": 2, + "escapedKey": { + "@path": "/literal/not/bound" + } + } + } + }, + { + "name": "test_action_v10_event_context_literal_path_and_call_keys", + "description": "Verifies v1.0 literal 'path' and 'call' keys inside event.context are preserved as literal keys through GenericBinder / NodeResolver without being misread as v0.9 bindings.", + "action": "dispatch_action", + "protocolVersion": "1.0", + "surfaceId": "main_surface", + "sourceComponentId": "save_btn", + "dataModel": { + "user": "Ada" + }, + "actionPayload": { + "event": { + "name": "save_document", + "context": { + "path": "/documents/report.md", + "call": "sync", + "author": { + "@path": "/user" + } + } + } + }, + "expectDispatched": { + "name": "save_document", + "surfaceId": "main_surface", + "sourceComponentId": "save_btn", + "timestampIso8601": true, + "context": { + "path": "/documents/report.md", + "call": "sync", + "author": "Ada" + } + } + }, + { + "name": "test_action_function_call_local_mutation_no_event_dispatched", + "description": "Verifies a local functionCall action executes locally, mutates dataModel, and dispatches no outbound event (expectDispatched is null).", + "action": "dispatch_action", + "protocolVersion": "1.0", + "surfaceId": "main_surface", + "sourceComponentId": "inc_btn", + "dataModel": { + "count": 1 + }, + "actionPayload": { + "functionCall": { + "@call": "incrementCounter", + "args": { + "path": "/count", + "step": 2 + } + } + }, + "expectDataModel": { + "count": 3 + }, + "expectDispatched": null + }, + { + "name": "test_action_function_call_open_url_with_user_activation_allowed", + "description": "Verifies openUrl functionCall action succeeds when userActivation is true and dispatches no outbound event.", + "action": "dispatch_action", + "protocolVersion": "1.0", + "surfaceId": "main_surface", + "sourceComponentId": "link_btn", + "userActivation": true, + "actionPayload": { + "functionCall": { + "@call": "openUrl", + "args": { + "url": "https://example.com" + } + } + }, + "expectDispatched": null + }, + { + "name": "test_action_function_call_open_url_without_user_activation_denied", + "description": "Verifies openUrl functionCall action fails when userActivation is false and emits EXECUTION_ERROR / INVALID_ACTION on surface.onError.", + "action": "dispatch_action", + "protocolVersion": "1.0", + "surfaceId": "main_surface", + "sourceComponentId": "link_btn", + "userActivation": false, + "actionPayload": { + "functionCall": { + "@call": "openUrl", + "args": { + "url": "https://example.com" + } + } + }, + "expectDispatched": null, + "expectError": { + "surfaceId": "main_surface", + "message": "openUrl" + } + }, + { + "name": "test_action_event_missing_name_error", + "description": "Verifies dispatch_action with a missing event name emits INVALID_ACTION on surface.onError.", + "action": "dispatch_action", + "protocolVersion": "1.0", + "surfaceId": "main_surface", + "sourceComponentId": "bad_btn", + "actionPayload": { + "event": { + "context": { + "foo": "bar" + } + } + }, + "expectDispatched": null, + "expectError": { + "code": "INVALID_ACTION", + "surfaceId": "main_surface" + } + }, + { + "name": "test_action_event_non_string_user_message_error", + "description": "Verifies dispatch_action with a non-string userMessage emits INVALID_ACTION on surface.onError.", + "action": "dispatch_action", + "protocolVersion": "1.0", + "surfaceId": "main_surface", + "sourceComponentId": "bad_msg_btn", + "actionPayload": { + "event": { + "name": "submit", + "userMessage": 12345 + } + }, + "expectDispatched": null, + "expectError": { + "code": "INVALID_ACTION", + "surfaceId": "main_surface" + } + }, + { + "name": "test_action_function_call_execution_error", + "description": "Verifies a failing local functionCall action emits EXECUTION_ERROR on surface.onError and dispatches no outbound event.", + "action": "dispatch_action", + "protocolVersion": "1.0", + "surfaceId": "main_surface", + "sourceComponentId": "fail_btn", + "actionPayload": { + "functionCall": { + "@call": "failingAction", + "args": {} + } + }, + "expectDispatched": null, + "expectError": { + "code": "EXECUTION_ERROR", + "surfaceId": "main_surface" + } + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/composition_constraints.json b/package/__fixtures__/a2ui-conformance/core/composition_constraints.json new file mode 100644 index 0000000000..201b787401 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/composition_constraints.json @@ -0,0 +1,734 @@ +[ + { + "name": "test_composition_surface_implicit_parent_container", + "description": "Verifies root components treating Surface as canonical parent container when allowedParents includes Surface.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "process_messages", + "validationConfig": {}, + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "t1" + ] + }, + { + "id": "t1", + "component": "Text", + "text": "Hello World" + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "s1": { + "components": { + "root": { + "component": "Column", + "children": [ + "t1" + ] + }, + "t1": { + "component": "Text", + "text": "Hello World" + } + } + } + } + } + }, + { + "name": "test_composition_unallowed_parent_error", + "description": "Verifies validation error thrown when component is placed under an unallowed parent component type.", + "catalog": { + "catalogId": "custom", + "protocolVersion": "1.0", + "components": { + "ColumnHeader": { + "type": "object", + "allowedParents": [ + "Column" + ] + }, + "Row": { + "type": "object", + "properties": { + "children": { + "$ref": "#/$defs/ChildList" + } + } + } + } + }, + "action": "process_messages", + "validationConfig": {}, + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom", + "components": [ + { + "id": "root", + "component": "Row", + "children": [ + "invalid_header" + ] + }, + { + "id": "invalid_header", + "component": "ColumnHeader" + } + ] + } + } + ] + } + ], + "expectError": { + "category": "ValidationError", + "code": "UNALLOWED_PARENT", + "surfaceId": "s1", + "path": "/components/1", + "message": "Component 'invalid_header' (ColumnHeader) cannot be placed under parent 'root' (Row). Allowed parents: ['Column'].", + "details": [ + { + "path": "/components/1", + "code": "UNALLOWED_PARENT", + "surfaceId": "s1" + } + ], + "rendererError": { + "version": "1.0", + "error": { + "code": "UNALLOWED_PARENT", + "surfaceId": "s1", + "path": "/components/1", + "message": "Component 'invalid_header' (ColumnHeader) cannot be placed under parent 'root' (Row). Allowed parents: ['Column']." + } + } + } + }, + { + "name": "test_composition_unallowed_parent_root_error", + "description": "Verifies validation error thrown when root component does not allow Surface as its implicit parent.", + "catalog": { + "protocolVersion": "1.0", + "catalogSchema": { + "catalogId": "custom", + "components": { + "ColumnHeader": { + "allowedParents": [ + "Column" + ] + } + } + } + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom", + "components": [ + { + "id": "root", + "component": "ColumnHeader" + } + ] + } + } + ] + } + ], + "expectError": { + "category": "ValidationError", + "code": "UNALLOWED_PARENT", + "surfaceId": "s1", + "path": "/components/0", + "message": "Component 'root' (ColumnHeader) cannot be placed under parent 'Surface' (Surface). Allowed parents: ['Column'].", + "details": [ + { + "path": "/components/0", + "code": "UNALLOWED_PARENT", + "surfaceId": "s1" + } + ], + "rendererError": { + "version": "1.0", + "error": { + "code": "UNALLOWED_PARENT", + "surfaceId": "s1", + "path": "/components/0", + "message": "Component 'root' (ColumnHeader) cannot be placed under parent 'Surface' (Surface). Allowed parents: ['Column']." + } + } + } + }, + { + "name": "test_composition_unallowed_child_error", + "description": "Verifies validation error thrown when container contains an unallowed child component type.", + "catalog": { + "catalogId": "custom", + "protocolVersion": "1.0", + "components": { + "RestrictedBox": { + "type": "object", + "allowedChildren": [ + "Text", + "Button" + ], + "properties": { + "children": { + "$ref": "#/$defs/ChildList" + } + } + }, + "Video": { + "type": "object" + } + } + }, + "action": "process_messages", + "validationConfig": {}, + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom", + "components": [ + { + "id": "root", + "component": "RestrictedBox", + "children": [ + "v1" + ] + }, + { + "id": "v1", + "component": "Video" + } + ] + } + } + ] + } + ], + "expectError": { + "category": "ValidationError", + "code": "UNALLOWED_CHILD", + "surfaceId": "s1", + "path": "/components/0/children/0", + "message": "Container 'root' (RestrictedBox) cannot contain child 'v1' (Video). Allowed children: ['Text', 'Button'].", + "details": [ + { + "path": "/components/0/children/0", + "code": "UNALLOWED_CHILD", + "surfaceId": "s1" + } + ], + "rendererError": { + "version": "1.0", + "error": { + "code": "UNALLOWED_CHILD", + "surfaceId": "s1", + "path": "/components/0/children/0", + "message": "Container 'root' (RestrictedBox) cannot contain child 'v1' (Video). Allowed children: ['Text', 'Button']." + } + } + } + }, + { + "name": "test_composition_dropped_child_is_not_checked_against_surface", + "description": "A container rewritten to drop a child leaves a component that nothing references. Only the surface's root is checked against Surface in allowedParents, so when orphans are allowed the update is accepted, even though the child's allowedParents does not include Surface. The other checks are on, so the processor checks the whole surface.", + "catalog": { + "catalogId": "custom", + "protocolVersion": "1.0", + "components": { + "Box": { + "type": "object", + "properties": { + "children": { + "$ref": "#/$defs/ChildList" + } + } + }, + "Header": { + "type": "object", + "allowedParents": [ + "Box" + ] + } + } + }, + "action": "process_messages", + "validationConfig": { + "allowOrphanComponents": true + }, + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom", + "components": [ + { + "id": "root", + "component": "Box", + "children": [ + "h" + ] + }, + { + "id": "h", + "component": "Header" + } + ] + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Box", + "children": [] + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "s1": { + "components": { + "root": { + "component": "Box", + "children": [] + }, + "h": { + "component": "Header" + } + } + } + } + } + }, + { + "name": "test_composition_dropped_child_is_not_checked_against_surface_when_references_may_dangle", + "description": "The same payloads as test_composition_dropped_child_is_not_checked_against_surface, when references to components that have not arrived are also allowed. An SDK may then check only the components each update changes; the dropped child is still not checked against Surface.", + "catalog": { + "catalogId": "custom", + "protocolVersion": "1.0", + "components": { + "Box": { + "type": "object", + "properties": { + "children": { + "$ref": "#/$defs/ChildList" + } + } + }, + "Header": { + "type": "object", + "allowedParents": [ + "Box" + ] + } + } + }, + "action": "process_messages", + "validationConfig": { + "allowOrphanComponents": true, + "allowDanglingReferences": true + }, + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom", + "components": [ + { + "id": "root", + "component": "Box", + "children": [ + "h" + ] + }, + { + "id": "h", + "component": "Header" + } + ] + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Box", + "children": [] + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "s1": { + "components": { + "root": { + "component": "Box", + "children": [] + }, + "h": { + "component": "Header" + } + } + } + } + } + }, + { + "name": "test_composition_dropped_child_is_reported_as_orphan", + "description": "The same payloads as test_composition_dropped_child_is_not_checked_against_surface, when orphans are not allowed. The topology check reports the dropped child as unreachable from the root; it is not reported as UNALLOWED_PARENT against Surface.", + "catalog": { + "catalogId": "custom", + "protocolVersion": "1.0", + "components": { + "Box": { + "type": "object", + "properties": { + "children": { + "$ref": "#/$defs/ChildList" + } + } + }, + "Header": { + "type": "object", + "allowedParents": [ + "Box" + ] + } + } + }, + "action": "process_messages", + "validationConfig": { + "allowOrphanComponents": false + }, + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom", + "components": [ + { + "id": "root", + "component": "Box", + "children": [ + "h" + ] + }, + { + "id": "h", + "component": "Header" + } + ] + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Box", + "children": [] + } + ] + } + } + ], + "expectError": { + "category": "IntegrityError", + "message": "Component 'h' is not reachable from 'root'" + } + } + ] + }, + { + "name": "test_composition_root_referenced_by_another_component_checked_against_surface", + "description": "The surface's root is checked against Surface in allowedParents even when another component also lists it as a child. The other component is an orphan, which the config allows.", + "catalog": { + "catalogId": "custom", + "protocolVersion": "1.0", + "components": { + "Box": { + "type": "object", + "properties": { + "children": { + "$ref": "#/$defs/ChildList" + } + } + }, + "Card": { + "type": "object", + "allowedParents": [ + "Box" + ] + } + } + }, + "action": "process_messages", + "validationConfig": { + "allowOrphanComponents": true + }, + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom", + "components": [ + { + "id": "root", + "component": "Card" + }, + { + "id": "box", + "component": "Box", + "children": [ + "root" + ] + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "code": "UNALLOWED_PARENT", + "message": "Component 'root' (Card) cannot be placed under parent 'Surface' (Surface). Allowed parents: ['Box']." + } + } + ] + }, + { + "name": "test_composition_multiple_parents_disallowed_first", + "description": "Every parent of a component is checked against its allowedParents. Here the disallowed parent comes first in surface order.", + "catalog": { + "catalogId": "custom", + "protocolVersion": "1.0", + "components": { + "Box": { + "type": "object", + "properties": { + "children": { + "$ref": "#/$defs/ChildList" + } + } + }, + "Card": { + "type": "object", + "properties": { + "children": { + "$ref": "#/$defs/ChildList" + } + } + }, + "Header": { + "type": "object", + "allowedParents": [ + "Box" + ] + } + } + }, + "action": "process_messages", + "validationConfig": {}, + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom", + "components": [ + { + "id": "root", + "component": "Box", + "children": [ + "card", + "b" + ] + }, + { + "id": "card", + "component": "Card", + "children": [ + "h" + ] + }, + { + "id": "b", + "component": "Box", + "children": [ + "h" + ] + }, + { + "id": "h", + "component": "Header" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "code": "UNALLOWED_PARENT", + "message": "Component 'h' (Header) cannot be placed under parent 'card' (Card). Allowed parents: ['Box']." + } + } + ] + }, + { + "name": "test_composition_multiple_parents_disallowed_second", + "description": "The same as test_composition_multiple_parents_disallowed_first, with the disallowed parent second in surface order.", + "catalog": { + "catalogId": "custom", + "protocolVersion": "1.0", + "components": { + "Box": { + "type": "object", + "properties": { + "children": { + "$ref": "#/$defs/ChildList" + } + } + }, + "Card": { + "type": "object", + "properties": { + "children": { + "$ref": "#/$defs/ChildList" + } + } + }, + "Header": { + "type": "object", + "allowedParents": [ + "Box" + ] + } + } + }, + "action": "process_messages", + "validationConfig": {}, + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom", + "components": [ + { + "id": "root", + "component": "Box", + "children": [ + "b", + "card" + ] + }, + { + "id": "b", + "component": "Box", + "children": [ + "h" + ] + }, + { + "id": "card", + "component": "Card", + "children": [ + "h" + ] + }, + { + "id": "h", + "component": "Header" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "code": "UNALLOWED_PARENT", + "message": "Component 'h' (Header) cannot be placed under parent 'card' (Card). Allowed parents: ['Box']." + } + } + ] + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/data_context.json b/package/__fixtures__/a2ui-conformance/core/data_context.json new file mode 100644 index 0000000000..10c6456d07 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/data_context.json @@ -0,0 +1,144 @@ +[ + { + "name": "test_resolve_path_relative_against_context", + "description": "Resolves a relative path against a scoped context path.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "resolve_path", + "args": { + "path": "name", + "contextPath": "/user" + }, + "expect": "/user/name" + }, + { + "name": "test_resolve_path_nested_relative_against_context", + "description": "Resolves a multi-segment relative path against a scoped context path.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "resolve_path", + "args": { + "path": "address/city", + "contextPath": "/user" + }, + "expect": "/user/address/city" + }, + { + "name": "test_resolve_path_absolute_ignores_context", + "description": "An absolute path starting with `/` ignores the context path.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "resolve_path", + "args": { + "path": "/list/0", + "contextPath": "/user" + }, + "expect": "/list/0" + }, + { + "name": "test_resolve_path_relative_against_root", + "description": "Resolves a relative path against the root context `/`.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "resolve_path", + "args": { + "path": "user/name", + "contextPath": "/" + }, + "expect": "/user/name" + }, + { + "name": "test_resolve_path_relative_without_context", + "description": "Resolves a relative path when no context path is provided.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "resolve_path", + "args": { + "path": "foo" + }, + "expect": "/foo" + }, + { + "name": "test_resolve_path_context_with_trailing_slash", + "description": "Strips a trailing slash on the context path before joining a relative path.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "resolve_path", + "args": { + "path": "foo", + "contextPath": "/bar/" + }, + "expect": "/bar/foo" + }, + { + "name": "test_resolve_path_empty_in_scoped_context", + "description": "An empty relative path resolves to the current context path.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "resolve_path", + "args": { + "path": "", + "contextPath": "/user" + }, + "expect": "/user" + }, + { + "name": "test_resolve_path_empty_in_trailing_slash_context", + "description": "An empty relative path in a context with a trailing slash resolves to the trimmed context path.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "resolve_path", + "args": { + "path": "", + "contextPath": "/user/" + }, + "expect": "/user" + }, + { + "name": "test_resolve_path_dot_in_scoped_context", + "description": "A `.` path resolves to the current context path.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "resolve_path", + "args": { + "path": ".", + "contextPath": "/user" + }, + "expect": "/user" + }, + { + "name": "test_resolve_path_empty_in_root_context", + "description": "An empty relative path in the root context resolves to `/`.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "resolve_path", + "args": { + "path": "", + "contextPath": "/" + }, + "expect": "/" + }, + { + "name": "test_resolve_path_dot_in_root_context", + "description": "A `.` path in the root context resolves to `/`.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "resolve_path", + "args": { + "path": ".", + "contextPath": "/" + }, + "expect": "/" + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/data_deletion.json b/package/__fixtures__/a2ui-conformance/core/data_deletion.json new file mode 100644 index 0000000000..64471a4081 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/data_deletion.json @@ -0,0 +1,358 @@ +[ + { + "name": "test_data_deletion_value_null", + "description": "Verifies that updateDataModel with value=null deletes the specified JSON Pointer path.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "dataModel": { + "user": { + "profile": { + "name": "Alice", + "bio": "Software Engineer" + } + } + } + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/user/profile/bio", + "value": null + } + } + ] + } + ], + "expect": { + "surfaces": { + "s1": { + "dataModel": { + "user": { + "profile": { + "name": "Alice" + } + } + } + } + } + } + }, + { + "name": "test_data_deletion_nested_pointer_sibling_preservation", + "description": "Verifies deleting a child property via value=null preserves sibling keys in parent object.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "dataModel": { + "settings": { + "notifications": { + "email": true, + "push": false, + "sms": true + } + } + } + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/settings/notifications/email", + "value": null + } + } + ] + } + ], + "expect": { + "surfaces": { + "s1": { + "dataModel": { + "settings": { + "notifications": { + "push": false, + "sms": true + } + } + } + } + } + } + }, + { + "name": "test_data_deletion_top_level_key", + "description": "Verifies deleting a top-level JSON Pointer path via value=null.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "dataModel": { + "persistentData": "keep", + "transientData": "remove" + } + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/transientData", + "value": null + } + } + ] + } + ], + "expect": { + "surfaces": { + "s1": { + "dataModel": { + "persistentData": "keep" + } + } + } + } + }, + { + "name": "test_data_deletion_parent_object_recursive", + "description": "Verifies deleting an intermediate parent object key via value=null recursively removes all nested child properties.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "dataModel": { + "settings": { + "notifications": { + "email": true, + "push": false, + "sms": true + }, + "theme": "dark" + } + } + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/settings/notifications", + "value": null + } + } + ] + } + ], + "expect": { + "surfaces": { + "s1": { + "dataModel": { + "settings": { + "theme": "dark" + } + } + } + } + } + }, + { + "name": "test_data_deletion_all_keys_empty_data_model", + "description": "Verifies deleting the only top-level key via value=null results in an empty dataModel.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "dataModel": { + "user": { + "profile": { + "name": "Alice" + } + } + } + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/user", + "value": null + } + } + ] + } + ], + "expect": { + "surfaces": { + "s1": { + "dataModel": {} + } + } + } + }, + { + "name": "test_data_deletion_array_index_preservation", + "description": "Verifies deleting array element via value=null preserves outer array length and structure with null.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "dataModel": { + "items": [ + "a", + "b", + "c" + ] + } + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/items/1", + "value": null + } + } + ] + } + ], + "expect": { + "surfaces": { + "s1": { + "dataModel": { + "items": [ + "a", + null, + "c" + ] + } + } + } + } + }, + { + "name": "test_data_deletion_root_replacement", + "description": "Verifies updating root path / with value=null resets data model to empty object.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "dataModel": { + "user": { + "name": "Alice" + }, + "count": 42 + } + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/", + "value": null + } + } + ] + } + ], + "expect": { + "surfaces": { + "s1": { + "dataModel": {} + } + } + } + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/data_model.json b/package/__fixtures__/a2ui-conformance/core/data_model.json new file mode 100644 index 0000000000..c4cf040e49 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/data_model.json @@ -0,0 +1,2012 @@ +[ + { + "name": "test_data_model_initializes_empty", + "description": "A model created without data starts as an empty object.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "steps": [ + { + "op": "get", + "path": "/", + "expect": {} + } + ] + }, + { + "name": "test_data_model_retrieves_root", + "description": "The root path returns the whole data model.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "get", + "path": "/", + "expect": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + } + } + ] + }, + { + "name": "test_data_model_retrieves_nested_path", + "description": "Nested object paths resolve segment by segment.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "get", + "path": "/user/name", + "expect": "Alice" + }, + { + "op": "get", + "path": "/user/settings/theme", + "expect": "dark" + } + ] + }, + { + "name": "test_data_model_retrieves_list_items", + "description": "List elements resolve by numeric segment.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "get", + "path": "/items/0", + "expect": "a" + }, + { + "op": "get", + "path": "/items/1", + "expect": "b" + } + ] + }, + { + "name": "test_data_model_absent_for_missing_paths", + "description": "Paths that do not exist resolve to no value.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "get", + "path": "/user/age", + "expect_absent": true + }, + { + "op": "get", + "path": "/unknown/path", + "expect_absent": true + } + ] + }, + { + "name": "test_data_model_absent_through_empty_segment", + "description": "Traversing through a segment that holds no value yields no value.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/nullable/inner", + "value": 1 + }, + { + "op": "delete", + "path": "/nullable/inner" + }, + { + "op": "get", + "path": "/nullable/deep/path", + "expect_absent": true + } + ] + }, + { + "name": "test_data_model_sets_existing_path", + "description": "Setting an existing path replaces its value.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/user/name", + "value": "Bob" + }, + { + "op": "get", + "path": "/user/name", + "expect": "Bob" + } + ] + }, + { + "name": "test_data_model_sets_new_path", + "description": "Setting a new key on an existing object adds it.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/user/age", + "value": 30 + }, + { + "op": "get", + "path": "/user/age", + "expect": 30 + } + ] + }, + { + "name": "test_data_model_creates_intermediate_objects", + "description": "Missing intermediate objects are created on write.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/a/b/c", + "value": "foo" + }, + { + "op": "get", + "path": "/a/b/c", + "expect": "foo" + }, + { + "op": "get", + "path": "/a/b", + "expect_type": "object" + } + ] + }, + { + "name": "test_data_model_delete_removes_key", + "description": "Deleting a path removes the key from its parent object.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "delete", + "path": "/user/name" + }, + { + "op": "get", + "path": "/user/name", + "expect_absent": true + }, + { + "op": "get", + "path": "/user", + "expect": { + "settings": { + "theme": "dark" + } + } + } + ] + }, + { + "name": "test_data_model_list_set_and_get", + "description": "Writing a numeric segment into a missing path creates a list.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/list/0", + "value": "hello" + }, + { + "op": "get", + "path": "/list/0", + "expect": "hello" + }, + { + "op": "get", + "path": "/list", + "expect_type": "list" + } + ] + }, + { + "name": "test_data_model_list_append", + "description": "Writing successive indices appends to a list.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/list/0", + "value": "hello" + }, + { + "op": "set", + "path": "/list/1", + "value": "world" + }, + { + "op": "get", + "path": "/list", + "expect": [ + "hello", + "world" + ] + } + ] + }, + { + "name": "test_data_model_list_update_index", + "description": "Writing an existing index replaces that element.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/items/1", + "value": "updated" + }, + { + "op": "get", + "path": "/items", + "expect": [ + "a", + "updated", + "c" + ] + } + ] + }, + { + "name": "test_data_model_creates_nested_structures", + "description": "Auto-vivification picks a list or an object based on the next segment.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/a/b/0/c", + "value": 123 + }, + { + "op": "get", + "path": "/a/b/0/c", + "expect": 123 + }, + { + "op": "get", + "path": "/a/b", + "expect_type": "list" + }, + { + "op": "get", + "path": "/a/b/0", + "expect_type": "object" + }, + { + "op": "set", + "path": "/x/y/z", + "value": "hello" + }, + { + "op": "get", + "path": "/x/y/z", + "expect": "hello" + }, + { + "op": "set", + "path": "/nestedList/0/0", + "value": "inner" + }, + { + "op": "get", + "path": "/nestedList/0/0", + "expect": "inner" + }, + { + "op": "get", + "path": "/nestedList", + "expect_type": "list" + }, + { + "op": "get", + "path": "/nestedList/0", + "expect_type": "list" + } + ] + }, + { + "name": "test_data_model_get_out_of_range_index", + "description": "Out of range and non numeric list indices resolve to no value on read.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "get", + "path": "/items/99", + "expect_absent": true + }, + { + "op": "get", + "path": "/items/-1", + "expect_absent": true + }, + { + "op": "get", + "path": "/items/invalid", + "expect_absent": true + } + ] + }, + { + "name": "test_data_model_normalizes_trailing_slash", + "description": "A trailing slash is not a distinct path.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "watch": [ + "/foo" + ], + "steps": [ + { + "op": "set", + "path": "/foo/", + "value": "bar", + "expect_notified": [ + "/foo" + ] + }, + { + "op": "get", + "path": "/foo/", + "expect": "bar" + }, + { + "op": "get", + "path": "/foo", + "expect": "bar" + } + ] + }, + { + "name": "test_data_model_replaces_root", + "description": "Setting the root path replaces the whole model.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "watch": [ + "/", + "/unrelated" + ], + "steps": [ + { + "op": "set", + "path": "/", + "value": { + "newRoot": "foo" + }, + "expect_notified": [ + "/" + ], + "expect_values": { + "/": { + "newRoot": "foo" + } + } + }, + { + "op": "get", + "path": "", + "expect": { + "newRoot": "foo" + } + } + ] + }, + { + "name": "test_data_model_escaped_slash", + "description": "A ~1 escape addresses a key containing a slash (RFC 6901).", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/user/detailed~1info", + "value": "some info" + }, + { + "op": "get", + "path": "/user/detailed~1info", + "expect": "some info" + }, + { + "op": "get", + "path": "/user", + "expect": { + "name": "Alice", + "settings": { + "theme": "dark" + }, + "detailed/info": "some info" + } + } + ] + }, + { + "name": "test_data_model_escaped_tilde", + "description": "A ~0 escape addresses a key containing a tilde (RFC 6901).", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/user/profile~0name", + "value": "profile~name" + }, + { + "op": "get", + "path": "/user/profile~0name", + "expect": "profile~name" + }, + { + "op": "get", + "path": "/user", + "expect": { + "name": "Alice", + "settings": { + "theme": "dark" + }, + "profile~name": "profile~name" + } + } + ] + }, + { + "name": "test_data_model_escaped_mixed", + "description": "Both escapes may appear in one segment.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/user/a~0b~1c", + "value": "value" + }, + { + "op": "get", + "path": "/user/a~0b~1c", + "expect": "value" + }, + { + "op": "get", + "path": "/user", + "expect": { + "name": "Alice", + "settings": { + "theme": "dark" + }, + "a~b/c": "value" + } + } + ] + }, + { + "name": "test_data_model_escape_order", + "description": "A ~01 sequence decodes to ~1, not to a slash.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/user/a~01b", + "value": "value" + }, + { + "op": "get", + "path": "/user/a~01b", + "expect": "value" + }, + { + "op": "get", + "path": "/user", + "expect": { + "name": "Alice", + "settings": { + "theme": "dark" + }, + "a~1b": "value" + } + } + ] + }, + { + "name": "test_data_model_rejects_write_through_primitive", + "description": "Writing through a primitive intermediate segment is an error.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/user/name", + "value": "not an object" + }, + { + "op": "set", + "path": "/user/name/first", + "value": "Alice", + "expect_error": { + "category": "DataError", + "message": "Cannot set path" + } + } + ] + }, + { + "name": "test_data_model_rejects_write_through_list_primitive", + "description": "Writing through a primitive list element is an error.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/items/0/foo", + "value": "bar", + "expect_error": { + "category": "DataError", + "message": "Cannot set path" + } + } + ] + }, + { + "name": "test_data_model_rejects_non_numeric_list_segment", + "description": "A non numeric segment cannot address a list element on write.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/items/foo", + "value": "bar", + "expect_error": { + "category": "DataError", + "message": "non-numeric segment" + } + } + ] + }, + { + "name": "test_data_model_rejects_non_numeric_list_segment_intermediate", + "description": "A non numeric intermediate segment cannot address a list element.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/items/foo/bar", + "value": "value", + "expect_error": { + "category": "DataError", + "message": "non-numeric segment" + } + } + ] + }, + { + "name": "test_data_model_notifies_exact_path", + "description": "An observer fires when its own path is written.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "watch": [ + "/user/name" + ], + "steps": [ + { + "op": "set", + "path": "/user/name", + "value": "Charlie", + "expect_values": { + "/user/name": "Charlie" + }, + "expect_notified": [ + "/user/name" + ] + } + ] + }, + { + "name": "test_data_model_notifies_ancestor", + "description": "An observer on a container fires when a descendant is written.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "watch": [ + "/user" + ], + "steps": [ + { + "op": "set", + "path": "/user/name", + "value": "Dave", + "expect_values": { + "/user": { + "name": "Dave", + "settings": { + "theme": "dark" + } + } + }, + "expect_notified": [ + "/user" + ] + } + ] + }, + { + "name": "test_data_model_notifies_descendant", + "description": "An observer on a leaf fires when an ancestor is replaced.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "watch": [ + "/user/settings/theme" + ], + "steps": [ + { + "op": "set", + "path": "/user/settings", + "value": { + "theme": "light" + }, + "expect_values": { + "/user/settings/theme": "light" + }, + "expect_notified": [ + "/user/settings/theme" + ] + } + ] + }, + { + "name": "test_data_model_notifies_root_observer", + "description": "A root observer fires on any write.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "watch": [ + "/" + ], + "steps": [ + { + "op": "set", + "path": "/newProp", + "value": "test", + "expect_notified": [ + "/" + ] + } + ] + }, + { + "name": "test_data_model_notifies_parent_on_child_write", + "description": "A container observer sees the updated container value.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "watch": [ + "/parent" + ], + "steps": [ + { + "op": "set", + "path": "/parent", + "value": { + "child": "initial" + } + }, + { + "op": "set", + "path": "/parent/child", + "value": "updated", + "expect_values": { + "/parent": { + "child": "updated" + } + } + } + ] + }, + { + "name": "test_data_model_does_not_notify_unrelated", + "description": "An observer does not fire for an unrelated path.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "a": 1, + "b": 2 + }, + "watch": [ + "/b" + ], + "steps": [ + { + "op": "set", + "path": "/a", + "value": 99, + "expect_notified": [] + } + ] + }, + { + "name": "test_data_model_does_not_notify_prefix_sibling", + "description": "A sibling whose name merely shares a prefix is not a descendant.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "foo": 1, + "foobar": 2 + }, + "watch": [ + "/foo", + "/foobar" + ], + "steps": [ + { + "op": "set", + "path": "/foo", + "value": 99, + "expect_notified": [ + "/foo" + ] + } + ] + }, + { + "name": "test_data_model_multiple_observers_same_path", + "description": "Every observer on a path is notified.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "watch": [ + "/user/name", + "/user/name" + ], + "steps": [ + { + "op": "set", + "path": "/user/name", + "value": "Eve", + "expect_values": { + "/user/name": "Eve" + }, + "expect_notified": [ + "/user/name", + "/user/name" + ] + } + ] + }, + { + "name": "test_data_model_observe_absent_path", + "description": "Observing a path that does not exist yet starts empty and fires on creation.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "watch": [ + "/non/existent" + ], + "steps": [ + { + "op": "get", + "path": "/non/existent", + "expect_absent": true + }, + { + "op": "set", + "path": "/non/existent", + "value": "exists now", + "expect_values": { + "/non/existent": "exists now" + }, + "expect_notified": [ + "/non/existent" + ] + } + ] + }, + { + "name": "test_data_model_stops_notifying_after_dispose", + "description": "Disposing the model detaches every observer.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "watch": [ + "/" + ], + "steps": [ + { + "op": "dispose" + }, + { + "op": "set", + "path": "/foo", + "value": "bar", + "expect_notified": [] + } + ] + }, + { + "name": "test_data_model_does_not_notify_unchanged_descendant", + "description": "An observer on a descendant path does not fire when an ancestor is rewritten but the observer's own value is still absent.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "watch": [ + "/a/b" + ], + "steps": [ + { + "op": "set", + "path": "/a", + "value": { + "c": 1 + }, + "expect_notified": [] + } + ] + }, + { + "name": "test_data_model_does_not_notify_same_value_rewrite", + "description": "Writing a path the value it already holds does not fire its observers.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "k": "v" + }, + "watch": [ + "/k" + ], + "steps": [ + { + "op": "set", + "path": "/k", + "value": "v", + "expect_notified": [] + }, + { + "op": "set", + "path": "/k", + "value": "w", + "expect_notified": [ + "/k" + ] + } + ] + }, + { + "name": "test_data_model_reactivity_child_deletion_notifies_parent_and_child", + "description": "Deleting a child property notifies both the child path and ancestor containers.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + } + }, + "watch": [ + "/user", + "/user/name" + ], + "steps": [ + { + "op": "delete", + "path": "/user/name", + "expect_notified": [ + "/user", + "/user/name" + ], + "expect_values": { + "/user": { + "settings": { + "theme": "dark" + } + } + } + }, + { + "op": "get", + "path": "/user/name", + "expect_absent": true + } + ] + }, + { + "name": "test_data_model_reactivity_array_element_update_notifies_array", + "description": "Updating an array element notifies both the element observer and the array observer.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "items": [ + "a", + "b", + "c" + ] + }, + "watch": [ + "/items", + "/items/1" + ], + "steps": [ + { + "op": "set", + "path": "/items/1", + "value": "updated", + "expect_notified": [ + "/items", + "/items/1" + ], + "expect_values": { + "/items": [ + "a", + "updated", + "c" + ], + "/items/1": "updated" + } + } + ] + }, + { + "name": "test_data_model_reactivity_ancestor_deletion_notifies_descendants", + "description": "Deleting a parent container notifies observers on nested descendant paths whose values become absent.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "settings": { + "theme": "dark" + } + } + }, + "watch": [ + "/user", + "/user/settings", + "/user/settings/theme" + ], + "steps": [ + { + "op": "delete", + "path": "/user", + "expect_notified": [ + "/user", + "/user/settings", + "/user/settings/theme" + ] + }, + { + "op": "get", + "path": "/user/settings/theme", + "expect_absent": true + } + ] + }, + { + "name": "test_data_model_delete_out_of_range_list_index_is_noop", + "description": "Deleting a list index past the end of the list leaves the list unchanged rather than padding it out to that index.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "delete", + "path": "/items/10" + }, + { + "op": "get", + "path": "/items", + "expect": [ + "a", + "b", + "c" + ] + } + ] + }, + { + "name": "test_data_model_notifies_descendant_on_root_replacement", + "description": "Replacing the root notifies observers on descendant paths whose values change or become absent.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice" + }, + "stale": "present" + }, + "watch": [ + "/user/name", + "/stale" + ], + "steps": [ + { + "op": "set", + "path": "/", + "value": { + "user": { + "name": "Bob" + } + }, + "expect_notified": [ + "/user/name", + "/stale" + ], + "expect_values": { + "/user/name": "Bob" + } + }, + { + "op": "get", + "path": "/stale", + "expect_absent": true + } + ] + }, + { + "name": "test_data_model_rejects_forbidden_path_segments", + "description": "Paths containing __proto__, constructor, or prototype are rejected on both read and write with DataError.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice" + } + }, + "steps": [ + { + "op": "set", + "path": "/__proto__/polluted", + "value": "hacked", + "expect_error": { + "category": "DataError", + "message": "Forbidden path segment" + } + }, + { + "op": "get", + "path": "/__proto__/polluted", + "expect_error": { + "category": "DataError", + "message": "Forbidden path segment" + } + }, + { + "op": "set", + "path": "/constructor/prototype/polluted", + "value": "hacked", + "expect_error": { + "category": "DataError", + "message": "Forbidden path segment" + } + }, + { + "op": "get", + "path": "/constructor/prototype/polluted", + "expect_error": { + "category": "DataError", + "message": "Forbidden path segment" + } + }, + { + "op": "set", + "path": "/user/prototype/polluted", + "value": "hacked", + "expect_error": { + "category": "DataError", + "message": "Forbidden path segment" + } + }, + { + "op": "get", + "path": "/user/prototype/polluted", + "expect_error": { + "category": "DataError", + "message": "Forbidden path segment" + } + } + ] + }, + { + "name": "test_data_model_allows_own_properties_named_after_object_methods", + "description": "Unset keys like toString, valueOf, and hasOwnProperty are absent on read, and can be written and read back as ordinary keys.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": {}, + "steps": [ + { + "op": "get", + "path": "/toString", + "expect_absent": true + }, + { + "op": "get", + "path": "/valueOf", + "expect_absent": true + }, + { + "op": "get", + "path": "/hasOwnProperty", + "expect_absent": true + }, + { + "op": "set", + "path": "/toString", + "value": "custom toString" + }, + { + "op": "get", + "path": "/toString", + "expect": "custom toString" + }, + { + "op": "set", + "path": "/valueOf/nested", + "value": "custom valueOf" + }, + { + "op": "get", + "path": "/valueOf/nested", + "expect": "custom valueOf" + } + ] + }, + { + "name": "test_data_model_delete_absent_deep_path_is_noop", + "description": "Deleting a deep path that does not exist is a no-op that neither vivifies intermediate containers nor notifies observers.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": {}, + "watch": [ + "/", + "/nonexistent" + ], + "steps": [ + { + "op": "delete", + "path": "/nonexistent/child/path", + "expect_notified": [] + }, + { + "op": "get", + "path": "/", + "expect": {} + }, + { + "op": "get", + "path": "/nonexistent", + "expect_absent": true + } + ] + }, + { + "name": "test_data_model_sparse_list_write_pads_with_null", + "description": "Writing an index past the current end of a list pads intermediate positions with null/absent values.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": {}, + "steps": [ + { + "op": "set", + "path": "/data/lists/1/value", + "value": 42 + }, + { + "op": "get", + "path": "/data/lists/1/value", + "expect": 42 + }, + { + "op": "get", + "path": "/data/lists/0", + "expect_absent": true + }, + { + "op": "get", + "path": "/data/lists", + "expect_type": "list" + } + ] + }, + { + "name": "test_data_model_rejects_excessive_list_index", + "description": "List indices exceeding the maximum supported bound (10000) are rejected with DataError.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/items/10000", + "value": "max_element" + }, + { + "op": "get", + "path": "/items/10000", + "expect": "max_element" + }, + { + "op": "set", + "path": "/items/10001", + "value": "x", + "expect_error": { + "category": "DataError" + } + }, + { + "op": "set", + "path": "/items/999999999", + "value": "x", + "expect_error": { + "category": "DataError" + } + }, + { + "op": "set", + "path": "/items/10001/name", + "value": "x", + "expect_error": { + "category": "DataError" + } + }, + { + "op": "set", + "path": "/newArray/10000000", + "value": "x", + "expect_error": { + "category": "DataError" + } + }, + { + "op": "set", + "path": "/newArray/10000000/field", + "value": "x", + "expect_error": { + "category": "DataError" + } + } + ] + }, + { + "name": "test_data_model_rejects_leading_zero_list_index", + "description": "Leading-zero list indices (RFC 6901 §4) resolve to absent on read and raise DataError on write.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "get", + "path": "/items/01", + "expect_absent": true + }, + { + "op": "set", + "path": "/items/01", + "value": "value", + "expect_error": { + "category": "DataError", + "message": "non-numeric segment" + } + }, + { + "op": "set", + "path": "/items/01/nested", + "value": "value", + "expect_error": { + "category": "DataError", + "message": "non-numeric segment" + } + } + ] + }, + { + "name": "test_data_model_rejects_write_through_primitive_root", + "description": "Writing a child path when the root itself holds a primitive (including falsy primitives) raises DataError and leaves the root intact.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": {}, + "steps": [ + { + "op": "set", + "path": "/", + "value": 1 + }, + { + "op": "set", + "path": "/a", + "value": 2, + "expect_error": { + "category": "DataError", + "message": "Cannot set path" + } + }, + { + "op": "get", + "path": "/", + "expect": 1 + }, + { + "op": "set", + "path": "/", + "value": false + }, + { + "op": "set", + "path": "/a", + "value": 2, + "expect_error": { + "category": "DataError", + "message": "Cannot set path" + } + }, + { + "op": "get", + "path": "/", + "expect": false + }, + { + "op": "set", + "path": "/", + "value": 0 + }, + { + "op": "set", + "path": "/a", + "value": 2, + "expect_error": { + "category": "DataError", + "message": "Cannot set path" + } + }, + { + "op": "get", + "path": "/", + "expect": 0 + }, + { + "op": "set", + "path": "/", + "value": "" + }, + { + "op": "set", + "path": "/a", + "value": 2, + "expect_error": { + "category": "DataError", + "message": "Cannot set path" + } + }, + { + "op": "get", + "path": "/", + "expect": "" + } + ] + }, + { + "name": "test_data_model_set_through_null_placeholder_vivifies", + "description": "Writing through an explicit null value in an object or list replaces the null slot with a container.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": null, + "counts": [ + null + ] + }, + "steps": [ + { + "op": "set", + "path": "/user/name", + "value": "Bob" + }, + { + "op": "set", + "path": "/counts/0/total", + "value": 1 + }, + { + "op": "get", + "path": "/user/name", + "expect": "Bob" + }, + { + "op": "get", + "path": "/counts/0/total", + "expect": 1 + } + ] + }, + { + "name": "test_data_model_normalizes_empty_path_segments", + "description": "Multiple or trailing slashes in a path are collapsed into canonical segments on read, write, and watch.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": {}, + "watch": [ + "/a/b" + ], + "steps": [ + { + "op": "set", + "path": "//a///b//", + "value": "val", + "expect_notified": [ + "/a/b" + ], + "expect_values": { + "/a/b": "val" + } + }, + { + "op": "get", + "path": "/a/b", + "expect": "val" + }, + { + "op": "get", + "path": "//a///b//", + "expect": "val" + }, + { + "op": "get", + "path": "///", + "expect": { + "a": { + "b": "val" + } + } + } + ] + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/data_model_pointers.json b/package/__fixtures__/a2ui-conformance/core/data_model_pointers.json new file mode 100644 index 0000000000..94df6744c4 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/data_model_pointers.json @@ -0,0 +1,358 @@ +[ + { + "name": "test_pointer_escape_characters", + "description": "Verifies RFC 6901 JSON Pointer escape sequence resolution (~0 for ~ and ~1 for /).", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "dataModel": { + "a/b": { + "c~d": "original_value" + }, + "slash/key": 100 + } + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/a~1b/c~0d", + "value": "escaped_value" + } + } + ] + } + ], + "expect": { + "surfaces": { + "s1": { + "dataModel": { + "a/b": { + "c~d": "escaped_value" + }, + "slash/key": 100 + } + } + } + } + }, + { + "name": "test_pointer_array_indexing", + "description": "Verifies numeric array indexing in JSON Pointers (/items/0, /items/1).", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "dataModel": { + "items": [ + "first", + "second", + "third" + ] + } + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/items/1", + "value": "updated_second" + } + } + ] + } + ], + "expect": { + "surfaces": { + "s1": { + "dataModel": { + "items": [ + "first", + "updated_second", + "third" + ] + } + } + } + } + }, + { + "name": "test_pointer_auto_vivification", + "description": "Verifies automatic intermediate object creation when setting deep uncreated JSON Pointer paths.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "dataModel": {} + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/deeply/nested/structure/key", + "value": "vivified_value" + } + } + ] + } + ], + "expect": { + "surfaces": { + "s1": { + "dataModel": { + "deeply": { + "nested": { + "structure": { + "key": "vivified_value" + } + } + } + } + } + } + } + }, + { + "name": "test_pointer_extended_escape_sequences", + "description": "Verifies advanced RFC 6901 escape sequence combinations (~10 for /0, ~00 for ~0, ~11 for /1, and ~01 for ~1).", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "dataModel": { + "m~0": "val0", + "m/1": "val1", + "m~1": "val2", + "m/0": "val3" + } + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/m~00", + "value": "updated0" + } + }, + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/m~11", + "value": "updated1" + } + }, + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/m~01", + "value": "updated2" + } + }, + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/m~10", + "value": "updated3" + } + } + ] + } + ], + "expect": { + "surfaces": { + "s1": { + "dataModel": { + "m~0": "updated0", + "m/1": "updated1", + "m~1": "updated2", + "m/0": "updated3" + } + } + } + } + }, + { + "name": "test_pointer_reject_leading_zero_array_index", + "description": "Verifies rejection of leading zero array indices (e.g. /items/01) per RFC 6901 §4.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "dataModel": { + "items": [ + "a", + "b", + "c" + ] + } + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/items/01", + "value": "invalid" + } + } + ], + "expectError": { + "category": "DataError", + "message": "non-numeric segment" + } + } + ] + }, + { + "name": "test_pointer_reject_write_through_primitive", + "description": "Verifies DataError when writing nested path through primitive intermediate node.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "dataModel": { + "user": "Alice" + } + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/user/name/first", + "value": "Alice" + } + } + ], + "expectError": { + "category": "DataError", + "message": "Cannot set path" + } + } + ] + }, + { + "name": "test_pointer_reject_non_numeric_array_segment", + "description": "Verifies DataError when setting non-numeric key on array target.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "dataModel": { + "items": [ + "a", + "b", + "c" + ] + } + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/items/invalidKey", + "value": "value" + } + } + ], + "expectError": { + "category": "DataError", + "message": "non-numeric segment" + } + } + ] + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/error_responses.json b/package/__fixtures__/a2ui-conformance/core/error_responses.json new file mode 100644 index 0000000000..d8a05e004c --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/error_responses.json @@ -0,0 +1,1093 @@ +[ + { + "name": "test_error_response_create_surface_invalid_theme_property", + "description": "Emits VALIDATION_FAILED with /theme/primaryColor when createSurface theme property has wrong type.", + "action": "validate", + "protocolVersion": "v0.9", + "catalogPaths": [ + "specification/v0_9/catalogs/basic/catalog.json" + ], + "payload": [ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json", + "theme": { + "primaryColor": 12345 + } + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "main", + "path": "/theme/primaryColor", + "rendererError": { + "version": "v0.9", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "main", + "path": "/theme/primaryColor", + "message": "primaryColor" + } + } + } + }, + { + "name": "test_error_response_create_surface_unknown_catalog_id", + "description": "Emits CATALOG_NOT_FOUND when createSurface references an unregistered catalogId.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://example.com/unknown_catalog.json" + } + } + ], + "expectError": { + "category": "CatalogError", + "surfaceId": "main", + "rendererError": { + "version": "1.0", + "error": { + "code": "CATALOG_NOT_FOUND", + "surfaceId": "main", + "message": "unknown_catalog" + } + } + } + }, + { + "name": "test_error_response_create_surface_incompatible_catalog_version", + "description": "Emits CATALOG_NOT_FOUND when createSurface binds a v0.9 catalog to a v1.0 surface.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "specification/v0_9/catalogs/basic/catalog.json" + ], + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" + } + } + ], + "expectError": { + "category": "CatalogError", + "surfaceId": "main", + "rendererError": { + "version": "1.0", + "error": { + "code": "CATALOG_NOT_FOUND", + "surfaceId": "main", + "message": "v0_9" + } + } + } + }, + { + "name": "test_error_response_create_surface_duplicate_surface_id", + "description": "Emits INTEGRITY_ERROR when createSurface is sent for an already existing surfaceId.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + } + ], + "expectError": { + "category": "IntegrityError", + "surfaceId": "main", + "rendererError": { + "version": "1.0", + "error": { + "code": "INTEGRITY_ERROR", + "surfaceId": "main", + "message": "already exists" + } + } + } + }, + { + "name": "test_error_response_update_components_unknown_surface", + "description": "Emits INTEGRITY_ERROR when updateComponents targets a non-existent surfaceId.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "payload": [ + { + "version": "1.0", + "updateComponents": { + "surfaceId": "missing_surface", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello" + } + ] + } + } + ], + "expectError": { + "category": "IntegrityError", + "surfaceId": "missing_surface", + "rendererError": { + "version": "1.0", + "error": { + "code": "INTEGRITY_ERROR", + "surfaceId": "missing_surface", + "message": "missing_surface" + } + } + } + }, + { + "name": "test_error_response_update_components_duplicate_id_in_batch", + "description": "Emits INTEGRITY_ERROR when updateComponents contains duplicate component IDs in the same batch.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "main", + "components": [ + { + "id": "root", + "component": "Text", + "text": "First" + }, + { + "id": "root", + "component": "Text", + "text": "Duplicate" + } + ] + } + } + ], + "expectError": { + "category": "IntegrityError", + "surfaceId": "main", + "rendererError": { + "version": "1.0", + "error": { + "code": "INTEGRITY_ERROR", + "surfaceId": "main", + "message": "root" + } + } + } + }, + { + "name": "test_error_response_update_components_unknown_type_strict", + "description": "Emits VALIDATION_FAILED with /components/0/component when component type is unknown in strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "main", + "components": [ + { + "id": "root", + "component": "NonExistentWidget" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "main", + "path": "/components/0/component", + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "main", + "path": "/components/0/component", + "message": "NonExistentWidget" + } + } + } + }, + { + "name": "test_error_response_update_components_invalid_property_type", + "description": "Emits VALIDATION_FAILED with /components/0/text when a component property has the wrong type.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "main", + "components": [ + { + "id": "root", + "component": "Text", + "text": 12345 + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "main", + "path": "/components/0/text", + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "main", + "path": "/components/0/text", + "message": "text" + } + } + } + }, + { + "name": "test_error_response_update_components_extra_forbidden_property", + "description": "Emits VALIDATION_FAILED when a component includes an undeclared extra property in strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "main", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello", + "unknownExtraProp": true + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "main", + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "main", + "path": "/components/0/unknownExtraProp", + "message": "unknownExtraProp" + } + } + } + }, + { + "name": "test_error_response_update_components_invalid_uax31_id", + "description": "Emits VALIDATION_FAILED with /components/0/id when a v1.0 component ID violates UAX #31.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "main", + "components": [ + { + "id": "123-invalid", + "component": "Text", + "text": "Hello" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "main", + "path": "/components/0/id", + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "main", + "path": "/components/0/id", + "message": "123-invalid" + } + } + } + }, + { + "name": "test_error_response_update_components_unallowed_parent", + "description": "Emits UNALLOWED_PARENT with /components/1 when a child component is placed inside a disallowed parent.", + "action": "validate", + "protocolVersion": "1.0", + "catalog": { + "catalogId": "test-comp-catalog", + "protocolVersion": "1.0", + "components": { + "Container": { + "type": "object", + "properties": { + "component": { + "const": "Container" + }, + "children": { + "$ref": "common_types.json#/$defs/ChildList" + } + }, + "required": [ + "component" + ] + }, + "RestrictedItem": { + "type": "object", + "allowedParents": [ + "SpecialList" + ], + "properties": { + "component": { + "const": "RestrictedItem" + } + }, + "required": [ + "component" + ] + } + } + }, + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "test-comp-catalog" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "main", + "components": [ + { + "id": "root", + "component": "Container", + "children": [ + "item1" + ] + }, + { + "id": "item1", + "component": "RestrictedItem" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "code": "UNALLOWED_PARENT", + "surfaceId": "main", + "path": "/components/1", + "details": [ + { + "path": "/components/1", + "code": "UNALLOWED_PARENT", + "surfaceId": "main" + } + ], + "rendererError": { + "version": "1.0", + "error": { + "code": "UNALLOWED_PARENT", + "surfaceId": "main", + "path": "/components/1", + "message": "RestrictedItem" + } + } + } + }, + { + "name": "test_error_response_update_components_unallowed_parent_root", + "description": "Emits UNALLOWED_PARENT with /components/0 when the root component does not allow Surface as parent.", + "action": "validate", + "protocolVersion": "1.0", + "catalog": { + "catalogId": "test-comp-catalog", + "protocolVersion": "1.0", + "components": { + "RestrictedItem": { + "type": "object", + "allowedParents": [ + "SpecialList" + ], + "properties": { + "component": { + "const": "RestrictedItem" + } + }, + "required": [ + "component" + ] + } + } + }, + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "test-comp-catalog" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "main", + "components": [ + { + "id": "root", + "component": "RestrictedItem" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "code": "UNALLOWED_PARENT", + "surfaceId": "main", + "path": "/components/0", + "details": [ + { + "path": "/components/0", + "code": "UNALLOWED_PARENT", + "surfaceId": "main" + } + ], + "rendererError": { + "version": "1.0", + "error": { + "code": "UNALLOWED_PARENT", + "surfaceId": "main", + "path": "/components/0", + "message": "RestrictedItem" + } + } + } + }, + { + "name": "test_error_response_update_components_unallowed_child", + "description": "Emits UNALLOWED_CHILD with /components/0/children/0 when a parent references a disallowed child type.", + "action": "validate", + "protocolVersion": "1.0", + "catalog": { + "catalogId": "test-comp-catalog", + "protocolVersion": "1.0", + "components": { + "StrictList": { + "type": "object", + "allowedChildren": [ + "ListItem" + ], + "properties": { + "component": { + "const": "StrictList" + }, + "children": { + "$ref": "common_types.json#/$defs/ChildList" + } + }, + "required": [ + "component" + ] + }, + "OtherItem": { + "type": "object", + "properties": { + "component": { + "const": "OtherItem" + } + }, + "required": [ + "component" + ] + } + } + }, + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "test-comp-catalog" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "main", + "components": [ + { + "id": "root", + "component": "StrictList", + "children": [ + "item1" + ] + }, + { + "id": "item1", + "component": "OtherItem" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "code": "UNALLOWED_CHILD", + "surfaceId": "main", + "path": "/components/0/children/0", + "details": [ + { + "path": "/components/0/children/0", + "code": "UNALLOWED_CHILD", + "surfaceId": "main" + } + ], + "rendererError": { + "version": "1.0", + "error": { + "code": "UNALLOWED_CHILD", + "surfaceId": "main", + "path": "/components/0/children/0", + "message": "OtherItem" + } + } + } + }, + { + "name": "test_error_response_update_components_circular_reference", + "description": "Emits RECURSION_LIMIT_EXCEEDED when components form a cycle in strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "main", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "child1" + ] + }, + { + "id": "child1", + "component": "Column", + "children": [ + "root" + ] + } + ] + } + } + ], + "expectError": { + "category": "RecursionError", + "surfaceId": "main", + "rendererError": { + "version": "1.0", + "error": { + "code": "RECURSION_LIMIT_EXCEEDED", + "surfaceId": "main", + "message": "root" + } + } + } + }, + { + "name": "test_error_response_update_components_dangling_child_strict", + "description": "Emits INTEGRITY_ERROR when a component references a non-existent child ID in strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "main", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "missing_child" + ] + } + ] + } + } + ], + "expectError": { + "category": "IntegrityError", + "surfaceId": "main", + "rendererError": { + "version": "1.0", + "error": { + "code": "INTEGRITY_ERROR", + "surfaceId": "main", + "message": "missing_child" + } + } + } + }, + { + "name": "test_error_response_update_components_orphaned_component_strict", + "description": "Emits INTEGRITY_ERROR when a component is unreachable from root in strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "main", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Root" + }, + { + "id": "orphan1", + "component": "Text", + "text": "Orphan" + } + ] + } + } + ], + "expectError": { + "category": "IntegrityError", + "surfaceId": "main", + "rendererError": { + "version": "1.0", + "error": { + "code": "INTEGRITY_ERROR", + "surfaceId": "main", + "message": "orphan1" + } + } + } + }, + { + "name": "test_error_response_update_components_buffered_streaming_non_strict", + "description": "Accepts dangling child references and orphaned components without error when strictMode is false.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": false, + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "main", + "components": [ + { + "id": "orphan1", + "component": "Text", + "text": "Buffered child" + }, + { + "id": "root", + "component": "Column", + "children": [ + "orphan1", + "dangling_child" + ] + } + ] + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateComponents": { + "surfaceId": "main", + "components": [ + { + "id": "dangling_child", + "component": "Text", + "text": "Arrived later" + } + ] + } + } + ] + } + ] + }, + { + "name": "test_error_response_update_data_model_unknown_surface", + "description": "Emits INTEGRITY_ERROR when updateDataModel targets a non-existent surfaceId.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "missing_surface", + "path": "/user/name", + "value": "Alice" + } + } + ], + "expectError": { + "category": "IntegrityError", + "surfaceId": "missing_surface", + "rendererError": { + "version": "1.0", + "error": { + "code": "INTEGRITY_ERROR", + "surfaceId": "missing_surface", + "message": "missing_surface" + } + } + } + }, + { + "name": "test_error_response_update_data_model_prototype_pollution", + "description": "Emits DATA_ERROR when updateDataModel targets a forbidden prototype-pollution path segment.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "main", + "path": "/__proto__/polluted", + "value": true + } + } + ], + "expectError": { + "category": "DataError", + "surfaceId": "main", + "rendererError": { + "version": "1.0", + "error": { + "code": "DATA_ERROR", + "surfaceId": "main", + "message": "__proto__" + } + } + } + }, + { + "name": "test_error_response_update_data_model_invalid_json_pointer", + "description": "Emits VALIDATION_FAILED with /path when updateDataModel path is not a valid JSON Pointer.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "main", + "path": "not_a_pointer", + "value": "Alice" + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "main", + "path": "/path", + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "main", + "path": "/path", + "message": "path" + } + } + } + }, + { + "name": "test_error_response_delete_surface_missing_surface_id", + "description": "Emits VALIDATION_FAILED with surfaceId '' and /surfaceId when deleteSurface omits surfaceId.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "payload": [ + { + "version": "1.0", + "deleteSurface": {} + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "", + "path": "/surfaceId", + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "", + "path": "/surfaceId", + "message": "surfaceId" + } + } + } + }, + { + "name": "test_error_response_call_renderer_function_missing_call_function", + "description": "Emits INVALID_FUNCTION_CALL correlated by functionCallId without surfaceId when callFunction is missing.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "payload": [ + { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-err-1" + } + } + ], + "expectError": { + "category": "ValidationError", + "functionCallId": "call-err-1", + "rendererError": { + "version": "1.0", + "error": { + "code": "INVALID_FUNCTION_CALL", + "functionCallId": "call-err-1", + "message": "callFunction" + } + } + } + }, + { + "name": "test_error_response_call_renderer_function_non_object_args", + "description": "Emits INVALID_FUNCTION_CALL correlated by functionCallId when callFunction.args is not an object.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "payload": [ + { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-err-2", + "callFunction": { + "@call": "formatString", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "args": "not_an_object" + } + } + } + ], + "expectError": { + "category": "ValidationError", + "functionCallId": "call-err-2", + "rendererError": { + "version": "1.0", + "error": { + "code": "INVALID_FUNCTION_CALL", + "functionCallId": "call-err-2", + "message": "args" + } + } + } + }, + { + "name": "test_error_response_call_renderer_function_missing_catalog_id", + "description": "Emits INVALID_FUNCTION_CALL correlated by functionCallId when callFunction omits required catalogId.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "payload": [ + { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-err-3", + "callFunction": { + "@call": "formatString", + "args": { + "value": "hello" + } + } + } + } + ], + "expectError": { + "category": "ValidationError", + "functionCallId": "call-err-3", + "rendererError": { + "version": "1.0", + "error": { + "code": "INVALID_FUNCTION_CALL", + "functionCallId": "call-err-3", + "message": "catalogId" + } + } + } + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/expressions.json b/package/__fixtures__/a2ui-conformance/core/expressions.json new file mode 100644 index 0000000000..30733740bb --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/expressions.json @@ -0,0 +1,1270 @@ +[ + { + "name": "test_expr_plain_text_is_one_literal", + "description": "A template with no interpolation yields the input unchanged.", + "action": "parse_expression_template", + "input": "hello world", + "expect": [ + "hello world" + ] + }, + { + "name": "test_expr_empty_template", + "description": "An empty template yields no content.", + "action": "parse_expression_template", + "input": "", + "expect": [] + }, + { + "name": "test_expr_relative_path", + "description": "A bare identifier is a relative data binding.", + "action": "parse_expression_template", + "input": "${user}", + "expect": [ + { + "path": "user" + } + ] + }, + { + "name": "test_expr_absolute_path", + "description": "An identifier starting with a slash is an absolute data binding.", + "action": "parse_expression_template", + "input": "value is ${/user/name}", + "expect": [ + "value is ", + { + "path": "/user/name" + } + ] + }, + { + "name": "test_expr_path_with_punctuation", + "description": "Dots, dashes and underscores are part of a path.", + "action": "parse_expression_template", + "input": "${my-path.with_underscores}", + "expect": [ + { + "path": "my-path.with_underscores" + } + ] + }, + { + "name": "test_expr_non_ascii_path", + "description": "Path segments may use any UAX 31 XID_Continue character.", + "action": "parse_expression_template", + "input": "${/użytkownik/größe}", + "expect": [ + { + "path": "/użytkownik/größe" + } + ] + }, + { + "name": "test_expr_error_non_identifier_character_in_path", + "description": "A character outside XID_Continue, such as ½, ends the path.", + "action": "parse_expression_template", + "input": "${a½}", + "expect_error": { + "category": "ParseError", + "message": "Unexpected characters" + } + }, + { + "name": "test_expr_combining_marks_and_supplementary_plane_path", + "description": "Path segments accept combining marks in XID_Continue, including right after a slash or a dot, and supplementary-plane XID_Continue code points such as 𠮷.", + "action": "parse_expression_template", + "input": "${/a/́b/grüße/𠮷.́c}", + "expect": [ + { + "path": "/a/́b/grüße/𠮷.́c" + } + ] + }, + { + "name": "test_expr_error_supplementary_plane_non_identifier_in_path", + "description": "A supplementary-plane character outside XID_Continue, such as 🚀, ends the path.", + "action": "parse_expression_template", + "input": "${a🚀}", + "expect_error": { + "category": "ParseError", + "message": "Unexpected characters" + } + }, + { + "name": "test_expr_literal_between_bindings", + "description": "Literal text surrounding interpolations is preserved.", + "action": "parse_expression_template", + "input": "a ${x} b ${y} c", + "expect": [ + "a ", + { + "path": "x" + }, + " b ", + { + "path": "y" + }, + " c" + ] + }, + { + "name": "test_expr_adjacent_bindings", + "description": "Interpolations with nothing between them stay separate parts.", + "action": "parse_expression_template", + "input": "${x}${y}", + "expect": [ + { + "path": "x" + }, + { + "path": "y" + } + ] + }, + { + "name": "test_expr_single_quoted_string", + "description": "Single-quoted literals parse to their contents.", + "action": "parse_expression_template", + "input": "${'hello world'}", + "expect": [ + "hello world" + ] + }, + { + "name": "test_expr_double_quoted_string", + "description": "Double-quoted literals parse to their contents.", + "action": "parse_expression_template", + "input": "${\"hello world\"}", + "expect": [ + "hello world" + ] + }, + { + "name": "test_expr_string_escapes", + "description": "Backslash escapes inside string literals are unescaped.", + "action": "parse_expression_template", + "input": "${'line\\nbreak'}", + "expect": [ + "line\nbreak" + ] + }, + { + "name": "test_expr_string_escaped_quote", + "description": "An escaped quote does not terminate the literal.", + "action": "parse_expression_template", + "input": "${'it\\'s'}", + "expect": [ + "it's" + ] + }, + { + "name": "test_expr_integer_literal", + "description": "Integer literals parse to numbers.", + "action": "parse_expression_template", + "input": "${42}", + "expect": [ + 42 + ] + }, + { + "name": "test_expr_decimal_literal", + "description": "Decimal literals parse to numbers.", + "action": "parse_expression_template", + "input": "${1.5}", + "expect": [ + 1.5 + ] + }, + { + "name": "test_expr_negative_integer_literal", + "description": "Negative integer literals parse to negative numbers.", + "action": "parse_expression_template", + "input": "${-42}", + "expect": [ + -42 + ] + }, + { + "name": "test_expr_negative_decimal_literal", + "description": "Negative decimal literals parse to negative numbers.", + "action": "parse_expression_template", + "input": "${-3.14}", + "expect": [ + -3.14 + ] + }, + { + "name": "test_expr_exponential_notation_positive", + "description": "Scientific/exponential notation parses to numbers.", + "action": "parse_expression_template", + "input": "${1e5}", + "expect": [ + 100000 + ] + }, + { + "name": "test_expr_exponential_notation_negative_exponent", + "description": "Scientific notation with negative exponent parses to floating point numbers.", + "action": "parse_expression_template", + "input": "${1.5e-3}", + "expect": [ + 0.0015 + ] + }, + { + "name": "test_expr_exponential_notation_capital_e", + "description": "Scientific notation with capital E and plus sign parses correctly.", + "action": "parse_expression_template", + "input": "${2.5E+4}", + "expect": [ + 25000 + ] + }, + { + "name": "test_expr_leading_zeros", + "description": "Leading zeros do not change the value.", + "action": "parse_expression_template", + "input": "${007}", + "expect": [ + 7 + ] + }, + { + "name": "test_expr_boolean_literals", + "description": "The true and false keywords parse to booleans.", + "action": "parse_expression_template", + "input": "${true} ${false}", + "expect": [ + true, + " ", + false + ] + }, + { + "name": "test_expr_keyword_prefix_is_a_path", + "description": "An identifier that merely starts with a keyword is a path.", + "action": "parse_expression_template", + "input": "${trueish}", + "expect": [ + { + "path": "trueish" + } + ] + }, + { + "name": "test_expr_keyword_followed_by_non_ascii_letter_is_a_path", + "description": "A non-ASCII XID_Continue character after a keyword makes it part of a path.", + "action": "parse_expression_template", + "input": "${nullß}", + "expect": [ + { + "path": "nullß" + } + ] + }, + { + "name": "test_expr_error_keyword_followed_by_non_identifier_character", + "description": "A character outside XID_Continue, such as ½, does not extend a keyword.", + "action": "parse_expression_template", + "input": "${true½}", + "expect_error": { + "category": "ParseError", + "message": "Unexpected characters" + } + }, + { + "name": "test_expr_function_call_no_args", + "description": "A function call with no arguments parses to an empty args map.", + "action": "parse_expression_template", + "input": "${now()}", + "expect": [ + { + "call": "now", + "args": {}, + "returnType": "any" + } + ] + }, + { + "name": "test_expr_function_call_named_args", + "description": "Function arguments are named and comma separated.", + "action": "parse_expression_template", + "input": "sum is ${add(a: 10, b: 20)}", + "expect": [ + "sum is ", + { + "call": "add", + "args": { + "a": 10, + "b": 20 + }, + "returnType": "any" + } + ] + }, + { + "name": "test_expr_non_ascii_function_and_argument_names", + "description": "Function and argument names may use any UAX 31 XID_Continue character.", + "action": "parse_expression_template", + "input": "${größe(wartość: 1, 値: 2)}", + "expect": [ + { + "call": "größe", + "args": { + "wartość": 1, + "値": 2 + }, + "returnType": "any" + } + ] + }, + { + "name": "test_expr_error_non_identifier_character_in_argument_name", + "description": "A character outside XID_Continue, such as ½, is not part of an argument name.", + "action": "parse_expression_template", + "input": "${add(½: 1)}", + "expect_error": { + "category": "ParseError", + "message": "after argument name" + } + }, + { + "name": "test_expr_function_call_negative_args", + "description": "Function arguments may be negative numeric literals.", + "action": "parse_expression_template", + "input": "${add(a: -5, b: 10)}", + "expect": [ + { + "call": "add", + "args": { + "a": -5, + "b": 10 + }, + "returnType": "any" + } + ] + }, + { + "name": "test_expr_function_call_mixed_arg_types", + "description": "Arguments may be literals, paths or nested calls.", + "action": "parse_expression_template", + "input": "${format(value: /amount, suffix: ' USD', fallback: zero())}", + "expect": [ + { + "call": "format", + "args": { + "value": { + "path": "/amount" + }, + "suffix": " USD", + "fallback": { + "call": "zero", + "args": {}, + "returnType": "any" + } + }, + "returnType": "any" + } + ] + }, + { + "name": "test_expr_nested_function_calls", + "description": "Function arguments may themselves be function calls.", + "action": "parse_expression_template", + "input": "${outer(a: inner(b: 1))}", + "expect": [ + { + "call": "outer", + "args": { + "a": { + "call": "inner", + "args": { + "b": 1 + }, + "returnType": "any" + } + }, + "returnType": "any" + } + ] + }, + { + "name": "test_expr_nested_interpolation", + "description": "An interpolation inside an interpolation resolves to the inner expression.", + "action": "parse_expression_template", + "input": "${${'hello'}}", + "expect": [ + "hello" + ] + }, + { + "name": "test_expr_whitespace_around_expression", + "description": "Whitespace inside the interpolation braces is ignored.", + "action": "parse_expression_template", + "input": "${ /user/name }", + "expect": [ + { + "path": "/user/name" + } + ] + }, + { + "name": "test_expr_escaped_marker_is_literal", + "description": "An escaped marker resolves to a literal \"${\" and is not interpolated.", + "action": "parse_expression_template", + "input": "\\${not_interpolated}", + "expect": [ + "${not_interpolated}" + ] + }, + { + "name": "test_expr_escaped_marker_between_text", + "description": "An escaped marker inside surrounding text stays literal.", + "action": "parse_expression_template", + "input": "before \\${x} after", + "expect": [ + "before ${x} after" + ] + }, + { + "name": "test_expr_escaped_and_real_marker", + "description": "An escaped marker and a real interpolation can appear in one template.", + "action": "parse_expression_template", + "input": "\\${literal} ${real}", + "expect": [ + "${literal} ", + { + "path": "real" + } + ] + }, + { + "name": "test_expr_error_unclosed_interpolation", + "description": "A template whose interpolation is never closed is rejected.", + "action": "parse_expression_template", + "input": "hello ${world", + "expect_error": { + "category": "ParseError", + "message": "Unclosed interpolation" + } + }, + { + "name": "test_expr_error_missing_closing_paren", + "description": "A function call whose parentheses are unbalanced is rejected.", + "action": "parse_expression_template", + "input": "${add(a: 1, b: 2}", + "expect_error": { + "category": "ParseError", + "message": "after function arguments" + } + }, + { + "name": "test_expr_error_missing_arg_colon", + "description": "A function argument without a colon is rejected.", + "action": "parse_expression_template", + "input": "${add(a 1)}", + "expect_error": { + "category": "ParseError", + "message": "after argument name" + } + }, + { + "name": "test_expr_error_trailing_characters", + "description": "Characters after a complete expression are rejected.", + "action": "parse_expression_template", + "input": "${true false}", + "expect_error": { + "category": "ParseError", + "message": "Unexpected characters" + } + }, + { + "name": "test_expr_number_with_trailing_point", + "description": "A trailing decimal point is accepted, as every client implementation does today. The grammar is stated in each parser rather than delegated to the platform's number parser, so this stays true.\n", + "action": "parse_expression_template", + "input": "${1.}", + "expect": [ + 1 + ] + }, + { + "name": "test_expr_error_invalid_number_two_points", + "description": "A number literal with two decimal points is rejected as a protocol error.", + "action": "parse_expression_template", + "input": "${1.2.3}", + "expect_error": { + "category": "ParseError", + "message": "Invalid number literal" + } + }, + { + "name": "test_expr_leading_dot_decimal_literal", + "description": "A decimal literal may omit the digits before the decimal point.", + "action": "parse_expression_template", + "input": "${.5}", + "expect": [ + 0.5 + ] + }, + { + "name": "test_expr_negative_leading_dot_decimal_literal", + "description": "A leading-dot decimal literal may carry a minus sign.", + "action": "parse_expression_template", + "input": "${-.5}", + "expect": [ + -0.5 + ] + }, + { + "name": "test_expr_positive_leading_dot_decimal_literal", + "description": "A leading-dot decimal literal may carry a plus sign.", + "action": "parse_expression_template", + "input": "${+.5}", + "expect": [ + 0.5 + ] + }, + { + "name": "test_expr_leading_dot_exponential_notation", + "description": "A leading-dot decimal literal may carry an exponent.", + "action": "parse_expression_template", + "input": "${.5e2}", + "expect": [ + 50 + ] + }, + { + "name": "test_expr_function_call_leading_dot_args", + "description": "Function arguments may be signed leading-dot decimal literals.", + "action": "parse_expression_template", + "input": "${scale(by: -.5, offset: .25)}", + "expect": [ + { + "call": "scale", + "args": { + "by": -0.5, + "offset": 0.25 + }, + "returnType": "any" + } + ] + }, + { + "name": "test_expr_leading_dot_path_is_not_a_number", + "description": "A leading dot starts a number only when a digit follows it. Otherwise the token stays a path, as does a sign followed by a dot and no digit.\n", + "action": "parse_expression_template", + "input": "${.foo} ${-.}", + "expect": [ + { + "path": ".foo" + }, + " ", + { + "path": "-." + } + ] + }, + { + "name": "test_expr_error_leading_dot_number_two_points", + "description": "A leading-dot number literal with a second decimal point is rejected.", + "action": "parse_expression_template", + "input": "${.5.5}", + "expect_error": { + "category": "ParseError", + "message": "Invalid number literal" + } + }, + { + "name": "test_expr_error_leading_dot_number_empty_exponent", + "description": "A signed leading-dot number literal with an empty exponent is rejected.", + "action": "parse_expression_template", + "input": "${-.5e}", + "expect_error": { + "category": "ParseError", + "message": "Invalid number literal" + } + }, + { + "name": "test_expr_positive_integer_literal", + "description": "An integer literal with a leading plus sign is parsed as a number.", + "action": "parse_expression_template", + "input": "${+1}", + "expect": [ + 1 + ] + }, + { + "name": "test_expr_hyphen_inside_path", + "description": "A hyphen after an identifier character stays part of the path token.", + "action": "parse_expression_template", + "input": "${a-1}", + "expect": [ + { + "path": "a-1" + } + ] + }, + { + "name": "test_expr_hyphen_before_non_digit_is_a_path", + "description": "A leading hyphen not followed by a digit or a dot-and-digit stays a path.", + "action": "parse_expression_template", + "input": "${-a}", + "expect": [ + { + "path": "-a" + } + ] + }, + { + "name": "test_expr_error_exponent_without_digits", + "description": "A number literal whose exponent has no digits is rejected.", + "action": "parse_expression_template", + "input": "${1e}", + "expect_error": { + "category": "ParseError", + "message": "Invalid number literal" + } + }, + { + "name": "test_expr_max_safe_integer_literals", + "description": "Integer literals at ±(2^53 - 1) are returned with exact value in every engine.", + "action": "parse_expression_template", + "input": "${9007199254740991} ${-9007199254740991}", + "expect": [ + 9007199254740991, + " ", + -9007199254740991 + ] + }, + { + "name": "test_expr_largest_exponent_literal", + "description": "A number literal near the top of the double range is accepted.", + "action": "parse_expression_template", + "input": "${1e308}", + "expect": [ + 1e+308 + ] + }, + { + "name": "test_expr_exponent_underflow_is_zero", + "description": "A number literal that underflows the double range evaluates to zero.", + "action": "parse_expression_template", + "input": "${1e-999}", + "expect": [ + 0 + ] + }, + { + "name": "test_expr_error_exponent_out_of_range", + "description": "A positive exponent literal that overflows the double range is rejected.", + "action": "parse_expression_template", + "input": "${1e999}", + "expect_error": { + "category": "ParseError", + "message": "out of range" + } + }, + { + "name": "test_expr_error_negative_exponent_out_of_range", + "description": "A negative number literal that overflows the double range is rejected.", + "action": "parse_expression_template", + "input": "${-1e999}", + "expect_error": { + "category": "ParseError", + "message": "out of range" + } + }, + { + "name": "test_expr_error_integer_out_of_range", + "description": "An integer literal of 10^309 is outside the double range and is rejected.", + "action": "parse_expression_template", + "input": "${1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000}", + "expect_error": { + "category": "ParseError", + "message": "out of range" + } + }, + { + "name": "test_expression_parser_string_interpolation_multiple_bindings", + "description": "Verifies ${/path} interpolation across multiple variables and static text.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "dataModel": { + "user": { + "name": "Alice", + "unreadCount": 5 + } + }, + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatString", + "args": { + "value": "Hello, ${/user/name}! You have ${/user/unreadCount} unread messages." + } + } + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "main": { + "components": { + "root": { + "component": "Text", + "text": "Hello, Alice! You have 5 unread messages." + } + } + } + } + } + }, + { + "name": "test_expression_parser_escaped_interpolation_sequence", + "description": "Verifies \\${...} renders literal ${...} without evaluation.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "dataModel": { + "amount": 100 + }, + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatString", + "args": { + "value": "Price is \\${/amount} USD" + } + } + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "main": { + "components": { + "root": { + "component": "Text", + "text": "Price is ${/amount} USD" + } + } + } + } + } + }, + { + "name": "test_expression_parser_nested_function_call", + "description": "Verifies evaluating nested function calls with named arguments inside ${...}.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "dataModel": { + "price": 49.99 + }, + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatString", + "args": { + "value": "Total: ${formatCurrency(value: /price, currency: 'USD')}" + } + } + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "main": { + "components": { + "root": { + "component": "Text", + "text": "Total: $49.99" + } + } + } + } + } + }, + { + "name": "test_expression_parser_data_type_coercion_matrix", + "description": "Verifies expression string coercion for booleans, numbers, and missing values.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "dataModel": { + "isActive": true, + "score": 98.5 + }, + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatString", + "args": { + "value": "Active: ${/isActive}, Score: ${/score}, Missing: ${/nonexistent}" + } + } + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "main": { + "components": { + "root": { + "component": "Text", + "text": "Active: true, Score: 98.5, Missing: " + } + } + } + } + } + }, + { + "name": "test_expression_parser_syntax_error_unclosed_brace", + "description": "Verifies error when expression contains unclosed interpolation brace.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatString", + "args": { + "value": "Hello ${/unclosed" + } + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Unclosed interpolation" + } + } + ] + }, + { + "name": "test_expression_parser_max_depth_exceeded_error", + "description": "Verifies error when expression nesting exceeds the maximum recursion depth. The value nests 101 interpolations, one past the limit of 100 that every engine applies.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatString", + "args": { + "value": "${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${1}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}" + } + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Max recursion depth" + } + } + ] + }, + { + "name": "test_expr_error_max_depth_nested_interpolations", + "description": "An interpolation nested past the recursion limit of 100 is rejected. This template nests 101 deep.", + "action": "parse_expression_template", + "input": "${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${1}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}", + "expect_error": { + "category": "ParseError", + "message": "Max recursion depth" + } + }, + { + "name": "test_expr_error_max_depth_function_arguments", + "description": "A function-call argument counts toward the recursion limit, not just a nested interpolation. This template nests 101 calls, one past the limit.", + "action": "parse_expression_template", + "input": "${f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: 1)))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))}", + "expect_error": { + "category": "ParseError", + "message": "Max recursion depth" + } + }, + { + "name": "test_expr_null_keyword_produces_no_part", + "description": "The null keyword inside an interpolation resolves to empty content.", + "action": "parse_expression_template", + "input": "a${null}b", + "expect": [ + "ab" + ] + }, + { + "name": "test_expr_null_keyword_argument_is_empty_string", + "description": "The null keyword passed as a function argument is an empty string.", + "action": "parse_expression_template", + "input": "${f(x: null)}", + "expect": [ + { + "call": "f", + "args": { + "x": "" + }, + "returnType": "any" + } + ] + }, + { + "name": "test_expr_nested_path_interpolation", + "description": "An interpolation nested inside another interpolation resolves to the inner path binding.", + "action": "parse_expression_template", + "input": "${${nested}}", + "expect": [ + { + "path": "nested" + } + ] + }, + { + "name": "test_expr_function_call_double_quoted_arg", + "description": "Function arguments may be double-quoted string literals.", + "action": "parse_expression_template", + "input": "${upper(text: \"hello\")}", + "expect": [ + { + "call": "upper", + "args": { + "text": "hello" + }, + "returnType": "any" + } + ] + }, + { + "name": "test_expr_string_escapes_tab_cr_backslash", + "description": "Tab, carriage-return, and backslash escapes inside string literals are unescaped.", + "action": "parse_expression_template", + "input": "${'a\\tb\\rc\\\\d'}", + "expect": [ + "a\tb\rc\\d" + ] + }, + { + "name": "test_expr_string_unknown_escape_keeps_character", + "description": "An unrecognized backslash escape inside a string literal yields the escaped character.", + "action": "parse_expression_template", + "input": "${'a\\qb'}", + "expect": [ + "aqb" + ] + }, + { + "name": "test_expr_negative_number_with_trailing_point", + "description": "A negative number literal may end with a trailing decimal point.", + "action": "parse_expression_template", + "input": "${-42.}", + "expect": [ + -42 + ] + }, + { + "name": "test_expr_positive_zero_with_trailing_point", + "description": "A positive zero literal with a trailing decimal point evaluates to zero.", + "action": "parse_expression_template", + "input": "${+0.}", + "expect": [ + 0 + ] + }, + { + "name": "test_expr_negative_zero_literal", + "description": "A negative zero integer literal evaluates to zero.", + "action": "parse_expression_template", + "input": "${-0}", + "expect": [ + 0 + ] + }, + { + "name": "test_expr_negative_exponential_notation", + "description": "A negative number literal in exponential notation parses to a negative number.", + "action": "parse_expression_template", + "input": "${-2e3}", + "expect": [ + -2000 + ] + }, + { + "name": "test_expr_negative_leading_dot_exponential_notation", + "description": "A negative leading-dot decimal literal with a negative exponent parses to a number.", + "action": "parse_expression_template", + "input": "${-.5E-1}", + "expect": [ + -0.05 + ] + }, + { + "name": "test_expr_function_call_signed_and_exponential_args", + "description": "Function arguments may be negative exponential or positive signed number literals.", + "action": "parse_expression_template", + "input": "${clamp(value: -1.5e2, max: +10)}", + "expect": [ + { + "call": "clamp", + "args": { + "value": -150, + "max": 10 + }, + "returnType": "any" + } + ] + }, + { + "name": "test_expr_dot_slash_relative_path", + "description": "A token starting with './' is parsed as a path.", + "action": "parse_expression_template", + "input": "${./x}", + "expect": [ + { + "path": "./x" + } + ] + }, + { + "name": "test_expr_leading_dot_before_exponent_letter_is_a_path", + "description": "A leading dot followed by 'e' and digits is a path, not a number literal.", + "action": "parse_expression_template", + "input": "${.e5}", + "expect": [ + { + "path": ".e5" + } + ] + }, + { + "name": "test_expr_dot_digit_inside_path", + "description": "A dot followed by a digit after an identifier character stays part of the path.", + "action": "parse_expression_template", + "input": "${a.5}", + "expect": [ + { + "path": "a.5" + } + ] + }, + { + "name": "test_expr_slash_dot_digit_inside_path", + "description": "A segment starting with a dot and digit inside a slash-delimited path stays a path.", + "action": "parse_expression_template", + "input": "${/items/.5}", + "expect": [ + { + "path": "/items/.5" + } + ] + }, + { + "name": "test_expr_slash_hyphen_digit_inside_path", + "description": "A segment starting with a hyphen and digit inside a slash-delimited path stays a path.", + "action": "parse_expression_template", + "input": "${/items/-1}", + "expect": [ + { + "path": "/items/-1" + } + ] + }, + { + "name": "test_expr_parent_directory_relative_path", + "description": "A token starting with '../' is parsed as a path.", + "action": "parse_expression_template", + "input": "${../x}", + "expect": [ + { + "path": "../x" + } + ] + }, + { + "name": "test_expr_function_call_trailing_comma", + "description": "A trailing comma after the last function argument is accepted.", + "action": "parse_expression_template", + "input": "${add(a: 1, b: 2,)}", + "expect": [ + { + "call": "add", + "args": { + "a": 1, + "b": 2 + }, + "returnType": "any" + } + ] + }, + { + "name": "test_expr_error_leading_dot_exponent_sign_without_digits", + "description": "A leading-dot number literal whose exponent has a sign but no digits is rejected.", + "action": "parse_expression_template", + "input": "${.5e+}", + "expect_error": { + "category": "ParseError", + "message": "Invalid number literal" + } + }, + { + "name": "test_expr_error_exponent_plus_without_digits", + "description": "A number literal whose exponent has a plus sign but no digits is rejected.", + "action": "parse_expression_template", + "input": "${1e+}", + "expect_error": { + "category": "ParseError", + "message": "Invalid number literal" + } + }, + { + "name": "test_expr_error_exponent_minus_without_digits", + "description": "A number literal whose exponent has a minus sign but no digits is rejected.", + "action": "parse_expression_template", + "input": "${1E-}", + "expect_error": { + "category": "ParseError", + "message": "Invalid number literal" + } + }, + { + "name": "test_expr_error_trailing_characters_after_number", + "description": "Identifier characters immediately following a number literal are rejected.", + "action": "parse_expression_template", + "input": "${-1x}", + "expect_error": { + "category": "ParseError", + "message": "Unexpected characters" + } + }, + { + "name": "test_expr_error_mantissa_overflow_out_of_range", + "description": "A number literal whose mantissa overflows the double range at 10^308 is rejected.", + "action": "parse_expression_template", + "input": "${2e308}", + "expect_error": { + "category": "ParseError", + "message": "out of range" + } + }, + { + "name": "test_expr_error_space_separated_identifiers", + "description": "Space-separated identifiers inside an interpolation are rejected.", + "action": "parse_expression_template", + "input": "${a b}", + "expect_error": { + "category": "ParseError", + "message": "Unexpected characters" + } + }, + { + "name": "test_expr_error_positional_function_arg", + "description": "A positional function argument without a name and colon is rejected.", + "action": "parse_expression_template", + "input": "${f(1)}", + "expect_error": { + "category": "ParseError", + "message": "after argument name" + } + }, + { + "name": "test_expr_max_depth_nested_interpolations", + "description": "An interpolation nested right at the recursion limit of 100 is accepted.", + "action": "parse_expression_template", + "input": "${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${x}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}", + "expect": [ + { + "path": "x" + } + ] + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/functions.json b/package/__fixtures__/a2ui-conformance/core/functions.json new file mode 100644 index 0000000000..9a6d3a17bb --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/functions.json @@ -0,0 +1,1286 @@ +[ + { + "name": "test_function_format_date_tr35_tokens", + "description": "Verifies formatDate TR35 token expansion (yyyy-MM-dd).", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatDate", + "args": { + "value": "2026-09-04T12:00:00Z", + "format": "yyyy-MM-dd" + } + } + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "main": { + "components": { + "root": { + "component": "Text", + "text": "2026-09-04" + } + } + } + } + } + }, + { + "name": "test_function_format_currency_locale_and_symbol", + "description": "Verifies formatCurrency with currency symbol, decimals, and grouping separators.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatCurrency", + "args": { + "value": 1234.5, + "currency": "USD", + "decimals": 2 + } + } + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "main": { + "components": { + "root": { + "component": "Text", + "text": "$1,234.50" + } + } + } + } + } + }, + { + "name": "test_function_pluralize_categories", + "description": "Verifies pluralize resolving CLDR categories (one, other).", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "t1", + "t2" + ] + }, + { + "id": "t1", + "component": "Text", + "text": { + "@call": "pluralize", + "args": { + "value": 1, + "one": "1 item", + "other": "items" + } + } + }, + { + "id": "t2", + "component": "Text", + "text": { + "@call": "pluralize", + "args": { + "value": 5, + "one": "1 item", + "other": "5 items" + } + } + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "main": { + "components": { + "t1": { + "component": "Text", + "text": "1 item" + }, + "t2": { + "component": "Text", + "text": "5 items" + } + } + } + } + } + }, + { + "name": "test_function_logical_and_or_not", + "description": "Verifies and, or, and not logical function evaluations.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "t_and", + "t_or", + "t_not" + ] + }, + { + "id": "t_and", + "component": "Text", + "text": { + "@call": "and", + "args": { + "values": [ + true, + false + ] + } + } + }, + { + "id": "t_or", + "component": "Text", + "text": { + "@call": "or", + "args": { + "values": [ + true, + false + ] + } + } + }, + { + "id": "t_not", + "component": "Text", + "text": { + "@call": "not", + "args": { + "value": false + } + } + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "main": { + "components": { + "t_and": { + "component": "Text", + "text": false + }, + "t_or": { + "component": "Text", + "text": true + }, + "t_not": { + "component": "Text", + "text": true + } + } + } + } + } + }, + { + "name": "test_function_invalid_argument_schema_error", + "description": "Verifies ValidationError when a function is invoked with an argument of the wrong type.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatNumber", + "args": { + "value": "not_a_number" + } + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError" + } + } + ] + }, + { + "name": "test_evaluate_function_required_string_valid", + "description": "Verifies required returns true for non-empty string.", + "action": "evaluate_function", + "function": "required", + "args": { + "value": "hello" + }, + "expect": true + }, + { + "name": "test_evaluate_function_required_string_empty", + "description": "Verifies required returns false for empty string.", + "action": "evaluate_function", + "function": "required", + "args": { + "value": "" + }, + "expect": false + }, + { + "name": "test_evaluate_function_required_null", + "description": "Verifies required returns false for null.", + "action": "evaluate_function", + "function": "required", + "args": { + "value": null + }, + "expect": false + }, + { + "name": "test_evaluate_function_required_array_valid", + "description": "Verifies required returns true for non-empty array.", + "action": "evaluate_function", + "function": "required", + "args": { + "value": [ + 1, + 2 + ] + }, + "expect": true + }, + { + "name": "test_evaluate_function_required_array_empty", + "description": "Verifies required returns false for empty array.", + "action": "evaluate_function", + "function": "required", + "args": { + "value": [] + }, + "expect": false + }, + { + "name": "test_evaluate_function_required_object_empty", + "description": "Verifies required returns true for empty object (D20).", + "action": "evaluate_function", + "function": "required", + "args": { + "value": {} + }, + "expect": true + }, + { + "name": "test_evaluate_function_regex_match", + "description": "Verifies regex returns true on matching string.", + "action": "evaluate_function", + "function": "regex", + "args": { + "pattern": "^[0-9]+$", + "value": "12345" + }, + "expect": true + }, + { + "name": "test_evaluate_function_regex_mismatch", + "description": "Verifies regex returns false on non-matching string.", + "action": "evaluate_function", + "function": "regex", + "args": { + "pattern": "^[0-9]+$", + "value": "abc" + }, + "expect": false + }, + { + "name": "test_evaluate_function_regex_invalid_pattern", + "description": "Verifies regex raises ExpressionError on invalid regex pattern (D21).", + "action": "evaluate_function", + "function": "regex", + "args": { + "pattern": "[invalid(", + "value": "abc" + }, + "expectError": { + "category": "ExpressionError" + } + }, + { + "name": "test_evaluate_function_email_valid", + "description": "Verifies email returns true on standard email address.", + "action": "evaluate_function", + "function": "email", + "args": { + "value": "user@example.com" + }, + "expect": true + }, + { + "name": "test_evaluate_function_email_invalid_no_domain", + "description": "Verifies email returns false on missing domain.", + "action": "evaluate_function", + "function": "email", + "args": { + "value": "invalid-email" + }, + "expect": false + }, + { + "name": "test_evaluate_function_email_invalid_single_char_tld", + "description": "Verifies email returns false on single-character TLD (D22).", + "action": "evaluate_function", + "function": "email", + "args": { + "value": "test@test.c" + }, + "expect": false + }, + { + "name": "test_evaluate_function_length_string_valid", + "description": "Verifies length returns true when string is within bounds.", + "action": "evaluate_function", + "function": "length", + "args": { + "value": "hello", + "min": 3, + "max": 10 + }, + "expect": true + }, + { + "name": "test_evaluate_function_length_string_too_short", + "description": "Verifies length returns false when string is shorter than min.", + "action": "evaluate_function", + "function": "length", + "args": { + "value": "hi", + "min": 3 + }, + "expect": false + }, + { + "name": "test_evaluate_function_length_string_too_long", + "description": "Verifies length returns false when string is longer than max.", + "action": "evaluate_function", + "function": "length", + "args": { + "value": "hello world", + "max": 5 + }, + "expect": false + }, + { + "name": "test_evaluate_function_length_array_count_valid", + "description": "Verifies length counts array elements, not character length (D24).", + "action": "evaluate_function", + "function": "length", + "args": { + "value": [ + "a", + "b", + "c" + ], + "min": 2, + "max": 4 + }, + "expect": true + }, + { + "name": "test_evaluate_function_length_array_count_too_short", + "description": "Verifies length returns false when array has fewer elements than min (D24).", + "action": "evaluate_function", + "function": "length", + "args": { + "value": [ + "a" + ], + "min": 2 + }, + "expect": false + }, + { + "name": "test_evaluate_function_length_null_value", + "description": "Verifies length counts a null value, such as an unset field, as length 0.", + "protocolVersion": "v0.9", + "action": "evaluate_function", + "function": "length", + "args": { + "value": null, + "max": 10 + }, + "expect": true + }, + { + "name": "test_evaluate_function_length_null_value_too_short", + "description": "Verifies length rejects a null value when min is greater than 0.", + "protocolVersion": "v0.9", + "action": "evaluate_function", + "function": "length", + "args": { + "value": null, + "min": 1 + }, + "expect": false + }, + { + "name": "test_evaluate_function_numeric_valid_float", + "description": "Verifies numeric returns true for float within bounds.", + "action": "evaluate_function", + "function": "numeric", + "args": { + "value": 42.5, + "min": 0, + "max": 100 + }, + "expect": true + }, + { + "name": "test_evaluate_function_numeric_valid_string", + "description": "Verifies numeric returns true for valid numeric string.", + "action": "evaluate_function", + "function": "numeric", + "args": { + "value": "42.5", + "min": 0, + "max": 100 + }, + "expect": true + }, + { + "name": "test_evaluate_function_numeric_too_small", + "description": "Verifies numeric returns false when value is below min.", + "action": "evaluate_function", + "function": "numeric", + "args": { + "value": -5, + "min": 0 + }, + "expect": false + }, + { + "name": "test_evaluate_function_numeric_too_large", + "description": "Verifies numeric returns false when value is above max.", + "action": "evaluate_function", + "function": "numeric", + "args": { + "value": 150, + "max": 100 + }, + "expect": false + }, + { + "name": "test_evaluate_function_numeric_unparseable_string", + "description": "Verifies numeric returns false for unparseable string without raising (D23).", + "action": "evaluate_function", + "function": "numeric", + "args": { + "value": "not_a_number" + }, + "expect": false + }, + { + "name": "test_evaluate_function_and_all_true", + "description": "Verifies and returns true when all values are true.", + "action": "evaluate_function", + "function": "and", + "args": { + "values": [ + true, + true + ] + }, + "expect": true + }, + { + "name": "test_evaluate_function_and_one_false", + "description": "Verifies and returns false when any value is false.", + "action": "evaluate_function", + "function": "and", + "args": { + "values": [ + true, + false + ] + }, + "expect": false + }, + { + "name": "test_evaluate_function_and_truthiness_coercion_true", + "description": "Verifies and truthiness coercion on non-boolean values (D26).", + "action": "evaluate_function", + "function": "and", + "args": { + "values": [ + 1, + "non-empty" + ] + }, + "expect": true + }, + { + "name": "test_evaluate_function_and_truthiness_coercion_false", + "description": "Verifies and truthiness coercion with a falsy value (D26).", + "action": "evaluate_function", + "function": "and", + "args": { + "values": [ + 1, + "" + ] + }, + "expect": false + }, + { + "name": "test_evaluate_function_and_fewer_than_two_values_error", + "description": "Verifies and requires at least 2 values per schema (D25).", + "action": "evaluate_function", + "function": "and", + "args": { + "values": [ + true + ] + }, + "expectError": { + "category": "ExpressionError" + } + }, + { + "name": "test_evaluate_function_or_all_false", + "description": "Verifies or returns false when all values are false.", + "action": "evaluate_function", + "function": "or", + "args": { + "values": [ + false, + false + ] + }, + "expect": false + }, + { + "name": "test_evaluate_function_or_one_true", + "description": "Verifies or returns true when at least one value is true.", + "action": "evaluate_function", + "function": "or", + "args": { + "values": [ + false, + true + ] + }, + "expect": true + }, + { + "name": "test_evaluate_function_or_truthiness_coercion_true", + "description": "Verifies or truthiness coercion on non-boolean values (D26).", + "action": "evaluate_function", + "function": "or", + "args": { + "values": [ + 0, + "yes" + ] + }, + "expect": true + }, + { + "name": "test_evaluate_function_or_fewer_than_two_values_error", + "description": "Verifies or requires at least 2 values per schema (D25).", + "action": "evaluate_function", + "function": "or", + "args": { + "values": [ + false + ] + }, + "expectError": { + "category": "ExpressionError" + } + }, + { + "name": "test_evaluate_function_not_true", + "description": "Verifies not returns false for true.", + "action": "evaluate_function", + "function": "not", + "args": { + "value": true + }, + "expect": false + }, + { + "name": "test_evaluate_function_not_false", + "description": "Verifies not returns true for false.", + "action": "evaluate_function", + "function": "not", + "args": { + "value": false + }, + "expect": true + }, + { + "name": "test_evaluate_function_not_truthiness_falsy", + "description": "Verifies not truthiness coercion for falsy value (D26).", + "action": "evaluate_function", + "function": "not", + "args": { + "value": "" + }, + "expect": true + }, + { + "name": "test_evaluate_function_not_truthiness_truthy", + "description": "Verifies not truthiness coercion for truthy value (D26).", + "action": "evaluate_function", + "function": "not", + "args": { + "value": "hello" + }, + "expect": false + }, + { + "name": "test_evaluate_function_and_validation_result_operands", + "description": "Verifies and reads a failing ValidationResult operand as false in v1.0.", + "protocolVersion": "1.0", + "action": "evaluate_function", + "function": "and", + "args": { + "values": [ + { + "valid": true + }, + { + "valid": false, + "message": "Required", + "code": "required" + } + ] + }, + "expect": false + }, + { + "name": "test_evaluate_function_or_validation_result_operands", + "description": "Verifies or over two failing ValidationResult operands is false in v1.0.", + "protocolVersion": "1.0", + "action": "evaluate_function", + "function": "or", + "args": { + "values": [ + { + "valid": false + }, + { + "valid": false + } + ] + }, + "expect": false + }, + { + "name": "test_evaluate_function_not_validation_result_operand", + "description": "Verifies not inverts the validity of a ValidationResult operand in v1.0.", + "protocolVersion": "1.0", + "action": "evaluate_function", + "function": "not", + "args": { + "value": { + "valid": false + } + }, + "expect": true + }, + { + "name": "test_evaluate_function_not_validation_result_operand_v0_9", + "description": "Verifies v0.9, whose validators return booleans, treats a {valid} object as a plain truthy object.", + "protocolVersion": "v0.9", + "action": "evaluate_function", + "function": "not", + "args": { + "value": { + "valid": false + } + }, + "expect": false + }, + { + "name": "test_evaluate_function_pluralize_explicit_zero", + "description": "Verifies pluralize selects explicit zero category over CLDR (D27).", + "action": "evaluate_function", + "function": "pluralize", + "args": { + "value": 0, + "zero": "No items", + "one": "1 item", + "other": "{n} items" + }, + "expect": "No items" + }, + { + "name": "test_evaluate_function_pluralize_explicit_one", + "description": "Verifies pluralize selects explicit one category.", + "action": "evaluate_function", + "function": "pluralize", + "args": { + "value": 1, + "zero": "No items", + "one": "1 item", + "other": "{n} items" + }, + "expect": "1 item" + }, + { + "name": "test_evaluate_function_pluralize_explicit_two", + "description": "Verifies pluralize selects explicit two category (D27).", + "action": "evaluate_function", + "function": "pluralize", + "args": { + "value": 2, + "zero": "No items", + "one": "1 item", + "two": "A couple items", + "other": "{n} items" + }, + "expect": "A couple items" + }, + { + "name": "test_evaluate_function_pluralize_explicit_empty_string", + "description": "Verifies pluralize preserves explicit empty string without falling back to other (D27).", + "action": "evaluate_function", + "function": "pluralize", + "args": { + "value": 0, + "zero": "", + "other": "default" + }, + "expect": "" + }, + { + "name": "test_evaluate_function_pluralize_cldr_other", + "description": "Verifies pluralize falls back to other for unlisted category.", + "action": "evaluate_function", + "function": "pluralize", + "args": { + "value": 5, + "one": "1 item", + "other": "Many items" + }, + "expect": "Many items" + }, + { + "name": "test_evaluate_function_format_number_decimals", + "description": "Verifies formatNumber formats with decimals and grouping.", + "action": "evaluate_function", + "function": "formatNumber", + "args": { + "value": 1234.567, + "decimals": 2, + "grouping": true + }, + "expect": "1,234.57" + }, + { + "name": "test_evaluate_function_format_number_zero_decimals", + "description": "Verifies formatNumber formats with zero decimals and no grouping.", + "action": "evaluate_function", + "function": "formatNumber", + "args": { + "value": 1234.567, + "decimals": 0, + "grouping": false + }, + "expect": "1235" + }, + { + "name": "test_evaluate_function_format_currency_usd", + "description": "Verifies formatCurrency formats USD currency.", + "action": "evaluate_function", + "function": "formatCurrency", + "args": { + "value": 99.99, + "currency": "USD", + "decimals": 2 + }, + "expect": "$99.99" + }, + { + "name": "test_evaluate_function_format_date_custom_pattern", + "description": "Verifies formatDate formats date with custom pattern.", + "action": "evaluate_function", + "function": "formatDate", + "args": { + "value": "2026-08-26T12:00:00Z", + "format": "yyyy-MM-dd" + }, + "expect": "2026-08-26" + }, + { + "name": "test_evaluate_function_format_date_iso_normalized", + "description": "Verifies formatDate normalizes ISO output to UTC YYYY-MM-DDTHH:mm:ss.sssZ (D29).", + "action": "evaluate_function", + "function": "formatDate", + "args": { + "value": "2026-08-26T12:00:00.000Z", + "format": "ISO" + }, + "expect": "2026-08-26T12:00:00.000Z" + }, + { + "name": "test_evaluate_function_format_date_impossible_day", + "description": "Verifies formatDate returns an empty string for a day that does not exist, rather than rolling it into the next month.", + "action": "evaluate_function", + "function": "formatDate", + "args": { + "value": "2026-02-30", + "format": "yyyy-MM-dd" + }, + "expect": "" + }, + { + "name": "test_evaluate_function_format_date_impossible_month", + "description": "Verifies formatDate returns an empty string for a month that does not exist, rather than rolling it into the next year.", + "action": "evaluate_function", + "function": "formatDate", + "args": { + "value": "2026-13-01", + "format": "yyyy-MM-dd" + }, + "expect": "" + }, + { + "name": "test_evaluate_function_format_date_utc_time", + "description": "Verifies formatDate renders the time fields of a UTC timestamp as written, whatever the host time zone.", + "action": "evaluate_function", + "function": "formatDate", + "args": { + "value": "2026-09-04T23:30:00Z", + "format": "yyyy-MM-dd HH:mm" + }, + "expect": "2026-09-04 23:30" + }, + { + "name": "test_evaluate_function_format_date_without_offset", + "description": "Verifies formatDate reads a timestamp without an offset as written (as UTC), never as host-local time.", + "action": "evaluate_function", + "function": "formatDate", + "args": { + "value": "2026-09-04T23:30:00", + "format": "yyyy-MM-dd HH:mm" + }, + "expect": "2026-09-04 23:30" + }, + { + "name": "test_evaluate_function_format_date_keeps_written_offset", + "description": "Verifies formatDate renders a timestamp with an offset in that offset, so the written wall-clock date and time are kept.", + "protocolVersion": "1.0", + "action": "evaluate_function", + "function": "formatDate", + "args": { + "value": "2026-09-04T23:30:00-05:00", + "format": "yyyy-MM-dd HH:mm" + }, + "expect": "2026-09-04 23:30" + }, + { + "name": "test_evaluate_function_format_string_basic", + "description": "Verifies formatString evaluates static template string.", + "action": "evaluate_function", + "function": "formatString", + "args": { + "value": "Hello World" + }, + "expect": "Hello World" + }, + { + "name": "test_evaluate_function_format_string_null_coercion", + "description": "Verifies formatString coerces null expression to empty string (D18).", + "action": "evaluate_function", + "function": "formatString", + "args": { + "value": "Greeting: ${null}" + }, + "expect": "Greeting: " + }, + { + "name": "test_evaluate_function_open_url_valid_https", + "description": "Verifies openUrl accepts valid https scheme.", + "action": "evaluate_function", + "function": "openUrl", + "args": { + "url": "https://example.com" + }, + "expect": null + }, + { + "name": "test_evaluate_function_open_url_disallowed_scheme", + "description": "Verifies openUrl rejects disallowed URL scheme (D30).", + "action": "evaluate_function", + "function": "openUrl", + "args": { + "url": "javascript:alert(1)" + }, + "expectError": { + "category": "ExpressionError" + } + }, + { + "name": "test_evaluate_function_v10_runs_in_named_catalog", + "description": "Verifies a v1.0 call that names a catalogId runs in that catalog rather than the surface default. The default is the conformance custom catalog, which declares no formatString; the call names the basic catalog, which does.", + "action": "evaluate_function", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json", + "conformance/test_data/catalogs/custom_catalog_v1_0.json" + ], + "surface": { + "catalogId": "conformance/custom" + }, + "function": "formatString", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "args": { + "value": "Hello" + }, + "expect": "Hello" + }, + { + "name": "test_evaluate_function_v10_runs_in_named_catalog_on_surface_without_default", + "description": "Verifies a v1.0 call that names a catalogId runs on a surface that has no default catalog.", + "action": "evaluate_function", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "surface": {}, + "function": "formatString", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "args": { + "value": "Hello" + }, + "expect": "Hello" + }, + { + "name": "test_evaluate_function_v10_runs_in_surface_default", + "description": "Verifies a v1.0 call that names no catalogId runs in the surface default catalog (the basic catalog), while the conformance custom catalog is also available on the surface.", + "action": "evaluate_function", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json", + "conformance/test_data/catalogs/custom_catalog_v1_0.json" + ], + "surface": { + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + }, + "function": "formatString", + "args": { + "value": "Hello" + }, + "expect": "Hello" + }, + { + "name": "test_evaluate_function_v10_without_catalog_id_on_surface_without_default_errors", + "description": "Verifies a v1.0 call that names no catalogId fails with a CatalogError on a surface that has no default catalog, even though the only available catalog declares the function. There is no fallback to the available catalogs.", + "action": "evaluate_function", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "surface": {}, + "function": "formatString", + "args": { + "value": "Hello" + }, + "expectError": { + "category": "CatalogError", + "message": "has no default catalogId" + } + }, + { + "name": "test_evaluate_function_v10_unknown_catalog_id_errors", + "description": "Verifies a v1.0 call that names a catalogId the surface does not have fails with a CatalogError, rather than falling back to the surface default, which declares the function.", + "action": "evaluate_function", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "surface": { + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + }, + "function": "formatString", + "catalogId": "missing-catalog", + "args": { + "value": "Hello" + }, + "expectError": { + "category": "CatalogError", + "message": "Catalog not found: missing-catalog" + } + }, + { + "name": "test_evaluate_function_v10_catalog_of_other_protocol_version_errors", + "description": "Verifies a v1.0 call that names the v0.9 basic catalog fails with a CatalogError. The client has that catalog, but a v1.0 surface only makes v1.0 catalogs available.", + "action": "evaluate_function", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json", + "specification/v0_9/catalogs/basic/catalog.json" + ], + "surface": { + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + }, + "function": "formatString", + "catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json", + "args": { + "value": "Hello" + }, + "expectError": { + "category": "CatalogError", + "message": "Catalog not found" + } + }, + { + "name": "test_evaluate_function_v10_nested_call_does_not_inherit_catalog", + "description": "Verifies a call nested in the args of a v1.0 call resolves its catalog on its own. The outer call names the basic catalog; the inner call names no catalogId, and the surface has no default catalog, so the inner call fails with a CatalogError.", + "action": "evaluate_function", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "surface": {}, + "function": "formatString", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "args": { + "value": { + "@call": "formatString", + "args": { + "value": "hi" + } + } + }, + "expectError": { + "category": "CatalogError", + "message": "has no default catalogId" + } + }, + { + "name": "test_evaluate_function_v09_ignores_call_catalog_id", + "description": "Verifies a catalogId on a call is ignored before v1.0. On a v0.9 surface the call runs in the surface catalog, so naming a catalog the client does not have is not an error.", + "action": "evaluate_function", + "protocolVersion": "v0.9", + "catalogPaths": [ + "specification/v0_9/catalogs/basic/catalog.json" + ], + "surface": { + "catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" + }, + "function": "formatString", + "catalogId": "missing-catalog", + "args": { + "value": "Hello" + }, + "expect": "Hello" + }, + { + "name": "test_evaluate_function_v10_validation_result_coerced_in_format_number_grouping", + "description": "Verifies a failing ValidationResult bound to formatNumber's DynamicBoolean grouping argument reads as false, turning grouping off. An object is truthy in most languages, so an engine that does not coerce it groups the digits.", + "action": "evaluate_function", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "surface": { + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + }, + "function": "formatNumber", + "args": { + "value": 1234, + "decimals": 0, + "grouping": { + "@call": "required", + "args": { + "value": "" + } + } + }, + "expect": "1234" + }, + { + "name": "test_evaluate_function_v10_validation_result_coerced_in_format_number_grouping_valid", + "description": "Verifies a passing ValidationResult bound to formatNumber's DynamicBoolean grouping argument reads as true, keeping grouping on.", + "action": "evaluate_function", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "surface": { + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + }, + "function": "formatNumber", + "args": { + "value": 1234, + "decimals": 0, + "grouping": { + "@call": "required", + "args": { + "value": "x" + } + } + }, + "expect": "1,234" + }, + { + "name": "test_evaluate_function_v10_validation_result_coerced_in_not_operand_on_surface", + "description": "Verifies a ValidationResult resolved from a nested validation call into not's DynamicBoolean operand is read by its validity when the call runs on a surface.", + "action": "evaluate_function", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "surface": { + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + }, + "function": "not", + "args": { + "value": { + "@call": "required", + "args": { + "value": "" + } + } + }, + "expect": true + }, + { + "name": "test_evaluate_function_v10_validation_result_coerced_in_and_operands_on_surface", + "description": "Verifies the ValidationResults resolved from nested validation calls into and's DynamicBoolean operands are read by their validity when the call runs on a surface, as in the specification's enable-when-valid example.", + "action": "evaluate_function", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "surface": { + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + }, + "dataModel": { + "email": "a@b.c", + "phone": "" + }, + "function": "and", + "args": { + "values": [ + { + "@call": "required", + "args": { + "value": { + "@path": "/email" + } + } + }, + { + "@call": "required", + "args": { + "value": { + "@path": "/phone" + } + } + } + ] + }, + "expect": false + }, + { + "name": "test_evaluate_function_v10_agent_only_function_errors", + "description": "Verifies a v1.0 call on a surface to a function whose allowedCallers is agentOnly fails with an ExpressionError and does not run.", + "action": "evaluate_function", + "protocolVersion": "1.0", + "catalogPaths": [ + "conformance/test_data/catalogs/allowed_callers_catalog_v1_0.json" + ], + "surface": { + "catalogId": "https://a2ui.org/catalogs/v10/allowed_callers" + }, + "function": "readSessionToken", + "args": {}, + "expectError": { + "category": "ExpressionError", + "message": "cannot be called by renderer" + } + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/index_function.json b/package/__fixtures__/a2ui-conformance/core/index_function.json new file mode 100644 index 0000000000..283f21106e --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/index_function.json @@ -0,0 +1,270 @@ +[ + { + "name": "test_index_function_in_collection_loop", + "description": "Verifies built-in @index function evaluation during collection template iteration.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "dataModel": { + "tasks": [ + "Buy Milk", + "Clean Kitchen", + "Pay Bills" + ] + }, + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "list" + ] + }, + { + "id": "list", + "component": "List", + "children": { + "path": "/tasks", + "componentId": "row" + } + }, + { + "id": "row", + "component": "Row", + "children": [ + "item_index", + "item_text" + ] + }, + { + "id": "item_index", + "component": "Text", + "text": { + "@call": "@index", + "args": {} + } + }, + { + "id": "item_text", + "component": "Text", + "text": { + "@path": "" + } + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "main": { + "components": { + "item_index_0": { + "text": "0" + }, + "item_text_0": { + "text": "Buy Milk" + }, + "item_index_1": { + "text": "1" + }, + "item_text_1": { + "text": "Clean Kitchen" + }, + "item_index_2": { + "text": "2" + }, + "item_text_2": { + "text": "Pay Bills" + } + } + } + } + } + }, + { + "name": "test_index_function_with_offset", + "description": "Verifies built-in @index function with offset argument (@index(1) for 1-based indexing).", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "dataModel": { + "players": [ + "Alice", + "Bob" + ] + }, + "components": [ + { + "id": "root", + "component": "List", + "children": { + "path": "/players", + "componentId": "text" + } + }, + { + "id": "text", + "component": "Text", + "text": { + "@call": "@index", + "args": { + "offset": 1 + } + } + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "main": { + "components": { + "text_0": { + "text": "1" + }, + "text_1": { + "text": "2" + } + } + } + } + } + }, + { + "name": "test_index_function_nested_path", + "description": "Verifies built-in @index function evaluation inside a deeply nested child property path of a template list.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "dataModel": { + "users": [ + { + "name": "Alice", + "address": { + "city": "Seattle" + } + }, + { + "name": "Bob", + "address": { + "city": "Portland" + } + } + ] + }, + "components": [ + { + "id": "root", + "component": "List", + "children": { + "path": "/users", + "componentId": "col" + } + }, + { + "id": "col", + "component": "Column", + "children": [ + "nested_text" + ] + }, + { + "id": "nested_text", + "component": "Text", + "text": { + "@call": "@index", + "args": { + "offset": 1 + } + } + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "main": { + "components": { + "nested_text_0": { + "text": "1" + }, + "nested_text_1": { + "text": "2" + } + } + } + } + } + }, + { + "name": "test_index_function_outside_loop_error", + "description": "Verifies error thrown when @index function is called outside a collection iteration scope.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "@index", + "args": {} + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "@index function can only be evaluated inside a collection template iteration scope." + } + } + ] + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/message_processor_v1_0.json b/package/__fixtures__/a2ui-conformance/core/message_processor_v1_0.json new file mode 100644 index 0000000000..05b297adb9 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/message_processor_v1_0.json @@ -0,0 +1,5222 @@ +[ + { + "name": "test_v10_create_surface_inline_initialization", + "description": "Tests v1.0 createSurface message with inline components and initial dataModel initialization.", + "action": "process_messages", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_v10", + "catalogId": "test-catalog-v10", + "sendDataModel": true, + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "text1" + ] + }, + { + "id": "text1", + "component": "Text", + "text": "Inline Text" + } + ], + "dataModel": { + "counter": 42 + } + } + } + ], + "expect": { + "surfaces": { + "s_v10": { + "exists": true, + "sendDataModel": true, + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "text1" + ] + }, + { + "id": "text1", + "component": "Text", + "text": "Inline Text" + } + ], + "dataModel": { + "counter": 42 + } + } + } + } + }, + { + "name": "test_v10_create_surface_optional_catalog_id", + "description": "Tests that v1.0 createSurface permits omitting catalogId.", + "action": "process_messages", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_v10_nocat" + } + } + ], + "expect": { + "surfaces": { + "s_v10_nocat": { + "exists": true + } + } + } + }, + { + "name": "test_v10_create_surface_with_metadata_extensions", + "description": "Tests v1.0 createSurface with metadata and extension descriptors.", + "action": "process_messages", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_v10_meta", + "catalogId": "test-catalog-v10", + "metadata": { + "extensions": { + "a2ui_experimental": { + "streaming": true + } + } + } + } + } + ], + "expect": { + "surfaces": { + "s_v10_meta": { + "exists": true + } + } + } + }, + { + "name": "test_v10_create_surface_metadata_extension_key_must_be_identifier", + "description": "Tests that v1.0 createSurface rejects metadata extensions whose key is not a Unicode identifier (UAX 31).", + "action": "process_messages", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_v10_meta_bad_key", + "catalogId": "test-catalog-v10", + "metadata": { + "extensions": { + "a2ui:experimental": { + "streaming": true + } + } + } + } + } + ], + "expectError": { + "category": "ValidationError" + } + }, + { + "name": "test_v10_create_surface_metadata_only_holds_extensions", + "description": "Tests that v1.0 createSurface rejects metadata keys other than extensions.", + "action": "process_messages", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_v10_meta_bad_field", + "catalogId": "test-catalog-v10", + "metadata": { + "owner": "me" + } + } + } + ], + "expectError": { + "category": "ValidationError" + } + }, + { + "name": "test_v10_component_catalog_override", + "description": "Tests that individual components in v1.0 can specify a catalogId override different from the surface default.", + "action": "process_messages", + "catalogs": [ + { + "catalogId": "main-catalog", + "protocolVersion": "1.0" + }, + { + "catalogId": "charts-catalog", + "protocolVersion": "1.0" + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_v10_multi", + "catalogId": "main-catalog" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_v10_multi", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "c1" + ] + }, + { + "id": "c1", + "component": "PieChart", + "catalogId": "charts-catalog", + "data": [ + 10, + 20, + 30 + ] + } + ] + } + } + ], + "expect": { + "surfaces": { + "s_v10_multi": { + "exists": true, + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "c1" + ] + }, + { + "id": "c1", + "component": "PieChart", + "data": [ + 10, + 20, + 30 + ] + } + ] + } + } + } + }, + { + "name": "test_v10_update_data_model_deletion", + "description": "Tests that updating a path with value null deletes the key from the data model in v1.0.", + "action": "process_messages", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_v10_del", + "catalogId": "test-catalog-v10", + "dataModel": { + "user": { + "name": "Alice", + "temporaryToken": "abc123xyz" + } + } + } + }, + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s_v10_del", + "path": "/user/temporaryToken", + "value": null + } + } + ], + "expect": { + "surfaces": { + "s_v10_del": { + "dataModel": { + "user": { + "name": "Alice" + } + } + } + } + } + }, + { + "name": "test_v10_get_renderer_capabilities", + "description": "Tests generating v1.0 renderer capabilities structure.", + "action": "get_renderer_capabilities", + "catalogs": [ + { + "catalogId": "cat-v10", + "protocolVersion": "1.0" + } + ], + "args": { + "includeInlineCatalogs": false, + "version": "1.0" + }, + "expect": { + "1.0": { + "supportedCatalogIds": [ + "cat-v10" + ] + } + } + }, + { + "name": "test_v10_get_renderer_capabilities_inline_catalogs", + "description": "Tests that v1.0 inline catalogs list each component as a plain schema, without the v0.9 ComponentCommon allOf envelope, because v1.0 applies ComponentCommon at the message envelope.", + "action": "get_renderer_capabilities", + "catalogs": [ + { + "catalogId": "cat-v10", + "protocolVersion": "1.0", + "components": { + "Button": { + "type": "object", + "properties": { + "component": { + "const": "Button" + }, + "label": { + "type": "string", + "description": "The button label" + } + }, + "required": [ + "component", + "label" + ] + } + } + } + ], + "args": { + "includeInlineCatalogs": true, + "version": "1.0" + }, + "expect": { + "1.0": { + "supportedCatalogIds": [ + "cat-v10" + ], + "inlineCatalogs": [ + { + "catalogId": "cat-v10", + "protocolVersion": "1.0", + "components": { + "Button": { + "type": "object", + "properties": { + "component": { + "const": "Button" + }, + "label": { + "type": "string", + "description": "The button label" + } + }, + "required": [ + "component", + "label" + ] + } + } + } + ] + } + } + }, + { + "name": "test_multiple_update_types_error", + "description": "Tests that sending a single message envelope containing multiple update actions raises a ValidationError.", + "action": "process_messages", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "test-catalog-v10" + }, + "deleteSurface": { + "surfaceId": "s1" + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Message contains multiple conflicting update actions" + } + }, + { + "name": "test_batch_message_array_and_wrapper", + "description": "Tests that message processor correctly processes message list arrays and wrapped container payloads.", + "action": "process_messages", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "test-catalog-v10" + } + }, + { + "version": "1.0", + "createSurface": { + "surfaceId": "s2", + "catalogId": "test-catalog-v10" + } + } + ] + }, + "expect": { + "surfaces": { + "s1": { + "exists": true + }, + "s2": { + "exists": true + } + } + } + }, + { + "name": "test_v10_rejects_v08_begin_rendering_action", + "description": "Tests that v1.0 MessageProcessor rejects v0.8 beginRendering action when protocolVersion is v1.0.", + "action": "process_messages", + "protocolVersion": "1.0", + "messages": [ + { + "version": "1.0", + "beginRendering": { + "surfaceId": "s_v10_invalid", + "catalogId": "test-catalog-v10" + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "action 'beginRendering' is not supported in protocol version 1.0" + } + }, + { + "name": "test_v10_non_string_surface_id_error", + "description": "Tests that a v1.0 action with a non-string surfaceId raises a ValidationError.", + "action": "process_messages", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": 12345, + "catalogId": "test-catalog-v10" + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "surfaceId must be a string" + } + }, + { + "name": "test_v10_update_components_mismatched_catalog_protocol_version_error", + "description": "Tests that updating a component with a catalog of a different protocol version raises CatalogError.", + "action": "process_messages", + "catalogs": [ + { + "catalogId": "cat-v10", + "protocolVersion": "1.0" + }, + { + "catalogId": "cat-v09", + "protocolVersion": "v0.9" + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_mismatch", + "catalogId": "cat-v10" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_mismatch", + "components": [ + { + "id": "c1", + "component": "Button", + "catalogId": "cat-v09" + } + ] + } + } + ], + "expectError": { + "category": "CatalogError", + "surfaceId": "s_mismatch", + "rendererError": { + "version": "1.0", + "error": { + "code": "CATALOG_NOT_FOUND", + "surfaceId": "s_mismatch", + "message": "cat-v09" + } + } + } + }, + { + "name": "test_unsupported_protocol_version_error", + "description": "Verifies rejecting unsupported protocol version strings (v99.0).", + "action": "process_messages", + "messages": [ + { + "version": "v99.0", + "createSurface": { + "surfaceId": "s1" + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Unsupported protocol version 'v99.0'" + } + }, + { + "name": "test_v091_version_string_compatibility", + "description": "Verifies processing messages with version v0.9.1.", + "action": "process_messages", + "protocolVersion": "v0.9.1", + "messages": [ + { + "version": "v0.9.1", + "createSurface": { + "surfaceId": "s_v091", + "catalogId": "basic" + } + } + ], + "expect": { + "surfaces": { + "s_v091": { + "exists": true + } + } + } + }, + { + "name": "test_missing_version_field_in_v10_process_messages_error", + "description": "Verifies ValidationError on missing version field during process_messages.", + "action": "process_messages", + "messages": [ + { + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic" + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Missing required version field" + } + }, + { + "name": "test_batch_multi_stage_lifecycle_pipeline", + "description": "Verifies create, component update, data update, and component mutation within a single batch array.", + "action": "process_messages", + "protocolVersion": "1.0", + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_pipeline", + "catalogId": "test-catalog-v10" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_pipeline", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "text1" + ] + }, + { + "id": "text1", + "component": "Text", + "text": "Initial" + } + ] + } + }, + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s_pipeline", + "path": "/status", + "value": "ready" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_pipeline", + "components": [ + { + "id": "text1", + "component": "Text", + "text": "Updated" + } + ] + } + } + ], + "expect": { + "surfaces": { + "s_pipeline": { + "exists": true, + "dataModel": { + "status": "ready" + }, + "components": [ + { + "id": "root", + "component": "Column" + }, + { + "id": "text1", + "component": "Text" + } + ] + } + } + } + }, + { + "name": "test_batch_duplicate_component_ids_in_same_message_error", + "description": "Verifies IntegrityError on duplicate component IDs within a single updateComponents message.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_dup", + "catalogId": "basic" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_dup", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "c1" + ] + }, + { + "id": "c1", + "component": "Text", + "text": "First" + }, + { + "id": "c1", + "component": "Text", + "text": "Duplicate" + } + ] + } + } + ], + "expectError": { + "category": "IntegrityError", + "surfaceId": "s_dup", + "rendererError": { + "version": "1.0", + "error": { + "code": "INTEGRITY_ERROR", + "surfaceId": "s_dup", + "message": "c1" + } + } + } + }, + { + "name": "test_batch_atomic_rollback_on_candidate_topology_cycle", + "description": "Verifies candidate topology pre-flight cycle check aborts without mutating existing valid component tree.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_cycle", + "catalogId": "basic", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "c1" + ] + }, + { + "id": "c1", + "component": "Text", + "text": "Valid Text" + } + ] + } + } + ], + "expect": { + "surfaces": { + "s_cycle": { + "exists": true, + "components": [ + { + "id": "root", + "component": "Column" + }, + { + "id": "c1", + "component": "Text" + } + ] + } + } + } + }, + { + "messages": [ + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_cycle", + "components": [ + { + "id": "c1", + "component": "Column", + "children": [ + "c2" + ] + }, + { + "id": "c2", + "component": "Column", + "children": [ + "c1" + ] + } + ] + } + } + ], + "expectError": { + "category": "IntegrityError", + "surfaceId": "s_cycle", + "rendererError": { + "version": "1.0", + "error": { + "code": "RECURSION_LIMIT_EXCEEDED", + "surfaceId": "s_cycle", + "message": "Circular" + } + } + } + } + ] + }, + { + "name": "test_update_components_on_deleted_surface_error", + "description": "Verifies updating components on a deleted surface raises IntegrityError.", + "action": "process_messages", + "protocolVersion": "1.0", + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_del", + "catalogId": "basic" + } + }, + { + "version": "1.0", + "deleteSurface": { + "surfaceId": "s_del" + } + } + ], + "expect": { + "surfaces": { + "s_del": { + "exists": false + } + } + } + }, + { + "messages": [ + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_del", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Fail" + } + ] + } + } + ], + "expectError": { + "category": "IntegrityError" + } + } + ] + }, + { + "name": "test_update_data_model_on_deleted_surface_error", + "description": "Verifies updating data model on a deleted surface raises IntegrityError.", + "action": "process_messages", + "protocolVersion": "1.0", + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_del_data", + "catalogId": "basic" + } + }, + { + "version": "1.0", + "deleteSurface": { + "surfaceId": "s_del_data" + } + } + ], + "expect": { + "surfaces": { + "s_del_data": { + "exists": false + } + } + } + }, + { + "messages": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s_del_data", + "path": "/key", + "value": "value" + } + } + ], + "expectError": { + "category": "IntegrityError" + } + } + ] + }, + { + "name": "test_delete_non_existent_surface_is_safe_noop", + "description": "Verifies deleting a non-existent surface ID executes cleanly as a no-op.", + "action": "process_messages", + "protocolVersion": "1.0", + "messages": [ + { + "version": "1.0", + "deleteSurface": { + "surfaceId": "non_existent_surface_id" + } + } + ], + "expect": { + "surfaces": { + "non_existent_surface_id": { + "exists": false + } + } + } + }, + { + "name": "test_permissive_mode_allows_orphan_components", + "description": "Verifies strictMode false accepts components unreachable from root.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": false, + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_permissive_orphan", + "catalogId": "test-catalog-v10", + "components": [ + { + "id": "root", + "component": "Column", + "children": [] + }, + { + "id": "orphan_comp", + "component": "Text", + "text": "Unreachable Text" + } + ] + } + } + ], + "expect": { + "surfaces": { + "s_permissive_orphan": { + "exists": true, + "components": [ + { + "id": "root", + "component": "Column" + }, + { + "id": "orphan_comp", + "component": "Text" + } + ] + } + } + } + }, + { + "name": "test_permissive_mode_allows_dangling_references", + "description": "Verifies strictMode false accepts containers referencing uncreated child IDs.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": false, + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_permissive_dangling", + "catalogId": "test-catalog-v10", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "uncreated_child_node" + ] + } + ] + } + } + ], + "expect": { + "surfaces": { + "s_permissive_dangling": { + "exists": true, + "components": [ + { + "id": "root", + "component": "Column" + } + ] + } + } + } + }, + { + "name": "test_v10_call_validation_config_relaxes_strict_processor", + "description": "A strict processor accepts an orphan component in a call whose validationConfig allows orphans, and rejects one in the next call, which has no validationConfig.", + "action": "process_messages", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "validationConfig": { + "allowOrphanComponents": true + }, + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_relaxed_call", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Root" + }, + { + "id": "orphan", + "component": "Text", + "text": "Orphan" + } + ] + } + } + ], + "expect": { + "surfaces": { + "s_relaxed_call": { + "exists": true, + "components": [ + { + "id": "root", + "component": "Text" + }, + { + "id": "orphan", + "component": "Text" + } + ] + } + } + } + }, + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_strict_call", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Root" + }, + { + "id": "orphan", + "component": "Text", + "text": "Orphan" + } + ] + } + } + ], + "expectError": { + "message": "is not reachable from" + } + } + ] + }, + { + "name": "test_v10_call_validation_config_tightens_permissive_processor", + "description": "A processor created without validation rules rejects an orphan component in a call whose validationConfig is strict, and accepts one in the next call, which has no validationConfig.", + "action": "process_messages", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": false, + "steps": [ + { + "validationConfig": {}, + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_strict_call", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Root" + }, + { + "id": "orphan", + "component": "Text", + "text": "Orphan" + } + ] + } + } + ], + "expectError": { + "message": "is not reachable from" + } + }, + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_permissive_call", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Root" + }, + { + "id": "orphan", + "component": "Text", + "text": "Orphan" + } + ] + } + } + ], + "expect": { + "surfaces": { + "s_permissive_call": { + "exists": true, + "components": [ + { + "id": "root", + "component": "Text" + }, + { + "id": "orphan", + "component": "Text" + } + ] + } + } + } + } + ] + }, + { + "name": "test_v10_call_validation_config_leaves_out_rules_as_strict", + "description": "A call whose validationConfig allows only orphans still rejects a dangling child reference, since the rules it leaves out are strict, even on a processor created without validation rules.", + "action": "process_messages", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": false, + "steps": [ + { + "validationConfig": { + "allowOrphanComponents": true + }, + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_dangling_call", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "missing_child" + ] + } + ] + } + } + ], + "expectError": { + "message": "Dangling reference" + } + } + ] + }, + { + "name": "test_composition_constraints_preserved_on_partial_parent_update", + "description": "Verifies updating container property without redefining children preserves existing child relationships.", + "action": "process_messages", + "protocolVersion": "1.0", + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_partial_parent", + "catalogId": "test-catalog-v10", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "text_child" + ] + }, + { + "id": "text_child", + "component": "Text", + "text": "Child Text" + } + ] + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_partial_parent", + "components": [ + { + "id": "text_child", + "component": "Text", + "text": "Updated Child Text" + } + ] + } + } + ], + "expect": { + "surfaces": { + "s_partial_parent": { + "exists": true, + "components": [ + { + "id": "root", + "component": "Column" + }, + { + "id": "text_child", + "component": "Text" + } + ] + } + } + } + }, + { + "name": "test_global_recursion_depth_limit_exceeded_error", + "description": "Verifies object nesting exceeding limit (>50) raises RecursionError.", + "action": "validate", + "protocolVersion": "1.0", + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_rec", + "catalogId": "basic" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_rec", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Deep", + "meta": { + "l1": { + "l2": { + "l3": { + "l4": { + "l5": { + "l6": { + "l7": { + "l8": { + "l9": { + "l10": { + "l11": { + "l12": { + "l13": { + "l14": { + "l15": { + "l16": { + "l17": { + "l18": { + "l19": { + "l20": { + "l21": { + "l22": { + "l23": { + "l24": { + "l25": { + "l26": { + "l27": { + "l28": { + "l29": { + "l30": { + "l31": { + "l32": { + "l33": { + "l34": { + "l35": { + "l36": { + "l37": { + "l38": { + "l39": { + "l40": { + "l41": { + "l42": { + "l43": { + "l44": { + "l45": { + "l46": { + "l47": { + "l48": { + "l49": { + "l50": { + "l51": { + "l52": "too deep" + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + ] + } + } + ], + "expectError": { + "category": "RecursionError", + "message": "Global recursion limit exceeded" + } + } + ] + }, + { + "name": "test_v10_component_without_catalog_id_on_surface_without_default_errors", + "description": "A component that names no catalogId on a surface that names no default catalog cannot be resolved, so the update fails instead of falling back to the first registered catalog.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello" + } + ] + } + } + ], + "expectError": { + "category": "CatalogError", + "surfaceId": "s", + "message": "has no default catalogId", + "rendererError": { + "version": "1.0", + "error": { + "code": "CATALOG_NOT_FOUND", + "surfaceId": "s", + "message": "has no default catalogId" + } + } + } + }, + { + "name": "test_v10_components_naming_catalogs_on_surface_without_default", + "description": "A surface without a default catalog accepts components that each name their catalog.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Column", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "children": [ + "greeting", + "badge" + ] + }, + { + "id": "greeting", + "component": "Text", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "text": "Hello" + }, + { + "id": "badge", + "component": "Badge", + "catalogId": "custom-v10", + "label": "New" + } + ] + } + } + ], + "expect": { + "surfaces": { + "s": { + "exists": true, + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "greeting", + "badge" + ] + }, + { + "id": "greeting", + "component": "Text", + "text": "Hello" + }, + { + "id": "badge", + "component": "Badge", + "label": "New" + } + ] + } + } + } + }, + { + "name": "test_v10_component_from_named_catalog_with_closed_schema_accepted", + "description": "A component's catalogId belongs to the component envelope (ComponentCommon), not to its properties. A catalog whose component schema is closed and declares no catalogId accepts a component that names that catalog.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogs": [ + { + "catalogId": "closed-v10", + "protocolVersion": "1.0", + "components": { + "Tag": { + "type": "object", + "properties": { + "label": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "functions": {} + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Tag", + "catalogId": "closed-v10", + "label": "Hello" + } + ] + } + } + ], + "expect": { + "surfaces": { + "s": { + "exists": true, + "components": [ + { + "id": "root", + "component": "Tag", + "label": "Hello" + } + ] + } + } + } + }, + { + "name": "test_v10_function_call_naming_catalog_without_that_function_errors", + "description": "Under strictMode: true (allow_unknown_elements=False), a function call that names a catalogId is validated against that catalog's function schema, which does not declare formatString. With strictMode: false, the call is accepted at processing time so a renderer can route it via callAgentFunction.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatString", + "catalogId": "custom-v10", + "args": { + "value": "Hello" + } + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "s", + "message": "Unrecognized function 'formatString'", + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "s", + "path": "/components/0/text", + "message": "Unrecognized function 'formatString'" + } + } + } + }, + { + "name": "test_v10_function_call_naming_catalog_with_invalid_args_errors", + "description": "A function call that names a catalogId has its args validated against that catalog's function definition.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "phrase": { + "type": "string" + } + }, + "required": [ + "phrase" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "shout", + "catalogId": "custom-v10", + "args": { + "phrase": 3 + } + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "s", + "message": "phrase", + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "s", + "path": "/components/0/text/args/phrase", + "message": "phrase" + } + } + } + }, + { + "name": "test_v10_function_call_naming_catalog_with_valid_args", + "description": "A component from the surface default catalog may call a function from another catalog by naming its catalogId.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "shout", + "catalogId": "custom-v10", + "args": { + "value": "Hello" + } + } + } + ] + } + } + ], + "expect": { + "surfaces": { + "s": { + "exists": true + } + } + } + }, + { + "name": "test_v10_function_call_naming_unknown_catalog_errors", + "description": "A function call that names a catalogId the client does not have is rejected.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "shout", + "catalogId": "missing-catalog", + "args": { + "value": "Hello" + } + } + } + ] + } + } + ], + "expectError": { + "category": "CatalogError", + "message": "Catalog not found: missing-catalog" + } + }, + { + "name": "test_v10_function_call_with_empty_catalog_id_errors", + "description": "A function call whose catalogId is an empty string names a catalog, so it does not fall back to the surface default. No catalog has an empty ID, so the call is rejected.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatString", + "catalogId": "", + "args": { + "value": "Hello" + } + } + } + ] + } + } + ], + "expectError": { + "category": "CatalogError", + "message": "Catalog not found" + } + }, + { + "name": "test_v10_component_with_non_string_catalog_id_errors", + "description": "A component's catalogId must be a string. A number is rejected as invalid instead of being ignored, which would resolve the component to the surface default.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "catalogId": 123, + "text": "Hello" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "catalogId" + } + }, + { + "name": "test_v10_function_call_without_catalog_id_uses_surface_default", + "description": "A function call that names no catalogId resolves against the surface default catalog, not the catalog of the component that contains it. formatString is declared by the basic catalog only.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Badge", + "catalogId": "custom-v10", + "label": { + "@call": "formatString", + "args": { + "value": "Hello" + } + } + } + ] + } + } + ], + "expect": { + "surfaces": { + "s": { + "exists": true + } + } + } + }, + { + "name": "test_v10_function_call_without_catalog_id_to_unknown_function_errors", + "description": "Under strictMode: true (allow_unknown_elements=False), a function call that names no catalogId is validated against the surface default catalog's function schema, which does not declare notAFunction. With strictMode: false, the call is accepted at processing time so a renderer can route it via callAgentFunction.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "notAFunction", + "args": {} + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Unrecognized function 'notAFunction'" + } + }, + { + "name": "test_v10_function_call_without_catalog_id_on_surface_without_default_errors", + "description": "A function call that names no catalogId on a surface without a default catalog cannot be resolved, even when the component's own catalog declares the function.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Badge", + "catalogId": "custom-v10", + "label": { + "@call": "shout", + "args": { + "value": "Hello" + } + } + } + ] + } + } + ], + "expectError": { + "category": "CatalogError", + "surfaceId": "s", + "message": "has no default catalogId", + "rendererError": { + "version": "1.0", + "error": { + "code": "CATALOG_NOT_FOUND", + "surfaceId": "s", + "message": "has no default catalogId" + } + } + } + }, + { + "name": "test_v10_index_function_on_surface_without_default", + "description": "@index is a universal system function that belongs to no catalog, so a template may call it without a catalogId on a surface that has no default catalog.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "List", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "children": { + "path": "/items", + "componentId": "row" + } + }, + { + "id": "row", + "component": "Text", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "text": { + "@call": "@index", + "args": { + "offset": 1 + } + } + } + ] + } + } + ], + "expect": { + "surfaces": { + "s": { + "exists": true + } + } + } + }, + { + "name": "test_v10_index_function_on_surface_with_default", + "description": "@index is accepted on a surface whose default catalog is the basic catalog; it is not looked up as a catalog function.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "List", + "children": { + "path": "/items", + "componentId": "row" + } + }, + { + "id": "row", + "component": "Text", + "text": { + "@call": "@index", + "args": { + "offset": 1 + } + } + } + ] + } + } + ], + "expect": { + "surfaces": { + "s": { + "exists": true + } + } + } + }, + { + "name": "test_v10_component_update_without_catalog_id_on_surface_without_default_errors", + "description": "An update that names no catalogId resolves against the surface default, not the catalog of the component's earlier version, so it fails on a surface without a default catalog.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "text": "Hello" + } + ] + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Goodbye" + } + ] + } + } + ], + "expectError": { + "category": "CatalogError", + "surfaceId": "s", + "message": "has no default catalogId", + "rendererError": { + "version": "1.0", + "error": { + "code": "CATALOG_NOT_FOUND", + "surfaceId": "s", + "message": "has no default catalogId" + } + } + } + }, + { + "name": "test_v10_component_update_without_catalog_id_uses_surface_default", + "description": "An update that names no catalogId resolves against the surface default catalog even when the component's earlier version named another catalog. The basic catalog declares no Badge, so the update is rejected.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + } + } + } + ], + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Badge", + "catalogId": "custom-v10", + "label": "First" + } + ] + } + } + ], + "expect": { + "surfaces": { + "s": { + "exists": true, + "components": [ + { + "id": "root", + "component": "Badge" + } + ] + } + } + } + }, + { + "messages": [ + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Badge", + "label": "Second" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "'Badge'" + } + } + ] + }, + { + "name": "test_v10_nested_call_in_args_resolves_its_own_catalog", + "description": "A call nested in the args of another call resolves its catalog on its own and does not inherit the catalog of the call around it. The outer wrap names custom-v10; the inner shout names no catalogId, so it resolves to the surface default (the basic catalog), which under strictMode: true rejects shout because the basic catalog does not declare it.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + }, + "Tags": { + "type": "object", + "properties": { + "component": { + "const": "Tags" + }, + "items": { + "type": "array" + } + }, + "required": [ + "component", + "items" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "wrap": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "wrap" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "wrap", + "catalogId": "custom-v10", + "args": { + "value": { + "@call": "shout", + "args": { + "value": "hi" + } + } + } + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Unrecognized function 'shout'" + } + }, + { + "name": "test_v10_nested_call_in_args_naming_its_catalog", + "description": "A call nested in the args of another call is accepted when it names the catalog that declares it, even though the surface default (the basic catalog) does not declare it.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + }, + "Tags": { + "type": "object", + "properties": { + "component": { + "const": "Tags" + }, + "items": { + "type": "array" + } + }, + "required": [ + "component", + "items" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "wrap": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "wrap" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "wrap", + "catalogId": "custom-v10", + "args": { + "value": { + "@call": "shout", + "catalogId": "custom-v10", + "args": { + "value": "hi" + } + } + } + } + } + ] + } + } + ], + "expect": { + "surfaces": { + "s": { + "exists": true + } + } + } + }, + { + "name": "test_v10_nested_call_in_args_on_surface_without_default_errors", + "description": "On a surface without a default catalog, a call nested in the args of a call that names custom-v10 cannot be resolved when it names no catalogId itself, even though custom-v10 declares it.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + }, + "Tags": { + "type": "object", + "properties": { + "component": { + "const": "Tags" + }, + "items": { + "type": "array" + } + }, + "required": [ + "component", + "items" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "wrap": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "wrap" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Badge", + "catalogId": "custom-v10", + "label": { + "@call": "wrap", + "catalogId": "custom-v10", + "args": { + "value": { + "@call": "shout", + "args": { + "value": "hi" + } + } + } + } + } + ] + } + } + ], + "expectError": { + "category": "CatalogError", + "surfaceId": "s", + "message": "has no default catalogId", + "rendererError": { + "version": "1.0", + "error": { + "code": "CATALOG_NOT_FOUND", + "surfaceId": "s", + "message": "has no default catalogId" + } + } + } + }, + { + "name": "test_v10_call_in_index_args_is_checked", + "description": "@index needs no catalog, but a call in its args is still resolved and checked. Here the offset calls notAFunction, which under strictMode: true is rejected because the surface default (the basic catalog) does not declare it.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + }, + "Tags": { + "type": "object", + "properties": { + "component": { + "const": "Tags" + }, + "items": { + "type": "array" + } + }, + "required": [ + "component", + "items" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "wrap": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "wrap" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "List", + "children": { + "path": "/items", + "componentId": "row" + } + }, + { + "id": "row", + "component": "Text", + "text": { + "@call": "@index", + "args": { + "offset": { + "@call": "notAFunction", + "args": {} + } + } + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Unrecognized function 'notAFunction'" + } + }, + { + "name": "test_v10_call_in_index_args_on_surface_without_default_errors", + "description": "On a surface without a default catalog, @index itself is accepted, but a call in its args that names no catalogId cannot be resolved.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + }, + "Tags": { + "type": "object", + "properties": { + "component": { + "const": "Tags" + }, + "items": { + "type": "array" + } + }, + "required": [ + "component", + "items" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "wrap": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "wrap" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "List", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "children": { + "path": "/items", + "componentId": "row" + } + }, + { + "id": "row", + "component": "Text", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "text": { + "@call": "@index", + "args": { + "offset": { + "@call": "formatNumber", + "args": { + "value": 1 + } + } + } + } + } + ] + } + } + ], + "expectError": { + "category": "CatalogError", + "surfaceId": "s", + "message": "has no default catalogId", + "rendererError": { + "version": "1.0", + "error": { + "code": "CATALOG_NOT_FOUND", + "surfaceId": "s", + "message": "has no default catalogId" + } + } + } + }, + { + "name": "test_v10_calls_in_lists_naming_their_catalog", + "description": "Calls inside a list property, and inside objects nested in that list, are resolved one by one; each names custom-v10, which declares shout.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + }, + "Tags": { + "type": "object", + "properties": { + "component": { + "const": "Tags" + }, + "items": { + "type": "array" + } + }, + "required": [ + "component", + "items" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "wrap": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "wrap" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Tags", + "catalogId": "custom-v10", + "items": [ + { + "@call": "shout", + "catalogId": "custom-v10", + "args": { + "value": "a" + } + }, + { + "nested": [ + { + "@call": "shout", + "catalogId": "custom-v10", + "args": { + "value": "b" + } + } + ] + } + ] + } + ] + } + } + ], + "expect": { + "surfaces": { + "s": { + "exists": true + } + } + } + }, + { + "name": "test_v10_call_in_list_without_catalog_id_uses_surface_default", + "description": "A call inside an object nested in a list property is resolved like any other call: it names no catalogId, so it resolves to the surface default (the basic catalog), which under strictMode: true rejects shout because the basic catalog does not declare it, even though the component and the list's first call use custom-v10.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + }, + "Tags": { + "type": "object", + "properties": { + "component": { + "const": "Tags" + }, + "items": { + "type": "array" + } + }, + "required": [ + "component", + "items" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "wrap": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "wrap" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Tags", + "catalogId": "custom-v10", + "items": [ + { + "@call": "shout", + "catalogId": "custom-v10", + "args": { + "value": "a" + } + }, + { + "nested": [ + { + "@call": "shout", + "args": { + "value": "b" + } + } + ] + } + ] + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Unrecognized function 'shout'" + } + }, + { + "name": "test_v10_call_in_list_with_invalid_args_errors", + "description": "A call inside an object nested in a list property has its args checked against the catalog it names; custom-v10's shout requires a string phrase.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + }, + "Tags": { + "type": "object", + "properties": { + "component": { + "const": "Tags" + }, + "items": { + "type": "array" + } + }, + "required": [ + "component", + "items" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "phrase": { + "type": "string" + } + }, + "required": [ + "phrase" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "wrap": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "wrap" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Tags", + "catalogId": "custom-v10", + "items": [ + { + "nested": [ + { + "@call": "shout", + "catalogId": "custom-v10", + "args": { + "phrase": 2 + } + } + ] + } + ] + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "phrase" + } + }, + { + "name": "test_v10_undefined_system_function_errors", + "description": "The @ namespace is reserved for system functions, and v1.0 defines only @index. A call to @foo is rejected rather than looked up in a catalog.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + }, + "Tags": { + "type": "object", + "properties": { + "component": { + "const": "Tags" + }, + "items": { + "type": "array" + } + }, + "required": [ + "component", + "items" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "wrap": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "wrap" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "@foo" + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "@foo" + } + }, + { + "name": "test_v10_function_call_with_empty_name_errors", + "description": "A function call's name must be a UAX #31 identifier. An empty name is not one, so the call is rejected rather than skipped.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "" + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "valid UAX #31 identifier" + } + }, + { + "name": "test_v10_call_without_catalog_id_on_surface_without_default_errors_without_strict_mode", + "description": "Catalog resolution does not depend on strict validation. With strictMode off, a call that names no catalogId on a surface without a default catalog is still rejected.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": false, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + }, + "Tags": { + "type": "object", + "properties": { + "component": { + "const": "Tags" + }, + "items": { + "type": "array" + } + }, + "required": [ + "component", + "items" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "wrap": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "wrap" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "text": { + "@call": "formatString", + "args": { + "value": "hi" + } + } + } + ] + } + } + ], + "expectError": { + "category": "CatalogError", + "surfaceId": "s", + "message": "has no default catalogId", + "rendererError": { + "version": "1.0", + "error": { + "code": "CATALOG_NOT_FOUND", + "surfaceId": "s", + "message": "has no default catalogId" + } + } + } + }, + { + "name": "test_v10_call_naming_unknown_catalog_errors_without_strict_mode", + "description": "Catalog resolution does not depend on strict validation. With strictMode off, a call that names a catalogId the client does not have is still rejected.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": false, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + }, + "Tags": { + "type": "object", + "properties": { + "component": { + "const": "Tags" + }, + "items": { + "type": "array" + } + }, + "required": [ + "component", + "items" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "wrap": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "wrap" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatString", + "catalogId": "missing-catalog", + "args": { + "value": "hi" + } + } + } + ] + } + } + ], + "expectError": { + "category": "CatalogError", + "message": "Catalog not found: missing-catalog" + } + }, + { + "name": "test_v10_call_to_undeclared_function_without_strict_mode_accepted", + "description": "With strictMode off (default renderer processing), a function call whose catalog resolves is accepted even when that local catalog does not declare the function, so the renderer can forward it to the agent via callAgentFunction.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": false, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "notAFunction", + "args": {} + } + } + ] + } + } + ], + "expect": { + "surfaces": { + "s": { + "exists": true + } + } + } + }, + { + "name": "test_v10_call_naming_catalog_of_other_protocol_version_errors", + "description": "Every catalog a v1.0 surface uses must be built for v1.0. A call that names legacy-v09, a v0.9 catalog the client has, cannot be resolved on a v1.0 surface. The message check only requires the error to name the catalog, since SDKs word the reason differently.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + }, + "Tags": { + "type": "object", + "properties": { + "component": { + "const": "Tags" + }, + "items": { + "type": "array" + } + }, + "required": [ + "component", + "items" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "wrap": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "wrap" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + }, + { + "catalogId": "legacy-v09", + "protocolVersion": "v0.9", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatString", + "catalogId": "legacy-v09", + "args": { + "value": "hi" + } + } + } + ] + } + } + ], + "expectError": { + "category": "CatalogError", + "message": "legacy-v09" + } + }, + { + "name": "test_v10_call_with_non_string_catalog_id_errors", + "description": "A call's catalogId must be a string. A number is rejected as invalid instead of being ignored, which would resolve the call to the surface default.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + }, + "Tags": { + "type": "object", + "properties": { + "component": { + "const": "Tags" + }, + "items": { + "type": "array" + } + }, + "required": [ + "component", + "items" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "wrap": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "wrap" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatString", + "catalogId": 3, + "args": { + "value": "hi" + } + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError" + } + }, + { + "name": "test_v10_component_naming_catalog_is_checked_against_that_catalog", + "description": "A component that names a catalogId is validated against that catalog, not the surface default. custom-v10 declares no Text, although the default (the basic catalog) does.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + }, + "Tags": { + "type": "object", + "properties": { + "component": { + "const": "Tags" + }, + "items": { + "type": "array" + } + }, + "required": [ + "component", + "items" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "wrap": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "wrap" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "catalogId": "custom-v10", + "text": "Hello" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Text" + } + }, + { + "name": "test_v10_component_without_catalog_id_errors_with_a_single_catalog", + "description": "There is no fallback to the catalogs the client supports, even when the case supplies only one. A component that names no catalogId on a surface without a default catalog is rejected.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello" + } + ] + } + } + ], + "expectError": { + "category": "CatalogError", + "surfaceId": "s", + "message": "has no default catalogId", + "rendererError": { + "version": "1.0", + "error": { + "code": "CATALOG_NOT_FOUND", + "surfaceId": "s", + "message": "has no default catalogId" + } + } + } + }, + { + "name": "test_v10_get_renderer_data_model_includes_surface_without_default", + "description": "A v1.0 surface created without a default catalog keeps the protocol version of the message that created it, so it is part of a v1.0 data model request. Its data model is empty.", + "action": "get_renderer_data_model", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "sendDataModel": true + } + } + ], + "args": { + "version": "1.0" + }, + "expect": { + "version": "1.0", + "surfaces": { + "s": {} + } + } + }, + { + "name": "test_v10_get_renderer_data_model_excludes_surface_without_default_from_v09", + "description": "A v1.0 surface created without a default catalog is not part of a v0.9 data model request, so the request finds no surface to report.", + "action": "get_renderer_data_model", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "sendDataModel": true + } + } + ], + "args": { + "version": "v0.9" + }, + "expect": null + }, + { + "name": "test_v10_get_renderer_data_model_filters_by_surface_protocol_version", + "description": "With a v1.0 surface that has no default catalog and a v0.9 surface side by side, a v0.9 data model request reports only the v0.9 surface. The surfaces are created in separate steps, one batch per protocol version.", + "action": "get_renderer_data_model", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json", + "specification/v0_9/catalogs/basic/catalog.json" + ], + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "sendDataModel": true + } + } + ] + }, + { + "messages": [ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "t", + "catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json", + "sendDataModel": true + } + } + ] + } + ], + "args": { + "version": "v0.9" + }, + "expect": { + "version": "v0.9", + "surfaces": { + "t": {} + } + } + }, + { + "name": "test_v10_update_components_stores_component_metadata", + "description": "Tests that v1.0 updateComponents keeps ComponentCommon.metadata apart from component properties and exposes it as component metadata.", + "action": "process_messages", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_v10_comp_meta", + "catalogId": "test-catalog-v10", + "metadata": { + "extensions": { + "com_example_session": "abc" + } + } + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_v10_comp_meta", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello", + "metadata": { + "extensions": { + "com_example_trace": "t1" + } + } + } + ] + } + } + ], + "expect": { + "surfaces": { + "s_v10_comp_meta": { + "exists": true, + "metadata": { + "extensions": { + "com_example_session": "abc" + } + }, + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello", + "metadata": { + "extensions": { + "com_example_trace": "t1" + } + } + } + ] + } + } + } + }, + { + "name": "test_v10_update_components_replaces_component_metadata", + "description": "Tests that a later v1.0 updateComponents entry replaces a component's metadata in full rather than merging it.", + "action": "process_messages", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_v10_comp_meta_replace", + "catalogId": "test-catalog-v10" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_v10_comp_meta_replace", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello", + "metadata": { + "extensions": { + "com_example_trace": "t1", + "com_example_state": "draft" + } + } + } + ] + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_v10_comp_meta_replace", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello", + "metadata": { + "extensions": { + "com_example_state": "final" + } + } + } + ] + } + } + ], + "expect": { + "surfaces": { + "s_v10_comp_meta_replace": { + "exists": true, + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello", + "metadata": { + "extensions": { + "com_example_state": "final" + } + } + } + ] + } + } + } + }, + { + "name": "test_v10_update_components_omitted_metadata_clears_component_metadata", + "description": "Tests that a v1.0 updateComponents entry without metadata clears the component's previously stored metadata, because an update replaces the definition in full.", + "action": "process_messages", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_v10_comp_meta_clear", + "catalogId": "test-catalog-v10" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_v10_comp_meta_clear", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello", + "metadata": { + "extensions": { + "com_example_trace": "t1" + } + } + } + ] + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_v10_comp_meta_clear", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello" + } + ] + } + } + ], + "expect": { + "surfaces": { + "s_v10_comp_meta_clear": { + "exists": true, + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello", + "metadata": null + } + ] + } + } + } + }, + { + "name": "test_v10_update_components_metadata_extension_key_must_be_identifier", + "description": "Tests that v1.0 updateComponents rejects component metadata extensions whose key is not a Unicode identifier (UAX 31) when the component is first created.", + "action": "process_messages", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_v10_comp_meta_bad_key", + "catalogId": "test-catalog-v10" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_v10_comp_meta_bad_key", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello", + "metadata": { + "extensions": { + "com.example.trace": "t1" + } + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError" + } + }, + { + "name": "test_v10_update_components_metadata_extension_key_must_be_identifier_on_update", + "description": "Tests that v1.0 updateComponents rejects an invalid UAX 31 extension key on an existing component and leaves the previously stored metadata untouched.", + "action": "process_messages", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_v10_comp_meta_bad_key_update", + "catalogId": "test-catalog-v10" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_v10_comp_meta_bad_key_update", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello", + "metadata": { + "extensions": { + "com_example_trace": "t1" + } + } + } + ] + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_v10_comp_meta_bad_key_update", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello", + "metadata": { + "extensions": { + "com-example-trace": "t2" + } + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError" + } + }, + { + "name": "test_v10_permissive_incremental_streaming_out_of_order_before_root", + "description": "Multi-step strictMode: false incremental streaming case verifying that out-of-order child components arriving before root emit zero errors and assemble cleanly once root arrives.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": false, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_stream", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_stream", + "components": [ + { + "id": "leaf_text", + "component": "Text", + "text": "Streamed Leaf" + } + ] + } + } + ], + "expect": { + "surfaces": { + "s_stream": { + "exists": true, + "components": [ + { + "id": "leaf_text", + "component": "Text", + "text": "Streamed Leaf" + } + ] + } + } + } + }, + { + "messages": [ + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_stream", + "components": [ + { + "id": "mid_card", + "component": "Card", + "child": "leaf_text" + } + ] + } + } + ], + "expect": { + "surfaces": { + "s_stream": { + "exists": true, + "components": [ + { + "id": "leaf_text", + "component": "Text", + "text": "Streamed Leaf" + }, + { + "id": "mid_card", + "component": "Card", + "child": "leaf_text" + } + ] + } + } + } + }, + { + "messages": [ + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_stream", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "mid_card" + ] + } + ] + } + } + ], + "expect": { + "surfaces": { + "s_stream": { + "exists": true, + "components": [ + { + "id": "leaf_text", + "component": "Text", + "text": "Streamed Leaf" + }, + { + "id": "mid_card", + "component": "Card", + "child": "leaf_text" + }, + { + "id": "root", + "component": "Column", + "children": [ + "mid_card" + ] + } + ] + } + } + } + } + ] + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/multi_catalog.json b/package/__fixtures__/a2ui-conformance/core/multi_catalog.json new file mode 100644 index 0000000000..d12fcec8f1 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/multi_catalog.json @@ -0,0 +1,237 @@ +[ + { + "name": "test_multi_catalog_explicit_catalog_id_resolution", + "description": "Verifies resolving component definition using explicit catalogId specified on component instance.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "select_catalog", + "args": { + "surface": { + "id": "main_surface", + "defaultCatalogId": "basic", + "supportedCatalogIds": [ + "basic", + "chart_catalog" + ] + }, + "components": { + "c1": { + "component": "Text", + "catalogId": "basic", + "text": "Default Catalog Component" + }, + "c2": { + "component": "LineChart", + "catalogId": "chart_catalog", + "data": "/series/line1" + } + } + }, + "expectSelected": "chart_catalog" + }, + { + "name": "test_multi_catalog_surface_default_fallback", + "description": "Verifies component falling back to surface defaultCatalogId when catalogId is omitted.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "select_catalog", + "args": { + "surface": { + "id": "main_surface", + "defaultCatalogId": "basic", + "supportedCatalogIds": [ + "basic", + "chart_catalog" + ] + }, + "components": { + "c1": { + "component": "Button", + "title": "Click Me" + } + } + }, + "expectSelected": "basic" + }, + { + "name": "test_multi_catalog_unregistered_catalog_error", + "description": "Verifies error thrown when component specifies catalogId not listed in surface supportedCatalogIds.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "select_catalog", + "args": { + "surface": { + "id": "main_surface", + "defaultCatalogId": "basic", + "supportedCatalogIds": [ + "basic" + ] + }, + "components": { + "c1": { + "component": "CustomWidget", + "catalogId": "unregistered_vendor_catalog" + } + } + }, + "expectError": { + "category": "CatalogError", + "message": "Catalog 'unregistered_vendor_catalog' is not supported by surface 'main_surface'." + } + }, + { + "name": "test_multi_catalog_version_mismatch_error", + "description": "Verifies error thrown when mixed catalogs specify conflicting protocol versions.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "select_catalog", + "args": { + "surface": { + "id": "main_surface", + "defaultCatalogId": "basic_v1_0", + "supportedCatalogIds": [ + "basic_v1_0", + "legacy_v0_9_catalog" + ] + }, + "catalogs": { + "basic_v1_0": { + "protocolVersion": "1.0" + }, + "legacy_v0_9_catalog": { + "protocolVersion": "v0.9" + } + } + }, + "expectError": { + "category": "CatalogError", + "message": "Protocol version mismatch: cannot mix catalog 'legacy_v0_9_catalog' (v0.9) with surface version 1.0." + } + }, + { + "name": "test_multi_catalog_function_resolution_precedence", + "description": "Verifies function calls with explicit catalogId resolve to the specified catalog.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "select_catalog", + "args": { + "surface": { + "id": "main_surface", + "defaultCatalogId": "basic", + "supportedCatalogIds": [ + "basic", + "custom_math_catalog" + ] + }, + "functionCall": { + "call": "add", + "catalogId": "custom_math_catalog", + "args": { + "a": 5, + "b": 10 + } + } + }, + "expectSelected": "custom_math_catalog" + }, + { + "name": "test_multi_catalog_cross_catalog_component_composition", + "description": "Verifies creating a surface with multiple supportedCatalogIds and rendering components from distinct catalogs in a single hierarchy.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "select_catalog", + "args": { + "surface": { + "id": "main_surface", + "defaultCatalogId": "basic", + "supportedCatalogIds": [ + "basic", + "chart_catalog", + "map_catalog" + ] + }, + "components": { + "c1": { + "component": "Text", + "catalogId": "basic", + "text": "Dashboard" + }, + "c2": { + "component": "BarChart", + "catalogId": "chart_catalog" + }, + "c3": { + "component": "GeoMap", + "catalogId": "map_catalog" + } + } + }, + "expectSelected": "map_catalog" + }, + { + "name": "test_multi_catalog_function_call_catalog_id_override", + "description": "Verifies a function call naming a non-default catalogId is evaluated by that catalog.", + "action": "validate", + "catalogs": [ + { + "catalogId": "app-cat", + "protocolVersion": "1.0", + "components": { + "Text": { + "type": "object", + "properties": { + "text": { + "$ref": "#/$defs/DynamicString" + } + } + } + } + } + ], + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "app-cat", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatDate", + "catalogId": "basic", + "args": { + "value": "2026-09-04T12:00:00Z", + "format": "yyyy-MM-dd" + } + } + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "main": { + "components": { + "root": { + "component": "Text", + "text": "2026-09-04" + } + } + } + } + } + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/reserved_keys.json b/package/__fixtures__/a2ui-conformance/core/reserved_keys.json new file mode 100644 index 0000000000..44b13ef899 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/reserved_keys.json @@ -0,0 +1,303 @@ +[ + { + "name": "test_reserved_at_path_valid", + "description": "Valid v1.0 DataBinding using the reserved @path prefix.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@path": "/user/displayName" + } + } + ] + } + } + ] + }, + { + "name": "test_reserved_at_call_valid", + "description": "Valid v1.0 FunctionCall using the reserved @call prefix.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatString", + "args": { + "value": "Hello world" + } + } + } + ] + } + } + ] + }, + { + "name": "test_reserved_at_call_index", + "description": "Valid v1.0 FunctionCall invoking the @index system context function within a List template.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "components": [ + { + "id": "root", + "component": "List", + "children": { + "componentId": "itemTemplate", + "path": "/items" + } + }, + { + "id": "itemTemplate", + "component": "Text", + "text": { + "@call": "@index", + "args": { + "offset": 1 + } + } + } + ] + } + } + ] + }, + { + "name": "test_plain_object_with_literal_path_and_call", + "description": "A plain object containing 'path' and 'call' keys is valid in v1.0 and is not intercepted as a dynamic DataBinding or FunctionCall directive.", + "catalog": { + "catalogId": "custom", + "protocolVersion": "1.0", + "components": { + "CustomCard": { + "type": "object", + "properties": { + "details": { + "$ref": "common_types.json#/$defs/DynamicValue" + } + } + } + } + }, + "action": "validate", + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom", + "components": [ + { + "id": "root", + "component": "CustomCard", + "details": { + "path": "/var/log/system.log", + "call": "audit" + } + } + ] + } + } + ] + }, + { + "name": "test_plain_object_escaped_doubled_at_key", + "description": "Literal @ keys escaped with prefix doubling (@@path, @@type) validate successfully as plain object keys.", + "catalog": { + "catalogId": "custom", + "protocolVersion": "1.0", + "components": { + "CustomCard": { + "type": "object", + "properties": { + "details": { + "$ref": "common_types.json#/$defs/DynamicValue" + } + } + } + } + }, + "action": "validate", + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom", + "components": [ + { + "id": "root", + "component": "CustomCard", + "details": { + "@@path": "/static/file", + "@@type": "Resource" + } + } + ] + } + } + ] + }, + { + "name": "test_disallowed_unknown_single_at_key", + "description": "Dynamic objects containing unknown single-@ keys (^@([^@]|$)) are disallowed to preserve backward compatibility for future directives.", + "catalog": { + "catalogId": "custom", + "protocolVersion": "1.0", + "components": { + "CustomCard": { + "type": "object", + "properties": { + "details": { + "$ref": "common_types.json#/$defs/DynamicValue" + } + } + } + } + }, + "action": "validate", + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom", + "components": [ + { + "id": "root", + "component": "CustomCard", + "details": { + "@if": true, + "then": "active" + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError" + } + }, + { + "name": "test_disallowed_bare_at_key", + "description": "A key consisting of a single @ with no following character is also reserved and disallowed. It must be escaped as \"@@\" to appear as literal data.", + "catalog": { + "catalogId": "custom", + "protocolVersion": "1.0", + "components": { + "CustomCard": { + "type": "object", + "properties": { + "details": { + "$ref": "common_types.json#/$defs/DynamicValue" + } + } + } + } + }, + "action": "validate", + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom", + "components": [ + { + "id": "root", + "component": "CustomCard", + "details": { + "@": "reserved" + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError" + } + }, + { + "name": "test_escaped_doubled_at_unescaping", + "description": "Verifies that doubled-@ keys ('@@path') are unescaped to '@path' during dynamic evaluation in v1.0.", + "catalog": { + "catalogId": "custom", + "protocolVersion": "1.0", + "components": { + "CustomCard": { + "type": "object", + "properties": { + "details": { + "$ref": "common_types.json#/$defs/DynamicValue" + } + } + } + } + }, + "action": "process_messages", + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom", + "components": [ + { + "id": "root", + "component": "CustomCard", + "details": { + "@@path": "/static/file" + } + } + ] + } + } + ], + "expect": { + "surfaces": { + "s1": { + "components": [ + { + "id": "root", + "component": "CustomCard", + "details": { + "@path": "/static/file" + } + } + ] + } + } + } + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/rpc_functions.json b/package/__fixtures__/a2ui-conformance/core/rpc_functions.json new file mode 100644 index 0000000000..873b662f45 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/rpc_functions.json @@ -0,0 +1,908 @@ +[ + { + "name": "test_rpc_call_renderer_function_success", + "description": "Verifies agent-initiated callRendererFunction on a function with allowedCallers=rendererOrAgent.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-101", + "callFunction": { + "@call": "playMedia", + "catalogId": "media_catalog", + "args": { + "mediaId": "video-789", + "autoplay": true + } + } + } + }, + "functionMetadata": { + "playMedia": { + "allowedCallers": "rendererOrAgent", + "requiresUserActivation": false + } + } + }, + "expect": { + "response": { + "version": "1.0", + "rendererFunctionResponse": { + "functionCallId": "call-101", + "value": { + "playing": true, + "timestamp": 0 + } + } + } + } + }, + { + "name": "test_rpc_call_renderer_function_unauthorized_caller", + "description": "Verifies callRendererFunction fails with INVALID_FUNCTION_CALL when allowedCallers is rendererOnly.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-102", + "callFunction": { + "@call": "internalStateReset", + "catalogId": "system_catalog", + "args": {} + } + } + }, + "functionMetadata": { + "internalStateReset": { + "allowedCallers": "rendererOnly", + "requiresUserActivation": false + } + } + }, + "expect": { + "response": { + "version": "1.0", + "rendererFunctionResponse": { + "functionCallId": "call-102", + "error": { + "code": "INVALID_FUNCTION_CALL", + "message": "Function 'internalStateReset' cannot be called by agent (allowedCallers is rendererOnly)." + } + } + } + } + }, + { + "name": "test_rpc_call_renderer_function_requires_user_activation", + "description": "Verifies callRendererFunction execution when requiresUserActivation is true and user gesture is present.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "userActivationPresent": true, + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-103", + "callFunction": { + "@call": "openExternalUrl", + "catalogId": "basic", + "args": { + "url": "https://example.com/details" + } + } + } + }, + "functionMetadata": { + "openExternalUrl": { + "allowedCallers": "rendererOrAgent", + "requiresUserActivation": true + } + } + }, + "expect": { + "response": { + "version": "1.0", + "rendererFunctionResponse": { + "functionCallId": "call-103", + "value": { + "opened": true + } + } + } + } + }, + { + "name": "test_rpc_call_agent_function_dispatch", + "description": "Verifies renderer-initiated callAgentFunction message generation and response correlation via functionCallId.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "outboundCall": { + "surfaceId": "main_surface", + "functionCallId": "agent-call-55", + "callFunction": { + "@call": "queryInventory", + "catalogId": "store_catalog", + "args": { + "sku": "ITEM-404" + } + } + }, + "inboundResponse": { + "version": "1.0", + "agentFunctionResponse": { + "functionCallId": "agent-call-55", + "value": { + "inStock": 12, + "price": "$29.99" + } + } + } + }, + "expect": { + "correlatedCallId": "agent-call-55", + "result": { + "inStock": 12, + "price": "$29.99" + } + } + }, + { + "name": "test_rpc_call_renderer_function_user_activation_denied", + "description": "Verifies callRendererFunction fails with INVALID_FUNCTION_CALL when requiresUserActivation is true but user gesture context is absent.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "userActivationPresent": false, + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-104", + "callFunction": { + "@call": "openExternalUrl", + "catalogId": "basic", + "args": { + "url": "https://example.com/details" + } + } + } + }, + "functionMetadata": { + "openExternalUrl": { + "allowedCallers": "rendererOrAgent", + "requiresUserActivation": true + } + } + }, + "expect": { + "response": { + "version": "1.0", + "rendererFunctionResponse": { + "functionCallId": "call-104", + "error": { + "code": "INVALID_FUNCTION_CALL", + "message": "Function 'openExternalUrl' requires user activation context to execute." + } + } + } + } + }, + { + "name": "test_rpc_call_renderer_function_execution_exception", + "description": "Verifies callRendererFunction handles runtime function execution exceptions and returns EXECUTION_ERROR.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-105", + "callFunction": { + "@call": "failingFunction", + "catalogId": "basic", + "args": {} + } + } + }, + "functionMetadata": { + "failingFunction": { + "allowedCallers": "rendererOrAgent", + "requiresUserActivation": false + } + } + }, + "expect": { + "response": { + "version": "1.0", + "rendererFunctionResponse": { + "functionCallId": "call-105", + "error": { + "code": "EXECUTION_ERROR", + "message": "An error occurred during function execution." + } + } + } + } + }, + { + "name": "test_rpc_call_renderer_function_agent_only_boundary", + "description": "Verifies agent-initiated callRendererFunction on a function with allowedCallers=agentOnly.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-106", + "callFunction": { + "@call": "syncState", + "catalogId": "basic", + "args": {} + } + } + }, + "functionMetadata": { + "syncState": { + "allowedCallers": "agentOnly", + "requiresUserActivation": false + } + } + }, + "expect": { + "response": { + "version": "1.0", + "rendererFunctionResponse": { + "functionCallId": "call-106", + "value": null + } + } + } + }, + { + "name": "test_rpc_call_renderer_function_unregistered_function", + "description": "Verifies callRendererFunction fails with INVALID_FUNCTION_CALL when calling an unregistered function.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-107", + "callFunction": { + "@call": "unknownFunc", + "catalogId": "basic", + "args": {} + } + } + }, + "functionMetadata": {} + }, + "expect": { + "response": { + "version": "1.0", + "rendererFunctionResponse": { + "functionCallId": "call-107", + "error": { + "code": "INVALID_FUNCTION_CALL", + "message": "Function not found: unknownFunc" + } + } + } + } + }, + { + "name": "test_rpc_call_renderer_function_unregistered_catalog", + "description": "Verifies callRendererFunction fails with INVALID_FUNCTION_CALL when specifying an unregistered catalogId.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-108", + "callFunction": { + "@call": "playMedia", + "catalogId": "non_existent_catalog", + "args": {} + } + } + }, + "functionMetadata": { + "playMedia": { + "allowedCallers": "rendererOrAgent" + } + } + }, + "expect": { + "response": { + "version": "1.0", + "rendererFunctionResponse": { + "functionCallId": "call-108", + "error": { + "code": "INVALID_FUNCTION_CALL", + "message": "Catalog not found: non_existent_catalog" + } + } + } + } + }, + { + "name": "test_rpc_call_renderer_function_invalid_payload_missing_call_function", + "description": "Verifies envelope/schema validation error when callRendererFunction is missing required callFunction property.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-109" + } + } + }, + "expect": { + "error": { + "code": "INVALID_FUNCTION_CALL", + "functionCallId": "call-109", + "message": "callFunction" + } + }, + "expectError": { + "category": "ValidationError", + "functionCallId": "call-109", + "rendererError": { + "version": "1.0", + "error": { + "code": "INVALID_FUNCTION_CALL", + "functionCallId": "call-109", + "message": "callFunction" + } + } + } + }, + { + "name": "test_rpc_call_renderer_function_invalid_payload_non_object_args", + "description": "Verifies envelope/schema validation error when callRendererFunction has non-object args.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-110", + "callFunction": { + "@call": "playMedia", + "catalogId": "media_catalog", + "args": "not-an-object" + } + } + } + }, + "expect": { + "error": { + "code": "INVALID_FUNCTION_CALL", + "functionCallId": "call-110", + "message": "args" + } + }, + "expectError": { + "category": "ValidationError", + "functionCallId": "call-110", + "rendererError": { + "version": "1.0", + "error": { + "code": "INVALID_FUNCTION_CALL", + "functionCallId": "call-110", + "message": "args" + } + } + } + }, + { + "name": "test_rpc_call_renderer_function_unknown_surface_id", + "description": "Verifies callRendererFunction targeting an unknown surfaceId returns rendererFunctionResponse with INVALID_FUNCTION_CALL.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-111", + "surfaceId": "unknown_surface", + "callFunction": { + "@call": "playMedia", + "catalogId": "media_catalog", + "args": { + "mediaId": "video-789" + } + } + } + }, + "functionMetadata": { + "playMedia": { + "allowedCallers": "rendererOrAgent" + } + } + }, + "expect": { + "response": { + "version": "1.0", + "rendererFunctionResponse": { + "functionCallId": "call-111", + "error": { + "code": "INVALID_FUNCTION_CALL", + "message": "unknown_surface" + } + } + } + } + }, + { + "name": "test_rpc_call_renderer_function_invalid_payload_missing_catalog_id", + "description": "Verifies a callRendererFunction whose callFunction has no catalogId fails schema validation, even when a held catalog defines the function.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-110", + "callFunction": { + "@call": "playMedia", + "args": { + "mediaId": "video-789" + } + } + } + }, + "functionMetadata": { + "playMedia": { + "allowedCallers": "rendererOrAgent", + "requiresUserActivation": false + } + } + }, + "expect": { + "error": { + "message": "catalogId" + } + } + }, + { + "name": "test_rpc_agent_function_response_inbound", + "description": "Verifies inbound agentFunctionResponse message processing by MessageProcessor.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "message": { + "version": "1.0", + "agentFunctionResponse": { + "functionCallId": "call-201", + "value": { + "status": "verified" + } + } + } + }, + "expect": { + "response": null + } + }, + { + "name": "test_rpc_call_renderer_function_invalid_argument_schema", + "description": "Verifies callRendererFunction returns INVALID_FUNCTION_CALL when argument schema validation fails.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-301", + "callFunction": { + "@call": "calculateTax", + "catalogId": "basic", + "args": { + "amount": "invalid_string_amount" + } + } + } + }, + "functionMetadata": { + "calculateTax": { + "allowedCallers": "rendererOrAgent", + "parameters": { + "type": "object", + "properties": { + "amount": { + "type": "number" + } + }, + "required": [ + "amount" + ] + } + } + } + }, + "expect": { + "response": { + "version": "1.0", + "rendererFunctionResponse": { + "functionCallId": "call-301", + "error": { + "code": "INVALID_FUNCTION_CALL", + "message": "Invalid" + } + } + } + } + }, + { + "name": "test_rpc_call_renderer_function_protocol_version_mismatch", + "description": "Verifies callRendererFunction returns INVALID_FUNCTION_CALL when catalog protocol version mismatches message protocol version.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "catalogId": "legacy_cat", + "catalogVersion": "v0.9", + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-302", + "callFunction": { + "@call": "playMedia", + "catalogId": "legacy_cat", + "args": { + "mediaId": "vid1" + } + } + } + }, + "functionMetadata": { + "playMedia": { + "allowedCallers": "rendererOrAgent" + } + } + }, + "expect": { + "response": { + "version": "1.0", + "rendererFunctionResponse": { + "functionCallId": "call-302", + "error": { + "code": "INVALID_FUNCTION_CALL", + "message": "does not match message protocol version" + } + } + } + } + }, + { + "name": "test_rpc_call_agent_function_timeout_handling", + "description": "Verifies callAgentFunction timeout emitting TIMEOUT error code.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "outboundCall": { + "surfaceId": "main_surface", + "functionCallId": "call-timeout-1", + "timeoutMs": 10, + "callFunction": { + "@call": "longRunningTask", + "catalogId": "basic", + "args": {} + } + } + }, + "expectError": { + "code": "TIMEOUT" + } + }, + { + "name": "test_rpc_call_agent_function_duplicate_call_id_error", + "description": "Verifies callAgentFunction rejecting duplicate pending functionCallId with DUPLICATE error code.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "outboundCall": { + "surfaceId": "main_surface", + "functionCallId": "call-dup-1", + "callFunction": { + "@call": "fetchData", + "catalogId": "basic", + "args": {} + } + }, + "secondOutboundCall": { + "surfaceId": "main_surface", + "functionCallId": "call-dup-1", + "callFunction": { + "@call": "fetchData", + "catalogId": "basic", + "args": {} + } + } + }, + "expectError": { + "code": "DUPLICATE" + } + }, + { + "name": "test_rpc_call_renderer_function_with_surface_id_and_dynamic_args", + "description": "Verifies callRendererFunction with surfaceId resolves @path and @call in callFunction.args against the target surface's root DataContext (/).", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "surfaceId": "media_surface", + "dataModel": { + "media": { + "id": "video-456" + }, + "prefix": "now-" + }, + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-401", + "surfaceId": "media_surface", + "callFunction": { + "@call": "echoArgs", + "catalogId": "media_catalog", + "args": { + "mediaId": { + "@path": "/media/id" + }, + "formatted": { + "@call": "formatString", + "catalogId": "basic", + "args": { + "value": "${/prefix}${/media/id}" + } + } + } + } + } + }, + "functionMetadata": { + "echoArgs": { + "allowedCallers": "rendererOrAgent", + "requiresUserActivation": false + } + } + }, + "expect": { + "response": { + "version": "1.0", + "rendererFunctionResponse": { + "functionCallId": "call-401", + "value": { + "mediaId": "video-456", + "formatted": "now-video-456" + } + } + } + } + }, + { + "name": "test_rpc_call_renderer_function_without_surface_id_isolated_data_model", + "description": "Verifies that when surfaceId is omitted on callRendererFunction, @path in callFunction.args resolves against an isolated empty {} data model even when a surface with data exists.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "surfaceId": "existing_surface", + "dataModel": { + "secret": "should-not-leak" + }, + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-402", + "callFunction": { + "@call": "echoArgs", + "catalogId": "media_catalog", + "args": { + "leaked": { + "@path": "/secret" + }, + "constant": "safe" + } + } + } + }, + "functionMetadata": { + "echoArgs": { + "allowedCallers": "rendererOrAgent", + "requiresUserActivation": false + } + } + }, + "expect": { + "response": { + "version": "1.0", + "rendererFunctionResponse": { + "functionCallId": "call-402", + "value": { + "leaked": null, + "constant": "safe" + } + } + } + } + }, + { + "name": "test_rpc_call_renderer_function_index_at_root_execution_error", + "description": "Verifies that evaluating @index at the root DataContext in callRendererFunction fails with EXECUTION_ERROR.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "surfaceId": "main_surface", + "dataModel": {}, + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-403", + "surfaceId": "main_surface", + "callFunction": { + "@call": "echoArgs", + "catalogId": "basic", + "args": { + "idx": { + "@call": "@index" + } + } + } + } + }, + "functionMetadata": { + "echoArgs": { + "allowedCallers": "rendererOrAgent", + "requiresUserActivation": false + } + } + }, + "expect": { + "response": { + "version": "1.0", + "rendererFunctionResponse": { + "functionCallId": "call-403", + "error": { + "code": "EXECUTION_ERROR", + "message": "index" + } + } + } + } + }, + { + "name": "test_rpc_ui_binding_triggers_call_agent_function_async_resolution", + "description": "Verifies a component property bound to an agent function initially resolves to null/undefined while emitting outbound callAgentFunction, and updates reactively when agentFunctionResponse arrives.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "uiBindingScenario": { + "surfaceId": "main_surface", + "catalogId": "store_catalog", + "component": { + "id": "root", + "component": "Text", + "text": { + "@call": "fetchRemoteGreeting", + "catalogId": "store_catalog", + "args": { + "user": "Ada" + } + } + }, + "inboundResponseValue": "Hello from agent, Ada!" + } + }, + "expect": { + "initialValue": null, + "outboundCall": { + "version": "1.0", + "callAgentFunction": { + "surfaceId": "main_surface", + "callFunction": { + "@call": "fetchRemoteGreeting", + "catalogId": "store_catalog", + "args": { + "user": "Ada" + } + } + } + }, + "resolvedValue": "Hello from agent, Ada!" + } + }, + { + "name": "test_rpc_ui_action_missing_local_function_fallback_to_agent", + "description": "Verifies an undeclared function invoked from a UI action in relaxed mode falls back to dispatching callAgentFunction to the agent.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "uiActionFallbackScenario": { + "surfaceId": "main_surface", + "catalogId": "store_catalog", + "actionPayload": { + "functionCall": { + "@call": "undeclaredAgentAction", + "catalogId": "store_catalog", + "args": { + "orderId": "ord-99" + } + } + }, + "inboundResponseValue": { + "accepted": true + } + } + }, + "expect": { + "outboundCall": { + "version": "1.0", + "callAgentFunction": { + "surfaceId": "main_surface", + "callFunction": { + "@call": "undeclaredAgentAction", + "catalogId": "store_catalog", + "args": { + "orderId": "ord-99" + } + } + } + }, + "result": { + "accepted": true + } + } + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/validation_result.json b/package/__fixtures__/a2ui-conformance/core/validation_result.json new file mode 100644 index 0000000000..14ebd7c363 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/validation_result.json @@ -0,0 +1,599 @@ +[ + { + "name": "test_validation_result_dynamic_object_return", + "description": "Verifies CheckRule evaluation capturing dynamic ValidationResult object returned from custom validation function.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "form_surface", + "catalogId": "basic", + "dataModel": { + "email": "invalid-email-address" + }, + "components": [ + { + "id": "root", + "component": "TextField", + "label": "Email", + "value": { + "@path": "/email" + }, + "checks": [ + { + "condition": { + "@call": "email", + "args": { + "value": { + "@path": "/email" + } + } + } + } + ] + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "form_surface": { + "isValid": { + "root": false + }, + "validationErrors": { + "root": [ + "Please enter a valid email address (e.g. user@example.com)." + ] + }, + "validationResults": { + "root": [ + { + "valid": false, + "code": "INVALID_EMAIL", + "message": "Please enter a valid email address (e.g. user@example.com).", + "severity": "error" + } + ] + }, + "validationResult": { + "root": { + "valid": false, + "code": "INVALID_EMAIL", + "message": "Please enter a valid email address (e.g. user@example.com).", + "severity": "error" + } + } + } + } + } + }, + { + "name": "test_validation_result_boolean_fallback", + "description": "Verifies CheckRule evaluation when validation function returns primitive boolean, falling back to CheckRule message.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "form_surface", + "catalogId": "basic", + "dataModel": { + "age": 15 + }, + "components": [ + { + "id": "root", + "component": "TextField", + "label": "Age", + "value": { + "@path": "/age" + }, + "checks": [ + { + "condition": { + "@call": "numeric", + "args": { + "value": { + "@path": "/age" + }, + "min": 18 + } + }, + "message": "Must be 18 or older." + } + ] + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "form_surface": { + "isValid": { + "root": false + }, + "validationErrors": { + "root": [ + "Must be 18 or older." + ] + }, + "validationResults": { + "root": [ + { + "valid": false, + "message": "Must be 18 or older.", + "severity": "error" + } + ] + }, + "validationResult": { + "root": { + "valid": false, + "message": "Must be 18 or older." + } + } + } + } + } + }, + { + "name": "test_validation_result_warning_and_info_non_blocking", + "description": "Verifies that CheckRules returning severity warning or info populate validationResults while keeping isValid true and validationErrors empty.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "form_surface", + "catalogId": "basic", + "dataModel": { + "password": "secret", + "warningCheck": { + "valid": false, + "code": "WEAK_PASSWORD", + "message": "Password could be stronger.", + "severity": "warning" + }, + "infoCheck": { + "valid": false, + "code": "PASSWORD_HINT", + "message": "Consider adding symbols.", + "severity": "info" + } + }, + "components": [ + { + "id": "root", + "component": "TextField", + "label": "Password", + "value": { + "@path": "/password" + }, + "checks": [ + { + "condition": { + "@path": "/warningCheck" + } + }, + { + "condition": { + "@path": "/infoCheck" + } + } + ] + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "form_surface": { + "isValid": { + "root": true + }, + "validationErrors": { + "root": [] + }, + "validationResults": { + "root": [ + { + "valid": false, + "code": "WEAK_PASSWORD", + "message": "Password could be stronger.", + "severity": "warning" + }, + { + "valid": false, + "code": "PASSWORD_HINT", + "message": "Consider adding symbols.", + "severity": "info" + } + ] + } + } + } + } + }, + { + "name": "test_validation_result_multiple_rules_preserve_order", + "description": "Verifies that a component with two failing error rules and one failing warning rule preserves declaration order in validationErrors and validationResults.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "form_surface", + "catalogId": "basic", + "dataModel": { + "email": "bad", + "warningCheck": { + "valid": false, + "code": "DOMAIN_WARNING", + "message": "External domain requires approval.", + "severity": "warning" + } + }, + "components": [ + { + "id": "root", + "component": "TextField", + "label": "Corporate Email", + "value": { + "@path": "/email" + }, + "checks": [ + { + "condition": { + "@call": "email", + "args": { + "value": { + "@path": "/email" + } + } + }, + "message": "Must be a valid email address." + }, + { + "condition": { + "@path": "/warningCheck" + } + }, + { + "condition": { + "@call": "length", + "args": { + "value": { + "@path": "/email" + }, + "min": 10 + } + }, + "message": "Email must be at least 10 characters." + } + ] + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "form_surface": { + "isValid": { + "root": false + }, + "validationErrors": { + "root": [ + "Must be a valid email address.", + "Email must be at least 10 characters." + ] + }, + "validationResults": { + "root": [ + { + "valid": false, + "code": "INVALID_EMAIL", + "message": "Must be a valid email address.", + "severity": "error" + }, + { + "valid": false, + "code": "DOMAIN_WARNING", + "message": "External domain requires approval.", + "severity": "warning" + }, + { + "valid": false, + "message": "Email must be at least 10 characters.", + "severity": "error" + } + ] + } + } + } + } + }, + { + "name": "test_validation_result_reactive_recovery_on_data_update", + "description": "Verifies that failing validation recovers reactively when updateDataModel provides a valid value in a subsequent step.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "form_surface", + "catalogId": "basic", + "dataModel": { + "email": "not-an-email" + }, + "components": [ + { + "id": "root", + "component": "TextField", + "label": "Email", + "value": { + "@path": "/email" + }, + "checks": [ + { + "condition": { + "@call": "email", + "args": { + "value": { + "@path": "/email" + } + } + } + } + ] + } + ] + } + } + ], + "expect": { + "surfaces": { + "form_surface": { + "isValid": { + "root": false + }, + "validationErrors": { + "root": [ + "Please enter a valid email address (e.g. user@example.com)." + ] + }, + "validationResults": { + "root": [ + { + "valid": false, + "code": "INVALID_EMAIL", + "message": "Please enter a valid email address (e.g. user@example.com).", + "severity": "error" + } + ] + } + } + } + } + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "form_surface", + "path": "/email", + "value": "user@example.com" + } + } + ], + "expect": { + "surfaces": { + "form_surface": { + "isValid": { + "root": true + }, + "validationErrors": { + "root": [] + }, + "validationResults": { + "root": [] + } + } + } + } + } + ] + }, + { + "name": "test_validation_result_coercion_in_logical_and_or_not", + "description": "Verifies that ValidationResult objects returned by functions inside logical and, or, and not expressions are coerced via their .valid boolean property.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "form_surface", + "catalogId": "basic", + "dataModel": { + "email": "invalid-email", + "allowOverride": true + }, + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "and_field", + "or_field", + "not_field" + ] + }, + { + "id": "and_field", + "component": "TextField", + "label": "And Field", + "value": { + "@path": "/email" + }, + "checks": [ + { + "condition": { + "@call": "and", + "args": { + "values": [ + { + "@call": "email", + "args": { + "value": { + "@path": "/email" + } + } + }, + { + "@path": "/allowOverride" + } + ] + } + }, + "message": "And check failed." + } + ] + }, + { + "id": "or_field", + "component": "TextField", + "label": "Or Field", + "value": { + "@path": "/email" + }, + "checks": [ + { + "condition": { + "@call": "or", + "args": { + "values": [ + { + "@call": "email", + "args": { + "value": { + "@path": "/email" + } + } + }, + { + "@path": "/allowOverride" + } + ] + } + }, + "message": "Or check failed." + } + ] + }, + { + "id": "not_field", + "component": "TextField", + "label": "Not Field", + "value": { + "@path": "/email" + }, + "checks": [ + { + "condition": { + "@call": "not", + "args": { + "value": { + "@call": "email", + "args": { + "value": { + "@path": "/email" + } + } + } + } + }, + "message": "Not check failed." + } + ] + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "form_surface": { + "isValid": { + "and_field": false, + "or_field": true, + "not_field": true + }, + "validationErrors": { + "and_field": [ + "And check failed." + ], + "or_field": [], + "not_field": [] + }, + "validationResults": { + "and_field": [ + { + "valid": false, + "message": "And check failed.", + "severity": "error" + } + ], + "or_field": [], + "not_field": [] + } + } + } + } + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/validator_v1_0.json b/package/__fixtures__/a2ui-conformance/core/validator_v1_0.json new file mode 100644 index 0000000000..611c15a926 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/validator_v1_0.json @@ -0,0 +1,2792 @@ +[ + { + "name": "test_validator_1_0", + "description": "Tests schema validation of v1.0 message envelopes (inline components, optional catalogId, data deletion) against the basic v1.0 catalog schema.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json", + "sendDataModel": true, + "components": [ + { + "id": "root", + "component": "Text", + "text": "Inline Text" + } + ], + "dataModel": { + "counter": 100 + } + } + } + ] + }, + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_nocat" + } + } + ] + }, + { + "messages": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/user/token", + "value": null + } + } + ] + }, + { + "messages": [ + { + "createSurface": { + "surfaceId": "s1" + } + } + ], + "expectError": { + "category": "ValidationError", + "details": [ + { + "path": "messages.0.version", + "code": "missing_field" + } + ] + } + }, + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": 123 + } + } + ], + "expectError": { + "category": "ValidationError", + "details": [ + { + "path": "messages.0.createSurface.surfaceId", + "code": "type_mismatch" + } + ] + } + } + ] + }, + { + "name": "test_custom_catalog_1_0", + "description": "Tests component property schema validation in v1.0 against custom catalog schema.", + "action": "validate", + "protocolVersion": "1.0", + "catalog": { + "catalogId": "custom", + "components": { + "Canvas": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "component": { + "const": "Canvas" + }, + "children": { + "type": "array", + "items": { + "$ref": "common_types.json#/$defs/ComponentId" + } + } + }, + "required": [ + "component", + "children" + ] + }, + "Chart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "component": { + "const": "Chart" + }, + "chartType": { + "enum": [ + "doughnut", + "pie" + ] + }, + "title": { + "type": "string" + } + }, + "required": [ + "component" + ] + } + } + }, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Canvas", + "children": [ + "c1", + "c2" + ] + }, + { + "id": "c1", + "component": "Chart", + "chartType": "doughnut", + "title": "Chart 1" + }, + { + "id": "c2", + "component": "Chart", + "chartType": "pie", + "title": "Chart 2" + } + ] + } + } + ] + }, + { + "messages": [ + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "c1", + "component": "Chart", + "chartType": "bar" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "s1", + "path": "/components/0/chartType", + "details": [ + { + "path": "/chartType", + "code": "invalid_value", + "surfaceId": "s1" + } + ], + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "s1", + "path": "/components/0/chartType", + "message": "chartType" + } + } + } + }, + { + "messages": [ + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "c1", + "component": "Canvas" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "s1", + "path": "/components/0/children", + "details": [ + { + "path": "/children", + "code": "missing_field", + "surfaceId": "s1" + } + ], + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "s1", + "path": "/components/0/children", + "message": "children" + } + } + } + }, + { + "messages": [ + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "c1", + "component": "UnknownComponent" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "s1", + "path": "/components/0/component", + "details": [ + { + "path": "/component", + "code": "invalid_value", + "surfaceId": "s1" + } + ], + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "s1", + "path": "/components/0/component", + "message": "UnknownComponent" + } + } + } + } + ] + }, + { + "name": "test_validator_theme_schema", + "description": "Tests schema validation of surface theme properties against catalog theme schema.", + "action": "validate", + "catalog": { + "catalogId": "theme_cat", + "theme": { + "type": "object", + "properties": { + "primaryColor": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$" + } + }, + "required": [ + "primaryColor" + ], + "additionalProperties": false + } + }, + "steps": [ + { + "messages": [ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "s1", + "catalogId": "theme_cat", + "theme": { + "primaryColor": "#ff0000" + } + } + } + ] + }, + { + "messages": [ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "s1", + "catalogId": "theme_cat", + "theme": { + "primaryColor": "not-a-hex" + } + } + } + ], + "expectError": { + "category": "ValidationError" + } + } + ] + }, + { + "name": "test_v10_topology_circular_reference_error", + "description": "Tests that circular component references fail validation in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "c1" + ] + }, + { + "id": "c1", + "component": "Column", + "children": [ + "root" + ] + } + ] + } + } + ], + "expectError": "Circular component reference" + } + ] + }, + { + "name": "test_v10_topology_dangling_child_reference_error", + "description": "Tests that dangling child references fail validation in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "missing_child" + ] + } + ] + } + } + ], + "expectError": "Dangling reference" + } + ] + }, + { + "name": "test_v10_topology_self_reference_error", + "description": "Tests that self-referencing components fail validation in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "root" + ] + } + ] + } + } + ], + "expectError": "Self-reference detected" + } + ] + }, + { + "name": "test_v10_topology_orphaned_component_error", + "description": "Tests that unreachable orphaned components fail validation in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Root" + }, + { + "id": "orphan", + "component": "Text", + "text": "Orphan" + } + ] + } + } + ], + "expectError": "is not reachable from" + } + ] + }, + { + "name": "test_v10_topology_missing_root_component_error", + "description": "Tests that component updates missing root component fail validation in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "c1", + "component": "Text", + "text": "No Root" + } + ] + } + } + ], + "expectError": "Missing root component" + } + ] + }, + { + "name": "test_v10_unknown_component_type", + "description": "Tests that unknown component types fail schema validation in v1.0.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "UnknownWidget", + "foo": "bar" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "s1", + "path": "/components/0/component", + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "s1", + "path": "/components/0/component", + "message": "UnknownWidget" + } + } + } + } + ] + }, + { + "name": "test_v10_unknown_component_property", + "description": "Tests that extra unknown component properties fail schema validation in v1.0.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Button", + "label": "Click Me", + "unknownAttribute": "someValue" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "s1", + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "s1", + "path": "/components/0", + "message": "Button" + } + } + } + } + ] + }, + { + "name": "test_v10_unknown_nested_function", + "description": "Tests that unknown nested functions fail schema validation in v1.0.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Button", + "child": "t1", + "action": { + "functionCall": { + "@call": "unknownFunc", + "args": { + "arg": "val" + } + } + } + }, + { + "id": "t1", + "component": "Text", + "text": "Click Me" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "s1", + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "s1", + "path": "/components/0/action/functionCall", + "message": "unknownFunc" + } + } + } + } + ] + }, + { + "name": "test_v10_multiple_action_envelope_error", + "description": "Tests that having multiple action keys in a single message envelope fails validation in v1.0.", + "action": "validate", + "protocolVersion": "1.0", + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1" + }, + "deleteSurface": { + "surfaceId": "s1" + } + } + ], + "expectError": { + "category": "ValidationError" + } + } + ] + }, + { + "name": "test_v10_invalid_json_pointer_path_error", + "description": "Tests that invalid JSON Pointer path syntax fails validation in v1.0.", + "action": "validate", + "protocolVersion": "1.0", + "steps": [ + { + "messages": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "invalid path [0]", + "value": "data" + } + } + ], + "expectError": { + "category": "ValidationError" + } + } + ] + }, + { + "name": "test_v10_uax31_invalid_identifier_error", + "description": "Tests that component IDs violating UAX#31 identifier syntax fail validation in v1.0.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "123invalid_id" + ] + }, + { + "id": "123invalid_id", + "component": "Text", + "text": "Invalid ID" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "s1", + "path": "/components/1/id", + "message": "Component id '123invalid_id' must be a valid", + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "s1", + "path": "/components/1/id", + "message": "123invalid_id" + } + } + } + } + ] + }, + { + "name": "test_v10_topology_template_child_reachable", + "description": "Tests that a component referenced only as a list template is reachable from the root in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "List", + "children": { + "componentId": "item", + "path": "/items" + } + }, + { + "id": "item", + "component": "Text", + "text": "Reachable" + } + ] + } + } + ] + } + ] + }, + { + "name": "test_v10_topology_template_dangling_reference_error", + "description": "Tests that a list template naming a component that does not exist fails validation in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "List", + "children": { + "componentId": "missing_id", + "path": "/items" + } + }, + { + "id": "item", + "component": "Text", + "text": "Unused" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "references non-existent component 'missing_id'" + } + } + ] + }, + { + "name": "test_v10_invalid_binding_path_error", + "description": "Tests that a data binding path with an invalid JSON Pointer escape ('~2') fails validation in v1.0.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@path": "/invalid/escape/~2" + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Invalid path syntax" + } + } + ] + }, + { + "name": "test_v10_component_nesting_depth_limit_exceeded_error", + "description": "Tests that a chain of nested components deeper than the global limit (50) fails validation in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Card", + "child": "c0" + }, + { + "id": "c0", + "component": "Card", + "child": "c1" + }, + { + "id": "c1", + "component": "Card", + "child": "c2" + }, + { + "id": "c2", + "component": "Card", + "child": "c3" + }, + { + "id": "c3", + "component": "Card", + "child": "c4" + }, + { + "id": "c4", + "component": "Card", + "child": "c5" + }, + { + "id": "c5", + "component": "Card", + "child": "c6" + }, + { + "id": "c6", + "component": "Card", + "child": "c7" + }, + { + "id": "c7", + "component": "Card", + "child": "c8" + }, + { + "id": "c8", + "component": "Card", + "child": "c9" + }, + { + "id": "c9", + "component": "Card", + "child": "c10" + }, + { + "id": "c10", + "component": "Card", + "child": "c11" + }, + { + "id": "c11", + "component": "Card", + "child": "c12" + }, + { + "id": "c12", + "component": "Card", + "child": "c13" + }, + { + "id": "c13", + "component": "Card", + "child": "c14" + }, + { + "id": "c14", + "component": "Card", + "child": "c15" + }, + { + "id": "c15", + "component": "Card", + "child": "c16" + }, + { + "id": "c16", + "component": "Card", + "child": "c17" + }, + { + "id": "c17", + "component": "Card", + "child": "c18" + }, + { + "id": "c18", + "component": "Card", + "child": "c19" + }, + { + "id": "c19", + "component": "Card", + "child": "c20" + }, + { + "id": "c20", + "component": "Card", + "child": "c21" + }, + { + "id": "c21", + "component": "Card", + "child": "c22" + }, + { + "id": "c22", + "component": "Card", + "child": "c23" + }, + { + "id": "c23", + "component": "Card", + "child": "c24" + }, + { + "id": "c24", + "component": "Card", + "child": "c25" + }, + { + "id": "c25", + "component": "Card", + "child": "c26" + }, + { + "id": "c26", + "component": "Card", + "child": "c27" + }, + { + "id": "c27", + "component": "Card", + "child": "c28" + }, + { + "id": "c28", + "component": "Card", + "child": "c29" + }, + { + "id": "c29", + "component": "Card", + "child": "c30" + }, + { + "id": "c30", + "component": "Card", + "child": "c31" + }, + { + "id": "c31", + "component": "Card", + "child": "c32" + }, + { + "id": "c32", + "component": "Card", + "child": "c33" + }, + { + "id": "c33", + "component": "Card", + "child": "c34" + }, + { + "id": "c34", + "component": "Card", + "child": "c35" + }, + { + "id": "c35", + "component": "Card", + "child": "c36" + }, + { + "id": "c36", + "component": "Card", + "child": "c37" + }, + { + "id": "c37", + "component": "Card", + "child": "c38" + }, + { + "id": "c38", + "component": "Card", + "child": "c39" + }, + { + "id": "c39", + "component": "Card", + "child": "c40" + }, + { + "id": "c40", + "component": "Card", + "child": "c41" + }, + { + "id": "c41", + "component": "Card", + "child": "c42" + }, + { + "id": "c42", + "component": "Card", + "child": "c43" + }, + { + "id": "c43", + "component": "Card", + "child": "c44" + }, + { + "id": "c44", + "component": "Card", + "child": "c45" + }, + { + "id": "c45", + "component": "Card", + "child": "c46" + }, + { + "id": "c46", + "component": "Card", + "child": "c47" + }, + { + "id": "c47", + "component": "Card", + "child": "c48" + }, + { + "id": "c48", + "component": "Card", + "child": "c49" + }, + { + "id": "c49", + "component": "Card", + "child": "c50" + }, + { + "id": "c50", + "component": "Card", + "child": "c51" + }, + { + "id": "c51", + "component": "Card", + "child": "c52" + }, + { + "id": "c52", + "component": "Card", + "child": "c53" + }, + { + "id": "c53", + "component": "Card", + "child": "c54" + }, + { + "id": "c54", + "component": "Card", + "child": "c55" + }, + { + "id": "c55", + "component": "Text", + "text": "Leaf" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Global recursion limit exceeded" + } + } + ] + }, + { + "name": "test_v10_component_nesting_depth_within_limit", + "description": "Tests that a chain of 41 nested components, within the global limit (50), passes validation in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Card", + "child": "c0" + }, + { + "id": "c0", + "component": "Card", + "child": "c1" + }, + { + "id": "c1", + "component": "Card", + "child": "c2" + }, + { + "id": "c2", + "component": "Card", + "child": "c3" + }, + { + "id": "c3", + "component": "Card", + "child": "c4" + }, + { + "id": "c4", + "component": "Card", + "child": "c5" + }, + { + "id": "c5", + "component": "Card", + "child": "c6" + }, + { + "id": "c6", + "component": "Card", + "child": "c7" + }, + { + "id": "c7", + "component": "Card", + "child": "c8" + }, + { + "id": "c8", + "component": "Card", + "child": "c9" + }, + { + "id": "c9", + "component": "Card", + "child": "c10" + }, + { + "id": "c10", + "component": "Card", + "child": "c11" + }, + { + "id": "c11", + "component": "Card", + "child": "c12" + }, + { + "id": "c12", + "component": "Card", + "child": "c13" + }, + { + "id": "c13", + "component": "Card", + "child": "c14" + }, + { + "id": "c14", + "component": "Card", + "child": "c15" + }, + { + "id": "c15", + "component": "Card", + "child": "c16" + }, + { + "id": "c16", + "component": "Card", + "child": "c17" + }, + { + "id": "c17", + "component": "Card", + "child": "c18" + }, + { + "id": "c18", + "component": "Card", + "child": "c19" + }, + { + "id": "c19", + "component": "Card", + "child": "c20" + }, + { + "id": "c20", + "component": "Card", + "child": "c21" + }, + { + "id": "c21", + "component": "Card", + "child": "c22" + }, + { + "id": "c22", + "component": "Card", + "child": "c23" + }, + { + "id": "c23", + "component": "Card", + "child": "c24" + }, + { + "id": "c24", + "component": "Card", + "child": "c25" + }, + { + "id": "c25", + "component": "Card", + "child": "c26" + }, + { + "id": "c26", + "component": "Card", + "child": "c27" + }, + { + "id": "c27", + "component": "Card", + "child": "c28" + }, + { + "id": "c28", + "component": "Card", + "child": "c29" + }, + { + "id": "c29", + "component": "Card", + "child": "c30" + }, + { + "id": "c30", + "component": "Card", + "child": "c31" + }, + { + "id": "c31", + "component": "Card", + "child": "c32" + }, + { + "id": "c32", + "component": "Card", + "child": "c33" + }, + { + "id": "c33", + "component": "Card", + "child": "c34" + }, + { + "id": "c34", + "component": "Card", + "child": "c35" + }, + { + "id": "c35", + "component": "Card", + "child": "c36" + }, + { + "id": "c36", + "component": "Card", + "child": "c37" + }, + { + "id": "c37", + "component": "Card", + "child": "c38" + }, + { + "id": "c38", + "component": "Card", + "child": "c39" + }, + { + "id": "c39", + "component": "Card", + "child": "c40" + }, + { + "id": "c40", + "component": "Text", + "text": "Leaf" + } + ] + } + } + ] + } + ] + }, + { + "name": "test_v10_multi_surface_independent_roots", + "description": "Tests that two surfaces are each validated against their own component tree in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "surface_a", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "createSurface": { + "surfaceId": "surface_b", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "surface_a", + "components": [ + { + "id": "root", + "component": "Card", + "child": "child_a" + }, + { + "id": "child_a", + "component": "Text", + "text": "Hello A" + } + ] + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "surface_b", + "components": [ + { + "id": "root", + "component": "Card", + "child": "child_b" + }, + { + "id": "child_b", + "component": "Text", + "text": "Hello B" + } + ] + } + } + ] + } + ] + }, + { + "name": "test_v10_multi_surface_missing_root_error", + "description": "Tests that a missing root on one of two surfaces fails validation in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "surface_a", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "createSurface": { + "surfaceId": "surface_b", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "surface_a", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello A" + } + ] + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "surface_b", + "components": [ + { + "id": "not_root", + "component": "Text", + "text": "Hello B" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Missing root component" + } + } + ] + }, + { + "name": "test_v10_incremental_update_without_root", + "description": "Tests that an updateComponents message that omits the root component passes once the root has been sent, in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "card" + ] + }, + { + "id": "card", + "component": "Card", + "child": "text" + }, + { + "id": "text", + "component": "Text", + "text": "Initial" + } + ] + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "card", + "component": "Card", + "child": "text" + }, + { + "id": "text", + "component": "Text", + "text": "Updated" + } + ] + } + } + ] + } + ] + }, + { + "name": "test_v10_incremental_update_self_reference_error", + "description": "Tests that an incremental updateComponents message introducing a self-reference fails validation in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "card" + ] + }, + { + "id": "card", + "component": "Card", + "child": "text" + }, + { + "id": "text", + "component": "Text", + "text": "Initial" + } + ] + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "card", + "component": "Card", + "child": "card" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Self-reference detected" + } + } + ] + }, + { + "name": "test_v10_incremental_update_circular_reference_error", + "description": "Tests that an incremental updateComponents message introducing a cycle fails validation in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "card" + ] + }, + { + "id": "card", + "component": "Card", + "child": "text" + }, + { + "id": "text", + "component": "Text", + "text": "Initial" + } + ] + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "card", + "component": "Card", + "child": "loop" + }, + { + "id": "loop", + "component": "Card", + "child": "card" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Circular reference detected" + } + } + ] + }, + { + "name": "test_v10_incremental_update_duplicate_component_id_error", + "description": "Tests that an incremental updateComponents message listing the same component ID twice fails validation in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "card" + ] + }, + { + "id": "card", + "component": "Card", + "child": "text" + }, + { + "id": "text", + "component": "Text", + "text": "Initial" + } + ] + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "text", + "component": "Text", + "text": "A" + }, + { + "id": "text", + "component": "Text", + "text": "B" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Duplicate component ID" + } + } + ] + }, + { + "name": "test_v10_recursion_limit_function_call_depth_exceeded_error", + "description": "Tests that nested function calls exceeding the maximum depth (5) fail validation in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "f1", + "args": { + "v": { + "@call": "f2", + "args": { + "v": { + "@call": "f3", + "args": { + "v": { + "@call": "f4", + "args": { + "v": { + "@call": "f5", + "args": { + "v": { + "@call": "f6", + "args": {} + } + } + } + } + } + } + } + } + } + } + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "functionCall depth" + } + } + ] + }, + { + "name": "test_v10_data_model_nesting_depth_limit_exceeded_error", + "description": "Tests that an updateDataModel value nested deeper than the global limit (50) fails validation in v1.0.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello" + } + ] + } + }, + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/", + "value": { + "level": 0, + "next": { + "level": 1, + "next": { + "level": 2, + "next": { + "level": 3, + "next": { + "level": 4, + "next": { + "level": 5, + "next": { + "level": 6, + "next": { + "level": 7, + "next": { + "level": 8, + "next": { + "level": 9, + "next": { + "level": 10, + "next": { + "level": 11, + "next": { + "level": 12, + "next": { + "level": 13, + "next": { + "level": 14, + "next": { + "level": 15, + "next": { + "level": 16, + "next": { + "level": 17, + "next": { + "level": 18, + "next": { + "level": 19, + "next": { + "level": 20, + "next": { + "level": 21, + "next": { + "level": 22, + "next": { + "level": 23, + "next": { + "level": 24, + "next": { + "level": 25, + "next": { + "level": 26, + "next": { + "level": 27, + "next": { + "level": 28, + "next": { + "level": 29, + "next": { + "level": 30, + "next": { + "level": 31, + "next": { + "level": 32, + "next": { + "level": 33, + "next": { + "level": 34, + "next": { + "level": 35, + "next": { + "level": 36, + "next": { + "level": 37, + "next": { + "level": 38, + "next": { + "level": 39, + "next": { + "level": 40, + "next": { + "level": 41, + "next": { + "level": 42, + "next": { + "level": 43, + "next": { + "level": 44, + "next": { + "level": 45, + "next": { + "level": 46, + "next": { + "level": 47, + "next": { + "level": 48, + "next": { + "level": 49, + "next": { + "level": 50, + "next": { + "level": 51, + "next": { + "level": 52, + "next": { + "level": 53, + "next": { + "level": 54, + "next": { + "level": 55 + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Global recursion limit exceeded" + } + } + ] + }, + { + "name": "test_v10_topology_structured_array_item_dangling_child_error", + "description": "Tests that a dangling child reference inside a structured array item (Tabs.tabs[].child) fails validation in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Tabs", + "tabs": [ + { + "title": "Overview", + "child": "tab_one" + }, + { + "title": "Details", + "child": "missing_tab" + } + ] + }, + { + "id": "tab_one", + "component": "Text", + "text": "First" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "missing_tab" + } + } + ] + }, + { + "name": "test_v10_topology_plain_string_property_not_treated_as_child_ref", + "description": "Tests that a plain string property whose value happens to match another component's ID does not make that component reachable in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Text", + "text": "orphan_id" + }, + { + "id": "orphan_id", + "component": "Text", + "text": "Still orphaned" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "orphan_id" + } + } + ] + }, + { + "name": "test_v10_topology_component_id_array_dangling_reference_error", + "description": "Tests that every item of an array property whose items are ComponentId is a child reference, whatever the property is called, so an item naming no component fails validation in v1.0 strict mode even when no other item names one.", + "action": "validate", + "protocolVersion": "1.0", + "catalog": { + "catalogId": "custom", + "components": { + "Stack": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "component": { + "const": "Stack" + }, + "slots": { + "type": "array", + "items": { + "$ref": "common_types.json#/$defs/ComponentId" + } + } + }, + "required": [ + "component", + "slots" + ] + } + } + }, + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Stack", + "slots": [ + "missing_slot" + ] + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "missing_slot" + } + } + ] + }, + { + "name": "test_v10_topology_component_id_array_child_reachable", + "description": "Tests that a component named in an array property whose items are ComponentId is reachable from the root in v1.0 strict mode, whatever the property is called.", + "action": "validate", + "protocolVersion": "1.0", + "catalog": { + "catalogId": "custom", + "components": { + "Stack": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "component": { + "const": "Stack" + }, + "slots": { + "type": "array", + "items": { + "$ref": "common_types.json#/$defs/ComponentId" + } + } + }, + "required": [ + "component", + "slots" + ] + } + } + }, + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Stack", + "slots": [ + "first" + ] + }, + { + "id": "first", + "component": "Stack", + "slots": [] + } + ] + } + } + ] + } + ] + }, + { + "name": "test_v10_topology_component_id_array_partial_dangling_reference_error", + "description": "A property declared as an array of ComponentId directly (an array whose items are a ComponentId $ref, not a ChildList) is read as a list of child references. With one held and one missing item, the missing item is reported as a dangling reference at its index in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalog": { + "catalogId": "custom", + "components": { + "Stack": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "component": { + "const": "Stack" + }, + "slots": { + "type": "array", + "items": { + "$ref": "common_types.json#/$defs/ComponentId" + } + } + }, + "required": [ + "component", + "slots" + ] + }, + "Label": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "component": { + "const": "Label" + }, + "text": { + "type": "string" + } + }, + "required": [ + "component", + "text" + ] + } + } + }, + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Stack", + "slots": [ + "held", + "missing" + ] + }, + { + "id": "held", + "component": "Label", + "text": "held" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "references non-existent component 'missing' in field 'slots[1]'" + } + } + ] + }, + { + "name": "test_v10_topology_component_id_array_circular_reference_error", + "description": "A property declared as an array of ComponentId directly (an array whose items are a ComponentId $ref, not a ChildList) is read as a list of child references, so a cycle through it is detected in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalog": { + "catalogId": "custom", + "components": { + "Stack": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "component": { + "const": "Stack" + }, + "slots": { + "type": "array", + "items": { + "$ref": "common_types.json#/$defs/ComponentId" + } + } + }, + "required": [ + "component", + "slots" + ] + }, + "Label": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "component": { + "const": "Label" + }, + "text": { + "type": "string" + } + }, + "required": [ + "component", + "text" + ] + } + } + }, + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Stack", + "slots": [ + "loop_a" + ] + }, + { + "id": "loop_a", + "component": "Stack", + "slots": [ + "loop_b" + ] + }, + { + "id": "loop_b", + "component": "Stack", + "slots": [ + "loop_a" + ] + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Circular reference detected" + } + } + ] + }, + { + "name": "test_v10_child_list_template_missing_path_error", + "description": "Tests that a ChildList template object missing its required 'path' property fails schema validation in v1.0.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "List", + "children": { + "componentId": "item" + } + }, + { + "id": "item", + "component": "Text", + "text": "Item" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError" + } + } + ] + }, + { + "name": "test_v10_update_data_model_invalid_path_error", + "description": "Tests that an updateDataModel message with an invalid JSON Pointer escape ('~2') fails validation in v1.0.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/user/~2invalid", + "value": "Alice" + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Invalid path syntax" + } + } + ] + }, + { + "name": "test_v10_function_argument_schema_mismatch_error", + "description": "Tests that a function call passing an argument of the wrong type fails schema validation in v1.0.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatNumber", + "args": { + "value": "not_a_number" + } + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "s1", + "path": "/components/0/text/args/value", + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "s1", + "path": "/components/0/text/args/value", + "message": "value" + } + } + } + } + ] + }, + { + "name": "test_v10_incremental_update_same_component_id_across_messages", + "description": "Tests that updating the same component ID across separate updateComponents messages succeeds in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Initial" + } + ] + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Updated" + } + ] + } + } + ] + } + ] + } +] \ No newline at end of file diff --git a/package/a2ui/package.json b/package/a2ui/package.json new file mode 100644 index 0000000000..7f6827791c --- /dev/null +++ b/package/a2ui/package.json @@ -0,0 +1,6 @@ +{ + "main": "../lib/commonjs/a2ui/index.js", + "module": "../lib/module/a2ui/index.js", + "react-native": "../src/a2ui/index.ts", + "types": "../lib/typescript/a2ui/index.d.ts" +} diff --git a/package/ai-components/conversation/package.json b/package/ai-components/conversation/package.json new file mode 100644 index 0000000000..2c5920b22a --- /dev/null +++ b/package/ai-components/conversation/package.json @@ -0,0 +1,6 @@ +{ + "main": "../../lib/commonjs/ai-components/conversation/index.js", + "module": "../../lib/module/ai-components/conversation/index.js", + "react-native": "../../src/ai-components/conversation/index.ts", + "types": "../../lib/typescript/ai-components/conversation/index.d.ts" +} diff --git a/package/bin/validate-entry-isolation.js b/package/bin/validate-entry-isolation.js index d69e885890..1d7dac568d 100644 --- a/package/bin/validate-entry-isolation.js +++ b/package/bin/validate-entry-isolation.js @@ -9,7 +9,13 @@ * the AI components or their dependencies. Apps that don't use AI must not bundle any of it. * 2. The AI entry points (core `src/ai-components/index.ts` and the wrappers' * `ai-components/index.js`) may only reach the AI sources plus an explicit allowlist of other - * files. Apps that only use the AI components must not bundle the chat SDK. + * files. Apps that only use the AI components must not bundle the chat SDK. Nor the A2UI + * renderer: the `a2ui` entry points (core `src/a2ui/index.ts` and the wrappers' + * `a2ui/index.js`) reach it and `@a2ui/web_core`, besides what the AI entry points reach. Nor + * the conversation, which needs react-native-keyboard-controller: the `ai-components/conversation` + * entry points (core `src/ai-components/conversation/index.ts` and the wrappers' + * `ai-components/conversation/index.js`) reach it, and the other AI entry points load + * react-native-keyboard-controller only through a guarded `require`. * 3. The codegen spec copies the wrappers need (codegen only reads a package's own `jsSrcsDir`) stay * byte-identical to the core source of truth. * @@ -23,22 +29,50 @@ const ts = require('typescript'); const PACKAGE_DIR = path.resolve(__dirname, '..'); const CORE_SRC = path.join(PACKAGE_DIR, 'src'); const AI_SRC = path.join(CORE_SRC, 'ai-components'); +const A2UI_SRC = path.join(CORE_SRC, 'a2ui'); +const CONVERSATION_SRC = path.join(AI_SRC, 'conversation'); const WRAPPERS = ['native-package', 'expo-package'].map((name) => path.join(PACKAGE_DIR, name)); // Third-party packages only the AI components may use. (`lodash` and `@stream-io/state-store` are // not among them: the main entry point imports both too.) const AI_ONLY_PACKAGES = [ '@khanacademy/simple-markdown', + '@legendapp/list', '@shopify/react-native-skia', + 'react-native-keyboard-controller', 'react-syntax-highlighter', 'victory-native', 'zod', ]; +// Third-party packages only the A2UI renderer may use: its own entry point keeps them out of apps +// that use the AI components without it. +const A2UI_ONLY_PACKAGES = ['@a2ui/web_core']; + +// Optional peers the AI components load with a guarded `require`, each from one file only, so apps +// that don't install them still bundle. +const GUARDED_PACKAGES = { + 'react-native-keyboard-controller': path.join(AI_SRC, 'internal/keyboard.tsx'), +}; + +// Dependencies only the conversation entry points may reach: they require +// react-native-keyboard-controller unguarded. +const CONVERSATION_ONLY_SPECIFIERS = ['@legendapp/list/keyboard']; + // Files outside `src/ai-components` that the AI entry points are allowed to reach. const AI_ALLOWED_FILES = [ path.join(CORE_SRC, 'native.ts'), path.join(CORE_SRC, 'components/UIComponents/NativeShimmerView.tsx'), + // The chat SDK's translation context, which the AI components translate with (not Streami18n). + path.join(CORE_SRC, 'contexts/translationContext/TranslationContext.tsx'), + path.join(CORE_SRC, 'contexts/translationContext/types.ts'), + path.join(CORE_SRC, 'contexts/utils/isTestEnvironment.ts'), + path.join(CORE_SRC, 'i18n/utils.ts'), + // The design tokens (plain data, no imports), which the AI components size their text with. Metro + // picks the platform's file; this check resolves the fallback, which re-exports the web one. + ...['', '.ios', '.android', '.web'].map((platform) => + path.join(CORE_SRC, `theme/generated/light/StreamTokens${platform}.ts`), + ), ...WRAPPERS.flatMap((wrapper) => [ path.join(wrapper, 'ai-components/index.js'), path.join(wrapper, 'src/optionalDependencies/NativeShimmerView.ts'), @@ -79,6 +113,12 @@ const resolveSpecifier = (specifier, fromFile) => { if (specifier === 'stream-chat-react-native-core/ai-components') { return { file: path.join(AI_SRC, 'index.ts') }; } + if (specifier === 'stream-chat-react-native-core/ai-components/conversation') { + return { file: path.join(CONVERSATION_SRC, 'index.ts') }; + } + if (specifier === 'stream-chat-react-native-core/a2ui') { + return { file: path.join(A2UI_SRC, 'index.ts') }; + } return { pkg: packageNameOf(specifier), specifier }; }; @@ -155,6 +195,32 @@ const errors = []; const report = (message, chain) => errors.push(`${message}\n via: ${chain.map(relative).join('\n -> ')}`); +/** Checks that an entry point outside the conversation reaches neither it nor what it needs. */ +const checkWithoutConversation = (entry, { files, packages }) => { + for (const [file, chain] of files) { + if (file.startsWith(CONVERSATION_SRC + path.sep)) { + report(`${relative(entry)} reaches the AI conversation (${relative(file)})`, chain); + } + for (const specifier of runtimeSpecifiers(file)) { + const guardedFile = GUARDED_PACKAGES[packageNameOf(specifier)]; + if (guardedFile && file !== guardedFile) { + report( + `${relative(file)} imports the optional "${specifier}"; load it through ${relative(guardedFile)}`, + chain, + ); + } + } + } + for (const [specifier, chain] of packages) { + if (CONVERSATION_ONLY_SPECIFIERS.includes(specifier)) { + report( + `${relative(entry)} reaches "${specifier}", which only the AI conversation may`, + chain, + ); + } + } +}; + // 1. Main entry points must not reach the AI components. for (const entry of [ path.join(CORE_SRC, 'index.ts'), @@ -164,13 +230,15 @@ for (const entry of [ for (const [file, chain] of files) { if ( file.startsWith(AI_SRC + path.sep) || - file.includes(`${path.sep}ai-components${path.sep}`) + file.startsWith(A2UI_SRC + path.sep) || + file.includes(`${path.sep}ai-components${path.sep}`) || + file.includes(`${path.sep}a2ui${path.sep}`) ) { report(`${relative(entry)} reaches the AI components (${relative(file)})`, chain); } } for (const [specifier, chain] of packages) { - if (AI_ONLY_PACKAGES.includes(packageNameOf(specifier))) { + if ([...AI_ONLY_PACKAGES, ...A2UI_ONLY_PACKAGES].includes(packageNameOf(specifier))) { report(`${relative(entry)} reaches the AI-only dependency "${specifier}"`, chain); } } @@ -181,7 +249,9 @@ for (const entry of [ path.join(AI_SRC, 'index.ts'), ...WRAPPERS.map((w) => path.join(w, 'ai-components/index.js')), ]) { - const { files, packages } = walk(entry); + const reached = walk(entry); + const { files, packages } = reached; + checkWithoutConversation(entry, reached); for (const [file, chain] of files) { if (!file.startsWith(AI_SRC + path.sep) && !AI_ALLOWED_FILES.includes(file)) { report( @@ -190,6 +260,35 @@ for (const entry of [ ); } } + for (const [specifier, chain] of packages) { + if (AI_FORBIDDEN_PACKAGES.includes(specifier)) { + report(`${relative(entry)} reaches "${specifier}"`, chain); + } + if (A2UI_ONLY_PACKAGES.includes(packageNameOf(specifier))) { + report(`${relative(entry)} reaches the A2UI-only dependency "${specifier}"`, chain); + } + } +} + +// 2b. The A2UI entry points reach the AI components and the A2UI renderer only. +const A2UI_WRAPPER_ENTRIES = WRAPPERS.map((w) => path.join(w, 'a2ui/index.js')); +for (const entry of [path.join(A2UI_SRC, 'index.ts'), ...A2UI_WRAPPER_ENTRIES]) { + const reached = walk(entry); + const { files, packages } = reached; + checkWithoutConversation(entry, reached); + for (const [file, chain] of files) { + if ( + !file.startsWith(AI_SRC + path.sep) && + !file.startsWith(A2UI_SRC + path.sep) && + !AI_ALLOWED_FILES.includes(file) && + !A2UI_WRAPPER_ENTRIES.includes(file) + ) { + report( + `${relative(entry)} reaches ${relative(file)}, which is not in the AI allowlist`, + chain, + ); + } + } for (const [specifier, chain] of packages) { if (AI_FORBIDDEN_PACKAGES.includes(specifier)) { report(`${relative(entry)} reaches "${specifier}"`, chain); @@ -197,12 +296,44 @@ for (const entry of [ } } +// 2c. The conversation entry points reach the AI components only. +const CONVERSATION_WRAPPER_ENTRIES = WRAPPERS.map((w) => + path.join(w, 'ai-components/conversation/index.js'), +); +for (const entry of [path.join(CONVERSATION_SRC, 'index.ts'), ...CONVERSATION_WRAPPER_ENTRIES]) { + const { files, packages } = walk(entry); + for (const [file, chain] of files) { + if ( + !file.startsWith(AI_SRC + path.sep) && + !AI_ALLOWED_FILES.includes(file) && + !CONVERSATION_WRAPPER_ENTRIES.includes(file) + ) { + report( + `${relative(entry)} reaches ${relative(file)}, which is not in the AI allowlist`, + chain, + ); + } + } + for (const [specifier, chain] of packages) { + if (AI_FORBIDDEN_PACKAGES.includes(specifier)) { + report(`${relative(entry)} reaches "${specifier}"`, chain); + } + if (A2UI_ONLY_PACKAGES.includes(packageNameOf(specifier))) { + report(`${relative(entry)} reaches the A2UI-only dependency "${specifier}"`, chain); + } + } +} + // 3. Codegen spec copies must not drift. const copies = [ [ path.join(AI_SRC, 'native-specs/NativeAIDictation.ts'), ...WRAPPERS.map((w) => path.join(w, 'src/native/NativeAIDictation.ts')), ], + [ + path.join(AI_SRC, 'native-specs/NativeAIAgent.ts'), + ...WRAPPERS.map((w) => path.join(w, 'src/native/NativeAIAgent.ts')), + ], WRAPPERS.map((w) => path.join(w, 'src/native/PerfTextNativeComponent.ts')), ]; for (const [source, ...targets] of copies) { diff --git a/package/expo-package/.gitignore b/package/expo-package/.gitignore index 81e0235dce..c70b7e72ca 100644 --- a/package/expo-package/.gitignore +++ b/package/expo-package/.gitignore @@ -1,6 +1,7 @@ # android android/build android/src/main/java/com/streamchatreactnative/shared +android/src/agent-sign-in # ios ios/build diff --git a/package/expo-package/a2ui/index.d.ts b/package/expo-package/a2ui/index.d.ts new file mode 100644 index 0000000000..7c6e147c82 --- /dev/null +++ b/package/expo-package/a2ui/index.d.ts @@ -0,0 +1 @@ +export * from 'stream-chat-react-native-core/a2ui'; diff --git a/package/expo-package/a2ui/index.js b/package/expo-package/a2ui/index.js new file mode 100644 index 0000000000..ca0f6bd382 --- /dev/null +++ b/package/expo-package/a2ui/index.js @@ -0,0 +1,6 @@ +// The A2UI renderer for the AI components' generated UI, an entry point of its own so an app whose +// agent never writes A2UI doesn't bundle the A2UI core. It draws with the AI components, whose +// entry point registers the native pieces they need. +import '../ai-components'; + +export * from 'stream-chat-react-native-core/a2ui'; diff --git a/package/expo-package/ai-components/conversation/index.d.ts b/package/expo-package/ai-components/conversation/index.d.ts new file mode 100644 index 0000000000..8296958f70 --- /dev/null +++ b/package/expo-package/ai-components/conversation/index.d.ts @@ -0,0 +1 @@ +export * from 'stream-chat-react-native-core/ai-components/conversation'; diff --git a/package/expo-package/ai-components/conversation/index.js b/package/expo-package/ai-components/conversation/index.js new file mode 100644 index 0000000000..260a4673dd --- /dev/null +++ b/package/expo-package/ai-components/conversation/index.js @@ -0,0 +1,6 @@ +// The AI conversation, an entry point of its own because it needs react-native-keyboard-controller, +// which the rest of the AI components don't. It draws with the AI components, whose entry point +// registers the native pieces they need. +import '..'; + +export * from 'stream-chat-react-native-core/ai-components/conversation'; diff --git a/package/expo-package/android/build.gradle b/package/expo-package/android/build.gradle index 5e231ac5dc..a63828b6cd 100644 --- a/package/expo-package/android/build.gradle +++ b/package/expo-package/android/build.gradle @@ -42,6 +42,26 @@ def hasNativeSources = { File dir -> dir.exists() && !fileTree(dir).matching { include "**/*.kt"; include "**/*.java" }.files.isEmpty() } +// The agent components' sign-ins open in the browser's Auth Tab, which needs androidx.browser +// 1.9.0, and that needs the app to build with compileSdk 36 and AGP 8.9.1. An app below either +// gets 1.8.0 and opens them in a Custom Tab instead, so the SDK asks neither of anyone. +def versionAtLeast = { String version, List wanted -> + def parts = version.tokenize('.-').collect { it.replaceAll(/\D.*$/, '') }.findAll { it }.collect { it.toInteger() } + for (int i = 0; i < wanted.size(); i++) { + def part = i < parts.size() ? parts[i] : 0 + if (part != wanted[i]) return part > wanted[i] + } + return true +} +def agentSignInWithAuthTab = + getExtOrIntegerDefault("compileSdkVersion").toString().toInteger() >= 36 && + versionAtLeast(com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION, [8, 9, 1]) +def agentSignInVariant = agentSignInWithAuthTab ? "authtab" : "customtabs" +def sharedAgentSignInDir = new File(canonicalProjectDir, "../../shared-native/android-${agentSignInVariant}") +def agentSignInDir = hasNativeSources(sharedAgentSignInDir) + ? sharedAgentSignInDir + : new File(projectDir, "src/agent-sign-in/${agentSignInVariant}") + android { compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION @@ -82,6 +102,7 @@ android { java.srcDirs += [ "src/newarch", "src/main/java/com/streamchatreactnative/shared", + agentSignInDir, "${project.buildDir}/generated/source/codegen/java" ] } @@ -140,6 +161,9 @@ repositories { dependencies { implementation "com.facebook.react:react-native:+" + // The agent components' tool-call sign-ins: an Auth Tab where the app's build allows 1.9.0, + // else a Custom Tab (see `agentSignInWithAuthTab`). + implementation "androidx.browser:browser:${agentSignInWithAuthTab ? '1.9.0' : '1.8.0'}" } if (isNewArchitectureEnabled()) { diff --git a/package/expo-package/android/src/main/AndroidManifest.xml b/package/expo-package/android/src/main/AndroidManifest.xml index 17df9bf89c..b71eebea63 100644 --- a/package/expo-package/android/src/main/AndroidManifest.xml +++ b/package/expo-package/android/src/main/AndroidManifest.xml @@ -1,4 +1,12 @@ + + + + + + + diff --git a/package/expo-package/android/src/main/java/com/streamchatexpo/StreamChatExpoPackage.java b/package/expo-package/android/src/main/java/com/streamchatexpo/StreamChatExpoPackage.java index 4442d860eb..9336dd4546 100644 --- a/package/expo-package/android/src/main/java/com/streamchatexpo/StreamChatExpoPackage.java +++ b/package/expo-package/android/src/main/java/com/streamchatexpo/StreamChatExpoPackage.java @@ -18,6 +18,7 @@ public class StreamChatExpoPackage extends TurboReactPackage { private static final String STREAM_MULTIPART_UPLOADER_MODULE = "StreamMultipartUploader"; private static final String STREAM_VIDEO_THUMBNAIL_MODULE = "StreamVideoThumbnail"; private static final String AI_DICTATION_MODULE = "AIDictation"; + private static final String AI_AGENT_MODULE = "AIAgent"; @Nullable @Override @@ -34,6 +35,10 @@ public NativeModule getModule(String name, ReactApplicationContext reactContext) return createNewArchModule("com.streamchatexpo.AIDictationModule", reactContext); } + if (name.equals(AI_AGENT_MODULE)) { + return createNewArchModule("com.streamchatexpo.AIAgentModule", reactContext); + } + return null; } @@ -75,6 +80,17 @@ public ReactModuleInfoProvider getReactModuleInfoProvider() { false, // isCxxModule true // isTurboModule )); + moduleInfos.put( + AI_AGENT_MODULE, + new ReactModuleInfo( + AI_AGENT_MODULE, + AI_AGENT_MODULE, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // hasConstants + false, // isCxxModule + true // isTurboModule + )); return moduleInfos; }; } diff --git a/package/expo-package/android/src/newarch/com/streamchatexpo/AIAgentModule.kt b/package/expo-package/android/src/newarch/com/streamchatexpo/AIAgentModule.kt new file mode 100644 index 0000000000..3a3490e723 --- /dev/null +++ b/package/expo-package/android/src/newarch/com/streamchatexpo/AIAgentModule.kt @@ -0,0 +1,37 @@ +package com.streamchatexpo + +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.streamchatreactnative.shared.agent.AIAgentController + +/** + * The `AIAgent` TurboModule used by the `ai-components` agent views: this install's identifier + * and the sign-ins of tool calls. The implementation is shared with the other SDK package; see + * [AIAgentController]. + */ +class AIAgentModule( + reactContext: ReactApplicationContext, +) : NativeAIAgentSpec(reactContext) { + private val controller = AIAgentController(reactContext) + + override fun getName(): String = NAME + + override fun initialize() { + super.initialize() + controller.initialize() + } + + override fun invalidate() { + super.invalidate() + controller.invalidate() + } + + override fun installId(): String = controller.installId() + + override fun openAuthSession(url: String, callbackScheme: String, promise: Promise) = + controller.openAuthSession(url, callbackScheme, promise) + + companion object { + const val NAME = AIAgentController.NAME + } +} diff --git a/package/expo-package/package.json b/package/expo-package/package.json index c78b74a28b..7822a6c49c 100644 --- a/package/expo-package/package.json +++ b/package/expo-package/package.json @@ -22,6 +22,7 @@ "react-native.config.js", "package.json", "ai-components", + "a2ui", "app.plugin.js", "plugin" ], @@ -47,6 +48,7 @@ "expo-sharing": "*", "expo-video": "*", "react-native": ">=0.79.0", + "react-native-keyboard-controller": ">=1.21.7", "victory-native": ">=41.0.0" }, "peerDependenciesMeta": { @@ -86,6 +88,9 @@ "expo-video": { "optional": true }, + "react-native-keyboard-controller": { + "optional": true + }, "victory-native": { "optional": true } @@ -111,6 +116,7 @@ }, "ios": { "modulesProvider": { + "AIAgent": "AIAgent", "StreamMultipartUploader": "StreamMultipartUploader", "StreamVideoThumbnail": "StreamVideoThumbnail" }, diff --git a/package/expo-package/src/native/NativeAIAgent.ts b/package/expo-package/src/native/NativeAIAgent.ts new file mode 100644 index 0000000000..7e22ca992e --- /dev/null +++ b/package/expo-package/src/native/NativeAIAgent.ts @@ -0,0 +1,25 @@ +import type { TurboModule } from 'react-native'; +import { TurboModuleRegistry } from 'react-native'; + +/** + * Codegen spec of the native module behind the agent components: this install's identifier and + * the sign-ins of tool calls. + * + * This file is duplicated (byte for byte) in `package/native-package/src/native/NativeAIAgent.ts` + * and `package/expo-package/src/native/NativeAIAgent.ts`, which are the copies codegen reads. + * `yarn validate-entry-isolation` fails if they drift apart. + */ +export interface Spec extends TurboModule { + /** This install's identifier, created on first use and kept in storage that isn't backed up. */ + installId(): string; + /** + * Opens `url` in the platform's web authentication session and resolves with the URL the page + * handed back to `callbackScheme`, or an empty string when the person closed it. Rejects when + * the session couldn't be opened. + */ + openAuthSession(url: string, callbackScheme: string): Promise; +} + +// `get` rather than `getEnforcing`, so importing the AI entry point never throws in an app built +// without the module, such as one on an older version of the SDK's native code. +export default TurboModuleRegistry.get('AIAgent'); diff --git a/package/expo-package/stream-chat-expo.podspec b/package/expo-package/stream-chat-expo.podspec index ead4ddac26..b9e842bef4 100644 --- a/package/expo-package/stream-chat-expo.podspec +++ b/package/expo-package/stream-chat-expo.podspec @@ -42,9 +42,12 @@ Pod::Spec.new do |s| false end + # `AIAgent` (the agent components' install id and tool-call sign-ins) opens sign-ins in + # `ASWebAuthenticationSession`, which needs no usage description, so it is always compiled. if dictation_enabled - s.frameworks = "Speech" + s.frameworks = ["AuthenticationServices", "Speech"] else + s.frameworks = "AuthenticationServices" s.exclude_files = ["ios/shared/AIDictation.h", "ios/shared/AIDictation.mm", "ios/shared/AIDictationModule.swift"] end diff --git a/package/jest-setup.tsx b/package/jest-setup.tsx index 806594ca18..fda7443b95 100644 --- a/package/jest-setup.tsx +++ b/package/jest-setup.tsx @@ -53,6 +53,18 @@ jest.mock('react-native-gesture-handler/lib/module/GestureHandlerRootViewContext const React = require('react'); return { __esModule: true, default: React.createContext(true) }; }); +// The AI conversation rides the keyboard with react-native-keyboard-controller, which ships a mock. +jest.mock('react-native-keyboard-controller', () => + require('react-native-keyboard-controller/jest'), +); +// The keyboard-aware list waits for its scroll view to report its insets before it shows its rows, +// which the mocked scroll view never does. Under Jest it is the list it wraps; how it rides the +// keyboard is checked on devices. +jest.mock('@legendapp/list/keyboard', () => ({ + ...jest.requireActual('@legendapp/list/keyboard'), + KeyboardAwareLegendList: jest.requireActual('@legendapp/list/reanimated').AnimatedLegendList, +})); + jest.mock('react-native-reanimated', () => { const RNReanimatedmock = require('react-native-reanimated/mock'); // RNGH (>= 3.3) subscribes to SharedValue-backed gesture config on the UI runtime via diff --git a/package/jest.config.js b/package/jest.config.js index 1ac5fcc627..8cf2405ff6 100644 --- a/package/jest.config.js +++ b/package/jest.config.js @@ -5,6 +5,10 @@ module.exports = { // cap worker count on CI ...(process.env.CI ? { maxWorkers: 2 } : {}), moduleNameMapper: { + // LegendList's exports map has only a `react-native` condition for its native build, which + // Jest's node conditions skip for the web build (react-dom). + '^@legendapp/list/(keyboard|react-native|reanimated)$': + '/node_modules/@legendapp/list/$1.js', 'mock-builders(.*)$': '/src/mock-builders$1', // See __mocks__/SvgTouchableMixin.js - react-native-svg reads `Touchable.Mixin` off the // react-native root, and RN 0.87's late, non-enumerable re-export of it is lost through diff --git a/package/native-package/.gitignore b/package/native-package/.gitignore index 81e0235dce..c70b7e72ca 100644 --- a/package/native-package/.gitignore +++ b/package/native-package/.gitignore @@ -1,6 +1,7 @@ # android android/build android/src/main/java/com/streamchatreactnative/shared +android/src/agent-sign-in # ios ios/build diff --git a/package/native-package/a2ui/index.d.ts b/package/native-package/a2ui/index.d.ts new file mode 100644 index 0000000000..7c6e147c82 --- /dev/null +++ b/package/native-package/a2ui/index.d.ts @@ -0,0 +1 @@ +export * from 'stream-chat-react-native-core/a2ui'; diff --git a/package/native-package/a2ui/index.js b/package/native-package/a2ui/index.js new file mode 100644 index 0000000000..ca0f6bd382 --- /dev/null +++ b/package/native-package/a2ui/index.js @@ -0,0 +1,6 @@ +// The A2UI renderer for the AI components' generated UI, an entry point of its own so an app whose +// agent never writes A2UI doesn't bundle the A2UI core. It draws with the AI components, whose +// entry point registers the native pieces they need. +import '../ai-components'; + +export * from 'stream-chat-react-native-core/a2ui'; diff --git a/package/native-package/ai-components/conversation/index.d.ts b/package/native-package/ai-components/conversation/index.d.ts new file mode 100644 index 0000000000..8296958f70 --- /dev/null +++ b/package/native-package/ai-components/conversation/index.d.ts @@ -0,0 +1 @@ +export * from 'stream-chat-react-native-core/ai-components/conversation'; diff --git a/package/native-package/ai-components/conversation/index.js b/package/native-package/ai-components/conversation/index.js new file mode 100644 index 0000000000..260a4673dd --- /dev/null +++ b/package/native-package/ai-components/conversation/index.js @@ -0,0 +1,6 @@ +// The AI conversation, an entry point of its own because it needs react-native-keyboard-controller, +// which the rest of the AI components don't. It draws with the AI components, whose entry point +// registers the native pieces they need. +import '..'; + +export * from 'stream-chat-react-native-core/ai-components/conversation'; diff --git a/package/native-package/android/build.gradle b/package/native-package/android/build.gradle index 11d3af6763..187eef47e3 100644 --- a/package/native-package/android/build.gradle +++ b/package/native-package/android/build.gradle @@ -49,6 +49,26 @@ def hasNativeSources = { File dir -> dir.exists() && !fileTree(dir).matching { include "**/*.kt"; include "**/*.java" }.files.isEmpty() } +// The agent components' sign-ins open in the browser's Auth Tab, which needs androidx.browser +// 1.9.0, and that needs the app to build with compileSdk 36 and AGP 8.9.1. An app below either +// gets 1.8.0 and opens them in a Custom Tab instead, so the SDK asks neither of anyone. +def versionAtLeast = { String version, List wanted -> + def parts = version.tokenize('.-').collect { it.replaceAll(/\D.*$/, '') }.findAll { it }.collect { it.toInteger() } + for (int i = 0; i < wanted.size(); i++) { + def part = i < parts.size() ? parts[i] : 0 + if (part != wanted[i]) return part > wanted[i] + } + return true +} +def agentSignInWithAuthTab = + getExtOrIntegerDefault("compileSdkVersion").toString().toInteger() >= 36 && + versionAtLeast(com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION, [8, 9, 1]) +def agentSignInVariant = agentSignInWithAuthTab ? "authtab" : "customtabs" +def sharedAgentSignInDir = new File(canonicalProjectDir, "../../shared-native/android-${agentSignInVariant}") +def agentSignInDir = hasNativeSources(sharedAgentSignInDir) + ? sharedAgentSignInDir + : new File(projectDir, "src/agent-sign-in/${agentSignInVariant}") + android { compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION @@ -92,6 +112,7 @@ android { java.srcDirs += [ "src/newarch", "src/main/java/com/streamchatreactnative/shared", + agentSignInDir, // This is needed to build Kotlin project with NewArch enabled "${project.buildDir}/generated/source/codegen/java" ] @@ -158,6 +179,9 @@ dependencies { //noinspection GradleDynamicVersion implementation "com.facebook.react:react-native:+" implementation "androidx.exifinterface:exifinterface:1.3.2" + // The agent components' tool-call sign-ins: an Auth Tab where the app's build allows 1.9.0, + // else a Custom Tab (see `agentSignInWithAuthTab`). + implementation "androidx.browser:browser:${agentSignInWithAuthTab ? '1.9.0' : '1.8.0'}" } if (isNewArchitectureEnabled()) { diff --git a/package/native-package/android/src/main/AndroidManifest.xml b/package/native-package/android/src/main/AndroidManifest.xml index 95bb2ff52c..df471ba82c 100644 --- a/package/native-package/android/src/main/AndroidManifest.xml +++ b/package/native-package/android/src/main/AndroidManifest.xml @@ -1,4 +1,12 @@ - \ No newline at end of file + + + + + + + + diff --git a/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativePackage.java b/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativePackage.java index 0a07a2c2b4..aa8e7be391 100644 --- a/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativePackage.java +++ b/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativePackage.java @@ -17,6 +17,7 @@ public class StreamChatReactNativePackage extends TurboReactPackage { private static final String STREAM_MULTIPART_UPLOADER_MODULE = "StreamMultipartUploader"; private static final String STREAM_VIDEO_THUMBNAIL_MODULE = "StreamVideoThumbnail"; private static final String AI_DICTATION_MODULE = "AIDictation"; + private static final String AI_AGENT_MODULE = "AIAgent"; @Nullable @Override @@ -38,6 +39,11 @@ public NativeModule getModule(String name, ReactApplicationContext reactContext) "com.streamchatreactnative.AIDictationModule", reactContext ); + } else if (name.equals(AI_AGENT_MODULE)) { + return createNewArchModule( + "com.streamchatreactnative.AIAgentModule", + reactContext + ); } else { return null; } @@ -92,6 +98,17 @@ public ReactModuleInfoProvider getReactModuleInfoProvider() { false, // isCxxModule true // isTurboModule )); + moduleInfos.put( + AI_AGENT_MODULE, + new ReactModuleInfo( + AI_AGENT_MODULE, + AI_AGENT_MODULE, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // hasConstants + false, // isCxxModule + true // isTurboModule + )); return moduleInfos; }; } diff --git a/package/native-package/android/src/newarch/com/streamchatreactnative/AIAgentModule.kt b/package/native-package/android/src/newarch/com/streamchatreactnative/AIAgentModule.kt new file mode 100644 index 0000000000..35ac1c99d6 --- /dev/null +++ b/package/native-package/android/src/newarch/com/streamchatreactnative/AIAgentModule.kt @@ -0,0 +1,37 @@ +package com.streamchatreactnative + +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.streamchatreactnative.shared.agent.AIAgentController + +/** + * The `AIAgent` TurboModule used by the `ai-components` agent views: this install's identifier + * and the sign-ins of tool calls. The implementation is shared with the other SDK package; see + * [AIAgentController]. + */ +class AIAgentModule( + reactContext: ReactApplicationContext, +) : NativeAIAgentSpec(reactContext) { + private val controller = AIAgentController(reactContext) + + override fun getName(): String = NAME + + override fun initialize() { + super.initialize() + controller.initialize() + } + + override fun invalidate() { + super.invalidate() + controller.invalidate() + } + + override fun installId(): String = controller.installId() + + override fun openAuthSession(url: String, callbackScheme: String, promise: Promise) = + controller.openAuthSession(url, callbackScheme, promise) + + companion object { + const val NAME = AIAgentController.NAME + } +} diff --git a/package/native-package/package.json b/package/native-package/package.json index c415e036a8..c46bfe2165 100644 --- a/package/native-package/package.json +++ b/package/native-package/package.json @@ -23,7 +23,8 @@ "*.podspec", "react-native.config.js", "package.json", - "ai-components" + "ai-components", + "a2ui" ], "license": "SEE LICENSE IN LICENSE", "main": "src/index.js", @@ -43,6 +44,7 @@ "react-native-blob-util": ">=0.22.0", "react-native-haptic-feedback": ">=2.3.0", "react-native-image-picker": ">=7.1.2", + "react-native-keyboard-controller": ">=1.21.7", "react-native-nitro-sound": ">=0.2.9", "react-native-share": ">=11.0.0", "react-native-video": ">=6.18.0", @@ -73,6 +75,9 @@ "react-native-image-picker": { "optional": true }, + "react-native-keyboard-controller": { + "optional": true + }, "react-native-nitro-sound": { "optional": true }, @@ -105,6 +110,7 @@ }, "ios": { "modulesProvider": { + "AIAgent": "AIAgent", "StreamChatReactNative": "StreamChatReactNative", "StreamMultipartUploader": "StreamMultipartUploader", "StreamVideoThumbnail": "StreamVideoThumbnail" diff --git a/package/native-package/src/native/NativeAIAgent.ts b/package/native-package/src/native/NativeAIAgent.ts new file mode 100644 index 0000000000..7e22ca992e --- /dev/null +++ b/package/native-package/src/native/NativeAIAgent.ts @@ -0,0 +1,25 @@ +import type { TurboModule } from 'react-native'; +import { TurboModuleRegistry } from 'react-native'; + +/** + * Codegen spec of the native module behind the agent components: this install's identifier and + * the sign-ins of tool calls. + * + * This file is duplicated (byte for byte) in `package/native-package/src/native/NativeAIAgent.ts` + * and `package/expo-package/src/native/NativeAIAgent.ts`, which are the copies codegen reads. + * `yarn validate-entry-isolation` fails if they drift apart. + */ +export interface Spec extends TurboModule { + /** This install's identifier, created on first use and kept in storage that isn't backed up. */ + installId(): string; + /** + * Opens `url` in the platform's web authentication session and resolves with the URL the page + * handed back to `callbackScheme`, or an empty string when the person closed it. Rejects when + * the session couldn't be opened. + */ + openAuthSession(url: string, callbackScheme: string): Promise; +} + +// `get` rather than `getEnforcing`, so importing the AI entry point never throws in an app built +// without the module, such as one on an older version of the SDK's native code. +export default TurboModuleRegistry.get('AIAgent'); diff --git a/package/native-package/stream-chat-react-native.podspec b/package/native-package/stream-chat-react-native.podspec index aef09c54f1..c584dcd81e 100644 --- a/package/native-package/stream-chat-react-native.podspec +++ b/package/native-package/stream-chat-react-native.podspec @@ -42,9 +42,12 @@ Pod::Spec.new do |s| false end + # `AIAgent` (the agent components' install id and tool-call sign-ins) opens sign-ins in + # `ASWebAuthenticationSession`, which needs no usage description, so it is always compiled. if dictation_enabled - s.frameworks = "Speech" + s.frameworks = ["AuthenticationServices", "Speech"] else + s.frameworks = "AuthenticationServices" s.exclude_files = ["ios/shared/AIDictation.h", "ios/shared/AIDictation.mm", "ios/shared/AIDictationModule.swift"] end diff --git a/package/package.json b/package/package.json index 68f6b14b53..081761a6e0 100644 --- a/package/package.json +++ b/package/package.json @@ -21,7 +21,8 @@ "lib", "package.json", "README.md", - "ai-components" + "ai-components", + "a2ui" ], "scripts": { "shared-native:sync": "bash ./scripts/sync-shared-native.sh all", @@ -69,8 +70,10 @@ ] }, "dependencies": { + "@a2ui/web_core": "~0.15.0", "@gorhom/bottom-sheet": "^5.2.14", "@khanacademy/simple-markdown": "^2.1.0", + "@legendapp/list": "^3.7.1", "@stream-io/i18n": "^1.1.0", "@stream-io/state-store": "^1.2.0", "@ungap/structured-clone": "^1.3.0", @@ -98,6 +101,7 @@ "emoji-mart": ">=5.4.0", "react-native": ">=0.79.0", "react-native-gesture-handler": ">=2.18.0", + "react-native-keyboard-controller": ">=1.21.7", "react-native-reanimated": ">=3.16.0", "react-native-safe-area-context": ">=5.4.1", "react-native-svg": ">=15.8.0", @@ -120,6 +124,9 @@ "emoji-mart": { "optional": true }, + "react-native-keyboard-controller": { + "optional": true + }, "victory-native": { "optional": true } @@ -159,6 +166,7 @@ "react-native": "0.88.0", "react-native-builder-bob": "0.40.11", "react-native-gesture-handler": "^3.4.0", + "react-native-keyboard-controller": "^1.22.7", "react-native-reanimated": "^4.7.1", "react-native-safe-area-context": "^5.10.1", "react-native-svg": "15.15.5", diff --git a/package/scripts/clean-shared-native-copies.sh b/package/scripts/clean-shared-native-copies.sh index 4b183454ce..1b96e1fc3b 100644 --- a/package/scripts/clean-shared-native-copies.sh +++ b/package/scripts/clean-shared-native-copies.sh @@ -11,8 +11,9 @@ clean_package() { local package_name="$1" local android_dir="$ROOT_DIR/$package_name/android/src/main/java/com/streamchatreactnative/shared" local ios_dir="$ROOT_DIR/$package_name/ios/shared" + local agent_sign_in_dir="$ROOT_DIR/$package_name/android/src/agent-sign-in" - rm -rf "$android_dir" "$ios_dir" + rm -rf "$android_dir" "$ios_dir" "$agent_sign_in_dir" rm -f \ "$STATE_DIR/${package_name}_android.manifest" \ "$STATE_DIR/${package_name}_ios.manifest" diff --git a/package/scripts/sync-shared-native.sh b/package/scripts/sync-shared-native.sh index 8d09ded9d5..06d0acb396 100644 --- a/package/scripts/sync-shared-native.sh +++ b/package/scripts/sync-shared-native.sh @@ -7,6 +7,9 @@ TARGET="${1:-all}" ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" SHARED_ANDROID_DIR="$ROOT_DIR/shared-native/android" SHARED_IOS_DIR="$ROOT_DIR/shared-native/ios" +# The agent sign-in's two Android builds: with Auth Tab (androidx.browser 1.9.0) and without. +# Each package's build.gradle compiles the one the app's build allows. +AGENT_SIGN_IN_VARIANTS=(authtab customtabs) sync_dir_contents() { local src_dir="$1" @@ -33,6 +36,13 @@ copy_to_package() { echo "Skipping Android sync: missing $SHARED_ANDROID_DIR" fi + for variant in "${AGENT_SIGN_IN_VARIANTS[@]}"; do + local variant_src="$ROOT_DIR/shared-native/android-$variant" + if [ -d "$variant_src" ]; then + sync_dir_contents "$variant_src" "$ROOT_DIR/$package_name/android/src/agent-sign-in/$variant" + fi + done + if [ -d "$SHARED_IOS_DIR" ]; then sync_dir_contents "$SHARED_IOS_DIR" "$ios_dst_dir" else diff --git a/package/shared-native/android-authtab/AgentAuthTab.kt b/package/shared-native/android-authtab/AgentAuthTab.kt new file mode 100644 index 0000000000..2092f9d21c --- /dev/null +++ b/package/shared-native/android-authtab/AgentAuthTab.kt @@ -0,0 +1,24 @@ +package com.streamchatreactnative.shared.agent + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.result.ActivityResultLauncher +import androidx.browser.auth.AuthTabIntent +import androidx.browser.customtabs.CustomTabsClient + +/** + * The browser's Auth Tab, from `androidx.browser` 1.9.0. Compiled in when the app builds with + * compileSdk 36 and AGP 8.9.1 or newer, which that version needs; otherwise the build takes the + * `android-customtabs` copy of this file, and sign-ins open in a Custom Tab. + */ +internal object AgentAuthTab { + fun isSupported(context: Context, browser: String): Boolean = + CustomTabsClient.isAuthTabSupported(context, browser) + + fun launch(launcher: ActivityResultLauncher, browser: String, uri: Uri, callbackScheme: String) { + AuthTabIntent.Builder().build() + .apply { intent.setPackage(browser) } + .launch(launcher, uri, callbackScheme) + } +} diff --git a/package/shared-native/android-customtabs/AgentAuthTab.kt b/package/shared-native/android-customtabs/AgentAuthTab.kt new file mode 100644 index 0000000000..4e6d9fa0bc --- /dev/null +++ b/package/shared-native/android-customtabs/AgentAuthTab.kt @@ -0,0 +1,20 @@ +package com.streamchatreactnative.shared.agent + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.result.ActivityResultLauncher + +/** + * No Auth Tab: the app builds below compileSdk 36 or AGP 8.9.1, which `androidx.browser` 1.9.0 + * needs, so sign-ins open in a Custom Tab. The `android-authtab` copy of this file has the Auth Tab. + */ +internal object AgentAuthTab { + @Suppress("UNUSED_PARAMETER") + fun isSupported(context: Context, browser: String): Boolean = false + + @Suppress("UNUSED_PARAMETER") + fun launch(launcher: ActivityResultLauncher, browser: String, uri: Uri, callbackScheme: String) { + throw UnsupportedOperationException("Auth Tabs need androidx.browser 1.9.0.") + } +} diff --git a/package/shared-native/android/PerfTextViewManager.kt b/package/shared-native/android/PerfTextViewManager.kt index 76a03d5df8..6aa7c7963d 100644 --- a/package/shared-native/android/PerfTextViewManager.kt +++ b/package/shared-native/android/PerfTextViewManager.kt @@ -83,7 +83,7 @@ class PerfTextViewManager : SimpleViewManager() { ): Long { val view = PerfTextView(context) - // Apply props that influence size (text, fontSize, lineHeight) + // Apply props that influence size (text, fontSize, fontFamily, lineHeight) props?.let { p -> if (p.hasKey("fontSize") && !p.isNull("fontSize")) { val fs = p.getDouble("fontSize") @@ -92,9 +92,13 @@ class PerfTextViewManager : SimpleViewManager() { } } - if (p.hasKey("fontFamily") && !p.isNull("fontFamily")) { - view.setFontFamilyCompat(p.getString("fontFamily")) - } + view.setFontFamilyCompat( + if (p.hasKey("fontFamily") && !p.isNull("fontFamily")) { + p.getString("fontFamily")?.takeIf { it.isNotEmpty() } + } else { + null + }, + ) if (p.hasKey("lineHeight") && !p.isNull("lineHeight")) { val lh = p.getDouble("lineHeight") @@ -137,7 +141,7 @@ class PerfTextViewManager : SimpleViewManager() { val measuredWidthDp = ceil(PixelUtil.toDIPFromPixel(view.measuredWidth.toFloat())) val measuredHeightDp = ceil(PixelUtil.toDIPFromPixel(view.measuredHeight.toFloat())) - return YogaMeasureOutput.make(measuredWidthDp * 1.6f, measuredHeightDp * 1.0f) + return YogaMeasureOutput.make(measuredWidthDp, measuredHeightDp) } } diff --git a/package/shared-native/android/agent/AIAgentController.kt b/package/shared-native/android/agent/AIAgentController.kt new file mode 100644 index 0000000000..ae8e276a22 --- /dev/null +++ b/package/shared-native/android/agent/AIAgentController.kt @@ -0,0 +1,197 @@ +package com.streamchatreactnative.shared.agent + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.browser.customtabs.CustomTabsClient +import androidx.browser.customtabs.CustomTabsIntent +import com.facebook.react.bridge.ActivityEventListener +import com.facebook.react.bridge.LifecycleEventListener +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import java.io.File +import java.lang.ref.WeakReference +import java.util.Locale +import java.util.UUID + +/** + * This install's identifier and the sign-ins of tool calls, for the `AIAgent` TurboModule of both + * SDK packages (each package's codegen generates its own `NativeAIAgentSpec`), which forward + * their calls here. + * + * A sign-in opens in the browser's Auth Tab where the app is built with one (see + * `AgentAuthTab`) and the browser has it: the tab hands the page's redirect to the callback scheme + * straight back. Otherwise it opens in a Custom Tab: the redirect then reaches the app as a new + * intent, and coming back to the app without one means the person closed the tab. + */ +class AIAgentController(private val reactContext: ReactApplicationContext) : + ActivityEventListener, + LifecycleEventListener { + companion object { + const val NAME = "AIAgent" + private const val INSTALL_ID_FILE = "io.getstream.ai.client-id" + private const val ERROR = "ai_agent_auth_session" + } + + /** The sign-in open now. One runs at a time; opening another ends this one. */ + private class SignIn( + val callbackScheme: String, + val promise: Promise, + /** The key the Auth Tab's result comes back under, or null for a Custom Tab. */ + val resultKey: String?, + ) { + /** Where the Auth Tab's result is received, and the activity it is registered on. */ + var launcher: ActivityResultLauncher? = null + var host: WeakReference? = null + + /** Whether the person left the app for the Custom Tab, so coming back means it was closed. */ + var left = false + } + + private var signIn: SignIn? = null + private var launches = 0 + + fun initialize() { + reactContext.addActivityEventListener(this) + reactContext.addLifecycleEventListener(this) + } + + fun invalidate() { + reactContext.removeActivityEventListener(this) + reactContext.removeLifecycleEventListener(this) + finish("") + } + + /** + * This install's identifier, created on first use and kept until the app's data is cleared. + * It is not backed up, so a device restored from a backup gets its own. + */ + @Synchronized + fun installId(): String { + // noBackupFilesDir: a restored device must not answer calls meant for the old one. + val file = File(reactContext.applicationContext.noBackupFilesDir, INSTALL_ID_FILE) + if (file.exists()) file.readText().takeIf { it.isNotBlank() }?.let { return it } + val created = "android-" + UUID.randomUUID().toString().uppercase(Locale.ROOT) + file.writeText(created) + return created + } + + fun openAuthSession(url: String, callbackScheme: String, promise: Promise) { + val activity = reactContext.currentActivity + if (activity == null) { + promise.reject(ERROR, "There's no activity to open the sign-in from.") + return + } + activity.runOnUiThread { + finish("") + val uri = Uri.parse(url) + val browser = CustomTabsClient.getPackageName(activity, null) + try { + if (activity is ComponentActivity && + browser != null && + AgentAuthTab.isSupported(activity, browser) + ) { + openAuthTab(activity, browser, uri, callbackScheme, promise) + } else { + signIn = SignIn(callbackScheme, promise, resultKey = null) + CustomTabsIntent.Builder().build().launchUrl(activity, uri) + } + } catch (error: Exception) { + signIn = null + promise.reject(ERROR, error.message ?: "The sign-in couldn't be opened.", error) + } + } + } + + private fun openAuthTab( + activity: ComponentActivity, + browser: String, + uri: Uri, + callbackScheme: String, + promise: Promise, + ) { + val current = SignIn(callbackScheme, promise, resultKey = "stream-chat-ai-sign-in-${launches++}") + signIn = current + val launcher = receiveResult(current, activity) + AgentAuthTab.launch(launcher, browser, uri, callbackScheme) + } + + // Registers where `current`'s Auth Tab result is received, on `activity`. It is registered for + // this sign-in alone and dropped once it answers, since a TurboModule can't register before the + // activity starts, as `AuthTabIntent.registerActivityResultLauncher` requires. Auth Tab's own + // contract only passes the intent through and reads the result's data, which is what + // `StartActivityForResult` does. An activity recreated while the tab is open, such as one + // rotated or reclaimed in the background, keeps the result under the same key until it is + // registered on it again, which `onHostResume` does. + private fun receiveResult(current: SignIn, activity: ComponentActivity): ActivityResultLauncher { + current.launcher?.unregister() + current.host = WeakReference(activity) + val launcher = activity.activityResultRegistry.register( + current.resultKey!!, + ActivityResultContracts.StartActivityForResult(), + ) { result -> + if (signIn !== current) return@register + when (result.resultCode) { + // Auth Tab's RESULT_OK and RESULT_CANCELED are the activity's own. + Activity.RESULT_OK -> finish(result.data?.data?.toString() ?: "") + Activity.RESULT_CANCELED -> finish("") + else -> fail("The sign-in didn't complete.") + } + } + current.launcher = launcher + return launcher + } + + private fun finish(callback: String) { + val current = signIn ?: return + signIn = null + current.launcher?.unregister() + current.promise.resolve(callback) + } + + private fun fail(message: String) { + val current = signIn ?: return + signIn = null + current.launcher?.unregister() + current.promise.reject(ERROR, message) + } + + // The Custom Tab's redirect to the callback scheme reaches the app as a new intent, which + // arrives before the activity resumes. + override fun onNewIntent(intent: Intent) { + val current = signIn ?: return + val data = intent.data ?: return + if (current.resultKey == null && data.scheme.equals(current.callbackScheme, ignoreCase = true)) { + finish(data.toString()) + } + } + + override fun onActivityResult( + activity: Activity, + requestCode: Int, + resultCode: Int, + data: Intent?, + ) = Unit + + override fun onHostPause() { + signIn?.takeIf { it.resultKey == null }?.left = true + } + + override fun onHostResume() { + val current = signIn ?: return + if (current.resultKey != null) { + // The activity was recreated while the Auth Tab was open: its result waits on this one. + val activity = reactContext.currentActivity + if (activity is ComponentActivity && current.host?.get() !== activity) { + receiveResult(current, activity) + } + return + } + if (current.left) finish("") + } + + override fun onHostDestroy() = Unit +} diff --git a/package/shared-native/android/jni/PerfTextMeasurementManager.cpp b/package/shared-native/android/jni/PerfTextMeasurementManager.cpp index d0a5fb5324..f79a44d9a1 100644 --- a/package/shared-native/android/jni/PerfTextMeasurementManager.cpp +++ b/package/shared-native/android/jni/PerfTextMeasurementManager.cpp @@ -42,6 +42,7 @@ Size PerfTextMeasurementManager::measure( serializedProps["text"] = props.text; serializedProps["fontSize"] = props.fontSize; serializedProps["lineHeight"] = props.lineHeight; + serializedProps["fontFamily"] = props.fontFamily; auto propsRNM = ReadableNativeMap::newObjectCxxArgs(serializedProps); local_ref propsRM = diff --git a/package/shared-native/ios/AIAgent.h b/package/shared-native/ios/AIAgent.h new file mode 100644 index 0000000000..e9c50ba2b9 --- /dev/null +++ b/package/shared-native/ios/AIAgent.h @@ -0,0 +1,16 @@ +#ifdef RCT_NEW_ARCH_ENABLED + +#if __has_include("StreamChatReactNativeSpec.h") +#import "StreamChatReactNativeSpec.h" +#elif __has_include("StreamChatExpoSpec.h") +#import "StreamChatExpoSpec.h" +#else +#error "Unable to find generated codegen spec header for AIAgent." +#endif + +// This install's identifier and the sign-ins of tool calls, for the `ai-components` agent views. +// The implementation lives in AIAgentModule.swift. +@interface AIAgent : NSObject +@end + +#endif diff --git a/package/shared-native/ios/AIAgent.mm b/package/shared-native/ios/AIAgent.mm new file mode 100644 index 0000000000..1ae5578491 --- /dev/null +++ b/package/shared-native/ios/AIAgent.mm @@ -0,0 +1,65 @@ +#import "AIAgent.h" + +#ifdef RCT_NEW_ARCH_ENABLED + +#if __has_include() +#import +#elif __has_include() +#import +#elif __has_include("stream_chat_react_native-Swift.h") +#import "stream_chat_react_native-Swift.h" +#elif __has_include("stream_chat_expo-Swift.h") +#import "stream_chat_expo-Swift.h" +#else +#error "Unable to import generated Swift header for AIAgent." +#endif + +@implementation AIAgent { + AIAgentModule *moduleInstance; +} + +RCT_EXPORT_MODULE(AIAgent) + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +- (instancetype)init +{ + if (self = [super init]) { + moduleInstance = [AIAgentModule new]; + } + return self; +} + +- (NSString *)installId +{ + return [AIAgentModule installId]; +} + +- (void)openAuthSession:(NSString *)url + callbackScheme:(NSString *)callbackScheme + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [moduleInstance openAuthSessionWithUrl:url + callbackScheme:callbackScheme + completion:^(NSString *_Nullable callback, NSString *_Nullable error) { + if (error) { + reject(@"ai_agent_auth_session", error, nil); + } else { + resolve(callback ?: @""); + } + }]; +} + +- (std::shared_ptr)getTurboModule: + (const facebook::react::ObjCTurboModule::InitParams &)params +{ + return std::make_shared(params); +} + +@end + +#endif diff --git a/package/shared-native/ios/AIAgentModule.swift b/package/shared-native/ios/AIAgentModule.swift new file mode 100644 index 0000000000..b308793116 --- /dev/null +++ b/package/shared-native/ios/AIAgentModule.swift @@ -0,0 +1,126 @@ +import AuthenticationServices +import Foundation +import UIKit + +/// This install's identifier and the sign-ins of tool calls, for the `AIAgent` TurboModule +/// (`AIAgent.mm`). +@objc(AIAgentModule) +public class AIAgentModule: NSObject { + private static let installIdKey = "io.getstream.ai.client-id" + + /// The session open now. One sign-in runs at a time; opening another ends this one. + private var session: ASWebAuthenticationSession? + private var completion: ((String?, String?) -> Void)? + private let anchor = PresentationAnchor() + + /// This install's identifier, created on first use. It is kept in a file left out of backups, + /// as on Android, so a phone restored from another's backup never answers tool calls meant + /// for that one. An id already kept in UserDefaults under the same key carries over, so a + /// native app moving to React Native keeps its id. + @objc public static func installId() -> String { + let file = installIdFile() + if let file, let saved = try? String(contentsOf: file, encoding: .utf8), !saved.isEmpty { + return saved + } + let id = UserDefaults.standard.string(forKey: installIdKey) ?? "ios-" + UUID().uuidString + guard let file, keep(id, in: file) else { + // Without the file, UserDefaults keeps it, backed up as it is. + UserDefaults.standard.set(id, forKey: installIdKey) + return id + } + // The backups from now on don't carry it. + UserDefaults.standard.removeObject(forKey: installIdKey) + return id + } + + // Application Support/io.getstream.ai/install-id. + private static func installIdFile() -> URL? { + guard let support = try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) else { return nil } + return support.appendingPathComponent("io.getstream.ai", isDirectory: true) + .appendingPathComponent("install-id") + } + + // Writes `id` to `file`, left out of backups. Whether it did. + private static func keep(_ id: String, in file: URL) -> Bool { + do { + try FileManager.default.createDirectory( + at: file.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try id.write(to: file, atomically: true, encoding: .utf8) + var values = URLResourceValues() + values.isExcludedFromBackup = true + var excluded = file + try excluded.setResourceValues(values) + return true + } catch { + try? FileManager.default.removeItem(at: file) + return false + } + } + + /// Opens `url` in a web authentication session. `completion` gets the URL the page handed + /// back to `callbackScheme`, nothing when the person closed the sheet, or an error when the + /// session couldn't be opened. + @objc public func openAuthSession( + url: String, + callbackScheme: String, + completion: @escaping (String?, String?) -> Void + ) { + DispatchQueue.main.async { + guard let target = URL(string: url) else { + completion(nil, "The sign-in URL isn't valid.") + return + } + // A sign-in still open is ended as closed; its session's own answer is then ignored. + let previous = self.session + self.finish(nil, error: nil) + previous?.cancel() + + // Weak, so the session and its own callback don't hold each other. + weak var opened: ASWebAuthenticationSession? + let session = ASWebAuthenticationSession(url: target, callbackURLScheme: callbackScheme) { callback, error in + let cancelled = (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin + DispatchQueue.main.async { + guard let opened, self.session === opened else { return } + if let callback { + self.finish(callback.absoluteString, error: nil) + } else if cancelled { + self.finish(nil, error: nil) + } else { + self.finish(nil, error: error?.localizedDescription ?? "The sign-in couldn't be opened.") + } + } + } + opened = session + session.presentationContextProvider = self.anchor + self.session = session + self.completion = completion + if !session.start() { + self.finish(nil, error: "The sign-in couldn't be opened.") + } + } + } + + // The session may answer after a start that failed, so each sign-in is answered once. + private func finish(_ callback: String?, error: String?) { + guard let completion else { return } + self.completion = nil + session = nil + completion(callback, error) + } +} + +// Kept off `AIAgentModule`, whose conformances go in the generated Swift header that every +// Objective-C file of the pod imports without AuthenticationServices. +private final class PresentationAnchor: NSObject, ASWebAuthenticationPresentationContextProviding { + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + return scenes.flatMap(\.windows).first(where: \.isKeyWindow) ?? scenes.first?.windows.first ?? ASPresentationAnchor() + } +} diff --git a/package/src/a2ui/A2UIContexts.ts b/package/src/a2ui/A2UIContexts.ts new file mode 100644 index 0000000000..767f22ac66 --- /dev/null +++ b/package/src/a2ui/A2UIContexts.ts @@ -0,0 +1,15 @@ +import { createContext } from 'react'; + +import type { A2UIEngine } from './A2UIRenderer'; + +/** The engine of the surfaces below, for what in them reaches the host, such as links. */ +export const A2UIEngineContext = createContext(undefined); + +/** The reply the surfaces below belong to, and whether the agent is still writing it. */ +export const A2UIReplyContext = createContext< + | { + isStreaming: boolean; + messageId: string; + } + | undefined +>(undefined); diff --git a/package/src/a2ui/A2UIInstructions.ts b/package/src/a2ui/A2UIInstructions.ts new file mode 100644 index 0000000000..90c9b6f71c --- /dev/null +++ b/package/src/a2ui/A2UIInstructions.ts @@ -0,0 +1,102 @@ +// What an agent is told to write A2UI with, and what checks it. +import { A2UI_VERSION } from './A2UIStream'; + +import type { AIUIComponent } from '../ai-components/generative-ui/AIUIComponent'; +import type { AIUIComponentDefinition } from '../ai-components/generative-ui/AIUIComponentDefinition'; +import { isRecord } from '../ai-components/generative-ui/AIUIHelpers'; +import type { AIClientToolDefinition } from '../ai-components/tool-calls/AIClientToolRunner'; + +/** + * What an agent is told to write replies' UI in A2UI with: the basic catalog, or the part of it + * `keep` names, with the app's components, and what the app adds. + */ +export type A2UIInstructionsConfig = { + /** The id of the catalog with the app's components, as the renderer names it. */ + catalogId?: string; + /** The app's components, or their portable definitions, such as a `UIComponents.json`. */ + components?: (AIUIComponent | AIUIComponentDefinition)[]; + /** + * Replies with their UI, as the agent should write them. Each block must render with the + * catalog, or no instructions are written: a broken example teaches the model broken UI. + */ + examples?: string[]; + /** The info string of a reply's UI block, as the agents router reads it. */ + fence?: string; + /** The basic catalog's components and functions to describe; all of them when absent. */ + keep?: string[]; + /** + * The basic catalog's components and functions to leave out, such as those the app's renderer + * leaves out with its own `removing`, after `keep`. + */ + removing?: string[]; + /** Guidance on using the app's components. */ + notes?: string[]; + /** What the model is told first, such as which answers to write as UI. */ + preamble?: string; + /** Rules added after the protocol's. */ + rules?: string[]; + /** The app's tools the agent can call from the UI it writes, as functions of the catalog. */ + tools?: AIClientToolDefinition[]; +}; + +/** The A2UI an example holds that would not render, and why. */ +export class A2UIInstructionsError extends Error { + readonly problems: string[]; + + constructor(problems: string[]) { + super(`An example's A2UI would not render:\n${problems.join('\n')}`); + this.name = 'A2UIInstructionsError'; + this.problems = problems; + } +} + +/** A description of the A2UI v1.0 messages and the rules that keep what they make valid. */ +export const A2UI_PROTOCOL_OVERVIEW = `# Generating user interfaces with A2UI v1.0 + +You render user interfaces by emitting A2UI messages. Every message is a JSON object with a \`"version": "1.0"\` field plus exactly one of these keys: + +- \`createSurface\`: creates a surface. Requires \`surfaceId\`, and accepts \`catalogId\`, \`sendDataModel\`, \`components\` and \`dataModel\` so a complete UI can be sent in one message. +- \`updateComponents\`: adds or replaces components of an existing surface. Requires \`surfaceId\` and \`components\`. +- \`updateDataModel\`: writes to the surface data model. Requires \`surfaceId\` and \`value\`, and accepts a JSON Pointer \`path\`. Setting \`value\` to \`null\` deletes the entry at \`path\`. +- \`deleteSurface\`: removes a surface. Requires \`surfaceId\`. +- \`callRendererFunction\`: asks the renderer to run one of its functions. +- \`agentFunctionResponse\`: answers a \`callAgentFunction\` request from the renderer. + +Rules: + +1. Components are a flat adjacency list. Each component has a unique \`id\`, a \`component\` type, and its properties inline. Exactly one component must have the id \`root\`. An id is an identifier: letters, digits and underscores, not starting with a digit, such as \`saturday_plan\` (never \`saturday-plan\`). +2. Children are referenced by id, never nested inline. Use an array of ids, or \`{"componentId": "...", "path": "/items"}\` to repeat a template for every item of a data model list. +3. Any property may be a literal, a data binding \`{"@path": "/a/b"}\` or a function call \`{"@call": "name", "args": {...}}\`. Inside a list template, paths without a leading slash resolve relative to the current item, and \`{"@call": "@index"}\` returns the item's position. A literal object key that starts with \`@\` is written with \`@@\`, so \`"@@type"\` means \`"@type"\`. +4. Put content that changes in the data model and bind to it, so you can update values with \`updateDataModel\` instead of resending components. +5. Interactive components declare an \`action\`, which either dispatches an event to you (\`{"event": {"name": "...", "context": {...}}}\`) or runs a renderer function (\`{"functionCall": {"@call": "openUrl", "args": {"url": "..."}}}\`). +6. Surface ids must be unique for the lifetime of the renderer. Creating a surface that already exists is an error: delete it first, or send \`updateComponents\` and \`updateDataModel\` to change it.`; + +export const writing = (fence: string, catalogId: string) => `## Writing UI in A2UI + +Write a reply's UI in one fenced block whose info string is \`${fence}\`, after one short sentence saying what it shows, which is what people read wherever the UI can't show. In the block, write A2UI messages, one JSON object per line, and nothing else: + +1. First a \`createSurface\` with a \`surfaceId\` new to this reply, the \`catalogId\` \`${catalogId}\`, and the \`dataModel\` the UI's inputs start from. +2. Then \`updateComponents\` messages for that surface with a few components each, \`root\` first, so the UI shows as you write it. + +A reply's UI is shown as you wrote it: never write \`callRendererFunction\` or \`agentFunctionResponse\`, and never change an earlier reply's UI, but write a new one. What the person does in it comes back as their next message, which says what the button they tapped says. Give every event action a \`userMessage\` saying what it asks of you, such as "Update the plan", which comes back with it, and a \`context\` with what you need to know, bound to the data model.`; + +// A JSON value with its object keys sorted, so the same catalog always reads the same. +export const sorted = (value: unknown): unknown => + Array.isArray(value) + ? value.map(sorted) + : isRecord(value) + ? Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, sorted(value[key])]), + ) + : value; + +export const fenceOf = (fence?: string) => fence ?? `ui:a2ui@${A2UI_VERSION}`; + +// The A2UI blocks of an example reply. +export const blocksIn = (example: string, fence: string) => + example + .split(`\`\`\`${fence}\n`) + .slice(1) + .map((block) => block.split('\n```')[0]); diff --git a/package/src/a2ui/A2UIPlaceholder.tsx b/package/src/a2ui/A2UIPlaceholder.tsx new file mode 100644 index 0000000000..2f10223cb3 --- /dev/null +++ b/package/src/a2ui/A2UIPlaceholder.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { useTheme } from '../ai-components/contexts'; +import { Shimmer } from '../ai-components/internal/Shimmer'; +import { primitives } from '../ai-components/internal/tokens'; + +/** What shows while a reply's first surface is being written. */ +export const A2UIPlaceholder = () => { + const { + theme: { colors }, + } = useTheme(); + return ( + + + + + + ); +}; + +const styles = StyleSheet.create({ + placeholder: { borderRadius: primitives.radiusXl, height: 120 }, +}); diff --git a/package/src/a2ui/A2UIRenderer.tsx b/package/src/a2ui/A2UIRenderer.tsx new file mode 100644 index 0000000000..7ec153f93f --- /dev/null +++ b/package/src/a2ui/A2UIRenderer.tsx @@ -0,0 +1,504 @@ +import React, { useLayoutEffect, useMemo, useSyncExternalStore } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { + type ActionPayload, + type Catalog, + type ComponentApi, + MessageProcessor, + type SurfaceModel, +} from '@a2ui/web_core'; + +import { A2UIEngineContext, A2UIReplyContext } from './A2UIContexts'; +import { A2UIPlaceholder } from './A2UIPlaceholder'; +import { + A2UI_VERSION, + a2uiMessagesIn, + a2uiSourceOf, + normalizeA2UIMessage, + supportsA2UIVersion, +} from './A2UIStream'; +import { A2UISurfaceView } from './A2UISurface'; +import { A2UI_DEFAULT_CATALOG_ID, A2UI_WEB_CATALOG_ID, a2uiTypeName } from './catalogDocument'; +import { a2uiCatalogsOf } from './catalogs'; +import { A2UI_BASIC_COMPONENTS } from './components/basicComponents'; +import type { A2UIComponents, A2UIIcons } from './components/types'; +import { a2uiInstructions } from './instructions'; +import { + secretContextKeysOf, + secretPathsOf, + secretValuesOf, + withoutPaths, + withoutSecrets, +} from './secrets'; + +import { useTheme } from '../ai-components/contexts'; +import type { AIUIDocument } from '../ai-components/generative-ui/AIUIDocument'; +import { isRecord } from '../ai-components/generative-ui/AIUIHelpers'; +import type { AIUIHost } from '../ai-components/generative-ui/AIUIHost'; +import { + aiUIMediaSource, + type AIUIMediaURL, + aiUIPublicMediaURL, +} from '../ai-components/generative-ui/AIUIMedia'; +import type { AIUIEngine, AIUIRenderer } from '../ai-components/generative-ui/AIUIRenderer'; +import { primitives } from '../ai-components/internal/tokens'; + +export { A2UI_DEFAULT_CATALOG_ID, A2UI_WEB_CATALOG_ID }; + +/** What went wrong in a reply's A2UI, as the A2UI specification reports it to an agent. */ +export type A2UIError = { + /** A code such as `VALIDATION_FAILED` or `EXPRESSION_ERROR`. */ + code: string; + message: string; + /** Where in the message it went wrong, as a JSON pointer. */ + path?: string; + surfaceId?: string; +}; + +export type A2UIRendererOptions = { + /** + * The id the agent names the catalog with the app's components by. A surface naming the + * basic catalog shows them too, and a surface naming none is made with this one. + */ + catalogId?: string; + /** + * The views of the basic catalog's components to use instead of the default ones, by their + * A2UI type, such as `{ Card: MyCard }`. Each gets what the agent wrote for it, resolved. + */ + components?: A2UIComponents; + /** + * The app's icons for the names A2UI's Icon takes, such as `{ shoppingCart: MyCartIcon }`, over + * the Material icons it draws by default. An app's own names work too, when its agent is told + * them. + */ + icons?: A2UIIcons; + /** + * What the app tells the model before A2UI's own instructions in `capabilities`, such as which + * answers to write as UI. + */ + instructions?: string; + /** The locale numbers and dates are formatted in. The device's by default. */ + locale?: string; + /** + * Decides what an Image, Video or AudioPlayer loads for the link the agent wrote: the address + * to load, or `null` not to load it. By default only public web links load, never addresses on + * the person's network, such as `192.168.1.1` or `localhost`. + */ + mediaUrl?: AIUIMediaURL; + /** + * The basic catalog's components and functions this renderer leaves out, such as `['Video']`. + * The agent's instructions leave them out too, and a reply that places one shows the rest. + */ + removing?: string[]; + /** + * Tells the app what went wrong in a reply's A2UI, such as a message that isn't JSON or a + * component the catalog doesn't have, with the reply's id. The rest of the reply's UI still + * shows. What is wrong with the renderer rather than a reply, such as an app's component that + * can't join the catalog, comes without an id. Logged by default. + */ + onError?: (error: A2UIError, messageId?: string) => void; +}; + +const reportA2UIError = (options: A2UIRendererOptions, error: A2UIError, messageId?: string) => { + if (options.onError) options.onError(error, messageId); + else console.error(`A reply's A2UI can't show: ${error.code} ${error.message}`, error); +}; + +type RendererError = { + code?: unknown; + message?: unknown; + path?: unknown; + surfaceId?: unknown; +}; + +const errorOf = (error: RendererError): A2UIError => ({ + code: typeof error.code === 'string' ? error.code : 'EXECUTION_ERROR', + message: typeof error.message === 'string' ? error.message : String(error.message), + ...(typeof error.path === 'string' ? { path: error.path } : {}), + ...(typeof error.surfaceId === 'string' ? { surfaceId: error.surfaceId } : {}), +}); + +// An action's name in words, such as `Submit form` for `submit_form`, for an action whose button +// says nothing. +const wordsOf = (name: string) => { + const words = name + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .split(/[_\-\s]+/) + .filter(Boolean) + .join(' ') + .toLowerCase(); + return words ? words.charAt(0).toUpperCase() + words.slice(1) : name; +}; + +/** + * The surfaces of one reply: its own processor, fed the messages of its document as they + * complete. Messages that changed, rather than grew, start its surfaces again. + */ +export class A2UIReplyState { + isStreaming = false; + + private applied: string[] = []; + private readonly engine: A2UIEngine; + private listeners = new Set<() => void>(); + private readonly messageId: string; + private processor: MessageProcessor; + private reported = new Set(); + private shown: SurfaceModel[] = []; + private showedNothing = false; + private stopWatching: () => void; + + constructor(engine: A2UIEngine, messageId: string) { + this.engine = engine; + this.messageId = messageId; + this.processor = this.makeProcessor(); + this.stopWatching = this.watch(); + } + + /** The reply's surfaces, in the order the agent made them. */ + surfaces = (): SurfaceModel[] => this.shown; + + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + /** Applies the messages of `source` not applied yet. */ + update(source: string, isStreaming: boolean) { + this.isStreaming = isStreaming; + const messages = a2uiMessagesIn(source); + const grew = + messages.length >= this.applied.length && + this.applied.every((message, index) => messages[index] === message); + if (!grew) { + this.stopWatching(); + this.processor.dispose(); + this.processor = this.makeProcessor(); + this.stopWatching = this.watch(); + this.applied = []; + } + for (const raw of messages.slice(this.applied.length)) { + let json: unknown; + try { + json = JSON.parse(raw); + } catch { + json = undefined; + } + if (!isRecord(json)) { + this.report({ code: 'VALIDATION_FAILED', message: 'A message isn’t a JSON object' }); + continue; + } + this.feed(normalizeA2UIMessage(json, this.engine.catalogId)); + } + this.applied = messages; + this.refresh(); + // A finished reply that made no surface shows its text, which the host shows when asked + // again: it asked before these messages were applied. + const showsNothing = !isStreaming && this.shown.length === 0; + if (showsNothing !== this.showedNothing) { + this.showedNothing = showsNothing; + this.engine.host.refresh(); + } + } + + dispose() { + this.stopWatching(); + this.processor.dispose(); + this.listeners.clear(); + } + + // Feeds a message to the processor. A batch of components that one bad component spoils is + // fed again a component at a time, so the rest show, as they would while being written. + private feed(message: Record) { + const errors = this.processor.processMessagesWithErrors(message as never); + const reported = errors.length ? (this.oneAtATime(message) ?? errors) : errors; + for (const { error } of reported) this.report(errorOf(error as RendererError)); + } + + // The components of a message one at a time, with what goes wrong with each, or `undefined` + // when the message has no batch of them to feed again. A surface made with components is made + // before they are added, and stays when they fail, so they are added to it. + private oneAtATime(message: Record) { + const { createSurface, updateComponents } = message; + const batch = isRecord(updateComponents) + ? updateComponents + : isRecord(createSurface) + ? createSurface + : undefined; + if (!batch || !Array.isArray(batch.components) || batch.components.length < 2) { + return undefined; + } + const { surfaceId } = batch; + if ( + batch === createSurface && + (typeof surfaceId !== 'string' || !this.processor.model.getSurface(surfaceId)) + ) { + return undefined; + } + return batch.components.flatMap((component: unknown) => + this.processor.processMessagesWithErrors({ + updateComponents: { components: [component], surfaceId }, + version: message.version, + } as never), + ); + } + + private makeProcessor() { + return new MessageProcessor(this.engine.catalogs(), (action) => this.act(action)); + } + + // Tells the agent what the person did, in the words of the button they tapped, else its name. + // Never in the words the agent gave the action, which could say something the button doesn't; + // those go along as `userMessage`. What the person typed into an obscured field goes nowhere: + // not in the context, the message or the data model, which goes when the surface asks for it. + private act(action: ActionPayload) { + const surface = this.processor.model.getSurface(action.surfaceId); + const paths = secretPathsOf(surface); + const secrets = secretValuesOf(surface); + const context = withoutSecrets(action.context ?? {}, secrets) as Record; + for (const key of secretContextKeysOf(surface, action.sourceComponentId)) delete context[key]; + const userMessage = + typeof action.userMessage === 'string' + ? withoutSecrets(action.userMessage, secrets).trim() + : ''; + const dataModel = surface?.sendDataModel + ? withoutSecrets(withoutPaths(surface.dataModel.get('/'), paths), secrets) + : undefined; + void this.engine.host.send({ + format: 'a2ui', + label: this.buttonText(action) ?? wordsOf(action.name), + messageId: this.messageId, + name: action.name, + payload: { + context, + sourceComponentId: action.sourceComponentId, + surfaceId: action.surfaceId, + ...(userMessage ? { userMessage } : {}), + ...(dataModel !== undefined ? { dataModel } : {}), + }, + }); + } + + // What the tapped button says: its child Text, written out or bound to the data model. + private buttonText(action: ActionPayload) { + const surface = this.processor.model.getSurface(action.surfaceId); + const components = surface?.componentsModel; + const child: unknown = components?.get(action.sourceComponentId)?.properties.child; + if (typeof child !== 'string') return undefined; + const text: unknown = components?.get(child)?.properties.text; + if (typeof text === 'string') return text.trim() === '' ? undefined : text; + if (isRecord(text) && typeof text['@path'] === 'string' && text['@path'].startsWith('/')) { + const bound: unknown = surface?.dataModel.get(text['@path']); + return typeof bound === 'string' && bound.trim() !== '' ? bound : undefined; + } + return undefined; + } + + private watch() { + const subscriptions = [ + this.processor.onSurfaceCreated(() => this.refresh()), + this.processor.onSurfaceDeleted(() => this.refresh()), + this.processor.model.onError.subscribe((error) => + this.report(errorOf(error as RendererError)), + ), + ]; + return () => subscriptions.forEach((subscription) => subscription.unsubscribe()); + } + + private refresh() { + const surfaces = [...this.processor.getSurfaces().values()]; + if ( + surfaces.length === this.shown.length && + surfaces.every((surface, index) => surface === this.shown[index]) + ) { + return; + } + this.shown = surfaces; + this.listeners.forEach((listener) => listener()); + } + + // Each error once: a reply's messages are fed again as it streams, and the processor tells + // the same error both as it fails a message, with the place in it, and to the surface. + private report(error: A2UIError) { + const key = [error.code, error.message, error.surfaceId].join('\u0000'); + if (this.reported.has(key)) return; + this.reported.add(key); + this.engine.report(error, this.messageId); + } +} + +/** A reply's surfaces, following its document as the agent writes it. */ +const A2UIReplyContent = ({ + document, + engine, + messageId, +}: { + document: AIUIDocument; + engine: A2UIEngine; + messageId: string; +}) => { + const reply = engine.replyOf(document, messageId); + const source = a2uiSourceOf(document) ?? ''; + const { isStreaming } = document; + useLayoutEffect(() => { + reply.update(source, isStreaming); + }, [isStreaming, reply, source]); + const surfaces = useSyncExternalStore(reply.subscribe, reply.surfaces); + const { theme } = useTheme(); + const replyContext = useMemo(() => ({ isStreaming, messageId }), [isStreaming, messageId]); + if (surfaces.length === 0) return isStreaming ? : null; + return ( + + + + {surfaces.map((surface) => ( + + ))} + + + + ); +}; + +/** + * Shows the A2UI of one conversation. Each reply's surfaces are its own, kept for as long as + * the host keeps the engine, so what the person entered survives scrolling. + */ +export class A2UIEngine implements AIUIEngine { + readonly catalogId: string; + readonly components: A2UIComponents; + readonly host: AIUIHost; + readonly icons: A2UIIcons; + readonly mediaUrl: AIUIMediaURL; + /** Each reply's surfaces, by the reply's id. */ + readonly replies = new Map(); + + private made?: Catalog[]; + private readonly options: A2UIRendererOptions; + + constructor(host: AIUIHost, options: A2UIRendererOptions) { + this.host = host; + this.options = options; + this.catalogId = options.catalogId ?? A2UI_DEFAULT_CATALOG_ID; + // The basic catalog's views, the app's own over them. + this.components = { ...A2UI_BASIC_COMPONENTS, ...options.components }; + this.icons = options.icons ?? {}; + this.mediaUrl = options.mediaUrl ?? aiUIPublicMediaURL; + } + + /** The address an Image, Video or AudioPlayer loads for a link the agent wrote, if any. */ + mediaSource(value: unknown) { + return aiUIMediaSource(value, this.mediaUrl); + } + + /** The catalogs a reply's surfaces are made with, made once. */ + catalogs(): Catalog[] { + this.made ??= a2uiCatalogsOf({ + catalogId: this.catalogId, + components: this.host.components, + locale: this.options.locale, + removing: this.options.removing, + onComponentError: (component, error) => + this.report({ + code: 'CATALOG_ERROR', + message: `The component ${component.definition.name} can't join the catalog: ${String(error)}`, + }), + onToolError: (tool, error) => + this.report({ + code: 'CATALOG_ERROR', + message: `The tool ${tool.name} can't join the catalog: ${String(error)}`, + }), + openUrl: (url) => this.host.open(url), + runTool: (name, args, options) => this.host.runTool(name, args, options), + tools: this.host.toolDefinitions, + }); + return this.made; + } + + /** The app's component the agent places by its A2UI type, such as `PlaceCard`. */ + appComponent(type: string) { + return this.host.components.find( + (component) => a2uiTypeName(component.definition.name) === type, + ); + } + + /** + * Tells the app what went wrong, after the current render: a reply's messages are applied + * as it first shows, and an app may set state when told. + */ + report(error: A2UIError, messageId?: string) { + queueMicrotask(() => reportA2UIError(this.options, error, messageId)); + } + + /** The surfaces of a reply, made with its messages so far the first time they are asked for. */ + replyOf(document: AIUIDocument, messageId: string) { + let reply = this.replies.get(messageId); + if (!reply) { + reply = new A2UIReplyState(this, messageId); + this.replies.set(messageId, reply); + reply.update(a2uiSourceOf(document) ?? '', document.isStreaming); + } + return reply; + } + + remove(messageId: string) { + this.replies.get(messageId)?.dispose(); + this.replies.delete(messageId); + } + + view(document: AIUIDocument, messageId: string) { + if (a2uiSourceOf(document) === undefined) return null; + // A finished reply whose A2UI made no surface shows its text instead, once that is known. + if (!document.isStreaming && this.replyOf(document, messageId).surfaces().length === 0) { + return null; + } + return ; + } +} + +/** + * The renderer of [A2UI](https://a2ui.org) 1.0 for `AIUIHost`, on the A2UI core + * (`@a2ui/web_core`), with the basic catalog's components drawn natively. The app's components + * join its catalog, so the agent places them like the basic catalog's own. + */ +export const createA2UIRenderer = (options: A2UIRendererOptions = {}): AIUIRenderer => { + const catalogId = options.catalogId ?? A2UI_DEFAULT_CATALOG_ID; + return { + // The fence, the catalog and the instructions for both, so the agent's backend has its model + // write UI this renderer shows. Without the instructions, when they can't be written, the rest + // still goes. + capabilities: async (components, tools) => { + const fence = `ui:a2ui@${A2UI_VERSION}`; + let instructions: string | undefined; + try { + instructions = await a2uiInstructions({ + catalogId, + components, + fence, + ...(options.instructions?.trim() ? { preamble: options.instructions.trim() } : {}), + removing: options.removing, + tools, + }); + } catch (error) { + reportA2UIError(options, { + code: 'EXECUTION_ERROR', + message: `The A2UI instructions couldn't be written: ${String(error)}`, + }); + } + return { + catalog_id: catalogId, + fence, + ...(instructions === undefined ? {} : { instructions }), + }; + }, + createEngine: (host) => new A2UIEngine(host, options), + format: 'a2ui', + supports: supportsA2UIVersion, + }; +}; + +const styles = StyleSheet.create({ + surfaces: { gap: primitives.spacingSm }, +}); diff --git a/package/src/a2ui/A2UIStream.ts b/package/src/a2ui/A2UIStream.ts new file mode 100644 index 0000000000..fd84e2e934 --- /dev/null +++ b/package/src/a2ui/A2UIStream.ts @@ -0,0 +1,105 @@ +import type { AIUIDocument } from '../ai-components/generative-ui/AIUIDocument'; +import { isRecord } from '../ai-components/generative-ui/AIUIHelpers'; + +/** The version of A2UI this renderer reads. */ +export const A2UI_VERSION = '1.0'; + +// The spellings of 1.0 an agent may write, such as from instructions written before its +// release, or by copying the fence's `ui:a2ui@1.0` in another form. +const VERSION_SPELLINGS = new Set(['1', '1.0', '1.0.0', 'v1', 'v1.0']); + +/** Whether a reply's A2UI of `version`, or of no version, is one this renderer reads. */ +export const supportsA2UIVersion = (version: string | undefined) => + version === undefined || VERSION_SPELLINGS.has(version); + +const VALUE_STARTS = ':,['; +const WHITESPACE = /\s/; + +/** + * The complete A2UI messages in `text`, as written: every top-level JSON object, in order. + * One object per line, a pretty-printed one or a JSON array of them all read the same, and + * words around them are skipped. An object still being written is left out until it closes, so + * a reply's UI shows each message as it completes. + */ +export const a2uiMessagesIn = (text: string): string[] => { + const messages: string[] = []; + let depth = 0; + let start = 0; + let inString = false; + let escaped = false; + let resyncing = false; + let previous = ' '; + const breakOff = (index: number) => { + messages.push(text.slice(start, index).trimEnd()); + depth = 0; + inString = false; + escaped = false; + resyncing = true; + }; + for (let index = 0; index < text.length; index++) { + const char = text[index]; + if (inString) { + if (char === '\n') breakOff(index); + else if (escaped) escaped = false; + else if (char === '\\') escaped = true; + else if (char === '"') { + inString = false; + previous = char; + } + continue; + } + if (WHITESPACE.test(char)) continue; + if (char === '"' && depth > 0) { + inString = true; + } else if (char === '{') { + const firstColumn = index === 0 || text[index - 1] === '\n'; + if (depth > 0 && firstColumn && !VALUE_STARTS.includes(previous)) breakOff(index); + if (depth > 0 || !resyncing || firstColumn) { + if (depth === 0) start = index; + resyncing = false; + depth++; + } + } else if (char === '}' && depth > 0) { + depth--; + if (depth === 0) messages.push(text.slice(start, index + 1)); + } + previous = char; + } + return messages; +}; + +/** + * The messages of a document as text: as the agent wrote them, or written out when a backend + * sent them as JSON. `undefined` for content that holds no messages. + */ +export const a2uiSourceOf = (document: AIUIDocument): string | undefined => { + const { content } = document; + if (typeof content === 'string') return content; + if (typeof content === 'object' && content !== null) { + try { + return JSON.stringify(content); + } catch { + return undefined; + } + } + return undefined; +}; + +/** + * A message as the processor reads it: 1.0 in its own spelling, and a surface the agent made + * without naming a catalog made with `catalogId`, the catalog with the app's components. + */ +export const normalizeA2UIMessage = ( + message: Record, + catalogId: string, +): Record => { + let normalized = message; + if (typeof message.version === 'string' && VERSION_SPELLINGS.has(message.version)) { + normalized = { ...normalized, version: A2UI_VERSION }; + } + const { createSurface } = normalized; + if (isRecord(createSurface) && createSurface.catalogId === undefined) { + normalized = { ...normalized, createSurface: { ...createSurface, catalogId } }; + } + return normalized; +}; diff --git a/package/src/a2ui/A2UISurface.tsx b/package/src/a2ui/A2UISurface.tsx new file mode 100644 index 0000000000..c017aedbc4 --- /dev/null +++ b/package/src/a2ui/A2UISurface.tsx @@ -0,0 +1,270 @@ +import React, { + createContext, + memo, + type ReactNode, + useCallback, + useContext, + useLayoutEffect, + useMemo, + useState, + useSyncExternalStore, +} from 'react'; +import { type AccessibilityProps, View as RNView, type ViewProps } from 'react-native'; +import Animated from 'react-native-reanimated'; + +import { + type ComponentApi, + type ComponentNode, + effect, + getValue, + isComponentNode, + isWritable, + NodeResolver, + peekValue, + ResolvedBinding, + type Signal, + type SurfaceModel, +} from '@a2ui/web_core'; + +import { A2UIEngineContext, A2UIReplyContext } from './A2UIContexts'; +import { keepSecretPath } from './secrets'; + +import { AIUIComponentView } from '../ai-components/generative-ui/AIUIComponent'; +import { useAIUIComesIn, useAIUIEntrance } from '../ai-components/generative-ui/AIUIEntrance'; +import { isRecord } from '../ai-components/generative-ui/AIUIHelpers'; + +/** The value of an A2UI signal, following it. */ +export const useSignalValue = (signal: Signal) => { + const subscribe = useCallback( + (onChange: () => void) => + effect(() => { + getValue(signal); + onChange(); + }), + [signal], + ); + const getSnapshot = useCallback(() => peekValue(signal), [signal]); + return useSyncExternalStore(subscribe, getSnapshot); +}; + +const isPlainObject = (value: unknown): value is Record => { + if (!isRecord(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +}; + +const viewValueOf = (value: unknown): unknown => { + if (isComponentNode(value)) return value; + if (value instanceof ResolvedBinding) return viewValueOf(value.value); + if (Array.isArray(value)) return value.map(viewValueOf); + if (isPlainObject(value)) return viewPropsOf(value); + return value; +}; + +const noop = () => undefined; + +// What a component's view reads: bindings as their values, with a setter for each one the person +// can change, named after it, such as `setValue` for `value`. Children stay nodes. +const viewPropsOf = (props: Record) => { + const view: Record = {}; + for (const [key, value] of Object.entries(props)) { + if (value instanceof ResolvedBinding) { + view[key] = viewValueOf(value.value); + view[`set${key.charAt(0).toUpperCase()}${key.slice(1)}`] = isWritable(value) + ? value.set + : noop; + } else view[key] = viewValueOf(value); + } + return view; +}; + +/** The axis the parent of a part lays its children out on, which a part's weight grows along. */ +export const A2UIAxisContext = createContext<'column' | 'row'>('column'); + +const said = (value: unknown) => + value === undefined || value === null || value === '' ? undefined : String(value); + +// What the agent tells assistive technology about a part, with its `accessibility`: what to call +// it, what more to say about it, whether to announce its changes, and whether to leave it out. +const accessibilityOf = (accessibility: unknown): AccessibilityProps => { + if (!isRecord(accessibility)) return {}; + const { description, hidden, label, live } = accessibility; + const named = said(label); + const described = said(description); + return { + ...(named ? { accessibilityLabel: named } : {}), + ...(described ? { accessibilityHint: described } : {}), + ...(live === 'polite' || live === 'assertive' ? { accessibilityLiveRegion: live } : {}), + ...(hidden === true + ? { accessibilityElementsHidden: true, importantForAccessibility: 'no-hide-descendants' } + : {}), + }; +}; + +// One of the app's components where the agent placed it, with the arguments it declares. +const PlacedComponent = ({ + node, + props, +}: { + node: ComponentNode; + props: Record; +}) => { + const engine = useContext(A2UIEngineContext); + const reply = useContext(A2UIReplyContext); + const component = engine?.appComponent(node.type); + const names = component?.definition.parameters.map(({ name }) => name); + const args = useMemo( + () => + Object.fromEntries( + (names ?? []).filter((name) => name in props).map((name) => [name, props[name]]), + ), + // eslint-disable-next-line react-hooks/exhaustive-deps -- the names change with the component + [component, props], + ); + if (!engine || !reply || !component) return null; + const { messageId } = reply; + const { name } = component.definition; + return ( + + void engine.host.send({ + format: 'a2ui', + label, + messageId, + name: event, + payload: { ...payload, component: name }, + }), + host: engine.host, + isStreaming: reply.isStreaming, + messageId, + }} + /> + ); +}; + +/** + * One part of a surface: its view from the basic catalog, or the app's component of its type, + * coming in when it shows, with its weight and what the agent tells assistive technology. A part + * whose type no catalog has, or that is still on its way, shows nothing, and one that holds other + * parts, such as a card, shows once one of them does while the agent writes it. + */ +// The path of the data model a TextField writes what the person types to, when it is obscured, +// such as a password, which must never reach the agent. +const secretPathOf = (node: ComponentNode, resolved: Record) => { + if (node.type !== 'TextField') return undefined; + const variant = + resolved.variant instanceof ResolvedBinding ? resolved.variant.value : resolved.variant; + const { value } = resolved; + return variant === 'obscured' && value instanceof ResolvedBinding && isWritable(value) + ? (node.context?.dataContext.resolvePath(value.path) ?? value.path) + : undefined; +}; + +// Tells the part around a part that it shows. +const A2UIPartShowsContext = createContext<(() => void) | undefined>(undefined); + +// Whether a part's props hold other parts, as a card's child or a column's children do. +const holdsParts = (value: unknown): boolean => + isComponentNode(value) || + (Array.isArray(value) + ? value.some(holdsParts) + : isPlainObject(value) && Object.values(value).some(holdsParts)); + +const hiddenStyle = { display: 'none' } as const; + +const A2UINode = memo(({ node }: { node: ComponentNode }) => { + const engine = useContext(A2UIEngineContext); + const axis = useContext(A2UIAxisContext); + const resolved = useSignalValue(node.props) as Record; + const props = useMemo(() => viewPropsOf(resolved), [resolved]); + // Before the person can type: whichever view draws the field, what they type stays theirs. + const secretPath = secretPathOf(node, resolved); + const surface = node.context?.dataContext.surface; + useLayoutEffect(() => { + if (secretPath && surface) keepSecretPath(surface, secretPath); + }, [secretPath, surface]); + const View = engine?.components[node.type]; + const isApp = !View && !!engine?.appComponent(node.type); + const isReady = !!engine && !node.isPlaceholder && node.state === 'resolved' && (!!View || isApp); + const holds = useMemo(() => holdsParts(props), [props]); + const [hasShownPart, setHasShownPart] = useState(false); + const isWritten = !useContext(A2UIReplyContext)?.isStreaming; + const shows = isReady && (!holds || hasShownPart || isWritten); + const partShows = useContext(A2UIPartShowsContext); + useLayoutEffect(() => { + if (shows) partShows?.(); + }, [partShows, shows]); + const onPartShown = useCallback(() => setHasShownPart(true), []); + const comesIn = useAIUIComesIn(shows); + if (!isReady) return null; + const content = ( + + {View ? : } + + ); + if (!shows) return {content}; + // A weighted part shares the room its row leaves, as flex-grow from nothing does. + const weight = typeof props.weight === 'number' && axis === 'row' ? props.weight : undefined; + const Part = comesIn ? EnteringPart : ShownPart; + return ( + + {content} + + ); +}); +A2UINode.displayName = 'A2UINode'; + +type PartProps = ViewProps & { children: ReactNode }; + +// A part that comes in as it shows, such as while the agent writes it. +const EnteringPart = ({ children, style, ...props }: PartProps) => { + const { scope, style: entrance } = useAIUIEntrance(); + return ( + + {scope(children)} + + ); +}; + +// A part that shows at once, such as one of a reply from history: a plain view, since nothing of +// it moves and a list that scrolls fast mounts it all the time. +const ShownPart = ({ children, ...props }: PartProps) => {children}; + +/** A child of a part, as its view gets it: a node, or nothing while it is on its way. */ +export const A2UIChild = ({ node }: { node: unknown }) => + isComponentNode(node) ? : null; + +/** The children of a part, as its view gets them. */ +export const A2UIChildren = ({ nodes }: { nodes: unknown }) => ( + <> + {Array.isArray(nodes) + ? nodes.map((node) => + isComponentNode(node) ? : null, + ) + : null} + +); + +const resolvers = new WeakMap, NodeResolver>(); + +const resolverOf = (surface: SurfaceModel) => { + let resolver = resolvers.get(surface); + if (!resolver) { + resolver = new NodeResolver(surface, surface.defaultCatalog); + resolvers.set(surface, resolver); + } + return resolver; +}; + +/** A surface of a reply: its tree of parts, from its root, as the agent writes them. */ +export const A2UISurfaceView = ({ surface }: { surface: SurfaceModel }) => { + const root = useSignalValue(resolverOf(surface).rootNode); + return root ? : null; +}; diff --git a/package/src/a2ui/__tests__/A2UIInstructions.test.ts b/package/src/a2ui/__tests__/A2UIInstructions.test.ts new file mode 100644 index 0000000000..6a3813cb8f --- /dev/null +++ b/package/src/a2ui/__tests__/A2UIInstructions.test.ts @@ -0,0 +1,247 @@ +// What an agent is told, written in a script or a build too. +import { parseAIUIComponentDefinition } from '../../ai-components'; +import { ohridLines, uiComponents } from '../../mock-builders/a2ui/ohrid'; +import { + A2UI_DEFAULT_CATALOG_ID, + A2UI_WEB_CATALOG_ID, + a2uiInstructions, + A2UIInstructionsError, + a2uiProblems, +} from '../index'; + +const components = uiComponents.flatMap((json) => { + const definition = parseAIUIComponentDefinition(json); + return definition ? [definition] : []; +}); + +// The basic catalog's part the Swift assistant's `a2ui/prompt.json` keeps. +const keep = [ + 'Text', + 'Icon', + 'Row', + 'Column', + 'List', + 'Card', + 'Tabs', + 'Divider', + 'Button', + 'TextField', + 'CheckBox', + 'ChoicePicker', + 'Slider', + 'DateTimeInput', + 'formatString', + 'formatDate', + 'formatNumber', + 'openUrl', + 'required', +]; + +const example = (lines: string[]) => + [ + 'Example — a weekend trip:', + 'A weekend in Ohrid by the lake: a map, your calendar and a plan for each day.', + `\`\`\`ui:a2ui@1.0\n${lines.join('\n')}\n\`\`\``, + ].join('\n\n'); + +const line = (message: Record) => JSON.stringify({ version: '1.0', ...message }); + +const surface = (components: Record[]) => [ + line({ createSurface: { catalogId: A2UI_DEFAULT_CATALOG_ID, surfaceId: 's' } }), + line({ updateComponents: { components, surfaceId: 's' } }), +]; + +describe('a2uiInstructions', () => { + it('writes the instructions from prompt.json, in its order', async () => { + const text = await a2uiInstructions({ + components, + examples: [example(ohridLines)], + keep, + notes: ['- PlaceCard, TripMap and CalendarCheck are the phone’s own views.'], + preamble: 'Some answers work better as interactive UI than as text.', + rules: ['Never add images for places.', 'Write the whole UI again.'], + }); + + const headings = text.split('\n').filter((row) => /^#{1,2} /.test(row)); + expect(headings).toEqual([ + '## Writing UI in A2UI', + '# Generating user interfaces with A2UI v1.0', + '## Catalog Guidelines', + "## The app's components", + '## The catalog `getstream.io:app-catalog`', + '## Rules', + '## Examples', + ]); + expect(text.startsWith('Some answers work better')).toBe(true); + expect(text).toContain('whose info string is `ui:a2ui@1.0`'); + expect(text).toContain('the `catalogId` `getstream.io:app-catalog`'); + expect(text).toContain('1. Never add images for places.\n2. Write the whole UI again.'); + + const catalog = JSON.parse((text.match(/```json\n(\{.*\})\n```/) ?? [])[1] ?? '{}') as { + catalogId: string; + components: Record; + }; + expect(catalog.catalogId).toBe(A2UI_DEFAULT_CATALOG_ID); + expect(Object.keys(catalog.components)).toEqual( + expect.arrayContaining(['PlaceCard', 'TripMap', 'CalendarCheck', 'Text', 'Tabs']), + ); + expect(catalog.components).not.toHaveProperty('Video'); + expect(catalog.components).not.toHaveProperty('Modal'); + expect(catalog.components.CalendarCheck).toEqual({ + description: + "The person's own calendar for the days of a plan, read on the phone. You never see it: use it whenever a plan has dates, so they can see what the trip would clash with.", + properties: { + component: { const: 'CalendarCheck' }, + end: { + description: 'The last day, as YYYY-MM-DD', + format: 'date', + type: 'string', + }, + start: { + description: 'The first day, as YYYY-MM-DD', + format: 'date', + type: 'string', + }, + }, + required: ['component', 'end', 'start'], + type: 'object', + }); + expect(JSON.stringify(catalog.components.PlaceCard)).toContain( + 'Tapping the card asks you about the place.', + ); + }); + + it('writes nothing when an example would not render, and says why', async () => { + const broken = [ + ...ohridLines.slice(0, 2), + line({ + updateComponents: { + components: [{ component: 'Globe', id: 'map' }], + surfaceId: 'ohrid-weekend', + }, + }), + ]; + const asked = a2uiInstructions({ components, examples: [example(broken)], keep }); + await expect(asked).rejects.toBeInstanceOf(A2UIInstructionsError); + await expect(asked).rejects.toMatchObject({ + problems: expect.arrayContaining([expect.stringMatching(/^example: map: .*Globe/)]), + }); + }); +}); + +describe('a2uiProblems', () => { + it('finds nothing in the Ohrid plan', async () => { + expect(await a2uiProblems(ohridLines.join('\n'), { components })).toEqual([]); + }); + + it('says what doesn’t fit the catalog: components, properties and their values', async () => { + const problems = await a2uiProblems( + surface([ + { children: ['title', 'map', 'go'], component: 'Column', id: 'root' }, + { component: 'Text', id: 'title', size: 3, text: 'A weekend' }, + { component: 'Globe', id: 'map' }, + { child: 'title', component: 'Button', id: 'go' }, + ]).join('\n'), + { components }, + ); + expect(problems).toEqual([ + expect.stringMatching(/^go: .*action/), + expect.stringMatching(/^map: .*Globe/), + expect.stringMatching(/^title: .*size/), + ]); + }); + + it('says what the components make together: references, root, orphans, cycles and ids', async () => { + const dangling = await a2uiProblems( + surface([ + { children: ['a', 'gone'], component: 'Column', id: 'root' }, + { component: 'Text', id: 'a', text: 'Here' }, + { component: 'Text', id: 'lost', text: 'Nobody holds this' }, + ]).join('\n'), + ); + expect(dangling).toEqual([ + 'root holds gone, which is not defined', + 'lost is defined but nothing holds it', + ]); + + expect( + await a2uiProblems(surface([{ component: 'Text', id: 'top', text: 'Hi' }]).join('\n')), + ).toEqual(['surface s has no component with the id root']); + + const cycle = await a2uiProblems( + surface([ + { children: ['loop'], component: 'Column', id: 'root' }, + { children: ['root'], component: 'Column', id: 'loop' }, + ]).join('\n'), + ); + expect(cycle).toEqual([expect.stringMatching(/holds itself through its children/)]); + + const named = await a2uiProblems( + surface([ + { children: ['saturday-plan'], component: 'Column', id: 'root' }, + { component: 'Text', id: 'saturday-plan', text: 'Morning' }, + ]).join('\n'), + ); + expect(named).toEqual([expect.stringMatching(/^saturday-plan: .*identifier/)]); + }); + + it('says what is wrong with the messages themselves', async () => { + const problems = await a2uiProblems( + [ + '{not json}', + line({ updateComponents: { components: [], surfaceId: 'later' } }), + line({ callRendererFunction: { name: 'openUrl' } }), + line({ createSurface: { catalogId: 'someone-else', surfaceId: 's' } }), + line({ + updateComponents: { + components: [ + { component: 'Text', id: 'root', text: 'One' }, + { component: 'Text', id: 'root', text: 'Two' }, + ], + surfaceId: 's', + }, + }), + ].join('\n'), + ); + expect(problems).toEqual([ + "this isn't JSON: {not json}", + 'surface later gets components before it is created', + "a reply's UI calls no functions", + 'surface s names the catalog someone-else, not getstream.io:app-catalog', + 'surface s defines root more than once in one message', + ]); + expect( + await a2uiProblems( + [ + ...surface([{ component: 'Text', id: 'root', text: 'One' }]), + line({ createSurface: { catalogId: A2UI_DEFAULT_CATALOG_ID, surfaceId: 's' } }), + ].join('\n'), + ), + ).toEqual([expect.stringMatching(/^surface s is created again/)]); + expect( + await a2uiProblems( + [ + line({ createSurface: { catalogId: A2UI_WEB_CATALOG_ID, surfaceId: 's' } }), + line({ + updateComponents: { + components: [{ component: 'Text', id: 'root', text: 'One' }], + surfaceId: 's', + }, + }), + ].join('\n'), + ), + ).toEqual([]); + expect( + await a2uiProblems( + [ + ...surface([{ component: 'Text', id: 'root', text: 'One' }]), + line({ deleteSurface: { surfaceId: 's' } }), + ...surface([{ component: 'Text', id: 'root', text: 'Two' }]), + ].join('\n'), + ), + ).toEqual([]); + expect(await a2uiProblems('Just words.')).toEqual([ + 'nothing renders: there is no createSurface', + ]); + }); +}); diff --git a/package/src/a2ui/__tests__/A2UIRenderer.test.tsx b/package/src/a2ui/__tests__/A2UIRenderer.test.tsx new file mode 100644 index 0000000000..58b60579a6 --- /dev/null +++ b/package/src/a2ui/__tests__/A2UIRenderer.test.tsx @@ -0,0 +1,1604 @@ +import React from 'react'; +import { Pressable, ScrollView, StyleSheet, Text, TextInput } from 'react-native'; + +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; + +import { + type AIUIAction, + AIUIHost, + AIUIView, + defineAIUIComponent, + parseAIUIComponentDefinition, + parseAIUIDocument, + StreamTheme, +} from '../../ai-components'; +import { aiUIMediaSource, isPublicWebURL } from '../../ai-components/generative-ui/AIUIMedia'; +import type { + AIClientTool, + AIClientToolDefinition, +} from '../../ai-components/tool-calls/AIClientToolRunner'; +import { ohridLines, uiComponents } from '../../mock-builders/a2ui/ohrid'; +import { NativeHandlers } from '../../native'; +import { a2uiMessagesIn, normalizeA2UIMessage, supportsA2UIVersion } from '../A2UIStream'; +import basicCatalogJSON from '../basicCatalog.json'; +import { a2uiAppCatalogDocument, a2uiTypeName } from '../catalogDocument'; +import { formatMoment, parseMoment } from '../components/DateTimeInput'; +import { A2UI_ICON_PATHS } from '../components/iconPaths'; +import { + A2UI_DEFAULT_CATALOG_ID, + A2UI_WEB_CATALOG_ID, + type A2UIRendererOptions, + createA2UIRenderer, +} from '../index'; +import { withoutPaths, withoutSecrets } from '../secrets'; + +const definitionOf = (name: string) => { + const definition = parseAIUIComponentDefinition( + uiComponents.find((component) => component.name === name), + ); + if (!definition) throw new Error(`no ${name}`); + return definition; +}; + +const placeCard = defineAIUIComponent<{ note?: string; title: string }>( + definitionOf('place_card'), + ({ args, context }) => ( + + context.emit('ask_about', `Tell me more about ${args.title}`, { title: args.title }) + } + testID='place-card' + > + + {args.title} + {args.note ? ` (${args.note})` : ''} + + + ), +); + +const tripMap = defineAIUIComponent<{ places: { name: string }[] }>( + definitionOf('trip_map'), + ({ args }) => {args.places.map(({ name }) => name).join(', ')}, +); + +const calendarCheck = defineAIUIComponent<{ end: Date; start: Date }>( + definitionOf('calendar_check'), + ({ args }) => ( + + {args.start.getDate()}–{args.end.getDate()} + + ), +); + +const setup = (options: A2UIRendererOptions = {}) => { + const sent: AIUIAction[] = []; + const errors: { code: string; message: string; surfaceId?: string }[] = []; + const open = jest.fn(); + const ui = new AIUIHost({ + components: [placeCard, tripMap, calendarCheck], + open, + renderers: [createA2UIRenderer({ ...options, onError: (error) => errors.push(error) })], + send: (action) => { + sent.push(action); + }, + }); + return { errors, open, sent, ui }; +}; + +const documentOf = (lines: string[], status = 'complete') => { + const document = parseAIUIDocument({ + content: lines.join('\n'), + format: 'a2ui', + status, + v: 1, + version: '1.0', + }); + if (!document) throw new Error('no document'); + return document; +}; + +// Renders one reply's UI, and lets what it tells the host after the render land. +const renderReply = async (ui: AIUIHost, lines: string[], status = 'complete') => { + const result = render( + + + , + ); + await act(() => Promise.resolve()); + return result; +}; + +const line = (message: Record) => JSON.stringify({ version: '1.0', ...message }); + +const button = (id: string, label: string, action: Record, extra = {}) => [ + { action, child: `${id}_label`, component: 'Button', id, ...extra }, + { component: 'Text', id: `${id}_label`, text: label }, +]; + +const surfaceOf = (components: Record[], dataModel?: Record) => [ + line({ createSurface: { surfaceId: 's', ...(dataModel ? { dataModel } : {}) } }), + line({ updateComponents: { components, surfaceId: 's' } }), +]; + +describe('a2uiMessagesIn', () => { + it('reads the messages one per line, in an array, pretty-printed, or among words', () => { + const one = '{"version":"1.0","deleteSurface":{"surfaceId":"a"}}'; + const two = '{"version":"1.0","deleteSurface":{"surfaceId":"b"}}'; + expect(a2uiMessagesIn(`${one}\n${two}`)).toEqual([one, two]); + expect(a2uiMessagesIn(`[${one},${two}]`)).toEqual([one, two]); + expect(a2uiMessagesIn(`Here it is:\n${one}\nand more.`)).toEqual([one]); + expect( + a2uiMessagesIn('{\n "version": "1.0",\n "deleteSurface": {"surfaceId": "a"}\n}'), + ).toHaveLength(1); + }); + + it('leaves out a message still being written', () => { + expect(a2uiMessagesIn('{"version":"1.0"}\n{"version":"1.0","updateComp')).toEqual([ + '{"version":"1.0"}', + ]); + }); + + it('is not misled by braces and quotes inside strings', () => { + const tricky = '{"text":"a } and a \\" and a {"}'; + expect(a2uiMessagesIn(tricky)).toEqual([tricky]); + }); + + const parsed = (message: string): unknown => { + try { + return JSON.parse(message); + } catch { + return undefined; + } + }; + + const surfaceIdOf = (message: string) => + (parsed(message) as { createSurface: { surfaceId: string } }).createSurface.surfaceId; + + it('reads a pretty-printed message across its lines', () => { + const text = [ + '{', + ' "version": "1.0",', + ' "updateComponents": {', + ' "surfaceId": "s",', + ' "components": [', + ' {"id": "root", "component": "Column", "children": ["a"]},', + ' {', + ' "id": "a", "component": "Text", "text": "{ not a brace }"', + ' }', + ' ]', + ' }', + '}', + '{"version": "1.0", "deleteSurface": {"surfaceId": "s"}}', + ].join('\n'); + const messages = a2uiMessagesIn(text); + expect(messages).toHaveLength(2); + expect(messages.map(parsed)).toEqual([expect.any(Object), expect.any(Object)]); + }); + + it('ends a message with a stray quote at its line, and the messages after it still count', () => { + const lines = [ + '{"version": "1.0", "createSurface": {"surfaceId": "a"}}', + '{"version": "1.0", "updateComponents": {"surfaceId": "a", "components": [{"id": "t", "text">Day 1"}]}}', + '{"version": "1.0", "createSurface": {"surfaceId": "b"}}', + '{"version": "1.0", "createSurface": {"surfaceId": "c", "note": "a } and a \\" in a string"}}', + ]; + const messages = a2uiMessagesIn(lines.join('\n')); + expect(messages).toEqual(lines); + expect(parsed(messages[1])).toBeUndefined(); + expect([0, 2, 3].map((index) => surfaceIdOf(messages[index]))).toEqual(['a', 'b', 'c']); + }); + + it('ends a message missing a brace where the next one starts', () => { + const lines = [ + '{"version": "1.0", "createSurface": {"surfaceId": "a"}', + '{"version": "1.0", "createSurface": {"surfaceId": "b"}}', + '{"version": "1.0", "createSurface": {"surfaceId": "c"}}', + ]; + const messages = a2uiMessagesIn(lines.join('\n')); + expect(messages).toEqual(lines); + expect(parsed(messages[0])).toBeUndefined(); + expect(messages.slice(1).map(surfaceIdOf)).toEqual(['b', 'c']); + }); + + it('skips the rest of a broken pretty-printed message instead of reading it as messages', () => { + const broken = [ + '{"version": "1.0", "updateComponents": {"surfaceId": "a", "components": [', + ' {"id": "t", "text">Day 1"},', + ' {"id": "u", "component": "Text"},', + ' {"id": "w", "component": "Text"}', + ']}}', + ].join('\n'); + const newline = [ + '{"version": "1.0", "updateComponents": {"surfaceId": "a", "components": [', + ' {"id": "t", "component": "Text", "text": "Day 1', + 'Use {name} here"}', + ']}}', + ].join('\n'); + const next = '{"version": "1.0", "createSurface": {"surfaceId": "b"}}'; + for (const text of [broken, newline]) { + const messages = a2uiMessagesIn(`${text}\n${next}`); + expect(messages).toHaveLength(2); + expect(parsed(messages[0])).toBeUndefined(); + expect(surfaceIdOf(messages[1])).toBe('b'); + } + }); + + it('keeps a missing comma between indented objects inside its message', () => { + const text = [ + '{"version": "1.0", "updateComponents": {"surfaceId": "a", "components": [', + ' {"id": "t", "component": "Text"}', + ' {"id": "u", "component": "Text"}', + ']}}', + '{"version": "1.0", "createSurface": {"surfaceId": "b"}}', + ].join('\n'); + const messages = a2uiMessagesIn(text); + expect(messages).toHaveLength(2); + expect(parsed(messages[0])).toBeUndefined(); + expect(surfaceIdOf(messages[1])).toBe('b'); + }); + + it('leaves out the line still being written after a broken message', () => { + const broken = '{"version": "1.0", "updateComponents": {"surfaceId": "a", "text">x"}}'; + const next = '{"version": "1.0", "createSurface": {"surfaceId": "b"}}'; + for (let length = 0; length < next.length; length++) { + expect(a2uiMessagesIn(`${broken}\n${next.slice(0, length)}`)).toEqual([broken]); + } + expect(a2uiMessagesIn(`${broken}\n${next}`)).toEqual([broken, next]); + }); + + it('lets a string still being written hold braces and quotes', () => { + const partial = + '{"version": "1.0", "createSurface": {"surfaceId": "s", "note": "a { and a \\" so far'; + expect(a2uiMessagesIn(partial)).toEqual([]); + expect(a2uiMessagesIn(`${partial}"}}`)).toHaveLength(1); + }); +}); + +describe('the A2UI versions and catalog it reads', () => { + it('reads 1.0 in the spellings an agent writes, and no other version', () => { + for (const version of [undefined, '1', '1.0', '1.0.0', 'v1', 'v1.0']) { + expect(supportsA2UIVersion(version)).toBe(true); + } + for (const version of ['0.9', '2.0', 'v0.8']) { + expect(supportsA2UIVersion(version)).toBe(false); + } + }); + + it('writes every message as 1.0, and names the app’s catalog for a surface naming none', () => { + expect(normalizeA2UIMessage({ version: 'v1.0' }, 'app').version).toBe('1.0'); + expect( + normalizeA2UIMessage({ createSurface: { surfaceId: 's' }, version: '1.0' }, 'app'), + ).toEqual({ createSurface: { catalogId: 'app', surfaceId: 's' }, version: '1.0' }); + expect( + normalizeA2UIMessage( + { createSurface: { catalogId: 'other', surfaceId: 's' }, version: '1.0' }, + 'app', + ).createSurface, + ).toEqual({ catalogId: 'other', surfaceId: 's' }); + }); + + it('describes the app’s components to the agent', () => { + expect(a2uiTypeName('place_card')).toBe('PlaceCard'); + const document = a2uiAppCatalogDocument(A2UI_DEFAULT_CATALOG_ID, [placeCard]); + expect(document.components.PlaceCard).toMatchObject({ + description: + 'One place of a plan, with its photo and a short summary, which the phone loads from Wikipedia, so it needs no image URL. Use one per sight, beach, monastery, village or day trip. Tapping the card asks you about the place.', + required: ['component', 'title'], + }); + }); + + it('tells the agent’s backend its fence, catalog and instructions', async () => { + const { ui } = setup(); + const { renderers } = await ui.capabilities(); + expect(renderers).toEqual([ + { + catalog_id: A2UI_DEFAULT_CATALOG_ID, + fence: 'ui:a2ui@1.0', + format: 'a2ui', + instructions: expect.any(String), + }, + ]); + const { instructions } = renderers[0] as unknown as { instructions: string }; + expect(instructions).toContain('whose info string is `ui:a2ui@1.0`'); + expect(instructions).toContain(`## The catalog \`${A2UI_DEFAULT_CATALOG_ID}\``); + expect(instructions).toContain('"PlaceCard"'); + }); + + it('leaves what it removes out of what it shows and of what the agent is told', async () => { + const { errors, ui } = setup({ removing: ['Divider', 'formatCurrency'] }); + const { renderers } = await ui.capabilities(); + const { instructions } = renderers[0] as unknown as { instructions: string }; + expect(instructions).not.toContain('"Divider"'); + expect(instructions).not.toContain('"formatCurrency"'); + expect(instructions).toContain('"Card"'); + await renderReply( + ui, + surfaceOf([ + { children: ['line', 'words'], component: 'Column', id: 'root' }, + { component: 'Divider', id: 'line' }, + { component: 'Text', id: 'words', text: 'Still here' }, + ]), + ); + await waitFor(() => expect(screen.getByText(/Still here/)).toBeTruthy()); + expect(screen.queryByTestId('a2ui-line')).toBeNull(); + expect(errors.map(({ code }) => code)).not.toContain('EXECUTION_ERROR'); + }); +}); + +describe('a reply written in A2UI', () => { + it('draws a surface that names the web SDK’s catalog id', async () => { + const { errors, ui } = setup(); + await renderReply(ui, [ + line({ createSurface: { catalogId: A2UI_WEB_CATALOG_ID, surfaceId: 's' } }), + line({ + updateComponents: { + components: [{ component: 'Text', id: 'root', text: 'From the web agent' }], + surfaceId: 's', + }, + }), + ]); + await waitFor(() => expect(screen.getByText(/From the web agent/)).toBeTruthy()); + expect(errors).toEqual([]); + }); + it('shows the Ohrid plan with the basic catalog and the app’s components', async () => { + const { errors, ui } = setup(); + await renderReply(ui, ohridLines); + await waitFor(() => + expect(screen.getByTestId('trip-map')).toHaveTextContent(/Church of St\. John at Kaneo/), + ); + expect(screen.getByTestId('calendar-check')).toHaveTextContent('14–15'); + expect(screen.getAllByTestId('place-card')[0]).toHaveTextContent('Plaošnik (1 hour)'); + expect(screen.getByText(/Two easy days by the lake/)).toBeTruthy(); + expect(screen.getByRole('tab', { name: 'Saturday' })).toBeTruthy(); + expect(errors).toEqual([]); + }); + + it('tells the agent what the person did, in the words of the button they tapped', async () => { + const { sent, ui } = setup(); + await renderReply(ui, ohridLines); + fireEvent.press(await screen.findByRole('radio', { name: 'Packed' })); + fireEvent.press(screen.getByRole('button', { name: 'Update the plan' })); + await waitFor(() => expect(sent).toHaveLength(1)); + expect(sent[0]).toEqual({ + format: 'a2ui', + label: 'Update the plan', + messageId: 'a1', + name: 'update_plan', + payload: { + context: { interests: [], pace: ['packed'] }, + sourceComponentId: 'update', + surfaceId: 'ohrid-weekend', + userMessage: 'Update the plan', + }, + }); + + fireEvent.press(screen.getAllByTestId('place-card')[0]); + await waitFor(() => expect(sent).toHaveLength(2)); + expect(sent[1]).toEqual({ + format: 'a2ui', + label: 'Tell me more about Plaošnik', + messageId: 'a1', + name: 'ask_about', + payload: { component: 'place_card', title: 'Plaošnik' }, + }); + }); + + it('never sends the agent’s words for an action in place of what its button says', async () => { + const { sent, ui } = setup(); + await renderReply( + ui, + surfaceOf( + button('root', 'Book the boat', { + event: { name: 'book', userMessage: 'Cancel my whole trip' }, + }), + ), + ); + fireEvent.press(await screen.findByRole('button', { name: 'Book the boat' })); + await waitFor(() => expect(sent).toHaveLength(1)); + expect(sent[0]?.label).toBe('Book the boat'); + expect(sent[0]?.payload.userMessage).toBe('Cancel my whole trip'); + }); + + it('names an action by what its button says when bound, else by its name in words', async () => { + const { sent, ui } = setup(); + await renderReply( + ui, + surfaceOf( + [ + { children: ['bound', 'nameless'], component: 'Column', id: 'root' }, + { + action: { event: { name: 'book' } }, + child: 'bound_label', + component: 'Button', + id: 'bound', + }, + { component: 'Text', id: 'bound_label', text: { '@path': '/cta' } }, + { + action: { event: { name: 'submit_form' } }, + child: 'icon', + component: 'Button', + id: 'nameless', + }, + { component: 'Icon', id: 'icon', name: 'check' }, + ], + { cta: 'Book for two' }, + ), + ); + fireEvent.press(await screen.findByRole('button', { name: 'Book for two' })); + await waitFor(() => expect(sent[0]?.label).toBe('Book for two')); + fireEvent.press(screen.getAllByRole('button')[1]); + await waitFor(() => expect(sent[1]?.label).toBe('Submit form')); + }); + + it('follows a reply as the agent writes it, a message at a time', async () => { + const { ui } = setup(); + const view = (lines: string[]) => ( + + + + ); + const { rerender } = render(view([...ohridLines.slice(0, 2), '{"version":"1.0","upd'])); + await waitFor(() => expect(screen.getByText(/Two easy days by the lake/)).toBeTruthy()); + expect(screen.queryByTestId('trip-map')).toBeNull(); + + rerender(view(ohridLines.slice(0, 3))); + await waitFor(() => expect(screen.getByTestId('trip-map')).toBeTruthy()); + }); + + it('holds back a card until something in it shows, while the agent writes it', async () => { + const { ui } = setup(); + const view = (lines: string[], status = 'streaming') => ( + + + + ); + const written = [ + { children: ['title', 'card'], component: 'Column', id: 'root' }, + { component: 'Text', id: 'title', text: 'Fit check' }, + { child: 'list', component: 'Card', id: 'card' }, + { children: ['tag'], component: 'Column', id: 'list' }, + ]; + const { rerender } = render(view(surfaceOf(written))); + await waitFor(() => expect(screen.getByTestId('a2ui-title')).toBeTruthy()); + expect(screen.queryByTestId('a2ui-card')).toBeNull(); + expect(screen.queryByTestId('a2ui-list')).toBeNull(); + + rerender( + view([ + ...surfaceOf(written), + line({ + updateComponents: { + components: [{ component: 'Text', id: 'tag', text: 'Name tag' }], + surfaceId: 's', + }, + }), + ]), + ); + await waitFor(() => expect(screen.getByTestId('a2ui-card')).toBeTruthy()); + expect(screen.getByText('Name tag')).toBeTruthy(); + }); + + it('shows a card that holds nothing yet once the agent has written the reply', async () => { + const { ui } = setup(); + await renderReply( + ui, + surfaceOf([ + { child: 'list', component: 'Card', id: 'root' }, + { children: ['tag'], component: 'Column', id: 'list' }, + ]), + ); + expect(screen.getByTestId('a2ui-root')).toBeTruthy(); + }); + + it('shows a placeholder while the first surface is being written', async () => { + const { ui } = setup(); + await renderReply(ui, ['{"version":"1.0","createSur'], 'streaming'); + expect(screen.getByTestId('a2ui-placeholder')).toBeTruthy(); + }); + + it('starts the surfaces again when the agent rewrites what it had written', async () => { + const { ui } = setup(); + const text = (words: string) => surfaceOf([{ component: 'Text', id: 'root', text: words }]); + const view = (lines: string[]) => ( + + + + ); + const { rerender } = render(view(text('First draft'))); + await waitFor(() => expect(screen.getByText(/First draft/)).toBeTruthy()); + rerender(view(text('Second draft'))); + await waitFor(() => expect(screen.getByText(/Second draft/)).toBeTruthy()); + expect(screen.queryByText(/First draft/)).toBeNull(); + }); + + it('shows the rest of a batch that one bad component spoils, and says what went wrong', async () => { + const { errors, ui } = setup(); + await renderReply( + ui, + surfaceOf([ + { children: ['good', 'bad'], component: 'Column', id: 'root' }, + { component: 'Text', id: 'good', text: 'Still here' }, + { component: 'Slider', id: 'bad', value: 'not a number' }, + ]), + ); + await waitFor(() => expect(screen.getByText(/Still here/)).toBeTruthy()); + await waitFor(() => + expect(errors).toEqual([ + expect.objectContaining({ code: 'VALIDATION_FAILED', surfaceId: 's' }), + ]), + ); + }); + + it('says a message is not JSON, and shows the rest', async () => { + const { errors, ui } = setup(); + await renderReply(ui, [ + line({ createSurface: { surfaceId: 's' } }), + '{not json}', + line({ + updateComponents: { + components: [{ component: 'Text', id: 'root', text: 'After the bad one' }], + surfaceId: 's', + }, + }), + ]); + await waitFor(() => expect(screen.getByText(/After the bad one/)).toBeTruthy()); + await waitFor(() => + expect(errors.map(({ message }) => message)).toContain('A message isn’t a JSON object'), + ); + }); + + it('shows the messages after one the agent broke, and reports the broken one', async () => { + const { errors, ui } = setup(); + const text = (surfaceId: string, words: string) => [ + line({ createSurface: { surfaceId } }), + line({ + updateComponents: { + components: [{ component: 'Text', id: 'root', text: words }], + surfaceId, + }, + }), + ]; + await renderReply(ui, [ + ...text('a', 'Day 1'), + line({ createSurface: { surfaceId: 'b' } }).slice(0, -1), + ...text('c', 'Day 2'), + ]); + await waitFor(() => expect(screen.getByText('Day 2')).toBeTruthy()); + expect(screen.getByText('Day 1')).toBeTruthy(); + expect(errors).toEqual([ + expect.objectContaining({ + code: 'VALIDATION_FAILED', + message: 'A message isn’t a JSON object', + }), + ]); + }); + + it('opens links the way the host allows, from the person’s tap only', async () => { + const { open, ui } = setup(); + await renderReply( + ui, + surfaceOf( + button('root', 'Read about Ohrid', { + functionCall: { + '@call': 'openUrl', + args: { url: 'https://en.wikipedia.org/wiki/Ohrid' }, + }, + }), + ), + ); + const read = await screen.findByRole('button', { name: 'Read about Ohrid' }); + expect(open).not.toHaveBeenCalled(); + fireEvent.press(read); + await waitFor(() => expect(open).toHaveBeenCalledWith('https://en.wikipedia.org/wiki/Ohrid')); + }); + + it('shows nothing for a finished reply whose A2UI made no surface, so its text shows', async () => { + const { ui } = setup(); + await renderReply(ui, ['{"version":"1.0","deleteSurface":{"surfaceId":"none"}}']); + expect( + ui.content(documentOf(['{"version":"1.0","deleteSurface":{"surfaceId":"none"}}']), 'a1'), + ).toBeNull(); + expect(screen.toJSON()).toBeNull(); + }); +}); + +describe('what the person types into an obscured field', () => { + // A sign-in form: an email, a password, and a button that sends both, and the password inside + // a sentence, as an agent may bind it. + const signIn = ({ sendDataModel = false } = {}) => [ + line({ + createSurface: { + dataModel: { account: { email: '', password: '' } }, + sendDataModel, + surfaceId: 's', + }, + }), + line({ + updateComponents: { + components: [ + { children: ['email', 'password', 'go'], component: 'Column', id: 'root' }, + { + component: 'TextField', + id: 'email', + label: 'Email', + value: { '@path': '/account/email' }, + }, + { + component: 'TextField', + id: 'password', + label: 'Password', + value: { '@path': '/account/password' }, + variant: 'obscured', + }, + ...button('go', 'Sign in', { + event: { + context: { + email: { '@path': '/account/email' }, + password: { '@path': '/account/password' }, + said: { + '@call': 'formatString', + args: { value: 'My password is ${/account/password}' }, + }, + }, + name: 'sign_in', + userMessage: { + '@call': 'formatString', + args: { value: 'Signing in with ${/account/password}' }, + }, + }, + }), + ], + surfaceId: 's', + }, + }), + ]; + + const fillIn = async () => { + fireEvent.changeText(await screen.findByLabelText('Email'), 'ana@example.com'); + fireEvent.changeText(screen.getByLabelText('Password'), 'hunter22'); + fireEvent.press(screen.getByRole('button', { name: 'Sign in' })); + }; + + it('never reaches the agent in an action’s context or its message, bound or within text', async () => { + const { sent, ui } = setup(); + await renderReply(ui, signIn()); + await fillIn(); + await waitFor(() => expect(sent).toHaveLength(1)); + // The key bound to the field goes; text written with the password goes without it. + expect(sent[0]?.payload.context).toEqual({ + email: 'ana@example.com', + said: 'My password is ', + }); + expect(sent[0]?.payload.userMessage).toBe('Signing in with'); + expect(sent[0]?.payload).not.toHaveProperty('dataModel'); + expect(JSON.stringify(sent[0])).not.toContain('hunter22'); + }); + + it('is left out of the data model a surface sends with its actions', async () => { + const { sent, ui } = setup(); + await renderReply(ui, signIn({ sendDataModel: true })); + await fillIn(); + await waitFor(() => expect(sent).toHaveLength(1)); + expect(sent[0]?.payload.dataModel).toEqual({ account: { email: 'ana@example.com' } }); + expect(JSON.stringify(sent[0])).not.toContain('hunter22'); + }); + + it('stays the person’s whichever view the app draws the field with', async () => { + const { sent, ui } = setup({ + components: { + TextField: ({ props }) => ( + (props.setValue as (value: string) => void)(text)} + value={String(props.value ?? '')} + /> + ), + }, + }); + await renderReply(ui, signIn()); + await fillIn(); + await waitFor(() => expect(sent).toHaveLength(1)); + expect(JSON.stringify(sent[0])).not.toContain('hunter22'); + }); + + it('stays the person’s in an obscured field of a list’s row', async () => { + const { sent, ui } = setup(); + await renderReply(ui, [ + line({ + createSurface: { + dataModel: { accounts: [{ name: 'Main', pin: '' }] }, + sendDataModel: true, + surfaceId: 's', + }, + }), + line({ + updateComponents: { + components: [ + { children: ['accounts', 'go'], component: 'Column', id: 'root' }, + { + children: { componentId: 'pin', path: '/accounts' }, + component: 'List', + id: 'accounts', + }, + { + component: 'TextField', + id: 'pin', + label: 'PIN', + value: { '@path': 'pin' }, + variant: 'obscured', + }, + ...button('go', 'Save', { event: { name: 'save' } }), + ], + surfaceId: 's', + }, + }), + ]); + fireEvent.changeText(await screen.findByLabelText('PIN'), '4821'); + fireEvent.press(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(sent).toHaveLength(1)); + expect(sent[0]?.payload.dataModel).toEqual({ accounts: [{ name: 'Main' }] }); + expect(JSON.stringify(sent[0])).not.toContain('4821'); + }); + + it('leaves the context key a row’s button binds to the row’s obscured field', async () => { + const { sent, ui } = setup(); + await renderReply(ui, [ + line({ + createSurface: { + dataModel: { + accounts: [ + { name: 'Main', pin: '' }, + { name: 'Savings', pin: '' }, + ], + }, + surfaceId: 's', + }, + }), + line({ + updateComponents: { + components: [ + { + children: { componentId: 'row', path: '/accounts' }, + component: 'List', + id: 'root', + }, + { children: ['pin', 'go'], component: 'Row', id: 'row' }, + { + component: 'TextField', + id: 'pin', + label: 'PIN', + value: { '@path': 'pin' }, + variant: 'obscured', + }, + ...button('go', 'Save', { + event: { + context: { + account: { '@path': 'name' }, + pin: { '@path': 'pin' }, + said: { '@call': 'formatString', args: { value: 'My PIN is ${pin}' } }, + }, + name: 'save', + }, + }), + ], + surfaceId: 's', + }, + }), + ]); + const [, savings] = await screen.findAllByLabelText('PIN'); + fireEvent.changeText(savings, '4821'); + fireEvent.press(screen.getAllByRole('button', { name: 'Save' })[1]); + await waitFor(() => expect(sent).toHaveLength(1)); + expect(sent[0]?.payload.context).toEqual({ account: 'Savings', said: 'My PIN is ' }); + expect(JSON.stringify(sent[0])).not.toContain('4821'); + }); +}); + +describe('parseMoment and formatMoment', () => { + it('read and write the days and times an agent writes', () => { + expect(parseMoment('2026-11-14')).toEqual({ day: { date: 14, month: 10, year: 2026 } }); + expect(parseMoment('09:30')).toEqual({ hours: 9, minutes: 30 }); + expect(parseMoment('2026-11-14T09:30:00Z')).toEqual({ + day: { date: 14, month: 10, year: 2026 }, + hours: 9, + minutes: 30, + }); + const both = { enableDate: true, enableTime: true }; + expect(formatMoment(parseMoment('2026-01-02T03:04'), both)).toBe('2026-01-02T03:04'); + expect(formatMoment(parseMoment('2026-01-02'), { ...both, enableTime: false })).toBe( + '2026-01-02', + ); + expect(formatMoment(parseMoment('03:04'), { ...both, enableDate: false })).toBe('03:04'); + }); +}); + +describe('withoutSecrets and withoutPaths', () => { + it('cut a secret out of every string, in objects and lists', () => { + expect( + withoutSecrets({ a: 'hunter22', b: ['x', 'my hunter22'], c: { d: 'keep', e: 1 } }, [ + 'hunter22', + '', + ]), + ).toEqual({ a: '', b: ['x', 'my '], c: { d: 'keep', e: 1 } }); + expect(withoutSecrets('Signing in with abc', ['abc'])).toBe('Signing in with '); + expect(withoutSecrets({ a: 1 }, [])).toEqual({ a: 1 }); + }); + + it('remove what is at JSON Pointers, escaped segments and list items included', () => { + expect( + withoutPaths({ 'a/b': 1, list: [1, 2, 3], nested: { keep: true, secret: 's' } }, [ + '/a~1b', + '/list/1', + '/nested/secret', + '/missing/path', + ]), + ).toEqual({ list: [1, 3], nested: { keep: true } }); + }); +}); + +describe('the app’s tools in a reply’s UI', () => { + const weather = { + annotations: { readOnlyHint: true }, + description: 'The weather in a city today.', + inputSchema: { + properties: { city: { description: 'The city', type: 'string' } }, + required: ['city'], + type: 'object', + }, + name: 'weather', + }; + // A tool as the app gives it, whose output is what `run` resolves to. + const toolOf = ( + definition: AIClientToolDefinition, + run: (args: Record) => unknown, + ): AIClientTool => ({ + definition, + run: async (call) => ({ output: await run(call.arguments as Record) }), + }); + + const withTools = (tools: ConstructorParameters[0]['tools']) => { + const errors: { code: string; message: string }[] = []; + const ui = new AIUIHost({ + renderers: [createA2UIRenderer({ onError: (error) => errors.push(error) })], + send: jest.fn(), + tools, + }); + return { errors, ui }; + }; + + const forecast = surfaceOf( + [ + { children: ['title', 'sky'], component: 'Column', id: 'root' }, + { component: 'Text', id: 'title', text: 'Ohrid today' }, + { + component: 'Text', + id: 'sky', + text: { '@call': 'weather', args: { city: { '@path': '/city' } } }, + }, + ], + { city: 'Ohrid' }, + ); + + it('runs a tool the agent calls as a function, with its arguments worked out', async () => { + const run = jest.fn(({ city }: Record) => + Promise.resolve(`Sunny in ${String(city)}`), + ); + const { errors, ui } = withTools([toolOf(weather, run)]); + await renderReply(ui, forecast); + await waitFor(() => expect(screen.getByText(/Sunny in Ohrid/)).toBeTruthy()); + expect(run).toHaveBeenCalledWith({ city: 'Ohrid' }); + expect(errors).toEqual([]); + }); + + it('tells the agent about the tools it can call, with what they take', async () => { + const { ui } = withTools([toolOf(weather, () => 'Sunny')]); + const { renderers, tools } = await ui.capabilities(); + expect(tools).toEqual([weather]); + const { instructions } = renderers[0] as unknown as { instructions: string }; + expect(instructions).toContain('"weather"'); + expect(instructions).toContain('The weather in a city today.'); + expect(instructions).toContain('"city"'); + expect(instructions).toContain("## The app's tools"); + expect(instructions).toContain('- `weather` (read-only): The weather in a city today.'); + expect(instructions).toContain('Any other runs only from a Button'); + }); + + it('shows the rest of the UI when a tool fails, and says so', async () => { + const { errors, ui } = withTools([ + toolOf(weather, () => Promise.reject(new Error('No forecast'))), + ]); + await renderReply(ui, forecast); + await waitFor(() => expect(screen.getByText(/Ohrid today/)).toBeTruthy()); + await waitFor(() => expect(errors.length).toBeGreaterThan(0)); + expect(screen.queryByText(/Sunny/)).toBeNull(); + }); + + it('tells the model the app’s own instructions first', async () => { + const ui = new AIUIHost({ + renderers: [createA2UIRenderer({ instructions: ' Write every trip plan as UI. ' })], + send: jest.fn(), + }); + const { renderers } = await ui.capabilities(); + const { instructions } = renderers[0] as unknown as { instructions: string }; + expect(instructions.startsWith('Write every trip plan as UI.')).toBe(true); + }); + + it('runs a tool that changes something only when the person asks, never as the reply shows', async () => { + const run = jest.fn(() => 'Sunny'); + const { errors, ui } = withTools([ + toolOf({ ...weather, annotations: { readOnlyHint: false } }, run), + ]); + await renderReply(ui, forecast); + await waitFor(() => expect(screen.getByText(/Ohrid today/)).toBeTruthy()); + await waitFor(() => expect(errors.length).toBeGreaterThan(0)); + expect(errors[0]?.message).toMatch(/isn't read-only/); + expect(run).not.toHaveBeenCalled(); + }); + + it('gives a tool none of what the person typed into an obscured field', async () => { + const run = jest.fn((args: Record) => `Sunny in ${String(args.city)}`); + const { ui } = withTools([toolOf(weather, run)]); + await renderReply( + ui, + surfaceOf( + [ + { children: ['secret', 'sky'], component: 'Column', id: 'root' }, + { + component: 'TextField', + id: 'secret', + label: 'Password', + value: { '@path': '/password' }, + variant: 'obscured', + }, + { + component: 'Text', + id: 'sky', + text: { '@call': 'weather', args: { city: { '@path': '/password' } } }, + }, + ], + { password: 'hunter22' }, + ), + ); + await waitFor(() => expect(run).toHaveBeenCalled()); + expect(JSON.stringify(run.mock.calls)).not.toContain('hunter22'); + }); + + it('leaves out a tool that would take the name of one of the basic catalog’s functions', async () => { + const { errors, ui } = withTools([toolOf({ name: 'required' }, () => true)]); + await renderReply(ui, surfaceOf([{ component: 'Text', id: 'root', text: 'Here' }])); + await waitFor(() => expect(screen.getByText(/Here/)).toBeTruthy()); + await waitFor(() => + expect(errors).toEqual([expect.objectContaining({ code: 'CATALOG_ERROR' })]), + ); + }); +}); + +describe('the basic catalog’s icons', () => { + const pathOf = (id: string) => + screen.getByTestId(`a2ui-${id}`).findAll((node) => typeof node.props.d === 'string')[0]?.props + .d as string | undefined; + + it('draws every icon name the catalog has', () => { + const document = basicCatalogJSON.components.Icon as unknown as Record; + const enumOf = (value: unknown): string[] | undefined => { + if (Array.isArray(value)) { + for (const item of value) { + const found = enumOf(item); + if (found) return found; + } + return undefined; + } + if (typeof value !== 'object' || value === null) return undefined; + const record = value as Record; + if (Array.isArray(record.enum)) return record.enum as string[]; + for (const item of Object.values(record)) { + const found = enumOf(item); + if (found) return found; + } + return undefined; + }; + const catalogNames = enumOf(document) ?? []; + expect(catalogNames.length).toBeGreaterThan(50); + expect(catalogNames.filter((name) => !A2UI_ICON_PATHS[name])).toEqual([]); + }); + + // A name the catalog doesn't have fails validation before it is drawn, so only a path that + // isn't one falls back to the neutral icon. + it('draws a name’s Material icon, the agent’s own path, and a neutral one for a bad path', async () => { + const { ui } = setup(); + await renderReply( + ui, + surfaceOf([ + { children: ['cart', 'drawn', 'bad'], component: 'Row', id: 'root' }, + { component: 'Icon', id: 'cart', name: 'shoppingCart' }, + { component: 'Icon', id: 'drawn', name: { svgPath: 'M0 0h24v24H0z' } }, + { component: 'Icon', id: 'bad', name: { svgPath: '