diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 7a6acc09..c805c34b 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -1,14 +1,14 @@ --- name: release -description: Cut a new release - create release branch, bump version in package.json, refresh root and demo lockfiles, regenerate CHANGELOG, and stage for review. Usage: /release patch|minor|major or /release . +description: Cut a new release - create release branch, bump version in package.json and src/version.ts, refresh root and demo lockfiles, regenerate CHANGELOG, and stage for review. Usage: /release patch|minor|major or /release . allowed-tools: Bash(git checkout *), Bash(git branch *), Bash(git status *), Bash(git diff *), Bash(git add *), Bash(git log *), Bash(npm install *), Bash(npm run *), Bash(rm *), Bash(ls *), Read, Edit, Glob, Grep, Skill, AskUserQuestion --- # Release: Cut a New Release Branch -Create a `release/NEW_VERSION` branch, bump the version in `package.json`, -refresh lockfiles, regenerate the `CHANGELOG.md` entry for the new version, -and stage everything for review. Do **not** commit — committing and pushing +Create a `release/NEW_VERSION` branch, bump the version in `package.json` +and `src/version.ts`, refresh lockfiles, regenerate the `CHANGELOG.md` +entry for the new version, and stage everything for review. Do **not** commit — committing and pushing remain human actions per `CLAUDE.md` workflow rules. ## Step 1: Pre-flight checks @@ -68,10 +68,16 @@ git checkout -b release/NEW_VERSION If the branch already exists, stop and ask the user to delete it or pick a different version — never `--force` over an existing branch. -## Step 5: Bump the version in package.json +## Step 5: Bump the version in package.json and src/version.ts -Use **Edit** to change the `version` field only. Do NOT use `npm version` -— it creates a tag and commit, and we want the human to control both. +Use **Edit** to change the `version` field in `package.json` only. Do NOT +use `npm version` — it creates a tag and commit, and we want the human to +control both. + +Then use **Edit** to update the `VERSION` constant in `src/version.ts` to +the same `NEW_VERSION` string. This file is the source of the +`ai-transport-js` agent identifier value sent to Ably for SDK usage +tracking — it must stay in lockstep with `package.json`. ## Step 6: Refresh the root lockfile @@ -121,8 +127,9 @@ If the changelog skill reports no PRs found (placeholder `-` bullet), note this in the final summary and remind the user to fill it in. **Contract after this step:** `CHANGELOG.md` is modified but not staged. -The working tree also contains the `package.json` edit from Step 5 and -the lockfile changes from Steps 6-7, all unstaged. Step 10 stages them. +The working tree also contains the `package.json` and `src/version.ts` +edits from Step 5 and the lockfile changes from Steps 6-7, all unstaged. +Step 10 stages them. ## Step 9: Validate @@ -137,7 +144,7 @@ Run `npm run precommit` (format:check + lint + typecheck). Stage the files this skill modified: -1. Always stage: `git add package.json package-lock.json CHANGELOG.md` +1. Always stage: `git add package.json package-lock.json src/version.ts CHANGELOG.md` 2. For each demo app directory recorded in Step 7, stage its lockfile explicitly using its path — for example `git add demo/vercel/react/use-chat/package-lock.json`. Do **not** diff --git a/README.md b/README.md index 75b1a0df..f534c88d 100644 --- a/README.md +++ b/README.md @@ -98,8 +98,7 @@ export async function POST(req: Request) { const data = (await req.json()) as InvocationData; const invocation = Invocation.fromJSON(data); - const channel = ably.channels.get(invocation.sessionName); - const session = createAgentSession({ channel }); + const session = createAgentSession({ client: ably, channelName: invocation.sessionName }); await session.connect(); const run = session.createRun(invocation, { signal: req.signal }); @@ -237,7 +236,8 @@ import { createClientSession } from '@ably/ai-transport'; import { myCodec } from './my-codec'; const session = createClientSession({ - channel, // Ably RealtimeChannel + client: ably, // Ably.Realtime + channelName: 'ai:demo', codec: myCodec, clientId: 'user-123', api: '/api/chat', @@ -261,7 +261,7 @@ while (true) { import { createAgentSession, Invocation } from '@ably/ai-transport'; import { myCodec } from './my-codec'; -const session = createAgentSession({ channel, codec: myCodec }); +const session = createAgentSession({ client: ably, channelName: 'ai:demo', codec: myCodec }); await session.connect(); const run = session.createRun(invocation); diff --git a/demo/vercel/react/use-chat/src/app/api/chat/route.ts b/demo/vercel/react/use-chat/src/app/api/chat/route.ts index 1e7d8fe6..1b4b79d4 100644 --- a/demo/vercel/react/use-chat/src/app/api/chat/route.ts +++ b/demo/vercel/react/use-chat/src/app/api/chat/route.ts @@ -38,9 +38,8 @@ const ably = new Ably.Realtime({ key: process.env.ABLY_API_KEY! }); export async function POST(req: Request) { const data = (await req.json()) as InvocationData; const invocation = Invocation.fromJSON(data); - const channel = ably.channels.get(invocation.sessionName); - const session = createAgentSession({ channel }); + const session = createAgentSession({ client: ably, channelName: invocation.sessionName }); await session.connect(); const run = session.createRun(invocation, { signal: req.signal }); diff --git a/demo/vercel/react/use-client-session/src/app/api/chat/route.ts b/demo/vercel/react/use-client-session/src/app/api/chat/route.ts index 8685d8e6..f1043243 100644 --- a/demo/vercel/react/use-client-session/src/app/api/chat/route.ts +++ b/demo/vercel/react/use-client-session/src/app/api/chat/route.ts @@ -37,8 +37,7 @@ export async function POST(req: Request) { const { toolApprovals } = data; const invocation = Invocation.fromJSON(data); - const channel = ably.channels.get(invocation.sessionName); - const session = createAgentSession({ channel }); + const session = createAgentSession({ client: ably, channelName: invocation.sessionName }); await session.connect(); const run = session.createRun(invocation, { signal: req.signal }); diff --git a/docs/concepts/sessions.md b/docs/concepts/sessions.md index 2afb8285..077b37ec 100644 --- a/docs/concepts/sessions.md +++ b/docs/concepts/sessions.md @@ -43,8 +43,7 @@ import { streamText } from 'ai'; import { Invocation } from '@ably/ai-transport'; import { createAgentSession } from '@ably/ai-transport/vercel'; -const channel = ably.channels.get(channelName); -const session = createAgentSession({ channel }); +const session = createAgentSession({ client: ably, channelName }); await session.connect(); const run = session.createRun(Invocation.fromJSON({ runId, clientId })); @@ -69,7 +68,7 @@ The client session manages conversation state: the message list, conversation tr ```typescript import { createClientSession } from '@ably/ai-transport/vercel'; -const session = createClientSession({ channel, clientId }); +const session = createClientSession({ client: ably, channelName, clientId }); await session.connect(); const view = session.view; diff --git a/docs/features/streaming.md b/docs/features/streaming.md index 3c256234..ed1ac956 100644 --- a/docs/features/streaming.md +++ b/docs/features/streaming.md @@ -43,7 +43,7 @@ import { streamText } from 'ai'; import { Invocation } from '@ably/ai-transport'; import { createAgentSession } from '@ably/ai-transport/vercel'; -const session = createAgentSession({ channel }); +const session = createAgentSession({ client: ably, channelName }); await session.connect(); const run = session.createRun(Invocation.fromJSON({ runId, clientId })); diff --git a/docs/features/tool-calling.md b/docs/features/tool-calling.md index 25afc9fd..b49e6f86 100644 --- a/docs/features/tool-calling.md +++ b/docs/features/tool-calling.md @@ -34,7 +34,7 @@ import { z } from 'zod'; import { Invocation } from '@ably/ai-transport'; import { createAgentSession } from '@ably/ai-transport/vercel'; -const session = createAgentSession({ channel }); +const session = createAgentSession({ client: ably, channelName }); await session.connect(); const run = session.createRun(Invocation.fromJSON({ runId, clientId })); diff --git a/docs/frameworks/vercel-ai-sdk.md b/docs/frameworks/vercel-ai-sdk.md index 868e20b4..4c7f5092 100644 --- a/docs/frameworks/vercel-ai-sdk.md +++ b/docs/frameworks/vercel-ai-sdk.md @@ -112,7 +112,7 @@ import { Invocation } from '@ably/ai-transport'; import { createAgentSession } from '@ably/ai-transport/vercel'; import { streamText, convertToModelMessages } from 'ai'; -const session = createAgentSession({ channel }); +const session = createAgentSession({ client: ably, channelName }); await session.connect(); const run = session.createRun(Invocation.fromJSON({ runId, clientId, parent, forkOf })); diff --git a/docs/get-started/vercel-use-chat.md b/docs/get-started/vercel-use-chat.md index 12d4c8e6..b9bd550c 100644 --- a/docs/get-started/vercel-use-chat.md +++ b/docs/get-started/vercel-use-chat.md @@ -111,9 +111,8 @@ const ably = new Ably.Realtime({ key: process.env.ABLY_API_KEY! }); export async function POST(req: Request) { const { messages, history, chatId, runId, clientId, forkOf, parent } = (await req.json()) as ChatRequestBody; - const channel = ably.channels.get(chatId); - const session = createAgentSession({ channel }); + const session = createAgentSession({ client: ably, channelName: chatId }); await session.connect(); const run = session.createRun(Invocation.fromJSON({ runId, clientId, parent, forkOf })); @@ -149,7 +148,7 @@ The `after()` call is a Next.js API that runs work after the HTTP response is se ## 4. Create the chat component -Wire up `useChat()` with the AI Transport hooks. `ChatTransportProvider` creates both the `ClientSession` and `ChatTransport` and wraps children with Ably's `ChannelProvider` internally: +Wire up `useChat()` with the AI Transport hooks. `ChatTransportProvider` creates both the `ClientSession` and `ChatTransport`. The Ably Realtime client is read from the surrounding ``; the session is bound to the supplied `channelName`. ```typescript // app/chat.tsx @@ -205,8 +204,9 @@ function ChatInner({ chatId }: { chatId: string }) { export function Chat({ chatId, clientId }: { chatId: string; clientId?: string }) { return ( - // ChatTransportProvider creates both ClientSession and ChatTransport, - // and wraps children with ChannelProvider. No codec argument needed. + // ChatTransportProvider creates both ClientSession and ChatTransport. + // The Realtime client is read from the surrounding ; the + // session resolves the channel from channelName itself. No codec argument needed. diff --git a/docs/get-started/vercel-use-client-session.md b/docs/get-started/vercel-use-client-session.md index 7b2a73ca..75c9a66c 100644 --- a/docs/get-started/vercel-use-client-session.md +++ b/docs/get-started/vercel-use-client-session.md @@ -10,7 +10,7 @@ Same as the [useChat quickstart](vercel-use-chat.md#prerequisites). Follow steps ## Create the chat component -Instead of `useChat()`, compose the generic hooks directly. `ClientSessionProvider` creates the session and wraps children with Ably's `ChannelProvider` internally: +Instead of `useChat()`, compose the generic hooks directly. `ClientSessionProvider` creates the session — it reads the Realtime client from the surrounding `` and binds the session to the supplied `channelName`. ```typescript // app/chat.tsx @@ -104,8 +104,9 @@ function ChatInner({ chatId }: { chatId: string }) { export function Chat({ chatId, clientId }: { chatId: string; clientId?: string }) { return ( - // ClientSessionProvider creates the ClientSession, wraps children with ChannelProvider, - // and merges `body` into every HTTP POST so the server knows which channel to use. + // ClientSessionProvider creates the ClientSession (reading the Realtime + // client from the surrounding ) and merges `body` into every + // HTTP POST so the server knows which channel to use. `; the session is bound to the supplied `channelName`. ```tsx { * import { createAgentSession } from '@ably/ai-transport'; * import { AgentCodec } from './codec.js'; * - * const session = createAgentSession(channel, { codec: AgentCodec }); + * const session = createAgentSession({ client: ably, channelName, codec: AgentCodec }); * ``` */ export const AgentCodec: Codec = { diff --git a/specification b/specification index 16380398..277f8f82 160000 --- a/specification +++ b/specification @@ -1 +1 @@ -Subproject commit 163803980dccfdd2e3bee8857848ffe9344ebf04 +Subproject commit 277f8f82beb5a6fbaa5214ff2995ee9b13f1c087 diff --git a/src/core/agent.ts b/src/core/agent.ts new file mode 100644 index 00000000..b3cc5291 --- /dev/null +++ b/src/core/agent.ts @@ -0,0 +1,41 @@ +/** + * Wraps the two paths chat-js uses (see ChatClient._addAgent): the + * `options.agents` mutation (read by ably-js when opening the initial + * WebSocket) and the `params.agent` channel option (sent on ATTACH so + * an already-open connection still carries the identifier). + * + * `options.agents` is a private API on the Realtime client — no public + * typed accessor exists in the `ably` package — so this module casts to a + * `RealtimeWithOptions` shape to write it. + */ + +import type * as Ably from 'ably'; + +import { VERSION } from '../version.js'; + +interface RealtimeWithOptions extends Ably.Realtime { + options: { agents?: Record }; +} + +const SDK_NAME = 'ai-transport-js'; + +/** + * Register this SDK on the supplied Realtime client and return the channel + * options the caller should pass to `client.channels.get(...)` so the agent + * is also carried on channel ATTACH. Sets + * `options.agents['ai-transport-js'] = VERSION`. Idempotent — repeated + * calls with the same client produce the same key/value. + * @param client - The Ably Realtime client to register on. + * @returns Channel options containing `params.agent` for `channels.get`. + */ +export const registerAgent = (client: Ably.Realtime): { params: { agent: string } } => { + // CAST: Ably.Realtime's public type omits `options.agents`, but the SDK + // does carry it at runtime. ably-chat-js relies on the same shape — see + // ChatClient._addAgent in https://github.com/ably/ably-chat-js. + const realtime = client as RealtimeWithOptions; + realtime.options.agents = { + ...realtime.options.agents, + [SDK_NAME]: VERSION, + }; + return { params: { agent: `${SDK_NAME}/${VERSION}` } }; +}; diff --git a/src/core/transport/agent-session.ts b/src/core/transport/agent-session.ts index 49eac016..772dd20b 100644 --- a/src/core/transport/agent-session.ts +++ b/src/core/transport/agent-session.ts @@ -22,6 +22,7 @@ import { import { ErrorCode } from '../../errors.js'; import type { Logger } from '../../logger.js'; import { getHeaders, mergeHeaders } from '../../utils.js'; +import { registerAgent } from '../agent.js'; import { buildTransportHeaders } from './headers.js'; import { Invocation } from './invocation.js'; import { pipeStream } from './pipe-stream.js'; @@ -93,7 +94,11 @@ class DefaultAgentSession implements AgentSession) { - this._channel = options.channel; + // Spec: AIT-ST1a, AIT-ST1a2 — register this SDK on both the connection + // (options.agents) and channel-attach (params.agent) paths. Idempotent + // across sessions sharing one client. + const channelOptions = registerAgent(options.client); + this._channel = options.client.channels.get(options.channelName, channelOptions); this._codec = options.codec; this._logger = options.logger?.withContext({ component: 'AgentSession' }); this._onError = options.onError; @@ -638,7 +643,9 @@ class DefaultAgentSession implements AgentSession implements ClientSession[] = []; constructor(options: ClientSessionOptions) { - this._channel = options.channel; + // Spec: AIT-CT1a, AIT-CT1a2 — register this SDK on both the connection + // (options.agents) and channel-attach (params.agent) paths. Idempotent + // across sessions sharing one client. + const channelOptions = registerAgent(options.client); + this._channel = options.client.channels.get(options.channelName, channelOptions); this._codec = options.codec; this._clientId = options.clientId; this._api = options.api; @@ -1012,8 +1017,10 @@ class DefaultClientSession implements ClientSession { - /** The Ably channel to publish to. Must match the client's channel. */ - channel: Ably.RealtimeChannel; + /** + * The Ably Realtime client. The caller owns its lifecycle — + * `session.close()` does not close the client. + */ + client: Ably.Realtime; + /** + * The name of the channel to publish to. The session owns this channel — + * do not also resolve it elsewhere with conflicting channel options. + */ + channelName: string; /** The codec to use for encoding events and messages. */ codec: Codec; /** Logger instance for diagnostic output. */ @@ -290,8 +298,18 @@ export interface AgentSession { /** Options for creating a client session. */ export interface ClientSessionOptions { - /** The Ably channel to receive responses on and publish cancel signals to. */ - channel: Ably.RealtimeChannel; + /** + * The Ably Realtime client. The caller owns its lifecycle — + * `session.close()` does not close the client. + */ + client: Ably.Realtime; + + /** + * The name of the channel to subscribe to and publish cancel signals on. + * The session owns this channel — do not also resolve it elsewhere with + * conflicting channel options. + */ + channelName: string; /** The codec to use for encoding/decoding. */ codec: Codec; diff --git a/src/react/contexts/client-session-provider.tsx b/src/react/contexts/client-session-provider.tsx index 31cb44aa..416955ea 100644 --- a/src/react/contexts/client-session-provider.tsx +++ b/src/react/contexts/client-session-provider.tsx @@ -2,17 +2,15 @@ * ClientSessionProvider: creates a ClientSession and makes it available to * descendants via ClientSessionContext. * - * Wraps children with Ably's ChannelProvider so the underlying channel - * lifecycle is managed in one place. An inner component calls useChannel - * to get the stable channel reference, creates the session once on first - * render (via useRef), and calls `await session.connect()` from a - * `useEffect` so the session is subscribed/attached before the first - * descendant operation. + * Reads the Ably Realtime client from the surrounding `` and + * forwards it to `createClientSession` along with the supplied `channelName`. * - * If createClientSession throws, the error is stored in the - * ClientSessionSlot (alongside an undefined session) so that - * useClientSession can surface it as sessionError without crashing the - * component tree. + * The session is created once on first render (via useRef) and `connect()` + * is invoked from a `useEffect` so the session is subscribed/attached + * before the first descendant operation. If `createClientSession` throws, + * the error is stored in the ClientSessionSlot (alongside an undefined + * session) so that useClientSession can surface it as `sessionError` + * without crashing the component tree. * * The session is closed when the provider truly unmounts. The close is * scheduled as a microtask so that React Strict Mode's synchronous @@ -25,7 +23,7 @@ */ import * as Ably from 'ably'; -import { ChannelProvider, useChannel } from 'ably/react'; +import { useAbly } from 'ably/react'; import { type PropsWithChildren, type ReactNode, useContext, useEffect, useMemo, useRef } from 'react'; import { createClientSession } from '../../core/transport/client-session.js'; @@ -37,22 +35,59 @@ import { ClientSessionContext } from './client-session-context.js'; /** * Props for {@link ClientSessionProvider}. * - * All {@link ClientSessionOptions} except `channel` (managed internally) plus `channelName`. + * All {@link ClientSessionOptions} except `client` (read from the surrounding + * ``). */ export interface ClientSessionProviderProps - extends Omit, 'channel'>, PropsWithChildren { - /** The Ably channel name to subscribe to. Also used as the context registry key. */ - channelName: string; -} + extends Omit, 'client'>, PropsWithChildren {} -// Inner component: rendered inside ChannelProvider so useChannel resolves to -// the channel created by the outer wrapper. -const ClientSessionProviderInner = ({ - channelName, +/** + * Provide a {@link ClientSession} to descendant components. + * + * Reads the Ably Realtime client from the surrounding ``, + * creates a session bound to `channelName`, calls `connect()` on mount, + * and registers it in `ClientSessionContext` under `channelName`. + * Descendants call {@link useClientSession} with the same `channelName` to + * access the session. + * + * If `createClientSession` throws during construction, the error is surfaced + * through `useClientSession` as `sessionError` — the component tree does not + * crash and children are still rendered. + * + * ```tsx + * + * + * + * + * + * + * // Inside Chat: + * const { session, sessionError } = useClientSession({ channelName: 'ai:demo' }); + * ``` + * + * For multiple sessions, nest providers with distinct channelNames: + * + * ```tsx + * + * + * + * + * + * + * // Inside App: + * const { session: main } = useClientSession({ channelName: 'ai:main' }); + * const { session: aux } = useClientSession({ channelName: 'ai:aux' }); + * ``` + * @param props - Provider configuration including `channelName`, `codec`, and all other {@link ClientSessionOptions} except `client`. + * @param props.children - Descendant components that consume the session via {@link useClientSession}. + * @returns A React element wrapping children with ClientSessionContext. + */ +export const ClientSessionProvider = ({ children, ...sessionOptions -}: ClientSessionProviderProps) => { - const { channel } = useChannel({ channelName }); +}: ClientSessionProviderProps): ReactNode => { + const client = useAbly(); + const { channelName } = sessionOptions; const sessionRef = useRef | undefined>(undefined); const sessionChannelRef = useRef(channelName); const sessionsToDisposeRef = useRef[]>([]); @@ -65,7 +100,7 @@ const ClientSessionProviderInner = ({ sessionChannelRef.current = channelName; if (sessionRef.current) sessionsToDisposeRef.current.push(sessionRef.current); try { - sessionRef.current = createClientSession({ ...sessionOptions, channel }); + sessionRef.current = createClientSession({ ...sessionOptions, client }); constructionErrorRef.current = undefined; } catch (error) { sessionRef.current = undefined; @@ -129,49 +164,3 @@ const ClientSessionProviderInner = ({ return {children}; }; - -/** - * Provide a {@link ClientSession} to descendant components. - * - * Wraps children with Ably's `ChannelProvider` using `channelName`, creates a - * session from the resolved channel and the remaining options, calls - * `connect()` on mount, and registers it in `ClientSessionContext` under - * `channelName`. Descendants call {@link useClientSession} with the same - * `channelName` to access the session. - * - * If `createClientSession` throws during construction, the error is surfaced - * through `useClientSession` as `sessionError` — the component tree does not - * crash and children are still rendered. - * - * ```tsx - * - * - * - * - * // Inside Chat: - * const { session, sessionError } = useClientSession({ channelName: 'ai:demo' }); - * ``` - * - * For multiple sessions, nest providers with distinct channelNames: - * - * ```tsx - * - * - * - * - * - * - * // Inside App: - * const { session: main } = useClientSession({ channelName: 'ai:main' }); - * const { session: aux } = useClientSession({ channelName: 'ai:aux' }); - * ``` - * @param props - Provider configuration including `channelName`, `codec`, and all other {@link ClientSessionOptions}. - * @returns A React element wrapping children with ChannelProvider and ClientSessionContext. - */ -export const ClientSessionProvider = ( - props: ClientSessionProviderProps, -): ReactNode => ( - - - -); diff --git a/src/react/use-client-session.ts b/src/react/use-client-session.ts index a7f9e685..e2c0766a 100644 --- a/src/react/use-client-session.ts +++ b/src/react/use-client-session.ts @@ -1,9 +1,9 @@ /** * useClientSession — read a ClientSession from the nearest ClientSessionProvider. * - * The session is created by {@link ClientSessionProvider}, which also wraps the subtree - * with Ably's `ChannelProvider`. This hook is a thin context reader — it does not - * create or manage session state. + * The session is created by {@link ClientSessionProvider}, which reads the Ably + * Realtime client from the surrounding ``. This hook is a thin + * context reader — it does not create or manage session state. * * **Provider lookup** * - Omit `channelName` to use the innermost `ClientSessionProvider` in the tree. diff --git a/src/vercel/react/contexts/chat-transport-provider.tsx b/src/vercel/react/contexts/chat-transport-provider.tsx index 42c78b7d..d437e2b7 100644 --- a/src/vercel/react/contexts/chat-transport-provider.tsx +++ b/src/vercel/react/contexts/chat-transport-provider.tsx @@ -2,10 +2,11 @@ * ChatTransportProvider: creates a ChatTransport from a ClientSession and makes it * available to descendants via ChatTransportContext. * - * Wraps children with ClientSessionProvider (using UIMessageCodec) so the Ably channel - * lifecycle is managed in one place. An inner component reads the ClientSession - * via useClientSession() and creates the ChatTransport once on first render - * (via useRef). + * Wraps children with ClientSessionProvider (using UIMessageCodec). The + * surrounding `` supplies the Realtime client; the session + * resolves the channel from `channelName` itself. An inner component reads + * the ClientSession via useClientSession() and creates the ChatTransport + * once on first render (via useRef). * * The ChatTransport is NOT closed on unmount — the underlying ClientSession * lifecycle is managed by the wrapping ClientSessionProvider. Auto-closing would break @@ -84,8 +85,9 @@ const ChatTransportProviderInner = ({ /** * Provide a {@link ChatTransport} and its underlying {@link ClientSession} to descendant components. * - * Wraps children with Ably's `ChannelProvider` (via `ClientSessionProvider`) using `channelName`, - * creates a {@link ClientSession} with UIMessageCodec, wraps it in a {@link ChatTransport}, + * Wraps children with `ClientSessionProvider` using `channelName` (the Realtime + * client is read from the surrounding ``), creates a + * {@link ClientSession} with UIMessageCodec, wraps it in a {@link ChatTransport}, * and registers the full slot in `ChatTransportContext` under `channelName`. Descendants call * {@link useChatTransport} with the same `channelName` to access both. * @@ -103,7 +105,7 @@ const ChatTransportProviderInner = ({ * @param props - Provider configuration including `channelName`, optional `chatOptions`, and all other session options. * @param props.chatOptions - Optional hooks for customizing chat request construction. Must be stable (memoized) — a new reference recreates the ChatTransport. * @param props.children - Descendant components that consume the chat transport via hooks. - * @returns A React element wrapping children with ChannelProvider, ClientSessionContext, and ChatTransportContext. + * @returns A React element wrapping children with ClientSessionContext and ChatTransportContext. */ export const ChatTransportProvider = ({ chatOptions, diff --git a/src/vercel/react/use-chat-transport.ts b/src/vercel/react/use-chat-transport.ts index 6b67f5da..345f6bfc 100644 --- a/src/vercel/react/use-chat-transport.ts +++ b/src/vercel/react/use-chat-transport.ts @@ -2,9 +2,10 @@ * useChatTransport: reads a ChatTransport and its underlying ClientSession from * the nearest ChatTransportProvider. * - * The chat transport is created by ChatTransportProvider, which also wraps the subtree - * with ClientSessionProvider and Ably's ChannelProvider. This hook is a thin context - * reader — it does not create or manage any session/transport state. + * The chat transport is created by ChatTransportProvider, which wraps the subtree + * with ClientSessionProvider. The Ably Realtime client is read from the + * surrounding ``. This hook is a thin context reader — it does + * not create or manage any session/transport state. * * Pass `channelName` to look up a specific provider by name. Omit to use the nearest * provider in the tree. Pass `skip: true` to defer (e.g. when auth is not yet resolved) diff --git a/src/vercel/transport/index.ts b/src/vercel/transport/index.ts index 3bde42bf..3e1d5a05 100644 --- a/src/vercel/transport/index.ts +++ b/src/vercel/transport/index.ts @@ -7,7 +7,7 @@ * ```ts * import { createClientSession } from '@ably/ai-transport/vercel'; * - * const session = createClientSession({ channel }); + * const session = createClientSession({ client, channelName: 'ai:demo' }); * await session.connect(); * ``` */ diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 00000000..af07c7b1 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,2 @@ +/** SDK version. Kept in sync with `package.json` by the `/release` workflow. */ +export const VERSION = '0.1.0'; diff --git a/test/core/agent.test.ts b/test/core/agent.test.ts new file mode 100644 index 00000000..92117a4e --- /dev/null +++ b/test/core/agent.test.ts @@ -0,0 +1,57 @@ +import type * as Ably from 'ably'; +import { describe, expect, it } from 'vitest'; + +import { registerAgent } from '../../src/core/agent.js'; +import { VERSION } from '../../src/version.js'; + +interface RealtimeWithAgents { + options: { agents?: Record }; +} + +const fakeClient = (initial?: Record): Ably.Realtime => { + const client: RealtimeWithAgents = { + options: initial ? { agents: { ...initial } } : {}, + }; + // CAST: minimal stub used purely to exercise registerAgent's mutation. + return client as unknown as Ably.Realtime; +}; + +const agentsOf = (client: Ably.Realtime): Record | undefined => + (client as unknown as RealtimeWithAgents).options.agents; + +describe('registerAgent', () => { + it('sets the ai-transport-js agent on a client with no prior agents', () => { + const client = fakeClient(); + registerAgent(client); + expect(agentsOf(client)).toEqual({ 'ai-transport-js': VERSION }); + }); + + it('preserves existing agents when registering', () => { + const client = fakeClient({ 'some-other-sdk': '1.2.3' }); + registerAgent(client); + expect(agentsOf(client)).toEqual({ + 'some-other-sdk': '1.2.3', + 'ai-transport-js': VERSION, + }); + }); + + it('is idempotent across repeated calls', () => { + const client = fakeClient(); + registerAgent(client); + registerAgent(client); + registerAgent(client); + expect(agentsOf(client)).toEqual({ 'ai-transport-js': VERSION }); + }); + + it('overwrites a stale prior version of itself', () => { + const client = fakeClient({ 'ai-transport-js': '0.0.0' }); + registerAgent(client); + expect(agentsOf(client)?.['ai-transport-js']).toBe(VERSION); + }); + + it('returns channel options carrying the agent identifier on params', () => { + const client = fakeClient(); + const channelOptions = registerAgent(client); + expect(channelOptions).toEqual({ params: { agent: `ai-transport-js/${VERSION}` } }); + }); +}); diff --git a/test/core/transport/agent-session.integration.test.ts b/test/core/transport/agent-session.integration.test.ts index 3adcab01..23ce745d 100644 --- a/test/core/transport/agent-session.integration.test.ts +++ b/test/core/transport/agent-session.integration.test.ts @@ -110,11 +110,11 @@ describe('AgentSession integration', () => { const serverClient = ablyRealtimeClient(); const subClient = ablyRealtimeClient(); - const serverChannel = serverClient.channels.get(channelName); const subChannel = subClient.channels.get(channelName); session = createAgentSession({ - channel: serverChannel, + client: serverClient, + channelName, codec: UIMessageCodec, }); await session.connect(); @@ -167,11 +167,11 @@ describe('AgentSession integration', () => { const serverClient = ablyRealtimeClient(); const subClient = ablyRealtimeClient(); - const serverChannel = serverClient.channels.get(channelName); const subChannel = subClient.channels.get(channelName); session = createAgentSession({ - channel: serverChannel, + client: serverClient, + channelName, codec: UIMessageCodec, }); await session.connect(); @@ -221,11 +221,11 @@ describe('AgentSession integration', () => { const serverClient = ablyRealtimeClient(); const cancelClient = ablyRealtimeClient(); - const serverChannel = serverClient.channels.get(channelName); const cancelChannel = cancelClient.channels.get(channelName); session = createAgentSession({ - channel: serverChannel, + client: serverClient, + channelName, codec: UIMessageCodec, }); await session.connect(); @@ -274,11 +274,11 @@ describe('AgentSession integration', () => { const serverClient = ablyRealtimeClient(); const subClient = ablyRealtimeClient(); - const serverChannel = serverClient.channels.get(channelName); const subChannel = subClient.channels.get(channelName); session = createAgentSession({ - channel: serverChannel, + client: serverClient, + channelName, codec: UIMessageCodec, }); await session.connect(); @@ -330,11 +330,11 @@ describe('AgentSession integration', () => { const serverClient = ablyRealtimeClient(); const subClient = ablyRealtimeClient(); - const serverChannel = serverClient.channels.get(channelName); const subChannel = subClient.channels.get(channelName); session = createAgentSession({ - channel: serverChannel, + client: serverClient, + channelName, codec: UIMessageCodec, }); await session.connect(); @@ -391,11 +391,11 @@ describe('AgentSession integration', () => { const serverClient = ablyRealtimeClient(); const subClient = ablyRealtimeClient(); - const serverChannel = serverClient.channels.get(channelName); const subChannel = subClient.channels.get(channelName); session = createAgentSession({ - channel: serverChannel, + client: serverClient, + channelName, codec: UIMessageCodec, }); await session.connect(); @@ -450,12 +450,12 @@ describe('AgentSession integration', () => { const sub1Client = ablyRealtimeClient(); const sub2Client = ablyRealtimeClient(); - const serverChannel = serverClient.channels.get(channelName); const sub1Channel = sub1Client.channels.get(channelName); const sub2Channel = sub2Client.channels.get(channelName); session = createAgentSession({ - channel: serverChannel, + client: serverClient, + channelName, codec: UIMessageCodec, }); await session.connect(); @@ -490,11 +490,11 @@ describe('AgentSession integration', () => { const serverClient = ablyRealtimeClient(); const subClient = ablyRealtimeClient(); - const serverChannel = serverClient.channels.get(channelName); const subChannel = subClient.channels.get(channelName); session = createAgentSession({ - channel: serverChannel, + client: serverClient, + channelName, codec: UIMessageCodec, }); await session.connect(); @@ -574,12 +574,12 @@ describe('AgentSession integration', () => { it('invokes onError with ChannelContinuityLost when the channel detaches', async () => { const channelName = uniqueChannelName('st-continuity'); const serverClient = ablyRealtimeClient(); - const serverChannel = serverClient.channels.get(channelName); const errors: Ably.ErrorInfo[] = []; session = createAgentSession({ - channel: serverChannel, + client: serverClient, + channelName, codec: UIMessageCodec, onError: (err) => errors.push(err), }); @@ -590,7 +590,7 @@ describe('AgentSession integration', () => { // Channel is ATTACHED after start() — any subsequent transition that // breaks continuity must surface via onError. - await serverChannel.detach(); + await serverClient.channels.get(channelName).detach(); await vi.waitFor( () => { @@ -617,11 +617,11 @@ describe('AgentSession integration', () => { const serverClient = ablyRealtimeClient(); const subClient = ablyRealtimeClient(); - const serverChannel = serverClient.channels.get(channelName); const subChannel = subClient.channels.get(channelName); session = createAgentSession({ - channel: serverChannel, + client: serverClient, + channelName, codec: UIMessageCodec, }); await session.connect(); diff --git a/test/core/transport/agent-session.test.ts b/test/core/transport/agent-session.test.ts index ffdb485a..bbf04c64 100644 --- a/test/core/transport/agent-session.test.ts +++ b/test/core/transport/agent-session.test.ts @@ -21,6 +21,8 @@ import type { Codec, StreamEncoder } from '../../../src/core/codec/types.js'; import { createAgentSession } from '../../../src/core/transport/agent-session.js'; import type { AgentSession, MessageNode } from '../../../src/core/transport/types.js'; import { ErrorCode } from '../../../src/errors.js'; +import { VERSION } from '../../../src/version.js'; +import { createMockClient } from '../../helper/mock-client.js'; import { createRunFromOpts } from '../../helper/run-from-opts.js'; // --------------------------------------------------------------------------- @@ -205,7 +207,7 @@ describe('AgentSession', () => { beforeEach(async () => { channel = createMockChannel(); codec = createMockCodec(); - session = createAgentSession({ channel, codec }); + session = createAgentSession({ client: createMockClient(channel), channelName: 'test-channel', codec }); await session.connect(); }); @@ -213,11 +215,49 @@ describe('AgentSession', () => { session.close(); }); + describe('construction', () => { + it('registers the ai-transport-js agent on the client and forwards params.agent to channels.get', () => { + const ch = createMockChannel(); + const client = createMockClient(ch); + const c = createMockCodec(); + const s = createAgentSession({ client, channelName: 'attribution-channel', codec: c }); + const agents = (client as unknown as { options: { agents?: Record } }).options.agents; + expect(agents?.['ai-transport-js']).toBe(VERSION); + // eslint-disable-next-line @typescript-eslint/unbound-method -- accessing vi mock + expect(client.channels.get).toHaveBeenCalledWith('attribution-channel', { + params: { agent: `ai-transport-js/${VERSION}` }, + }); + s.close(); + }); + + it('does not pollute options.agents when constructing multiple sessions on the same client', () => { + const ch1 = createMockChannel(); + const ch2 = createMockChannel(); + const client = createMockClient(ch1); + const optionsRef = (client as unknown as { options: { agents?: Record } }).options; + // Seed an unrelated entry so we can assert it survives. + optionsRef.agents = { 'some-other-sdk': '9.9.9' }; + const c = createMockCodec(); + const s1 = createAgentSession({ client, channelName: 'ch-a', codec: c }); + // Swap the channel returned by channels.get for the second session so + // each session has its own channel mock to publish to. + // eslint-disable-next-line @typescript-eslint/unbound-method -- vi.mocked takes a method reference + vi.mocked(client.channels.get).mockReturnValue(ch2); + const s2 = createAgentSession({ client, channelName: 'ch-b', codec: c }); + expect(optionsRef.agents).toEqual({ + 'some-other-sdk': '9.9.9', + 'ai-transport-js': VERSION, + }); + s1.close(); + s2.close(); + }); + }); + describe('connect()', () => { it('subscribes to cancel events', async () => { const ch = createMockChannel(); const c = createMockCodec(); - const s = createAgentSession({ channel: ch, codec: c }); + const s = createAgentSession({ client: createMockClient(ch), channelName: 'test-channel', codec: c }); await s.connect(); expect(ch.subscribe).toHaveBeenCalledWith(EVENT_CANCEL, expect.any(Function)); s.close(); @@ -226,7 +266,7 @@ describe('AgentSession', () => { it('is idempotent — multiple calls return the same subscribe', async () => { const ch = createMockChannel(); const c = createMockCodec(); - const s = createAgentSession({ channel: ch, codec: c }); + const s = createAgentSession({ client: createMockClient(ch), channelName: 'test-channel', codec: c }); const p1 = s.connect(); const p2 = s.connect(); expect(p1).toBe(p2); @@ -238,7 +278,7 @@ describe('AgentSession', () => { it('rejects when called after close()', async () => { const ch = createMockChannel(); const c = createMockCodec(); - const s = createAgentSession({ channel: ch, codec: c }); + const s = createAgentSession({ client: createMockClient(ch), channelName: 'test-channel', codec: c }); s.close(); await expect(s.connect()).rejects.toBeErrorInfoWithCode(ErrorCode.SessionClosed); }); @@ -248,7 +288,7 @@ describe('AgentSession', () => { it('start() throws InvalidArgument if connect() was not called', async () => { const ch = createMockChannel(); const c = createMockCodec(); - const s = createAgentSession({ channel: ch, codec: c }); + const s = createAgentSession({ client: createMockClient(ch), channelName: 'test-channel', codec: c }); const run = createRunFromOpts(s, { runId: 'run-1' }); await expect(run.start()).rejects.toBeErrorInfoWithCode(ErrorCode.InvalidArgument); s.close(); @@ -257,7 +297,7 @@ describe('AgentSession', () => { it('addMessages() throws InvalidArgument if connect() was not called', async () => { const ch = createMockChannel(); const c = createMockCodec(); - const s = createAgentSession({ channel: ch, codec: c }); + const s = createAgentSession({ client: createMockClient(ch), channelName: 'test-channel', codec: c }); const run = createRunFromOpts(s, { runId: 'run-1' }); await expect(run.addMessages([makeNode({ id: 'm', content: 'hi' })])).rejects.toBeErrorInfoWithCode( ErrorCode.InvalidArgument, @@ -268,7 +308,7 @@ describe('AgentSession', () => { it('addEvents() throws InvalidArgument if connect() was not called', async () => { const ch = createMockChannel(); const c = createMockCodec(); - const s = createAgentSession({ channel: ch, codec: c }); + const s = createAgentSession({ client: createMockClient(ch), channelName: 'test-channel', codec: c }); const run = createRunFromOpts(s, { runId: 'run-1' }); await expect( run.addEvents([{ kind: 'event', msgId: 'target-1', events: [{ type: 'ev' }] }]), @@ -279,7 +319,7 @@ describe('AgentSession', () => { it('pipe() throws InvalidArgument if connect() was not called', async () => { const ch = createMockChannel(); const c = createMockCodec(); - const s = createAgentSession({ channel: ch, codec: c }); + const s = createAgentSession({ client: createMockClient(ch), channelName: 'test-channel', codec: c }); const run = createRunFromOpts(s, { runId: 'run-1' }); const stream = new ReadableStream({ start: (controller) => { @@ -293,7 +333,7 @@ describe('AgentSession', () => { it('end() throws InvalidArgument if connect() was not called', async () => { const ch = createMockChannel(); const c = createMockCodec(); - const s = createAgentSession({ channel: ch, codec: c }); + const s = createAgentSession({ client: createMockClient(ch), channelName: 'test-channel', codec: c }); const run = createRunFromOpts(s, { runId: 'run-1' }); await expect(run.end('complete')).rejects.toBeErrorInfoWithCode(ErrorCode.InvalidArgument); s.close(); @@ -686,7 +726,8 @@ describe('AgentSession', () => { const onError = vi.fn(); const failSession = createAgentSession({ - channel: failChannel, + client: createMockClient(failChannel), + channelName: 'test-channel', codec, onError, }); @@ -723,7 +764,11 @@ describe('AgentSession', () => { // eslint-disable-next-line @typescript-eslint/unbound-method -- vi mock vi.mocked(failCodec.createEncoder).mockReturnValue(failEncoder); - const failSession = createAgentSession({ channel, codec: failCodec }); + const failSession = createAgentSession({ + client: createMockClient(channel), + channelName: 'test-channel', + codec: failCodec, + }); await failSession.connect(); const run = createRunFromOpts(failSession, { runId: 'run-1', onError }); await run.start(); @@ -745,7 +790,11 @@ describe('AgentSession', () => { // eslint-disable-next-line @typescript-eslint/unbound-method -- vi mock vi.mocked(failCodec.createEncoder).mockReturnValue(failEncoder); - const failSession = createAgentSession({ channel, codec: failCodec }); + const failSession = createAgentSession({ + client: createMockClient(channel), + channelName: 'test-channel', + codec: failCodec, + }); await failSession.connect(); const run = createRunFromOpts(failSession, { runId: 'run-1', onError }); await run.start(); @@ -911,7 +960,12 @@ describe('AgentSession', () => { const onError = vi.fn(); const ch = createMockChannel(); ch.state = 'initialized'; - createAgentSession({ channel: ch, codec: createMockCodec(), onError }); + createAgentSession({ + client: createMockClient(ch), + channelName: 'test-channel', + codec: createMockCodec(), + onError, + }); simulateInitialAttach(ch); simulateStateChange(ch, { @@ -932,7 +986,12 @@ describe('AgentSession', () => { const onError = vi.fn(); const ch = createMockChannel(); ch.state = 'initialized'; - createAgentSession({ channel: ch, codec: createMockCodec(), onError }); + createAgentSession({ + client: createMockClient(ch), + channelName: 'test-channel', + codec: createMockCodec(), + onError, + }); simulateInitialAttach(ch); simulateStateChange(ch, { @@ -949,7 +1008,12 @@ describe('AgentSession', () => { const onError = vi.fn(); const ch = createMockChannel(); ch.state = 'initialized'; - createAgentSession({ channel: ch, codec: createMockCodec(), onError }); + createAgentSession({ + client: createMockClient(ch), + channelName: 'test-channel', + codec: createMockCodec(), + onError, + }); simulateInitialAttach(ch); // Simulate the channel losing connection and re-attaching without resume. @@ -975,7 +1039,12 @@ describe('AgentSession', () => { const onError = vi.fn(); const ch = createMockChannel(); ch.state = 'initialized'; - createAgentSession({ channel: ch, codec: createMockCodec(), onError }); + createAgentSession({ + client: createMockClient(ch), + channelName: 'test-channel', + codec: createMockCodec(), + onError, + }); simulateStateChange(ch, { current: 'attached', @@ -990,7 +1059,12 @@ describe('AgentSession', () => { const onError = vi.fn(); const ch = createMockChannel(); ch.state = 'initialized'; - createAgentSession({ channel: ch, codec: createMockCodec(), onError }); + createAgentSession({ + client: createMockClient(ch), + channelName: 'test-channel', + codec: createMockCodec(), + onError, + }); simulateInitialAttach(ch); simulateStateChange(ch, { @@ -1006,7 +1080,12 @@ describe('AgentSession', () => { const onError = vi.fn(); const ch = createMockChannel(); ch.state = 'attached'; - createAgentSession({ channel: ch, codec: createMockCodec(), onError }); + createAgentSession({ + client: createMockClient(ch), + channelName: 'test-channel', + codec: createMockCodec(), + onError, + }); // UPDATE with resumed: false — should be treated as a real discontinuity // even though no initial ATTACHING → ATTACHED transition was observed. @@ -1025,7 +1104,12 @@ describe('AgentSession', () => { const runOnError = vi.fn(); const ch = createMockChannel(); ch.state = 'initialized'; - const s = createAgentSession({ channel: ch, codec: createMockCodec(), onError: sessionOnError }); + const s = createAgentSession({ + client: createMockClient(ch), + channelName: 'test-channel', + codec: createMockCodec(), + onError: sessionOnError, + }); await s.connect(); simulateInitialAttach(ch); @@ -1045,7 +1129,12 @@ describe('AgentSession', () => { const onError = vi.fn(); const ch = createMockChannel(); ch.state = 'initialized'; - const t = createAgentSession({ channel: ch, codec: createMockCodec(), onError }); + const t = createAgentSession({ + client: createMockClient(ch), + channelName: 'test-channel', + codec: createMockCodec(), + onError, + }); simulateInitialAttach(ch); t.close(); @@ -1061,7 +1150,7 @@ describe('AgentSession', () => { it('does not crash when no onError callback is supplied', () => { const ch = createMockChannel(); ch.state = 'initialized'; - createAgentSession({ channel: ch, codec: createMockCodec() }); + createAgentSession({ client: createMockClient(ch), channelName: 'test-channel', codec: createMockCodec() }); simulateInitialAttach(ch); expect(() => { diff --git a/test/core/transport/client-session.integration.test.ts b/test/core/transport/client-session.integration.test.ts index a9467c93..22f08230 100644 --- a/test/core/transport/client-session.integration.test.ts +++ b/test/core/transport/client-session.integration.test.ts @@ -150,17 +150,16 @@ describe('ClientSession integration', () => { const serverClient = ablyRealtimeClient(); const clientClient = ablyRealtimeClient(); - const serverChannel = serverClient.channels.get(channelName); - const clientChannel = clientClient.channels.get(channelName); - agentSession = createAgentSession({ - channel: serverChannel, + client: serverClient, + channelName, codec: UIMessageCodec, }); await agentSession.connect(); clientSession = createClientSession({ - channel: clientChannel, + client: clientClient, + channelName, codec: UIMessageCodec, clientId: clientClient.auth.clientId, fetch: noopFetch as typeof globalThis.fetch, @@ -226,17 +225,16 @@ describe('ClientSession integration', () => { const serverClient = ablyRealtimeClient(); const clientClient = ablyRealtimeClient(); - const serverChannel = serverClient.channels.get(channelName); - const clientChannel = clientClient.channels.get(channelName); - agentSession = createAgentSession({ - channel: serverChannel, + client: serverClient, + channelName, codec: UIMessageCodec, }); await agentSession.connect(); clientSession = createClientSession({ - channel: clientChannel, + client: clientClient, + channelName, codec: UIMessageCodec, clientId: clientClient.auth.clientId, fetch: noopFetch as typeof globalThis.fetch, @@ -283,17 +281,16 @@ describe('ClientSession integration', () => { const serverClient = ablyRealtimeClient(); const clientClient = ablyRealtimeClient(); - const serverChannel = serverClient.channels.get(channelName); - const clientChannel = clientClient.channels.get(channelName); - agentSession = createAgentSession({ - channel: serverChannel, + client: serverClient, + channelName, codec: UIMessageCodec, }); await agentSession.connect(); clientSession = createClientSession({ - channel: clientChannel, + client: clientClient, + channelName, codec: UIMessageCodec, clientId: clientClient.auth.clientId, fetch: noopFetch as typeof globalThis.fetch, @@ -350,17 +347,16 @@ describe('ClientSession integration', () => { const serverClient = ablyRealtimeClient(); const clientClient = ablyRealtimeClient(); - const serverChannel = serverClient.channels.get(channelName); - const clientChannel = clientClient.channels.get(channelName); - agentSession = createAgentSession({ - channel: serverChannel, + client: serverClient, + channelName, codec: UIMessageCodec, }); await agentSession.connect(); clientSession = createClientSession({ - channel: clientChannel, + client: clientClient, + channelName, codec: UIMessageCodec, clientId: clientClient.auth.clientId, fetch: noopFetch as typeof globalThis.fetch, @@ -417,17 +413,16 @@ describe('ClientSession integration', () => { const serverClient = ablyRealtimeClient(); const clientClient = ablyRealtimeClient(); - const serverChannel = serverClient.channels.get(channelName); - const clientChannel = clientClient.channels.get(channelName); - agentSession = createAgentSession({ - channel: serverChannel, + client: serverClient, + channelName, codec: UIMessageCodec, }); await agentSession.connect(); clientSession = createClientSession({ - channel: clientChannel, + client: clientClient, + channelName, codec: UIMessageCodec, clientId: clientClient.auth.clientId, fetch: noopFetch as typeof globalThis.fetch, @@ -490,11 +485,11 @@ describe('ClientSession integration', () => { const serverClient = ablyRealtimeClient(); const observerClient = ablyRealtimeClient(); - const serverChannel = serverClient.channels.get(channelName); const observerChannel = observerClient.channels.get(channelName); agentSession = createAgentSession({ - channel: serverChannel, + client: serverClient, + channelName, codec: UIMessageCodec, }); await agentSession.connect(); @@ -528,10 +523,10 @@ describe('ClientSession integration', () => { // New client connects and loads history const historyClient = ablyRealtimeClient(); - const historyChannel = historyClient.channels.get(channelName); clientSession = createClientSession({ - channel: historyChannel, + client: historyClient, + channelName, codec: UIMessageCodec, clientId: historyClient.auth.clientId, fetch: noopFetch as typeof globalThis.fetch, @@ -563,17 +558,16 @@ describe('ClientSession integration', () => { const serverClient = ablyRealtimeClient(); const clientClient = ablyRealtimeClient(); - const serverChannel = serverClient.channels.get(channelName); - const clientChannel = clientClient.channels.get(channelName); - agentSession = createAgentSession({ - channel: serverChannel, + client: serverClient, + channelName, codec: UIMessageCodec, }); await agentSession.connect(); clientSession = createClientSession({ - channel: clientChannel, + client: clientClient, + channelName, codec: UIMessageCodec, clientId: clientClient.auth.clientId, fetch: noopFetch as typeof globalThis.fetch, @@ -625,17 +619,16 @@ describe('ClientSession integration', () => { const serverClient = ablyRealtimeClient(); const clientClient = ablyRealtimeClient(); - const serverChannel = serverClient.channels.get(channelName); - const clientChannel = clientClient.channels.get(channelName); - agentSession = createAgentSession({ - channel: serverChannel, + client: serverClient, + channelName, codec: UIMessageCodec, }); await agentSession.connect(); clientSession = createClientSession({ - channel: clientChannel, + client: clientClient, + channelName, codec: UIMessageCodec, clientId: clientClient.auth.clientId, fetch: noopFetch as typeof globalThis.fetch, diff --git a/test/core/transport/client-session.test.ts b/test/core/transport/client-session.test.ts index 8e55fce0..313d85f0 100644 --- a/test/core/transport/client-session.test.ts +++ b/test/core/transport/client-session.test.ts @@ -18,6 +18,8 @@ import type { Codec, DecoderOutput, MessageAccumulator, StreamDecoder } from '.. import { createClientSession } from '../../../src/core/transport/client-session.js'; import type { ClientSession, RunLifecycleEvent } from '../../../src/core/transport/types.js'; import { ErrorCode } from '../../../src/errors.js'; +import { VERSION } from '../../../src/version.js'; +import { createMockClient } from '../../helper/mock-client.js'; // --------------------------------------------------------------------------- // Helpers @@ -275,7 +277,8 @@ const createSeededSession = async ( ): Promise> => { const ch = createMockChannel(); const session = createClientSession({ - channel: ch, + client: createMockClient(ch), + channelName: 'test-channel', codec, clientId: 'client-1', api: '/test', @@ -313,7 +316,8 @@ describe('ClientSession', () => { codec = createMockCodec(decoder); mockFetch = createMockFetch(); session = createClientSession({ - channel, + client: createMockClient(channel), + channelName: 'test-channel', codec, clientId: 'client-1', api: '/api/chat', @@ -334,7 +338,8 @@ describe('ClientSession', () => { it('connect() is idempotent — multiple calls return the same subscribe', async () => { const ch = createMockChannel(); const s = createClientSession({ - channel: ch, + client: createMockClient(ch), + channelName: 'test-channel', codec, api: '/api/chat', fetch: mockFetch.fn as unknown as typeof globalThis.fetch, @@ -352,7 +357,8 @@ describe('ClientSession', () => { it('send() throws InvalidArgument if connect() was not called', async () => { const ch = createMockChannel(); const s = createClientSession({ - channel: ch, + client: createMockClient(ch), + channelName: 'test-channel', codec, clientId: 'client-1', api: '/api/chat', @@ -365,7 +371,8 @@ describe('ClientSession', () => { it('cancel() throws InvalidArgument if connect() was not called', async () => { const ch = createMockChannel(); const s = createClientSession({ - channel: ch, + client: createMockClient(ch), + channelName: 'test-channel', codec, clientId: 'client-1', api: '/api/chat', @@ -378,7 +385,8 @@ describe('ClientSession', () => { it('waitForRun() throws InvalidArgument if connect() was not called', async () => { const ch = createMockChannel(); const s = createClientSession({ - channel: ch, + client: createMockClient(ch), + channelName: 'test-channel', codec, clientId: 'client-1', api: '/api/chat', @@ -394,6 +402,56 @@ describe('ClientSession', () => { // ------------------------------------------------------------------------- describe('construction', () => { + it('registers the ai-transport-js agent on the client and forwards params.agent to channels.get', () => { + const ch = createMockChannel(); + const client = createMockClient(ch); + const s = createClientSession({ + client, + channelName: 'attribution-channel', + codec, + api: '/test', + fetch: mockFetch.fn as unknown as typeof globalThis.fetch, + }); + const agents = (client as unknown as { options: { agents?: Record } }).options.agents; + expect(agents?.['ai-transport-js']).toBe(VERSION); + // eslint-disable-next-line @typescript-eslint/unbound-method -- accessing vi mock + expect(client.channels.get).toHaveBeenCalledWith('attribution-channel', { + params: { agent: `ai-transport-js/${VERSION}` }, + }); + void s.close(); + }); + + it('does not pollute options.agents when constructing multiple sessions on the same client', () => { + const ch1 = createMockChannel(); + const ch2 = createMockChannel(); + const client = createMockClient(ch1); + const optionsRef = (client as unknown as { options: { agents?: Record } }).options; + // Seed an unrelated entry so we can assert it survives. + optionsRef.agents = { 'some-other-sdk': '9.9.9' }; + const s1 = createClientSession({ + client, + channelName: 'ch-a', + codec, + api: '/test', + fetch: mockFetch.fn as unknown as typeof globalThis.fetch, + }); + // eslint-disable-next-line @typescript-eslint/unbound-method -- vi.mocked takes a method reference + vi.mocked(client.channels.get).mockReturnValue(ch2); + const s2 = createClientSession({ + client, + channelName: 'ch-b', + codec, + api: '/test', + fetch: mockFetch.fn as unknown as typeof globalThis.fetch, + }); + expect(optionsRef.agents).toEqual({ + 'some-other-sdk': '9.9.9', + 'ai-transport-js': VERSION, + }); + void s1.close(); + void s2.close(); + }); + it('subscribes to the channel with a callback', () => { expect(channel.subscribe).toHaveBeenCalledWith(expect.any(Function)); }); @@ -405,7 +463,8 @@ describe('ClientSession', () => { it('seeds initial messages into the tree', () => { const seeded = createClientSession({ - channel: createMockChannel(), + client: createMockClient(createMockChannel()), + channelName: 'test-channel', codec, api: '/test', messages: [ @@ -423,7 +482,8 @@ describe('ClientSession', () => { it('seeded messages form a parent chain in the tree', () => { const seeded = createClientSession({ - channel: createMockChannel(), + client: createMockClient(createMockChannel()), + channelName: 'test-channel', codec, api: '/test', messages: [ @@ -440,7 +500,8 @@ describe('ClientSession', () => { it('works with no initial messages', async () => { const empty = createClientSession({ - channel: createMockChannel(), + client: createMockClient(createMockChannel()), + channelName: 'test-channel', codec, api: '/test', fetch: mockFetch.fn as unknown as typeof globalThis.fetch, @@ -472,7 +533,8 @@ describe('ClientSession', () => { it('auto-computes parent from the last message in the tree', async () => { const seeded = createClientSession({ - channel: createMockChannel(), + client: createMockClient(createMockChannel()), + channelName: 'test-channel', codec, clientId: 'client-1', api: '/test', @@ -538,7 +600,8 @@ describe('ClientSession', () => { }), ); const blockSession = createClientSession({ - channel: createMockChannel(), + client: createMockClient(createMockChannel()), + channelName: 'test-channel', codec, api: '/test', fetch: blockingFetch as unknown as typeof globalThis.fetch, @@ -588,7 +651,8 @@ describe('ClientSession', () => { it('fires error event when POST fails with non-OK status', async () => { const failFetch = createMockFetch(500); const failSession = createClientSession({ - channel: createMockChannel(), + client: createMockClient(createMockChannel()), + channelName: 'test-channel', codec, api: '/test', fetch: failFetch.fn as unknown as typeof globalThis.fetch, @@ -612,7 +676,8 @@ describe('ClientSession', () => { // eslint-disable-next-line @typescript-eslint/promise-function-async -- mock returns Promise.reject directly const errorFetch = vi.fn(() => Promise.reject(new Error('network down'))); const errorSession = createClientSession({ - channel: createMockChannel(), + client: createMockClient(createMockChannel()), + channelName: 'test-channel', codec, api: '/test', fetch: errorFetch as unknown as typeof globalThis.fetch, @@ -635,7 +700,8 @@ describe('ClientSession', () => { it('errors the stream when POST fails', async () => { const failFetch = createMockFetch(500); const failSession = createClientSession({ - channel: createMockChannel(), + client: createMockClient(createMockChannel()), + channelName: 'test-channel', codec, api: '/test', fetch: failFetch.fn as unknown as typeof globalThis.fetch, @@ -660,7 +726,8 @@ describe('ClientSession', () => { // eslint-disable-next-line @typescript-eslint/promise-function-async -- mock returns Promise.reject directly const errorFetch = vi.fn(() => Promise.reject(new Error('network down'))); const errorSession = createClientSession({ - channel: createMockChannel(), + client: createMockClient(createMockChannel()), + channelName: 'test-channel', codec, api: '/test', fetch: errorFetch as unknown as typeof globalThis.fetch, @@ -715,7 +782,8 @@ describe('ClientSession', () => { it('merges dynamic options.headers and options.body', async () => { const dynSession = createClientSession({ - channel: createMockChannel(), + client: createMockClient(createMockChannel()), + channelName: 'test-channel', codec, api: '/test', headers: () => ({ 'X-Auth': 'bearer-token' }), @@ -738,7 +806,8 @@ describe('ClientSession', () => { it('includes credentials option in fetch when configured', async () => { const credSession = createClientSession({ - channel: createMockChannel(), + client: createMockClient(createMockChannel()), + channelName: 'test-channel', codec, api: '/test', credentials: 'include', @@ -778,7 +847,8 @@ describe('ClientSession', () => { it('does not auto-compute parent when forkOf is set', async () => { const seeded = createClientSession({ - channel: createMockChannel(), + client: createMockClient(createMockChannel()), + channelName: 'test-channel', codec, api: '/test', messages: [{ id: 'seed-1', content: 'first' }], @@ -2307,7 +2377,8 @@ describe('ClientSession', () => { it('returns seeded messages', () => { const seeded = createClientSession({ - channel: createMockChannel(), + client: createMockClient(createMockChannel()), + channelName: 'test-channel', codec, api: '/test', messages: [{ id: 'a', content: 'alpha' }], @@ -2401,7 +2472,8 @@ describe('ClientSession', () => { it('returns conversation nodes with headers and msgId', () => { const seeded = createClientSession({ - channel: createMockChannel(), + client: createMockClient(createMockChannel()), + channelName: 'test-channel', codec, api: '/test', messages: [{ id: 'msg-1', content: 'hi' }], @@ -2446,7 +2518,8 @@ describe('ClientSession', () => { it('has messages before close', async () => { const seeded = createClientSession({ - channel: createMockChannel(), + client: createMockClient(createMockChannel()), + channelName: 'test-channel', codec, api: '/test', messages: [{ id: 'msg-1', content: 'hi' }], @@ -2497,7 +2570,8 @@ describe('ClientSession', () => { preAttachedChannel.state = 'attached'; const preAttachedSession = createClientSession({ - channel: preAttachedChannel, + client: createMockClient(preAttachedChannel), + channelName: 'test-channel', codec, api: '/test', fetch: mockFetch.fn as unknown as typeof globalThis.fetch, @@ -2526,7 +2600,8 @@ describe('ClientSession', () => { uninitChannel.state = 'initialized'; const uninitSession = createClientSession({ - channel: uninitChannel, + client: createMockClient(uninitChannel), + channelName: 'test-channel', codec, api: '/test', fetch: mockFetch.fn as unknown as typeof globalThis.fetch, @@ -2553,7 +2628,8 @@ describe('ClientSession', () => { uninitChannel.state = 'initialized'; const uninitSession = createClientSession({ - channel: uninitChannel, + client: createMockClient(uninitChannel), + channelName: 'test-channel', codec, api: '/test', fetch: mockFetch.fn as unknown as typeof globalThis.fetch, @@ -3069,7 +3145,8 @@ describe('ClientSession', () => { vi.mocked(histChannel.history).mockResolvedValueOnce(histPage); const histSession = createClientSession({ - channel: histChannel as unknown as Ably.RealtimeChannel, + client: createMockClient(histChannel as unknown as Ably.RealtimeChannel), + channelName: 'test-channel', codec, api: '/test', fetch: mockFetch.fn as unknown as typeof globalThis.fetch, @@ -3102,7 +3179,8 @@ describe('ClientSession', () => { ); const pendingSession = createClientSession({ - channel: pendingChannel as unknown as Ably.RealtimeChannel, + client: createMockClient(pendingChannel as unknown as Ably.RealtimeChannel), + channelName: 'test-channel', codec, api: '/test', fetch: mockFetch.fn as unknown as typeof globalThis.fetch, @@ -3132,7 +3210,8 @@ describe('ClientSession', () => { ); const pendingTransport = createClientSession({ - channel: pendingChannel as unknown as Ably.RealtimeChannel, + client: createMockClient(pendingChannel as unknown as Ably.RealtimeChannel), + channelName: 'test-channel', codec, api: '/test', fetch: mockFetch.fn as unknown as typeof globalThis.fetch, @@ -3159,7 +3238,8 @@ describe('ClientSession', () => { ); const pendingTransport = createClientSession({ - channel: pendingChannel as unknown as Ably.RealtimeChannel, + client: createMockClient(pendingChannel as unknown as Ably.RealtimeChannel), + channelName: 'test-channel', codec, api: '/test', fetch: mockFetch.fn as unknown as typeof globalThis.fetch, @@ -3280,7 +3360,8 @@ describe('ClientSession', () => { const handler = vi.fn(); const ch = createMockChannel(); const seeded = createClientSession({ - channel: ch as unknown as Ably.RealtimeChannel, + client: createMockClient(ch as unknown as Ably.RealtimeChannel), + channelName: 'test-channel', codec, api: '/test', messages: [{ id: 'seed-1', content: 'hi' }], diff --git a/test/helper/mock-client.ts b/test/helper/mock-client.ts new file mode 100644 index 00000000..1cba6470 --- /dev/null +++ b/test/helper/mock-client.ts @@ -0,0 +1,21 @@ +import type * as Ably from 'ably'; +import { vi } from 'vitest'; + +/** + * Create a minimal `Ably.Realtime` mock that returns the supplied channel + * from `client.channels.get()` and exposes a writable + * `options.agents` map. Suitable for unit tests that previously injected a + * pre-resolved channel directly into a session factory — pair it with the + * file-local `createMockChannel()` helper. + * @param channel - The mock channel to return from `channels.get(...)`. + * @returns A mock `Ably.Realtime` instance. + */ +export const createMockClient = (channel: Ably.RealtimeChannel): Ably.Realtime => { + const client = { + channels: { get: vi.fn(() => channel) }, + options: {} as { agents?: Record }, + }; + // CAST: minimal stub — only `channels.get` and `options.agents` are exercised + // in unit tests; other Ably.Realtime members are unused. + return client as unknown as Ably.Realtime; +}; diff --git a/test/react/create-session-hooks.test.ts b/test/react/create-session-hooks.test.ts index 7e05b31f..64b4f974 100644 --- a/test/react/create-session-hooks.test.ts +++ b/test/react/create-session-hooks.test.ts @@ -12,10 +12,10 @@ import { createMockSession } from './helper/mock-session.js'; // Mocks // --------------------------------------------------------------------------- +// Stand-in Realtime client returned by the mocked `useAbly()`. Only its +// shape needs to satisfy TypeScript; createClientSession is also mocked. vi.mock('ably/react', () => ({ - // ChannelProvider is a pass-through wrapper in tests; explicit return type avoids promise-function-async - ChannelProvider: ({ children }: { children: ReactNode }): ReactNode => children, - useChannel: ({ channelName }: { channelName: string }) => ({ channel: { name: channelName } }), + useAbly: () => ({ options: {} }), })); // Typed with explicit parameter signature so mock.calls[0] is [unknown], enabling assertions diff --git a/test/react/providers/client-session-provider.test.ts b/test/react/providers/client-session-provider.test.ts index feca0bb8..89e0390b 100644 --- a/test/react/providers/client-session-provider.test.ts +++ b/test/react/providers/client-session-provider.test.ts @@ -24,10 +24,13 @@ import { createMockSession } from '../helper/mock-session.js'; // Mocks // --------------------------------------------------------------------------- +// Stand-in Realtime client returned by the mocked `useAbly()`. The provider +// passes it straight through to createClientSession (which is itself mocked), +// so the shape only needs to satisfy TypeScript. +const fakeAblyClient = { options: {} } as unknown as Ably.Realtime; + vi.mock('ably/react', () => ({ - // ChannelProvider is a pass-through wrapper in tests; explicit return type avoids promise-function-async - ChannelProvider: ({ children }: { children: ReactNode }): ReactNode => children, - useChannel: ({ channelName }: { channelName: string }) => ({ channel: { name: channelName } }), + useAbly: () => fakeAblyClient, })); // Typed with explicit parameter signature so mock.calls[0] is [unknown], enabling assertions @@ -94,12 +97,13 @@ describe('ClientSessionProvider', () => { expect(createClientSessionMock).toHaveBeenCalledTimes(1); }); - it('passes channelName to createClientSession via useChannel', () => { + it('passes channelName and the Ably client to createClientSession', () => { renderHook(() => useClientSession({ channelName: 'ai:demo' }), { wrapper: wrapDemo }); // CAST: wire-boundary assertion — vitest types mock args as unknown - const callArgs = createClientSessionMock.mock.calls[0]?.[0] as { channel: { name: string } }; - expect(callArgs.channel.name).toBe('ai:demo'); + const callArgs = createClientSessionMock.mock.calls[0]?.[0] as { channelName: string; client: Ably.Realtime }; + expect(callArgs.channelName).toBe('ai:demo'); + expect(callArgs.client).toBe(fakeAblyClient); }); it('registers the session under channelName', () => { diff --git a/test/vercel/transport/index.test.ts b/test/vercel/transport/index.test.ts index 484eb609..cf28eb4d 100644 --- a/test/vercel/transport/index.test.ts +++ b/test/vercel/transport/index.test.ts @@ -2,6 +2,7 @@ import type * as Ably from 'ably'; import { describe, expect, it, vi } from 'vitest'; import { createAgentSession, createClientSession } from '../../../src/vercel/transport/index.js'; +import { createMockClient } from '../../helper/mock-client.js'; import { createRunFromOpts } from '../../helper/run-from-opts.js'; // --------------------------------------------------------------------------- @@ -51,7 +52,7 @@ const createMockChannel = (): MockChannel & Ably.RealtimeChannel => { describe('Vercel createClientSession', () => { it('returns a functional ClientSession with UIMessageCodec pre-bound', async () => { const channel = createMockChannel(); - const session = createClientSession({ channel }); + const session = createClientSession({ client: createMockClient(channel), channelName: 'test-channel' }); // view.flattenNodes works without error — proves the codec is wired up expect(session.view.flattenNodes()).toEqual([]); @@ -64,7 +65,8 @@ describe('Vercel createClientSession', () => { // eslint-disable-next-line @typescript-eslint/promise-function-async -- mock returns Promise.resolve directly const mockFetch = vi.fn(() => Promise.resolve(new Response(undefined, { status: 200 }))); const session = createClientSession({ - channel, + client: createMockClient(channel), + channelName: 'test-channel', fetch: mockFetch as unknown as typeof globalThis.fetch, }); await session.connect(); @@ -86,7 +88,8 @@ describe('Vercel createClientSession', () => { // eslint-disable-next-line @typescript-eslint/promise-function-async -- mock returns Promise.resolve directly const mockFetch = vi.fn(() => Promise.resolve(new Response(undefined, { status: 200 }))); const session = createClientSession({ - channel, + client: createMockClient(channel), + channelName: 'test-channel', clientId: 'user-1', api: '/api/custom', headers: { Authorization: 'Bearer token' }, @@ -123,7 +126,7 @@ describe('Vercel createClientSession', () => { describe('Vercel createAgentSession', () => { it('returns a functional AgentSession with UIMessageCodec pre-bound', async () => { const channel = createMockChannel(); - const session = createAgentSession({ channel }); + const session = createAgentSession({ client: createMockClient(channel), channelName: 'test-channel' }); await session.connect(); const run = createRunFromOpts(session, { runId: 'test-run' }); @@ -135,7 +138,7 @@ describe('Vercel createAgentSession', () => { it('passes through options to the core factory', async () => { const channel = createMockChannel(); const onError = vi.fn(); - const session = createAgentSession({ channel, onError }); + const session = createAgentSession({ client: createMockClient(channel), channelName: 'test-channel', onError }); await session.connect(); // Session was created without error — proves options were forwarded diff --git a/test/vercel/transport/use-chat-error-propagation.integration.test.ts b/test/vercel/transport/use-chat-error-propagation.integration.test.ts index 2600b0ff..46ba2e4c 100644 --- a/test/vercel/transport/use-chat-error-propagation.integration.test.ts +++ b/test/vercel/transport/use-chat-error-propagation.integration.test.ts @@ -47,10 +47,10 @@ describe('useChat error propagation', () => { it('transitions to status: error and calls onError when POST fails', async () => { const channelName = uniqueChannelName('uc-post-fail'); const clientClient = ablyRealtimeClient(); - const clientChannel = clientClient.channels.get(channelName); clientSession = createClientSession({ - channel: clientChannel, + client: clientClient, + channelName, codec: UIMessageCodec, clientId: clientClient.auth.clientId, api: 'http://localhost:1/nonexistent', @@ -95,11 +95,11 @@ describe('useChat error propagation', () => { const serverClient = ablyRealtimeClient(); const clientClient = ablyRealtimeClient(); - const serverChannel = serverClient.channels.get(channelName); const clientChannel = clientClient.channels.get(channelName); agentSession = createAgentSession({ - channel: serverChannel, + client: serverClient, + channelName, codec: UIMessageCodec, }); await agentSession.connect(); @@ -113,7 +113,8 @@ describe('useChat error propagation', () => { }) as typeof globalThis.fetch; clientSession = createClientSession({ - channel: clientChannel, + client: clientClient, + channelName, codec: UIMessageCodec, clientId: clientClient.auth.clientId, api: '/api/chat',