diff --git a/demo/anthropic/react/.env.local.example b/demo/anthropic/react/.env.local.example new file mode 100644 index 00000000..ac248937 --- /dev/null +++ b/demo/anthropic/react/.env.local.example @@ -0,0 +1,9 @@ +# Ably API key — get one at https://ably.com/accounts +ABLY_API_KEY=your-app.key:secret + +# Anthropic API key — get one at https://console.anthropic.com +ANTHROPIC_API_KEY=sk-ant-... + +# Ably channel name (optional, default: ai:demo) +# Can also be overridden per-session via ?channel= query param +# NEXT_PUBLIC_ABLY_CHANNEL=ai:demo diff --git a/demo/anthropic/react/.gitignore b/demo/anthropic/react/.gitignore new file mode 100644 index 00000000..8f980b77 --- /dev/null +++ b/demo/anthropic/react/.gitignore @@ -0,0 +1,6 @@ +.next/ +node_modules/ +package-lock.json +.env.local +next-env.d.ts +tsconfig.tsbuildinfo diff --git a/demo/anthropic/react/next.config.ts b/demo/anthropic/react/next.config.ts new file mode 100644 index 00000000..cad86d32 --- /dev/null +++ b/demo/anthropic/react/next.config.ts @@ -0,0 +1,35 @@ +import path from "path"; +import type { NextConfig } from "next"; + +const repoRoot = path.resolve(__dirname, "..", "..", ".."); + +const nextConfig: NextConfig = { + // Strict Mode double-mounts cause TransportProvider to close the transport on + // the first cleanup cycle while the ref persists, leaving a closed transport on + // remount. Disable until TransportProvider handles this correctly. + reactStrictMode: false, + serverExternalPackages: ["jsonwebtoken", "ably", "@anthropic-ai/claude-agent-sdk"], + webpack: (config) => { + // Resolve @ably/ai-transport imports to source files instead of the pre-built + // dist/ bundles. The dist bundles contain a Rolldown CJS runtime shim that + // calls `require("react")` (breaks in the browser) and inline `ably` imports + // that bypass `serverExternalPackages` (breaks native ws on the server). + // With source aliases, webpack compiles the TS directly and can properly + // externalize `ably` on the server via `serverExternalPackages`. + config.resolve.alias = { + ...config.resolve.alias, + "@ably/ai-transport/react": path.join(repoRoot, "src/react/index.ts"), + "@ably/ai-transport/anthropic": path.join(repoRoot, "src/anthropic/index.ts"), + "@ably/ai-transport": path.join(repoRoot, "src/index.ts"), + }; + + // Source files use .js extensions in imports (standard TS ESM convention). + // Webpack needs to resolve .js imports to .ts files. + config.resolve.extensionAlias = { + ".js": [".ts", ".tsx", ".js"], + }; + return config; + }, +}; + +export default nextConfig; diff --git a/demo/anthropic/react/package.json b/demo/anthropic/react/package.json new file mode 100644 index 00000000..2afe21ee --- /dev/null +++ b/demo/anthropic/react/package.json @@ -0,0 +1,36 @@ +{ + "name": "anthropic-agent-sdk-demo", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "next dev", + "build": "tsc --noEmit && next build", + "start": "next start", + "typecheck": "tsc --noEmit", + "lint": "next lint" + }, + "dependencies": { + "@ably/ai-transport": "file:../../../", + "@anthropic-ai/claude-agent-sdk": "^0.2.85", + "@anthropic-ai/sdk": "^0.80.0", + "ably": "^2", + "jsonwebtoken": "^9", + "next": "^15", + "react": "^19", + "react-dom": "^19" + }, + "overrides": { + "@anthropic-ai/sdk": "$@anthropic-ai/sdk" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/jsonwebtoken": "^9", + "@types/node": "^22", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "^15", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/demo/anthropic/react/postcss.config.mjs b/demo/anthropic/react/postcss.config.mjs new file mode 100644 index 00000000..79bcf135 --- /dev/null +++ b/demo/anthropic/react/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/demo/anthropic/react/src/app/api/auth/ably-token/route.ts b/demo/anthropic/react/src/app/api/auth/ably-token/route.ts new file mode 100644 index 00000000..f4384056 --- /dev/null +++ b/demo/anthropic/react/src/app/api/auth/ably-token/route.ts @@ -0,0 +1,38 @@ +/** + * Ably JWT token endpoint. + * + * Issues short-lived JWTs signed with the Ably API key secret. + * The client connects to Ably with `authUrl` pointing here. + */ + +import jwt from 'jsonwebtoken'; +import { NextResponse } from 'next/server'; + +export async function GET(req: Request) { + const apiKey = process.env.ABLY_API_KEY; + if (!apiKey) { + return NextResponse.json({ error: 'ABLY_API_KEY not set' }, { status: 500 }); + } + + const [keyName, keySecret] = apiKey.split(':'); + + const url = new URL(req.url); + const clientId = url.searchParams.get('clientId') ?? `user-${crypto.randomUUID().slice(0, 8)}`; + + const token = jwt.sign( + { + 'x-ably-clientId': clientId, + 'x-ably-capability': JSON.stringify({ '*': ['publish', 'subscribe', 'history'] }), + }, + keySecret, + { + algorithm: 'HS256', + keyid: keyName, + expiresIn: '1h', + }, + ); + + return new NextResponse(token, { + headers: { 'Content-Type': 'application/jwt' }, + }); +} diff --git a/demo/anthropic/react/src/app/api/chat/route.ts b/demo/anthropic/react/src/app/api/chat/route.ts new file mode 100644 index 00000000..9d5baad7 --- /dev/null +++ b/demo/anthropic/react/src/app/api/chat/route.ts @@ -0,0 +1,127 @@ +/** + * Chat API route — receives messages from the client transport's HTTP POST, + * streams the AI response back over Ably using the Anthropic Agent SDK. + * + * The Agent SDK's query() function produces an AsyncGenerator. + * We filter to conversation-relevant types (AgentCodecEvent) and pipe them + * through the Anthropic transport's encoder to the Ably channel. + */ + +import { after } from 'next/server'; +import { query } from '@anthropic-ai/claude-agent-sdk'; +import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; +import Ably from 'ably'; +import { createServerTransport } from '@ably/ai-transport/anthropic'; +import type { AgentCodecEvent, AgentMessage } from '@ably/ai-transport/anthropic'; +import type { MessageNode } from '@ably/ai-transport'; + +/** Shape of the POST body sent by the client transport. */ +interface ChatRequestBody { + turnId: string; + clientId: string; + messages: MessageNode[]; + history?: MessageNode[]; + id: string; + forkOf?: string; + parent?: string | null; +} + +/** Check if an SDKMessage is a conversation-relevant AgentCodecEvent. */ +function isAgentCodecEvent(msg: SDKMessage): msg is AgentCodecEvent { + return ['stream_event', 'assistant', 'user', 'result', 'tool_progress'].includes(msg.type); +} + +/** Convert the Agent SDK's async generator into a ReadableStream. */ +function sdkMessageStream(queryResult: AsyncIterable): ReadableStream { + return new ReadableStream({ + async start(controller) { + try { + for await (const message of queryResult) { + if (isAgentCodecEvent(message)) { + controller.enqueue(message); + } + } + controller.close(); + } catch (err) { + controller.error(err); + } + }, + }); +} + +/** Extract the user's prompt text from the conversation messages. */ +function extractPrompt(messages: MessageNode[], history: MessageNode[]): string { + // Get the latest user message text + const allMsgs = [...history, ...messages]; + const lastUser = allMsgs.filter((m) => m.message.type === 'user').at(-1); + if (!lastUser) return ''; + + const content = lastUser.message.message.content; + if (typeof content === 'string') return content; + + // Content is an array of content blocks — extract text + if (Array.isArray(content)) { + return content + .filter( + (block): block is { type: 'text'; text: string } => + typeof block === 'object' && block !== null && 'type' in block && block.type === 'text', + ) + .map((block) => block.text) + .join('\n'); + } + + return ''; +} + +// Server-side Ably client — uses API key directly (trusted environment). +const ably = new Ably.Realtime({ key: process.env.ABLY_API_KEY! }); + +export async function POST(req: Request) { + const { messages, history = [], id, turnId, clientId, forkOf, parent } = (await req.json()) as ChatRequestBody; + const channel = ably.channels.get(id); + + const transport = createServerTransport({ channel }); + const turn = transport.newTurn({ turnId, clientId, parent: parent ?? undefined, forkOf }); + + await turn.start(); + + // Publish user messages to the channel so all clients see them + let lastUserMsgId: string | undefined; + if (messages.length > 0) { + const { msgIds } = await turn.addMessages(messages, { clientId }); + lastUserMsgId = msgIds.at(-1); + } + + // Extract the user's prompt for the Agent SDK + const prompt = extractPrompt(messages, history); + + // Bridge the transport's abort signal to an AbortController for the Agent SDK. + // When the client cancels a turn, the transport fires turn.abortSignal, which + // propagates to the Agent SDK to stop the LLM call. + const abortController = new AbortController(); + turn.abortSignal.addEventListener('abort', () => abortController.abort(), { once: true }); + + // Call the Agent SDK — this spawns a Claude Code process that calls the + // Anthropic API with the ANTHROPIC_API_KEY environment variable. + const conversation = query({ + prompt, + options: { + includePartialMessages: true, + maxTurns: 1, + systemPrompt: 'You are a helpful assistant.', + abortController, + }, + }); + + // Stream the response over Ably in the background using after(). + after(async () => { + const stream = sdkMessageStream(conversation); + const { reason } = await turn.streamResponse(stream, { + parent: lastUserMsgId, + }); + await turn.end(reason); + transport.close(); + }); + + return new Response(null, { status: 200 }); +} diff --git a/demo/anthropic/react/src/app/components/chat.tsx b/demo/anthropic/react/src/app/components/chat.tsx new file mode 100644 index 00000000..5252a709 --- /dev/null +++ b/demo/anthropic/react/src/app/components/chat.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { useClientTransport, useView, useActiveTurns, useAblyMessages } from '@ably/ai-transport/react'; +import type { AgentCodecEvent, AgentMessage } from '@ably/ai-transport/anthropic'; + +import { userMessage } from '../helpers'; +import { MessageList } from './message-list'; +import { DebugPane } from './debug-pane'; + +interface ChatProps { + chatId: string; + clientId?: string; + historyLimit?: number; +} + +export function Chat({ chatId, clientId, historyLimit }: ChatProps) { + const [input, setInput] = useState(''); + + const transport = useClientTransport({ channelName: chatId }); + const view = useView({ transport, limit: historyLimit ?? 30 }); + const activeTurns = useActiveTurns({ transport }); + const ablyMessages = useAblyMessages({ transport }); + + const hasOwnTurns = clientId ? activeTurns.has(clientId) : false; + + const handleSubmit = useCallback(() => { + const text = input.trim(); + if (!text) return; + setInput(''); + view.send([userMessage(text)]); + }, [input, view]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }; + + return ( +
+
+ {/* Header */} +
+
+

Ably AI — Anthropic Agent SDK Demo

+ {clientId && {clientId}} +
+ + {/* Messages */} + + + {/* Input */} +
+
+ setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Type a message..." + className="flex-1 rounded-md bg-zinc-900 border border-zinc-700 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-600 outline-none focus:border-zinc-500" + autoFocus + /> + {hasOwnTurns && ( + + )} + +
+
+
+ + n.message)} + ablyMessages={ablyMessages} + activeTurns={activeTurns} + /> +
+ ); +} diff --git a/demo/anthropic/react/src/app/components/debug-pane.tsx b/demo/anthropic/react/src/app/components/debug-pane.tsx new file mode 100644 index 00000000..5ef7632d --- /dev/null +++ b/demo/anthropic/react/src/app/components/debug-pane.tsx @@ -0,0 +1,181 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import type { AgentMessage } from '@ably/ai-transport/anthropic'; +import type * as Ably from 'ably'; + +interface DebugPaneProps { + messages: AgentMessage[]; + ablyMessages: Ably.InboundMessage[]; + activeTurns: Map>; +} + +type Tab = 'ably' | 'messages'; + +function extractHeaders(msg: Ably.InboundMessage): Record { + const extras = msg.extras as { headers?: Record } | undefined; + return extras?.headers ?? {}; +} + +function AblyMessagesTab({ entries }: { entries: Ably.InboundMessage[] }) { + const scrollRef = useRef(null); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [entries]); + + return ( +
+ {entries.length === 0 && ( +

Raw Ably messages will appear here.

+ )} + {entries.map((entry, idx) => { + const headers = extractHeaders(entry); + return ( +
+
+ #{idx} + {new Date(entry.timestamp ?? Date.now()).toLocaleTimeString()} + {entry.name ?? '(unnamed)'} + {String(entry.action ?? 'message.create')} +
+ {Object.keys(headers).length > 0 && ( +
+ {Object.entries(headers).map(([k, v]) => ( +
+ {k} + : + {v} +
+ ))} +
+ )} + {entry.data !== undefined && entry.data !== null && ( +
+ {typeof entry.data === 'string' ? entry.data : JSON.stringify(entry.data, null, 2)} +
+ )} +
+ ); + })} +
+ ); +} + +function DomainMessagesTab({ + messages, + activeTurns, +}: { + messages: AgentMessage[]; + activeTurns: Map>; +}) { + const scrollRef = useRef(null); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [messages]); + + const turnsDisplay = + activeTurns.size > 0 + ? Array.from(activeTurns.entries()) + .map( + ([cid, tids]) => + `${cid}: [${Array.from(tids) + .map((t) => t.slice(0, 8)) + .join(', ')}]`, + ) + .join('; ') + : 'none'; + + return ( +
+
+ Active turns: + 0 ? 'text-blue-400' : 'text-zinc-600'}`}>{turnsDisplay} +
+ {messages.length === 0 ? ( +

Messages will appear here as JSON.

+ ) : ( +
+          {JSON.stringify(messages, null, 2)}
+        
+ )} +
+ ); +} + +export function DebugPane({ messages, ablyMessages, activeTurns }: DebugPaneProps) { + const [isOpen, setIsOpen] = useState(false); + const [tab, setTab] = useState('ably'); + + return ( + <> + {!isOpen && ( + + )} + + {isOpen && ( +
+
+
+ + +
+ +
+ {tab === 'ably' ? ( + + ) : ( + + )} +
+ )} + + ); +} diff --git a/demo/anthropic/react/src/app/components/message-bubble.tsx b/demo/anthropic/react/src/app/components/message-bubble.tsx new file mode 100644 index 00000000..ea285fa0 --- /dev/null +++ b/demo/anthropic/react/src/app/components/message-bubble.tsx @@ -0,0 +1,151 @@ +'use client'; + +import type { AgentMessage } from '@ably/ai-transport/anthropic'; +import type { SDKAssistantMessage, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; + +interface MessageBubbleProps { + message: AgentMessage; + headers: Record | undefined; +} + +function Badge({ label, value, color }: { label: string; value: string; color: string }) { + return ( + + {label} + {value} + + ); +} + +function StatusBadge({ status }: { status: string }) { + const color = + status === 'finished' + ? 'bg-emerald-950 text-emerald-400' + : status === 'streaming' + ? 'bg-amber-950 text-amber-400' + : status === 'aborted' + ? 'bg-red-950 text-red-400' + : 'bg-zinc-900 text-zinc-500'; + return ( + + ); +} + +function bubbleClasses(isUser: boolean, status: string | undefined): string { + const base = 'rounded-lg px-3 py-2 text-sm leading-relaxed whitespace-pre-wrap'; + if (isUser) return `${base} bg-zinc-800 text-zinc-200`; + if (status === 'streaming') return `${base} bg-zinc-900 text-zinc-300 border border-amber-900/40`; + if (status === 'finished') return `${base} bg-zinc-900 text-zinc-300 border border-emerald-900/40`; + if (status === 'aborted') return `${base} bg-zinc-900 text-zinc-300 border border-red-900/40`; + return `${base} bg-zinc-900 text-zinc-300 border border-zinc-800`; +} + +/** Extract displayable text from an assistant message's content blocks. */ +function renderAssistantContent(msg: SDKAssistantMessage) { + const content = msg.message.content; + if (!Array.isArray(content) || content.length === 0) { + return {JSON.stringify(msg.message, null, 2)}; + } + + return content.map((block, i: number) => { + // Cast through unknown to access discriminant fields across the content block union. + const b = block as unknown as Record; + switch (b.type) { + case 'text': + return {String(b.text ?? '')}; + case 'thinking': + return ( +
+ {String(b.thinking ?? '')} +
+ ); + case 'tool_use': + return ( +
+ {String(b.name ?? 'tool')} + ( + {JSON.stringify(b.input ?? {})} + ) +
+ ); + default: + return null; + } + }); +} + +/** Extract displayable text from a user message. */ +function renderUserContent(msg: SDKUserMessage) { + const content = msg.message.content; + if (typeof content === 'string') return {content}; + + if (Array.isArray(content)) { + return content.map((block, i: number) => { + // Cast through unknown to access discriminant fields across the content block param union. + const b = block as unknown as Record; + if (b.type === 'text') return {String(b.text ?? '')}; + return null; + }); + } + + return null; +} + +export function MessageBubble({ message, headers }: MessageBubbleProps) { + const isUser = message.type === 'user'; + const role = headers?.['x-ably-role'] ?? (isUser ? 'user' : 'assistant'); + const clientId = headers?.['x-ably-turn-client-id']; + const turnId = headers?.['x-ably-turn-id']; + const status = headers?.['x-ably-status']; + + return ( +
+
+
+ {isUser + ? renderUserContent(message as SDKUserMessage) + : renderAssistantContent(message as SDKAssistantMessage)} + {!isUser && status === 'streaming' && ( + + )} +
+ + {/* Debug badges */} + {headers && ( +
+ + {clientId && ( + + )} + {turnId && ( + + )} + {status && } +
+ )} +
+
+ ); +} diff --git a/demo/anthropic/react/src/app/components/message-list.tsx b/demo/anthropic/react/src/app/components/message-list.tsx new file mode 100644 index 00000000..0b097cd6 --- /dev/null +++ b/demo/anthropic/react/src/app/components/message-list.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { useRef, useEffect } from 'react'; +import type { ViewHandle } from '@ably/ai-transport/react'; +import type { AgentCodecEvent, AgentMessage } from '@ably/ai-transport/anthropic'; +import { MessageBubble } from './message-bubble'; + +interface MessageListProps { + view: ViewHandle; +} + +export function MessageList({ view }: MessageListProps) { + const endRef = useRef(null); + const scrollRef = useRef(null); + const prevCountRef = useRef(0); + + const { nodes, hasOlder, loading, loadOlder } = view; + + // Auto-scroll when new messages arrive + useEffect(() => { + if (nodes.length > prevCountRef.current) { + prevCountRef.current = nodes.length; + endRef.current?.scrollIntoView({ behavior: 'smooth' }); + } + }, [nodes]); + + const handleScroll = () => { + const el = scrollRef.current; + if (!el || !hasOlder || loading) return; + if (el.scrollTop < 60) { + void loadOlder(); + } + }; + + return ( +
+ {hasOlder && ( +
+ +
+ )} + {loading &&
Loading history...
} + {nodes.length === 0 && !loading && ( +

Send a message to start chatting.

+ )} + {nodes.map((node) => ( + + ))} +
+
+ ); +} diff --git a/demo/anthropic/react/src/app/globals.css b/demo/anthropic/react/src/app/globals.css new file mode 100644 index 00000000..d4b50785 --- /dev/null +++ b/demo/anthropic/react/src/app/globals.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/demo/anthropic/react/src/app/helpers.ts b/demo/anthropic/react/src/app/helpers.ts new file mode 100644 index 00000000..8b10d39d --- /dev/null +++ b/demo/anthropic/react/src/app/helpers.ts @@ -0,0 +1,11 @@ +import type { AgentMessage } from '@ably/ai-transport/anthropic'; + +/** Construct a user AgentMessage from a text string. */ +export function userMessage(text: string): AgentMessage { + return { + type: 'user', + message: { role: 'user', content: text }, + parent_tool_use_id: null, + session_id: '', + }; +} diff --git a/demo/anthropic/react/src/app/layout.tsx b/demo/anthropic/react/src/app/layout.tsx new file mode 100644 index 00000000..d6a51835 --- /dev/null +++ b/demo/anthropic/react/src/app/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next'; +import './globals.css'; + +export const metadata: Metadata = { + title: 'Ably AI — Anthropic Agent SDK Demo', + description: 'Anthropic Agent SDK demo with Ably transport and debug pane', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/demo/anthropic/react/src/app/page.tsx b/demo/anthropic/react/src/app/page.tsx new file mode 100644 index 00000000..9de3ed30 --- /dev/null +++ b/demo/anthropic/react/src/app/page.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { Providers, useAblyReady } from './providers'; +import { TransportProvider } from '@ably/ai-transport/react'; +import { AgentCodec } from '@ably/ai-transport/anthropic'; +import type { AgentCodecEvent, AgentMessage } from '@ably/ai-transport/anthropic'; +import { Chat } from './components/chat'; +import { useSearchParams } from 'next/navigation'; +import { Suspense } from 'react'; + +const DEFAULT_CHANNEL = process.env.NEXT_PUBLIC_ABLY_CHANNEL ?? 'ai:demo'; + +function ChatWhenReady({ channelName, clientId, limit }: { channelName: string; clientId?: string; limit?: number }) { + const ready = useAblyReady(); + + if (!ready) { + return
Connecting...
; + } + + return ( + + channelName={channelName} + codec={AgentCodec} + clientId={clientId} + body={() => ({ id: channelName })} + > + + + ); +} + +function ChatPage() { + const searchParams = useSearchParams(); + const channelName = searchParams.get('channel') ?? DEFAULT_CHANNEL; + const clientId = searchParams.get('clientId') ?? undefined; + const limit = Number(searchParams.get('limit')) || undefined; + + return ( + + + + ); +} + +export default function Home() { + return ( + + + + ); +} diff --git a/demo/anthropic/react/src/app/providers.tsx b/demo/anthropic/react/src/app/providers.tsx new file mode 100644 index 00000000..7f562c6d --- /dev/null +++ b/demo/anthropic/react/src/app/providers.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; +import * as Ably from 'ably'; +import { AblyProvider } from 'ably/react'; + +const AblyReadyContext = createContext(false); + +export function useAblyReady() { + return useContext(AblyReadyContext); +} + +export function Providers({ clientId, children }: { clientId?: string; children: ReactNode }) { + const [client, setClient] = useState(null); + + useEffect(() => { + const authParams = clientId ? `?clientId=${encodeURIComponent(clientId)}` : ''; + const ably = new Ably.Realtime({ + authCallback: async (_tokenParams, callback) => { + try { + const response = await fetch(`/api/auth/ably-token${authParams}`); + const jwt = await response.text(); + callback(null, jwt); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + callback(message, null); + } + }, + }); + setClient(ably); + return () => { + ably.close(); + }; + }, [clientId]); + + if (!client) { + return {children}; + } + + return ( + + {children} + + ); +} diff --git a/demo/anthropic/react/tsconfig.json b/demo/anthropic/react/tsconfig.json new file mode 100644 index 00000000..8629a9de --- /dev/null +++ b/demo/anthropic/react/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./src/*"] } + }, + "include": ["**/*.ts", "**/*.tsx", "next-env.d.ts", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/package-lock.json b/package-lock.json index 32e3ea73..6ae7496e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,12 @@ "license": "Apache-2.0", "devDependencies": { "@ai-sdk/react": "^3.0.151", + "@anthropic-ai/claude-agent-sdk": "^0.2.85", + "@anthropic-ai/sdk": "^0.80.0", "@eslint/compat": "^1.2.7", "@eslint/eslintrc": "^3.3.0", "@eslint/js": "^9.12.0", + "@modelcontextprotocol/sdk": "^1.29.0", "@testing-library/react": "^16.3.2", "@types/react": "^19.2.14", "@typescript-eslint/eslint-plugin": "^8.26.1", @@ -43,11 +46,19 @@ "node": ">=20.0.0" }, "peerDependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.85", + "@anthropic-ai/sdk": "^0.80.0", "ably": "^2.21.0", "ai": "^6", "react": "^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { + "@anthropic-ai/claude-agent-sdk": { + "optional": true + }, + "@anthropic-ai/sdk": { + "optional": true + }, "ai": { "optional": true }, @@ -67,9 +78,9 @@ } }, "node_modules/@ai-sdk/gateway": { - "version": "3.0.91", - "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.91.tgz", - "integrity": "sha512-J39Dh6Gyg6HjG3A7OFKnJMp3QyZ3Eex+XDiX8aFBdRwwZm3jGWaMhkCxQPH7yiQ9kRiErZwHXX/Oexx4SyGGGA==", + "version": "3.0.98", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.98.tgz", + "integrity": "sha512-Ol+nP8PIlj8FjN8qKlxhE89N0woqAaGi9CUBGp1boe3RafpphJ7WMuq/RErSvxtwTqje03TP+zIdzP113krxRg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -116,14 +127,14 @@ } }, "node_modules/@ai-sdk/react": { - "version": "3.0.151", - "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-3.0.151.tgz", - "integrity": "sha512-Y6jGr3amKVG4tisMaIymbsbI/uNrbSO1OH9M7WBHeuTLbig7Ejn/DoVW/agOC20294nFdOTu73Z2XU14XLPwxg==", + "version": "3.0.163", + "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-3.0.163.tgz", + "integrity": "sha512-UM8BwNx4YFcG1XIBSTepIGx48RXk974qVSplVZc2JPiY86tC4Qpb8trquh5MdtSKzlS6yrUX46n8gS2WZaUIXQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider-utils": "4.0.23", - "ai": "6.0.149", + "ai": "6.0.161", "swr": "^2.2.5", "throttleit": "2.1.0" }, @@ -134,6 +145,51 @@ "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.2.85", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.85.tgz", + "integrity": "sha512-/ohKLtP1zy6aWXLW/9KTYBveJPEtAfdO96qiP1Cl5S7LgVq/qRDUl7AUw5YGrBaK6YWHEE/rfMQZGwP/i5zIvQ==", + "dev": true, + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "^0.34.2", + "@img/sharp-darwin-x64": "^0.34.2", + "@img/sharp-linux-arm": "^0.34.2", + "@img/sharp-linux-arm64": "^0.34.2", + "@img/sharp-linux-x64": "^0.34.2", + "@img/sharp-linuxmusl-arm64": "^0.34.2", + "@img/sharp-linuxmusl-x64": "^0.34.2", + "@img/sharp-win32-arm64": "^0.34.2", + "@img/sharp-win32-x64": "^0.34.2" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.80.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.80.0.tgz", + "integrity": "sha512-WeXLn7zNVk3yjeshn+xZHvld6AoFUOR3Sep6pSoHho5YbSi6HwcirqgPA5ccFuW8QTVJAAU7N8uQQC6Wa9TG+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@asamuzakjp/css-color": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", @@ -1104,6 +1160,19 @@ "@shikijs/vscode-textmate": "^10.0.2" } }, + "node_modules/@hono/node-server": { + "version": "1.19.12", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.12.tgz", + "integrity": "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1156,6 +1225,326 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1348,6 +1737,71 @@ "dev": true, "license": "MIT" }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", @@ -2428,14 +2882,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.2.tgz", - "integrity": "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.4.tgz", + "integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.2", + "@vitest/utils": "4.1.4", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -2449,8 +2903,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.2", - "vitest": "4.1.2" + "@vitest/browser": "4.1.4", + "vitest": "4.1.4" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2459,16 +2913,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", - "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2477,13 +2931,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", - "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.2", + "@vitest/spy": "4.1.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2504,9 +2958,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", - "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", "dev": true, "license": "MIT", "dependencies": { @@ -2517,13 +2971,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", - "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.2", + "@vitest/utils": "4.1.4", "pathe": "^2.0.3" }, "funding": { @@ -2531,14 +2985,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", - "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2547,9 +3001,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", - "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", "dev": true, "license": "MIT", "funding": { @@ -2557,13 +3011,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", - "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.2", + "@vitest/pretty-format": "4.1.4", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -2744,6 +3198,20 @@ } } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2768,13 +3236,13 @@ } }, "node_modules/ai": { - "version": "6.0.149", - "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.149.tgz", - "integrity": "sha512-3asRb/m3ZGH7H4+VTuTgj8eQYJZ9IJUmV0ljLslY92mQp6Zj+NVn4SmFj0TBr2Y/wFBWC3xgn++47tSGOXxdbw==", + "version": "6.0.161", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.161.tgz", + "integrity": "sha512-ufhmijmx2YyWTPAicGgtpLOB/xD7mG8zKs1pT1Trj+JL/3r1rS8fkMi/cHZoChSAQSGB4pgmcWVxDrVTUvK2IQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@ai-sdk/gateway": "3.0.91", + "@ai-sdk/gateway": "3.0.98", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" @@ -3124,6 +3592,31 @@ "require-from-string": "^2.0.2" } }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/bops": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/bops/-/bops-1.0.1.tgz", @@ -3193,6 +3686,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cacheable-lookup": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", @@ -3440,6 +3943,30 @@ "dev": true, "license": "MIT" }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3447,6 +3974,26 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/core-js-compat": { "version": "3.49.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", @@ -3461,6 +4008,24 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3679,6 +4244,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -3744,6 +4319,13 @@ "node": ">= 0.4" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.321", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", @@ -3751,6 +4333,16 @@ "dev": true, "license": "ISC" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -3982,6 +4574,13 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -4422,6 +5021,29 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/eventsource-parser": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", @@ -4442,6 +5064,69 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -4525,6 +5210,28 @@ "node": ">=16.0.0" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -4592,6 +5299,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-extra": { "version": "11.3.4", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", @@ -4948,8 +5675,18 @@ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", "dev": true, "license": "MIT", - "bin": { - "he": "bin/he" + "bin": { + "he": "bin/he" + } + }, + "node_modules/hono": { + "version": "4.12.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", + "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" } }, "node_modules/html-encoding-sniffer": { @@ -4996,6 +5733,27 @@ "license": "BSD-2-Clause", "peer": true }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/http2-wrapper": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", @@ -5010,6 +5768,23 @@ "node": ">=10.19.0" } }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5070,6 +5845,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -5085,6 +5867,26 @@ "node": ">= 0.4" } }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -5345,6 +6147,13 @@ "dev": true, "license": "MIT" }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -5560,6 +6369,16 @@ "dev": true, "license": "MIT" }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5658,6 +6477,20 @@ "dev": true, "license": "(AFL-2.1 OR BSD-3-Clause)" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -5665,6 +6498,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -6178,6 +7018,56 @@ "dev": true, "license": "MIT" }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mimic-response": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", @@ -6283,6 +7173,16 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/node-releases": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", @@ -6303,6 +7203,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-deep-merge": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/object-deep-merge/-/object-deep-merge-1.0.5.tgz", @@ -6421,12 +7331,24 @@ ], "license": "MIT" }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "license": "ISC", - "peer": true, "dependencies": { "wrappy": "1" } @@ -6552,6 +7474,16 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -6586,6 +7518,17 @@ "dev": true, "license": "MIT" }, + "node_modules/path-to-regexp": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.1.tgz", + "integrity": "sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -6613,6 +7556,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-types": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", @@ -6730,6 +7683,20 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -6761,6 +7728,22 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -6791,6 +7774,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -7009,6 +8018,23 @@ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -7074,6 +8100,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -7108,6 +8141,53 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -7157,6 +8237,13 @@ "node": ">= 0.4" } }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7322,6 +8409,16 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", @@ -7579,6 +8676,16 @@ "license": "MIT", "peer": true }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tough-cookie": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", @@ -7605,6 +8712,13 @@ "node": ">=20" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "dev": true, + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -7685,6 +8799,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -7764,17 +8893,17 @@ } }, "node_modules/typedoc": { - "version": "0.28.18", - "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.18.tgz", - "integrity": "sha512-NTWTUOFRQ9+SGKKTuWKUioUkjxNwtS3JDRPVKZAXGHZy2wCA8bdv2iJiyeePn0xkmK+TCCqZFT0X7+2+FLjngA==", + "version": "0.28.19", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.19.tgz", + "integrity": "sha512-wKh+lhdmMFivMlc6vRRcMGXeGEHGU2g8a2CkPTJjJlwRf1iXbimWIPcFolCqe4E0d/FRtGszpIrsp3WLpDB8Pw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@gerrit0/mini-shiki": "^3.23.0", "lunr": "^2.3.9", "markdown-it": "^14.1.1", - "minimatch": "^10.2.4", - "yaml": "^2.8.2" + "minimatch": "^10.2.5", + "yaml": "^2.8.3" }, "bin": { "typedoc": "bin/typedoc" @@ -7944,6 +9073,16 @@ "node": ">= 10.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -7995,6 +9134,16 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", @@ -8101,19 +9250,19 @@ } }, "node_modules/vitest": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", - "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.2", - "@vitest/mocker": "4.1.2", - "@vitest/pretty-format": "4.1.2", - "@vitest/runner": "4.1.2", - "@vitest/snapshot": "4.1.2", - "@vitest/spy": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -8141,10 +9290,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.2", - "@vitest/browser-preview": "4.1.2", - "@vitest/browser-webdriverio": "4.1.2", - "@vitest/ui": "4.1.2", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -8168,6 +9319,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, @@ -8373,8 +9530,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/ws": { "version": "8.19.0", @@ -8457,10 +9613,19 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index 889c82c1..56cba360 100644 --- a/package.json +++ b/package.json @@ -34,20 +34,27 @@ "import": "./dist/vercel/react/ably-ai-transport-vercel-react.js", "require": "./dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs" }, + "./anthropic": { + "types": "./dist/anthropic/index.d.ts", + "react-native": "./dist/anthropic/ably-ai-transport-anthropic.umd.cjs", + "import": "./dist/anthropic/ably-ai-transport-anthropic.js", + "require": "./dist/anthropic/ably-ai-transport-anthropic.umd.cjs" + }, "./package.json": "./package.json" }, "scripts": { - "build": "npm run build:clean && npm run build:core && npm run build:react && npm run build:vercel && npm run build:vercel-react", + "build": "npm run build:clean && npm run build:core && npm run build:react && npm run build:vercel && npm run build:vercel-react && npm run build:anthropic", "build:clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"", "build:core": "vite build --config ./src/vite.config.ts", "build:react": "vite build --config ./src/react/vite.config.ts --emptyOutDir", "build:vercel": "vite build --config ./src/vercel/vite.config.ts --emptyOutDir", "build:vercel-react": "vite build --config ./src/vercel/react/vite.config.ts --emptyOutDir", + "build:anthropic": "vite build --config ./src/anthropic/vite.config.ts --emptyOutDir", "prepare": "npm run build", "lint": "eslint .", "lint:fix": "eslint --fix .; (npm run format > /dev/null)", - "format": "prettier --list-different --write .", - "format:check": "prettier --check .", + "format": "prettier --list-different --write src demo/vercel/react/*/src demo/anthropic/react/src", + "format:check": "prettier --check src demo/vercel/react/*/src demo/anthropic/react/src", "typecheck": "tsc --noEmit", "test": "vitest run", "test:integration": "vitest run --config vitest.config.integration.ts", @@ -83,11 +90,19 @@ "node": ">=20.0.0" }, "peerDependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.85", + "@anthropic-ai/sdk": "^0.80.0", "ably": "^2.21.0", "ai": "^6", "react": "^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { + "@anthropic-ai/claude-agent-sdk": { + "optional": true + }, + "@anthropic-ai/sdk": { + "optional": true + }, "ai": { "optional": true }, @@ -97,13 +112,18 @@ }, "devDependencies": { "@ai-sdk/react": "^3.0.151", + "@anthropic-ai/claude-agent-sdk": "^0.2.85", + "@anthropic-ai/sdk": "^0.80.0", "@eslint/compat": "^1.2.7", "@eslint/eslintrc": "^3.3.0", "@eslint/js": "^9.12.0", + "@modelcontextprotocol/sdk": "^1.29.0", "@testing-library/react": "^16.3.2", "@types/react": "^19.2.14", "@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/parser": "^8.26.1", + "@anthropic-ai/claude-agent-sdk": "^0.2.85", + "@anthropic-ai/sdk": "^0.80.0", "@vitest/coverage-v8": "^4.1.2", "ai": "^6.0.137", "eslint": "^9.31.0", diff --git a/src/anthropic/codec/accumulator.ts b/src/anthropic/codec/accumulator.ts new file mode 100644 index 00000000..75d3881d --- /dev/null +++ b/src/anthropic/codec/accumulator.ts @@ -0,0 +1,593 @@ +/** + * Anthropic Agent SDK Message Accumulator + * + * Builds and maintains an AgentMessage[] list from decoder outputs. + * Implements MessageAccumulator. + * + * The accumulator consumes DecoderOutput[] from the decoder and groups + * streaming events into SDKAssistantMessage objects using lifecycle + * boundaries (message_start / message_stop). Complete messages (from + * writeMessages) are inserted directly. + * + * Multiple messages can be in-progress concurrently — each is identified + * by the `messageId` field on DecoderOutput (read from x-ably-msg-id). + */ + +import type { UUID } from 'node:crypto'; + +import type * as Anthropic from '@anthropic-ai/claude-agent-sdk'; + +import type { DecoderOutput, MessageAccumulator } from '../../core/codec/types.js'; +import type { AgentCodecEvent, AgentMessage, BetaMessage, StreamEvent } from './types.js'; + +/** Status of a content block stream. */ +type StreamStatus = 'streaming' | 'finished' | 'aborted'; + +/** Tracks the type and index of an in-progress content block. */ +interface ContentBlockState { + /** Content block type discriminant. */ + type: string; + /** Position in the message's content array. */ + index: number; +} + +/** Bundled per-message state for an in-progress assistant message. */ +interface ActiveMessageState { + /** The in-progress SDKAssistantMessage being assembled. */ + message: Anthropic.SDKAssistantMessage; + /** Content blocks being built, keyed by block index. */ + contentBlocks: Map; + /** JSON accumulation buffers for tool_use inputs, keyed by block index. */ + toolInputBuffers: Map; + /** Stream status per block index. */ + streamStatus: Map; +} + +// --------------------------------------------------------------------------- +// Default implementation +// --------------------------------------------------------------------------- + +class DefaultAgentAccumulator implements MessageAccumulator { + private readonly _messageList: AgentMessage[] = []; + private readonly _activeMessages = new Map(); + + get messages(): AgentMessage[] { + return this._messageList; + } + + get completedMessages(): AgentMessage[] { + const activeSet = new Set(); + for (const state of this._activeMessages.values()) { + activeSet.add(state.message); + } + return this._messageList.filter((msg) => !activeSet.has(msg)); + } + + get hasActiveStream(): boolean { + for (const state of this._activeMessages.values()) { + for (const status of state.streamStatus.values()) { + if (status === 'streaming') return true; + } + } + return false; + } + + processOutputs(outputs: DecoderOutput[]): void { + for (const output of outputs) { + if (output.kind === 'message') { + this._messageList.push(output.message); + } else if (output.messageId !== undefined) { + this._processEvent(output.event, output.messageId); + } + } + } + + initMessage(messageId: string, message: AgentMessage): void { + const existing = this._activeMessages.get(messageId); + + if (existing) { + // Already active — sync with the externally updated message. + // Replace the message and rebuild tracking maps so the accumulator + // reflects updates (e.g. cross-turn amendments applied to the tree) + // that happened outside the streaming flow. + const cloned = structuredClone(message); + const listIdx = this._messageList.indexOf(existing.message); + if (cloned.type === 'assistant') { + existing.message = cloned; + existing.contentBlocks.clear(); + existing.toolInputBuffers.clear(); + existing.streamStatus.clear(); + for (let i = 0; i < cloned.message.content.length; i++) { + // CAST: Content blocks are a union of SDK types; cast through unknown + // to read the type discriminant. + const block = cloned.message.content[i] as unknown as Record; + existing.contentBlocks.set(i, { type: block.type as string, index: i }); + existing.streamStatus.set(i, 'finished'); + } + } + if (listIdx !== -1) { + this._messageList[listIdx] = cloned; + } + return; + } + + // Not active — create tracking state from the existing message. + const cloned = structuredClone(message); + + if (cloned.type === 'assistant') { + const contentBlocks = new Map(); + const streamStatus = new Map(); + + for (let i = 0; i < cloned.message.content.length; i++) { + // CAST: Content blocks are a union of SDK types; cast through unknown + // to read the type discriminant. + const block = cloned.message.content[i] as unknown as Record; + contentBlocks.set(i, { type: block.type as string, index: i }); + streamStatus.set(i, 'finished'); + } + + const state: ActiveMessageState = { + message: cloned, + contentBlocks, + toolInputBuffers: new Map(), + streamStatus, + }; + + this._activeMessages.set(messageId, state); + } + + // If this message is already in the list (completed previously), + // replace in-place. Otherwise push as a new entry. + const existingIdx = this._messageList.findIndex((m) => { + if (m === message) return true; + // For assistant messages, the BetaMessage ID is a stable unique identifier. + if (m.type === 'assistant' && cloned.type === 'assistant') { + return m.message.id === cloned.message.id; + } + // For user messages, fall back to uuid (if present) or session_id. + const mKey = m.uuid ?? m.session_id; + const clonedKey = cloned.uuid ?? cloned.session_id; + return mKey === clonedKey; + }); + if (existingIdx === -1) { + this._messageList.push(cloned); + } else { + this._messageList[existingIdx] = cloned; + } + } + + completeMessage(messageId: string): void { + this._activeMessages.delete(messageId); + } + + // Note: This method is not currently called by the core transport. The + // identity key (uuid ?? session_id) is best-effort — SDKUserMessage.uuid + // is optional and session_id can be empty. The object-identity check + // (m === message) handles the common case where the caller holds a + // reference to the same object already in the list. If this method becomes + // load-bearing, the interface should be extended to pass x-ably-msg-id. + updateMessage(message: AgentMessage): void { + const key = message.uuid ?? message.session_id; + + const idx = this._messageList.findIndex((m) => { + const mKey = m.uuid ?? m.session_id; + return m === message || mKey === key; + }); + if (idx !== -1) { + this._messageList[idx] = message; + } + } + + // ------------------------------------------------------------------------- + // Event dispatch + // ------------------------------------------------------------------------- + + private _processEvent(event: AgentCodecEvent, messageId: string): void { + switch (event.type) { + case 'stream_event': { + this._processStreamEvent(event.event, messageId, event); + break; + } + + case 'assistant': { + this._processCompleteAssistant(event, messageId); + break; + } + + case 'user': { + this._messageList.push(event); + break; + } + + case 'result': { + // Terminal signal — clean up any active message for this messageId. + // On abort, the decoder produces a synthetic SDKResultMessage. The + // active message (if any) should be finalized: mark all streaming + // blocks as aborted and remove from active tracking. + const activeState = this._activeMessages.get(messageId); + if (activeState) { + for (const [idx, status] of activeState.streamStatus) { + if (status === 'streaming') { + activeState.streamStatus.set(idx, 'aborted'); + } + } + this._activeMessages.delete(messageId); + } + break; + } + + case 'tool_progress': { + break; + } + + default: { + break; + } + } + } + + // ------------------------------------------------------------------------- + // Complete assistant message + // ------------------------------------------------------------------------- + + private _processCompleteAssistant(event: Anthropic.SDKAssistantMessage, messageId: string): void { + const activeState = this._activeMessages.get(messageId); + if (activeState) { + // Complete message supersedes the in-progress streaming message. + // Replace it in-place in the message list. + const idx = this._messageList.indexOf(activeState.message); + if (idx === -1) { + this._messageList.push(event); + } else { + this._messageList[idx] = event; + } + this._activeMessages.delete(messageId); + } else { + this._messageList.push(event); + } + } + + // ------------------------------------------------------------------------- + // Stream event processing + // ------------------------------------------------------------------------- + + private _processStreamEvent( + innerEvent: StreamEvent, + messageId: string, + outerEvent: Anthropic.SDKPartialAssistantMessage, + ): void { + const eventType = innerEvent.type as string; + + switch (eventType) { + case 'message_start': { + this._handleMessageStart(innerEvent as Extract, messageId, outerEvent); + break; + } + + case 'content_block_start': { + this._handleContentBlockStart(innerEvent as Extract, messageId); + break; + } + + case 'content_block_delta': { + this._handleContentBlockDelta(innerEvent as Extract, messageId); + break; + } + + case 'content_block_stop': { + this._handleContentBlockStop(innerEvent as Extract, messageId); + break; + } + + case 'message_delta': { + this._handleMessageDelta(innerEvent as Extract, messageId); + break; + } + + case 'message_stop': { + this._handleMessageStop(messageId); + break; + } + + default: { + break; + } + } + } + + // ------------------------------------------------------------------------- + // message_start + // ------------------------------------------------------------------------- + + private _handleMessageStart( + // CAST: The inner event is narrowed by the switch on .type above. + // message_start carries a .message field with the initial BetaMessage shell. + event: Extract, + messageId: string, + outerEvent: Anthropic.SDKPartialAssistantMessage, + ): void { + const state: ActiveMessageState = { + message: { + type: 'assistant', + message: event.message, + parent_tool_use_id: outerEvent.parent_tool_use_id, + uuid: outerEvent.uuid, + session_id: outerEvent.session_id, + }, + contentBlocks: new Map(), + toolInputBuffers: new Map(), + streamStatus: new Map(), + }; + this._activeMessages.set(messageId, state); + this._messageList.push(state.message); + } + + // ------------------------------------------------------------------------- + // content_block_start + // ------------------------------------------------------------------------- + + private _handleContentBlockStart( + event: Extract, + messageId: string, + ): void { + const state = this._ensureActiveMessage(messageId); + const index: number = event.index; + + // CAST: content_block is a union of many SDK types; cast through unknown to access + // discriminant fields without exhaustively matching every union member. + const contentBlock: Record = event.content_block as unknown as Record; + const blockType = contentBlock.type as string; + + const content: BetaMessage['content'] = state.message.message.content; + + switch (blockType) { + case 'text': { + content[index] = { type: 'text', text: '' } as BetaMessage['content'][number]; + state.contentBlocks.set(index, { type: 'text', index }); + state.streamStatus.set(index, 'streaming'); + break; + } + + case 'tool_use': { + content[index] = { + type: 'tool_use', + id: contentBlock.id as string, + name: contentBlock.name as string, + input: {}, + } as BetaMessage['content'][number]; + state.contentBlocks.set(index, { type: 'tool_use', index }); + state.toolInputBuffers.set(index, ''); + state.streamStatus.set(index, 'streaming'); + break; + } + + case 'thinking': { + content[index] = { + type: 'thinking', + thinking: '', + signature: '', + } as BetaMessage['content'][number]; + state.contentBlocks.set(index, { type: 'thinking', index }); + state.streamStatus.set(index, 'streaming'); + break; + } + + default: { + // Non-streaming block types — push as-is. + // CAST: contentBlock is Record from the cast above; re-cast to the + // content element type since we cannot exhaustively narrow every SDK union member. + content[index] = contentBlock as unknown as BetaMessage['content'][number]; + state.contentBlocks.set(index, { type: blockType, index }); + break; + } + } + } + + // ------------------------------------------------------------------------- + // content_block_delta + // ------------------------------------------------------------------------- + + private _handleContentBlockDelta( + event: Extract, + messageId: string, + ): void { + const state = this._activeMessages.get(messageId); + if (!state) return; + + const index: number = event.index; + const blockState = state.contentBlocks.get(index); + if (!blockState) return; + + // CAST: delta is a union of many SDK delta types; cast through unknown to access + // discriminant fields without exhaustively matching every union member. + const delta = event.delta as unknown as Record; + const deltaType = delta.type as string; + + // CAST: Content blocks are a union of SDK types; cast through unknown to access + // fields by name after switching on the blockState.type discriminant. + const block = state.message.message.content[index] as unknown as Record | undefined; + if (!block) return; + + switch (deltaType) { + case 'text_delta': { + if (blockState.type === 'text' && typeof block.text === 'string') { + block.text += delta.text as string; + } + break; + } + + case 'input_json_delta': { + const buffer = state.toolInputBuffers.get(index); + if (buffer !== undefined) { + const updated = buffer + (delta.partial_json as string); + state.toolInputBuffers.set(index, updated); + + try { + // CAST: JSON.parse returns any; unknown is the safe trust-boundary type. + block.input = JSON.parse(updated) as unknown; + } catch { + // Partial JSON — not parseable yet, keep accumulating. + } + } + break; + } + + case 'thinking_delta': { + if (blockState.type === 'thinking' && typeof block.thinking === 'string') { + block.thinking += delta.thinking as string; + } + break; + } + + case 'signature_delta': { + // Signatures are required on thinking blocks for multi-turn API continuity. + if (blockState.type === 'thinking' && typeof block.signature === 'string') { + block.signature += delta.signature as string; + } + break; + } + + // Other delta types (e.g. citations_delta): no-op + default: { + break; + } + } + } + + // ------------------------------------------------------------------------- + // content_block_stop + // ------------------------------------------------------------------------- + + private _handleContentBlockStop( + event: Extract, + messageId: string, + ): void { + const state = this._activeMessages.get(messageId); + if (!state) return; + + const index: number = event.index; + state.streamStatus.set(index, 'finished'); + + // Final JSON parse for tool_use blocks with buffered input. + const blockState = state.contentBlocks.get(index); + if (blockState?.type === 'tool_use') { + const buffer = state.toolInputBuffers.get(index); + if (buffer !== undefined && buffer.length > 0) { + // CAST: Content block is a union of SDK types; cast through unknown to access .input. + const block = state.message.message.content[index] as unknown as Record | undefined; + if (block) { + try { + // CAST: JSON.parse returns any; unknown is the safe trust-boundary type. + block.input = JSON.parse(buffer) as unknown; + } catch { + // Buffer did not parse — leave input as last successful parse or {}. + } + } + } + state.toolInputBuffers.delete(index); + } + } + + // ------------------------------------------------------------------------- + // message_delta + // ------------------------------------------------------------------------- + + private _handleMessageDelta(event: Extract, messageId: string): void { + const state = this._activeMessages.get(messageId); + if (!state) return; + + // CAST: delta is typed as BetaRawMessageDeltaEvent.Delta; cast through unknown + // to access .stop_reason without matching the exact SDK type shape. + const delta = event.delta as unknown as Record; + if (delta.stop_reason !== undefined) { + // CAST: stop_reason is a string | null on BetaMessage; cast through unknown to set it. + (state.message.message as unknown as Record).stop_reason = delta.stop_reason; + } + + // CAST: usage on message_delta is BetaMessageDeltaUsage (output_tokens only). + // Merge into the message's usage field. Cast through unknown because + // BetaMessageDeltaUsage does not have a string index signature. + const usage = event.usage as unknown as Record | undefined; + if (usage !== undefined) { + // CAST: BetaUsage does not have a string index signature; cast through unknown. + const msgUsage = state.message.message.usage as unknown as Record; + if (typeof usage.output_tokens === 'number') { + msgUsage.output_tokens = usage.output_tokens; + } + } + } + + // ------------------------------------------------------------------------- + // message_stop + // ------------------------------------------------------------------------- + + private _handleMessageStop(messageId: string): void { + // Message is already in _messageList (pushed on creation). + // Just remove from active tracking so completedMessages includes it. + this._activeMessages.delete(messageId); + } + + // ------------------------------------------------------------------------- + // Shared helpers + // ------------------------------------------------------------------------- + + private _ensureActiveMessage(messageId: string): ActiveMessageState { + const existing = this._activeMessages.get(messageId); + if (existing) return existing; + + // CAST: Defensive creation for mid-stream join — message_start was missed. + // The accumulator creates a minimal shell that will be updated as more events arrive. + + /* eslint-disable unicorn/no-null -- SDK types use null for absent optional fields */ + const shell: Anthropic.SDKAssistantMessage = { + type: 'assistant', + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: 'unknown', + content: [], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: null, + cache_read_input_tokens: null, + cache_creation: null, + inference_geo: null, + iterations: null, + server_tool_use: null, + service_tier: null, + speed: null, + }, + container: null, + context_management: null, + } as BetaMessage, + parent_tool_use_id: null, + uuid: messageId as UUID, + session_id: '', + }; + /* eslint-enable unicorn/no-null */ + + const state: ActiveMessageState = { + message: shell, + contentBlocks: new Map(), + toolInputBuffers: new Map(), + streamStatus: new Map(), + }; + this._activeMessages.set(messageId, state); + this._messageList.push(state.message); + return state; + } +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +/** + * Create an Anthropic Agent SDK accumulator that builds AgentMessage[] from decoder outputs. + * @returns A {@link MessageAccumulator} for AgentCodecEvent/AgentMessage. + */ +export const createAccumulator = (): MessageAccumulator => new DefaultAgentAccumulator(); diff --git a/src/anthropic/codec/decoder.ts b/src/anthropic/codec/decoder.ts new file mode 100644 index 00000000..9fe819ed --- /dev/null +++ b/src/anthropic/codec/decoder.ts @@ -0,0 +1,525 @@ +/** + * Anthropic Agent SDK Decoder + * + * Maps Ably inbound messages to DecoderOutput[]. + * + * Delegates action dispatch and serial tracking to the decoder core. + * This file contains only the Anthropic-specific event building, discrete + * event decoding, and synthetic event emission. + * + * Domain-specific headers use the `x-domain-` prefix. Transport-level + * headers use the `x-ably-` prefix. + */ + +import type { UUID } from 'node:crypto'; + +import type * as Anthropic from '@anthropic-ai/claude-agent-sdk'; +import type * as Ably from 'ably'; + +import { HEADER_TURN_ID } from '../../constants.js'; +import type { DecoderCore, DecoderCoreHooks, DecoderCoreOptions } from '../../core/codec/decoder.js'; +import { createDecoderCore, eventOutput } from '../../core/codec/decoder.js'; +import type { LifecycleTracker } from '../../core/codec/lifecycle-tracker.js'; +import { createLifecycleTracker } from '../../core/codec/lifecycle-tracker.js'; +import type { DecoderOutput, MessagePayload, StreamDecoder, StreamTrackerState } from '../../core/codec/types.js'; +import { headerReader } from '../../utils.js'; +import type { AgentCodecEvent, AgentMessage, BetaMessage, StreamEvent } from './types.js'; + +// --------------------------------------------------------------------------- +// Shared output type alias +// --------------------------------------------------------------------------- + +type Out = DecoderOutput; + +/** + * Bind eventOutput to the Anthropic domain types. + * @param e - The AgentCodecEvent to wrap. + * @returns A single-element decoder output array. + */ +const event = (e: AgentCodecEvent): Out[] => eventOutput(e); + +// --------------------------------------------------------------------------- +// SDKPartialAssistantMessage construction helper +// --------------------------------------------------------------------------- + +/** + * Wrap a BetaRawMessageStreamEvent in an SDKPartialAssistantMessage envelope. + * @param streamEvent - The inner stream event to wrap. + * @param headers - Domain headers for reading parentToolUseId and messageId. + * @returns A fully formed SDKPartialAssistantMessage. + */ +const wrapStreamEvent = ( + streamEvent: StreamEvent, + headers: Record, +): Anthropic.SDKPartialAssistantMessage => ({ + type: 'stream_event', + event: streamEvent, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + parent_tool_use_id: headerReader(headers).str('parentToolUseId') ?? null, + // CAST: UUID from domain header. Synthetic events use a placeholder. + uuid: (headerReader(headers).str('messageId') ?? 'synthetic') as UUID, + session_id: '', +}); + +// --------------------------------------------------------------------------- +// Lifecycle tracker configuration (synthetic event phases) +// --------------------------------------------------------------------------- + +const createAgentLifecycleTracker = (): LifecycleTracker => + createLifecycleTracker([ + { + key: 'message_start', + build: (ctx) => { + // CAST: Synthetic BetaMessage — cast through unknown because the SDK type + // has many required fields irrelevant for this shell. The accumulator fills + // in real data as streaming events arrive. + /* eslint-disable unicorn/no-null -- SDK types use null for absent optional fields */ + const syntheticMessage = { + id: ctx.messageId ?? 'synthetic', + type: 'message', + role: 'assistant', + model: ctx.model ?? 'unknown', + content: [], + container: null, + context_management: null, + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + } as unknown as BetaMessage; + /* eslint-enable unicorn/no-null */ + + const syntheticEvent = { + type: 'message_start' as const, + message: syntheticMessage, + } as StreamEvent; + + return [ + wrapStreamEvent(syntheticEvent, { + 'x-domain-messageId': ctx.messageId ?? 'synthetic', + 'x-domain-model': ctx.model ?? 'unknown', + }), + ]; + }, + }, + ]); + +/** + * Run the lifecycle tracker and wrap results as DecoderOutput events. + * @param lifecycle - The lifecycle tracker instance. + * @param turnId - The turn scope ID. + * @param context - Context passed through to phase build functions. + * @returns Decoder outputs for any synthesized lifecycle events. + */ +const ensurePhases = ( + lifecycle: LifecycleTracker, + turnId: string, + context: Record, +): Out[] => lifecycle.ensurePhases(turnId, context).map((e) => ({ kind: 'event', event: e })); + +// --------------------------------------------------------------------------- +// Streamed message event builders +// --------------------------------------------------------------------------- + +const buildStartEvents = (tracker: StreamTrackerState): AgentCodecEvent[] => { + const r = headerReader(tracker.headers); + const blockIndex = Number(r.strOr('blockIndex', '0')); + + switch (tracker.name) { + case 'text': { + return [ + wrapStreamEvent( + { + type: 'content_block_start', + index: blockIndex, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + content_block: { type: 'text', text: '', citations: null }, + } as StreamEvent, + tracker.headers, + ), + ]; + } + + case 'tool-input': { + return [ + wrapStreamEvent( + { + type: 'content_block_start', + index: blockIndex, + content_block: { + type: 'tool_use', + id: r.strOr('toolUseId', ''), + name: r.strOr('toolName', ''), + input: {}, + }, + } as StreamEvent, + tracker.headers, + ), + ]; + } + + case 'thinking': { + return [ + wrapStreamEvent( + { + type: 'content_block_start', + index: blockIndex, + content_block: { type: 'thinking', thinking: '', signature: '' }, + } as StreamEvent, + tracker.headers, + ), + ]; + } + + default: { + return [ + wrapStreamEvent( + { + type: 'content_block_start', + index: blockIndex, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + content_block: { type: 'text', text: '', citations: null }, + } as StreamEvent, + tracker.headers, + ), + ]; + } + } +}; + +const buildDeltaEvent = (tracker: StreamTrackerState, delta: string): AgentCodecEvent => { + const r = headerReader(tracker.headers); + const blockIndex = Number(r.strOr('blockIndex', '0')); + + switch (tracker.name) { + case 'text': { + return wrapStreamEvent( + { + type: 'content_block_delta', + index: blockIndex, + delta: { type: 'text_delta', text: delta }, + } as StreamEvent, + tracker.headers, + ); + } + + case 'tool-input': { + return wrapStreamEvent( + { + type: 'content_block_delta', + index: blockIndex, + delta: { type: 'input_json_delta', partial_json: delta }, + } as StreamEvent, + tracker.headers, + ); + } + + case 'thinking': { + return wrapStreamEvent( + { + type: 'content_block_delta', + index: blockIndex, + delta: { type: 'thinking_delta', thinking: delta }, + } as StreamEvent, + tracker.headers, + ); + } + + default: { + return wrapStreamEvent( + { + type: 'content_block_delta', + index: blockIndex, + delta: { type: 'text_delta', text: delta }, + } as StreamEvent, + tracker.headers, + ); + } + } +}; + +const buildCloseEvents = (tracker: StreamTrackerState, closingHeaders: Record): AgentCodecEvent[] => { + const r = headerReader(closingHeaders); + const blockIndex = Number(r.strOr('blockIndex', headerReader(tracker.headers).strOr('blockIndex', '0'))); + + const events: AgentCodecEvent[] = []; + + // The encoder buffers signature_delta data and includes the accumulated + // signature as a closing header. Emit a synthetic signature_delta before + // content_block_stop so the accumulator can populate block.signature + // (required for multi-turn API continuity with thinking blocks). + const signature = r.str('signature'); + if (signature && tracker.name === 'thinking') { + events.push( + wrapStreamEvent( + { + type: 'content_block_delta', + index: blockIndex, + delta: { type: 'signature_delta', signature }, + } as StreamEvent, + closingHeaders, + ), + ); + } + + events.push( + wrapStreamEvent( + { + type: 'content_block_stop', + index: blockIndex, + } as StreamEvent, + closingHeaders, + ), + ); + + return events; +}; + +// --------------------------------------------------------------------------- +// Discrete event decoders (one function per event type) +// --------------------------------------------------------------------------- + +const decodeMessageStart = ( + input: MessagePayload, + turnId: string, + lifecycle: LifecycleTracker, +): Out[] => { + lifecycle.markEmitted(turnId, 'message_start'); + const h = input.headers ?? {}; + return event( + wrapStreamEvent( + { + type: 'message_start', + // CAST: Trust boundary — encoder serialized a BetaMessage from the wire. + message: input.data as BetaMessage, + } as StreamEvent, + h, + ), + ); +}; + +const decodeMessageDelta = (input: MessagePayload): Out[] => { + const h = input.headers ?? {}; + // CAST: Trust boundary — encoder serialized { stop_reason, usage } as the data payload. + // Split into the delta (stop_reason) and usage fields that message_delta expects. + const wireData = (input.data ?? {}) as Record; + return event( + wrapStreamEvent( + { + type: 'message_delta', + delta: { stop_reason: wireData.stop_reason }, + usage: wireData.usage ?? { output_tokens: 0 }, + } as StreamEvent, + h, + ), + ); +}; + +const decodeMessageStop = (input: MessagePayload): Out[] => { + const h = input.headers ?? {}; + return event(wrapStreamEvent({ type: 'message_stop' } as StreamEvent, h)); +}; + +const decodeAssistantMessage = (input: MessagePayload): Out[] => { + const h = input.headers ?? {}; + const r = headerReader(h); + // CAST: Trust boundary — encoder serialized an SDKAssistantMessage. + + const message: Anthropic.SDKAssistantMessage = { + type: 'assistant', + message: input.data as Anthropic.SDKAssistantMessage['message'], + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + parent_tool_use_id: r.str('parentToolUseId') ?? null, + // CAST: UUID from domain header. The encoder writes both the BetaMessage.id + // (as 'messageId') and the Agent SDK uuid (as 'uuid'). + uuid: (r.str('uuid') ?? r.str('messageId') ?? '') as UUID, + session_id: r.str('sessionId') ?? '', + }; + return [{ kind: 'message', message }]; +}; + +const decodeUserMessage = (input: MessagePayload): Out[] => { + const h = input.headers ?? {}; + const r = headerReader(h); + // CAST: Trust boundary — encoder serialized an SDKUserMessage. + + // SDKUserMessage.uuid is optional — only set it if the encoder wrote one. + const uuidValue = r.str('uuid'); + + const message: Anthropic.SDKUserMessage = { + type: 'user', + message: input.data as Anthropic.SDKUserMessage['message'], + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + parent_tool_use_id: r.str('parentToolUseId') ?? null, + isSynthetic: r.bool('isSynthetic'), + ...(uuidValue ? { uuid: uuidValue as UUID } : {}), + session_id: r.str('sessionId') ?? '', + }; + return [{ kind: 'message', message }]; +}; + +const decodeResult = (input: MessagePayload, turnId: string, lifecycle: LifecycleTracker): Out[] => { + lifecycle.clearScope(turnId); + // CAST: Trust boundary — encoder serialized the full SDKResultMessage. + const result = input.data as Anthropic.SDKResultMessage; + return event(result); +}; + +const decodeToolProgress = (input: MessagePayload): Out[] => { + // CAST: Trust boundary — encoder serialized the full SDKToolProgressMessage. + const progress = input.data as Anthropic.SDKToolProgressMessage; + return event(progress); +}; + +const decodeAbort = (input: MessagePayload, turnId: string, lifecycle: LifecycleTracker): Out[] => { + lifecycle.clearScope(turnId); + // CAST: Construct a minimal SDKResultMessage to signal the stream ended. + // Synthetic transport signal with placeholder fields — carries enough data + // for the stream router's isTerminal check and accumulator cleanup. + const reason = typeof input.data === 'string' && input.data ? input.data : 'cancelled'; + /* eslint-disable unicorn/no-null -- SDK types use null for absent optional fields */ + const result = { + type: 'result' as const, + subtype: 'error_during_execution' as const, + duration_ms: 0, + duration_api_ms: 0, + is_error: true, + num_turns: 0, + stop_reason: reason, + total_cost_usd: 0, + usage: { + input_tokens: 0, + output_tokens: 0, + cache_creation: null, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + inference_geo: null, + iterations: null, + server_tool_use: null, + service_tier: null, + speed: null, + }, + modelUsage: {}, + permission_denials: [], + errors: [reason], + uuid: '' as unknown as UUID, + session_id: '', + } as unknown as Anthropic.SDKResultMessage; + /* eslint-enable unicorn/no-null */ + return event(result); +}; + +const decodeContentBlock = (input: MessagePayload): Out[] => { + const h = input.headers ?? {}; + const r = headerReader(h); + const blockIndex = Number(r.strOr('blockIndex', '0')); + + // CAST: Trust boundary — encoder serialized the content block from the wire. + // The content_block shape is opaque at this point; cast through unknown + // because Record does not overlap with the SDK's union. + return event( + wrapStreamEvent( + { + type: 'content_block_start', + index: blockIndex, + content_block: input.data, + } as unknown as StreamEvent, + h, + ), + ); +}; + +// --------------------------------------------------------------------------- +// Discrete event dispatch +// --------------------------------------------------------------------------- + +const decodeDiscretePayload = (input: MessagePayload, lifecycle: LifecycleTracker): Out[] => { + const h = input.headers ?? {}; + const turnId = h[HEADER_TURN_ID] ?? ''; + + switch (input.name) { + case 'message-start': { + return decodeMessageStart(input, turnId, lifecycle); + } + case 'message-delta': { + return decodeMessageDelta(input); + } + case 'message-stop': { + return decodeMessageStop(input); + } + case 'assistant-message': { + return decodeAssistantMessage(input); + } + case 'user-message': { + return decodeUserMessage(input); + } + case 'result': { + return decodeResult(input, turnId, lifecycle); + } + case 'tool-progress': { + return decodeToolProgress(input); + } + case 'abort': { + return decodeAbort(input, turnId, lifecycle); + } + case 'content-block': { + return decodeContentBlock(input); + } + default: { + return []; + } + } +}; + +// --------------------------------------------------------------------------- +// Decoder core hooks +// --------------------------------------------------------------------------- + +const createHooks = ( + lifecycle: LifecycleTracker, +): DecoderCoreHooks => ({ + buildStartEvents: (tracker: StreamTrackerState): Out[] => { + const turnId = tracker.headers[HEADER_TURN_ID] ?? ''; + const r = headerReader(tracker.headers); + const outputs = ensurePhases(lifecycle, turnId, { messageId: r.str('messageId'), model: r.str('model') }); + for (const evt of buildStartEvents(tracker)) { + outputs.push({ kind: 'event', event: evt }); + } + return outputs; + }, + + buildDeltaEvents: (tracker: StreamTrackerState, delta: string): Out[] => event(buildDeltaEvent(tracker, delta)), + + buildEndEvents: (tracker: StreamTrackerState, closingHeaders: Record): Out[] => + buildCloseEvents(tracker, closingHeaders).flatMap((e) => event(e)), + + decodeDiscrete: (payload: MessagePayload): Out[] => decodeDiscretePayload(payload, lifecycle), +}); + +// --------------------------------------------------------------------------- +// Default implementation +// --------------------------------------------------------------------------- + +class DefaultAgentDecoder implements StreamDecoder { + private readonly _core: DecoderCore; + + constructor(options: DecoderCoreOptions = {}) { + this._core = createDecoderCore(createHooks(createAgentLifecycleTracker()), options); + } + + decode(message: Ably.InboundMessage): Out[] { + return this._core.decode(message); + } +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +/** + * Create an Anthropic Agent SDK decoder that maps Ably messages to + * AgentCodecEvent events and AgentMessage objects via the decoder core. + * @param options - Decoder configuration (callbacks, logger). + * @returns A {@link StreamDecoder} for AgentCodecEvent/AgentMessage. + */ +export const createDecoder = (options: DecoderCoreOptions = {}): StreamDecoder => + new DefaultAgentDecoder(options); diff --git a/src/anthropic/codec/encoder.ts b/src/anthropic/codec/encoder.ts new file mode 100644 index 00000000..2609db44 --- /dev/null +++ b/src/anthropic/codec/encoder.ts @@ -0,0 +1,394 @@ +/** + * Anthropic Agent SDK Encoder + * + * Maps AgentCodecEvent events and complete AgentMessage objects to Ably channel + * operations (publish, appendMessage, updateMessage). + * + * Delegates the message append lifecycle (publish, append, close, abort, + * flush/recover) to the encoder core. This file contains only the + * Anthropic-specific event-to-operation mapping. + * + * Domain-specific headers use the `x-domain-` prefix to distinguish them + * from transport-level `x-ably-` headers. + * + * ## Core operations and domain headers + * + * Each AgentCodecEvent maps to one or more encoder core operations. Domain + * headers are passed to every operation that accepts them — the core handles + * merging, persistence, and deduplication: + * + * - **`startStream`**: Opens a message stream for a content block (text, + * tool_use input, thinking). Domain headers become "persistent headers" — + * the core repeats them on every subsequent append. + * - **`appendStream`**: Appends a text/json/thinking delta. Data only, no + * headers parameter. The core automatically carries persistent headers + * from start. + * - **`closeStream`**: Closes the stream on `content_block_stop`. Pass all + * domain headers from the block — the core merges them on top of persistent + * headers. + * - **`publishDiscrete`**: Publishes a standalone message for lifecycle events + * (`message_start`, `message_delta`, `message_stop`), complete messages, + * results, and non-streaming content blocks. + * + * ## Open block tracking + * + * Unlike Vercel where each chunk self-identifies (e.g. `text-delta` carries + * `chunk.id`), Anthropic's `content_block_delta` and `content_block_stop` + * only carry an `index`. The encoder tracks open content blocks via + * `_openBlocks` to map index → streamId on delta/stop events. + */ + +import * as Ably from 'ably'; + +import { HEADER_STATUS } from '../../constants.js'; +import type { EncoderCore, EncoderCoreOptions } from '../../core/codec/encoder.js'; +import { createEncoderCore } from '../../core/codec/encoder.js'; +import type { ChannelWriter, MessagePayload, StreamEncoder, WriteOptions } from '../../core/codec/types.js'; +import { ErrorCode } from '../../errors.js'; +import { headerWriter } from '../../utils.js'; +import type { AgentCodecEvent, AgentMessage, StreamEvent } from './types.js'; + +/** Metadata for an open content block stream: the Ably message name and stream ID. */ +interface OpenBlock { + name: string; + streamId: string; +} + +// --------------------------------------------------------------------------- +// Default implementation +// --------------------------------------------------------------------------- + +class DefaultAgentEncoder implements StreamEncoder { + private readonly _core: EncoderCore; + private readonly _openBlocks = new Map(); + private readonly _signatureBuffers = new Map(); + private readonly _streamedMessages = new Set(); + private _aborted = false; + + constructor(writer: ChannelWriter, options: EncoderCoreOptions = {}) { + this._core = createEncoderCore(writer, options); + } + + async appendEvent(event: AgentCodecEvent, perWrite?: WriteOptions): Promise { + switch (event.type) { + // -- Streaming: SDKPartialAssistantMessage wraps BetaRawMessageStreamEvent + case 'stream_event': { + await this._handleStreamEvent(event.event, perWrite); + break; + } + + // -- Complete assistant message (non-streaming only). + // The Agent SDK emits SDKAssistantMessage both after streaming completes + // and as the sole event in non-streaming mode. Skip the discrete publish + // when the message was already streamed — the content arrived as deltas. + case 'assistant': { + const messageId = event.message.id; + if (this._streamedMessages.has(messageId)) break; + + const h = headerWriter() + .str('messageId', messageId) + .str('uuid', event.uuid) + .str('sessionId', event.session_id) + .str('parentToolUseId', event.parent_tool_use_id ?? undefined) + .build(); + await this._core.publishDiscrete({ name: 'assistant-message', data: event.message, headers: h }, perWrite); + break; + } + + // -- User message (including synthetic tool results) + case 'user': { + const h = headerWriter() + .str('uuid', event.uuid) + .str('sessionId', event.session_id) + .str('parentToolUseId', event.parent_tool_use_id ?? undefined) + .bool('isSynthetic', event.isSynthetic) + .build(); + await this._core.publishDiscrete({ name: 'user-message', data: event.message, headers: h }, perWrite); + break; + } + + // -- Terminal result signal + case 'result': { + const h = headerWriter().str('subtype', event.subtype).build(); + await this._core.publishDiscrete({ name: 'result', data: event, headers: h }, perWrite); + break; + } + + // -- Tool execution progress + case 'tool_progress': { + await this._core.publishDiscrete({ name: 'tool-progress', data: event }, perWrite); + break; + } + + // -- Unknown event types: no-op + default: { + break; + } + } + } + + async writeEvent(event: AgentCodecEvent, perWrite?: WriteOptions): Promise { + if (event.type === 'result') { + const h = headerWriter().str('subtype', event.subtype).build(); + return this._core.publishDiscrete({ name: 'result', data: event, headers: h }, perWrite); + } + + if (event.type === 'tool_progress') { + return this._core.publishDiscrete({ name: 'tool-progress', data: event }, perWrite); + } + + throw new Ably.ErrorInfo( + `unable to write event; only 'result' and 'tool_progress' types are supported as discrete events, got '${event.type}'`, + ErrorCode.InvalidArgument, + 400, + ); + } + + async writeMessages(messages: AgentMessage[], perWrite?: WriteOptions): Promise { + const payloads = messages.map((msg) => encodeMessagePayload(msg)); + return this._core.publishDiscreteBatch(payloads, perWrite); + } + + async abort(reason?: string): Promise { + if (this._aborted) return; + this._aborted = true; + await this._core.abortAllStreams(); + await this._core.publishDiscrete({ + name: 'abort', + data: reason ?? '', + headers: { [HEADER_STATUS]: 'aborted' }, + }); + } + + async close(): Promise { + await this._core.close(); + } + + // ------------------------------------------------------------------------- + // Private: stream event routing + // ------------------------------------------------------------------------- + + // CAST: StreamEvent is BetaRawMessageStreamEvent — a union of many event shapes. + // We cast through unknown to access fields after switching on .type, rather than + // exhaustively narrowing every union member. + private async _handleStreamEvent(streamEvent: StreamEvent, perWrite?: WriteOptions): Promise { + const eventType = streamEvent.type as string; + + switch (eventType) { + case 'message_start': { + // CAST: message_start carries .message with id and model fields per the Anthropic wire protocol. + const message = (streamEvent as unknown as { message: { id: string; model: string } }).message; + + this._streamedMessages.add(message.id); + const h = headerWriter().str('messageId', message.id).str('model', message.model).build(); + await this._core.publishDiscrete({ name: 'message-start', data: message, headers: h }, perWrite); + break; + } + + case 'content_block_start': { + // CAST: content_block_start carries .index and .content_block; cast through unknown. + const { index, content_block } = streamEvent as unknown as { + index: number; + content_block: Record; + }; + await this._handleContentBlockStart(index, content_block, perWrite); + break; + } + + case 'content_block_delta': { + // CAST: content_block_delta carries .index and .delta; cast through unknown. + const { index, delta } = streamEvent as unknown as { index: number; delta: Record }; + this._handleContentBlockDelta(index, delta); + break; + } + + case 'content_block_stop': { + // CAST: content_block_stop carries .index; cast through unknown. + const { index } = streamEvent as unknown as { index: number }; + await this._handleContentBlockStop(index); + break; + } + + case 'message_delta': { + // CAST: message_delta carries .delta and .usage; cast through unknown. + const { delta, usage } = streamEvent as unknown as { + delta: { stop_reason?: string }; + usage: Record; + }; + const h = headerWriter() + .str('stopReason', delta.stop_reason ?? undefined) + .build(); + await this._core.publishDiscrete( + { name: 'message-delta', data: { stop_reason: delta.stop_reason, usage }, headers: h }, + perWrite, + ); + break; + } + + case 'message_stop': { + await this._core.publishDiscrete({ name: 'message-stop', data: '' }, perWrite); + break; + } + + default: { + break; + } + } + } + + // Content block and delta parameters are Record because the + // caller casts the union-typed fields through unknown. Property accesses rely on + // the Anthropic streaming protocol's documented structure — each variant carries + // a `type` discriminant and known payload fields. + + private async _handleContentBlockStart( + index: number, + contentBlock: Record, + perWrite?: WriteOptions, + ): Promise { + // CAST: content_block.type is always a string discriminant per the Anthropic wire protocol. + const blockType = contentBlock.type as string; + const streamId = String(index); + + switch (blockType) { + case 'text': { + const h = headerWriter().str('blockIndex', streamId).str('blockType', blockType).build(); + await this._core.startStream(streamId, { name: 'text', data: '', headers: h }, perWrite); + this._openBlocks.set(index, { name: 'text', streamId }); + break; + } + + case 'tool_use': { + // CAST: tool_use content blocks carry string `id` and `name` per the Anthropic protocol. + const h = headerWriter() + .str('blockIndex', streamId) + .str('blockType', blockType) + .str('toolUseId', contentBlock.id as string) + .str('toolName', contentBlock.name as string) + .build(); + await this._core.startStream(streamId, { name: 'tool-input', data: '', headers: h }, perWrite); + this._openBlocks.set(index, { name: 'tool-input', streamId }); + break; + } + + case 'thinking': { + const h = headerWriter().str('blockIndex', streamId).str('blockType', blockType).build(); + await this._core.startStream(streamId, { name: 'thinking', data: '', headers: h }, perWrite); + this._openBlocks.set(index, { name: 'thinking', streamId }); + break; + } + + // Non-streaming block types (server_tool_use, web_search_tool_result, etc.) + // arrive complete — publish as a discrete message. + default: { + const h = headerWriter().str('blockIndex', streamId).str('blockType', blockType).build(); + await this._core.publishDiscrete({ name: 'content-block', data: contentBlock, headers: h }, perWrite); + break; + } + } + } + + private _handleContentBlockDelta(index: number, delta: Record): void { + const block = this._openBlocks.get(index); + if (!block) return; + + // CAST: delta.type is always a string discriminant per the Anthropic wire protocol. + const deltaType = delta.type as string; + + switch (deltaType) { + case 'text_delta': { + // CAST: text_delta carries a string `text` field per the Anthropic protocol. + this._core.appendStream(block.streamId, delta.text as string); + break; + } + + case 'input_json_delta': { + // CAST: input_json_delta carries a string `partial_json` field per the Anthropic protocol. + this._core.appendStream(block.streamId, delta.partial_json as string); + break; + } + + case 'thinking_delta': { + // CAST: thinking_delta carries a string `thinking` field per the Anthropic protocol. + this._core.appendStream(block.streamId, delta.thinking as string); + break; + } + + case 'signature_delta': { + // CAST: signature_delta carries a string `signature` field per the Anthropic protocol. + // Buffer signatures instead of streaming — the decoder cannot distinguish + // signature appends from thinking appends on the same stream. The buffered + // signature is included in the closeStream headers so the decoder can emit + // a proper signature_delta event on the receiving end. + const existing = this._signatureBuffers.get(index) ?? ''; + this._signatureBuffers.set(index, existing + (delta.signature as string)); + break; + } + + // Other delta types (e.g. citations_delta): no-op + default: { + break; + } + } + } + + private async _handleContentBlockStop(index: number): Promise { + const block = this._openBlocks.get(index); + if (!block) return; + + // Include buffered signature in close headers so the decoder can emit + // a signature_delta event. Signatures arrive as deltas during streaming + // but cannot be distinguished from thinking text on the wire, so they + // are buffered and delivered as a closing header instead. + const signature = this._signatureBuffers.get(index); + const h = signature ? headerWriter().str('signature', signature).build() : {}; + await this._core.closeStream(block.streamId, { name: block.name, data: '', headers: h }); + this._signatureBuffers.delete(index); + this._openBlocks.delete(index); + } +} + +// --------------------------------------------------------------------------- +// Message payload encoding (stateless helper) +// --------------------------------------------------------------------------- + +const encodeMessagePayload = (message: AgentMessage): MessagePayload => { + switch (message.type) { + case 'user': { + const h = headerWriter() + .str('uuid', message.uuid) + .str('sessionId', message.session_id) + .str('parentToolUseId', message.parent_tool_use_id ?? undefined) + .bool('isSynthetic', message.isSynthetic) + .build(); + return { name: 'user-message', data: message.message, headers: h }; + } + + case 'assistant': { + const msgId = message.message.id; + const h = headerWriter() + .str('messageId', msgId) + .str('uuid', message.uuid) + .str('sessionId', message.session_id) + .str('parentToolUseId', message.parent_tool_use_id ?? undefined) + .build(); + return { name: 'assistant-message', data: message.message, headers: h }; + } + } +}; + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +/** + * Create an Anthropic Agent SDK encoder that maps AgentCodecEvent events to + * Ably channel operations via the encoder core. + * @param writer - The channel writer to publish messages through. + * @param options - Encoder configuration (clientId, extras, hooks, logger). + * @returns A {@link StreamEncoder} for AgentCodecEvent/AgentMessage. + */ +export const createEncoder = ( + writer: ChannelWriter, + options: EncoderCoreOptions = {}, +): StreamEncoder => new DefaultAgentEncoder(writer, options); diff --git a/src/anthropic/codec/index.ts b/src/anthropic/codec/index.ts new file mode 100644 index 00000000..5558737c --- /dev/null +++ b/src/anthropic/codec/index.ts @@ -0,0 +1,31 @@ +/** + * Anthropic Agent SDK codec — maps Agent SDK message types to/from + * native Ably message primitives (publish, append, update, delete). + * + * ```ts + * import { AgentCodec } from '@ably/ai-transport/anthropic'; + * + * const encoder = AgentCodec.createEncoder(writer, options); + * const decoder = AgentCodec.createDecoder(); + * const accumulator = AgentCodec.createAccumulator(); + * ``` + */ + +import type { Codec } from '../../core/codec/types.js'; +import { createAccumulator } from './accumulator.js'; +import { createDecoder } from './decoder.js'; +import { createEncoder } from './encoder.js'; +import type { AgentCodecEvent, AgentMessage } from './types.js'; + +/** + * Anthropic Agent SDK codec implementing `Codec`. + * + * Provides factory methods for creating encoders, decoders, and accumulators + * that map between Anthropic Agent SDK types and Ably's native message primitives. + */ +export const AgentCodec: Codec = { + createEncoder, + createDecoder, + createAccumulator, + isTerminal: (event: AgentCodecEvent): boolean => event.type === 'result', +}; diff --git a/src/anthropic/codec/types.ts b/src/anthropic/codec/types.ts new file mode 100644 index 00000000..429ae622 --- /dev/null +++ b/src/anthropic/codec/types.ts @@ -0,0 +1,84 @@ +/** + * Type definitions for the Anthropic Agent SDK codec. + * + * The codec is parameterized by two types: + * + * Codec + * + * - `AgentCodecEvent`: A filtered subset of `SDKMessage` from the Agent SDK, + * containing only the conversation-relevant message types. The server pipes + * `query()` output through the transport — the encoder handles these types + * and ignores operational variants (hooks, auth, config, etc.). + * + * - `AgentMessage`: The message types stored in the conversation tree and + * rendered in the UI. A union of `SDKAssistantMessage` (assistant responses) + * and `SDKUserMessage` (user inputs, including synthetic tool results). + * + * Both types re-export from `@anthropic-ai/claude-agent-sdk` as a peer + * dependency. The filtered subset `AgentCodecEvent` is defined here to + * keep the encoder's switch statement focused on conversation-relevant types. + */ + +import type * as Anthropic from '@anthropic-ai/claude-agent-sdk'; + +// --------------------------------------------------------------------------- +// TEvent — the streaming event type for the codec +// --------------------------------------------------------------------------- + +/** + * Filtered subset of `SDKMessage` containing only conversation-relevant types. + * + * The full `SDKMessage` union has ~24 variants, but most are operational + * (hooks, auth, config, tasks, etc.) and not relevant to the transport codec. + * The encoder handles these five types and ignores anything else from the + * `query()` stream. + * + * | Type | Role | + * |---|---| + * | `SDKPartialAssistantMessage` | Streaming chunks wrapping `BetaRawMessageStreamEvent` | + * | `SDKAssistantMessage` | Complete assistant response (non-streaming mode or after streaming completes) | + * | `SDKUserMessage` | User input, including synthetic tool results in agentic flows | + * | `SDKResultMessage` | Terminal signal — the query is done | + * | `SDKToolProgressMessage` | Tool execution progress indicators | + */ +export type AgentCodecEvent = + | Anthropic.SDKPartialAssistantMessage + | Anthropic.SDKAssistantMessage + | Anthropic.SDKUserMessage + | Anthropic.SDKResultMessage + | Anthropic.SDKToolProgressMessage; + +// --------------------------------------------------------------------------- +// TMessage — the message type stored in the conversation tree +// --------------------------------------------------------------------------- + +/** + * Union message type for the conversation tree. + * + * Both variants carry a `type` discriminant field (`"assistant"` or `"user"`) + * for switching. Unlike Vercel's `UIMessage` (a single type with a `role` + * field), these are structurally different types: + * + * - `SDKAssistantMessage.message` is a `BetaMessage` with content blocks + * - `SDKUserMessage.message` is a `MessageParam` with a content string/array + */ +export type AgentMessage = Anthropic.SDKAssistantMessage | Anthropic.SDKUserMessage; + +// --------------------------------------------------------------------------- +// Shared internal type aliases +// --------------------------------------------------------------------------- + +/** + * The inner event of an `SDKPartialAssistantMessage`. This is a + * `BetaRawMessageStreamEvent` from the Anthropic SDK — a union of + * `message_start`, `content_block_start`, `content_block_delta`, + * `content_block_stop`, `message_delta`, and `message_stop`. + */ +export type StreamEvent = Anthropic.SDKPartialAssistantMessage['event']; + +/** + * The `BetaMessage` type from the Anthropic SDK, extracted via indexed + * access on `SDKAssistantMessage` to avoid importing it directly from the + * transitive `@anthropic-ai/sdk` dependency. + */ +export type BetaMessage = Anthropic.SDKAssistantMessage['message']; diff --git a/src/anthropic/index.ts b/src/anthropic/index.ts new file mode 100644 index 00000000..b0d01473 --- /dev/null +++ b/src/anthropic/index.ts @@ -0,0 +1,7 @@ +// Anthropic Agent SDK codec +export { AgentCodec } from './codec/index.js'; +export type { AgentCodecEvent, AgentMessage } from './codec/types.js'; + +// Anthropic Agent SDK transport wrappers (pre-bound to AgentCodec) +export type { AnthropicClientTransportOptions, AnthropicServerTransportOptions } from './transport/index.js'; +export { createClientTransport, createServerTransport } from './transport/index.js'; diff --git a/src/anthropic/transport/index.ts b/src/anthropic/transport/index.ts new file mode 100644 index 00000000..41d4cf76 --- /dev/null +++ b/src/anthropic/transport/index.ts @@ -0,0 +1,51 @@ +/** + * Anthropic Agent SDK transport wrappers that pre-bind the AgentCodec. + * + * These are convenience factories so consumers don't need to pass the codec + * explicitly when using the Anthropic Agent SDK integration. + * + * ```ts + * import { createClientTransport } from '@ably/ai-transport/anthropic'; + * + * const transport = createClientTransport({ channel, clientId }); + * ``` + */ + +import { createClientTransport as createCoreClientTransport } from '../../core/transport/client-transport.js'; +import { createServerTransport as createCoreServerTransport } from '../../core/transport/server-transport.js'; +import type { + ClientTransport, + ClientTransportOptions, + ServerTransport, + ServerTransportOptions, +} from '../../core/transport/types.js'; +import { AgentCodec } from '../codec/index.js'; +import type { AgentCodecEvent, AgentMessage } from '../codec/types.js'; + +/** Options for creating an Anthropic client transport. Same as core options but without the codec field. */ +export type AnthropicClientTransportOptions = Omit, 'codec'>; + +/** Options for creating an Anthropic server transport. Same as core options but without the codec field. */ +export type AnthropicServerTransportOptions = Omit, 'codec'>; + +/** + * Create a client-side transport pre-configured with the Anthropic Agent SDK codec. + * + * Equivalent to calling the core `createClientTransport` with `codec: AgentCodec`. + * @param options - Configuration for the client transport (codec is provided automatically). + * @returns A new {@link ClientTransport} for Anthropic Agent SDK types. + */ +export const createClientTransport = ( + options: AnthropicClientTransportOptions, +): ClientTransport => createCoreClientTransport({ ...options, codec: AgentCodec }); + +/** + * Create a server-side transport pre-configured with the Anthropic Agent SDK codec. + * + * Equivalent to calling the core `createServerTransport` with `codec: AgentCodec`. + * @param options - Configuration for the server transport (codec is provided automatically). + * @returns A new {@link ServerTransport} for Anthropic Agent SDK types. + */ +export const createServerTransport = ( + options: AnthropicServerTransportOptions, +): ServerTransport => createCoreServerTransport({ ...options, codec: AgentCodec }); diff --git a/src/anthropic/vite.config.ts b/src/anthropic/vite.config.ts new file mode 100644 index 00000000..2293cf60 --- /dev/null +++ b/src/anthropic/vite.config.ts @@ -0,0 +1,33 @@ +import { resolve } from 'path'; +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; + +export default defineConfig({ + root: resolve(__dirname, '.'), + plugins: [ + dts({ + entryRoot: resolve(__dirname, '.'), + insertTypesEntry: true, + }), + ], + build: { + outDir: '../../dist/anthropic', + lib: { + entry: resolve(__dirname, 'index.ts'), + name: 'AblyAiTransportAnthropic', + fileName: 'ably-ai-transport-anthropic', + formats: ['es', 'umd'], + }, + rollupOptions: { + external: ['ably', '@anthropic-ai/claude-agent-sdk', '@anthropic-ai/sdk'], + output: { + globals: { + ably: 'Ably', + '@anthropic-ai/claude-agent-sdk': 'AnthropicAgentSDK', + '@anthropic-ai/sdk': 'AnthropicSDK', + }, + }, + }, + sourcemap: true, + }, +}); diff --git a/src/vite.config.ts b/src/vite.config.ts index 86686552..e09bdfb4 100644 --- a/src/vite.config.ts +++ b/src/vite.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ dts({ entryRoot: resolve(__dirname, '.'), insertTypesEntry: true, - exclude: ['react/**', 'vercel/**'], + exclude: ['react/**', 'vercel/**', 'anthropic/**'], }), ], build: { diff --git a/test/anthropic/codec/accumulator.test.ts b/test/anthropic/codec/accumulator.test.ts new file mode 100644 index 00000000..7fdeca6c --- /dev/null +++ b/test/anthropic/codec/accumulator.test.ts @@ -0,0 +1,1337 @@ +import type { UUID } from 'node:crypto'; + +import type * as Anthropic from '@anthropic-ai/claude-agent-sdk'; +import { describe, expect, it } from 'vitest'; + +import { createAccumulator } from '../../../src/anthropic/codec/accumulator.js'; +import type { AgentCodecEvent, AgentMessage } from '../../../src/anthropic/codec/types.js'; +import type { DecoderOutput } from '../../../src/core/codec/types.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +type Output = DecoderOutput; + +// Retrieve an array element, throwing if it is undefined. +const at = (arr: T[], index: number): T => { + const item = arr[index]; + if (item === undefined) throw new Error(`expected element at index ${String(index)}`); + return item; +}; + +const DEFAULT_MSG_ID = 'msg-1'; +const DEFAULT_UUID = 'test-uuid' as UUID; +const DEFAULT_SESSION_ID = 'test-session'; + +// Wrap an inner stream event into an Anthropic.SDKPartialAssistantMessage envelope. +const makeStreamEvent = ( + innerEvent: Record, + options?: { parentToolUseId?: string | null; uuid?: UUID; sessionId?: string }, +): Anthropic.SDKPartialAssistantMessage => ({ + type: 'stream_event', + // CAST: Synthetic test events — cast through unknown because object literals + // do not fully satisfy the BetaRawMessageStreamEvent union. + event: innerEvent as unknown as Anthropic.SDKPartialAssistantMessage['event'], + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + parent_tool_use_id: options?.parentToolUseId ?? null, + uuid: options?.uuid ?? DEFAULT_UUID, + session_id: options?.sessionId ?? DEFAULT_SESSION_ID, +}); + +// Build a message_start stream event with a BetaMessage shell. +const messageStartEvent = ( + messageId: string = DEFAULT_MSG_ID, + options?: { parentToolUseId?: string | null; uuid?: UUID; sessionId?: string }, +): Anthropic.SDKPartialAssistantMessage => + makeStreamEvent( + { + type: 'message_start', + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: 'claude-opus-4-20250514', + content: [], + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + stop_reason: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + stop_sequence: null, + usage: { + input_tokens: 10, + output_tokens: 0, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + cache_creation_input_tokens: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + cache_read_input_tokens: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + cache_creation: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + inference_geo: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + iterations: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + server_tool_use: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + service_tier: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + speed: null, + }, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + container: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + context_management: null, + }, + }, + options, + ); + +// Build a content_block_start stream event for a text block. +const textBlockStart = (index: number): Anthropic.SDKPartialAssistantMessage => + makeStreamEvent({ + type: 'content_block_start', + index, + content_block: { type: 'text', text: '' }, + }); + +// Build a content_block_delta stream event for a text_delta. +const textDelta = (index: number, text: string): Anthropic.SDKPartialAssistantMessage => + makeStreamEvent({ + type: 'content_block_delta', + index, + delta: { type: 'text_delta', text }, + }); + +// Build a content_block_stop stream event. +const contentBlockStop = (index: number): Anthropic.SDKPartialAssistantMessage => + makeStreamEvent({ + type: 'content_block_stop', + index, + }); + +// Build a content_block_start stream event for a tool_use block. +const toolUseBlockStart = (index: number, id: string, name: string): Anthropic.SDKPartialAssistantMessage => + makeStreamEvent({ + type: 'content_block_start', + index, + content_block: { type: 'tool_use', id, name, input: {} }, + }); + +// Build a content_block_delta stream event for input_json_delta. +const inputJsonDelta = (index: number, partialJson: string): Anthropic.SDKPartialAssistantMessage => + makeStreamEvent({ + type: 'content_block_delta', + index, + delta: { type: 'input_json_delta', partial_json: partialJson }, + }); + +// Build a content_block_start stream event for a thinking block. +const thinkingBlockStart = (index: number): Anthropic.SDKPartialAssistantMessage => + makeStreamEvent({ + type: 'content_block_start', + index, + content_block: { type: 'thinking', thinking: '', signature: '' }, + }); + +// Build a content_block_delta stream event for a thinking_delta. +const thinkingDelta = (index: number, thinking: string): Anthropic.SDKPartialAssistantMessage => + makeStreamEvent({ + type: 'content_block_delta', + index, + delta: { type: 'thinking_delta', thinking }, + }); + +// Build a message_delta stream event with stop_reason and usage. +const messageDelta = ( + stopReason: string, + outputTokens?: number, +): Anthropic.SDKPartialAssistantMessage => + makeStreamEvent({ + type: 'message_delta', + delta: { stop_reason: stopReason }, + ...(outputTokens === undefined ? {} : { usage: { output_tokens: outputTokens } }), + }); + +// Build a message_stop stream event. +const messageStop = (): Anthropic.SDKPartialAssistantMessage => + makeStreamEvent({ + type: 'message_stop', + }); + +// Wrap an AgentCodecEvent in a DecoderOutput event envelope. +const eventOutput = (event: AgentCodecEvent, messageId: string = DEFAULT_MSG_ID): Output => ({ + kind: 'event', + event, + messageId, +}); + +// Wrap an AgentMessage in a DecoderOutput message envelope. +const messageOutput = (msg: AgentMessage): Output => ({ + kind: 'message', + message: msg, +}); + +// Build a complete Anthropic.SDKAssistantMessage (non-streaming). +const completeAssistantMessage = ( + messageId: string, + content: Record[] = [], + options?: { uuid?: UUID; sessionId?: string; parentToolUseId?: string | null }, +): Anthropic.SDKAssistantMessage => ({ + type: 'assistant', + // CAST: Synthetic BetaMessage for testing — cast through unknown because the + // content array is Record[] rather than the full BetaContentBlock union. + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: 'claude-opus-4-20250514', + content, + stop_reason: 'end_turn', + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + stop_sequence: null, + usage: { + input_tokens: 10, + output_tokens: 20, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + cache_creation_input_tokens: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + cache_read_input_tokens: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + cache_creation: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + inference_geo: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + iterations: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + server_tool_use: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + service_tier: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + speed: null, + }, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + container: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + context_management: null, + } as unknown as Anthropic.SDKAssistantMessage['message'], + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + parent_tool_use_id: options?.parentToolUseId ?? null, + uuid: options?.uuid ?? (messageId as UUID), + session_id: options?.sessionId ?? DEFAULT_SESSION_ID, +}); + +// Build a complete Anthropic.SDKUserMessage. +const userMessage = ( + content: string, + options?: { uuid?: UUID; sessionId?: string; parentToolUseId?: string | null }, +): Anthropic.SDKUserMessage => ({ + type: 'user', + message: { role: 'user', content }, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + parent_tool_use_id: options?.parentToolUseId ?? null, + uuid: options?.uuid ?? (DEFAULT_UUID), + session_id: options?.sessionId ?? DEFAULT_SESSION_ID, +}); + +// Helper to access the content array from an Anthropic.SDKAssistantMessage. +const getContent = (msg: AgentMessage): Record[] => { + if (msg.type !== 'assistant') throw new Error('Expected assistant message'); + // CAST: BetaMessage.content is a union of many SDK block types; cast through + // unknown to access as generic records for test assertions. + return msg.message.content as unknown as Record[]; +}; + +// Helper to access the inner BetaMessage from an Anthropic.SDKAssistantMessage. +const getInnerMessage = (msg: AgentMessage): Record => { + if (msg.type !== 'assistant') throw new Error('Expected assistant message'); + return msg.message as unknown as Record; +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Anthropic Agent SDK accumulator', () => { + // -- text streaming lifecycle ----------------------------------------------- + + describe('text streaming lifecycle', () => { + it('accumulates text across multiple deltas in a full lifecycle', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput(messageStartEvent()), + eventOutput(textBlockStart(0)), + eventOutput(textDelta(0, 'hello')), + eventOutput(textDelta(0, ' world')), + eventOutput(contentBlockStop(0)), + eventOutput(messageStop()), + ]); + + expect(acc.messages).toHaveLength(1); + const msg = at(acc.messages, 0); + expect(msg.type).toBe('assistant'); + + const content = getContent(msg); + expect(content).toHaveLength(1); + expect(content[0]).toEqual(expect.objectContaining({ type: 'text', text: 'hello world' })); + }); + + it('includes in-progress message in messages during streaming', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput(messageStartEvent()), + eventOutput(textBlockStart(0)), + eventOutput(textDelta(0, 'partial')), + ]); + + expect(acc.messages).toHaveLength(1); + const content = getContent(at(acc.messages, 0)); + expect(content[0]).toEqual(expect.objectContaining({ type: 'text', text: 'partial' })); + }); + + it('does not include in-progress message in completedMessages', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput(messageStartEvent()), + eventOutput(textBlockStart(0)), + eventOutput(textDelta(0, 'partial')), + ]); + + expect(acc.completedMessages).toHaveLength(0); + }); + + it('moves message to completedMessages on message_stop', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput(messageStartEvent()), + eventOutput(textBlockStart(0)), + eventOutput(textDelta(0, 'done')), + eventOutput(contentBlockStop(0)), + eventOutput(messageStop()), + ]); + + expect(acc.completedMessages).toHaveLength(1); + expect(acc.messages).toHaveLength(1); + }); + + it('reports hasActiveStream true during streaming', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput(messageStartEvent()), + eventOutput(textBlockStart(0)), + ]); + + expect(acc.hasActiveStream).toBe(true); + }); + + it('reports hasActiveStream false after content_block_stop', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput(messageStartEvent()), + eventOutput(textBlockStart(0)), + eventOutput(textDelta(0, 'text')), + eventOutput(contentBlockStop(0)), + ]); + + expect(acc.hasActiveStream).toBe(false); + }); + + it('reports hasActiveStream false after message_stop', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput(messageStartEvent()), + eventOutput(textBlockStart(0)), + eventOutput(textDelta(0, 'text')), + eventOutput(contentBlockStop(0)), + eventOutput(messageStop()), + ]); + + expect(acc.hasActiveStream).toBe(false); + }); + + it('handles empty text deltas without error', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput(messageStartEvent()), + eventOutput(textBlockStart(0)), + eventOutput(textDelta(0, '')), + eventOutput(textDelta(0, 'hello')), + eventOutput(textDelta(0, '')), + eventOutput(contentBlockStop(0)), + eventOutput(messageStop()), + ]); + + const content = getContent(at(acc.messages, 0)); + expect(content[0]).toEqual(expect.objectContaining({ type: 'text', text: 'hello' })); + }); + }); + + // -- tool use streaming lifecycle ------------------------------------------- + + describe('tool use streaming lifecycle', () => { + it('accumulates tool_use input from JSON fragments', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput(messageStartEvent()), + eventOutput(toolUseBlockStart(0, 'tool-1', 'search')), + eventOutput(inputJsonDelta(0, '{"q":')), + eventOutput(inputJsonDelta(0, '"test"}')), + eventOutput(contentBlockStop(0)), + eventOutput(messageStop()), + ]); + + const content = getContent(at(acc.messages, 0)); + expect(content).toHaveLength(1); + expect(content[0]).toEqual( + expect.objectContaining({ + type: 'tool_use', + id: 'tool-1', + name: 'search', + input: { q: 'test' }, + }), + ); + }); + + it('parses input on content_block_stop even if intermediate parses fail', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput(messageStartEvent()), + eventOutput(toolUseBlockStart(0, 'tool-1', 'calc')), + // Partial JSON that is not parseable on its own + eventOutput(inputJsonDelta(0, '{"x": 1')), + eventOutput(inputJsonDelta(0, ', "y": 2}')), + eventOutput(contentBlockStop(0)), + eventOutput(messageStop()), + ]); + + const content = getContent(at(acc.messages, 0)); + expect(content[0]).toEqual( + expect.objectContaining({ + type: 'tool_use', + input: { x: 1, y: 2 }, + }), + ); + }); + + it('retains empty input if JSON buffer never parses', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput(messageStartEvent()), + eventOutput(toolUseBlockStart(0, 'tool-1', 'broken')), + eventOutput(inputJsonDelta(0, '{invalid json')), + eventOutput(contentBlockStop(0)), + eventOutput(messageStop()), + ]); + + const content = getContent(at(acc.messages, 0)); + // Input should remain as {} from the initial block start since JSON never parsed + expect(content[0]).toEqual( + expect.objectContaining({ + type: 'tool_use', + id: 'tool-1', + name: 'broken', + input: {}, + }), + ); + }); + + it('correctly identifies tool_use block with id and name', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput(messageStartEvent()), + eventOutput(toolUseBlockStart(0, 'call_abc123', 'get_weather')), + eventOutput(inputJsonDelta(0, '{"city":"London"}')), + eventOutput(contentBlockStop(0)), + eventOutput(messageStop()), + ]); + + const content = getContent(at(acc.messages, 0)); + expect(content[0]).toEqual( + expect.objectContaining({ + type: 'tool_use', + id: 'call_abc123', + name: 'get_weather', + }), + ); + }); + }); + + // -- thinking block lifecycle ----------------------------------------------- + + describe('thinking block lifecycle', () => { + it('accumulates thinking text across deltas', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput(messageStartEvent()), + eventOutput(thinkingBlockStart(0)), + eventOutput(thinkingDelta(0, 'Let me ')), + eventOutput(thinkingDelta(0, 'think about this...')), + eventOutput(contentBlockStop(0)), + eventOutput(messageStop()), + ]); + + const content = getContent(at(acc.messages, 0)); + expect(content).toHaveLength(1); + expect(content[0]).toEqual( + expect.objectContaining({ + type: 'thinking', + thinking: 'Let me think about this...', + }), + ); + }); + + it('reports hasActiveStream during thinking block streaming', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput(messageStartEvent()), + eventOutput(thinkingBlockStart(0)), + eventOutput(thinkingDelta(0, 'thinking...')), + ]); + + expect(acc.hasActiveStream).toBe(true); + }); + }); + + // -- multiple content blocks in one message --------------------------------- + + describe('multiple content blocks in one message', () => { + it('accumulates text and tool_use in the same message', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput(messageStartEvent()), + eventOutput(textBlockStart(0)), + eventOutput(textDelta(0, 'I will search for that.')), + eventOutput(contentBlockStop(0)), + eventOutput(toolUseBlockStart(1, 'tool-1', 'search')), + eventOutput(inputJsonDelta(1, '{"q":"test"}')), + eventOutput(contentBlockStop(1)), + eventOutput(messageStop()), + ]); + + const content = getContent(at(acc.messages, 0)); + expect(content).toHaveLength(2); + expect(content[0]).toEqual(expect.objectContaining({ type: 'text', text: 'I will search for that.' })); + expect(content[1]).toEqual( + expect.objectContaining({ type: 'tool_use', id: 'tool-1', name: 'search', input: { q: 'test' } }), + ); + }); + + it('accumulates multiple text blocks at correct indices', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput(messageStartEvent()), + eventOutput(textBlockStart(0)), + eventOutput(textDelta(0, 'first')), + eventOutput(contentBlockStop(0)), + eventOutput(textBlockStart(1)), + eventOutput(textDelta(1, 'second')), + eventOutput(contentBlockStop(1)), + eventOutput(messageStop()), + ]); + + const content = getContent(at(acc.messages, 0)); + expect(content).toHaveLength(2); + expect(content[0]).toEqual(expect.objectContaining({ type: 'text', text: 'first' })); + expect(content[1]).toEqual(expect.objectContaining({ type: 'text', text: 'second' })); + }); + + it('accumulates thinking + text + tool_use in sequence', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput(messageStartEvent()), + eventOutput(thinkingBlockStart(0)), + eventOutput(thinkingDelta(0, 'reasoning')), + eventOutput(contentBlockStop(0)), + eventOutput(textBlockStart(1)), + eventOutput(textDelta(1, 'response')), + eventOutput(contentBlockStop(1)), + eventOutput(toolUseBlockStart(2, 'tc-1', 'action')), + eventOutput(inputJsonDelta(2, '{}')), + eventOutput(contentBlockStop(2)), + eventOutput(messageStop()), + ]); + + const content = getContent(at(acc.messages, 0)); + expect(content).toHaveLength(3); + expect(content[0]).toEqual(expect.objectContaining({ type: 'thinking' })); + expect(content[1]).toEqual(expect.objectContaining({ type: 'text' })); + expect(content[2]).toEqual(expect.objectContaining({ type: 'tool_use' })); + }); + }); + + // -- complete message handling ---------------------------------------------- + + describe('complete message handling', () => { + it('pushes Anthropic.SDKAssistantMessage directly to completed list', () => { + const acc = createAccumulator(); + const assistant = completeAssistantMessage('msg-a', [{ type: 'text', text: 'hello' }]); + + acc.processOutputs([messageOutput(assistant)]); + + expect(acc.messages).toHaveLength(1); + expect(acc.completedMessages).toHaveLength(1); + expect(acc.messages[0]).toBe(assistant); + }); + + it('pushes Anthropic.SDKUserMessage directly to completed list', () => { + const acc = createAccumulator(); + const user = userMessage('hello'); + + acc.processOutputs([messageOutput(user)]); + + expect(acc.messages).toHaveLength(1); + expect(acc.completedMessages).toHaveLength(1); + expect(acc.messages[0]).toBe(user); + }); + + it('inserts complete Anthropic.SDKAssistantMessage via event processing', () => { + const acc = createAccumulator(); + const assistant = completeAssistantMessage('msg-a', [{ type: 'text', text: 'complete' }]); + + acc.processOutputs([eventOutput(assistant, 'msg-a')]); + + expect(acc.messages).toHaveLength(1); + expect(acc.completedMessages).toHaveLength(1); + }); + + it('inserts Anthropic.SDKUserMessage via event processing', () => { + const acc = createAccumulator(); + const user = userMessage('hello'); + + acc.processOutputs([eventOutput(user, 'msg-u')]); + + expect(acc.messages).toHaveLength(1); + expect(acc.completedMessages).toHaveLength(1); + }); + + it('supersedes in-progress streaming message with complete message', () => { + const acc = createAccumulator(); + + // Start streaming + acc.processOutputs([ + eventOutput(messageStartEvent('msg-a'), 'msg-a'), + eventOutput(textBlockStart(0), 'msg-a'), + eventOutput(textDelta(0, 'partial'), 'msg-a'), + ]); + + expect(acc.messages).toHaveLength(1); + expect(acc.completedMessages).toHaveLength(0); + + // Complete message arrives — should supersede the in-progress one + const complete = completeAssistantMessage('msg-a', [{ type: 'text', text: 'full response' }]); + acc.processOutputs([eventOutput(complete, 'msg-a')]); + + expect(acc.messages).toHaveLength(1); + expect(acc.completedMessages).toHaveLength(1); + expect(acc.hasActiveStream).toBe(false); + }); + }); + + // -- message_delta handling ------------------------------------------------- + + describe('message_delta handling', () => { + it('updates stop_reason on the in-progress message', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput(messageStartEvent()), + eventOutput(textBlockStart(0)), + eventOutput(textDelta(0, 'text')), + eventOutput(contentBlockStop(0)), + eventOutput(messageDelta('end_turn')), + eventOutput(messageStop()), + ]); + + const inner = getInnerMessage(at(acc.messages, 0)); + expect(inner.stop_reason).toBe('end_turn'); + }); + + it('updates usage statistics', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput(messageStartEvent()), + eventOutput(textBlockStart(0)), + eventOutput(textDelta(0, 'text')), + eventOutput(contentBlockStop(0)), + eventOutput(messageDelta('end_turn', 50)), + eventOutput(messageStop()), + ]); + + const inner = getInnerMessage(at(acc.messages, 0)); + const usage = inner.usage as Record; + expect(usage.output_tokens).toBe(50); + }); + + it('ignores message_delta when no active message exists', () => { + const acc = createAccumulator(); + // Should not throw + acc.processOutputs([eventOutput(messageDelta('end_turn', 10))]); + expect(acc.messages).toHaveLength(0); + }); + }); + + // -- lazy message creation (mid-stream join) -------------------------------- + + describe('lazy message creation (mid-stream join)', () => { + it('creates shell message on content_block_start without prior message_start', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput(textBlockStart(0)), + eventOutput(textDelta(0, 'mid-stream')), + eventOutput(contentBlockStop(0)), + eventOutput(messageStop()), + ]); + + expect(acc.messages).toHaveLength(1); + const msg = at(acc.messages, 0); + expect(msg.type).toBe('assistant'); + + // Shell message should have the messageId as its id + const inner = getInnerMessage(msg); + expect(inner.id).toBe(DEFAULT_MSG_ID); + expect(inner.model).toBe('unknown'); + + const content = getContent(msg); + expect(content[0]).toEqual(expect.objectContaining({ type: 'text', text: 'mid-stream' })); + }); + + it('fills in shell correctly with subsequent events', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput(textBlockStart(0)), + eventOutput(textDelta(0, 'hello')), + eventOutput(contentBlockStop(0)), + eventOutput(toolUseBlockStart(1, 'tc-1', 'search')), + eventOutput(inputJsonDelta(1, '{"q":"test"}')), + eventOutput(contentBlockStop(1)), + eventOutput(messageStop()), + ]); + + const content = getContent(at(acc.messages, 0)); + expect(content).toHaveLength(2); + expect(content[0]).toEqual(expect.objectContaining({ type: 'text', text: 'hello' })); + expect(content[1]).toEqual( + expect.objectContaining({ type: 'tool_use', id: 'tc-1', name: 'search', input: { q: 'test' } }), + ); + }); + + it('handles content_block_delta before content_block_start (mid-stream edge case)', () => { + const acc = createAccumulator(); + // Delta arrives before block_start — the active message is lazily created + // but there is no contentBlock state for index 0, so delta is a no-op + acc.processOutputs([ + eventOutput( + makeStreamEvent({ + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'orphan' }, + }), + ), + ]); + + // The ensureActiveMessage is not called by _handleContentBlockDelta, + // and since no message_start or content_block_start occurred, there's no active message + // content_block_delta only processes if an active message already exists + expect(acc.messages).toHaveLength(0); + }); + }); + + // -- SDKResultMessage handling ---------------------------------------------- + + describe('SDKResultMessage handling', () => { + it('does not add result event to message list', () => { + const acc = createAccumulator(); + acc.processOutputs([ + // CAST: Synthetic SDKResultMessage — usage does not fully satisfy NonNullableUsage. + eventOutput({ + type: 'result', + subtype: 'success', + duration_ms: 100, + duration_api_ms: 80, + is_error: false, + num_turns: 1, + result: 'done', + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + stop_reason: null, + total_cost_usd: 0.01, + usage: { + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + modelUsage: {}, + permission_denials: [], + uuid: DEFAULT_UUID, + session_id: DEFAULT_SESSION_ID, + } as unknown as AgentCodecEvent), + ]); + + expect(acc.messages).toHaveLength(0); + }); + + it('cleans up active streams on result (abort/completion)', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput(messageStartEvent()), + eventOutput(textBlockStart(0)), + ]); + + expect(acc.hasActiveStream).toBe(true); + + acc.processOutputs([ + // CAST: Synthetic SDKResultMessage — usage does not fully satisfy NonNullableUsage. + eventOutput({ + type: 'result', + subtype: 'success', + duration_ms: 100, + duration_api_ms: 80, + is_error: false, + num_turns: 1, + result: 'done', + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + stop_reason: null, + total_cost_usd: 0.01, + usage: { + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + modelUsage: {}, + permission_denials: [], + uuid: DEFAULT_UUID, + session_id: DEFAULT_SESSION_ID, + } as unknown as AgentCodecEvent), + ]); + + // Result is a terminal signal — active streams are cleaned up + expect(acc.hasActiveStream).toBe(false); + }); + }); + + // -- tool_progress handling ------------------------------------------------- + + describe('tool_progress handling', () => { + it('does not add tool_progress event to message list', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput({ + type: 'tool_progress', + tool_use_id: 'tc-1', + tool_name: 'search', + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + parent_tool_use_id: null, + elapsed_time_seconds: 5, + uuid: DEFAULT_UUID, + session_id: DEFAULT_SESSION_ID, + } as AgentCodecEvent), + ]); + + expect(acc.messages).toHaveLength(0); + }); + }); + + // -- updateMessage ---------------------------------------------------------- + + describe('updateMessage', () => { + it('replaces existing message in completed list by uuid', () => { + const acc = createAccumulator(); + const original = completeAssistantMessage('msg-a', [{ type: 'text', text: 'original' }], { + uuid: 'uuid-1' as UUID, + }); + acc.processOutputs([messageOutput(original)]); + + const updated = completeAssistantMessage('msg-a', [{ type: 'text', text: 'updated' }], { + uuid: 'uuid-1' as UUID, + }); + acc.updateMessage(updated); + + expect(acc.messages).toHaveLength(1); + expect(acc.messages[0]).toBe(updated); + const content = getContent(at(acc.messages, 0)); + expect(content[0]).toEqual(expect.objectContaining({ text: 'updated' })); + }); + + it('does nothing for unknown message', () => { + const acc = createAccumulator(); + const unknown = completeAssistantMessage('unknown', [], { uuid: 'uuid-unknown' as UUID }); + acc.updateMessage(unknown); + expect(acc.messages).toHaveLength(0); + }); + + it('replaces message matched by session_id when uuid is absent', () => { + const acc = createAccumulator(); + const user = userMessage('hello', { sessionId: 'session-1' }); + // Anthropic.SDKUserMessage has optional uuid — when absent, fall back to session_id + delete (user as Record).uuid; + acc.processOutputs([messageOutput(user)]); + + const updated: Anthropic.SDKUserMessage = { + ...user, + message: { role: 'user', content: 'updated hello' }, + }; + acc.updateMessage(updated); + + expect(acc.messages).toHaveLength(1); + expect(acc.messages[0]).toBe(updated); + }); + }); + + // -- initMessage and completeMessage ---------------------------------------- + + describe('initMessage and completeMessage', () => { + it('creates active tracking for an existing completed message', () => { + const acc = createAccumulator(); + const assistant = completeAssistantMessage('msg-a', [{ type: 'text', text: 'hello' }]); + acc.processOutputs([messageOutput(assistant)]); + + // initMessage should re-activate it + acc.initMessage('msg-a', assistant); + + // Message should still be in the list (not duplicated) + expect(acc.messages).toHaveLength(1); + + // It's now active, so completedMessages excludes it + expect(acc.completedMessages).toHaveLength(0); + + // completeMessage should mark it done + acc.completeMessage('msg-a'); + expect(acc.completedMessages).toHaveLength(1); + }); + + it('syncs state when initMessage is called on an already-active message', () => { + const acc = createAccumulator(); + + // Start streaming + acc.processOutputs([ + eventOutput(messageStartEvent('msg-a'), 'msg-a'), + eventOutput(textBlockStart(0), 'msg-a'), + eventOutput(textDelta(0, 'partial'), 'msg-a'), + ]); + + // Create an externally updated version + const updated = completeAssistantMessage('msg-a', [{ type: 'text', text: 'externally updated' }]); + + // initMessage syncs the active state + acc.initMessage('msg-a', updated); + + // The message in the list should reflect the update + expect(acc.messages).toHaveLength(1); + const content = getContent(at(acc.messages, 0)); + expect(content[0]).toEqual(expect.objectContaining({ type: 'text', text: 'externally updated' })); + }); + + it('adds message to list if not already present', () => { + const acc = createAccumulator(); + const assistant = completeAssistantMessage('msg-new', [{ type: 'text', text: 'new' }]); + + acc.initMessage('msg-new', assistant); + + expect(acc.messages).toHaveLength(1); + const content = getContent(at(acc.messages, 0)); + expect(content[0]).toEqual(expect.objectContaining({ type: 'text', text: 'new' })); + }); + + it('completeMessage is a no-op for unknown messageId', () => { + const acc = createAccumulator(); + acc.completeMessage('nonexistent'); + expect(acc.messages).toHaveLength(0); + }); + + it('handles initMessage for user messages', () => { + const acc = createAccumulator(); + const user = userMessage('hello'); + acc.processOutputs([messageOutput(user)]); + + acc.initMessage('msg-u', user); + + // User messages don't create active state (no streaming), but should be in list + expect(acc.messages).toHaveLength(1); + }); + + it('replaces in-place when message with same identity exists', () => { + const acc = createAccumulator(); + const original = completeAssistantMessage('msg-a', [{ type: 'text', text: 'original' }]); + acc.processOutputs([messageOutput(original)]); + + const replacement = completeAssistantMessage('msg-a', [{ type: 'text', text: 'replaced' }]); + acc.initMessage('msg-a', replacement); + + expect(acc.messages).toHaveLength(1); + const content = getContent(at(acc.messages, 0)); + expect(content[0]).toEqual(expect.objectContaining({ type: 'text', text: 'replaced' })); + }); + + it('rebuilds content block tracking on already-active message', () => { + const acc = createAccumulator(); + + // Start streaming with one text block + acc.processOutputs([ + eventOutput(messageStartEvent('msg-a'), 'msg-a'), + eventOutput(textBlockStart(0), 'msg-a'), + eventOutput(textDelta(0, 'streaming'), 'msg-a'), + ]); + + expect(acc.hasActiveStream).toBe(true); + + // Sync with an externally updated message that has two blocks + const updated = completeAssistantMessage('msg-a', [ + { type: 'text', text: 'first block' }, + { type: 'tool_use', id: 'tc-1', name: 'search', input: { q: 'test' } }, + ]); + acc.initMessage('msg-a', updated); + + // Tracking should reflect the updated content — both blocks marked finished + expect(acc.hasActiveStream).toBe(false); + + // Content should reflect the synced message, not the old streaming state + const content = getContent(at(acc.messages, 0)); + expect(content).toHaveLength(2); + expect(content[0]).toEqual(expect.objectContaining({ type: 'text', text: 'first block' })); + expect(content[1]).toEqual(expect.objectContaining({ type: 'tool_use', id: 'tc-1' })); + }); + + it('matches assistant messages by BetaMessage ID, not session_id', () => { + const acc = createAccumulator(); + + // Add two assistant messages with different IDs but same session_id + const msgA = completeAssistantMessage('msg-a', [{ type: 'text', text: 'A' }], { + sessionId: 'shared-session', + }); + const msgB = completeAssistantMessage('msg-b', [{ type: 'text', text: 'B' }], { + sessionId: 'shared-session', + }); + acc.processOutputs([messageOutput(msgA), messageOutput(msgB)]); + + expect(acc.messages).toHaveLength(2); + + // initMessage for msg-b should replace only msg-b, not msg-a + const updatedB = completeAssistantMessage('msg-b', [{ type: 'text', text: 'B updated' }], { + sessionId: 'shared-session', + }); + acc.initMessage('msg-b', updatedB); + + // Should still have exactly 2 messages + expect(acc.messages).toHaveLength(2); + const contentA = getContent(at(acc.messages, 0)); + const contentB = getContent(at(acc.messages, 1)); + expect(contentA[0]).toEqual(expect.objectContaining({ text: 'A' })); + expect(contentB[0]).toEqual(expect.objectContaining({ text: 'B updated' })); + }); + }); + + // -- signature_delta handling ----------------------------------------------- + + describe('signature_delta handling', () => { + it('accumulates signature on thinking blocks', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput(messageStartEvent()), + eventOutput(thinkingBlockStart(0)), + eventOutput(thinkingDelta(0, 'reasoning')), + eventOutput( + makeStreamEvent({ + type: 'content_block_delta', + index: 0, + delta: { type: 'signature_delta', signature: 'sig-part-1' }, + }), + ), + eventOutput( + makeStreamEvent({ + type: 'content_block_delta', + index: 0, + delta: { type: 'signature_delta', signature: 'sig-part-2' }, + }), + ), + eventOutput(contentBlockStop(0)), + eventOutput(messageStop()), + ]); + + const content = getContent(at(acc.messages, 0)); + expect(content[0]).toEqual( + expect.objectContaining({ + type: 'thinking', + thinking: 'reasoning', + signature: 'sig-part-1sig-part-2', + }), + ); + }); + + it('ignores signature_delta for non-thinking blocks', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput(messageStartEvent()), + eventOutput(textBlockStart(0)), + eventOutput( + makeStreamEvent({ + type: 'content_block_delta', + index: 0, + delta: { type: 'signature_delta', signature: 'should-be-ignored' }, + }), + ), + eventOutput(contentBlockStop(0)), + eventOutput(messageStop()), + ]); + + const content = getContent(at(acc.messages, 0)); + // Text block has no signature field — delta should be a no-op + expect(content[0]).toEqual(expect.objectContaining({ type: 'text', text: '' })); + }); + }); + + // -- multiple concurrent messages ------------------------------------------- + + describe('multiple concurrent messages', () => { + it('routes interleaved events to separate messages by messageId', () => { + const acc = createAccumulator(); + + acc.processOutputs([ + eventOutput(messageStartEvent('msg-a', { uuid: 'uuid-a' as UUID }), 'msg-a'), + eventOutput(messageStartEvent('msg-b', { uuid: 'uuid-b' as UUID }), 'msg-b'), + eventOutput(textBlockStart(0), 'msg-a'), + eventOutput(textBlockStart(0), 'msg-b'), + eventOutput(textDelta(0, 'hello'), 'msg-a'), + eventOutput(textDelta(0, 'world'), 'msg-b'), + eventOutput(contentBlockStop(0), 'msg-a'), + eventOutput(contentBlockStop(0), 'msg-b'), + eventOutput(messageStop(), 'msg-a'), + eventOutput(messageStop(), 'msg-b'), + ]); + + expect(acc.messages).toHaveLength(2); + expect(acc.completedMessages).toHaveLength(2); + + const contentA = getContent(at(acc.messages, 0)); + const contentB = getContent(at(acc.messages, 1)); + expect(contentA[0]).toEqual(expect.objectContaining({ type: 'text', text: 'hello' })); + expect(contentB[0]).toEqual(expect.objectContaining({ type: 'text', text: 'world' })); + }); + + it('tracks active streams independently per message', () => { + const acc = createAccumulator(); + + acc.processOutputs([ + eventOutput(messageStartEvent('msg-a', { uuid: 'uuid-a' as UUID }), 'msg-a'), + eventOutput(messageStartEvent('msg-b', { uuid: 'uuid-b' as UUID }), 'msg-b'), + eventOutput(textBlockStart(0), 'msg-a'), + eventOutput(textBlockStart(0), 'msg-b'), + ]); + + expect(acc.hasActiveStream).toBe(true); + expect(acc.completedMessages).toHaveLength(0); + + // Finish message A only + acc.processOutputs([ + eventOutput(contentBlockStop(0), 'msg-a'), + eventOutput(messageStop(), 'msg-a'), + ]); + + expect(acc.hasActiveStream).toBe(true); // msg-b still streaming + expect(acc.completedMessages).toHaveLength(1); + + // Finish message B + acc.processOutputs([ + eventOutput(contentBlockStop(0), 'msg-b'), + eventOutput(messageStop(), 'msg-b'), + ]); + + expect(acc.hasActiveStream).toBe(false); + expect(acc.completedMessages).toHaveLength(2); + }); + + it('handles message_stop on one without affecting the other', () => { + const acc = createAccumulator(); + + acc.processOutputs([ + eventOutput(messageStartEvent('msg-a', { uuid: 'uuid-a' as UUID }), 'msg-a'), + eventOutput(messageStartEvent('msg-b', { uuid: 'uuid-b' as UUID }), 'msg-b'), + eventOutput(textBlockStart(0), 'msg-a'), + eventOutput(textBlockStart(0), 'msg-b'), + eventOutput(textDelta(0, 'partial-a'), 'msg-a'), + eventOutput(contentBlockStop(0), 'msg-a'), + eventOutput(messageStop(), 'msg-a'), + ]); + + // msg-a completed; msg-b still active + expect(acc.completedMessages).toHaveLength(1); + expect(acc.hasActiveStream).toBe(true); + + acc.processOutputs([ + eventOutput(textDelta(0, 'still going'), 'msg-b'), + eventOutput(contentBlockStop(0), 'msg-b'), + eventOutput(messageStop(), 'msg-b'), + ]); + + expect(acc.messages).toHaveLength(2); + expect(acc.completedMessages).toHaveLength(2); + expect(acc.hasActiveStream).toBe(false); + + const contentB = getContent(at(acc.messages, 1)); + expect(contentB[0]).toEqual(expect.objectContaining({ type: 'text', text: 'still going' })); + }); + }); + + // -- edge cases ------------------------------------------------------------- + + describe('edge cases', () => { + it('message_stop without prior message_start is a no-op', () => { + const acc = createAccumulator(); + acc.processOutputs([eventOutput(messageStop())]); + + expect(acc.messages).toHaveLength(0); + expect(acc.hasActiveStream).toBe(false); + }); + + it('content_block_stop without prior start is a no-op', () => { + const acc = createAccumulator(); + acc.processOutputs([eventOutput(contentBlockStop(0))]); + + expect(acc.messages).toHaveLength(0); + }); + + it('content_block_delta without active message is a no-op', () => { + const acc = createAccumulator(); + acc.processOutputs([eventOutput(textDelta(0, 'orphan'))]); + + expect(acc.messages).toHaveLength(0); + }); + + it('content_block_delta for unknown block index is a no-op', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput(messageStartEvent()), + eventOutput(textBlockStart(0)), + // Delta for index 5 which has no block state + eventOutput(textDelta(5, 'orphan')), + eventOutput(contentBlockStop(0)), + eventOutput(messageStop()), + ]); + + const content = getContent(at(acc.messages, 0)); + // Only index 0 should exist with empty text + expect(content).toHaveLength(1); + expect(content[0]).toEqual(expect.objectContaining({ type: 'text', text: '' })); + }); + + it('handles non-streaming content block types in default branch', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput(messageStartEvent()), + eventOutput( + makeStreamEvent({ + type: 'content_block_start', + index: 0, + content_block: { type: 'unknown_type', data: 'foo' }, + }), + ), + eventOutput(contentBlockStop(0)), + eventOutput(messageStop()), + ]); + + const content = getContent(at(acc.messages, 0)); + expect(content).toHaveLength(1); + expect(content[0]).toEqual(expect.objectContaining({ type: 'unknown_type', data: 'foo' })); + }); + + it('handles unknown stream event types gracefully', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput(messageStartEvent()), + eventOutput(makeStreamEvent({ type: 'some_future_event' })), + eventOutput(messageStop()), + ]); + + // Should not throw, message should still be completed + expect(acc.completedMessages).toHaveLength(1); + }); + + it('handles unknown delta types gracefully', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput(messageStartEvent()), + eventOutput(textBlockStart(0)), + eventOutput( + makeStreamEvent({ + type: 'content_block_delta', + index: 0, + delta: { type: 'citations_delta', citation: {} }, + }), + ), + eventOutput(contentBlockStop(0)), + eventOutput(messageStop()), + ]); + + // Should not throw, text should remain empty + const content = getContent(at(acc.messages, 0)); + expect(content[0]).toEqual(expect.objectContaining({ type: 'text', text: '' })); + }); + + it('ignores event outputs without messageId', () => { + const acc = createAccumulator(); + acc.processOutputs([ + { kind: 'event', event: messageStartEvent(), messageId: undefined }, + ]); + + expect(acc.messages).toHaveLength(0); + }); + + it('hasActiveStream is false when no messages exist', () => { + const acc = createAccumulator(); + expect(acc.hasActiveStream).toBe(false); + }); + + it('hasActiveStream is false with only completed messages', () => { + const acc = createAccumulator(); + acc.processOutputs([messageOutput(completeAssistantMessage('msg-a'))]); + expect(acc.hasActiveStream).toBe(false); + }); + + it('messages property returns completed then in-progress messages', () => { + const acc = createAccumulator(); + const user = userMessage('hello'); + acc.processOutputs([messageOutput(user)]); + + // Start a streaming message + acc.processOutputs([ + eventOutput(messageStartEvent('msg-stream'), 'msg-stream'), + eventOutput(textBlockStart(0), 'msg-stream'), + ]); + + expect(acc.messages).toHaveLength(2); + expect(acc.messages[0]).toBe(user); // completed first + expect(acc.messages[1]?.type).toBe('assistant'); // in-progress second + }); + + it('preserves parent_tool_use_id from outer event in message_start', () => { + const acc = createAccumulator(); + acc.processOutputs([ + eventOutput( + messageStartEvent('msg-nested', { parentToolUseId: 'parent-tool-1' }), + 'msg-nested', + ), + eventOutput(textBlockStart(0), 'msg-nested'), + eventOutput(textDelta(0, 'nested'), 'msg-nested'), + eventOutput(contentBlockStop(0), 'msg-nested'), + eventOutput(messageStop(), 'msg-nested'), + ]); + + const msg = at(acc.messages, 0) as Anthropic.SDKAssistantMessage; + expect(msg.parent_tool_use_id).toBe('parent-tool-1'); + }); + + it('preserves uuid and session_id from outer event in message_start', () => { + const acc = createAccumulator(); + const uuid = 'custom-uuid' as UUID; + acc.processOutputs([ + eventOutput( + messageStartEvent('msg-ids', { uuid, sessionId: 'custom-session' }), + 'msg-ids', + ), + eventOutput(messageStop(), 'msg-ids'), + ]); + + const msg = at(acc.messages, 0) as Anthropic.SDKAssistantMessage; + expect(msg.uuid).toBe(uuid); + expect(msg.session_id).toBe('custom-session'); + }); + }); +}); diff --git a/test/anthropic/codec/codec.integration.test.ts b/test/anthropic/codec/codec.integration.test.ts new file mode 100644 index 00000000..768c2cee --- /dev/null +++ b/test/anthropic/codec/codec.integration.test.ts @@ -0,0 +1,1184 @@ +/** + * Anthropic AgentCodec integration tests. + * + * Validate encode -> publish -> subscribe -> decode -> accumulate roundtrips + * over real Ably channels using message appends. These tests prove the + * wire format and Ably message serialization work end-to-end without + * transport machinery. + * + * Each test uses a unique channel name in the `mutable:` namespace and + * a dedicated Ably client pair (publisher + subscriber) to avoid crosstalk. + * The sandbox app is created by the globalSetup in test-setup.ts. + */ + +import type { UUID } from 'node:crypto'; + +import type * as Anthropic from '@anthropic-ai/claude-agent-sdk'; +import type * as Ably from 'ably'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { AgentCodec } from '../../../src/anthropic/codec/index.js'; +import type { AgentCodecEvent, AgentMessage } from '../../../src/anthropic/codec/types.js'; +import { HEADER_MSG_ID, HEADER_TURN_ID } from '../../../src/constants.js'; +import type { DecoderOutput } from '../../../src/core/codec/types.js'; +import { uniqueChannelName } from '../../helper/identifier.js'; +import { ablyRealtimeClient, closeAllClients } from '../../helper/realtime-client.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +type Out = DecoderOutput; + +/** + * Extract events from decoder outputs. + * @param outputs - Decoder outputs to extract from. + * @returns Array of AgentCodecEvent events. + */ +const eventsOf = (outputs: Out[]): AgentCodecEvent[] => + outputs.filter((o): o is Extract => o.kind === 'event').map((o) => o.event); + +/** + * Extract messages from decoder outputs. + * @param outputs - Decoder outputs to extract from. + * @returns Array of AgentMessage messages. + */ +const messagesOf = (outputs: Out[]): AgentMessage[] => + outputs.filter((o): o is Extract => o.kind === 'message').map((o) => o.message); + +/** + * Create an onMessage hook that stamps turn and message ID headers + * on every outgoing Ably message. + * @param turnId - The turn ID to stamp. + * @param messageId - The message ID to stamp. + * @returns An onMessage callback for encoder options. + */ +const stampHeaders = (turnId: string, messageId: string) => (msg: Ably.Message) => { + // CAST: Ably SDK types `extras` as `any`; we trust the encoder always sets it. + const headers = (msg.extras as { headers?: Record } | undefined)?.headers; + if (headers) { + headers[HEADER_TURN_ID] = turnId; + headers[HEADER_MSG_ID] = messageId; + } +}; + +/** + * Wrap a BetaRawMessageStreamEvent in an SDKPartialAssistantMessage envelope. + * @param event - The inner stream event record. + * @returns An SDKPartialAssistantMessage suitable for the encoder. + */ +const makeStreamEvent = (event: Record): Anthropic.SDKPartialAssistantMessage => ({ + type: 'stream_event', + event: event as unknown as Anthropic.SDKPartialAssistantMessage['event'], + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + parent_tool_use_id: null, + uuid: 'test-uuid' as UUID, + session_id: 'test-session', +}); + +/** + * Construct a minimal BetaUsage object with all required fields. + * @returns A Record with the BetaUsage shape. + */ +const makeBetaUsage = (): Record => ({ + input_tokens: 10, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + cache_creation: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + inference_geo: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + iterations: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + server_tool_use: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + service_tier: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + speed: null, +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Anthropic AgentCodec integration', () => { + afterEach(() => { + closeAllClients(); + }); + + /** + * Scenario 1: Text response roundtrip + * + * Encodes message_start -> content_block_start(text) -> content_block_delta(text_delta) x 3 + * -> content_block_stop -> message_delta(stop_reason:'end_turn') -> message_stop, + * then publishes a result event. Verifies the decoder+accumulator reconstruct + * an SDKAssistantMessage with the correct text in content[0]. + */ + it('text response roundtrip', async () => { + const channelName = uniqueChannelName('anth-text-roundtrip'); + const pubClient = ablyRealtimeClient(); + const subClient = ablyRealtimeClient(); + + const pubChannel = pubClient.channels.get(channelName); + const subChannel = subClient.channels.get(channelName); + + const decoder = AgentCodec.createDecoder(); + const accumulator = AgentCodec.createAccumulator(); + + const messageId = 'msg-text-1'; + + const allOutputs: Out[] = []; + let resolveFinish: () => void; + const finished = new Promise((r) => { + resolveFinish = r; + }); + + await subChannel.subscribe((msg) => { + const outputs = decoder.decode(msg); + allOutputs.push(...outputs); + accumulator.processOutputs(outputs); + + if (eventsOf(outputs).some((e) => e.type === 'result')) { + resolveFinish(); + } + }); + + const encoder = AgentCodec.createEncoder(pubChannel, { + onMessage: stampHeaders('turn-1', messageId), + }); + + // message_start + await encoder.appendEvent( + makeStreamEvent({ + type: 'message_start', + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-20250514', + content: [], + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + stop_reason: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + stop_sequence: null, + usage: makeBetaUsage(), + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + container: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + context_management: null, + }, + }), + ); + + // content_block_start (text) + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 0, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + content_block: { type: 'text', text: '', citations: null }, + }), + ); + + // content_block_delta (text_delta) x 3 + void encoder.appendEvent( + makeStreamEvent({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hello' } }), + ); + void encoder.appendEvent( + makeStreamEvent({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: ', ' } }), + ); + void encoder.appendEvent( + makeStreamEvent({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'world!' } }), + ); + + // content_block_stop + await encoder.appendEvent(makeStreamEvent({ type: 'content_block_stop', index: 0 })); + + // message_delta + await encoder.appendEvent( + makeStreamEvent({ + type: 'message_delta', + delta: { stop_reason: 'end_turn' }, + usage: { output_tokens: 5 }, + }), + ); + + // message_stop + await encoder.appendEvent(makeStreamEvent({ type: 'message_stop' })); + + // result (terminal) + // CAST: Minimal SDKResultMessage for the terminal signal. + await encoder.appendEvent({ + type: 'result', + subtype: 'success', + duration_ms: 100, + duration_api_ms: 80, + is_error: false, + num_turns: 1, + stop_reason: 'end_turn', + total_cost_usd: 0.01, + usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + modelUsage: {}, + permission_denials: [], + errors: [], + uuid: 'test-uuid' as UUID, + session_id: 'test-session', + } as unknown as Anthropic.SDKResultMessage); + + await encoder.close(); + await finished; + + // Verify events include expected stream event types + const streamEvents = eventsOf(allOutputs).filter( + (e): e is Anthropic.SDKPartialAssistantMessage => e.type === 'stream_event', + ); + const innerTypes = streamEvents.map((e) => (e.event as unknown as Record).type); + expect(innerTypes).toContain('message_start'); + expect(innerTypes).toContain('content_block_start'); + expect(innerTypes).toContain('content_block_delta'); + expect(innerTypes).toContain('content_block_stop'); + expect(innerTypes).toContain('message_delta'); + expect(innerTypes).toContain('message_stop'); + + // Verify result event + expect(eventsOf(allOutputs).some((e) => e.type === 'result')).toBe(true); + + // Verify accumulated message + expect(accumulator.completedMessages).toHaveLength(1); + const [msg] = accumulator.completedMessages; + expect(msg).toBeDefined(); + expect(msg?.type).toBe('assistant'); + + // CAST: Narrow to SDKAssistantMessage to access .message.content. + const assistantMsg = msg as Anthropic.SDKAssistantMessage; + const textBlock = assistantMsg.message.content[0] as unknown as Record; + expect(textBlock.type).toBe('text'); + expect(textBlock.text).toBe('Hello, world!'); + expect(accumulator.hasActiveStream).toBe(false); + }); + + /** + * Scenario 2: Tool call roundtrip + * + * Encodes a tool_use content block with streaming input_json_delta, then + * content_block_stop. Verifies the accumulated message has a tool_use + * content block with parsed JSON input. + */ + it('tool call roundtrip', async () => { + const channelName = uniqueChannelName('anth-tool-roundtrip'); + const pubClient = ablyRealtimeClient(); + const subClient = ablyRealtimeClient(); + + const pubChannel = pubClient.channels.get(channelName); + const subChannel = subClient.channels.get(channelName); + + const decoder = AgentCodec.createDecoder(); + const accumulator = AgentCodec.createAccumulator(); + + const messageId = 'msg-tool-1'; + + const allOutputs: Out[] = []; + let resolveFinish: () => void; + const finished = new Promise((r) => { + resolveFinish = r; + }); + + await subChannel.subscribe((msg) => { + const outputs = decoder.decode(msg); + allOutputs.push(...outputs); + accumulator.processOutputs(outputs); + + if (eventsOf(outputs).some((e) => e.type === 'result')) { + resolveFinish(); + } + }); + + const encoder = AgentCodec.createEncoder(pubChannel, { + onMessage: stampHeaders('turn-tool-1', messageId), + }); + + // message_start + await encoder.appendEvent( + makeStreamEvent({ + type: 'message_start', + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-20250514', + content: [], + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + stop_reason: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + stop_sequence: null, + usage: makeBetaUsage(), + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + container: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + context_management: null, + }, + }), + ); + + // content_block_start (tool_use) + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 0, + content_block: { type: 'tool_use', id: 'toolu_01', name: 'get_weather', input: {} }, + }), + ); + + // content_block_delta (input_json_delta) x 2 + void encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_delta', + index: 0, + delta: { type: 'input_json_delta', partial_json: '{"loc' }, + }), + ); + void encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_delta', + index: 0, + delta: { type: 'input_json_delta', partial_json: 'ation":"SF"}' }, + }), + ); + + // content_block_stop + await encoder.appendEvent(makeStreamEvent({ type: 'content_block_stop', index: 0 })); + + // message_delta + await encoder.appendEvent( + makeStreamEvent({ + type: 'message_delta', + delta: { stop_reason: 'tool_use' }, + usage: { output_tokens: 10 }, + }), + ); + + // message_stop + await encoder.appendEvent(makeStreamEvent({ type: 'message_stop' })); + + // result (terminal) + await encoder.appendEvent({ + type: 'result', + subtype: 'success', + duration_ms: 100, + duration_api_ms: 80, + is_error: false, + num_turns: 1, + stop_reason: 'tool_use', + total_cost_usd: 0.01, + usage: { input_tokens: 10, output_tokens: 10, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + modelUsage: {}, + permission_denials: [], + errors: [], + uuid: 'test-uuid' as UUID, + session_id: 'test-session', + } as unknown as Anthropic.SDKResultMessage); + + await encoder.close(); + await finished; + + expect(accumulator.completedMessages).toHaveLength(1); + const [msg] = accumulator.completedMessages; + expect(msg?.type).toBe('assistant'); + + const assistantMsg = msg as Anthropic.SDKAssistantMessage; + const toolBlock = assistantMsg.message.content[0] as unknown as Record; + expect(toolBlock.type).toBe('tool_use'); + expect(toolBlock.name).toBe('get_weather'); + expect(toolBlock.id).toBe('toolu_01'); + expect(toolBlock.input).toEqual({ location: 'SF' }); + expect(accumulator.hasActiveStream).toBe(false); + }); + + /** + * Scenario 3: Thinking block roundtrip + * + * Encodes a thinking content block with thinking_delta events. Verifies + * accumulated message has thinking content with correct text. + */ + it('thinking block roundtrip', async () => { + const channelName = uniqueChannelName('anth-thinking-roundtrip'); + const pubClient = ablyRealtimeClient(); + const subClient = ablyRealtimeClient(); + + const pubChannel = pubClient.channels.get(channelName); + const subChannel = subClient.channels.get(channelName); + + const decoder = AgentCodec.createDecoder(); + const accumulator = AgentCodec.createAccumulator(); + + const messageId = 'msg-thinking-1'; + + const allOutputs: Out[] = []; + let resolveFinish: () => void; + const finished = new Promise((r) => { + resolveFinish = r; + }); + + await subChannel.subscribe((msg) => { + const outputs = decoder.decode(msg); + allOutputs.push(...outputs); + accumulator.processOutputs(outputs); + + if (eventsOf(outputs).some((e) => e.type === 'result')) { + resolveFinish(); + } + }); + + const encoder = AgentCodec.createEncoder(pubChannel, { + onMessage: stampHeaders('turn-thinking-1', messageId), + }); + + // message_start + await encoder.appendEvent( + makeStreamEvent({ + type: 'message_start', + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-20250514', + content: [], + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + stop_reason: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + stop_sequence: null, + usage: makeBetaUsage(), + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + container: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + context_management: null, + }, + }), + ); + + // content_block_start (thinking) + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 0, + content_block: { type: 'thinking', thinking: '', signature: '' }, + }), + ); + + // content_block_delta (thinking_delta) x 2 + void encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta', thinking: 'Let me ' }, + }), + ); + void encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta', thinking: 'think...' }, + }), + ); + + // content_block_stop + await encoder.appendEvent(makeStreamEvent({ type: 'content_block_stop', index: 0 })); + + // Then a text block follows + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 1, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + content_block: { type: 'text', text: '', citations: null }, + }), + ); + + void encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_delta', + index: 1, + delta: { type: 'text_delta', text: 'The answer is 42.' }, + }), + ); + + await encoder.appendEvent(makeStreamEvent({ type: 'content_block_stop', index: 1 })); + + // message_delta + message_stop + result + await encoder.appendEvent( + makeStreamEvent({ + type: 'message_delta', + delta: { stop_reason: 'end_turn' }, + usage: { output_tokens: 15 }, + }), + ); + await encoder.appendEvent(makeStreamEvent({ type: 'message_stop' })); + await encoder.appendEvent({ + type: 'result', + subtype: 'success', + duration_ms: 100, + duration_api_ms: 80, + is_error: false, + num_turns: 1, + stop_reason: 'end_turn', + total_cost_usd: 0.01, + usage: { input_tokens: 10, output_tokens: 15, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + modelUsage: {}, + permission_denials: [], + errors: [], + uuid: 'test-uuid' as UUID, + session_id: 'test-session', + } as unknown as Anthropic.SDKResultMessage); + + await encoder.close(); + await finished; + + expect(accumulator.completedMessages).toHaveLength(1); + const [msg] = accumulator.completedMessages; + expect(msg?.type).toBe('assistant'); + + const assistantMsg = msg as Anthropic.SDKAssistantMessage; + const thinkingBlock = assistantMsg.message.content[0] as unknown as Record; + expect(thinkingBlock.type).toBe('thinking'); + expect(thinkingBlock.thinking).toBe('Let me think...'); + + const textBlock = assistantMsg.message.content[1] as unknown as Record; + expect(textBlock.type).toBe('text'); + expect(textBlock.text).toBe('The answer is 42.'); + expect(accumulator.hasActiveStream).toBe(false); + }); + + /** + * Scenario 4: Abort mid-stream + * + * Starts a text stream, sends some deltas, then calls encoder.abort(). + * Verifies the decoder surfaces the abort (as a result event) and the + * accumulator handles it. + */ + it('abort mid-stream', async () => { + const channelName = uniqueChannelName('anth-abort'); + const pubClient = ablyRealtimeClient(); + const subClient = ablyRealtimeClient(); + + const pubChannel = pubClient.channels.get(channelName); + const subChannel = subClient.channels.get(channelName); + + const decoder = AgentCodec.createDecoder(); + const accumulator = AgentCodec.createAccumulator(); + + const messageId = 'msg-abort-1'; + + const allOutputs: Out[] = []; + let resolveAbort: () => void; + const aborted = new Promise((r) => { + resolveAbort = r; + }); + + await subChannel.subscribe((msg) => { + const outputs = decoder.decode(msg); + allOutputs.push(...outputs); + accumulator.processOutputs(outputs); + + // The abort is surfaced as a result event with is_error true + if (eventsOf(outputs).some((e) => e.type === 'result')) { + resolveAbort(); + } + }); + + const encoder = AgentCodec.createEncoder(pubChannel, { + onMessage: stampHeaders('turn-abort-1', messageId), + }); + + // message_start + await encoder.appendEvent( + makeStreamEvent({ + type: 'message_start', + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-20250514', + content: [], + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + stop_reason: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + stop_sequence: null, + usage: makeBetaUsage(), + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + container: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + context_management: null, + }, + }), + ); + + // content_block_start (text) + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 0, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + content_block: { type: 'text', text: '', citations: null }, + }), + ); + + // Some deltas + void encoder.appendEvent( + makeStreamEvent({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hello' } }), + ); + void encoder.appendEvent( + makeStreamEvent({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: ', wo' } }), + ); + + // Abort + await encoder.abort('user cancelled'); + await encoder.close(); + + await aborted; + + // Verify we got stream events and the abort result + const streamEvents = eventsOf(allOutputs).filter( + (e): e is Anthropic.SDKPartialAssistantMessage => e.type === 'stream_event', + ); + const innerTypes = streamEvents.map((e) => (e.event as unknown as Record).type); + expect(innerTypes).toContain('content_block_start'); + expect(innerTypes).toContain('content_block_delta'); + + // The abort produces a result event with is_error true + const resultEvent = eventsOf(allOutputs).find( + (e): e is Anthropic.SDKResultMessage => e.type === 'result', + ); + expect(resultEvent).toBeDefined(); + expect(resultEvent?.is_error).toBe(true); + expect(accumulator.hasActiveStream).toBe(false); + }); + + /** + * Scenario 5: History hydration via channel history + * + * Publishes a complete text stream, then fetches channel history + * and feeds it through a fresh decoder + accumulator. Verifies + * the decoder handles history messages correctly. + */ + it('history hydration', async () => { + const channelName = uniqueChannelName('anth-history'); + const pubClient = ablyRealtimeClient(); + + const pubChannel = pubClient.channels.get(channelName); + + const messageId = 'msg-hist-1'; + + const encoder = AgentCodec.createEncoder(pubChannel, { + onMessage: stampHeaders('turn-hist-1', messageId), + }); + + // Publish a complete text stream + await encoder.appendEvent( + makeStreamEvent({ + type: 'message_start', + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-20250514', + content: [], + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + stop_reason: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + stop_sequence: null, + usage: makeBetaUsage(), + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + container: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + context_management: null, + }, + }), + ); + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 0, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + content_block: { type: 'text', text: '', citations: null }, + }), + ); + void encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'History ' }, + }), + ); + void encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'test.' }, + }), + ); + await encoder.appendEvent(makeStreamEvent({ type: 'content_block_stop', index: 0 })); + await encoder.appendEvent( + makeStreamEvent({ + type: 'message_delta', + delta: { stop_reason: 'end_turn' }, + usage: { output_tokens: 5 }, + }), + ); + await encoder.appendEvent(makeStreamEvent({ type: 'message_stop' })); + await encoder.appendEvent({ + type: 'result', + subtype: 'success', + duration_ms: 100, + duration_api_ms: 80, + is_error: false, + num_turns: 1, + stop_reason: 'end_turn', + total_cost_usd: 0.01, + usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + modelUsage: {}, + permission_denials: [], + errors: [], + uuid: 'test-uuid' as UUID, + session_id: 'test-session', + } as unknown as Anthropic.SDKResultMessage); + await encoder.close(); + + // Wait for Ably's history API to become consistent -- real network propagation + // cannot be flushed with microtasks. + await new Promise((r) => setTimeout(r, 1000)); + + const histClient = ablyRealtimeClient(); + const histChannel = histClient.channels.get(channelName); + + const historyPage = await histChannel.history({ direction: 'forwards' }); + const historyMessages = historyPage.items; + + expect(historyMessages.length).toBeGreaterThan(0); + + const decoder = AgentCodec.createDecoder(); + const accumulator = AgentCodec.createAccumulator(); + + for (const msg of historyMessages) { + const outputs = decoder.decode(msg); + accumulator.processOutputs(outputs); + } + + expect(accumulator.messages.length).toBeGreaterThanOrEqual(1); + + // Find the assistant message with the text content + const assistantMsgs = accumulator.messages.filter( + (m): m is Anthropic.SDKAssistantMessage => m.type === 'assistant', + ); + expect(assistantMsgs.length).toBeGreaterThanOrEqual(1); + + const textMsg = assistantMsgs.find((m) => + m.message.content.some((block) => { + const b = block as unknown as Record; + return b.type === 'text' && typeof b.text === 'string' && b.text.includes('History test.'); + }), + ); + expect(textMsg).toBeDefined(); + }); + + /** + * Scenario 6: Multi-client sync + * + * Two subscribers on the same channel both receive a streamed response. + * Verifies both decoders/accumulators reconstruct the same message. + */ + it('multi-client sync', async () => { + const channelName = uniqueChannelName('anth-multi-client'); + const pubClient = ablyRealtimeClient(); + const sub1Client = ablyRealtimeClient(); + const sub2Client = ablyRealtimeClient(); + + const pubChannel = pubClient.channels.get(channelName); + const sub1Channel = sub1Client.channels.get(channelName); + const sub2Channel = sub2Client.channels.get(channelName); + + const decoder1 = AgentCodec.createDecoder(); + const accumulator1 = AgentCodec.createAccumulator(); + const decoder2 = AgentCodec.createDecoder(); + const accumulator2 = AgentCodec.createAccumulator(); + + const messageId = 'msg-multi-1'; + + let resolve1: () => void; + let resolve2: () => void; + const finished1 = new Promise((r) => { + resolve1 = r; + }); + const finished2 = new Promise((r) => { + resolve2 = r; + }); + + await sub1Channel.subscribe((msg) => { + const outputs = decoder1.decode(msg); + accumulator1.processOutputs(outputs); + if (eventsOf(outputs).some((e) => e.type === 'result')) resolve1(); + }); + + await sub2Channel.subscribe((msg) => { + const outputs = decoder2.decode(msg); + accumulator2.processOutputs(outputs); + if (eventsOf(outputs).some((e) => e.type === 'result')) resolve2(); + }); + + const encoder = AgentCodec.createEncoder(pubChannel, { + onMessage: stampHeaders('turn-multi-1', messageId), + }); + + // Stream a text response + await encoder.appendEvent( + makeStreamEvent({ + type: 'message_start', + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-20250514', + content: [], + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + stop_reason: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + stop_sequence: null, + usage: makeBetaUsage(), + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + container: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + context_management: null, + }, + }), + ); + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 0, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + content_block: { type: 'text', text: '', citations: null }, + }), + ); + void encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'Sync ' }, + }), + ); + void encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'test.' }, + }), + ); + await encoder.appendEvent(makeStreamEvent({ type: 'content_block_stop', index: 0 })); + await encoder.appendEvent( + makeStreamEvent({ + type: 'message_delta', + delta: { stop_reason: 'end_turn' }, + usage: { output_tokens: 5 }, + }), + ); + await encoder.appendEvent(makeStreamEvent({ type: 'message_stop' })); + await encoder.appendEvent({ + type: 'result', + subtype: 'success', + duration_ms: 100, + duration_api_ms: 80, + is_error: false, + num_turns: 1, + stop_reason: 'end_turn', + total_cost_usd: 0.01, + usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + modelUsage: {}, + permission_denials: [], + errors: [], + uuid: 'test-uuid' as UUID, + session_id: 'test-session', + } as unknown as Anthropic.SDKResultMessage); + await encoder.close(); + + await Promise.all([finished1, finished2]); + + expect(accumulator1.completedMessages).toHaveLength(1); + expect(accumulator2.completedMessages).toHaveLength(1); + + const msg1 = accumulator1.completedMessages[0] as Anthropic.SDKAssistantMessage; + const msg2 = accumulator2.completedMessages[0] as Anthropic.SDKAssistantMessage; + + const text1 = (msg1.message.content[0] as unknown as Record).text; + const text2 = (msg2.message.content[0] as unknown as Record).text; + expect(text1).toBe('Sync test.'); + expect(text2).toBe('Sync test.'); + }); + + /** + * Scenario 7: Complete assistant message (non-streaming) + * + * Publishes a complete SDKAssistantMessage via appendEvent (simulating + * non-streaming mode). Verifies it's decoded as a kind:'message' output. + */ + it('complete assistant message (non-streaming)', async () => { + const channelName = uniqueChannelName('anth-complete-msg'); + const pubClient = ablyRealtimeClient(); + const subClient = ablyRealtimeClient(); + + const pubChannel = pubClient.channels.get(channelName); + const subChannel = subClient.channels.get(channelName); + + const decoder = AgentCodec.createDecoder(); + const accumulator = AgentCodec.createAccumulator(); + + const messageId = 'msg-complete-1'; + + const allOutputs: Out[] = []; + let resolveMessage: () => void; + const gotMessage = new Promise((r) => { + resolveMessage = r; + }); + + await subChannel.subscribe((msg) => { + const outputs = decoder.decode(msg); + allOutputs.push(...outputs); + accumulator.processOutputs(outputs); + + if (messagesOf(outputs).some((m) => m.type === 'assistant')) { + resolveMessage(); + } + }); + + const encoder = AgentCodec.createEncoder(pubChannel, { + onMessage: stampHeaders('turn-complete-1', messageId), + }); + + // Publish a complete assistant message + const completeMessage: Anthropic.SDKAssistantMessage = { + type: 'assistant', + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-20250514', + content: [ + // CAST: Text content block for the non-streaming message. + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + { type: 'text', text: 'Complete message.', citations: null } as unknown as Anthropic.SDKAssistantMessage['message']['content'][number], + ], + stop_reason: 'end_turn', + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + stop_sequence: null, + usage: makeBetaUsage() as unknown as Anthropic.SDKAssistantMessage['message']['usage'], + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + container: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + context_management: null, + } as Anthropic.SDKAssistantMessage['message'], + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + parent_tool_use_id: null, + uuid: 'uuid-complete' as UUID, + session_id: 'test-session', + }; + + await encoder.appendEvent(completeMessage); + await encoder.close(); + + await gotMessage; + + const messages = messagesOf(allOutputs); + expect(messages).toHaveLength(1); + const [msg] = messages; + expect(msg?.type).toBe('assistant'); + + const assistantMsg = msg as Anthropic.SDKAssistantMessage; + const textBlock = assistantMsg.message.content[0] as unknown as Record; + expect(textBlock.type).toBe('text'); + expect(textBlock.text).toBe('Complete message.'); + }); + + /** + * Scenario 8: User message roundtrip via writeMessages + * + * Encodes an SDKUserMessage via writeMessages, verifies it's decoded + * as a kind:'message' with correct fields. + */ + it('user message roundtrip via writeMessages', async () => { + const channelName = uniqueChannelName('anth-user-msg'); + const pubClient = ablyRealtimeClient(); + const subClient = ablyRealtimeClient(); + + const pubChannel = pubClient.channels.get(channelName); + const subChannel = subClient.channels.get(channelName); + + const decoder = AgentCodec.createDecoder(); + const accumulator = AgentCodec.createAccumulator(); + + const allOutputs: Out[] = []; + let resolveMessage: () => void; + const gotMessage = new Promise((r) => { + resolveMessage = r; + }); + + await subChannel.subscribe((msg) => { + const outputs = decoder.decode(msg); + allOutputs.push(...outputs); + accumulator.processOutputs(outputs); + + if (messagesOf(outputs).some((m) => m.type === 'user')) { + resolveMessage(); + } + }); + + const encoder = AgentCodec.createEncoder(pubChannel, { + onMessage: stampHeaders('turn-user-1', 'user-msg-1'), + }); + + const userMessage: Anthropic.SDKUserMessage = { + type: 'user', + // CAST: User message payload — a simple string content for the test. + message: { role: 'user', content: 'Hello from the user' } as Anthropic.SDKUserMessage['message'], + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + parent_tool_use_id: null, + isSynthetic: false, + uuid: 'uuid-user-1' as UUID, + session_id: 'test-session', + }; + + await encoder.writeMessages([userMessage]); + await encoder.close(); + + await gotMessage; + + const messages = messagesOf(allOutputs); + expect(messages).toHaveLength(1); + const [msg] = messages; + expect(msg?.type).toBe('user'); + + const userMsg = msg as Anthropic.SDKUserMessage; + // CAST: message.content is a union type; cast to access the string value. + const content = userMsg.message as unknown as Record; + expect(content.content).toBe('Hello from the user'); + expect(userMsg.isSynthetic).toBe(false); + expect(userMsg.session_id).toBe('test-session'); + }); + + /** + * Scenario 9: Error result mid-stream + * + * Starts streaming text, then publishes an SDKResultMessage with + * is_error: true (simulating a rate limit or other error). Verifies + * the error result event propagates correctly through the wire and + * that the accumulator cleans up active streams. + * + * Mirrors the Vercel codec's "error propagation mid-stream" test: + * uses fire-and-forget deltas (matching production behavior) and + * asserts on the error event delivery, not on partial text content. + */ + it('error result mid-stream', async () => { + const channelName = uniqueChannelName('anth-error-result'); + const pubClient = ablyRealtimeClient(); + const subClient = ablyRealtimeClient(); + + const pubChannel = pubClient.channels.get(channelName); + const subChannel = subClient.channels.get(channelName); + + const decoder = AgentCodec.createDecoder(); + const accumulator = AgentCodec.createAccumulator(); + + const messageId = 'msg-err-1'; + + const allOutputs: Out[] = []; + let resolveResult: () => void; + const gotResult = new Promise((r) => { + resolveResult = r; + }); + + await subChannel.subscribe((msg) => { + const outputs = decoder.decode(msg); + allOutputs.push(...outputs); + accumulator.processOutputs(outputs); + + if (eventsOf(outputs).some((e) => e.type === 'result')) { + resolveResult(); + } + }); + + const encoder = AgentCodec.createEncoder(pubChannel, { + onMessage: stampHeaders('turn-err-1', messageId), + }); + + // message_start + await encoder.appendEvent( + makeStreamEvent({ + type: 'message_start', + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-20250514', + content: [], + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + stop_reason: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + stop_sequence: null, + usage: makeBetaUsage(), + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + container: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + context_management: null, + }, + }), + ); + + // content_block_start (text) + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 0, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + content_block: { type: 'text', text: '', citations: null }, + }), + ); + + // Fire-and-forget delta — matches production behavior where deltas are + // not awaited for performance. The delta may or may not be delivered + // before the result event arrives. + void encoder.appendEvent( + makeStreamEvent({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Partial...' } }), + ); + + // Error result — simulates a rate limit or other error mid-execution + await encoder.appendEvent({ + type: 'result', + subtype: 'error_during_execution', + duration_ms: 50, + duration_api_ms: 40, + is_error: true, + num_turns: 1, + stop_reason: 'error', + total_cost_usd: 0.005, + usage: { input_tokens: 10, output_tokens: 2, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + modelUsage: {}, + permission_denials: [], + errors: ['rate limit exceeded'], + uuid: 'test-uuid' as UUID, + session_id: 'test-session', + } as unknown as Anthropic.SDKResultMessage); + + await encoder.close(); + await gotResult; + + // Verify the error result event propagated with correct fields + const resultEvent = eventsOf(allOutputs).find( + (e): e is Anthropic.SDKResultMessage => e.type === 'result', + ); + expect(resultEvent).toBeDefined(); + expect(resultEvent?.is_error).toBe(true); + expect(resultEvent?.subtype).toBe('error_during_execution'); + + // Verify hasActiveStream is false (result cleans up active streams) + expect(accumulator.hasActiveStream).toBe(false); + }); +}); diff --git a/test/anthropic/codec/decoder.test.ts b/test/anthropic/codec/decoder.test.ts new file mode 100644 index 00000000..51454ccd --- /dev/null +++ b/test/anthropic/codec/decoder.test.ts @@ -0,0 +1,1485 @@ +import type { UUID } from 'node:crypto'; + +import type * as Anthropic from '@anthropic-ai/claude-agent-sdk'; +import type * as Ably from 'ably'; +import { describe, expect, it } from 'vitest'; + +import { createDecoder } from '../../../src/anthropic/codec/decoder.js'; +import type { AgentCodecEvent, AgentMessage } from '../../../src/anthropic/codec/types.js'; +import { + DOMAIN_HEADER_PREFIX as D, + HEADER_MSG_ID, + HEADER_ROLE, + HEADER_STATUS, + HEADER_STREAM, + HEADER_STREAM_ID, + HEADER_TURN_ID, +} from '../../../src/constants.js'; +import type { DecoderOutput } from '../../../src/core/codec/types.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +type Out = DecoderOutput; + +// Retrieve an array element, throwing if it is undefined. +const at = (arr: T[], index: number): T => { + const item = arr[index]; + if (item === undefined) throw new Error(`expected element at index ${String(index)}`); + return item; +}; + +const withHeaders = ( + msg: Partial, + headers: Record, +): Ably.InboundMessage => + ({ + serial: 'serial-1', + action: 'message.create', + name: 'text', + data: '', + ...msg, + extras: { headers }, + }) as unknown as Ably.InboundMessage; + +const eventsOf = (outputs: Out[]): AgentCodecEvent[] => + outputs + .filter((o): o is Out & { kind: 'event'; event: AgentCodecEvent } => o.kind === 'event' && 'event' in o) + .map((o) => o.event); + +const messagesOf = (outputs: Out[]): AgentMessage[] => + outputs + .filter((o): o is Out & { kind: 'message'; message: AgentMessage } => o.kind === 'message' && 'message' in o) + .map((o) => o.message); + +// Extract the inner stream event from an Anthropic.SDKPartialAssistantMessage. +// Returns undefined if the event is not an Anthropic.SDKPartialAssistantMessage. +const streamEventOf = (event: AgentCodecEvent): Anthropic.SDKPartialAssistantMessage['event'] | undefined => { + if (event.type !== 'stream_event') return undefined; + + return (event).event; +}; + +// Extract all inner stream events from decoder outputs. +const streamEventsOf = (outputs: Out[]): Anthropic.SDKPartialAssistantMessage['event'][] => + eventsOf(outputs) + + .map((e) => streamEventOf(e)) + .filter((e): e is Anthropic.SDKPartialAssistantMessage['event'] => e !== undefined); + +// Extract the `type` fields of inner stream events. +const streamEventTypesOf = (outputs: Out[]): string[] => + + streamEventsOf(outputs).map((e) => e.type); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Anthropic decoder', () => { + // -- streamed text -------------------------------------------------------- + + describe('streamed text', () => { + it('emits content_block_start with text type on stream create', () => { + const decoder = createDecoder(); + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', serial: 's1', name: 'text', data: '' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'txt-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '0', + [`${D}messageId`]: 'msg-1', + [`${D}model`]: 'claude-sonnet-4-20250514', + }, + ), + ); + + const types = streamEventTypesOf(outputs); + // Should have message_start (synthesized) + content_block_start + expect(types).toContain('message_start'); + expect(types).toContain('content_block_start'); + + + const blockStart = streamEventsOf(outputs).find((e) => e.type === 'content_block_start'); + expect(blockStart).toEqual( + expect.objectContaining({ + type: 'content_block_start', + index: 0, + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- expect.objectContaining returns any + content_block: expect.objectContaining({ type: 'text', text: '' }), + }), + ); + }); + + it('emits content_block_delta with text_delta on append', () => { + const decoder = createDecoder(); + // Create + decoder.decode( + withHeaders( + { action: 'message.create', serial: 's1', name: 'text', data: '' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'txt-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '0', + }, + ), + ); + + // Append + const outputs = decoder.decode( + withHeaders( + { action: 'message.append', serial: 's1', name: 'text', data: 'hello' }, + { [HEADER_TURN_ID]: 'turn-1' }, + ), + ); + + const events = streamEventsOf(outputs); + expect(events).toHaveLength(1); + expect(events[0]).toEqual( + expect.objectContaining({ + type: 'content_block_delta', + index: 0, + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- expect.objectContaining returns any + delta: expect.objectContaining({ type: 'text_delta', text: 'hello' }), + }), + ); + }); + + it('emits content_block_stop on finished append', () => { + const decoder = createDecoder(); + decoder.decode( + withHeaders( + { action: 'message.create', serial: 's1', name: 'text', data: '' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'txt-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '0', + }, + ), + ); + + const outputs = decoder.decode( + withHeaders( + { action: 'message.append', serial: 's1', name: 'text', data: '' }, + { [HEADER_STATUS]: 'finished', [HEADER_TURN_ID]: 'turn-1', [`${D}blockIndex`]: '0' }, + ), + ); + + expect(streamEventTypesOf(outputs)).toContain('content_block_stop'); + + const stop = streamEventsOf(outputs).find((e) => e.type === 'content_block_stop'); + expect(stop).toEqual(expect.objectContaining({ type: 'content_block_stop', index: 0 })); + }); + }); + + // -- streamed tool-input -------------------------------------------------- + + describe('streamed tool-input', () => { + it('emits content_block_start with tool_use type on create', () => { + const decoder = createDecoder(); + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', serial: 's1', name: 'tool-input', data: '' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'tc-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '1', + [`${D}toolUseId`]: 'tc-1', + [`${D}toolName`]: 'search', + }, + ), + ); + + + const blockStart = streamEventsOf(outputs).find((e) => e.type === 'content_block_start'); + expect(blockStart).toEqual( + expect.objectContaining({ + type: 'content_block_start', + index: 1, + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- expect.objectContaining returns any + content_block: expect.objectContaining({ + type: 'tool_use', + id: 'tc-1', + name: 'search', + input: {}, + }), + }), + ); + }); + + it('emits content_block_delta with input_json_delta on append', () => { + const decoder = createDecoder(); + decoder.decode( + withHeaders( + { action: 'message.create', serial: 's1', name: 'tool-input', data: '' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'tc-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '1', + [`${D}toolUseId`]: 'tc-1', + [`${D}toolName`]: 'search', + }, + ), + ); + + const outputs = decoder.decode( + withHeaders( + { action: 'message.append', serial: 's1', name: 'tool-input', data: '{"q":"test"}' }, + { [HEADER_TURN_ID]: 'turn-1' }, + ), + ); + + + const delta = streamEventsOf(outputs).find((e) => e.type === 'content_block_delta'); + expect(delta).toEqual( + expect.objectContaining({ + type: 'content_block_delta', + index: 1, + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- expect.objectContaining returns any + delta: expect.objectContaining({ type: 'input_json_delta', partial_json: '{"q":"test"}' }), + }), + ); + }); + + it('emits content_block_stop on finished', () => { + const decoder = createDecoder(); + decoder.decode( + withHeaders( + { action: 'message.create', serial: 's1', name: 'tool-input', data: '' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'tc-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '1', + [`${D}toolUseId`]: 'tc-1', + [`${D}toolName`]: 'search', + }, + ), + ); + + const outputs = decoder.decode( + withHeaders( + { action: 'message.append', serial: 's1', name: 'tool-input', data: '' }, + { [HEADER_STATUS]: 'finished', [HEADER_TURN_ID]: 'turn-1', [`${D}blockIndex`]: '1' }, + ), + ); + + expect(streamEventTypesOf(outputs)).toContain('content_block_stop'); + }); + }); + + // -- streamed thinking ---------------------------------------------------- + + describe('streamed thinking', () => { + it('emits content_block_start with thinking type on create', () => { + const decoder = createDecoder(); + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', serial: 's1', name: 'thinking', data: '' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'th-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '0', + }, + ), + ); + + + const blockStart = streamEventsOf(outputs).find((e) => e.type === 'content_block_start'); + expect(blockStart).toEqual( + expect.objectContaining({ + type: 'content_block_start', + index: 0, + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- expect.objectContaining returns any + content_block: expect.objectContaining({ type: 'thinking', thinking: '', signature: '' }), + }), + ); + }); + + it('emits content_block_delta with thinking_delta on append', () => { + const decoder = createDecoder(); + decoder.decode( + withHeaders( + { action: 'message.create', serial: 's1', name: 'thinking', data: '' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'th-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '0', + }, + ), + ); + + const outputs = decoder.decode( + withHeaders( + { action: 'message.append', serial: 's1', name: 'thinking', data: 'hmm let me think' }, + { [HEADER_TURN_ID]: 'turn-1' }, + ), + ); + + + const delta = streamEventsOf(outputs).find((e) => e.type === 'content_block_delta'); + expect(delta).toEqual( + expect.objectContaining({ + type: 'content_block_delta', + index: 0, + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- expect.objectContaining returns any + delta: expect.objectContaining({ type: 'thinking_delta', thinking: 'hmm let me think' }), + }), + ); + }); + + it('emits content_block_stop on finished', () => { + const decoder = createDecoder(); + decoder.decode( + withHeaders( + { action: 'message.create', serial: 's1', name: 'thinking', data: '' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'th-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '0', + }, + ), + ); + + const outputs = decoder.decode( + withHeaders( + { action: 'message.append', serial: 's1', name: 'thinking', data: '' }, + { [HEADER_STATUS]: 'finished', [HEADER_TURN_ID]: 'turn-1', [`${D}blockIndex`]: '0' }, + ), + ); + + expect(streamEventTypesOf(outputs)).toContain('content_block_stop'); + }); + + it('emits signature_delta before content_block_stop when signature header is present', () => { + const decoder = createDecoder(); + decoder.decode( + withHeaders( + { action: 'message.create', serial: 's1', name: 'thinking', data: '' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'th-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '0', + }, + ), + ); + + const outputs = decoder.decode( + withHeaders( + { action: 'message.append', serial: 's1', name: 'thinking', data: '' }, + { + [HEADER_STATUS]: 'finished', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '0', + [`${D}signature`]: 'sig-from-close', + }, + ), + ); + + const types = streamEventTypesOf(outputs); + // signature_delta must come before content_block_stop + const sigIdx = types.indexOf('content_block_delta'); + const stopIdx = types.indexOf('content_block_stop'); + expect(sigIdx).toBeGreaterThanOrEqual(0); + expect(stopIdx).toBeGreaterThan(sigIdx); + + const sigEvent = streamEventsOf(outputs).find( + (e) => e.type === 'content_block_delta' && (e as unknown as Record).delta !== undefined, + ); + // CAST: Access delta fields through unknown since the union type doesn't narrow by .type alone. + const delta = (sigEvent as unknown as { delta: Record }).delta; + expect(delta.type).toBe('signature_delta'); + expect(delta.signature).toBe('sig-from-close'); + }); + + it('does not emit signature_delta when signature header is absent', () => { + const decoder = createDecoder(); + decoder.decode( + withHeaders( + { action: 'message.create', serial: 's1', name: 'thinking', data: '' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'th-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '0', + }, + ), + ); + + const outputs = decoder.decode( + withHeaders( + { action: 'message.append', serial: 's1', name: 'thinking', data: '' }, + { [HEADER_STATUS]: 'finished', [HEADER_TURN_ID]: 'turn-1', [`${D}blockIndex`]: '0' }, + ), + ); + + // Only content_block_stop, no signature_delta + const types = streamEventTypesOf(outputs); + expect(types).toEqual(['content_block_stop']); + }); + }); + + // -- discrete: message-start ---------------------------------------------- + + describe('discrete message-start', () => { + it('produces Anthropic.SDKPartialAssistantMessage wrapping message_start', () => { + const decoder = createDecoder(); + const messageData = { + id: 'msg-abc', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-20250514', + content: [], + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + stop_reason: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + stop_sequence: null, + usage: { input_tokens: 100, output_tokens: 0 }, + }; + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', name: 'message-start', data: messageData }, + { + [HEADER_STREAM]: 'false', + [HEADER_TURN_ID]: 'turn-1', + [`${D}messageId`]: 'msg-abc', + [`${D}model`]: 'claude-sonnet-4-20250514', + }, + ), + ); + + const events = eventsOf(outputs); + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe('stream_event'); + + const inner = streamEventOf(at(events, 0)); + + expect(inner?.type).toBe('message_start'); + }); + + it('marks the phase as emitted so lifecycle tracker does not re-synthesize', () => { + const decoder = createDecoder(); + + // Explicit message-start arrives first + decoder.decode( + withHeaders( + { action: 'message.create', name: 'message-start', data: { id: 'msg-1', type: 'message', role: 'assistant', model: 'test', content: [] } }, + { + [HEADER_STREAM]: 'false', + [HEADER_TURN_ID]: 'turn-1', + [`${D}messageId`]: 'msg-1', + }, + ), + ); + + // Next streamed content block — should NOT synthesize another message_start + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', serial: 's1', name: 'text', data: '' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'txt-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '0', + }, + ), + ); + + + const messageStartCount = streamEventsOf(outputs).filter((e) => e.type === 'message_start').length; + expect(messageStartCount).toBe(0); + }); + }); + + // -- discrete: message-delta ---------------------------------------------- + + describe('discrete message-delta', () => { + it('produces Anthropic.SDKPartialAssistantMessage wrapping message_delta', () => { + const decoder = createDecoder(); + const deltaData = { stop_reason: 'end_turn' }; + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', name: 'message-delta', data: deltaData }, + { + [HEADER_STREAM]: 'false', + [HEADER_TURN_ID]: 'turn-1', + [`${D}messageId`]: 'msg-1', + }, + ), + ); + + const events = eventsOf(outputs); + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe('stream_event'); + + const inner = streamEventOf(at(events, 0)); + + expect(inner?.type).toBe('message_delta'); + }); + }); + + // -- discrete: message-stop ----------------------------------------------- + + describe('discrete message-stop', () => { + it('produces Anthropic.SDKPartialAssistantMessage wrapping message_stop', () => { + const decoder = createDecoder(); + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', name: 'message-stop', data: '' }, + { [HEADER_STREAM]: 'false', [HEADER_TURN_ID]: 'turn-1' }, + ), + ); + + const events = eventsOf(outputs); + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe('stream_event'); + + const inner = streamEventOf(at(events, 0)); + + expect(inner?.type).toBe('message_stop'); + }); + }); + + // -- discrete: assistant-message ------------------------------------------ + + describe('discrete assistant-message', () => { + it('produces kind:message with Anthropic.SDKAssistantMessage', () => { + const decoder = createDecoder(); + const betaMessage = { + id: 'msg-abc', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-20250514', + content: [{ type: 'text', text: 'Hello!' }], + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + stop_reason: null, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + stop_sequence: null, + usage: { input_tokens: 50, output_tokens: 10 }, + }; + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', name: 'assistant-message', data: betaMessage }, + { + [HEADER_STREAM]: 'false', + [`${D}parentToolUseId`]: 'tool-1', + [`${D}messageId`]: 'uuid-1', + }, + ), + ); + + const messages = messagesOf(outputs); + expect(messages).toHaveLength(1); + const msg = messages[0] as Anthropic.SDKAssistantMessage; + expect(msg.type).toBe('assistant'); + expect(msg.parent_tool_use_id).toBe('tool-1'); + expect(msg.uuid).toBe('uuid-1'); + expect(msg.message).toBe(betaMessage); + }); + + it('defaults parent_tool_use_id to null when header is absent', () => { + const decoder = createDecoder(); + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', name: 'assistant-message', data: { id: 'msg-1' } }, + { [HEADER_STREAM]: 'false' }, + ), + ); + + const msg = messagesOf(outputs)[0] as Anthropic.SDKAssistantMessage; + + expect(msg.parent_tool_use_id).toBeNull(); + }); + }); + + // -- discrete: user-message ----------------------------------------------- + + describe('discrete user-message', () => { + it('produces kind:message with Anthropic.SDKUserMessage', () => { + const decoder = createDecoder(); + const messageParam = { role: 'user', content: 'Hello from user' }; + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', name: 'user-message', data: messageParam }, + { + [HEADER_STREAM]: 'false', + [`${D}parentToolUseId`]: 'tool-2', + [`${D}uuid`]: 'uuid-2', + [`${D}isSynthetic`]: 'true', + }, + ), + ); + + const messages = messagesOf(outputs); + expect(messages).toHaveLength(1); + const msg = messages[0] as Anthropic.SDKUserMessage; + expect(msg.type).toBe('user'); + expect(msg.parent_tool_use_id).toBe('tool-2'); + expect(msg.uuid).toBe('uuid-2'); + expect(msg.isSynthetic).toBe(true); + expect(msg.message).toBe(messageParam); + }); + + it('decodes user-message when x-ably-role header is present', () => { + const decoder = createDecoder(); + const messageParam = { role: 'user', content: 'Hi' }; + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', name: 'user-message', data: messageParam }, + { + [HEADER_STREAM]: 'false', + [HEADER_ROLE]: 'user', + [`${D}messageId`]: 'uuid-3', + }, + ), + ); + + const messages = messagesOf(outputs); + expect(messages).toHaveLength(1); + expect((messages[0] as Anthropic.SDKUserMessage).type).toBe('user'); + }); + + it('decodes assistant-message when x-ably-role header is present', () => { + const decoder = createDecoder(); + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', name: 'assistant-message', data: { id: 'msg-1' } }, + { + [HEADER_STREAM]: 'false', + [HEADER_ROLE]: 'assistant', + [`${D}messageId`]: 'uuid-4', + }, + ), + ); + + const messages = messagesOf(outputs); + expect(messages).toHaveLength(1); + expect((messages[0] as Anthropic.SDKAssistantMessage).type).toBe('assistant'); + }); + }); + + // -- discrete: result ----------------------------------------------------- + + describe('discrete result', () => { + it('produces kind:event with Anthropic.SDKResultMessage', () => { + const decoder = createDecoder(); + // CAST: Synthetic SDKResultMessage — usage does not fully satisfy NonNullableUsage. + const resultData = { + type: 'result' as const, + subtype: 'success' as const, + duration_ms: 1000, + duration_api_ms: 800, + is_error: false, + num_turns: 1, + result: 'done', + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + stop_reason: null, + total_cost_usd: 0.01, + usage: { input_tokens: 100, output_tokens: 50, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + modelUsage: {}, + permission_denials: [], + uuid: 'result-uuid' as UUID, + session_id: 'session-1', + } as unknown as Anthropic.SDKResultMessage; + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', name: 'result', data: resultData }, + { [HEADER_STREAM]: 'false', [HEADER_TURN_ID]: 'turn-1' }, + ), + ); + + const events = eventsOf(outputs); + expect(events).toHaveLength(1); + expect(events[0]).toBe(resultData); + }); + + it('dispatches by name even when x-ably-role header is present', () => { + // Regression: the transport stamps x-ably-role: assistant on all messages + // in a turn, including result events. The decoder must dispatch result + // events by name ('result'), not by role (which would misroute them as + // assistant-message kind:'message' outputs). + const decoder = createDecoder(); + const resultData = { + type: 'result' as const, + subtype: 'success' as const, + is_error: false, + } as unknown as Anthropic.SDKResultMessage; + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', name: 'result', data: resultData }, + { + [HEADER_STREAM]: 'false', + [HEADER_TURN_ID]: 'turn-1', + [HEADER_ROLE]: 'assistant', + }, + ), + ); + + // Must produce kind:'event' (terminal signal), NOT kind:'message' + const events = eventsOf(outputs); + expect(events).toHaveLength(1); + expect(events[0]).toBe(resultData); + + const messages = messagesOf(outputs); + expect(messages).toHaveLength(0); + }); + }); + + // -- discrete: tool-progress ---------------------------------------------- + + describe('discrete tool-progress', () => { + it('produces kind:event with Anthropic.SDKToolProgressMessage', () => { + const decoder = createDecoder(); + const progressData: Anthropic.SDKToolProgressMessage = { + type: 'tool_progress', + tool_use_id: 'tu-1', + tool_name: 'bash', + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + parent_tool_use_id: null, + elapsed_time_seconds: 5, + uuid: 'progress-uuid' as UUID, + session_id: 'session-1', + }; + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', name: 'tool-progress', data: progressData }, + { [HEADER_STREAM]: 'false', [HEADER_TURN_ID]: 'turn-1' }, + ), + ); + + const events = eventsOf(outputs); + expect(events).toHaveLength(1); + expect(events[0]).toBe(progressData); + }); + }); + + // -- discrete: abort ------------------------------------------------------ + + describe('discrete abort', () => { + it('produces kind:event with Anthropic.SDKResultMessage (terminal)', () => { + const decoder = createDecoder(); + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', name: 'abort', data: 'user cancelled' }, + { [HEADER_STREAM]: 'false', [HEADER_TURN_ID]: 'turn-1' }, + ), + ); + + const events = eventsOf(outputs); + expect(events).toHaveLength(1); + const result = events[0] as Anthropic.SDKResultMessage; + expect(result.type).toBe('result'); + expect(result.subtype).toBe('error_during_execution'); + expect(result.is_error).toBe(true); + }); + + it('uses "cancelled" as default reason when data is empty', () => { + const decoder = createDecoder(); + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', name: 'abort', data: '' }, + { [HEADER_STREAM]: 'false', [HEADER_TURN_ID]: 'turn-1' }, + ), + ); + + const result = eventsOf(outputs)[0] as Anthropic.SDKResultMessage; + expect(result.stop_reason).toBe('cancelled'); + }); + + it('uses provided reason string', () => { + const decoder = createDecoder(); + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', name: 'abort', data: 'timeout' }, + { [HEADER_STREAM]: 'false', [HEADER_TURN_ID]: 'turn-1' }, + ), + ); + + const result = eventsOf(outputs)[0] as Anthropic.SDKResultMessage; + expect(result.stop_reason).toBe('timeout'); + }); + + it('clears lifecycle scope so subsequent turns start fresh', () => { + const decoder = createDecoder(); + + // Start a turn — lifecycle tracker creates a scope + decoder.decode( + withHeaders( + { action: 'message.create', serial: 's1', name: 'text', data: '' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'txt-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '0', + [`${D}messageId`]: 'msg-1', + }, + ), + ); + + // Abort the turn + decoder.decode( + withHeaders( + { action: 'message.create', name: 'abort', data: 'cancelled' }, + { [HEADER_STREAM]: 'false', [HEADER_TURN_ID]: 'turn-1' }, + ), + ); + + // New stream on same turn-1 after abort — lifecycle tracker should re-synthesize message_start + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', serial: 's2', name: 'text', data: '' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'txt-2', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '0', + [`${D}messageId`]: 'msg-2', + }, + ), + ); + + expect(streamEventTypesOf(outputs)).toContain('message_start'); + }); + }); + + // -- discrete: content-block ---------------------------------------------- + + describe('discrete content-block', () => { + it('produces kind:event with Anthropic.SDKPartialAssistantMessage wrapping content_block_start', () => { + const decoder = createDecoder(); + const contentBlock = { type: 'text', text: 'Final text.' }; + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', name: 'content-block', data: contentBlock }, + { + [HEADER_STREAM]: 'false', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '2', + [`${D}messageId`]: 'msg-1', + }, + ), + ); + + const events = eventsOf(outputs); + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe('stream_event'); + + const inner = streamEventOf(at(events, 0)); + expect(inner).toEqual( + expect.objectContaining({ + type: 'content_block_start', + index: 2, + content_block: contentBlock, + }), + ); + }); + }); + + // -- lifecycle tracker: synthetic message_start --------------------------- + + describe('lifecycle tracker', () => { + it('synthesizes message_start before first content_block_start in a turn', () => { + const decoder = createDecoder(); + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', serial: 's1', name: 'text', data: '' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'txt-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '0', + [`${D}messageId`]: 'msg-1', + [`${D}model`]: 'claude-sonnet-4-20250514', + }, + ), + ); + + const types = streamEventTypesOf(outputs); + const msgStartIdx = types.indexOf('message_start'); + const blockStartIdx = types.indexOf('content_block_start'); + expect(msgStartIdx).toBeGreaterThanOrEqual(0); + expect(blockStartIdx).toBeGreaterThan(msgStartIdx); + }); + + it('synthetic message_start uses messageId and model from domain headers', () => { + const decoder = createDecoder(); + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', serial: 's1', name: 'text', data: '' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'txt-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '0', + [`${D}messageId`]: 'synth-msg-1', + [`${D}model`]: 'claude-opus-4-20250514', + }, + ), + ); + + + const msgStart = streamEventsOf(outputs).find((e) => e.type === 'message_start'); + expect(msgStart).toBeDefined(); + // The synthetic message_start should carry the model and id from headers + const message = (msgStart as { type: 'message_start'; message: { id: string; model: string } }).message; + expect(message.id).toBe('synth-msg-1'); + expect(message.model).toBe('claude-opus-4-20250514'); + }); + + it('emits message_start only once per turn', () => { + const decoder = createDecoder(); + + // First stream in turn + const first = decoder.decode( + withHeaders( + { action: 'message.create', serial: 's1', name: 'text', data: '' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'txt-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '0', + [`${D}messageId`]: 'msg-1', + }, + ), + ); + expect(streamEventTypesOf(first)).toContain('message_start'); + + // Second stream in same turn + const second = decoder.decode( + withHeaders( + { action: 'message.create', serial: 's2', name: 'tool-input', data: '' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'tc-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '1', + [`${D}toolUseId`]: 'tc-1', + [`${D}toolName`]: 'search', + }, + ), + ); + expect(streamEventTypesOf(second)).not.toContain('message_start'); + }); + + it('mid-stream join: append without preceding create synthesizes message_start', () => { + const decoder = createDecoder(); + + // Simulate mid-stream join: first message is an append (treated as first-contact update by decoder core) + const outputs = decoder.decode( + withHeaders( + { action: 'message.append', serial: 's1', name: 'text', data: 'partial' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'txt-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '0', + [`${D}messageId`]: 'msg-1', + [`${D}model`]: 'claude-sonnet-4-20250514', + }, + ), + ); + + // The decoder core falls through to first-contact (update) path + // which calls buildStartEvents → ensurePhases synthesizes message_start + const types = streamEventTypesOf(outputs); + expect(types).toContain('message_start'); + expect(types).toContain('content_block_start'); + expect(types).toContain('content_block_delta'); + }); + }); + + // -- domain headers ------------------------------------------------------- + + describe('domain headers', () => { + it('blockIndex is extracted correctly for content blocks', () => { + const decoder = createDecoder(); + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', serial: 's1', name: 'text', data: '' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'txt-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '3', + }, + ), + ); + + + const blockStart = streamEventsOf(outputs).find((e) => e.type === 'content_block_start'); + expect(blockStart).toEqual(expect.objectContaining({ index: 3 })); + }); + + it('defaults blockIndex to 0 when header is missing', () => { + const decoder = createDecoder(); + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', serial: 's1', name: 'text', data: '' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'txt-1', + [HEADER_TURN_ID]: 'turn-1', + }, + ), + ); + + + const blockStart = streamEventsOf(outputs).find((e) => e.type === 'content_block_start'); + expect(blockStart).toEqual(expect.objectContaining({ index: 0 })); + }); + + it('toolUseId and toolName are read for tool-input blocks', () => { + const decoder = createDecoder(); + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', serial: 's1', name: 'tool-input', data: '' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'tc-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '1', + [`${D}toolUseId`]: 'tool-use-abc', + [`${D}toolName`]: 'file_editor', + }, + ), + ); + + + const blockStart = streamEventsOf(outputs).find((e) => e.type === 'content_block_start'); + expect(blockStart).toEqual( + expect.objectContaining({ + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- expect.objectContaining returns any + content_block: expect.objectContaining({ + type: 'tool_use', + id: 'tool-use-abc', + name: 'file_editor', + }), + }), + ); + }); + + it('parentToolUseId is read for Anthropic.SDKPartialAssistantMessage wrapping', () => { + const decoder = createDecoder(); + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', serial: 's1', name: 'text', data: '' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'txt-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '0', + [`${D}parentToolUseId`]: 'parent-tool-1', + }, + ), + ); + + const events = eventsOf(outputs); + // Find the content_block_start event (not the synthesized message_start from lifecycle tracker) + const partial = events.find( + + (e) => e.type === 'stream_event' && (e).event.type === 'content_block_start', + ) as Anthropic.SDKPartialAssistantMessage | undefined; + expect(partial?.parent_tool_use_id).toBe('parent-tool-1'); + }); + + it('parentToolUseId defaults to null when absent', () => { + const decoder = createDecoder(); + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', serial: 's1', name: 'text', data: '' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'txt-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '0', + }, + ), + ); + + const partial = eventsOf(outputs).find((e) => e.type === 'stream_event'); + + expect(partial?.parent_tool_use_id).toBeNull(); + }); + + it('messageId is read for message-start events', () => { + const decoder = createDecoder(); + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', name: 'message-start', data: { id: 'msg-x', type: 'message' } }, + { + [HEADER_STREAM]: 'false', + [HEADER_TURN_ID]: 'turn-1', + [`${D}messageId`]: 'msg-uuid-123', + }, + ), + ); + + const partial = eventsOf(outputs).find((e) => e.type === 'stream_event'); + expect(partial?.uuid).toBe('msg-uuid-123'); + }); + + it('model is read for synthetic message_start events', () => { + const decoder = createDecoder(); + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', serial: 's1', name: 'text', data: '' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'txt-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '0', + [`${D}messageId`]: 'msg-1', + [`${D}model`]: 'claude-haiku-3', + }, + ), + ); + + + const msgStart = streamEventsOf(outputs).find((e) => e.type === 'message_start'); + const message = (msgStart as { type: 'message_start'; message: { model: string } }).message; + expect(message.model).toBe('claude-haiku-3'); + }); + }); + + // -- first-contact path (message.update for unknown serial) --------------- + + describe('first-contact update (history hydration)', () => { + it('emits full lifecycle for finished streamed message', () => { + const decoder = createDecoder(); + const outputs = decoder.decode( + withHeaders( + { action: 'message.update', serial: 's1', name: 'text', data: 'hello world' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'finished', + [HEADER_STREAM_ID]: 'txt-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '0', + [`${D}messageId`]: 'msg-1', + }, + ), + ); + + const types = streamEventTypesOf(outputs); + expect(types).toContain('message_start'); + expect(types).toContain('content_block_start'); + expect(types).toContain('content_block_delta'); + expect(types).toContain('content_block_stop'); + + + const delta = streamEventsOf(outputs).find((e) => e.type === 'content_block_delta'); + expect(delta).toEqual( + expect.objectContaining({ + type: 'content_block_delta', + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- expect.objectContaining returns any + delta: expect.objectContaining({ type: 'text_delta', text: 'hello world' }), + }), + ); + }); + + it('emits start + delta but no stop for a still-streaming first contact', () => { + const decoder = createDecoder(); + const outputs = decoder.decode( + withHeaders( + { action: 'message.update', serial: 's1', name: 'text', data: 'partial' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'txt-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '0', + }, + ), + ); + + const types = streamEventTypesOf(outputs); + expect(types).toContain('content_block_start'); + expect(types).toContain('content_block_delta'); + expect(types).not.toContain('content_block_stop'); + }); + + it('does not emit delta when first-contact data is empty', () => { + const decoder = createDecoder(); + const outputs = decoder.decode( + withHeaders( + { action: 'message.update', serial: 's1', name: 'text', data: '' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'txt-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '0', + }, + ), + ); + + const types = streamEventTypesOf(outputs); + expect(types).toContain('content_block_start'); + expect(types).not.toContain('content_block_delta'); + }); + }); + + // -- default stream name handling ----------------------------------------- + + describe('default stream name handling', () => { + it('treats unknown stream name as text type for start events', () => { + const decoder = createDecoder(); + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', serial: 's1', name: 'unknown-type', data: '' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'unk-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '0', + }, + ), + ); + + + const blockStart = streamEventsOf(outputs).find((e) => e.type === 'content_block_start'); + expect(blockStart).toEqual( + expect.objectContaining({ + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- expect.objectContaining returns any + content_block: expect.objectContaining({ type: 'text' }), + }), + ); + }); + + it('treats unknown stream name as text_delta for delta events', () => { + const decoder = createDecoder(); + + // Create with unknown name + decoder.decode( + withHeaders( + { action: 'message.create', serial: 's1', name: 'unknown-type', data: '' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'unk-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '0', + }, + ), + ); + + // Append + const outputs = decoder.decode( + withHeaders( + { action: 'message.append', serial: 's1', name: 'unknown-type', data: 'fallback text' }, + { [HEADER_TURN_ID]: 'turn-1' }, + ), + ); + + + const delta = streamEventsOf(outputs).find((e) => e.type === 'content_block_delta'); + expect(delta).toEqual( + expect.objectContaining({ + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- expect.objectContaining returns any + delta: expect.objectContaining({ type: 'text_delta', text: 'fallback text' }), + }), + ); + }); + }); + + // -- edge cases ----------------------------------------------------------- + + describe('edge cases', () => { + it('unknown message name in decodeDiscrete returns empty output', () => { + const decoder = createDecoder(); + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', name: 'completely-unknown', data: 'something' }, + { [HEADER_STREAM]: 'false' }, + ), + ); + + expect(outputs).toHaveLength(0); + }); + + it('missing domain headers produce graceful defaults', () => { + const decoder = createDecoder(); + + // assistant-message with no domain headers at all + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', name: 'assistant-message', data: { id: 'msg-1' } }, + { [HEADER_STREAM]: 'false' }, + ), + ); + + const messages = messagesOf(outputs); + expect(messages).toHaveLength(1); + const msg = messages[0] as Anthropic.SDKAssistantMessage; + + expect(msg.parent_tool_use_id).toBeNull(); + expect(msg.uuid).toBe(''); + }); + + it('unknown action returns empty output', () => { + const decoder = createDecoder(); + + const outputs = decoder.decode( + withHeaders( + { action: 'message.summary' as Ably.InboundMessage['action'] }, + {}, + ), + ); + + expect(outputs).toHaveLength(0); + }); + + it('message.create without serial for streamed message returns empty output', () => { + const decoder = createDecoder(); + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', serial: undefined, name: 'text', data: '' }, + { [HEADER_STREAM]: 'true', [HEADER_STATUS]: 'streaming' }, + ), + ); + + expect(outputs).toHaveLength(0); + }); + + it('tags event outputs with messageId from x-ably-msg-id', () => { + const decoder = createDecoder(); + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', name: 'message-stop', data: '' }, + { [HEADER_STREAM]: 'false', [HEADER_MSG_ID]: 'ably-msg-42' }, + ), + ); + + expect(outputs).toHaveLength(1); + expect(outputs[0]?.kind).toBe('event'); + if (outputs[0]?.kind === 'event') { + expect(outputs[0].messageId).toBe('ably-msg-42'); + } + }); + }); + + // -- wrapStreamEvent envelope fields -------------------------------------- + + describe('wrapStreamEvent envelope', () => { + it('sets session_id to empty string', () => { + const decoder = createDecoder(); + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', serial: 's1', name: 'text', data: '' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'txt-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '0', + }, + ), + ); + + const partial = eventsOf(outputs).find((e) => e.type === 'stream_event'); + expect(partial?.session_id).toBe(''); + }); + + it('uses "synthetic" as uuid when messageId header is absent', () => { + const decoder = createDecoder(); + + const outputs = decoder.decode( + withHeaders( + { action: 'message.create', serial: 's1', name: 'text', data: '' }, + { + [HEADER_STREAM]: 'true', + [HEADER_STATUS]: 'streaming', + [HEADER_STREAM_ID]: 'txt-1', + [HEADER_TURN_ID]: 'turn-1', + [`${D}blockIndex`]: '0', + }, + ), + ); + + // The content_block_start event (not the synthetic message_start) should + // have uuid derived from the stream headers + const partials = eventsOf(outputs).filter((e) => e.type === 'stream_event'); + const blockStartPartial = partials.find( + + (p) => p.event.type === 'content_block_start', + ); + expect(blockStartPartial?.uuid).toBe('synthetic'); + }); + }); +}); diff --git a/test/anthropic/codec/encoder.test.ts b/test/anthropic/codec/encoder.test.ts new file mode 100644 index 00000000..005d8ad1 --- /dev/null +++ b/test/anthropic/codec/encoder.test.ts @@ -0,0 +1,1125 @@ +import type { UUID } from 'node:crypto'; + +import type * as Anthropic from '@anthropic-ai/claude-agent-sdk'; +import type * as Ably from 'ably'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createEncoder } from '../../../src/anthropic/codec/encoder.js'; +import { + DOMAIN_HEADER_PREFIX as D, + HEADER_STATUS, + HEADER_STREAM, + HEADER_STREAM_ID, +} from '../../../src/constants.js'; +import type { ChannelWriter } from '../../../src/core/codec/types.js'; + +// --------------------------------------------------------------------------- +// Mock writer +// --------------------------------------------------------------------------- + +interface MockWriter extends ChannelWriter { + publishCalls: (Ably.Message | Ably.Message[])[]; + appendCalls: Ably.Message[]; + updateCalls: Ably.Message[]; + nextPublishResult: Ably.PublishResult; + nextAppendResult: Ably.UpdateDeleteResult; +} + +const createMockWriter = (): MockWriter => { + const mock: MockWriter = { + publishCalls: [], + appendCalls: [], + updateCalls: [], + nextPublishResult: { serials: ['serial-1'] } as Ably.PublishResult, + nextAppendResult: {} as Ably.UpdateDeleteResult, + publish: vi.fn(async (message: Ably.Message | Ably.Message[]) => { + mock.publishCalls.push(message); + return await Promise.resolve(mock.nextPublishResult); + }), + appendMessage: vi.fn(async (message: Ably.Message) => { + mock.appendCalls.push(message); + return await Promise.resolve(mock.nextAppendResult); + }), + updateMessage: vi.fn(async (message: Ably.Message) => { + mock.updateCalls.push(message); + return await Promise.resolve(mock.nextAppendResult); + }), + }; + return mock; +}; + +const headersOf = (msg: Ably.Message): Record => + (msg.extras as { headers: Record }).headers; + +const firstPublish = (writer: MockWriter): Ably.Message => { + const call = writer.publishCalls[0]; + if (!call) throw new Error('no publish calls'); + if (Array.isArray(call)) { + const first = call[0]; + if (!first) throw new Error('empty batch'); + return first; + } + return call; +}; + +const lastPublish = (writer: MockWriter): Ably.Message => { + const call = writer.publishCalls.at(-1); + if (!call) throw new Error('no publish calls'); + if (Array.isArray(call)) { + const first = call[0]; + if (!first) throw new Error('empty batch'); + return first; + } + return call; +}; + +const lastAppend = (writer: MockWriter): Ably.Message => { + const msg = writer.appendCalls.at(-1); + if (!msg) throw new Error('no append calls'); + return msg; +}; + +// --------------------------------------------------------------------------- +// Test helpers — event builders +// --------------------------------------------------------------------------- + +const makeStreamEvent = ( + event: Record, + overrides?: Partial>, +): Anthropic.SDKPartialAssistantMessage => ({ + type: 'stream_event', + // CAST: Synthetic test events — cast through unknown because object literals + // do not fully satisfy the BetaRawMessageStreamEvent union. + event: event as unknown as Anthropic.SDKPartialAssistantMessage['event'], + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + parent_tool_use_id: null, + uuid: 'test-uuid' as UUID, + session_id: 'test-session', + ...overrides, +}); + +const makeAssistantMessage = ( + overrides?: Partial>, +): Anthropic.SDKAssistantMessage => + ({ + type: 'assistant', + message: { id: 'msg-abc', model: 'claude-opus-4-20250514', content: [], role: 'assistant' }, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + parent_tool_use_id: null, + uuid: 'test-uuid' as UUID, + session_id: 'test-session', + ...overrides, + }) as Anthropic.SDKAssistantMessage; + +const makeUserMessage = ( + overrides?: Partial>, +): Anthropic.SDKUserMessage => + ({ + type: 'user', + message: { role: 'user', content: 'hello' }, + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + parent_tool_use_id: null, + session_id: 'test-session', + ...overrides, + }) as Anthropic.SDKUserMessage; + +const makeResultMessage = ( + overrides?: Partial>, +): Anthropic.SDKResultMessage => + ({ + type: 'result', + subtype: 'success', + duration_ms: 100, + duration_api_ms: 80, + is_error: false, + num_turns: 1, + result: 'done', + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + stop_reason: null, + total_cost_usd: 0.01, + usage: { input_tokens: 10, output_tokens: 20, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, server_tool_use_input_tokens: 0 }, + modelUsage: {}, + permission_denials: [], + uuid: 'test-uuid' as UUID, + session_id: 'test-session', + ...overrides, + }) as Anthropic.SDKResultMessage; + +const makeToolProgressMessage = ( + overrides?: Partial>, +): Anthropic.SDKToolProgressMessage => + ({ + type: 'tool_progress', + tool_use_id: 'tu-1', + tool_name: 'bash', + // eslint-disable-next-line unicorn/no-null -- SDK type requires null + parent_tool_use_id: null, + elapsed_time_seconds: 5, + uuid: 'test-uuid' as UUID, + session_id: 'test-session', + ...overrides, + }) as Anthropic.SDKToolProgressMessage; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Anthropic encoder', () => { + let writer: MockWriter; + + beforeEach(() => { + writer = createMockWriter(); + }); + + // -- text content block streaming ----------------------------------------- + + describe('text content block streaming', () => { + it('encodes content_block_start (text) as a streamed publish', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }), + ); + + const msg = firstPublish(writer); + expect(msg.name).toBe('text'); + expect(headersOf(msg)[HEADER_STREAM]).toBe('true'); + expect(headersOf(msg)[HEADER_STATUS]).toBe('streaming'); + expect(headersOf(msg)[HEADER_STREAM_ID]).toBe('0'); + expect(headersOf(msg)[`${D}blockIndex`]).toBe('0'); + expect(headersOf(msg)[`${D}blockType`]).toBe('text'); + }); + + it('encodes content_block_delta (text_delta) as an append', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }), + ); + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'Hello world' }, + }), + ); + + expect(writer.appendCalls).toHaveLength(1); + expect(writer.appendCalls[0]?.data).toBe('Hello world'); + }); + + it('encodes content_block_stop as a closing append', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }), + ); + await encoder.appendEvent( + makeStreamEvent({ type: 'content_block_stop', index: 0 }), + ); + + const msg = lastAppend(writer); + expect(headersOf(msg)[HEADER_STATUS]).toBe('finished'); + }); + + it('encodes full text lifecycle: start -> delta -> delta -> stop', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }), + ); + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'Hello' }, + }), + ); + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: ' world' }, + }), + ); + await encoder.appendEvent( + makeStreamEvent({ type: 'content_block_stop', index: 0 }), + ); + + // 1 publish (start), 3 appends (2 deltas + 1 close) + expect(writer.publishCalls).toHaveLength(1); + expect(writer.appendCalls).toHaveLength(3); + }); + }); + + // -- tool_use content block streaming ------------------------------------- + + describe('tool_use content block streaming', () => { + it('encodes content_block_start (tool_use) with tool metadata headers', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 1, + content_block: { type: 'tool_use', id: 'toolu_123', name: 'search' }, + }), + ); + + const msg = firstPublish(writer); + expect(msg.name).toBe('tool-input'); + expect(headersOf(msg)[HEADER_STREAM_ID]).toBe('1'); + expect(headersOf(msg)[`${D}blockIndex`]).toBe('1'); + expect(headersOf(msg)[`${D}blockType`]).toBe('tool_use'); + expect(headersOf(msg)[`${D}toolUseId`]).toBe('toolu_123'); + expect(headersOf(msg)[`${D}toolName`]).toBe('search'); + }); + + it('encodes content_block_delta (input_json_delta) as an append', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 1, + content_block: { type: 'tool_use', id: 'toolu_123', name: 'search' }, + }), + ); + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_delta', + index: 1, + delta: { type: 'input_json_delta', partial_json: '{"q":' }, + }), + ); + + expect(writer.appendCalls).toHaveLength(1); + expect(writer.appendCalls[0]?.data).toBe('{"q":'); + }); + + it('encodes content_block_stop for tool_use as a closing append', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 1, + content_block: { type: 'tool_use', id: 'toolu_123', name: 'search' }, + }), + ); + await encoder.appendEvent( + makeStreamEvent({ type: 'content_block_stop', index: 1 }), + ); + + const msg = lastAppend(writer); + expect(headersOf(msg)[HEADER_STATUS]).toBe('finished'); + }); + }); + + // -- thinking content block streaming ------------------------------------- + + describe('thinking content block streaming', () => { + it('encodes content_block_start (thinking) as a streamed publish', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 0, + content_block: { type: 'thinking', thinking: '' }, + }), + ); + + const msg = firstPublish(writer); + expect(msg.name).toBe('thinking'); + expect(headersOf(msg)[HEADER_STREAM]).toBe('true'); + expect(headersOf(msg)[HEADER_STATUS]).toBe('streaming'); + expect(headersOf(msg)[HEADER_STREAM_ID]).toBe('0'); + expect(headersOf(msg)[`${D}blockIndex`]).toBe('0'); + expect(headersOf(msg)[`${D}blockType`]).toBe('thinking'); + }); + + it('encodes content_block_delta (thinking_delta) as an append', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 0, + content_block: { type: 'thinking', thinking: '' }, + }), + ); + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta', thinking: 'Let me think...' }, + }), + ); + + expect(writer.appendCalls).toHaveLength(1); + expect(writer.appendCalls[0]?.data).toBe('Let me think...'); + }); + + it('encodes content_block_stop for thinking as a closing append', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 0, + content_block: { type: 'thinking', thinking: '' }, + }), + ); + await encoder.appendEvent( + makeStreamEvent({ type: 'content_block_stop', index: 0 }), + ); + + const msg = lastAppend(writer); + expect(headersOf(msg)[HEADER_STATUS]).toBe('finished'); + }); + }); + + // -- non-streaming content blocks ----------------------------------------- + + describe('non-streaming content blocks', () => { + it('publishes server_tool_use as discrete content-block', async () => { + const encoder = createEncoder(writer); + const contentBlock = { + type: 'server_tool_use', + id: 'stu_123', + name: 'web_search', + input: { query: 'test' }, + }; + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 2, + content_block: contentBlock, + }), + ); + + const msg = firstPublish(writer); + expect(msg.name).toBe('content-block'); + expect(headersOf(msg)[HEADER_STREAM]).toBe('false'); + expect(headersOf(msg)[`${D}blockIndex`]).toBe('2'); + expect(headersOf(msg)[`${D}blockType`]).toBe('server_tool_use'); + expect(msg.data).toEqual(contentBlock); + }); + + it('publishes web_search_tool_result as discrete content-block', async () => { + const encoder = createEncoder(writer); + const contentBlock = { + type: 'web_search_tool_result', + tool_use_id: 'wst_123', + content: [{ type: 'web_search_result', url: 'https://example.com', title: 'Example' }], + }; + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 3, + content_block: contentBlock, + }), + ); + + const msg = firstPublish(writer); + expect(msg.name).toBe('content-block'); + expect(headersOf(msg)[`${D}blockType`]).toBe('web_search_tool_result'); + expect(msg.data).toEqual(contentBlock); + }); + }); + + // -- message lifecycle events (stream) ------------------------------------ + + describe('message lifecycle events', () => { + it('encodes message_start as a discrete publish with messageId and model headers', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent( + makeStreamEvent({ + type: 'message_start', + message: { id: 'msg_01ABC', model: 'claude-opus-4-20250514', role: 'assistant', content: [] }, + }), + ); + + const msg = firstPublish(writer); + expect(msg.name).toBe('message-start'); + expect(headersOf(msg)[HEADER_STREAM]).toBe('false'); + expect(headersOf(msg)[`${D}messageId`]).toBe('msg_01ABC'); + expect(headersOf(msg)[`${D}model`]).toBe('claude-opus-4-20250514'); + }); + + it('encodes message_delta as a discrete publish with stopReason header', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent( + makeStreamEvent({ + type: 'message_delta', + delta: { stop_reason: 'end_turn' }, + usage: { output_tokens: 50 }, + }), + ); + + const msg = firstPublish(writer); + expect(msg.name).toBe('message-delta'); + expect(headersOf(msg)[HEADER_STREAM]).toBe('false'); + expect(headersOf(msg)[`${D}stopReason`]).toBe('end_turn'); + expect(msg.data).toEqual({ stop_reason: 'end_turn', usage: { output_tokens: 50 } }); + }); + + it('omits stopReason header when stop_reason is absent', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent( + makeStreamEvent({ + type: 'message_delta', + delta: {}, + usage: { output_tokens: 10 }, + }), + ); + + const msg = firstPublish(writer); + expect(headersOf(msg)[`${D}stopReason`]).toBeUndefined(); + }); + + it('encodes message_stop as a discrete publish', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent( + makeStreamEvent({ type: 'message_stop' }), + ); + + const msg = firstPublish(writer); + expect(msg.name).toBe('message-stop'); + expect(msg.data).toBe(''); + expect(headersOf(msg)[HEADER_STREAM]).toBe('false'); + }); + }); + + // -- complete messages (appendEvent) -------------------------------------- + + describe('complete messages via appendEvent', () => { + it('encodes Anthropic.SDKAssistantMessage as discrete assistant-message', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent(makeAssistantMessage()); + + const msg = firstPublish(writer); + expect(msg.name).toBe('assistant-message'); + expect(headersOf(msg)[HEADER_STREAM]).toBe('false'); + expect(headersOf(msg)[`${D}messageId`]).toBe('msg-abc'); + }); + + it('includes parentToolUseId header on assistant-message when present', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent(makeAssistantMessage({ parent_tool_use_id: 'tu-parent' })); + + const msg = firstPublish(writer); + expect(headersOf(msg)[`${D}parentToolUseId`]).toBe('tu-parent'); + }); + + it('omits parentToolUseId header on assistant-message when null', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent(makeAssistantMessage()); + + const msg = firstPublish(writer); + expect(headersOf(msg)[`${D}parentToolUseId`]).toBeUndefined(); + }); + + it('encodes Anthropic.SDKResultMessage as discrete result', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent(makeResultMessage()); + + const msg = firstPublish(writer); + expect(msg.name).toBe('result'); + expect(headersOf(msg)[HEADER_STREAM]).toBe('false'); + expect(headersOf(msg)[`${D}subtype`]).toBe('success'); + expect(msg.data).toEqual(makeResultMessage()); + }); + + it('encodes Anthropic.SDKResultMessage with error subtype', async () => { + const encoder = createEncoder(writer); + const result = makeResultMessage({ subtype: 'error_during_execution' } as Partial); + await encoder.appendEvent(result); + + const msg = firstPublish(writer); + expect(headersOf(msg)[`${D}subtype`]).toBe('error_during_execution'); + }); + + it('encodes Anthropic.SDKToolProgressMessage as discrete tool-progress', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent(makeToolProgressMessage()); + + const msg = firstPublish(writer); + expect(msg.name).toBe('tool-progress'); + expect(headersOf(msg)[HEADER_STREAM]).toBe('false'); + expect(msg.data).toEqual(makeToolProgressMessage()); + }); + + it('encodes Anthropic.SDKUserMessage as discrete user-message', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent(makeUserMessage()); + + const msg = firstPublish(writer); + expect(msg.name).toBe('user-message'); + expect(headersOf(msg)[HEADER_STREAM]).toBe('false'); + }); + + it('includes parentToolUseId header on user-message when present', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent(makeUserMessage({ parent_tool_use_id: 'tu-parent' })); + + const msg = firstPublish(writer); + expect(headersOf(msg)[`${D}parentToolUseId`]).toBe('tu-parent'); + }); + + it('includes isSynthetic header on user-message when true', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent(makeUserMessage({ isSynthetic: true })); + + const msg = firstPublish(writer); + expect(headersOf(msg)[`${D}isSynthetic`]).toBe('true'); + }); + + it('omits isSynthetic header on user-message when undefined', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent(makeUserMessage()); + + const msg = firstPublish(writer); + expect(headersOf(msg)[`${D}isSynthetic`]).toBeUndefined(); + }); + }); + + // -- streaming guard: assistant after stream is skipped ------------------- + + describe('streaming guard', () => { + it('skips discrete assistant-message after message was already streamed', async () => { + const encoder = createEncoder(writer); + + // Stream the message via message_start + await encoder.appendEvent( + makeStreamEvent({ + type: 'message_start', + message: { id: 'msg-abc', model: 'claude-opus-4-20250514', role: 'assistant', content: [] }, + }), + ); + + const publishCountAfterStream = writer.publishCalls.length; + + // Now the SDK emits the complete assistant message — should be skipped + await encoder.appendEvent(makeAssistantMessage()); + + expect(writer.publishCalls.length).toBe(publishCountAfterStream); + }); + + it('publishes discrete assistant-message when message was not streamed', async () => { + const encoder = createEncoder(writer); + + // Non-streaming mode: no prior stream_event, just the complete message + await encoder.appendEvent(makeAssistantMessage()); + + const msg = firstPublish(writer); + expect(msg.name).toBe('assistant-message'); + }); + + it('tracks streamed messages independently by message ID', async () => { + const encoder = createEncoder(writer); + + // Stream message A + await encoder.appendEvent( + makeStreamEvent({ + type: 'message_start', + message: { id: 'msg-a', model: 'claude-opus-4-20250514', role: 'assistant', content: [] }, + }), + ); + + const publishCountAfterStream = writer.publishCalls.length; + + // Publish complete message B (not streamed) — should publish. + // CAST: Override message.id via spread; the rest of the BetaMessage shape + // comes from makeAssistantMessage's default. + const msgB = makeAssistantMessage(); + (msgB.message as unknown as Record).id = 'msg-b'; + await encoder.appendEvent(msgB); + + expect(writer.publishCalls.length).toBe(publishCountAfterStream + 1); + const msg = lastPublish(writer); + expect(msg.name).toBe('assistant-message'); + }); + }); + + // -- signature_delta buffering ------------------------------------------------ + + describe('signature_delta buffering', () => { + it('buffers signature_delta and includes it in close headers', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 0, + content_block: { type: 'thinking', thinking: '' }, + }), + ); + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_delta', + index: 0, + delta: { type: 'signature_delta', signature: 'sig-abc' }, + }), + ); + + // signature_delta should NOT produce a stream append + expect(writer.appendCalls).toHaveLength(0); + + await encoder.appendEvent( + makeStreamEvent({ type: 'content_block_stop', index: 0 }), + ); + + // Close should include buffered signature in domain headers + const closeMsg = lastAppend(writer); + expect(headersOf(closeMsg)[`${D}signature`]).toBe('sig-abc'); + }); + + it('concatenates multiple signature_delta events into one header', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 0, + content_block: { type: 'thinking', thinking: '' }, + }), + ); + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_delta', + index: 0, + delta: { type: 'signature_delta', signature: 'part1' }, + }), + ); + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_delta', + index: 0, + delta: { type: 'signature_delta', signature: 'part2' }, + }), + ); + + // No stream appends for signature deltas + expect(writer.appendCalls).toHaveLength(0); + + await encoder.appendEvent( + makeStreamEvent({ type: 'content_block_stop', index: 0 }), + ); + + const closeMsg = lastAppend(writer); + expect(headersOf(closeMsg)[`${D}signature`]).toBe('part1part2'); + }); + + it('omits signature header when closing a block with no signature', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }), + ); + await encoder.appendEvent( + makeStreamEvent({ type: 'content_block_stop', index: 0 }), + ); + + const closeMsg = lastAppend(writer); + expect(headersOf(closeMsg)[`${D}signature`]).toBeUndefined(); + }); + + it('does not mix signature with thinking text appends', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 0, + content_block: { type: 'thinking', thinking: '' }, + }), + ); + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta', thinking: 'Let me think...' }, + }), + ); + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_delta', + index: 0, + delta: { type: 'signature_delta', signature: 'sig-123' }, + }), + ); + + // Only the thinking_delta should be a stream append + expect(writer.appendCalls).toHaveLength(1); + expect(writer.appendCalls[0]?.data).toBe('Let me think...'); + + await encoder.appendEvent( + makeStreamEvent({ type: 'content_block_stop', index: 0 }), + ); + + // Close carries the signature in headers, separate from stream data + const closeMsg = lastAppend(writer); + expect(headersOf(closeMsg)[`${D}signature`]).toBe('sig-123'); + }); + }); + + // -- unknown event types (no-op) ------------------------------------------ + + describe('unknown event types', () => { + it('ignores unknown top-level event types', async () => { + const encoder = createEncoder(writer); + // CAST: simulating an unknown event type the encoder should skip + await encoder.appendEvent({ type: 'auth_status' } as unknown as Anthropic.SDKPartialAssistantMessage); + + expect(writer.publishCalls).toHaveLength(0); + expect(writer.appendCalls).toHaveLength(0); + }); + + it('ignores unknown stream event types', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent( + makeStreamEvent({ type: 'some_future_event', data: {} }), + ); + + expect(writer.publishCalls).toHaveLength(0); + expect(writer.appendCalls).toHaveLength(0); + }); + }); + + // -- writeMessages -------------------------------------------------------- + + describe('writeMessages', () => { + it('encodes Anthropic.SDKUserMessage as discrete user-message in batch', async () => { + const encoder = createEncoder(writer); + await encoder.writeMessages([makeUserMessage()]); + + expect(writer.publishCalls).toHaveLength(1); + const batch = writer.publishCalls[0] as Ably.Message[]; + expect(batch).toHaveLength(1); + expect(batch[0]?.name).toBe('user-message'); + }); + + it('encodes Anthropic.SDKAssistantMessage as discrete assistant-message in batch', async () => { + const encoder = createEncoder(writer); + await encoder.writeMessages([makeAssistantMessage()]); + + expect(writer.publishCalls).toHaveLength(1); + const batch = writer.publishCalls[0] as Ably.Message[]; + expect(batch).toHaveLength(1); + expect(batch[0]?.name).toBe('assistant-message'); + if (batch[0]) expect(headersOf(batch[0])[`${D}messageId`]).toBe('msg-abc'); + }); + + it('encodes multiple messages as a single batch', async () => { + const encoder = createEncoder(writer); + await encoder.writeMessages([makeUserMessage(), makeAssistantMessage()]); + + expect(writer.publishCalls).toHaveLength(1); + const batch = writer.publishCalls[0] as Ably.Message[]; + expect(batch).toHaveLength(2); + expect(batch[0]?.name).toBe('user-message'); + expect(batch[1]?.name).toBe('assistant-message'); + }); + + it('includes parentToolUseId on user-message in batch', async () => { + const encoder = createEncoder(writer); + await encoder.writeMessages([makeUserMessage({ parent_tool_use_id: 'tu-parent' })]); + + const batch = writer.publishCalls[0] as Ably.Message[]; + if (batch[0]) expect(headersOf(batch[0])[`${D}parentToolUseId`]).toBe('tu-parent'); + }); + + it('includes parentToolUseId on assistant-message in batch', async () => { + const encoder = createEncoder(writer); + await encoder.writeMessages([makeAssistantMessage({ parent_tool_use_id: 'tu-parent' })]); + + const batch = writer.publishCalls[0] as Ably.Message[]; + if (batch[0]) expect(headersOf(batch[0])[`${D}parentToolUseId`]).toBe('tu-parent'); + }); + + it('includes isSynthetic on user-message in batch', async () => { + const encoder = createEncoder(writer); + await encoder.writeMessages([makeUserMessage({ isSynthetic: true })]); + + const batch = writer.publishCalls[0] as Ably.Message[]; + if (batch[0]) expect(headersOf(batch[0])[`${D}isSynthetic`]).toBe('true'); + }); + }); + + // -- writeEvent ----------------------------------------------------------- + + describe('writeEvent', () => { + it('publishes Anthropic.SDKResultMessage as discrete event', async () => { + const encoder = createEncoder(writer); + const result = await encoder.writeEvent(makeResultMessage()); + + expect(result).toEqual({ serials: ['serial-1'] }); + const msg = firstPublish(writer); + expect(msg.name).toBe('result'); + expect(headersOf(msg)[`${D}subtype`]).toBe('success'); + }); + + it('publishes Anthropic.SDKToolProgressMessage as discrete event', async () => { + const encoder = createEncoder(writer); + const result = await encoder.writeEvent(makeToolProgressMessage()); + + expect(result).toEqual({ serials: ['serial-1'] }); + const msg = firstPublish(writer); + expect(msg.name).toBe('tool-progress'); + }); + + it('throws for Anthropic.SDKPartialAssistantMessage (streaming event)', async () => { + const encoder = createEncoder(writer); + const streamEvent = makeStreamEvent({ + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }); + + await expect(encoder.writeEvent(streamEvent)).rejects.toThrow('unable to write event'); + }); + + it('throws for Anthropic.SDKAssistantMessage', async () => { + const encoder = createEncoder(writer); + await expect(encoder.writeEvent(makeAssistantMessage())).rejects.toThrow('unable to write event'); + }); + + it('throws for Anthropic.SDKUserMessage', async () => { + const encoder = createEncoder(writer); + await expect(encoder.writeEvent(makeUserMessage())).rejects.toThrow('unable to write event'); + }); + }); + + // -- abort ---------------------------------------------------------------- + + describe('abort', () => { + it('aborts all open streams and publishes discrete abort event', async () => { + const encoder = createEncoder(writer); + // Open a text stream + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }), + ); + + await encoder.abort('cancelled'); + + const abortMsg = lastPublish(writer); + expect(abortMsg.name).toBe('abort'); + expect(abortMsg.data).toBe('cancelled'); + expect(headersOf(abortMsg)[HEADER_STATUS]).toBe('aborted'); + + // The stream should have been aborted + const abortAppend = writer.appendCalls.find( + (m) => headersOf(m)[HEADER_STATUS] === 'aborted', + ); + expect(abortAppend).toBeDefined(); + }); + + it('is idempotent — second call is a no-op', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }), + ); + + await encoder.abort('cancelled'); + const publishCountAfterFirst = writer.publishCalls.length; + const appendCountAfterFirst = writer.appendCalls.length; + + await encoder.abort('cancelled'); + expect(writer.publishCalls.length).toBe(publishCountAfterFirst); + expect(writer.appendCalls.length).toBe(appendCountAfterFirst); + }); + + it('with no open streams publishes only the abort discrete event', async () => { + const encoder = createEncoder(writer); + await encoder.abort('user-stop'); + + expect(writer.publishCalls).toHaveLength(1); + const msg = firstPublish(writer); + expect(msg.name).toBe('abort'); + expect(msg.data).toBe('user-stop'); + expect(headersOf(msg)[HEADER_STATUS]).toBe('aborted'); + expect(writer.appendCalls).toHaveLength(0); + }); + + it('uses empty string when no reason is provided', async () => { + const encoder = createEncoder(writer); + await encoder.abort(); + + const msg = firstPublish(writer); + expect(msg.data).toBe(''); + }); + + it('aborts multiple open streams', async () => { + const encoder = createEncoder(writer); + // Open text stream at index 0 + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }), + ); + // Open tool stream at index 1 + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 1, + content_block: { type: 'tool_use', id: 'toolu_123', name: 'search' }, + }), + ); + + await encoder.abort('cancelled'); + + // Both streams should have been aborted + const abortAppends = writer.appendCalls.filter( + (m) => headersOf(m)[HEADER_STATUS] === 'aborted', + ); + expect(abortAppends).toHaveLength(2); + }); + }); + + // -- close ---------------------------------------------------------------- + + describe('close', () => { + it('delegates to core.close()', async () => { + const encoder = createEncoder(writer); + await encoder.close(); + + // Should not throw on double close + await encoder.close(); + }); + }); + + // -- edge cases ----------------------------------------------------------- + + describe('edge cases', () => { + it('content_block_delta for unknown block index is a no-op', async () => { + const encoder = createEncoder(writer); + // Send a delta without a preceding start + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_delta', + index: 99, + delta: { type: 'text_delta', text: 'orphan' }, + }), + ); + + expect(writer.appendCalls).toHaveLength(0); + }); + + it('content_block_stop for unknown block index is a no-op', async () => { + const encoder = createEncoder(writer); + // Send a stop without a preceding start + await encoder.appendEvent( + makeStreamEvent({ type: 'content_block_stop', index: 99 }), + ); + + expect(writer.appendCalls).toHaveLength(0); + }); + + it('content_block_delta with unknown delta type is a no-op', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }), + ); + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_delta', + index: 0, + delta: { type: 'citations_delta', citation: { url: 'https://example.com' } }, + }), + ); + + // Only the start publish, no appends from the unknown delta type + expect(writer.appendCalls).toHaveLength(0); + }); + + it('multiple content blocks at different indices are tracked independently', async () => { + const encoder = createEncoder(writer); + // Start text at index 0 + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }), + ); + // Start tool at index 1 + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_start', + index: 1, + content_block: { type: 'tool_use', id: 'toolu_123', name: 'search' }, + }), + ); + + // Delta to text (index 0) + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'Hello' }, + }), + ); + // Delta to tool (index 1) + await encoder.appendEvent( + makeStreamEvent({ + type: 'content_block_delta', + index: 1, + delta: { type: 'input_json_delta', partial_json: '{"q":"test"}' }, + }), + ); + + expect(writer.appendCalls).toHaveLength(2); + expect(writer.appendCalls[0]?.data).toBe('Hello'); + expect(writer.appendCalls[1]?.data).toBe('{"q":"test"}'); + + // Close both + await encoder.appendEvent( + makeStreamEvent({ type: 'content_block_stop', index: 0 }), + ); + await encoder.appendEvent( + makeStreamEvent({ type: 'content_block_stop', index: 1 }), + ); + + // 2 deltas + 2 closes = 4 appends + expect(writer.appendCalls).toHaveLength(4); + }); + + it('message_delta with null stop_reason omits stopReason header', async () => { + const encoder = createEncoder(writer); + await encoder.appendEvent( + makeStreamEvent({ + type: 'message_delta', + // eslint-disable-next-line unicorn/no-null -- SDK type uses null + delta: { stop_reason: null }, + usage: { output_tokens: 10 }, + }), + ); + + const msg = firstPublish(writer); + // null is coerced to undefined by `?? undefined` in the encoder + expect(headersOf(msg)[`${D}stopReason`]).toBeUndefined(); + }); + }); +}); diff --git a/test/anthropic/codec/index.test.ts b/test/anthropic/codec/index.test.ts new file mode 100644 index 00000000..18892417 --- /dev/null +++ b/test/anthropic/codec/index.test.ts @@ -0,0 +1,75 @@ +/** + * Anthropic AgentCodec entry point tests. + * + * Verifies the codec object is wired correctly: factory methods return + * the right types and isTerminal identifies result messages. + */ + +import type * as Anthropic from '@anthropic-ai/claude-agent-sdk'; +import { describe, expect, it } from 'vitest'; + +import { AgentCodec } from '../../../src/anthropic/codec/index.js'; +import type { AgentCodecEvent } from '../../../src/anthropic/codec/types.js'; + +describe('AgentCodec', () => { + it('creates an encoder', () => { + const mockWriter = { + publish: async () => await Promise.resolve({ serial: 's1' }), + appendMessage: async () => await Promise.resolve({ serial: 's1' }), + updateMessage: async () => await Promise.resolve({ serial: 's1' }), + }; + + const encoder = AgentCodec.createEncoder(mockWriter as never); + expect(encoder).toBeDefined(); + expect(typeof encoder.appendEvent).toBe('function'); + expect(typeof encoder.writeMessages).toBe('function'); + expect(typeof encoder.abort).toBe('function'); + expect(typeof encoder.close).toBe('function'); + }); + + it('creates a decoder', () => { + const decoder = AgentCodec.createDecoder(); + expect(decoder).toBeDefined(); + expect(typeof decoder.decode).toBe('function'); + }); + + it('creates an accumulator', () => { + const accumulator = AgentCodec.createAccumulator(); + expect(accumulator).toBeDefined(); + expect(accumulator.messages).toEqual([]); + expect(accumulator.completedMessages).toEqual([]); + expect(accumulator.hasActiveStream).toBe(false); + }); + + describe('isTerminal', () => { + it('returns true for result messages', () => { + const result = { + type: 'result', + subtype: 'success', + } as Anthropic.SDKResultMessage; + expect(AgentCodec.isTerminal(result as AgentCodecEvent)).toBe(true); + }); + + it('returns false for stream events', () => { + const streamEvent = { + type: 'stream_event', + } as AgentCodecEvent; + expect(AgentCodec.isTerminal(streamEvent)).toBe(false); + }); + + it('returns false for assistant messages', () => { + const assistant = { + type: 'assistant', + } as AgentCodecEvent; + expect(AgentCodec.isTerminal(assistant)).toBe(false); + }); + + it('returns false for user messages', () => { + const user = { + type: 'user', + } as AgentCodecEvent; + expect(AgentCodec.isTerminal(user)).toBe(false); + }); + }); + +});