diff --git a/README.md b/README.md
index 5081b92..50cebec 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,52 @@ const message = await complete(model!, {
console.log(message.content);
```
+## Consuming from webpack / Next.js
+
+The packages publish ESM with `.js`-suffixed relative imports (e.g.
+`from './foo.js'`), which is the correct ESM-with-TS pattern. Webpack does not
+auto-rewrite `.js` → `.ts` when reading TypeScript sources directly (e.g. when
+linking the workspace from `apps/`), so add an `extensionAlias` to your
+`next.config.mjs`:
+
+```js
+// next.config.mjs
+export default {
+ transpilePackages: [
+ 'agentic-kit',
+ '@agentic-kit/agent',
+ '@agentic-kit/react',
+ '@agentic-kit/openai',
+ '@agentic-kit/anthropic',
+ '@agentic-kit/ollama',
+ ],
+ webpack: (config) => {
+ config.resolve.extensionAlias = {
+ '.js': ['.ts', '.tsx', '.js'],
+ '.mjs': ['.mts', '.mjs'],
+ };
+ return config;
+ },
+};
+```
+
+Once a published artifact is installed (`npm install agentic-kit`), the
+compiled `dist/` is what resolves and no `extensionAlias` is required — this
+workaround only matters when reading TypeScript source through webpack.
+
+Vite, Bun, and esbuild handle `.js` → `.ts` natively. Vite users who want to
+consume the workspace TypeScript source via the package `"source"` condition
+can opt in with:
+
+```js
+// vite.config.ts
+export default {
+ resolve: {
+ conditions: ['source', 'import', 'module', 'browser', 'default'],
+ },
+};
+```
+
## Contributing
See individual package READMEs for docs and local dev instructions.
diff --git a/apps/nextjs-chat-demo/.env.example b/apps/nextjs-chat-demo/.env.example
new file mode 100644
index 0000000..40082f5
--- /dev/null
+++ b/apps/nextjs-chat-demo/.env.example
@@ -0,0 +1,9 @@
+# Either OPENAI_* or LLM_* (the LLM_* convention is shared with the dashboard).
+# OPENAI_* takes precedence if both are set.
+OPENAI_API_KEY=sk-...
+# OPENAI_BASE_URL=https://api.openai.com/v1
+# OPENAI_MODEL=gpt-5.4-mini
+
+# LLM_API_KEY=...
+# LLM_BASE_URL=https://api.deepseek.com/v1
+# LLM_MODEL=deepseek-chat
diff --git a/apps/nextjs-chat-demo/.gitignore b/apps/nextjs-chat-demo/.gitignore
new file mode 100644
index 0000000..6bee4d1
--- /dev/null
+++ b/apps/nextjs-chat-demo/.gitignore
@@ -0,0 +1,38 @@
+# dependencies
+node_modules
+.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+coverage
+
+# next.js
+.next/
+out/
+build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files
+.env
+.env*.local
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
diff --git a/apps/nextjs-chat-demo/README.md b/apps/nextjs-chat-demo/README.md
new file mode 100644
index 0000000..54a25bf
--- /dev/null
+++ b/apps/nextjs-chat-demo/README.md
@@ -0,0 +1,59 @@
+# nextjs-chat-demo
+
+A Next.js 15 demo proving `agentic-kit` can replace `@ai-sdk/react` for the
+dashboard chatbot. Demonstrates:
+
+- streaming chat via `useChat` from `@agentic-kit/react`
+- a plain server tool (`get_current_time`)
+- a **pausable** server tool (`send_email`) — model proposes args, the UI shows
+ Allow / Deny, the answer is fed back in via `respondWithDecision`, and the
+ agent resumes server-side.
+
+## Run
+
+```bash
+# from monorepo root
+pnpm install
+
+# point the demo at OpenAI
+export OPENAI_API_KEY=sk-...
+
+pnpm --filter nextjs-chat-demo dev
+# open http://localhost:3001
+```
+
+## AI SDK → agentic-kit migration map
+
+| Dashboard (AI SDK) | This demo (agentic-kit) |
+| -------------------------------------------------- | -------------------------------------------------------- |
+| `streamText` + `convertToModelMessages` | `Agent.prompt()` / `continue()` + `handle.toResponse()` |
+| `tool({ needsApproval: true })` | `AgentTool.decision` JSON Schema |
+| `addToolApprovalResponse({ id, approved })` | `respondWithDecision(toolCallId, value)` (auto re-POST) |
+| `result.toUIMessageStreamResponse()` | `handle.toResponse()` |
+| `useChat` from `@ai-sdk/react` | `useChat` from `@agentic-kit/react` |
+
+## Out of scope
+
+This demo deliberately does not port:
+
+- mentions / @-suggestions
+- multi-slot queue (`messageQueue`, `isFullySettled`, `sendAutomaticallyWhen`)
+- task queue UI (`plan_tasks`, `complete_task`, `approve_previous_tool`)
+- ask vs agent modes, settings menu
+- FAB + portal placement
+- history dropdown
+
+These are dashboard UI sugar that sits on top of the SDK, not in it.
+
+## Workspace dep wiring
+
+`@agentic-kit/react`, `@agentic-kit/agent`, and `agentic-kit` packages declare
+build outputs (`main: index.js`, `module: esm/index.js`) that don't exist on
+disk in development. To consume them without a build step the demo combines:
+
+- `tsconfig.json` `paths` map to `../../packages/*/src/index.ts`
+- `next.config.mjs` `transpilePackages` so SWC compiles the TS source
+- `experimental.externalDir` so Next is happy reading from outside the app dir
+
+See [`PLAN.md`](./PLAN.md) for the full implementation plan and
+[`GAPS.md`](./GAPS.md) for everything that felt rough to wire up.
diff --git a/apps/nextjs-chat-demo/next.config.mjs b/apps/nextjs-chat-demo/next.config.mjs
new file mode 100644
index 0000000..28ef9da
--- /dev/null
+++ b/apps/nextjs-chat-demo/next.config.mjs
@@ -0,0 +1,28 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ reactStrictMode: true,
+ transpilePackages: [
+ 'agentic-kit',
+ '@agentic-kit/agent',
+ '@agentic-kit/react',
+ '@agentic-kit/openai',
+ '@agentic-kit/anthropic',
+ '@agentic-kit/ollama',
+ ],
+ experimental: {
+ externalDir: true,
+ },
+ webpack: (config) => {
+ // The agentic-kit packages are TS source with .js extension imports
+ // (`from './foo.js'`). webpack doesn't auto-rewrite those to .ts; we
+ // teach it to fall back to the .ts source.
+ config.resolve.extensionAlias = {
+ ...(config.resolve.extensionAlias ?? {}),
+ '.js': ['.ts', '.tsx', '.js'],
+ '.mjs': ['.mts', '.mjs'],
+ };
+ return config;
+ },
+};
+
+export default nextConfig;
diff --git a/apps/nextjs-chat-demo/package.json b/apps/nextjs-chat-demo/package.json
new file mode 100644
index 0000000..529dc21
--- /dev/null
+++ b/apps/nextjs-chat-demo/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "nextjs-chat-demo",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "next dev --port 3001",
+ "start": "next start --port 3001",
+ "lint": "next lint"
+ },
+ "dependencies": {
+ "@agentic-kit/agent": "workspace:*",
+ "@agentic-kit/openai": "workspace:*",
+ "@agentic-kit/react": "workspace:*",
+ "agentic-kit": "workspace:*",
+ "clsx": "^2.1.1",
+ "next": "15.0.4",
+ "react": "19.0.0",
+ "react-dom": "19.0.0",
+ "tailwind-merge": "^3.5.0"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4.1.18",
+ "@types/node": "^22.10.2",
+ "@types/react": "19.0.0",
+ "@types/react-dom": "19.0.0",
+ "tailwindcss": "^4.1.18",
+ "typescript": "^5.7.2"
+ }
+}
diff --git a/apps/nextjs-chat-demo/postcss.config.mjs b/apps/nextjs-chat-demo/postcss.config.mjs
new file mode 100644
index 0000000..a34a3d5
--- /dev/null
+++ b/apps/nextjs-chat-demo/postcss.config.mjs
@@ -0,0 +1,5 @@
+export default {
+ plugins: {
+ '@tailwindcss/postcss': {},
+ },
+};
diff --git a/apps/nextjs-chat-demo/src/app/api/chat/route.ts b/apps/nextjs-chat-demo/src/app/api/chat/route.ts
new file mode 100644
index 0000000..6363019
--- /dev/null
+++ b/apps/nextjs-chat-demo/src/app/api/chat/route.ts
@@ -0,0 +1,94 @@
+import { Agent } from '@agentic-kit/agent';
+import { OpenAIAdapter } from '@agentic-kit/openai';
+import type { Message } from 'agentic-kit';
+
+import { tools } from '@/lib/tools';
+
+export const runtime = 'nodejs';
+export const dynamic = 'force-dynamic';
+
+const SYSTEM_PROMPT = [
+ 'You are a friendly assistant in a chat-app demo.',
+ 'You have two tools available:',
+ '- get_current_time(timezone?): returns the current time in the requested IANA timezone.',
+ '- send_email(to, subject, body): drafts an email. The user must approve before it is sent.',
+ 'When the user asks for the current time anywhere, call get_current_time.',
+ 'When the user asks you to send an email, call send_email exactly once and wait for the user decision.',
+ 'Keep replies short.',
+].join('\n');
+
+interface RequestBody {
+ messages: Message[];
+}
+
+function lastMessageHasPendingDecision(messages: Message[]): boolean {
+ const last = messages[messages.length - 1];
+ if (!last || last.role !== 'assistant') return false;
+ const completedToolCallIds = new Set(
+ messages
+ .filter((m): m is Extract => m.role === 'toolResult')
+ .map((m) => m.toolCallId)
+ );
+ return last.content.some(
+ (block) =>
+ block.type === 'toolCall' &&
+ !completedToolCallIds.has(block.id) &&
+ 'decision' in block &&
+ block.decision !== undefined
+ );
+}
+
+export async function POST(req: Request): Promise {
+ const apiKey = process.env.OPENAI_API_KEY ?? process.env.LLM_API_KEY;
+ const baseUrl =
+ process.env.OPENAI_BASE_URL ?? process.env.LLM_BASE_URL ?? 'https://api.openai.com/v1';
+ const modelId = process.env.OPENAI_MODEL ?? process.env.LLM_MODEL ?? 'gpt-5.4-mini';
+
+ if (!apiKey) {
+ return new Response('OPENAI_API_KEY (or LLM_API_KEY) is not set on the server', {
+ status: 500,
+ });
+ }
+
+ let body: RequestBody;
+ try {
+ body = (await req.json()) as RequestBody;
+ } catch {
+ return new Response('Invalid JSON body', { status: 400 });
+ }
+
+ const messages = Array.isArray(body.messages) ? body.messages : [];
+ if (messages.length === 0) {
+ return new Response('Empty messages', { status: 400 });
+ }
+
+ const adapter = new OpenAIAdapter({ apiKey, baseUrl });
+ const model = adapter.createModel(modelId);
+
+ const agent = new Agent({
+ initialState: { model, tools, systemPrompt: SYSTEM_PROMPT },
+ streamFn: (m, ctx, opts) => adapter.stream(m, ctx, opts),
+ maxSteps: 5,
+ });
+
+ const isResume = lastMessageHasPendingDecision(messages);
+
+ if (isResume) {
+ agent.replaceMessages(messages);
+ try {
+ const handle = agent.continue();
+ return handle.toResponse();
+ } catch (err) {
+ return new Response(`continue() failed: ${(err as Error).message}`, { status: 400 });
+ }
+ }
+
+ const last = messages[messages.length - 1];
+ if (last.role !== 'user') {
+ return new Response('Last message must be a user message when not resuming', { status: 400 });
+ }
+
+ agent.replaceMessages(messages.slice(0, -1));
+ const handle = agent.prompt(last);
+ return handle.toResponse();
+}
diff --git a/apps/nextjs-chat-demo/src/app/globals.css b/apps/nextjs-chat-demo/src/app/globals.css
new file mode 100644
index 0000000..ba8bba4
--- /dev/null
+++ b/apps/nextjs-chat-demo/src/app/globals.css
@@ -0,0 +1,9 @@
+@import "tailwindcss";
+
+:root {
+ color-scheme: light dark;
+}
+
+html, body {
+ height: 100%;
+}
diff --git a/apps/nextjs-chat-demo/src/app/layout.tsx b/apps/nextjs-chat-demo/src/app/layout.tsx
new file mode 100644
index 0000000..1db9e6c
--- /dev/null
+++ b/apps/nextjs-chat-demo/src/app/layout.tsx
@@ -0,0 +1,18 @@
+import './globals.css';
+
+import type { ReactNode } from 'react';
+
+export const metadata = {
+ title: 'agentic-kit chat demo',
+ description: 'Next.js demo proving agentic-kit can replace AI SDK for the dashboard chatbot.',
+};
+
+export default function RootLayout({ children }: { children: ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/apps/nextjs-chat-demo/src/app/page.tsx b/apps/nextjs-chat-demo/src/app/page.tsx
new file mode 100644
index 0000000..8d2be67
--- /dev/null
+++ b/apps/nextjs-chat-demo/src/app/page.tsx
@@ -0,0 +1,9 @@
+import { ChatPanel } from '@/components/chat-panel';
+
+export default function Page() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/nextjs-chat-demo/src/components/chat-input.tsx b/apps/nextjs-chat-demo/src/components/chat-input.tsx
new file mode 100644
index 0000000..89ebfb3
--- /dev/null
+++ b/apps/nextjs-chat-demo/src/components/chat-input.tsx
@@ -0,0 +1,59 @@
+'use client';
+
+import { type KeyboardEvent, useState } from 'react';
+
+import { cn } from '@/lib/cn';
+
+interface ChatInputProps {
+ disabled?: boolean;
+ placeholder?: string;
+ onSend: (text: string) => void;
+}
+
+export function ChatInput({ disabled, placeholder, onSend }: ChatInputProps) {
+ const [value, setValue] = useState('');
+
+ function submit() {
+ const text = value.trim();
+ if (!text || disabled) return;
+ onSend(text);
+ setValue('');
+ }
+
+ function onKeyDown(e: KeyboardEvent) {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ submit();
+ }
+ }
+
+ return (
+
+
+ );
+}
diff --git a/apps/nextjs-chat-demo/src/components/chat-messages.tsx b/apps/nextjs-chat-demo/src/components/chat-messages.tsx
new file mode 100644
index 0000000..90fa27c
--- /dev/null
+++ b/apps/nextjs-chat-demo/src/components/chat-messages.tsx
@@ -0,0 +1,175 @@
+'use client';
+
+import type { ToolDecisionPendingEvent } from '@agentic-kit/react';
+import type { AssistantMessage, Message } from 'agentic-kit';
+import { useEffect, useRef } from 'react';
+
+import { cn } from '@/lib/cn';
+
+import { ToolApprovalCard } from './tool-approval-card';
+import { ToolCallCard } from './tool-call-card';
+
+interface ChatMessagesProps {
+ messages: Message[];
+ streamingMessage: AssistantMessage | null;
+ pendingDecisions: ReadonlyMap;
+ executingToolCallIds: ReadonlySet;
+ respondWithDecision: (toolCallId: string, value: unknown) => Promise;
+ isStreaming: boolean;
+}
+
+export function ChatMessages({
+ messages,
+ streamingMessage,
+ pendingDecisions,
+ executingToolCallIds,
+ respondWithDecision,
+ isStreaming,
+}: ChatMessagesProps) {
+ const scrollRef = useRef(null);
+
+ useEffect(() => {
+ const node = scrollRef.current;
+ if (!node) return;
+ node.scrollTop = node.scrollHeight;
+ }, [messages, streamingMessage, isStreaming]);
+
+ const toolResultsByCallId = new Map<
+ string,
+ Extract
+ >();
+ for (const m of messages) {
+ if (m.role === 'toolResult') {
+ toolResultsByCallId.set(m.toolCallId, m);
+ }
+ }
+
+ return (
+
+ {messages.length === 0 && !streamingMessage ? (
+
No messages yet. Ask the assistant something.
+ ) : null}
+
+
+
+ );
+}
+
+interface AssistantMessageBodyProps {
+ message: AssistantMessage;
+ toolResultsByCallId: Map>;
+ pendingDecisions: ReadonlyMap;
+ executingToolCallIds: ReadonlySet;
+ respondWithDecision: (toolCallId: string, value: unknown) => Promise;
+}
+
+function AssistantMessageBody({
+ message,
+ toolResultsByCallId,
+ pendingDecisions,
+ executingToolCallIds,
+ respondWithDecision,
+}: AssistantMessageBodyProps) {
+ return (
+ <>
+ {message.content.map((block, i) => {
+ if (block.type === 'text') {
+ return (
+
+ {block.text}
+
+ );
+ }
+ if (block.type === 'toolCall') {
+ const result = toolResultsByCallId.get(block.id);
+ const needsDecision =
+ pendingDecisions.has(block.id) &&
+ !result &&
+ (!('decision' in block) || block.decision === undefined);
+ const isExecuting = executingToolCallIds.has(block.id);
+ return (
+
+ }
+ result={result}
+ isExecuting={isExecuting}
+ />
+ {needsDecision ? (
+ }
+ onAllow={() =>
+ void respondWithDecision(block.id, { approved: true })
+ }
+ onDeny={() =>
+ void respondWithDecision(block.id, { approved: false })
+ }
+ />
+ ) : null}
+
+ );
+ }
+ return null;
+ })}
+ >
+ );
+}
diff --git a/apps/nextjs-chat-demo/src/components/chat-panel.tsx b/apps/nextjs-chat-demo/src/components/chat-panel.tsx
new file mode 100644
index 0000000..f942052
--- /dev/null
+++ b/apps/nextjs-chat-demo/src/components/chat-panel.tsx
@@ -0,0 +1,88 @@
+'use client';
+
+import { useChat } from '@agentic-kit/react';
+import { createUserMessage, injectDeferralResults } from 'agentic-kit';
+
+import { ChatInput } from './chat-input';
+import { ChatMessages } from './chat-messages';
+
+const SUGGESTIONS = [
+ 'What time is it in Tokyo?',
+ 'Email alice@example.com about the meeting',
+ 'Tell me a one-liner about React',
+];
+
+export function ChatPanel() {
+ const chat = useChat({ api: '/api/chat' });
+
+ const showSuggestions = chat.messages.length === 0 && !chat.isStreaming;
+
+ return (
+
+
+
+
+
+ {chat.error ? (
+
+ {String((chat.error as Error)?.message ?? chat.error)}
+
+ ) : null}
+
+ {showSuggestions ? (
+
+ {SUGGESTIONS.map((s) => (
+ {
+ void chat.send(s);
+ }}
+ >
+ {s}
+
+ ))}
+
+ ) : null}
+
+ {
+ // If a decision is pending and the user types instead of clicking a
+ // button, treat the text as their response: synthesize deferral
+ // results for the dangling toolCalls so the next request is clean.
+ if (chat.pendingDecisions.size > 0) {
+ void chat.sendMessages([
+ ...injectDeferralResults(
+ chat.messages,
+ 'User chose to respond with a message instead.'
+ ),
+ createUserMessage(text),
+ ]);
+ return;
+ }
+ void chat.send(text);
+ }}
+ placeholder={
+ chat.pendingDecisions.size > 0
+ ? 'Type a response, or use the approve/deny buttons above…'
+ : 'Type a message…'
+ }
+ />
+
+ );
+}
diff --git a/apps/nextjs-chat-demo/src/components/tool-approval-card.tsx b/apps/nextjs-chat-demo/src/components/tool-approval-card.tsx
new file mode 100644
index 0000000..e319eee
--- /dev/null
+++ b/apps/nextjs-chat-demo/src/components/tool-approval-card.tsx
@@ -0,0 +1,37 @@
+'use client';
+
+interface ToolApprovalCardProps {
+ toolName: string;
+ args: Record;
+ onAllow: () => void;
+ onDeny: () => void;
+}
+
+export function ToolApprovalCard({ toolName, args, onAllow, onDeny }: ToolApprovalCardProps) {
+ return (
+
+
+ Approval required for {toolName}
+
+
+ {JSON.stringify(args, null, 2)}
+
+
+
+ Allow
+
+
+ Deny
+
+
+
+ );
+}
diff --git a/apps/nextjs-chat-demo/src/components/tool-call-card.tsx b/apps/nextjs-chat-demo/src/components/tool-call-card.tsx
new file mode 100644
index 0000000..a5de4a0
--- /dev/null
+++ b/apps/nextjs-chat-demo/src/components/tool-call-card.tsx
@@ -0,0 +1,62 @@
+'use client';
+
+import type { Message } from 'agentic-kit';
+import { useState } from 'react';
+
+interface ToolCallCardProps {
+ name: string;
+ args: Record;
+ result?: Extract;
+ isExecuting?: boolean;
+}
+
+export function ToolCallCard({ name, args, result, isExecuting }: ToolCallCardProps) {
+ const [open, setOpen] = useState(false);
+ const argsSummary = JSON.stringify(args);
+ const status = result
+ ? result.isError
+ ? 'error'
+ : 'done'
+ : isExecuting
+ ? 'running'
+ : 'pending';
+ const resultText = result
+ ? result.content
+ .map((c) => (c.type === 'text' ? c.text : `[${c.type} block]`))
+ .join('\n')
+ : '';
+
+ return (
+
+
setOpen((v) => !v)}
+ >
+
+ tool {' '}
+ {name}
+ {argsSummary}
+
+
+ {status}
+
+
+ {open && result ? (
+
+ {resultText}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/nextjs-chat-demo/src/lib/cn.ts b/apps/nextjs-chat-demo/src/lib/cn.ts
new file mode 100644
index 0000000..33a54ad
--- /dev/null
+++ b/apps/nextjs-chat-demo/src/lib/cn.ts
@@ -0,0 +1,6 @@
+import { type ClassValue,clsx } from 'clsx';
+import { twMerge } from 'tailwind-merge';
+
+export function cn(...inputs: ClassValue[]): string {
+ return twMerge(clsx(inputs));
+}
diff --git a/apps/nextjs-chat-demo/src/lib/tools.ts b/apps/nextjs-chat-demo/src/lib/tools.ts
new file mode 100644
index 0000000..36f95c2
--- /dev/null
+++ b/apps/nextjs-chat-demo/src/lib/tools.ts
@@ -0,0 +1,74 @@
+import type { AgentTool } from '@agentic-kit/agent';
+
+export const getCurrentTime: AgentTool = {
+ name: 'get_current_time',
+ label: 'Get current time',
+ description: 'Returns the current time in the requested IANA timezone.',
+ parameters: {
+ type: 'object',
+ properties: {
+ timezone: {
+ type: 'string',
+ description: 'IANA timezone, e.g. "America/Los_Angeles". Defaults to UTC.',
+ },
+ },
+ additionalProperties: false,
+ },
+ execute: async (_id, params) => {
+ const timezone = (params.timezone as string | undefined) ?? 'UTC';
+ let text: string;
+ try {
+ text = new Date().toLocaleString('en-US', { timeZone: timezone, timeZoneName: 'short' });
+ } catch (err) {
+ text = `Invalid timezone "${timezone}": ${(err as Error).message}`;
+ }
+ return { content: [{ type: 'text', text }] };
+ },
+};
+
+export const sendEmail: AgentTool = {
+ name: 'send_email',
+ label: 'Send email',
+ description:
+ 'Send an email. Always requires explicit user approval before the email is actually sent.',
+ parameters: {
+ type: 'object',
+ properties: {
+ to: { type: 'string', description: 'Recipient email address.' },
+ subject: { type: 'string', description: 'Subject line.' },
+ body: { type: 'string', description: 'Plain-text email body.' },
+ },
+ required: ['to', 'subject', 'body'],
+ additionalProperties: false,
+ },
+ decision: {
+ type: 'object',
+ properties: {
+ approved: { type: 'boolean', description: 'true if the user approved sending.' },
+ },
+ required: ['approved'],
+ additionalProperties: false,
+ },
+ execute: async (_id, params, decision) => {
+ const { approved } = (decision ?? {}) as { approved?: boolean };
+ if (!approved) {
+ return {
+ content: [
+ { type: 'text', text: 'User denied sending the email. The email was not sent.' },
+ ],
+ };
+ }
+ const to = params.to as string;
+ const subject = params.subject as string;
+ return {
+ content: [
+ {
+ type: 'text',
+ text: `Email sent to ${to} with subject "${subject}".`,
+ },
+ ],
+ };
+ },
+};
+
+export const tools: AgentTool[] = [getCurrentTime, sendEmail];
diff --git a/apps/nextjs-chat-demo/tsconfig.json b/apps/nextjs-chat-demo/tsconfig.json
new file mode 100644
index 0000000..2fb0879
--- /dev/null
+++ b/apps/nextjs-chat-demo/tsconfig.json
@@ -0,0 +1,59 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "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,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": [
+ "./src/*"
+ ],
+ "agentic-kit": [
+ "../../packages/agentic-kit/src/index.ts"
+ ],
+ "@agentic-kit/agent": [
+ "../../packages/agent/src/index.ts"
+ ],
+ "@agentic-kit/react": [
+ "../../packages/react/src/index.ts"
+ ],
+ "@agentic-kit/openai": [
+ "../../packages/openai/src/index.ts"
+ ],
+ "@agentic-kit/anthropic": [
+ "../../packages/anthropic/src/index.ts"
+ ],
+ "@agentic-kit/ollama": [
+ "../../packages/ollama/src/index.ts"
+ ]
+ },
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ]
+ },
+ "include": [
+ "**/*.ts",
+ "**/*.tsx",
+ "next-env.d.ts",
+ ".next/types/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules"
+ ]
+}
diff --git a/packages/agent/README.md b/packages/agent/README.md
index 7b83ee3..00df7d7 100644
--- a/packages/agent/README.md
+++ b/packages/agent/README.md
@@ -1,13 +1,167 @@
# @agentic-kit/agent
-Minimal stateful agent runtime for `agentic-kit`.
+
+
+
-This package provides:
+
+
+
+
+
+
+
-- sequential tool execution
-- lifecycle events for UI and orchestration
-- abort and continue semantics
-- pluggable context transforms
+Minimal stateful agent runtime built on `agentic-kit`. The `Agent` class drives
+a sequential model/tool loop, emits structured lifecycle events, and exposes a
+run handle that can be consumed as async events, a `ReadableStream`, or an SSE
+`Response` for transport to a frontend.
-It is intentionally minimal in v1 and sits on top of the lower-level
-`agentic-kit` provider portability layer.
+## Installation
+
+```bash
+npm install @agentic-kit/agent agentic-kit
+```
+
+## Quick Start
+
+```ts
+import { Agent } from '@agentic-kit/agent';
+import { getModel } from 'agentic-kit';
+
+const agent = new Agent({
+ initialState: {
+ model: getModel('openai', 'gpt-5.4-mini')!,
+ systemPrompt: 'You are a helpful assistant.',
+ tools: [],
+ },
+});
+
+await agent.prompt('What is 2 + 2?');
+console.log(agent.state.messages);
+```
+
+## Streaming a Run
+
+The `prompt()` and `continue()` methods return an `AgentRunHandle`. A handle
+can be consumed exactly once via one of these methods:
+
+```ts
+const handle = agent.prompt('Plan a trip to Lisbon.');
+
+for await (const event of handle.events()) {
+ if (event.type === 'message_update') {
+ process.stdout.write(JSON.stringify(event.assistantMessageEvent));
+ }
+}
+```
+
+- `await handle` — run to completion without observing events. The handle is
+ `PromiseLike`, so it `await`s directly. Equivalent to `handle.wait()`.
+- `handle.wait()` — explicit form of the above. Prefer this when the handle
+ might be passed through generic wrappers (`Promise.resolve(...)`,
+ `Promise.all([...])`) where accidental thenable assimilation would consume
+ it before you intended.
+- `handle.events()` — iterate `AgentEvent`s.
+- `handle.toReadableStream()` — wrap events in a `ReadableStream`.
+- `handle.toResponse(init?)` — wrap events as an SSE `Response`, ready to
+ return from a Next.js / Hono / Express handler.
+
+## SSE Transport
+
+`toResponse()` serializes events as `data: \n\n` frames. On the client,
+parse them back into `AgentEvent`s with `parseSSEStream`:
+
+```ts
+import { parseSSEStream } from '@agentic-kit/agent';
+
+const response = await fetch('/api/chat', { method: 'POST', body });
+for await (const event of parseSSEStream(response.body!)) {
+ // event is a typed AgentEvent
+}
+```
+
+## Tools, Decisions, and Pauses
+
+Tools extend the base `ToolDefinition` from `agentic-kit` with an executor and
+an optional human-in-the-loop `decision` schema. When a tool with a `decision`
+schema is called and no decision is attached, the agent emits a
+`tool_decision_pending` event and pauses. Attach the decision to the matching
+`toolCall` block and call `continue()` to resume.
+
+```ts
+const sendEmail: AgentTool = {
+ name: 'send_email',
+ label: 'Send email',
+ description: 'Send an email to a recipient.',
+ parameters: {
+ type: 'object',
+ properties: { to: { type: 'string' }, body: { type: 'string' } },
+ required: ['to', 'body'],
+ },
+ decision: {
+ type: 'object',
+ properties: { approved: { type: 'boolean' } },
+ required: ['approved'],
+ },
+ execute: async (toolCallId, args, decision) => {
+ if (!(decision as { approved: boolean }).approved) {
+ return { content: [{ type: 'text', text: 'Cancelled by user.' }] };
+ }
+ // ... send email
+ return { content: [{ type: 'text', text: 'Sent.' }] };
+ },
+};
+```
+
+## Agent API
+
+```ts
+new Agent(options: AgentOptions)
+```
+
+`AgentOptions`:
+
+- `initialState` — must include a `model`; `systemPrompt`, `tools`, and
+ `messages` are optional.
+- `maxSteps` — cap on model invocations per run. Resets in `prompt()`,
+ persists across `continue()`.
+- `streamFn` — override the underlying stream function (defaults to
+ `stream` from `agentic-kit`).
+- `transformContext(messages, signal)` — async hook to rewrite the message
+ list before each model call (compaction, summarization, retrieval).
+- `validateToolArguments(schema, args)` — override tool argument validation.
+ Default uses a built-in JSON Schema subset.
+
+State mutation:
+
+- `setModel`, `setSystemPrompt`, `setTools`, `setStreamOptions`
+- `replaceMessages`, `appendMessage`, `clearMessages`, `reset`
+
+Execution:
+
+- `prompt(input, opts?)` — start a new run from a user message.
+- `continue(opts?)` — resume after a paused decision or after the messages
+ array was edited externally. If the most recent pending assistant has
+ non-`toolResult` messages appended after it (e.g., a user message
+ injected while the tool was paused), `continue()` throws — use
+ `injectDeferralResults()` + `prompt()` from `agentic-kit` instead, which
+ synthesizes stand-in `toolResult`s before the new user message so the
+ transcript stays well-formed for OpenAI / Anthropic.
+- `abort()` — cancel the active run.
+- `waitForIdle()` — resolves when the current run finishes.
+- `subscribe(listener)` — receive `AgentEvent`s without consuming the handle.
+
+## Event Types
+
+`AgentEvent` is a discriminated union covering the full lifecycle:
+
+- `agent_start`, `agent_end` (with `stopReason: 'completed' | 'max_steps' | 'aborted'`)
+- `turn_start`, `turn_end`
+- `message_start`, `message_update`, `message_end`
+- `tool_execution_start`, `tool_execution_update`, `tool_execution_end`
+- `tool_decision_pending` (carries `input` and `schema`)
+
+Every `message_update` includes the underlying `assistantMessageEvent` from
+the provider stream (text/thinking/toolcall deltas), so consumers can render
+streaming text without re-deriving it from the partial message.
diff --git a/packages/agent/__tests__/agent.test.ts b/packages/agent/__tests__/agent.test.ts
index aa6681c..d0c7d92 100644
--- a/packages/agent/__tests__/agent.test.ts
+++ b/packages/agent/__tests__/agent.test.ts
@@ -2,119 +2,44 @@ import {
type AssistantMessage,
type Context,
createAssistantMessageEventStream,
+ type Message,
type ModelDescriptor,
+ type ToolCallContent,
} from 'agentic-kit';
+import {
+ createScriptedProvider,
+ makeFakeAssistantMessage,
+ makeFakeModel,
+} from '@test/index';
-import { Agent } from '../src';
-
-function createModel(): ModelDescriptor {
- return {
- id: 'demo',
- name: 'Demo',
- api: 'fake',
- provider: 'fake',
- baseUrl: 'http://fake.local',
- input: ['text'],
- reasoning: false,
- tools: true,
- };
-}
+import {
+ Agent,
+ type AgentEvent,
+ type AgentTool,
+ DecisionValidationError,
+} from '../src';
describe('@agentic-kit/agent', () => {
it('runs a minimal sequential tool loop', async () => {
const responses = [
- {
- role: 'assistant' as const,
- api: 'fake',
- provider: 'fake',
- model: 'demo',
- usage: {
- input: 1,
- output: 1,
- cacheRead: 0,
- cacheWrite: 0,
- totalTokens: 2,
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
- },
- stopReason: 'toolUse' as const,
- timestamp: Date.now(),
+ makeFakeAssistantMessage({
+ usage: makeUsage(),
+ stopReason: 'toolUse',
content: [
- { type: 'toolCall' as const, id: 'tool_1', name: 'echo', arguments: { text: 'hello' } },
+ { type: 'toolCall', id: 'tool_1', name: 'echo', arguments: { text: 'hello' } },
],
- },
- {
- role: 'assistant' as const,
- api: 'fake',
- provider: 'fake',
- model: 'demo',
- usage: {
- input: 1,
- output: 1,
- cacheRead: 0,
- cacheWrite: 0,
- totalTokens: 2,
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
- },
- stopReason: 'stop' as const,
- timestamp: Date.now(),
- content: [{ type: 'text' as const, text: 'done' }],
- },
+ }),
+ makeFakeAssistantMessage({
+ usage: makeUsage(),
+ stopReason: 'stop',
+ content: [{ type: 'text', text: 'done' }],
+ }),
];
- let callIndex = 0;
- const streamFn = (_model: ModelDescriptor, _context: Context) => {
- const stream = createAssistantMessageEventStream();
- const response = responses[callIndex++];
-
- queueMicrotask(() => {
- stream.push({ type: 'start', partial: response });
- if (response.content[0].type === 'toolCall') {
- stream.push({
- type: 'toolcall_start',
- contentIndex: 0,
- partial: response,
- });
- stream.push({
- type: 'toolcall_end',
- contentIndex: 0,
- toolCall: response.content[0],
- partial: response,
- });
- } else {
- stream.push({
- type: 'text_start',
- contentIndex: 0,
- partial: response,
- });
- stream.push({
- type: 'text_delta',
- contentIndex: 0,
- delta: 'done',
- partial: response,
- });
- stream.push({
- type: 'text_end',
- contentIndex: 0,
- content: 'done',
- partial: response,
- });
- }
- stream.push({
- type: 'done',
- reason: response.stopReason === 'toolUse' ? 'toolUse' : 'stop',
- message: response,
- });
- stream.end(response);
- });
-
- return stream;
- };
-
+ const provider = createScriptedProvider({ responses });
const agent = new Agent({
- initialState: {
- model: createModel(),
- },
- streamFn,
+ initialState: { model: makeFakeModel({ id: 'demo', name: 'Demo' }) },
+ streamFn: provider.stream,
});
agent.setTools([
@@ -129,7 +54,7 @@ describe('@agentic-kit/agent', () => {
},
required: ['text'],
},
- execute: async (_toolCallId, params) => ({
+ execute: async (_toolCallId, params, _decision) => ({
content: [{ type: 'text', text: String(params.text) }],
}),
},
@@ -155,20 +80,20 @@ describe('@agentic-kit/agent', () => {
it('turns tool argument validation failures into error tool results and continues', async () => {
const responses = [
- createAssistantResponse({
+ makeFakeAssistantMessage({
stopReason: 'toolUse',
content: [{ type: 'toolCall', id: 'tool_1', name: 'echo', arguments: {} }],
}),
- createAssistantResponse({
+ makeFakeAssistantMessage({
stopReason: 'stop',
content: [{ type: 'text', text: 'recovered' }],
}),
];
- let callIndex = 0;
+ const provider = createScriptedProvider({ responses });
const agent = new Agent({
- initialState: { model: createModel() },
- streamFn: () => streamMessage(responses[callIndex++]),
+ initialState: { model: makeFakeModel({ id: 'demo', name: 'Demo' }) },
+ streamFn: provider.stream,
});
const execute = jest.fn(async () => ({
@@ -211,10 +136,10 @@ describe('@agentic-kit/agent', () => {
it('records aborted assistant turns when the active stream is cancelled', async () => {
const agent = new Agent({
- initialState: { model: createModel() },
+ initialState: { model: makeFakeModel({ id: 'demo', name: 'Demo' }) },
streamFn: (_model: ModelDescriptor, _context: Context, options) => {
const stream = createAssistantMessageEventStream();
- const partial = createAssistantResponse({
+ const partial = makeFakeAssistantMessage({
stopReason: 'stop',
content: [{ type: 'text', text: '' }],
});
@@ -225,7 +150,7 @@ describe('@agentic-kit/agent', () => {
options?.signal?.addEventListener(
'abort',
() => {
- const aborted = createAssistantResponse({
+ const aborted: AssistantMessage = makeFakeAssistantMessage({
stopReason: 'aborted',
errorMessage: 'aborted by test',
content: [],
@@ -256,76 +181,510 @@ describe('@agentic-kit/agent', () => {
});
});
-function createAssistantResponse(overrides: Partial): AssistantMessage {
+function makeUsage() {
return {
- ...createAssistantResponseBase(),
- ...overrides,
+ input: 1,
+ output: 1,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 2,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
-function createAssistantResponseBase(): AssistantMessage {
- return {
- role: 'assistant' as const,
- api: 'fake',
- provider: 'fake',
- model: 'demo',
- usage: {
- input: 1,
- output: 1,
- cacheRead: 0,
- cacheWrite: 0,
- totalTokens: 2,
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
- },
- stopReason: 'stop' as const,
- timestamp: Date.now(),
- content: [] as AssistantMessage['content'],
- };
-}
+describe('@agentic-kit/agent — pausable tools', () => {
+ function makeApprovalTool(execute: AgentTool['execute']): AgentTool {
+ return {
+ name: 'approve',
+ label: 'Approve',
+ description: 'Tool that requires explicit approval',
+ parameters: {
+ type: 'object',
+ properties: { target: { type: 'string' } },
+ required: ['target'],
+ },
+ decision: {
+ type: 'object',
+ properties: { approved: { type: 'boolean' } },
+ required: ['approved'],
+ },
+ execute,
+ };
+ }
-function streamMessage(message: AssistantMessage) {
- const stream = createAssistantMessageEventStream();
+ function pauseResponse() {
+ return makeFakeAssistantMessage({
+ stopReason: 'toolUse',
+ content: [
+ { type: 'toolCall', id: 'tool_1', name: 'approve', arguments: { target: 'thing' } },
+ ],
+ });
+ }
- queueMicrotask(() => {
- stream.push({ type: 'start', partial: message });
- if (message.content[0]?.type === 'toolCall') {
- stream.push({
- type: 'toolcall_start',
- contentIndex: 0,
- partial: message,
- });
- stream.push({
- type: 'toolcall_end',
- contentIndex: 0,
- toolCall: message.content[0],
- partial: message,
- });
- } else {
- stream.push({
- type: 'text_start',
- contentIndex: 0,
- partial: message,
- });
- stream.push({
- type: 'text_delta',
- contentIndex: 0,
- delta: message.content[0]?.type === 'text' ? message.content[0].text : '',
- partial: message,
- });
- stream.push({
- type: 'text_end',
- contentIndex: 0,
- content: message.content[0]?.type === 'text' ? message.content[0].text : '',
- partial: message,
+ function finalResponse() {
+ return makeFakeAssistantMessage({
+ stopReason: 'stop',
+ content: [{ type: 'text', text: 'finalized' }],
+ });
+ }
+
+ function attachDecision(agent: Agent, toolCallId: string, decision: unknown): void {
+ const messages = agent.state.messages;
+ const last = messages[messages.length - 1] as AssistantMessage;
+ const updatedContent = last.content.map((block) =>
+ block.type === 'toolCall' && block.id === toolCallId
+ ? ({ ...block, decision } as ToolCallContent)
+ : block
+ );
+ const updated: AssistantMessage = { ...last, content: updatedContent };
+ agent.replaceMessages([...messages.slice(0, -1), updated]);
+ }
+
+ it('pauses on a decision-bearing tool and emits tool_decision_pending without runId', async () => {
+ const provider = createScriptedProvider({ responses: [pauseResponse()] });
+ const execute = jest.fn();
+ const events: AgentEvent[] = [];
+
+ const agent = new Agent({
+ initialState: { model: makeFakeModel() },
+ streamFn: provider.stream,
+ });
+ agent.subscribe((event) => events.push(event));
+ agent.setTools([makeApprovalTool(execute)]);
+
+ await agent.prompt('approve thing');
+
+ expect(execute).not.toHaveBeenCalled();
+ expect(agent.state.isStreaming).toBe(false);
+ expect(events.some((e) => e.type === 'agent_end')).toBe(false);
+
+ const pendingEvent = events.find((e) => e.type === 'tool_decision_pending');
+ expect(pendingEvent).toEqual({
+ type: 'tool_decision_pending',
+ toolCallId: 'tool_1',
+ toolName: 'approve',
+ input: { target: 'thing' },
+ schema: expect.objectContaining({ type: 'object' }),
+ });
+ expect(pendingEvent).not.toHaveProperty('runId');
+
+ const lastMessage = agent.state.messages.at(-1);
+ expect(lastMessage).toMatchObject({ role: 'assistant', stopReason: 'toolUse' });
+ const toolResults = agent.state.messages.filter((m) => m.role === 'toolResult');
+ expect(toolResults).toHaveLength(0);
+ });
+
+ it('continue() invokes execute with the decision attached to the tool call and continues the loop', async () => {
+ const provider = createScriptedProvider({ responses: [pauseResponse(), finalResponse()] });
+ const execute = jest.fn(
+ async (_id: string, _params: Record, decision: unknown) => ({
+ content: [{ type: 'text' as const, text: `decision=${JSON.stringify(decision)}` }],
+ })
+ );
+ const events: AgentEvent[] = [];
+
+ const agent = new Agent({
+ initialState: { model: makeFakeModel() },
+ streamFn: provider.stream,
+ });
+ agent.subscribe((event) => events.push(event));
+ agent.setTools([makeApprovalTool(execute)]);
+
+ await agent.prompt('approve thing');
+
+ attachDecision(agent, 'tool_1', { approved: true });
+
+ await agent.continue();
+
+ expect(execute).toHaveBeenCalledTimes(1);
+ expect(execute.mock.calls[0]?.[2]).toEqual({ approved: true });
+
+ expect(agent.state.messages.at(-1)).toMatchObject({
+ role: 'assistant',
+ content: [{ type: 'text', text: 'finalized' }],
+ });
+ expect(events.some((e) => e.type === 'agent_end')).toBe(true);
+ });
+
+ it('continue() throws DecisionValidationError synchronously on a malformed decision', async () => {
+ const provider = createScriptedProvider({ responses: [pauseResponse(), finalResponse()] });
+ const execute = jest.fn(async () => ({
+ content: [{ type: 'text' as const, text: 'ok' }],
+ }));
+
+ const agent = new Agent({
+ initialState: { model: makeFakeModel() },
+ streamFn: provider.stream,
+ });
+ agent.setTools([makeApprovalTool(execute)]);
+
+ await agent.prompt('approve thing');
+
+ attachDecision(agent, 'tool_1', { approved: 'yes' });
+
+ expect(() => agent.continue()).toThrow(DecisionValidationError);
+ expect(execute).not.toHaveBeenCalled();
+ const toolResults = agent.state.messages.filter((m) => m.role === 'toolResult');
+ expect(toolResults).toHaveLength(0);
+
+ attachDecision(agent, 'tool_1', { approved: true });
+ await agent.continue();
+
+ expect(execute).toHaveBeenCalledTimes(1);
+ });
+
+ it('continue() rejects when the trailing assistant has tool calls but no decisions attached', async () => {
+ const provider = createScriptedProvider({ responses: [pauseResponse()] });
+
+ const agent = new Agent({
+ initialState: { model: makeFakeModel() },
+ streamFn: provider.stream,
+ });
+ agent.setTools([makeApprovalTool(jest.fn())]);
+
+ await agent.prompt('approve thing');
+
+ expect(() => agent.continue()).toThrow(/no tool calls awaiting a decision/);
+ });
+
+ it('continue() rejects when non-toolResult messages have been appended after the pending assistant', async () => {
+ const provider = createScriptedProvider({ responses: [pauseResponse()] });
+ const execute = jest.fn();
+
+ const agent = new Agent({
+ initialState: { model: makeFakeModel() },
+ streamFn: provider.stream,
+ });
+ agent.setTools([makeApprovalTool(execute)]);
+
+ await agent.prompt('approve thing');
+
+ attachDecision(agent, 'tool_1', { approved: true });
+
+ const trailingNote: Message = {
+ role: 'user',
+ content: 'side note injected by an external queue while paused',
+ timestamp: Date.now(),
+ };
+ agent.replaceMessages([...agent.state.messages, trailingNote]);
+
+ expect(() => agent.continue()).toThrow(/non-toolResult messages have been appended/);
+ expect(execute).not.toHaveBeenCalled();
+ });
+
+ it('abort() while paused stops further work without throwing', async () => {
+ const provider = createScriptedProvider({ responses: [pauseResponse()] });
+
+ const agent = new Agent({
+ initialState: { model: makeFakeModel() },
+ streamFn: provider.stream,
+ });
+ agent.setTools([makeApprovalTool(jest.fn())]);
+
+ await agent.prompt('approve thing');
+
+ expect(() => agent.abort()).not.toThrow();
+ expect(agent.state.isStreaming).toBe(false);
+ });
+
+ it('flushes prior tool results before the args-validation error tool_result on a mixed batch', async () => {
+ const provider = createScriptedProvider({
+ responses: [
+ makeFakeAssistantMessage({
+ stopReason: 'toolUse',
+ content: [
+ { type: 'toolCall', id: 'tool_regular', name: 'echo', arguments: { text: 'first' } },
+ { type: 'toolCall', id: 'tool_approve', name: 'approve', arguments: {} },
+ ],
+ }),
+ makeFakeAssistantMessage({
+ stopReason: 'stop',
+ content: [{ type: 'text', text: 'recovered' }],
+ }),
+ ],
+ });
+
+ const regularExecute = jest.fn(async () => ({
+ content: [{ type: 'text' as const, text: 'first-result' }],
+ }));
+ const approveExecute = jest.fn(async () => ({
+ content: [{ type: 'text' as const, text: 'should not run' }],
+ }));
+
+ const agent = new Agent({
+ initialState: { model: makeFakeModel() },
+ streamFn: provider.stream,
+ });
+ agent.setTools([
+ {
+ name: 'echo',
+ label: 'Echo',
+ description: 'Echo text',
+ parameters: {
+ type: 'object',
+ properties: { text: { type: 'string' } },
+ required: ['text'],
+ },
+ execute: regularExecute,
+ },
+ makeApprovalTool(approveExecute),
+ ]);
+
+ await agent.prompt('go');
+
+ expect(regularExecute).toHaveBeenCalledTimes(1);
+ expect(approveExecute).not.toHaveBeenCalled();
+
+ const messages = agent.state.messages;
+ expect(messages[1]).toMatchObject({ role: 'assistant', stopReason: 'toolUse' });
+ expect(messages[2]).toMatchObject({
+ role: 'toolResult',
+ toolCallId: 'tool_regular',
+ toolName: 'echo',
+ content: [{ type: 'text', text: 'first-result' }],
+ });
+ expect(messages[3]).toMatchObject({
+ role: 'toolResult',
+ toolCallId: 'tool_approve',
+ toolName: 'approve',
+ isError: true,
+ });
+ expect(messages[3].content[0]).toMatchObject({
+ type: 'text',
+ text: expect.stringContaining('Tool argument validation failed'),
+ });
+ expect(messages[4]).toMatchObject({
+ role: 'assistant',
+ content: [{ type: 'text', text: 'recovered' }],
+ });
+ });
+
+ it('abort() during tool execution stops the loop and does not invoke the model again', async () => {
+ const provider = createScriptedProvider({
+ responses: [
+ makeFakeAssistantMessage({
+ stopReason: 'toolUse',
+ content: [
+ { type: 'toolCall', id: 'tool_slow', name: 'slow', arguments: {} },
+ ],
+ }),
+ makeFakeAssistantMessage({
+ stopReason: 'stop',
+ content: [{ type: 'text', text: 'should never reach here' }],
+ }),
+ ],
+ });
+
+ const streamCalls = jest.fn(provider.stream);
+ let abortAgent: (() => void) | undefined;
+ const slowExecute = jest.fn<
+ ReturnType,
+ Parameters
+ >((_id, _params, _decision, signal) => {
+ return new Promise((_resolve, reject) => {
+ signal?.addEventListener(
+ 'abort',
+ () => reject(new Error('aborted')),
+ { once: true }
+ );
+ queueMicrotask(() => abortAgent?.());
});
- }
- stream.push({
- type: 'done',
- reason: message.stopReason === 'toolUse' ? 'toolUse' : 'stop',
- message,
});
- stream.end(message);
+
+ const agent = new Agent({
+ initialState: { model: makeFakeModel() },
+ streamFn: streamCalls,
+ });
+ abortAgent = () => agent.abort();
+ agent.setTools([
+ {
+ name: 'slow',
+ label: 'Slow',
+ description: 'Slow tool',
+ parameters: { type: 'object', properties: {} },
+ execute: slowExecute,
+ },
+ ]);
+
+ const events: AgentEvent[] = [];
+ agent.subscribe((e) => events.push(e));
+
+ await agent.prompt('go');
+
+ expect(streamCalls).toHaveBeenCalledTimes(1);
+ expect(slowExecute).toHaveBeenCalledTimes(1);
+ const end = events.find(
+ (e): e is Extract => e.type === 'agent_end'
+ );
+ expect(end?.stopReason).toBe('aborted');
+ expect(agent.state.isStreaming).toBe(false);
});
- return stream;
-}
+ it('regression: a tool without a decision schema runs without pausing', async () => {
+ const provider = createScriptedProvider({
+ responses: [
+ makeFakeAssistantMessage({
+ stopReason: 'toolUse',
+ content: [
+ { type: 'toolCall', id: 'tool_1', name: 'echo', arguments: { text: 'hi' } },
+ ],
+ }),
+ makeFakeAssistantMessage({
+ stopReason: 'stop',
+ content: [{ type: 'text', text: 'done' }],
+ }),
+ ],
+ });
+ const execute = jest.fn(async () => ({
+ content: [{ type: 'text' as const, text: 'hi' }],
+ }));
+
+ const agent = new Agent({
+ initialState: { model: makeFakeModel() },
+ streamFn: provider.stream,
+ });
+ agent.setTools([
+ {
+ name: 'echo',
+ label: 'Echo',
+ description: 'Echo text',
+ parameters: {
+ type: 'object',
+ properties: { text: { type: 'string' } },
+ required: ['text'],
+ },
+ execute,
+ },
+ ]);
+
+ const events: AgentEvent[] = [];
+ agent.subscribe((e) => events.push(e));
+
+ await agent.prompt('go');
+
+ expect(execute).toHaveBeenCalledTimes(1);
+ expect(events.some((e) => e.type === 'tool_decision_pending')).toBe(false);
+ expect(events.some((e) => e.type === 'agent_end')).toBe(true);
+ });
+});
+
+describe('@agentic-kit/agent — maxSteps', () => {
+ function makeEchoTool(): AgentTool {
+ return {
+ name: 'echo',
+ label: 'Echo',
+ description: 'Echo text',
+ parameters: {
+ type: 'object',
+ properties: { text: { type: 'string' } },
+ required: ['text'],
+ },
+ execute: async (_id, params) => ({
+ content: [{ type: 'text', text: String(params.text) }],
+ }),
+ };
+ }
+
+ function toolThenText(toolText = 'one', finalText = 'done') {
+ return [
+ makeFakeAssistantMessage({
+ stopReason: 'toolUse',
+ content: [
+ { type: 'toolCall', id: 'tool_1', name: 'echo', arguments: { text: toolText } },
+ ],
+ }),
+ makeFakeAssistantMessage({
+ stopReason: 'stop',
+ content: [{ type: 'text', text: finalText }],
+ }),
+ ];
+ }
+
+ it('halts after the configured number of model calls and emits agent_end with stopReason=max_steps', async () => {
+ const provider = createScriptedProvider({ responses: toolThenText() });
+ const agent = new Agent({
+ initialState: { model: makeFakeModel() },
+ streamFn: provider.stream,
+ maxSteps: 1,
+ });
+ agent.setTools([makeEchoTool()]);
+
+ const events: AgentEvent[] = [];
+ agent.subscribe((e) => events.push(e));
+
+ await agent.prompt('go');
+
+ expect(agent.state.stepCount).toBe(1);
+ // Tool ran for the first turn, but no second model call.
+ const toolResults = agent.state.messages.filter((m) => m.role === 'toolResult');
+ expect(toolResults).toHaveLength(1);
+ const assistants = agent.state.messages.filter((m) => m.role === 'assistant');
+ expect(assistants).toHaveLength(1);
+
+ const end = events.find((e) => e.type === 'agent_end');
+ expect(end).toMatchObject({ type: 'agent_end', stopReason: 'max_steps' });
+ });
+
+ it('does not enforce a cap when maxSteps is undefined (no behavior change)', async () => {
+ const provider = createScriptedProvider({ responses: toolThenText() });
+ const agent = new Agent({
+ initialState: { model: makeFakeModel() },
+ streamFn: provider.stream,
+ });
+ agent.setTools([makeEchoTool()]);
+
+ const events: AgentEvent[] = [];
+ agent.subscribe((e) => events.push(e));
+
+ await agent.prompt('go');
+
+ expect(agent.state.stepCount).toBe(2);
+ expect(agent.state.messages.at(-1)).toMatchObject({
+ role: 'assistant',
+ content: [{ type: 'text', text: 'done' }],
+ });
+ const end = events.find((e) => e.type === 'agent_end');
+ expect(end).toMatchObject({ stopReason: 'completed' });
+ });
+
+ it('per-call maxSteps overrides the constructor default', async () => {
+ const provider = createScriptedProvider({ responses: toolThenText() });
+ const agent = new Agent({
+ initialState: { model: makeFakeModel() },
+ streamFn: provider.stream,
+ maxSteps: 1, // would cap; per-call override allows the second call
+ });
+ agent.setTools([makeEchoTool()]);
+
+ await agent.prompt('go', { maxSteps: 5 });
+
+ expect(agent.state.stepCount).toBe(2);
+ expect(agent.state.messages.at(-1)).toMatchObject({
+ role: 'assistant',
+ content: [{ type: 'text', text: 'done' }],
+ });
+ });
+
+ it('prompt() resets stepCount; continue() preserves it across turns', async () => {
+ // Two prompt rounds: first one consumes 2 steps; second prompt resets to 0.
+ const responses = [
+ ...toolThenText('first', 'first-done'),
+ makeFakeAssistantMessage({
+ stopReason: 'stop',
+ content: [{ type: 'text', text: 'second-done' }],
+ }),
+ ];
+ const provider = createScriptedProvider({ responses });
+ const agent = new Agent({
+ initialState: { model: makeFakeModel() },
+ streamFn: provider.stream,
+ });
+ agent.setTools([makeEchoTool()]);
+
+ await agent.prompt('first');
+ expect(agent.state.stepCount).toBe(2);
+
+ await agent.prompt('second');
+ expect(agent.state.stepCount).toBe(1);
+ });
+});
diff --git a/packages/agent/__tests__/run-handle.test.ts b/packages/agent/__tests__/run-handle.test.ts
new file mode 100644
index 0000000..00fbc71
--- /dev/null
+++ b/packages/agent/__tests__/run-handle.test.ts
@@ -0,0 +1,483 @@
+import {
+ type AssistantMessageEvent,
+ type Context,
+ createAssistantMessageEventStream,
+ type ModelDescriptor,
+ type StreamOptions,
+} from 'agentic-kit';
+import {
+ createScriptedProvider,
+ makeFakeAssistantMessage,
+ makeFakeModel,
+} from '@test/index';
+
+import { Agent, type AgentEvent, type AgentTool, parseSSEStream } from '../src';
+
+describe('AgentRunHandle', () => {
+ describe('events()', () => {
+ it('yields scripted events in emission order with correct shapes', async () => {
+ const provider = createScriptedProvider({
+ responses: [
+ makeFakeAssistantMessage({
+ stopReason: 'stop',
+ content: [{ type: 'text', text: 'hello' }],
+ }),
+ ],
+ });
+ const agent = new Agent({
+ initialState: { model: makeFakeModel() },
+ streamFn: provider.stream,
+ });
+
+ const handle = agent.prompt('hi');
+ const collected: AgentEvent[] = [];
+ for await (const event of handle.events()) {
+ collected.push(event);
+ }
+
+ expect(collected[0]).toEqual({ type: 'agent_start' });
+ const types = collected.map((e) => e.type);
+ expect(types).toContain('message_start');
+ expect(types).toContain('turn_start');
+ expect(types).toContain('turn_end');
+ expect(types[types.length - 1]).toBe('agent_end');
+
+ const subscribeEvents: AgentEvent[] = [];
+ const agent2 = new Agent({
+ initialState: { model: makeFakeModel() },
+ streamFn: createScriptedProvider({
+ responses: [
+ makeFakeAssistantMessage({
+ stopReason: 'stop',
+ content: [{ type: 'text', text: 'hello' }],
+ }),
+ ],
+ }).stream,
+ });
+ agent2.subscribe((e) => subscribeEvents.push(e));
+ await agent2.prompt('hi');
+ expect(collected.map((e) => e.type)).toEqual(subscribeEvents.map((e) => e.type));
+ });
+ });
+
+ describe('toReadableStream()', () => {
+ it('produces a ReadableStream whose events match the subscribe channel exactly', async () => {
+ const subscribeEvents: AgentEvent[] = [];
+ const subscribeAgent = new Agent({
+ initialState: { model: makeFakeModel() },
+ streamFn: createScriptedProvider({
+ responses: [
+ makeFakeAssistantMessage({
+ stopReason: 'stop',
+ content: [{ type: 'text', text: 'world' }],
+ }),
+ ],
+ }).stream,
+ });
+ subscribeAgent.subscribe((e) => subscribeEvents.push(e));
+ await subscribeAgent.prompt('hi');
+
+ const streamAgent = new Agent({
+ initialState: { model: makeFakeModel() },
+ streamFn: createScriptedProvider({
+ responses: [
+ makeFakeAssistantMessage({
+ stopReason: 'stop',
+ content: [{ type: 'text', text: 'world' }],
+ }),
+ ],
+ }).stream,
+ });
+
+ const stream = streamAgent.prompt('hi').toReadableStream();
+ const reader = stream.getReader();
+ const events: AgentEvent[] = [];
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ events.push(value);
+ }
+
+ expect(events.map((e) => e.type)).toEqual(subscribeEvents.map((e) => e.type));
+ });
+ });
+
+ describe('toResponse()', () => {
+ it('sets SSE headers and emits a body parseable by parseSSEStream', async () => {
+ const provider = createScriptedProvider({
+ responses: [
+ makeFakeAssistantMessage({
+ stopReason: 'stop',
+ content: [{ type: 'text', text: 'sse' }],
+ }),
+ ],
+ });
+ const agent = new Agent({
+ initialState: { model: makeFakeModel() },
+ streamFn: provider.stream,
+ });
+
+ const response = agent.prompt('hi').toResponse();
+ expect(response.headers.get('Content-Type')).toBe('text/event-stream');
+ const cacheControl = response.headers.get('Cache-Control') ?? '';
+ expect(cacheControl).toMatch(/no-cache/);
+ expect(response.headers.get('Connection')).toBe('keep-alive');
+
+ expect(response.body).toBeInstanceOf(ReadableStream);
+ const events: AgentEvent[] = [];
+ for await (const event of parseSSEStream(response.body!)) {
+ events.push(event);
+ }
+
+ expect(events[0]).toEqual({ type: 'agent_start' });
+ expect(events.at(-1)?.type).toBe('agent_end');
+ });
+
+ it('respects user-supplied headers without clobbering them', () => {
+ const provider = createScriptedProvider({
+ responses: [
+ makeFakeAssistantMessage({
+ stopReason: 'stop',
+ content: [{ type: 'text', text: 'sse' }],
+ }),
+ ],
+ });
+ const agent = new Agent({
+ initialState: { model: makeFakeModel() },
+ streamFn: provider.stream,
+ });
+
+ const response = agent.prompt('hi').toResponse({
+ status: 202,
+ headers: { 'X-Custom': 'yes', 'Cache-Control': 'private' },
+ });
+ expect(response.status).toBe(202);
+ expect(response.headers.get('X-Custom')).toBe('yes');
+ expect(response.headers.get('Cache-Control')).toBe('private');
+ // Drain to avoid leaking
+ void response.body?.cancel();
+ });
+ });
+
+ describe('backpressure', () => {
+ it('throttles the producer when the consumer stops reading', async () => {
+ const target = makeFakeAssistantMessage({
+ stopReason: 'stop',
+ content: [{ type: 'text', text: 'x'.repeat(200) }],
+ });
+ const TOTAL_DELTAS = 200;
+
+ const burstStreamFn = (
+ _model: ModelDescriptor,
+ _context: Context,
+ _options?: StreamOptions
+ ) => {
+ const stream = createAssistantMessageEventStream();
+ queueMicrotask(() => {
+ stream.push({ type: 'start', partial: target });
+ stream.push({ type: 'text_start', contentIndex: 0, partial: target });
+ for (let i = 0; i < TOTAL_DELTAS; i++) {
+ const delta: AssistantMessageEvent = {
+ type: 'text_delta',
+ contentIndex: 0,
+ delta: 'x',
+ partial: target,
+ };
+ stream.push(delta);
+ }
+ stream.push({
+ type: 'text_end',
+ contentIndex: 0,
+ content: target.content[0].type === 'text' ? target.content[0].text : '',
+ partial: target,
+ });
+ stream.push({ type: 'done', reason: 'stop', message: target });
+ stream.end(target);
+ });
+ return stream;
+ };
+
+ const agent = new Agent({
+ initialState: { model: makeFakeModel() },
+ streamFn: burstStreamFn,
+ });
+
+ let emitCount = 0;
+ agent.subscribe(() => {
+ emitCount++;
+ });
+
+ const stream = agent.prompt('go').toReadableStream();
+ const reader = stream.getReader();
+
+ // Read just enough to start, then stop. The producer should stall well
+ // before reaching TOTAL_DELTAS.
+ await reader.read();
+
+ // Yield repeatedly so the agent loop has every chance to push more.
+ for (let i = 0; i < 50; i++) {
+ await new Promise((resolve) => setImmediate(resolve));
+ }
+
+ // hwm=8 by default; one read frees one slot. Allow plenty of headroom
+ // for events the agent emits before each text_delta begins streaming
+ // and for any in-flight push.
+ expect(emitCount).toBeLessThan(50);
+ expect(emitCount).toBeLessThan(TOTAL_DELTAS);
+
+ // Drain the stream so the run can finish cleanly.
+ while (true) {
+ const { done } = await reader.read();
+ if (done) break;
+ }
+
+ expect(emitCount).toBeGreaterThan(TOTAL_DELTAS);
+ });
+ });
+
+ describe('cancel propagation', () => {
+ function makeAbortableStreamFn(): {
+ streamFn: (model: ModelDescriptor, context: Context, options?: StreamOptions) => ReturnType;
+ getSignal: () => AbortSignal | undefined;
+ } {
+ let capturedSignal: AbortSignal | undefined;
+ const streamFn = (
+ _model: ModelDescriptor,
+ _context: Context,
+ options?: StreamOptions
+ ) => {
+ capturedSignal = options?.signal;
+ const stream = createAssistantMessageEventStream();
+ const finishAborted = () => {
+ const aborted = makeFakeAssistantMessage({
+ stopReason: 'aborted',
+ errorMessage: 'cancelled by consumer',
+ content: [],
+ });
+ stream.push({ type: 'error', reason: 'aborted', error: aborted });
+ stream.end(aborted);
+ };
+ if (options?.signal?.aborted) {
+ queueMicrotask(finishAborted);
+ } else {
+ options?.signal?.addEventListener('abort', finishAborted, { once: true });
+ }
+ return stream;
+ };
+ return { streamFn, getSignal: () => capturedSignal };
+ }
+
+ it('aborts streamFn and clears isStreaming when reader.cancel() is called', async () => {
+ const { streamFn, getSignal } = makeAbortableStreamFn();
+ const agent = new Agent({
+ initialState: { model: makeFakeModel() },
+ streamFn,
+ });
+
+ const handle = agent.prompt('go');
+ const stream = handle.toReadableStream();
+ const reader = stream.getReader();
+
+ await reader.read();
+ await reader.cancel();
+ await handle;
+
+ expect(getSignal()?.aborted).toBe(true);
+ expect(agent.state.isStreaming).toBe(false);
+ });
+
+ it('aborts streamFn and clears isStreaming when response.body.cancel() is called', async () => {
+ const { streamFn, getSignal } = makeAbortableStreamFn();
+ const agent = new Agent({
+ initialState: { model: makeFakeModel() },
+ streamFn,
+ });
+
+ const handle = agent.prompt('go');
+ const response = handle.toResponse();
+ const reader = response.body!.getReader();
+
+ await reader.read();
+ await reader.cancel();
+ await handle;
+
+ expect(getSignal()?.aborted).toBe(true);
+ expect(agent.state.isStreaming).toBe(false);
+ });
+
+ it('aborts streamFn when events() iteration breaks early', async () => {
+ const { streamFn, getSignal } = makeAbortableStreamFn();
+ const agent = new Agent({
+ initialState: { model: makeFakeModel() },
+ streamFn,
+ });
+
+ const handle = agent.prompt('go');
+ for await (const _event of handle.events()) {
+ break;
+ }
+ await agent.waitForIdle();
+
+ expect(getSignal()?.aborted).toBe(true);
+ expect(agent.state.isStreaming).toBe(false);
+ });
+ });
+
+ describe('single-use enforcement', () => {
+ it('throws when a second consumer is attached', async () => {
+ const provider = createScriptedProvider({
+ responses: [
+ makeFakeAssistantMessage({
+ stopReason: 'stop',
+ content: [{ type: 'text', text: 'x' }],
+ }),
+ ],
+ });
+ const agent = new Agent({
+ initialState: { model: makeFakeModel() },
+ streamFn: provider.stream,
+ });
+
+ const handle = agent.prompt('hi');
+ handle.events();
+ expect(() => handle.toReadableStream()).toThrow(/already consumed/);
+ expect(() => handle.toResponse()).toThrow(/already consumed/);
+ expect(() => handle.events()).toThrow(/already consumed/);
+ });
+
+ it('throws on a second prompt() while a handle from the first is still unconsumed', () => {
+ const provider = createScriptedProvider({
+ responses: [
+ makeFakeAssistantMessage({
+ stopReason: 'stop',
+ content: [{ type: 'text', text: 'x' }],
+ }),
+ ],
+ });
+ const agent = new Agent({
+ initialState: { model: makeFakeModel() },
+ streamFn: provider.stream,
+ });
+
+ const first = agent.prompt('hi');
+ expect(() => agent.prompt('hi again')).toThrow(/unconsumed run handle/);
+
+ // abort frees the agent state; first remains a dangling handle reference
+ agent.abort();
+ expect(() => agent.prompt('after abort')).not.toThrow();
+ void first;
+ });
+
+ it('throws when toResponse() is called twice', () => {
+ const provider = createScriptedProvider({
+ responses: [
+ makeFakeAssistantMessage({
+ stopReason: 'stop',
+ content: [{ type: 'text', text: 'x' }],
+ }),
+ ],
+ });
+ const agent = new Agent({
+ initialState: { model: makeFakeModel() },
+ streamFn: provider.stream,
+ });
+
+ const handle = agent.prompt('hi');
+ const first = handle.toResponse();
+ expect(() => handle.toResponse()).toThrow(/already consumed/);
+ void first.body?.cancel();
+ });
+ });
+
+ describe('wait()', () => {
+ it('drives the run to completion without an explicit event consumer', async () => {
+ const provider = createScriptedProvider({
+ responses: [
+ makeFakeAssistantMessage({
+ stopReason: 'stop',
+ content: [{ type: 'text', text: 'done' }],
+ }),
+ ],
+ });
+ const agent = new Agent({
+ initialState: { model: makeFakeModel() },
+ streamFn: provider.stream,
+ });
+
+ await agent.prompt('hi');
+
+ expect(agent.state.messages.at(-1)).toMatchObject({
+ role: 'assistant',
+ content: [{ type: 'text', text: 'done' }],
+ });
+ expect(agent.state.isStreaming).toBe(false);
+ });
+
+ it('rejects when the binder rejects (e.g. streamFn throws)', async () => {
+ const agent = new Agent({
+ initialState: { model: makeFakeModel() },
+ streamFn: () => {
+ throw new Error('binder failure');
+ },
+ });
+
+ await expect(agent.prompt('hi').wait()).rejects.toThrow(/binder failure/);
+ expect(agent.state.isStreaming).toBe(false);
+ });
+
+ it('resolves when the run pauses on a decision-bearing tool', async () => {
+ const provider = createScriptedProvider({
+ responses: [
+ makeFakeAssistantMessage({
+ stopReason: 'toolUse',
+ content: [
+ {
+ type: 'toolCall',
+ id: 'tool_1',
+ name: 'approve',
+ arguments: { target: 'thing' },
+ },
+ ],
+ }),
+ ],
+ });
+ const execute = jest.fn();
+ const tool: AgentTool = {
+ name: 'approve',
+ label: 'Approve',
+ description: 'Tool that requires explicit approval',
+ parameters: {
+ type: 'object',
+ properties: { target: { type: 'string' } },
+ required: ['target'],
+ },
+ decision: {
+ type: 'object',
+ properties: { approved: { type: 'boolean' } },
+ required: ['approved'],
+ },
+ execute,
+ };
+
+ const agent = new Agent({
+ initialState: { model: makeFakeModel() },
+ streamFn: provider.stream,
+ });
+ agent.setTools([tool]);
+
+ const events: AgentEvent[] = [];
+ agent.subscribe((e) => events.push(e));
+
+ await agent.prompt('approve thing');
+
+ expect(execute).not.toHaveBeenCalled();
+ expect(agent.state.isStreaming).toBe(false);
+ const pending = events.find((e) => e.type === 'tool_decision_pending');
+ expect(pending).toMatchObject({
+ type: 'tool_decision_pending',
+ toolCallId: 'tool_1',
+ toolName: 'approve',
+ });
+ });
+ });
+});
diff --git a/packages/agent/__tests__/sse.test.ts b/packages/agent/__tests__/sse.test.ts
new file mode 100644
index 0000000..4cbc0b9
--- /dev/null
+++ b/packages/agent/__tests__/sse.test.ts
@@ -0,0 +1,111 @@
+// Exercises `parseSSEStream` exported from `@agentic-kit/agent`. Symmetric to
+// the SSE producer in `toResponse()` — these tests pin down the parser's
+// edge-case behavior so the wire-format contract has a baseline.
+import { type AgentEvent, parseSSEStream } from '../src';
+
+const encoder = new TextEncoder();
+
+function streamFromChunks(chunks: string[]): ReadableStream {
+ return new ReadableStream({
+ start(controller) {
+ for (const chunk of chunks) {
+ controller.enqueue(encoder.encode(chunk));
+ }
+ controller.close();
+ },
+ });
+}
+
+async function collect(stream: ReadableStream): Promise {
+ const out: AgentEvent[] = [];
+ for await (const event of parseSSEStream(stream)) {
+ out.push(event);
+ }
+ return out;
+}
+
+describe('parseSSEStream', () => {
+ it('parses a single complete event', async () => {
+ const events = await collect(streamFromChunks(['data: {"type":"agent_start"}\n\n']));
+ expect(events).toEqual([{ type: 'agent_start' }]);
+ });
+
+ it('reassembles a payload split across chunks', async () => {
+ const events = await collect(
+ streamFromChunks(['data: {"type":"agen', 't_start"}\n', '\n'])
+ );
+ expect(events).toEqual([{ type: 'agent_start' }]);
+ });
+
+ it('joins multiple data: lines with newlines into a single payload', async () => {
+ const events = await collect(
+ streamFromChunks(['data: {"type":\ndata: "agent_start"}\n\n'])
+ );
+ expect(events).toEqual([{ type: 'agent_start' }]);
+ });
+
+ it('ignores comment lines starting with `:`', async () => {
+ const events = await collect(
+ streamFromChunks([': keepalive\ndata: {"type":"turn_start"}\n\n'])
+ );
+ expect(events).toEqual([{ type: 'turn_start' }]);
+ });
+
+ it('ignores event:, id:, and retry: framing fields', async () => {
+ const events = await collect(
+ streamFromChunks([
+ 'event: turn_start\nid: 1\nretry: 1000\ndata: {"type":"turn_start"}\n\n',
+ ])
+ );
+ expect(events).toEqual([{ type: 'turn_start' }]);
+ });
+
+ it('skips a [DONE] marker without yielding an event', async () => {
+ const events = await collect(
+ streamFromChunks([
+ 'data: {"type":"agent_start"}\n\ndata: [DONE]\n\n',
+ ])
+ );
+ expect(events).toEqual([{ type: 'agent_start' }]);
+ });
+
+ it('handles trailing newlines without emitting a spurious event', async () => {
+ const events = await collect(
+ streamFromChunks(['data: {"type":"agent_start"}\n\n\n\n'])
+ );
+ expect(events).toEqual([{ type: 'agent_start' }]);
+ });
+
+ it('handles CRLF line endings', async () => {
+ const events = await collect(
+ streamFromChunks(['data: {"type":"agent_start"}\r\n\r\n'])
+ );
+ expect(events).toEqual([{ type: 'agent_start' }]);
+ });
+
+ it('drops a final incomplete event when the stream ends mid-event', async () => {
+ const events = await collect(
+ streamFromChunks([
+ 'data: {"type":"agent_start"}\n\n',
+ 'data: {"type":"turn_start"}',
+ ])
+ );
+ expect(events).toEqual([{ type: 'agent_start' }]);
+ });
+
+ it('yields multiple complete events in order', async () => {
+ const events = await collect(
+ streamFromChunks([
+ 'data: {"type":"agent_start"}\n\ndata: {"type":"turn_start"}\n\n',
+ ])
+ );
+ expect(events).toEqual([{ type: 'agent_start' }, { type: 'turn_start' }]);
+ });
+
+ it('honors an optional space after the `data:` field name', async () => {
+ const events = await collect(
+ streamFromChunks(['data:{"type":"agent_start"}\n\n'])
+ );
+ expect(events).toEqual([{ type: 'agent_start' }]);
+ });
+});
diff --git a/packages/agent/__tests__/tsconfig.json b/packages/agent/__tests__/tsconfig.json
index 6c4fda5..3ae83c4 100644
--- a/packages/agent/__tests__/tsconfig.json
+++ b/packages/agent/__tests__/tsconfig.json
@@ -2,9 +2,19 @@
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
- "rootDir": "..",
+ "rootDir": "../../..",
+ "baseUrl": "../../..",
+ "paths": {
+ "@test/*": ["tools/test/*"],
+ "agentic-kit": ["packages/agentic-kit/src"],
+ "@agentic-kit/agent": ["packages/agent/src"]
+ },
"types": ["jest", "node"]
},
- "include": ["./**/*.ts", "../src/**/*.ts"],
+ "include": [
+ "./**/*.ts",
+ "../src/**/*.ts",
+ "../../../tools/test/**/*.ts"
+ ],
"exclude": ["../dist", "../node_modules"]
}
diff --git a/packages/agent/jest.config.js b/packages/agent/jest.config.js
index 6622fd1..2069518 100644
--- a/packages/agent/jest.config.js
+++ b/packages/agent/jest.config.js
@@ -17,6 +17,7 @@ module.exports = {
modulePathIgnorePatterns: ['dist/*'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
+ '^@test/(.*)$': '/../../tools/test/$1',
'^agentic-kit$': '/../agentic-kit/src',
'^@agentic-kit/(.*)$': '/../$1/src',
},
diff --git a/packages/agent/package.json b/packages/agent/package.json
index c4cbeb2..7edb2c9 100644
--- a/packages/agent/package.json
+++ b/packages/agent/package.json
@@ -6,6 +6,14 @@
"main": "index.js",
"module": "esm/index.js",
"types": "index.d.ts",
+ "exports": {
+ ".": {
+ "source": "./src/index.ts",
+ "types": "./index.d.ts",
+ "import": "./esm/index.js",
+ "require": "./index.js"
+ }
+ },
"homepage": "https://github.com/constructive-io/agentic-kit",
"license": "SEE LICENSE IN LICENSE",
"publishConfig": {
diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts
index 8fc8503..887e646 100644
--- a/packages/agent/src/agent.ts
+++ b/packages/agent/src/agent.ts
@@ -6,8 +6,14 @@ import {
type Message,
stream,
type StreamOptions,
+ type ToolCallContent,
} from 'agentic-kit';
+import {
+ type AgentRunHandle,
+ DefaultAgentRunHandle,
+ type RunChannelPush,
+} from './run-handle.js';
import type {
AgentEvent,
AgentOptions,
@@ -15,15 +21,22 @@ import type {
AgentTool,
AgentToolResult,
} from './types.js';
-import { validateToolArguments as defaultValidateToolArguments } from './validation.js';
+import {
+ DecisionValidationError,
+ validateSchema,
+ validateToolArguments as defaultValidateToolArguments,
+} from './validation.js';
export class Agent {
private readonly listeners = new Set<(event: AgentEvent) => void>();
private readonly transformContext?: AgentOptions['transformContext'];
private readonly streamFn: NonNullable;
private readonly validateToolArguments: NonNullable;
+ private readonly defaultMaxSteps?: number;
private abortController?: AbortController;
private running?: Promise;
+ private runChannel?: { push: RunChannelPush };
+ private outstandingHandle?: AgentRunHandle;
private _state: AgentState;
@@ -33,6 +46,7 @@ export class Agent {
tools: [],
messages: [],
isStreaming: false,
+ stepCount: 0,
streamMessage: null,
streamOptions: undefined,
...options.initialState,
@@ -40,6 +54,7 @@ export class Agent {
this.streamFn = options.streamFn ?? stream;
this.transformContext = options.transformContext;
this.validateToolArguments = options.validateToolArguments ?? defaultValidateToolArguments;
+ this.defaultMaxSteps = options.maxSteps;
}
get state(): AgentState {
@@ -89,90 +104,232 @@ export class Agent {
abort(): void {
this.abortController?.abort();
+ this.outstandingHandle = undefined;
}
waitForIdle(): Promise {
return this.running ?? Promise.resolve();
}
- async prompt(input: string | Message): Promise {
- if (this._state.isStreaming) {
- throw new Error('Agent is already processing a prompt');
- }
+ prompt(input: string | Message, opts?: { maxSteps?: number }): AgentRunHandle {
+ this.assertIdle('prompt');
const message = typeof input === 'string' ? createUserMessage(input) : input;
- await this.runLoop([message]);
+ this._state.stepCount = 0;
+
+ const handle: AgentRunHandle = new DefaultAgentRunHandle(async (push, signal) => {
+ if (this.outstandingHandle === handle) {
+ this.outstandingHandle = undefined;
+ }
+ return this.runLoop({
+ initialMessages: [message],
+ externalPush: push ?? undefined,
+ externalAbortSignal: signal,
+ maxSteps: opts?.maxSteps ?? this.defaultMaxSteps,
+ });
+ });
+ this.outstandingHandle = handle;
+ return handle;
}
- async continue(): Promise {
- if (this._state.isStreaming) {
- throw new Error('Agent is already processing');
- }
+ continue(opts?: { maxSteps?: number }): AgentRunHandle {
+ this.assertIdle('continue');
- const lastMessage = this._state.messages[this._state.messages.length - 1];
- if (!lastMessage) {
+ if (this._state.messages.length === 0) {
throw new Error('No messages to continue from');
}
- if (lastMessage.role === 'assistant') {
- throw new Error('Cannot continue from message role: assistant');
+
+ const pendingMessage = this.findMostRecentPendingAssistant();
+ if (pendingMessage) {
+ const pendingIndex = this._state.messages.indexOf(pendingMessage);
+ const trailing = this._state.messages.slice(pendingIndex + 1);
+ const hasNonToolResultTrailing = trailing.some((m) => m.role !== 'toolResult');
+ if (hasNonToolResultTrailing) {
+ throw new Error(
+ 'Cannot continue() with a pending decision when non-toolResult messages have been appended after the pending assistant. Use injectDeferralResults() + prompt() instead — see the agentic-kit deferral docs.'
+ );
+ }
+ const pendingDecisions = this.findPendingDecisions(pendingMessage);
+ for (const { tool, decision } of pendingDecisions) {
+ const errors = validateSchema(tool.decision!, decision, 'root');
+ if (errors.length > 0) {
+ throw new DecisionValidationError(tool.name, errors);
+ }
+ }
+ } else {
+ const lastMessage = this._state.messages[this._state.messages.length - 1];
+ if (lastMessage.role === 'assistant') {
+ throw new Error(
+ 'Cannot continue from trailing assistant message: no tool calls awaiting a decision'
+ );
+ }
}
- await this.runLoop();
+ const handle: AgentRunHandle = new DefaultAgentRunHandle(async (push, signal) => {
+ if (this.outstandingHandle === handle) {
+ this.outstandingHandle = undefined;
+ }
+ return this.runLoop({
+ externalPush: push ?? undefined,
+ externalAbortSignal: signal,
+ maxSteps: opts?.maxSteps ?? this.defaultMaxSteps,
+ });
+ });
+ this.outstandingHandle = handle;
+ return handle;
}
- private async runLoop(initialMessages?: Message[]): Promise {
+ private assertIdle(method: 'prompt' | 'continue'): void {
+ if (this._state.isStreaming) {
+ throw new Error(`Agent is already processing; cannot call ${method}() while a run is active`);
+ }
+ if (this.outstandingHandle) {
+ throw new Error(
+ `Agent has an unconsumed run handle from a previous ${method}()/prompt()/continue() call; consume it (events / toReadableStream / toResponse / wait) or abort the agent before issuing another`
+ );
+ }
+ }
+
+ private findMostRecentPendingAssistant(): AssistantMessage | undefined {
+ for (let i = this._state.messages.length - 1; i >= 0; i--) {
+ const msg = this._state.messages[i];
+ if (msg.role !== 'assistant') continue;
+ const pending = this.findPendingDecisions(msg);
+ if (pending.length > 0) return msg;
+ }
+ return undefined;
+ }
+
+ private findPendingDecisions(
+ message: AssistantMessage
+ ): Array<{ toolCall: ToolCallContent; tool: AgentTool; decision: unknown }> {
+ const completedToolCallIds = new Set(
+ this._state.messages
+ .filter((m): m is Extract => m.role === 'toolResult')
+ .map((m) => m.toolCallId)
+ );
+
+ const pending: Array<{ toolCall: ToolCallContent; tool: AgentTool; decision: unknown }> = [];
+ for (const block of message.content) {
+ if (block.type !== 'toolCall') {
+ continue;
+ }
+ if (completedToolCallIds.has(block.id)) {
+ continue;
+ }
+ if (!('decision' in block) || block.decision === undefined) {
+ continue;
+ }
+ const tool = this._state.tools.find((t) => t.name === block.name);
+ if (!tool || !tool.decision) {
+ continue;
+ }
+ pending.push({ toolCall: block, tool, decision: block.decision });
+ }
+ return pending;
+ }
+
+ private async runLoop(opts: {
+ initialMessages?: Message[];
+ externalPush?: RunChannelPush;
+ externalAbortSignal?: AbortSignal;
+ maxSteps?: number;
+ }): Promise {
this.running = (async () => {
this.abortController = new AbortController();
+ const localAbortController = this.abortController;
this._state.isStreaming = true;
this._state.streamMessage = null;
this._state.error = undefined;
+ if (opts.externalPush) {
+ this.runChannel = { push: opts.externalPush };
+ }
+
+ const onExternalAbort = () => localAbortController.abort();
+ if (opts.externalAbortSignal) {
+ if (opts.externalAbortSignal.aborted) {
+ localAbortController.abort();
+ } else {
+ opts.externalAbortSignal.addEventListener('abort', onExternalAbort, { once: true });
+ }
+ }
+
+ let stopReason: 'completed' | 'max_steps' | 'aborted' = 'completed';
try {
- this.emit({ type: 'agent_start' });
+ await this.emit({ type: 'agent_start' });
- if (initialMessages && initialMessages.length > 0) {
- for (const message of initialMessages) {
- this.emit({ type: 'message_start', message });
+ if (opts.initialMessages && opts.initialMessages.length > 0) {
+ for (const message of opts.initialMessages) {
+ await this.emit({ type: 'message_start', message });
this.appendMessage(message);
- this.emit({ type: 'message_end', message });
+ await this.emit({ type: 'message_end', message });
}
}
- while (true) {
- this.emit({ type: 'turn_start' });
+ let resumeAssistant: AssistantMessage | undefined =
+ this.findMostRecentPendingAssistant();
- const assistantMessage = await this.generateAssistantMessage(this.abortController.signal);
- this.appendMessage(assistantMessage);
- this.emit({ type: 'message_end', message: assistantMessage });
-
- if (assistantMessage.stopReason === 'error' || assistantMessage.stopReason === 'aborted') {
- this._state.error = assistantMessage.errorMessage;
- this.emit({ type: 'turn_end', message: assistantMessage, toolResults: [] });
- break;
+ while (true) {
+ let assistantMessage: AssistantMessage;
+
+ if (resumeAssistant) {
+ assistantMessage = resumeAssistant;
+ resumeAssistant = undefined;
+ } else {
+ if (
+ opts.maxSteps !== undefined &&
+ this._state.stepCount >= opts.maxSteps
+ ) {
+ stopReason = 'max_steps';
+ break;
+ }
+ this._state.stepCount += 1;
+
+ await this.emit({ type: 'turn_start' });
+ assistantMessage = await this.generateAssistantMessage(localAbortController.signal);
+ this.appendMessage(assistantMessage);
+ await this.emit({ type: 'message_end', message: assistantMessage });
+
+ if (assistantMessage.stopReason === 'error' || assistantMessage.stopReason === 'aborted') {
+ this._state.error = assistantMessage.errorMessage;
+ await this.emit({ type: 'turn_end', message: assistantMessage, toolResults: [] });
+ break;
+ }
}
- const toolCalls = assistantMessage.content.filter((block) => block.type === 'toolCall');
+ const toolCalls = assistantMessage.content.filter(
+ (block): block is ToolCallContent => block.type === 'toolCall'
+ );
if (toolCalls.length === 0) {
- this.emit({ type: 'turn_end', message: assistantMessage, toolResults: [] });
+ await this.emit({ type: 'turn_end', message: assistantMessage, toolResults: [] });
break;
}
- const toolResults = await this.executeToolCalls(toolCalls, this.abortController.signal);
- for (const toolResult of toolResults) {
- this.emit({ type: 'message_start', message: toolResult });
- this.appendMessage(toolResult);
- this.emit({ type: 'message_end', message: toolResult });
+ const outcome = await this.executeToolCalls(toolCalls, localAbortController.signal);
+
+ if (outcome.status === 'paused') {
+ return;
}
- this.emit({ type: 'turn_end', message: assistantMessage, toolResults });
+ await this.emit({ type: 'turn_end', message: assistantMessage, toolResults: outcome.results });
+
+ if (localAbortController.signal.aborted) {
+ stopReason = 'aborted';
+ break;
+ }
}
- this.emit({ type: 'agent_end', messages: [...this._state.messages] });
+ await this.emit({ type: 'agent_end', messages: [...this._state.messages], stopReason });
} finally {
+ if (opts.externalAbortSignal) {
+ opts.externalAbortSignal.removeEventListener('abort', onExternalAbort);
+ }
this._state.isStreaming = false;
this._state.streamMessage = null;
this.abortController = undefined;
this.running = undefined;
+ this.runChannel = undefined;
}
})();
@@ -199,7 +356,7 @@ export class Agent {
switch (event.type) {
case 'start':
this._state.streamMessage = event.partial;
- this.emit({ type: 'message_start', message: event.partial });
+ await this.emit({ type: 'message_start', message: event.partial });
break;
case 'text_start':
case 'text_delta':
@@ -211,7 +368,7 @@ export class Agent {
case 'toolcall_delta':
case 'toolcall_end':
this._state.streamMessage = event.partial;
- this.emit({
+ await this.emit({
type: 'message_update',
message: event.partial,
assistantMessageEvent: event,
@@ -228,73 +385,175 @@ export class Agent {
}
private async executeToolCalls(
- toolCalls: Array>,
+ toolCalls: ToolCallContent[],
signal: AbortSignal
- ) {
- const results = [];
+ ): Promise<
+ | { status: 'completed'; results: ReturnType[] }
+ | { status: 'paused' }
+ > {
+ const completedToolCallIds = new Set(
+ this._state.messages
+ .filter((m): m is Extract => m.role === 'toolResult')
+ .map((m) => m.toolCallId)
+ );
+
+ const results: ReturnType[] = [];
for (const toolCall of toolCalls) {
- const tool = this._state.tools.find((candidate) => candidate.name === toolCall.name);
- this.emit({
- type: 'tool_execution_start',
- toolCallId: toolCall.id,
- toolName: toolCall.name,
- args: toolCall.arguments as Record,
- });
+ if (completedToolCallIds.has(toolCall.id)) {
+ continue;
+ }
- let result: AgentToolResult;
- let isError = false;
+ if (signal.aborted) {
+ break;
+ }
- try {
- if (!tool) {
- throw new Error(`Tool '${toolCall.name}' not found`);
+ const tool = this._state.tools.find((candidate) => candidate.name === toolCall.name);
+ const args = toolCall.arguments as Record;
+ const decisionAttached = 'decision' in toolCall && toolCall.decision !== undefined;
+
+ if (tool?.decision && !decisionAttached) {
+ let validatedArgs: Record;
+ try {
+ validatedArgs = this.validateToolArguments(tool.parameters, args);
+ } catch (error) {
+ for (const prior of results) {
+ await this.appendMessageWithEvents(prior);
+ }
+ results.length = 0;
+
+ const result: AgentToolResult = {
+ content: [
+ {
+ type: 'text',
+ text: error instanceof Error ? error.message : String(error),
+ },
+ ],
+ };
+ await this.emit({
+ type: 'tool_execution_start',
+ toolCallId: toolCall.id,
+ toolName: toolCall.name,
+ args,
+ });
+ await this.emit({
+ type: 'tool_execution_end',
+ toolCallId: toolCall.id,
+ toolName: toolCall.name,
+ result,
+ isError: true,
+ });
+ const toolResult = createToolResultMessage(toolCall.id, toolCall.name, result.content, true);
+ await this.appendMessageWithEvents(toolResult);
+ continue;
}
- const validatedArgs = this.validateToolArguments(
- tool.parameters,
- toolCall.arguments as Record
- );
+ for (const toolResult of results) {
+ await this.appendMessageWithEvents(toolResult);
+ }
+
+ await this.emit({
+ type: 'tool_decision_pending',
+ toolCallId: toolCall.id,
+ toolName: toolCall.name,
+ input: validatedArgs,
+ schema: tool.decision,
+ });
+ return { status: 'paused' };
+ }
- result = await tool.execute(toolCall.id, validatedArgs, signal, (partialResult) => {
- this.emit({
+ const decisionForExecute = decisionAttached ? toolCall.decision : undefined;
+ const toolResult = await this.executeOneTool(
+ tool,
+ toolCall,
+ args,
+ decisionForExecute,
+ signal
+ );
+ results.push(toolResult);
+ }
+
+ for (const toolResult of results) {
+ await this.appendMessageWithEvents(toolResult);
+ }
+
+ return { status: 'completed', results };
+ }
+
+ private async executeOneTool(
+ tool: AgentTool | undefined,
+ toolCall: ToolCallContent,
+ args: Record,
+ decision: unknown,
+ signal: AbortSignal
+ ): Promise> {
+ await this.emit({
+ type: 'tool_execution_start',
+ toolCallId: toolCall.id,
+ toolName: toolCall.name,
+ args,
+ });
+
+ let result: AgentToolResult;
+ let isError = false;
+
+ try {
+ if (!tool) {
+ throw new Error(`Tool '${toolCall.name}' not found`);
+ }
+
+ const validatedArgs = this.validateToolArguments(tool.parameters, args);
+
+ result = await tool.execute(
+ toolCall.id,
+ validatedArgs,
+ decision,
+ signal,
+ (partialResult) => {
+ void this.emit({
type: 'tool_execution_update',
toolCallId: toolCall.id,
toolName: toolCall.name,
args: validatedArgs,
partialResult,
});
- });
- } catch (error) {
- result = {
- content: [
- {
- type: 'text',
- text: error instanceof Error ? error.message : String(error),
- },
- ],
- };
- isError = true;
- }
-
- this.emit({
- type: 'tool_execution_end',
- toolCallId: toolCall.id,
- toolName: toolCall.name,
- result,
- isError,
- });
-
- results.push(
- createToolResultMessage(toolCall.id, toolCall.name, result.content, isError)
+ }
);
+ } catch (error) {
+ result = {
+ content: [
+ {
+ type: 'text',
+ text: error instanceof Error ? error.message : String(error),
+ },
+ ],
+ };
+ isError = true;
}
- return results;
+ await this.emit({
+ type: 'tool_execution_end',
+ toolCallId: toolCall.id,
+ toolName: toolCall.name,
+ result,
+ isError,
+ });
+
+ return createToolResultMessage(toolCall.id, toolCall.name, result.content, isError);
}
- private emit(event: AgentEvent): void {
+ private async appendMessageWithEvents(message: Message): Promise {
+ await this.emit({ type: 'message_start', message });
+ this.appendMessage(message);
+ await this.emit({ type: 'message_end', message });
+ }
+
+ private async emit(event: AgentEvent): Promise {
for (const listener of this.listeners) {
listener(event);
}
+ if (this.runChannel) {
+ await this.runChannel.push(event);
+ }
}
}
diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts
index b8b99bb..46e2f2a 100644
--- a/packages/agent/src/index.ts
+++ b/packages/agent/src/index.ts
@@ -1,3 +1,5 @@
export * from './agent.js';
+export * from './run-handle.js';
+export * from './sse.js';
export * from './types.js';
export * from './validation.js';
diff --git a/packages/agent/src/run-handle.ts b/packages/agent/src/run-handle.ts
new file mode 100644
index 0000000..ac85eef
--- /dev/null
+++ b/packages/agent/src/run-handle.ts
@@ -0,0 +1,218 @@
+import type { AgentEvent } from './types.js';
+
+export type RunChannelPush = (event: AgentEvent) => Promise;
+
+export type AgentRunBinder = (
+ push: RunChannelPush | null,
+ signal: AbortSignal
+) => Promise;
+
+export interface AgentRunHandle extends PromiseLike {
+ events(): AsyncIterable;
+ toReadableStream(): ReadableStream;
+ toResponse(init?: ResponseInit): Response;
+ /**
+ * Run to completion without observing events. Equivalent to `await handle`
+ * (the handle is `PromiseLike`), but explicit. Use this if you want
+ * to avoid accidental thenable assimilation in code paths where the handle
+ * might be passed through generic wrappers.
+ */
+ wait(): Promise;
+}
+
+const DEFAULT_HIGH_WATER_MARK = 8;
+
+export interface AgentRunHandleOptions {
+ highWaterMark?: number;
+}
+
+export class DefaultAgentRunHandle implements AgentRunHandle {
+ private startedAs: 'events' | 'stream' | 'response' | 'sink' | null = null;
+ private completion: Promise | null = null;
+ private readonly highWaterMark: number;
+
+ constructor(
+ private readonly bind: AgentRunBinder,
+ options: AgentRunHandleOptions = {}
+ ) {
+ this.highWaterMark = options.highWaterMark ?? DEFAULT_HIGH_WATER_MARK;
+ }
+
+ events(): AsyncIterable {
+ const stream = this.startStream('events');
+ return readableStreamToAsyncIterable(stream);
+ }
+
+ toReadableStream(): ReadableStream {
+ return this.startStream('stream');
+ }
+
+ toResponse(init?: ResponseInit): Response {
+ const stream = this.startStream('response');
+ const sse = stream.pipeThrough(createSSETransform());
+
+ const headers = new Headers(init?.headers);
+ if (!headers.has('Content-Type')) {
+ headers.set('Content-Type', 'text/event-stream');
+ }
+ if (!headers.has('Cache-Control')) {
+ headers.set('Cache-Control', 'no-cache, no-transform');
+ }
+ if (!headers.has('Connection')) {
+ headers.set('Connection', 'keep-alive');
+ }
+
+ const responseInit: ResponseInit = { ...init, headers };
+ return new Response(sse, responseInit);
+ }
+
+ wait(): Promise {
+ if (!this.startedAs) {
+ this.startSink();
+ }
+ return this.completion!;
+ }
+
+ then(
+ onfulfilled?: ((value: void) => TResult1 | PromiseLike) | null,
+ onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null
+ ): Promise {
+ return this.wait().then(onfulfilled, onrejected);
+ }
+
+ private ensureNotStarted(via: NonNullable): void {
+ if (this.startedAs && this.startedAs !== via) {
+ throw new Error(
+ `AgentRunHandle already consumed via ${this.startedAs}; cannot also call ${via}()`
+ );
+ }
+ if (this.startedAs === via) {
+ throw new Error(`AgentRunHandle already consumed via ${via}()`);
+ }
+ }
+
+ private startStream(via: 'events' | 'stream' | 'response'): ReadableStream {
+ this.ensureNotStarted(via);
+ this.startedAs = via;
+
+ const abortController = new AbortController();
+ let cancelled = false;
+ const pullWaiters = new Set<() => void>();
+
+ const releasePullWaiters = () => {
+ if (pullWaiters.size === 0) {
+ return;
+ }
+ const waiters = Array.from(pullWaiters);
+ pullWaiters.clear();
+ for (const resolve of waiters) {
+ resolve();
+ }
+ };
+
+ let runPromise: Promise;
+
+ const stream = new ReadableStream(
+ {
+ start: (controller) => {
+ const push: RunChannelPush = async (event) => {
+ if (cancelled) {
+ return;
+ }
+ try {
+ controller.enqueue(event);
+ } catch {
+ cancelled = true;
+ return;
+ }
+ while (!cancelled && (controller.desiredSize ?? 1) <= 0) {
+ await new Promise((resolve) => {
+ pullWaiters.add(resolve);
+ });
+ }
+ };
+
+ runPromise = (async () => {
+ try {
+ await this.bind(push, abortController.signal);
+ if (!cancelled) {
+ try {
+ controller.close();
+ } catch {
+ // already closed
+ }
+ }
+ } catch (err) {
+ if (!cancelled) {
+ try {
+ controller.error(err);
+ } catch {
+ // already closed
+ }
+ }
+ throw err;
+ }
+ })();
+
+ this.completion = runPromise;
+ this.completion.catch(() => {});
+ },
+ pull: () => {
+ releasePullWaiters();
+ },
+ cancel: () => {
+ cancelled = true;
+ abortController.abort();
+ releasePullWaiters();
+ },
+ },
+ { highWaterMark: this.highWaterMark }
+ );
+
+ return stream;
+ }
+
+ private startSink(): void {
+ this.ensureNotStarted('sink');
+ this.startedAs = 'sink';
+
+ const abortController = new AbortController();
+ this.completion = this.bind(null, abortController.signal);
+ this.completion.catch(() => {});
+ }
+}
+
+async function* readableStreamToAsyncIterable(
+ stream: ReadableStream
+): AsyncIterableIterator {
+ const reader = stream.getReader();
+ let drained = false;
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) {
+ drained = true;
+ return;
+ }
+ yield value;
+ }
+ } finally {
+ if (!drained) {
+ try {
+ await reader.cancel();
+ } catch {
+ // cancel can reject if the stream already errored — safe to ignore
+ }
+ }
+ reader.releaseLock();
+ }
+}
+
+function createSSETransform(): TransformStream {
+ const encoder = new TextEncoder();
+ return new TransformStream({
+ transform(event, controller) {
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
+ },
+ });
+}
diff --git a/packages/agent/src/sse.ts b/packages/agent/src/sse.ts
new file mode 100644
index 0000000..f8002d5
--- /dev/null
+++ b/packages/agent/src/sse.ts
@@ -0,0 +1,67 @@
+import type { AgentEvent } from './types.js';
+
+export async function* parseSSEStream(
+ stream: ReadableStream
+): AsyncIterable {
+ const reader = stream.getReader();
+ const decoder = new TextDecoder('utf-8');
+ let buffer = '';
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) {
+ break;
+ }
+
+ buffer += decoder.decode(value, { stream: true });
+ buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
+
+ let blankIdx = buffer.indexOf('\n\n');
+ while (blankIdx !== -1) {
+ const rawEvent = buffer.slice(0, blankIdx);
+ buffer = buffer.slice(blankIdx + 2);
+ const event = parseEvent(rawEvent);
+ if (event) {
+ yield event;
+ }
+ blankIdx = buffer.indexOf('\n\n');
+ }
+ }
+ } finally {
+ reader.releaseLock();
+ }
+}
+
+function parseEvent(raw: string): AgentEvent | null {
+ const dataLines: string[] = [];
+ for (const line of raw.split('\n')) {
+ if (line === '' || line.startsWith(':')) {
+ continue;
+ }
+ const colon = line.indexOf(':');
+ const field = colon === -1 ? line : line.slice(0, colon);
+ let value = colon === -1 ? '' : line.slice(colon + 1);
+ if (value.startsWith(' ')) {
+ value = value.slice(1);
+ }
+ if (field === 'data') {
+ dataLines.push(value);
+ }
+ }
+
+ if (dataLines.length === 0) {
+ return null;
+ }
+
+ const data = dataLines.join('\n');
+ if (data === '[DONE]') {
+ return null;
+ }
+
+ try {
+ return JSON.parse(data) as AgentEvent;
+ } catch {
+ return null;
+ }
+}
diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts
index 1486987..908aba1 100644
--- a/packages/agent/src/types.ts
+++ b/packages/agent/src/types.ts
@@ -21,9 +21,11 @@ export type AgentToolUpdateCallback = (
export interface AgentTool extends ToolDefinition {
label: string;
+ decision?: JsonSchema;
execute: (
toolCallId: string,
params: Record,
+ decision: unknown,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback
) => Promise>;
@@ -34,6 +36,7 @@ export interface AgentState {
isStreaming: boolean;
messages: Message[];
model: ModelDescriptor;
+ stepCount: number;
streamMessage: AssistantMessage | null;
streamOptions?: Omit;
systemPrompt: string;
@@ -46,7 +49,7 @@ export interface AgentEventBase {
export type AgentEvent =
| { type: 'agent_start' }
- | { type: 'agent_end'; messages: Message[] }
+ | { type: 'agent_end'; messages: Message[]; stopReason?: 'completed' | 'max_steps' | 'aborted' }
| { type: 'turn_start' }
| { type: 'turn_end'; message: AssistantMessage; toolResults: ToolResultMessage[] }
| { type: 'message_start'; message: Message }
@@ -66,10 +69,23 @@ export type AgentEvent =
toolName: string;
result: AgentToolResult;
isError: boolean;
+ }
+ | {
+ type: 'tool_decision_pending';
+ toolCallId: string;
+ toolName: string;
+ input: Record;
+ schema: JsonSchema;
};
export interface AgentOptions {
initialState: Pick & Partial>;
+ /**
+ * Maximum number of model invocations the agent will perform per run.
+ * One model call counts as one step. Counter persists across `continue()`
+ * — it only resets in `prompt()`. Default: unlimited.
+ */
+ maxSteps?: number;
streamFn?: (
model: ModelDescriptor,
context: Context,
diff --git a/packages/agent/src/validation.ts b/packages/agent/src/validation.ts
index 51634c7..0e3d64c 100644
--- a/packages/agent/src/validation.ts
+++ b/packages/agent/src/validation.ts
@@ -1,5 +1,16 @@
import type { JsonSchema } from 'agentic-kit';
+export class DecisionValidationError extends Error {
+ readonly toolName: string;
+ readonly errors: string[];
+ constructor(toolName: string, errors: string[]) {
+ super(`Decision validation failed for tool '${toolName}':\n${errors.map((e) => `- ${e}`).join('\n')}`);
+ this.name = 'DecisionValidationError';
+ this.toolName = toolName;
+ this.errors = errors;
+ }
+}
+
export function validateToolArguments(
schema: JsonSchema,
args: Record
@@ -12,7 +23,7 @@ export function validateToolArguments(
throw new Error(`Tool argument validation failed:\n${errors.map((error) => `- ${error}`).join('\n')}`);
}
-function validateSchema(schema: JsonSchema, value: unknown, path: string): string[] {
+export function validateSchema(schema: JsonSchema, value: unknown, path: string): string[] {
if (!schema || Object.keys(schema).length === 0) {
return [];
}
diff --git a/packages/agentic-kit/README.md b/packages/agentic-kit/README.md
index 6d76101..3c2cd01 100644
--- a/packages/agentic-kit/README.md
+++ b/packages/agentic-kit/README.md
@@ -67,10 +67,41 @@ for await (const event of result) {
- `stream(model: ModelDescriptor, context: Context, options?: StreamOptions)`
- `complete(model: ModelDescriptor, context: Context, options?: StreamOptions)`
- `completeText(model: ModelDescriptor, context: Context, options?: StreamOptions)`
-- `registerModel(model: ModelDescriptor): void`
-- `registerProvider(provider: ProviderAdapter): void`
-- `getModel(provider: string, modelId: string): ModelDescriptor | undefined`
-- `getModels(provider?: string): ModelDescriptor[]`
+
+### Registry
+
+- `registerModel(model)`, `registerModels(models)`, `clearModels()`
+- `registerProvider(provider, sourceId?)`, `unregisterProviders(sourceId)`, `clearProviders()`
+- `getModel(provider, modelId)`, `getModels(provider?)`, `getModelProviders()`
+- `getProvider(api)`, `getRegisteredProviders()`
+
+The package pre-registers `OpenAIAdapter`, `AnthropicAdapter`, and
+`OllamaAdapter` (with empty credentials) plus the built-in model catalogs
+from each adapter package. Re-register an adapter with your own API key to
+activate it.
+
+### Message helpers
+
+- `createUserMessage(content)`, `createAssistantMessage(model)`,
+ `createToolResultMessage(toolCallId, toolName, content, isError?)`
+- `createTextContent(text?)`, `createImageContent(data, mimeType)`,
+ `createToolCall(id, name)`
+- `getMessageText(assistant)` — concatenate text blocks.
+- `cloneMessage(message)` — deep clone.
+- `normalizeContext(context)` — apply per-message normalization.
+- `injectDeferralResults(messages, options?)` — synthesize stand-in
+ `toolResult` messages for every `toolCall` that lacks both a decision and
+ a paired result. Useful when the user types a new message instead of
+ responding to a paused tool — the next request needs a well-formed
+ transcript.
+- `transformMessages(messages, model)` — cross-provider normalization for
+ replay across different providers.
+
+### Adapter re-exports
+
+- `OpenAIAdapter`, `AnthropicAdapter`, `OllamaAdapter`
+- Types: `OpenAIOptions`, `AnthropicOptions`
+- `OllamaClient` for direct Ollama HTTP access.
### Legacy Compatibility API
diff --git a/packages/agentic-kit/__tests__/adapter.test.ts b/packages/agentic-kit/__tests__/adapter.test.ts
index b186f64..60dccdd 100644
--- a/packages/agentic-kit/__tests__/adapter.test.ts
+++ b/packages/agentic-kit/__tests__/adapter.test.ts
@@ -1,47 +1,28 @@
+import {
+ createScriptedProvider,
+ makeFakeAssistantMessage,
+ makeFakeModel,
+} from '@test/index';
+
import {
AgentKit,
type AssistantMessage,
- createAssistantMessageEventStream,
getMessageText,
type ModelDescriptor,
- type ProviderAdapter,
transformMessages,
} from '../src';
function createFakeModel(): ModelDescriptor {
- return {
- id: 'demo',
- name: 'Demo',
- api: 'fake-api',
- provider: 'fake',
- baseUrl: 'http://fake.local',
- input: ['text'],
- reasoning: false,
- tools: true,
- };
+ return makeFakeModel({ name: 'Demo' });
}
function createAssistantMessage(
overrides: Partial = {}
): AssistantMessage {
- return {
- role: 'assistant',
- api: 'fake-api',
- provider: 'fake',
- model: 'demo',
- usage: {
- input: 0,
- output: 0,
- cacheRead: 0,
- cacheWrite: 0,
- totalTokens: 0,
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
- },
- stopReason: 'stop',
- timestamp: Date.now(),
+ return makeFakeAssistantMessage({
content: [{ type: 'text', text: 'hello world' }],
...overrides,
- };
+ });
}
describe('agentic-kit core', () => {
@@ -236,43 +217,11 @@ describe('agentic-kit core', () => {
});
it('keeps the legacy AgentKit generate API working through structured streams', async () => {
- const provider: ProviderAdapter & { name: string } = {
- api: 'fake-api',
- provider: 'fake',
- name: 'fake',
- createModel: () => createFakeModel(),
- stream: () => {
- const stream = createAssistantMessageEventStream();
- const message = createAssistantMessage();
-
- queueMicrotask(() => {
- stream.push({ type: 'start', partial: { ...message, content: [{ type: 'text', text: '' }] } });
- stream.push({
- type: 'text_start',
- contentIndex: 0,
- partial: { ...message, content: [{ type: 'text', text: '' }] },
- });
- stream.push({
- type: 'text_delta',
- contentIndex: 0,
- delta: 'hello world',
- partial: message,
- });
- stream.push({
- type: 'text_end',
- contentIndex: 0,
- content: 'hello world',
- partial: message,
- });
- stream.push({ type: 'done', reason: 'stop', message });
- stream.end(message);
- });
-
- return stream;
- },
- };
-
- const kit = new AgentKit().addProvider(provider);
+ const kit = new AgentKit().addProvider(
+ createScriptedProvider({
+ responses: [createAssistantMessage(), createAssistantMessage()],
+ })
+ );
const chunks: string[] = [];
await kit.generate(
{ model: 'demo', prompt: 'hi', stream: true },
@@ -284,29 +233,17 @@ describe('agentic-kit core', () => {
});
it('rejects legacy generate when a provider returns a terminal error in non-stream mode', async () => {
- const provider: ProviderAdapter & { name: string } = {
- api: 'fake-api',
- provider: 'fake',
- name: 'fake',
- createModel: () => createFakeModel(),
- stream: () => {
- const stream = createAssistantMessageEventStream();
- const failure = createAssistantMessage({
- stopReason: 'error',
- errorMessage: 'provider failed',
- content: [{ type: 'text', text: '' }],
- });
-
- queueMicrotask(() => {
- stream.push({ type: 'error', reason: 'error', error: failure });
- stream.end(failure);
- });
-
- return stream;
- },
- };
-
- const kit = new AgentKit().addProvider(provider);
+ const kit = new AgentKit().addProvider(
+ createScriptedProvider({
+ responses: [
+ createAssistantMessage({
+ stopReason: 'error',
+ errorMessage: 'provider failed',
+ content: [{ type: 'text', text: '' }],
+ }),
+ ],
+ })
+ );
const onComplete = jest.fn();
const onError = jest.fn();
const onStateChange = jest.fn();
@@ -324,44 +261,17 @@ describe('agentic-kit core', () => {
});
it('rejects legacy generate when a provider returns a terminal error in stream mode', async () => {
- const provider: ProviderAdapter & { name: string } = {
- api: 'fake-api',
- provider: 'fake',
- name: 'fake',
- createModel: () => createFakeModel(),
- stream: () => {
- const stream = createAssistantMessageEventStream();
- const partial = createAssistantMessage({
- content: [{ type: 'text', text: 'partial' }],
- });
- const failure = createAssistantMessage({
- stopReason: 'error',
- errorMessage: 'provider failed',
- content: [{ type: 'text', text: 'partial' }],
- });
-
- queueMicrotask(() => {
- stream.push({ type: 'start', partial: { ...partial, content: [{ type: 'text', text: '' }] } });
- stream.push({
- type: 'text_start',
- contentIndex: 0,
- partial: { ...partial, content: [{ type: 'text', text: '' }] },
- });
- stream.push({
- type: 'text_delta',
- contentIndex: 0,
- delta: 'partial',
- partial,
- });
- stream.push({ type: 'error', reason: 'error', error: failure });
- stream.end(failure);
- });
-
- return stream;
- },
- };
-
- const kit = new AgentKit().addProvider(provider);
+ const kit = new AgentKit().addProvider(
+ createScriptedProvider({
+ responses: [
+ createAssistantMessage({
+ stopReason: 'error',
+ errorMessage: 'provider failed',
+ content: [{ type: 'text', text: 'partial' }],
+ }),
+ ],
+ })
+ );
const chunks: string[] = [];
const onComplete = jest.fn();
const onError = jest.fn();
diff --git a/packages/agentic-kit/__tests__/inject-deferral-results.test.ts b/packages/agentic-kit/__tests__/inject-deferral-results.test.ts
new file mode 100644
index 0000000..8291701
--- /dev/null
+++ b/packages/agentic-kit/__tests__/inject-deferral-results.test.ts
@@ -0,0 +1,140 @@
+import { makeFakeAssistantMessage } from '@test/index';
+import type { AssistantMessage, Message, ToolResultMessage } from '../src';
+import { injectDeferralResults } from '../src';
+
+function assistantWithToolCall(
+ id: string,
+ name = 'echo',
+ extra: Partial<{ decision: unknown }> = {}
+): AssistantMessage {
+ return makeFakeAssistantMessage({
+ stopReason: 'toolUse',
+ content: [
+ {
+ type: 'toolCall',
+ id,
+ name,
+ arguments: {},
+ rawArguments: '{}',
+ ...extra,
+ },
+ ],
+ });
+}
+
+function toolResult(id: string, name = 'echo'): ToolResultMessage {
+ return {
+ role: 'toolResult',
+ toolCallId: id,
+ toolName: name,
+ content: [{ type: 'text', text: 'ok' }],
+ isError: false,
+ timestamp: 1,
+ };
+}
+
+describe('injectDeferralResults', () => {
+ it('returns input unchanged when no unpaired toolCalls', () => {
+ const messages: Message[] = [
+ { role: 'user', content: 'hi', timestamp: 1 },
+ assistantWithToolCall('call_1'),
+ toolResult('call_1'),
+ ];
+ const result = injectDeferralResults(messages);
+ expect(result).toBe(messages);
+ });
+
+ it('returns input unchanged when there are no assistant messages with tool calls', () => {
+ const messages: Message[] = [{ role: 'user', content: 'hi', timestamp: 1 }];
+ const result = injectDeferralResults(messages);
+ expect(result).toBe(messages);
+ });
+
+ it('synthesizes a toolResult for each unpaired toolCall in order', () => {
+ const messages: Message[] = [
+ { role: 'user', content: 'hi', timestamp: 1 },
+ makeFakeAssistantMessage({
+ stopReason: 'toolUse',
+ content: [
+ { type: 'toolCall', id: 'call_1', name: 'first', arguments: {}, rawArguments: '{}' },
+ { type: 'toolCall', id: 'call_2', name: 'second', arguments: {}, rawArguments: '{}' },
+ ],
+ }),
+ ];
+ const result = injectDeferralResults(messages);
+ expect(result).toHaveLength(messages.length + 2);
+ const synthetic = result.slice(messages.length) as ToolResultMessage[];
+ expect(synthetic[0].role).toBe('toolResult');
+ expect(synthetic[0].toolCallId).toBe('call_1');
+ expect(synthetic[0].toolName).toBe('first');
+ expect(synthetic[1].toolCallId).toBe('call_2');
+ expect(synthetic[1].toolName).toBe('second');
+ });
+
+ it('uses default deferral text when none supplied', () => {
+ const messages: Message[] = [assistantWithToolCall('call_1')];
+ const result = injectDeferralResults(messages);
+ const synthetic = result[result.length - 1] as ToolResultMessage;
+ expect(synthetic.content).toEqual([
+ { type: 'text', text: 'User did not respond. Continue.' },
+ ]);
+ });
+
+ it('passes through custom deferral text', () => {
+ const messages: Message[] = [assistantWithToolCall('call_1')];
+ const result = injectDeferralResults(messages, 'custom message');
+ const synthetic = result[result.length - 1] as ToolResultMessage;
+ expect(synthetic.content).toEqual([{ type: 'text', text: 'custom message' }]);
+ });
+
+ it('accepts options form with text + details', () => {
+ const messages: Message[] = [assistantWithToolCall('call_1')];
+ const result = injectDeferralResults(messages, {
+ deferralText: 'opts text',
+ details: { deferred: true },
+ });
+ const synthetic = result[result.length - 1] as ToolResultMessage;
+ expect(synthetic.content).toEqual([{ type: 'text', text: 'opts text' }]);
+ expect(synthetic.details).toEqual({ deferred: true });
+ });
+
+ it('omits details when not provided', () => {
+ const messages: Message[] = [assistantWithToolCall('call_1')];
+ const result = injectDeferralResults(messages);
+ const synthetic = result[result.length - 1] as ToolResultMessage;
+ expect(synthetic.details).toBeUndefined();
+ });
+
+ it('skips toolCalls with a decision already attached', () => {
+ const messages: Message[] = [
+ assistantWithToolCall('call_1', 'echo', { decision: { action: 'approve' } }),
+ ];
+ const result = injectDeferralResults(messages);
+ expect(result).toBe(messages);
+ });
+
+ it('skips toolCalls already paired with a toolResult', () => {
+ const messages: Message[] = [
+ assistantWithToolCall('call_1'),
+ toolResult('call_1'),
+ assistantWithToolCall('call_2'),
+ ];
+ const result = injectDeferralResults(messages);
+ expect(result).toHaveLength(messages.length + 1);
+ const synthetic = result[result.length - 1] as ToolResultMessage;
+ expect(synthetic.toolCallId).toBe('call_2');
+ });
+
+ it('handles a mix: one paired, one decisioned, one unpaired', () => {
+ const messages: Message[] = [
+ assistantWithToolCall('call_1'),
+ toolResult('call_1'),
+ assistantWithToolCall('call_2', 'echo', { decision: { action: 'approve' } }),
+ assistantWithToolCall('call_3'),
+ ];
+ const result = injectDeferralResults(messages);
+ expect(result).toHaveLength(messages.length + 1);
+ const synthetic = result[result.length - 1] as ToolResultMessage;
+ expect(synthetic.toolCallId).toBe('call_3');
+ });
+});
diff --git a/packages/agentic-kit/__tests__/tsconfig.json b/packages/agentic-kit/__tests__/tsconfig.json
index 6c4fda5..3ae83c4 100644
--- a/packages/agentic-kit/__tests__/tsconfig.json
+++ b/packages/agentic-kit/__tests__/tsconfig.json
@@ -2,9 +2,19 @@
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
- "rootDir": "..",
+ "rootDir": "../../..",
+ "baseUrl": "../../..",
+ "paths": {
+ "@test/*": ["tools/test/*"],
+ "agentic-kit": ["packages/agentic-kit/src"],
+ "@agentic-kit/agent": ["packages/agent/src"]
+ },
"types": ["jest", "node"]
},
- "include": ["./**/*.ts", "../src/**/*.ts"],
+ "include": [
+ "./**/*.ts",
+ "../src/**/*.ts",
+ "../../../tools/test/**/*.ts"
+ ],
"exclude": ["../dist", "../node_modules"]
}
diff --git a/packages/agentic-kit/jest.config.js b/packages/agentic-kit/jest.config.js
index c539b86..79ccd00 100644
--- a/packages/agentic-kit/jest.config.js
+++ b/packages/agentic-kit/jest.config.js
@@ -17,6 +17,8 @@ module.exports = {
modulePathIgnorePatterns: ['dist/*'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
+ '^@test/(.*)$': '/../../tools/test/$1',
+ '^agentic-kit$': '/src',
'^@agentic-kit/(.*)$': '/../$1/src',
},
setupFilesAfterEnv: ['/jest.setup.js']
diff --git a/packages/agentic-kit/jest.setup.js b/packages/agentic-kit/jest.setup.js
index d3320e0..5335d6a 100644
--- a/packages/agentic-kit/jest.setup.js
+++ b/packages/agentic-kit/jest.setup.js
@@ -1,4 +1,4 @@
global.TextEncoder = require('util').TextEncoder;
global.TextDecoder = require('util').TextDecoder;
-jest.mock('cross-fetch', () => jest.fn());
+global.fetch = jest.fn();
diff --git a/packages/agentic-kit/package.json b/packages/agentic-kit/package.json
index 4bf8502..93b0b2a 100644
--- a/packages/agentic-kit/package.json
+++ b/packages/agentic-kit/package.json
@@ -6,6 +6,14 @@
"main": "index.js",
"module": "esm/index.js",
"types": "index.d.ts",
+ "exports": {
+ ".": {
+ "source": "./src/index.ts",
+ "types": "./index.d.ts",
+ "import": "./esm/index.js",
+ "require": "./index.js"
+ }
+ },
"homepage": "https://github.com/constructive-io/agentic-kit",
"license": "SEE LICENSE IN LICENSE",
"publishConfig": {
@@ -33,8 +41,5 @@
"@agentic-kit/ollama": "workspace:*",
"@agentic-kit/openai": "workspace:*"
},
- "devDependencies": {
- "cross-fetch": "^4.1.0"
- },
"keywords": []
}
diff --git a/packages/agentic-kit/src/messages.ts b/packages/agentic-kit/src/messages.ts
index 5a01e72..e4e2438 100644
--- a/packages/agentic-kit/src/messages.ts
+++ b/packages/agentic-kit/src/messages.ts
@@ -95,6 +95,56 @@ export function getMessageText(message: AssistantMessage): string {
.join('');
}
+const DEFAULT_DEFERRAL_TEXT = 'User did not respond. Continue.';
+
+export interface InjectDeferralResultsOptions {
+ /** Text body for synthesized results. Defaults to a generic continue prompt. */
+ deferralText?: string;
+ /** Stamped onto each synthesized result's `details` field — typically `{ deferred: true }` so renderers can hide a "Done" badge. */
+ details?: unknown;
+}
+
+/**
+ * Synthesize toolResult messages for every toolCall that lacks both a decision
+ * and a paired toolResult. Returns input unchanged (referentially equal) when
+ * nothing needs synthesizing.
+ *
+ * Use case: user types a message instead of clicking approve/deny on a paused
+ * tool. The agent can't resume on text alone — every dangling toolCall needs
+ * a result before the next request. Compose with sendMessages to forward the
+ * conversation cleanly:
+ *
+ * await sendMessages([...injectDeferralResults(messages), createUserMessage(text)]);
+ */
+export function injectDeferralResults(
+ messages: Message[],
+ optionsOrText: InjectDeferralResultsOptions | string = {}
+): Message[] {
+ const opts: InjectDeferralResultsOptions =
+ typeof optionsOrText === 'string' ? { deferralText: optionsOrText } : optionsOrText;
+ const deferralText = opts.deferralText ?? DEFAULT_DEFERRAL_TEXT;
+ const completed = new Set();
+ for (const m of messages) {
+ if (m.role === 'toolResult') completed.add(m.toolCallId);
+ }
+ const synthetic: ToolResultMessage[] = [];
+ for (const m of messages) {
+ if (m.role !== 'assistant') continue;
+ for (const block of m.content) {
+ if (block.type !== 'toolCall') continue;
+ if (completed.has(block.id)) continue;
+ if ('decision' in block && block.decision !== undefined) continue;
+ const result = createToolResultMessage(block.id, block.name, [
+ { type: 'text', text: deferralText },
+ ]);
+ if (opts.details !== undefined) result.details = opts.details;
+ synthetic.push(result);
+ }
+ }
+ if (synthetic.length === 0) return messages;
+ return [...messages, ...synthetic];
+}
+
export function cloneMessage(message: TMessage): TMessage {
return JSON.parse(JSON.stringify(message)) as TMessage;
}
diff --git a/packages/agentic-kit/src/types.ts b/packages/agentic-kit/src/types.ts
index 00b432c..d4812a8 100644
--- a/packages/agentic-kit/src/types.ts
+++ b/packages/agentic-kit/src/types.ts
@@ -89,6 +89,7 @@ export interface ToolCallContent {
name: string;
arguments: Record;
rawArguments?: string;
+ decision?: unknown;
}
export interface Usage {
diff --git a/packages/anthropic/README.md b/packages/anthropic/README.md
index 68d3647..e9a1893 100644
--- a/packages/anthropic/README.md
+++ b/packages/anthropic/README.md
@@ -12,11 +12,94 @@
-Anthropic (Claude) adapter for agentic-kit with SSE streaming support.
+Anthropic (Claude) adapter for `agentic-kit`. Speaks the Messages API over
+SSE, surfaces text / thinking / tool-call deltas as structured events, and
+plugs into the shared model and provider registries.
## Installation
```bash
-npm install @agentic-kit/anthropic
+npm install @agentic-kit/anthropic agentic-kit
```
+## Usage
+
+The adapter can either be used directly or registered with the shared
+`agentic-kit` provider registry.
+
+### Direct adapter
+
+```ts
+import { AnthropicAdapter } from '@agentic-kit/anthropic';
+
+const adapter = new AnthropicAdapter({ apiKey: process.env.ANTHROPIC_API_KEY! });
+const model = adapter.createModel('claude-sonnet-4-5');
+
+const result = adapter.stream(model, {
+ messages: [{ role: 'user', content: 'Hello', timestamp: Date.now() }],
+});
+
+for await (const event of result) {
+ if (event.type === 'text_delta') {
+ process.stdout.write(event.delta);
+ }
+}
+```
+
+### Through `agentic-kit`
+
+The base `agentic-kit` package pre-registers an Anthropic adapter with an
+empty key. Override it with your own key once at startup:
+
+```ts
+import { registerProvider, stream, getModel } from 'agentic-kit';
+import { AnthropicAdapter } from '@agentic-kit/anthropic';
+
+registerProvider(new AnthropicAdapter({ apiKey: process.env.ANTHROPIC_API_KEY! }));
+
+const model = getModel('anthropic', 'claude-sonnet-4-5')!;
+const result = stream(model, { messages: [...] });
+```
+
+## API Reference
+
+### `new AnthropicAdapter(options | apiKey)`
+
+`AnthropicOptions`:
+
+- `apiKey` — required.
+- `baseUrl` — defaults to `https://api.anthropic.com/v1`.
+- `defaultModel` — defaults to `claude-sonnet-4-5`.
+- `provider` — override the registry key (defaults to `'anthropic'`).
+- `headers` — extra headers merged into every request.
+- `maxTokens` — default `max_tokens` when neither model nor request sets one.
+
+### `adapter.createModel(modelId, overrides?)`
+
+Returns a `ModelDescriptor`. If `modelId` matches a built-in entry in
+`ANTHROPIC_MODELS`, the built-in is used as a base; otherwise a generic
+descriptor is synthesized.
+
+### `adapter.stream(model, context, options?)`
+
+Starts a streaming request and returns an `AssistantMessageEventStream` — an
+async iterable of `AssistantMessageEvent`s with a `.result()` promise for the
+final `AssistantMessage`.
+
+`StreamOptions`:
+
+- `apiKey`, `headers` — per-request overrides.
+- `maxTokens`, `temperature`.
+- `reasoning` — `'minimal' | 'low' | 'medium' | 'high' | 'xhigh'`. Enables
+ Claude extended thinking; maps to a token budget (`256` → `16384`).
+- `onPayload` — observe the raw request body before send.
+- `signal` — abort the request mid-stream.
+
+### `adapter.listModels()`
+
+Returns the entries from `ANTHROPIC_MODELS` for this adapter's provider key.
+
+### `ANTHROPIC_MODELS`
+
+Built-in `ModelDescriptor[]` with cost, context window, and capability
+metadata for the latest Claude tier (Sonnet 4.5, Haiku 4.5).
diff --git a/packages/anthropic/__tests__/anthropic.test.ts b/packages/anthropic/__tests__/anthropic.test.ts
index 82c9881..338577c 100644
--- a/packages/anthropic/__tests__/anthropic.test.ts
+++ b/packages/anthropic/__tests__/anthropic.test.ts
@@ -1,8 +1,9 @@
-import fetch from 'cross-fetch';
import { TextEncoder } from 'util';
import { AnthropicAdapter } from '../src';
+const fetch = global.fetch as jest.Mock;
+
function createStreamingResponse(frames: string[]) {
const encoded = new TextEncoder().encode(frames.join('\n\n'));
const reader = {
diff --git a/packages/anthropic/__tests__/tsconfig.json b/packages/anthropic/__tests__/tsconfig.json
index 6c4fda5..3ae83c4 100644
--- a/packages/anthropic/__tests__/tsconfig.json
+++ b/packages/anthropic/__tests__/tsconfig.json
@@ -2,9 +2,19 @@
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
- "rootDir": "..",
+ "rootDir": "../../..",
+ "baseUrl": "../../..",
+ "paths": {
+ "@test/*": ["tools/test/*"],
+ "agentic-kit": ["packages/agentic-kit/src"],
+ "@agentic-kit/agent": ["packages/agent/src"]
+ },
"types": ["jest", "node"]
},
- "include": ["./**/*.ts", "../src/**/*.ts"],
+ "include": [
+ "./**/*.ts",
+ "../src/**/*.ts",
+ "../../../tools/test/**/*.ts"
+ ],
"exclude": ["../dist", "../node_modules"]
}
diff --git a/packages/anthropic/jest.config.js b/packages/anthropic/jest.config.js
index e11f478..d0dfaaa 100644
--- a/packages/anthropic/jest.config.js
+++ b/packages/anthropic/jest.config.js
@@ -15,5 +15,11 @@ module.exports = {
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
modulePathIgnorePatterns: ['dist/*'],
+ moduleNameMapper: {
+ '^(\\.{1,2}/.*)\\.js$': '$1',
+ '^@test/(.*)$': '/../../tools/test/$1',
+ '^agentic-kit$': '/../agentic-kit/src',
+ '^@agentic-kit/(.*)$': '/../$1/src',
+ },
setupFilesAfterEnv: ['/jest.setup.js']
};
diff --git a/packages/anthropic/jest.setup.js b/packages/anthropic/jest.setup.js
index d3320e0..5335d6a 100644
--- a/packages/anthropic/jest.setup.js
+++ b/packages/anthropic/jest.setup.js
@@ -1,4 +1,4 @@
global.TextEncoder = require('util').TextEncoder;
global.TextDecoder = require('util').TextDecoder;
-jest.mock('cross-fetch', () => jest.fn());
+global.fetch = jest.fn();
diff --git a/packages/anthropic/package.json b/packages/anthropic/package.json
index 0058e80..1512594 100644
--- a/packages/anthropic/package.json
+++ b/packages/anthropic/package.json
@@ -6,6 +6,14 @@
"main": "index.js",
"module": "esm/index.js",
"types": "index.d.ts",
+ "exports": {
+ ".": {
+ "source": "./src/index.ts",
+ "types": "./index.d.ts",
+ "import": "./esm/index.js",
+ "require": "./index.js"
+ }
+ },
"homepage": "https://github.com/constructive-io/agentic-kit",
"license": "SEE LICENSE IN LICENSE",
"publishConfig": {
@@ -28,8 +36,5 @@
"test": "jest",
"test:watch": "jest --watch"
},
- "keywords": [],
- "dependencies": {
- "cross-fetch": "^4.1.0"
- }
+ "keywords": []
}
diff --git a/packages/anthropic/src/index.ts b/packages/anthropic/src/index.ts
index 79efe7c..256b642 100644
--- a/packages/anthropic/src/index.ts
+++ b/packages/anthropic/src/index.ts
@@ -1,4 +1,4 @@
-import fetch from 'cross-fetch';
+const fetch: typeof globalThis.fetch = globalThis.fetch.bind(globalThis);
type JsonPrimitive = string | number | boolean | null;
type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
diff --git a/packages/ollama/README.md b/packages/ollama/README.md
index a875078..c80b9f8 100644
--- a/packages/ollama/README.md
+++ b/packages/ollama/README.md
@@ -62,8 +62,9 @@ await client.deleteModel('mistral');
- `new OllamaClient(baseUrl?: string)` – defaults to `http://localhost:11434`
- `.listModels(): Promise`
+- `.showModel(model: string): Promise<{ capabilities?: string[] } | null>`
- `.generate(input: GenerateInput, onChunk?: (chunk: string) => void): Promise`
-- `.generateEmbedding(text: string): Promise`
+- `.generateEmbedding(text: string, model?: string): Promise` — defaults to `nomic-embed-text`
- `.pullModel(model: string): Promise`
- `.deleteModel(model: string): Promise`
@@ -107,11 +108,17 @@ Notes:
```ts
interface GenerateInput {
model: string;
- prompt: string;
+ prompt?: string;
+ messages?: ChatMessage[];
+ system?: string;
stream?: boolean;
+ temperature?: number;
+ maxTokens?: number;
}
```
+Either `prompt` (single-turn) or `messages` (multi-turn) must be set.
+
## Contributing
Please open issues or pull requests on [GitHub](https://github.com/constructive-io/agentic-kit).
diff --git a/packages/ollama/__tests__/ollama.test.ts b/packages/ollama/__tests__/ollama.test.ts
index 218bbad..92ef1f1 100644
--- a/packages/ollama/__tests__/ollama.test.ts
+++ b/packages/ollama/__tests__/ollama.test.ts
@@ -1,9 +1,10 @@
-import fetch from 'cross-fetch';
import { PassThrough } from 'stream';
import { TextEncoder } from 'util';
import OllamaClient, { OllamaAdapter } from '../src';
+const fetch = global.fetch as jest.Mock;
+
function createLineResponse(lines: string[]) {
const encoded = new TextEncoder().encode(lines.join('\n'));
const reader = {
diff --git a/packages/ollama/__tests__/tsconfig.json b/packages/ollama/__tests__/tsconfig.json
index 6c4fda5..3ae83c4 100644
--- a/packages/ollama/__tests__/tsconfig.json
+++ b/packages/ollama/__tests__/tsconfig.json
@@ -2,9 +2,19 @@
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
- "rootDir": "..",
+ "rootDir": "../../..",
+ "baseUrl": "../../..",
+ "paths": {
+ "@test/*": ["tools/test/*"],
+ "agentic-kit": ["packages/agentic-kit/src"],
+ "@agentic-kit/agent": ["packages/agent/src"]
+ },
"types": ["jest", "node"]
},
- "include": ["./**/*.ts", "../src/**/*.ts"],
+ "include": [
+ "./**/*.ts",
+ "../src/**/*.ts",
+ "../../../tools/test/**/*.ts"
+ ],
"exclude": ["../dist", "../node_modules"]
}
diff --git a/packages/ollama/jest.config.js b/packages/ollama/jest.config.js
index 5b89d20..061b4b9 100644
--- a/packages/ollama/jest.config.js
+++ b/packages/ollama/jest.config.js
@@ -16,5 +16,11 @@ module.exports = {
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
modulePathIgnorePatterns: ['dist/*'],
testPathIgnorePatterns: process.env.OLLAMA_LIVE_READY === '1' ? [] : ['\\.live\\.test\\.ts$'],
+ moduleNameMapper: {
+ '^(\\.{1,2}/.*)\\.js$': '$1',
+ '^@test/(.*)$': '/../../tools/test/$1',
+ '^agentic-kit$': '/../agentic-kit/src',
+ '^@agentic-kit/(.*)$': '/../$1/src',
+ },
setupFilesAfterEnv: ['/jest.setup.js']
};
diff --git a/packages/ollama/jest.setup.js b/packages/ollama/jest.setup.js
index d186834..6429acb 100644
--- a/packages/ollama/jest.setup.js
+++ b/packages/ollama/jest.setup.js
@@ -2,5 +2,5 @@ global.TextEncoder = require('util').TextEncoder;
global.TextDecoder = require('util').TextDecoder;
if (process.env.OLLAMA_LIVE_READY !== '1') {
- jest.mock('cross-fetch', () => jest.fn());
+ global.fetch = jest.fn();
}
diff --git a/packages/ollama/package.json b/packages/ollama/package.json
index 3352afc..ec3c939 100644
--- a/packages/ollama/package.json
+++ b/packages/ollama/package.json
@@ -6,6 +6,14 @@
"main": "index.js",
"module": "esm/index.js",
"types": "index.d.ts",
+ "exports": {
+ ".": {
+ "source": "./src/index.ts",
+ "types": "./index.d.ts",
+ "import": "./esm/index.js",
+ "require": "./index.js"
+ }
+ },
"homepage": "https://github.com/constructive-io/agentic-kit",
"license": "SEE LICENSE IN LICENSE",
"publishConfig": {
@@ -31,8 +39,5 @@
"test:live:extended": "node ./scripts/run-live-tests.js extended",
"test:watch": "jest --watch"
},
- "keywords": [],
- "dependencies": {
- "cross-fetch": "^4.1.0"
- }
+ "keywords": []
}
diff --git a/packages/ollama/src/index.ts b/packages/ollama/src/index.ts
index 1fe5ba9..5852889 100644
--- a/packages/ollama/src/index.ts
+++ b/packages/ollama/src/index.ts
@@ -1,4 +1,4 @@
-import fetch from 'cross-fetch';
+const fetch: typeof globalThis.fetch = globalThis.fetch.bind(globalThis);
type JsonPrimitive = string | number | boolean | null;
type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
diff --git a/packages/openai/README.md b/packages/openai/README.md
index e1bfcf3..02a5b2f 100644
--- a/packages/openai/README.md
+++ b/packages/openai/README.md
@@ -12,11 +12,128 @@
-OpenAI (and OpenAI-compatible) adapter for agentic-kit. Works with GPT models, LM Studio, vLLM, Together AI, and any OpenAI-compatible endpoint.
+OpenAI and OpenAI-compatible adapter for `agentic-kit`. Works with GPT models,
+LM Studio, vLLM, Together AI, Groq, Mistral, and any other endpoint speaking
+the chat-completions wire format. Compatibility quirks (token field naming,
+reasoning effort, tool-call id encoding) are toggleable per-model via the
+`compat` block.
## Installation
```bash
-npm install @agentic-kit/openai
+npm install @agentic-kit/openai agentic-kit
```
+## Usage
+
+### Direct adapter
+
+```ts
+import { OpenAIAdapter } from '@agentic-kit/openai';
+
+const adapter = new OpenAIAdapter({
+ apiKey: process.env.OPENAI_API_KEY!,
+ baseUrl: 'https://api.openai.com/v1',
+});
+const model = adapter.createModel('gpt-5.4-mini');
+
+const result = adapter.stream(model, {
+ messages: [{ role: 'user', content: 'Hello', timestamp: Date.now() }],
+});
+
+for await (const event of result) {
+ if (event.type === 'text_delta') {
+ process.stdout.write(event.delta);
+ }
+}
+```
+
+### Through `agentic-kit`
+
+`agentic-kit` pre-registers an OpenAI adapter with no key. Replace it with a
+configured one:
+
+```ts
+import { registerProvider, stream, getModel } from 'agentic-kit';
+import { OpenAIAdapter } from '@agentic-kit/openai';
+
+registerProvider(new OpenAIAdapter({ apiKey: process.env.OPENAI_API_KEY! }));
+
+const model = getModel('openai', 'gpt-5.4-mini')!;
+const result = stream(model, { messages: [...] });
+```
+
+### Pointing at an OpenAI-compatible endpoint
+
+```ts
+const lmStudio = new OpenAIAdapter({
+ baseUrl: 'http://localhost:1234/v1',
+ provider: 'lmstudio',
+ reasoning: false,
+ tools: true,
+ compat: {
+ maxTokensField: 'max_tokens',
+ reasoningFormat: 'none',
+ supportsStrictToolSchema: false,
+ supportsUsageInStreaming: false,
+ },
+});
+const model = lmStudio.createModel('llama-3.1-8b-instruct');
+```
+
+## API Reference
+
+### `new OpenAIAdapter(options? | apiKey?)`
+
+`OpenAIOptions`:
+
+- `apiKey` — required for hosted OpenAI; optional for unauthenticated local
+ endpoints.
+- `baseUrl` — defaults to `https://api.openai.com/v1`.
+- `defaultModel` — defaults to `gpt-5.4-mini`.
+- `provider` — registry key; defaults to `'openai'`. Set to your own value
+ (e.g. `'lmstudio'`) when registering alongside the OpenAI adapter.
+- `defaultInput` — input modalities for ad-hoc models. Defaults to
+ `['text', 'image']`.
+- `reasoning`, `tools` — capability defaults for ad-hoc models.
+- `contextWindow`, `maxTokens` — defaults for ad-hoc models.
+- `headers` — extra headers on every request.
+- `compat` — `OpenAICompatibleCompat` overrides applied to every model
+ produced by this adapter.
+
+### `OpenAICompatibleCompat`
+
+Per-model quirks for non-OpenAI endpoints:
+
+- `maxTokensField` — `'max_tokens'` or `'max_completion_tokens'`.
+- `reasoningFormat` — `'openai'` or `'none'`.
+- `supportsReasoningEffort` — whether the endpoint accepts the
+ `reasoning_effort` field.
+- `supportsStrictToolSchema` — whether to send `strict: true` on tool defs.
+- `supportsUsageInStreaming` — request usage in streaming payloads.
+- `toolCallIdFormat` — `'passthrough' | 'safe64' | 'mistral9'` for endpoints
+ that constrain tool-call ids.
+- `requiresToolResultName` — include `name` on `tool` messages (some
+ endpoints reject it, others require it).
+
+### `adapter.createModel(modelId, overrides?)`
+
+Returns a `ModelDescriptor`. Built-ins in `OPENAI_COMPATIBLE_MODELS` are
+used as a base when the id matches; otherwise a generic descriptor is
+synthesized from the adapter's defaults.
+
+### `adapter.stream(model, context, options?)`
+
+Returns an `AssistantMessageEventStream`. Supports the standard `agentic-kit`
+`StreamOptions` (`apiKey`, `headers`, `maxTokens`, `temperature`,
+`reasoning`, `onPayload`, `signal`).
+
+### `adapter.listModels()`
+
+If an API key is configured, fetches `${baseUrl}/models`; otherwise returns
+the built-in `OPENAI_COMPATIBLE_MODELS` entries for this adapter's provider.
+
+### `OPENAI_COMPATIBLE_MODELS`
+
+Built-in descriptors for the GPT-5.4 tier (`gpt-5.4`, `gpt-5.4-mini`,
+`gpt-5.4-nano`) with cost, context window, and `compat` metadata.
diff --git a/packages/openai/__tests__/openai.test.ts b/packages/openai/__tests__/openai.test.ts
index 7613973..5b84f9b 100644
--- a/packages/openai/__tests__/openai.test.ts
+++ b/packages/openai/__tests__/openai.test.ts
@@ -1,8 +1,9 @@
-import fetch from 'cross-fetch';
import { TextEncoder } from 'util';
import { OpenAIAdapter } from '../src';
+const fetch = global.fetch as jest.Mock;
+
function createStreamingResponse(lines: string[]) {
const payload = lines.join('\n');
const encoded = new TextEncoder().encode(payload);
diff --git a/packages/openai/__tests__/tsconfig.json b/packages/openai/__tests__/tsconfig.json
index 6c4fda5..3ae83c4 100644
--- a/packages/openai/__tests__/tsconfig.json
+++ b/packages/openai/__tests__/tsconfig.json
@@ -2,9 +2,19 @@
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
- "rootDir": "..",
+ "rootDir": "../../..",
+ "baseUrl": "../../..",
+ "paths": {
+ "@test/*": ["tools/test/*"],
+ "agentic-kit": ["packages/agentic-kit/src"],
+ "@agentic-kit/agent": ["packages/agent/src"]
+ },
"types": ["jest", "node"]
},
- "include": ["./**/*.ts", "../src/**/*.ts"],
+ "include": [
+ "./**/*.ts",
+ "../src/**/*.ts",
+ "../../../tools/test/**/*.ts"
+ ],
"exclude": ["../dist", "../node_modules"]
}
diff --git a/packages/openai/jest.config.js b/packages/openai/jest.config.js
index e11f478..d0dfaaa 100644
--- a/packages/openai/jest.config.js
+++ b/packages/openai/jest.config.js
@@ -15,5 +15,11 @@ module.exports = {
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
modulePathIgnorePatterns: ['dist/*'],
+ moduleNameMapper: {
+ '^(\\.{1,2}/.*)\\.js$': '$1',
+ '^@test/(.*)$': '/../../tools/test/$1',
+ '^agentic-kit$': '/../agentic-kit/src',
+ '^@agentic-kit/(.*)$': '/../$1/src',
+ },
setupFilesAfterEnv: ['/jest.setup.js']
};
diff --git a/packages/openai/jest.setup.js b/packages/openai/jest.setup.js
index d3320e0..5335d6a 100644
--- a/packages/openai/jest.setup.js
+++ b/packages/openai/jest.setup.js
@@ -1,4 +1,4 @@
global.TextEncoder = require('util').TextEncoder;
global.TextDecoder = require('util').TextDecoder;
-jest.mock('cross-fetch', () => jest.fn());
+global.fetch = jest.fn();
diff --git a/packages/openai/package.json b/packages/openai/package.json
index ef5d29d..70e597c 100644
--- a/packages/openai/package.json
+++ b/packages/openai/package.json
@@ -6,6 +6,14 @@
"main": "index.js",
"module": "esm/index.js",
"types": "index.d.ts",
+ "exports": {
+ ".": {
+ "source": "./src/index.ts",
+ "types": "./index.d.ts",
+ "import": "./esm/index.js",
+ "require": "./index.js"
+ }
+ },
"homepage": "https://github.com/constructive-io/agentic-kit",
"license": "SEE LICENSE IN LICENSE",
"publishConfig": {
@@ -28,8 +36,5 @@
"test": "jest",
"test:watch": "jest --watch"
},
- "keywords": [],
- "dependencies": {
- "cross-fetch": "^4.1.0"
- }
+ "keywords": []
}
diff --git a/packages/openai/src/index.ts b/packages/openai/src/index.ts
index 365a76d..1a18825 100644
--- a/packages/openai/src/index.ts
+++ b/packages/openai/src/index.ts
@@ -1,4 +1,7 @@
-import fetch from 'cross-fetch';
+// Use the runtime's native fetch. Node 18.17+ (engine requirement),
+// browsers, Bun, and Deno all provide it with a Web ReadableStream body —
+// which is what SSE parsing here requires.
+const fetch: typeof globalThis.fetch = globalThis.fetch.bind(globalThis);
type JsonPrimitive = string | number | boolean | null;
type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
diff --git a/packages/react/README.md b/packages/react/README.md
new file mode 100644
index 0000000..d2fc137
--- /dev/null
+++ b/packages/react/README.md
@@ -0,0 +1,155 @@
+# @agentic-kit/react
+
+
+
+
+
+
+
+
+
+
+
+
+
+Headless React bindings for `@agentic-kit/agent`. Exposes a single hook —
+`useChat` — that POSTs messages to a server endpoint, parses the SSE stream
+back into typed `AgentEvent`s, and folds them into a `Message[]` plus a
+small set of derived states (streaming snapshot, executing tools, pending
+decisions).
+
+The hook ships no UI. State lives in messages — there is no separate run
+store and no `runId` to keep in sync.
+
+## Installation
+
+```bash
+npm install @agentic-kit/react @agentic-kit/agent agentic-kit
+```
+
+Peer-deps `react@>=18` and `react-dom@>=18`.
+
+## Server Contract
+
+`useChat` POSTs JSON of the shape `{ messages, ...body() }` to `api`, and
+expects an SSE response whose `data:` frames decode to `AgentEvent`s. The
+easiest way to produce that is `AgentRunHandle#toResponse()`:
+
+```ts
+// app/api/chat/route.ts (Next.js)
+import { Agent } from '@agentic-kit/agent';
+
+export async function POST(req: Request) {
+ const { messages } = await req.json();
+ const agent = new Agent({ initialState: { model, tools, systemPrompt } });
+ agent.replaceMessages(messages.slice(0, -1));
+ return agent.prompt(messages[messages.length - 1]).toResponse();
+}
+```
+
+## Usage
+
+```tsx
+import { useChat } from '@agentic-kit/react';
+
+export function Chat() {
+ const {
+ messages,
+ streamingMessage,
+ isStreaming,
+ pendingDecisions,
+ executingToolCallIds,
+ send,
+ respondWithDecision,
+ abort,
+ } = useChat({ api: '/api/chat' });
+
+ return (
+
+ {messages.map((m, i) => )}
+ {streamingMessage && }
+
+ {[...pendingDecisions.values()].map((pending) => (
+ respondWithDecision(pending.toolCallId, { approved: true })}
+ onDeny={() => respondWithDecision(pending.toolCallId, { approved: false })}
+ />
+ ))}
+
+
+
+ );
+}
+```
+
+## API
+
+### `useChat(options): UseChatResult`
+
+#### Options
+
+- `api` — endpoint to POST messages to.
+- `body?: () => Record` — extra fields merged into each
+ request body (e.g. model id, conversation id).
+- `initialMessages?: Message[]` — starting message log.
+- `fetch?: typeof globalThis.fetch` — inject a custom fetch (useful for
+ auth headers, tests).
+- `onMessage?(message)` — fired for each finalized message (assistant or
+ toolResult).
+- `onFinish?(assistant)` — fired once per run with the final assistant
+ message.
+- `onDecisionPending?(event)` — fired when a tool pauses awaiting a decision.
+- `onToolExecutionStart?(event)` — fired when a tool begins running.
+- `onToolExecutionEnd?(event)` — fired when a tool finishes (or errors).
+- `onError?(err)` — fired on transport or server errors.
+
+#### Result state
+
+- `messages: Message[]` — committed log.
+- `streamingMessage: AssistantMessage | null` — the in-flight assistant
+ message being streamed, including any partial tool-call blocks.
+- `isStreaming: boolean`.
+- `pendingDecisions: ReadonlyMap` — keyed
+ by `toolCallId`.
+- `executingToolCallIds: ReadonlySet` — tool calls currently running
+ on the server.
+- `error: unknown`.
+
+#### Result actions
+
+- `send(input: string | Message)` — append a user message and run.
+- `sendMessages(msgs: Message[])` — replace the local log and run with that
+ exact list. Useful when the caller has already prepared a transcript.
+- `setMessages(update)` — patch messages without sending. Accepts an array
+ or `(prev) => next`. Recomputes `pendingDecisions` from the new log.
+- `respondWithDecision(toolCallId, value)` — attach a decision to the
+ matching pending `toolCall` block and re-POST. Walks the log backwards to
+ find the most recent assistant message owning that id with no decision
+ yet, so the caller is free to append unrelated messages between the pause
+ and the user's response. Throws if no pending match is found.
+- `abort()` — cancel the in-flight request. Visible text in the streaming
+ message is preserved as a finalized assistant message; orphan tool-call
+ blocks are dropped so they don't re-pause the run on the next call.
+
+## Patterns
+
+### User types instead of clicking approve/deny
+
+If the UI lets the user send a new message while a tool is paused, the
+server can't resume on text alone — every dangling `toolCall` needs a
+result before the next request. Compose `injectDeferralResults` from
+`agentic-kit` with `sendMessages`:
+
+```ts
+import { injectDeferralResults, createUserMessage } from 'agentic-kit';
+
+await sendMessages([
+ ...injectDeferralResults(messages),
+ createUserMessage(text),
+]);
+```
+
+That synthesizes a stand-in `toolResult` for each paused tool call so the
+transcript is well-formed when the server picks it back up.
diff --git a/packages/react/__tests__/tsconfig.json b/packages/react/__tests__/tsconfig.json
new file mode 100644
index 0000000..e4f9a38
--- /dev/null
+++ b/packages/react/__tests__/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "rootDir": "../../..",
+ "baseUrl": "../../..",
+ "paths": {
+ "@test/*": ["tools/test/*"],
+ "agentic-kit": ["packages/agentic-kit/src"],
+ "@agentic-kit/agent": ["packages/agent/src"],
+ "@agentic-kit/react": ["packages/react/src"]
+ },
+ "types": ["jest", "node"]
+ },
+ "include": [
+ "./**/*.ts",
+ "./**/*.tsx",
+ "../src/**/*.ts",
+ "../src/**/*.tsx",
+ "../../../tools/test/**/*.ts"
+ ],
+ "exclude": ["../dist", "../node_modules"]
+}
diff --git a/packages/react/__tests__/use-chat.test.ts b/packages/react/__tests__/use-chat.test.ts
new file mode 100644
index 0000000..7616cce
--- /dev/null
+++ b/packages/react/__tests__/use-chat.test.ts
@@ -0,0 +1,1011 @@
+import type { AgentEvent } from '@agentic-kit/agent';
+import { createScriptedSSEResponse, makeFakeAssistantMessage } from '@test/index';
+import { act, renderHook, waitFor } from '@testing-library/react';
+import type { AssistantMessage, Message, UserMessage } from 'agentic-kit';
+
+import { useChat } from '../src';
+
+function streamFromEvents(events: AgentEvent[]): Response {
+ return createScriptedSSEResponse(events);
+}
+
+function makeUser(content: string, timestamp = 1): UserMessage {
+ return { role: 'user', content, timestamp };
+}
+
+function makeFinalAssistant(text: string): AssistantMessage {
+ return makeFakeAssistantMessage({
+ stopReason: 'stop',
+ content: [{ type: 'text', text }],
+ });
+}
+
+function makePartialAssistant(text: string): AssistantMessage {
+ return makeFakeAssistantMessage({
+ content: [{ type: 'text', text }],
+ });
+}
+
+function makeAssistantWithToolCall(
+ id = 'call_1',
+ name = 'echo'
+): AssistantMessage {
+ return makeFakeAssistantMessage({
+ stopReason: 'toolUse',
+ content: [
+ {
+ type: 'toolCall',
+ id,
+ name,
+ arguments: { text: 'hi' },
+ rawArguments: '{"text":"hi"}',
+ },
+ ],
+ });
+}
+
+describe('useChat', () => {
+ it('hydrates messages from initialMessages', () => {
+ const initial: Message[] = [makeUser('hi')];
+ const { result } = renderHook(() => useChat({ api: '/chat', initialMessages: initial }));
+ expect(result.current.messages).toEqual(initial);
+ expect(result.current.streamingMessage).toBeNull();
+ expect(result.current.pendingDecisions.size).toBe(0);
+ expect(result.current.executingToolCallIds.size).toBe(0);
+ });
+
+ it('sends, streams, and folds messages into the log', async () => {
+ const final = makeFinalAssistant('world');
+ const userEcho = makeUser('hello');
+ const fetchFn = jest.fn(
+ async (): Promise =>
+ streamFromEvents([
+ { type: 'agent_start' },
+ { type: 'message_start', message: userEcho },
+ { type: 'message_end', message: userEcho },
+ { type: 'message_start', message: makePartialAssistant('') },
+ {
+ type: 'message_update',
+ message: makePartialAssistant('wo'),
+ assistantMessageEvent: {
+ type: 'text_delta',
+ contentIndex: 0,
+ delta: 'wo',
+ partial: makePartialAssistant('wo'),
+ },
+ },
+ { type: 'message_end', message: final },
+ { type: 'agent_end', messages: [userEcho, final] },
+ ])
+ );
+ const onMessage = jest.fn();
+ const onFinish = jest.fn();
+
+ const { result } = renderHook(() =>
+ useChat({ api: '/chat', fetch: fetchFn, onMessage, onFinish })
+ );
+
+ await act(async () => {
+ await result.current.send('hello');
+ });
+
+ expect(result.current.messages).toMatchObject([
+ { role: 'user', content: 'hello' },
+ { role: 'assistant', content: [{ type: 'text', text: 'world' }] },
+ ]);
+ expect(result.current.streamingMessage).toBeNull();
+ expect(result.current.isStreaming).toBe(false);
+ expect(result.current.error).toBeUndefined();
+ expect(onMessage).toHaveBeenCalledTimes(2);
+ expect(onFinish).toHaveBeenCalledWith(
+ expect.objectContaining({ role: 'assistant', content: [{ type: 'text', text: 'world' }] })
+ );
+ });
+
+ it('exposes streamingMessage during stream and clears it on agent_end', async () => {
+ let pushFn!: (event: AgentEvent) => void;
+ let closeFn!: () => void;
+ const encoder = new TextEncoder();
+ const body = new ReadableStream({
+ start(controller) {
+ pushFn = (event) =>
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
+ closeFn = () => controller.close();
+ },
+ });
+ const fetchFn = jest.fn(
+ async (): Promise =>
+ new Response(body, {
+ status: 200,
+ headers: { 'Content-Type': 'text/event-stream' },
+ })
+ );
+ const { result } = renderHook(() => useChat({ api: '/chat', fetch: fetchFn }));
+
+ let sendPromise!: Promise;
+ act(() => {
+ sendPromise = result.current.send('hi');
+ });
+ await waitFor(() => expect(fetchFn).toHaveBeenCalled());
+
+ pushFn({ type: 'agent_start' });
+ pushFn({ type: 'message_start', message: makePartialAssistant('') });
+ pushFn({
+ type: 'message_update',
+ message: makePartialAssistant('partial'),
+ assistantMessageEvent: {
+ type: 'text_delta',
+ contentIndex: 0,
+ delta: 'partial',
+ partial: makePartialAssistant('partial'),
+ },
+ });
+
+ await waitFor(() =>
+ expect(result.current.streamingMessage?.content).toEqual([
+ { type: 'text', text: 'partial' },
+ ])
+ );
+ expect(result.current.messages).toMatchObject([{ role: 'user', content: 'hi' }]);
+
+ const final = makeFinalAssistant('done');
+ pushFn({ type: 'message_end', message: final });
+ pushFn({
+ type: 'agent_end',
+ messages: [makeUser('hi'), final],
+ });
+ closeFn();
+ await act(async () => {
+ await sendPromise;
+ });
+
+ expect(result.current.streamingMessage).toBeNull();
+ expect(result.current.messages).toMatchObject([
+ { role: 'user', content: 'hi' },
+ { role: 'assistant', content: [{ type: 'text', text: 'done' }] },
+ ]);
+ });
+
+ it('forwards body() fields and current messages in the POST body', async () => {
+ const final = makeFinalAssistant('ok');
+ const fetchFn = jest.fn(
+ async (_url: RequestInfo | URL, _init?: RequestInit): Promise =>
+ streamFromEvents([
+ { type: 'agent_start' },
+ { type: 'agent_end', messages: [makeUser('hi'), final] },
+ ])
+ );
+ const body = jest.fn(() => ({ model: 'demo', sessionId: 'abc' }));
+
+ const { result } = renderHook(() => useChat({ api: '/chat', fetch: fetchFn, body }));
+
+ await act(async () => {
+ await result.current.send('hi');
+ });
+
+ expect(fetchFn).toHaveBeenCalledTimes(1);
+ expect(body).toHaveBeenCalledTimes(1);
+ const init = fetchFn.mock.calls[0][1] as RequestInit;
+ expect(init.method).toBe('POST');
+ expect(init.headers).toMatchObject({ 'Content-Type': 'application/json' });
+ const sent = JSON.parse(init.body as string);
+ expect(sent).toMatchObject({
+ model: 'demo',
+ sessionId: 'abc',
+ messages: [{ role: 'user', content: 'hi' }],
+ });
+ });
+
+ it('drops a malformed SSE event and continues processing valid ones', async () => {
+ const final = makeFinalAssistant('survived');
+ const encoder = new TextEncoder();
+ const body = new ReadableStream({
+ start(controller) {
+ controller.enqueue(encoder.encode('data: {"type":"agent_start"}\n\n'));
+ controller.enqueue(encoder.encode('data: {garbage not json\n\n'));
+ controller.enqueue(
+ encoder.encode(
+ `data: ${JSON.stringify({ type: 'agent_end', messages: [makeUser('hi'), final] })}\n\n`
+ )
+ );
+ controller.close();
+ },
+ });
+ const response = new Response(body, {
+ status: 200,
+ headers: { 'Content-Type': 'text/event-stream' },
+ });
+ const fetchFn = jest.fn(async (): Promise => response);
+
+ const { result } = renderHook(() => useChat({ api: '/chat', fetch: fetchFn }));
+
+ await act(async () => {
+ await result.current.send('hi');
+ });
+
+ expect(result.current.error).toBeUndefined();
+ expect(result.current.messages).toMatchObject([
+ { role: 'user', content: 'hi' },
+ { role: 'assistant', content: [{ type: 'text', text: 'survived' }] },
+ ]);
+ });
+
+ describe('sendMessages', () => {
+ it('sends the supplied array verbatim without auto-appending', async () => {
+ const final = makeFinalAssistant('ok');
+ const fetchFn = jest.fn(
+ async (_url: RequestInfo | URL, _init?: RequestInit): Promise =>
+ streamFromEvents([
+ { type: 'agent_start' },
+ { type: 'agent_end', messages: [makeUser('a'), makeUser('b'), final] },
+ ])
+ );
+ const { result } = renderHook(() => useChat({ api: '/chat', fetch: fetchFn }));
+
+ const explicit: Message[] = [makeUser('a'), makeUser('b')];
+ await act(async () => {
+ await result.current.sendMessages(explicit);
+ });
+
+ const init = fetchFn.mock.calls[0][1] as RequestInit;
+ const sent = JSON.parse(init.body as string);
+ expect(sent.messages).toEqual(explicit);
+ });
+
+ it('makes input messages visible immediately (before the response arrives)', async () => {
+ let releaseFetch: (() => void) | null = null;
+ const fetchFn = jest.fn(
+ async (_url: RequestInfo | URL, _init?: RequestInit): Promise => {
+ await new Promise((resolve) => {
+ releaseFetch = resolve;
+ });
+ return streamFromEvents([
+ { type: 'agent_start' },
+ { type: 'agent_end', messages: [makeUser('hi'), makeFinalAssistant('hello')] },
+ ]);
+ }
+ );
+ const { result } = renderHook(() => useChat({ api: '/chat', fetch: fetchFn }));
+
+ const input: Message[] = [makeUser('hi')];
+ act(() => {
+ void result.current.sendMessages(input);
+ });
+
+ expect(result.current.messages).toEqual(input);
+ expect(result.current.isStreaming).toBe(true);
+
+ await act(async () => {
+ releaseFetch?.();
+ });
+ });
+ });
+
+ describe('setMessages', () => {
+ it('replaces messages and recomputes pendingDecisions', async () => {
+ const { result } = renderHook(() => useChat({ api: '/chat' }));
+ const withPending: Message[] = [
+ makeUser('hi'),
+ makeAssistantWithToolCall('call_1'),
+ ];
+ act(() => {
+ result.current.setMessages(withPending);
+ });
+ expect(result.current.messages).toEqual(withPending);
+ expect(result.current.pendingDecisions.has('call_1')).toBe(true);
+ expect(result.current.pendingDecisions.get('call_1')).toMatchObject({
+ toolCallId: 'call_1',
+ toolName: 'echo',
+ });
+ });
+
+ it('clears executingToolCallIds and error', async () => {
+ const fetchFn = jest.fn(async (): Promise => {
+ throw new Error('boom');
+ });
+ const { result } = renderHook(() => useChat({ api: '/chat', fetch: fetchFn }));
+ await act(async () => {
+ await result.current.send('hi');
+ });
+ expect(result.current.error).toBeDefined();
+
+ act(() => {
+ result.current.setMessages([]);
+ });
+ expect(result.current.error).toBeUndefined();
+ expect(result.current.executingToolCallIds.size).toBe(0);
+ });
+
+ it('removes pending entries for decisioned toolCalls', () => {
+ const { result } = renderHook(() => useChat({ api: '/chat' }));
+ const pending: Message[] = [
+ makeUser('hi'),
+ makeAssistantWithToolCall('call_1'),
+ ];
+ act(() => {
+ result.current.setMessages(pending);
+ });
+ expect(result.current.pendingDecisions.has('call_1')).toBe(true);
+
+ const decisioned: Message[] = [
+ makeUser('hi'),
+ makeFakeAssistantMessage({
+ stopReason: 'toolUse',
+ content: [
+ {
+ type: 'toolCall',
+ id: 'call_1',
+ name: 'echo',
+ arguments: { text: 'hi' },
+ rawArguments: '{"text":"hi"}',
+ decision: { action: 'approve' },
+ },
+ ],
+ }),
+ ];
+ act(() => {
+ result.current.setMessages(decisioned);
+ });
+ expect(result.current.pendingDecisions.has('call_1')).toBe(false);
+ });
+
+ it('accepts a setter function', () => {
+ const initial: Message[] = [makeUser('hi')];
+ const { result } = renderHook(() =>
+ useChat({ api: '/chat', initialMessages: initial })
+ );
+ act(() => {
+ result.current.setMessages((prev) => [...prev, makeUser('there')]);
+ });
+ expect(result.current.messages).toHaveLength(2);
+ });
+ });
+
+ describe('executingToolCallIds', () => {
+ it('adds on tool_execution_start, removes on tool_execution_end', async () => {
+ const userEcho = makeUser('hi');
+ const final = makeFinalAssistant('done');
+ const onStart = jest.fn();
+ const onEnd = jest.fn();
+ const fetchFn = jest.fn(
+ async (): Promise =>
+ streamFromEvents([
+ { type: 'agent_start' },
+ { type: 'message_start', message: userEcho },
+ { type: 'message_end', message: userEcho },
+ {
+ type: 'tool_execution_start',
+ toolCallId: 'call_1',
+ toolName: 'echo',
+ args: { text: 'hi' },
+ },
+ {
+ type: 'tool_execution_end',
+ toolCallId: 'call_1',
+ toolName: 'echo',
+ result: { content: [{ type: 'text', text: 'ok' }] },
+ isError: false,
+ },
+ { type: 'message_end', message: final },
+ { type: 'agent_end', messages: [userEcho, final] },
+ ])
+ );
+
+ const { result } = renderHook(() =>
+ useChat({
+ api: '/chat',
+ fetch: fetchFn,
+ onToolExecutionStart: onStart,
+ onToolExecutionEnd: onEnd,
+ })
+ );
+
+ await act(async () => {
+ await result.current.send('hi');
+ });
+
+ expect(onStart).toHaveBeenCalledWith({
+ toolCallId: 'call_1',
+ toolName: 'echo',
+ args: { text: 'hi' },
+ });
+ expect(onEnd).toHaveBeenCalledWith({
+ toolCallId: 'call_1',
+ toolName: 'echo',
+ result: { content: [{ type: 'text', text: 'ok' }] },
+ isError: false,
+ });
+ expect(result.current.executingToolCallIds.size).toBe(0);
+ });
+
+ it('clears on abort', async () => {
+ let pushFn!: (event: AgentEvent) => void;
+ const encoder = new TextEncoder();
+ const body = new ReadableStream({
+ start(controller) {
+ pushFn = (event) =>
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
+ },
+ });
+ const fetchFn = jest.fn(
+ async (): Promise =>
+ new Response(body, {
+ status: 200,
+ headers: { 'Content-Type': 'text/event-stream' },
+ })
+ );
+ const { result } = renderHook(() => useChat({ api: '/chat', fetch: fetchFn }));
+
+ act(() => {
+ void result.current.send('hi');
+ });
+ await waitFor(() => expect(fetchFn).toHaveBeenCalled());
+
+ pushFn({
+ type: 'tool_execution_start',
+ toolCallId: 'call_1',
+ toolName: 'echo',
+ args: {},
+ });
+ await waitFor(() => expect(result.current.executingToolCallIds.has('call_1')).toBe(true));
+
+ act(() => {
+ result.current.abort();
+ });
+ expect(result.current.executingToolCallIds.size).toBe(0);
+ });
+ });
+
+ describe('abort', () => {
+ it('cancels the in-flight request and clears isStreaming', async () => {
+ let signalCaptured: AbortSignal | undefined;
+ const fetchFn = jest.fn(
+ (_url: RequestInfo | URL, init?: RequestInit): Promise => {
+ signalCaptured = init?.signal ?? undefined;
+ return new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener('abort', () => {
+ const err = new Error('aborted');
+ err.name = 'AbortError';
+ reject(err);
+ });
+ });
+ }
+ );
+
+ const { result } = renderHook(() => useChat({ api: '/chat', fetch: fetchFn }));
+
+ act(() => {
+ void result.current.send('hi');
+ });
+
+ await waitFor(() => expect(fetchFn).toHaveBeenCalled());
+ expect(result.current.isStreaming).toBe(true);
+
+ act(() => {
+ result.current.abort();
+ });
+
+ await waitFor(() => expect(result.current.isStreaming).toBe(false));
+ expect(signalCaptured?.aborted).toBe(true);
+ expect(result.current.error).toBeUndefined();
+ });
+
+ it('preserves visible partial text as a committed assistant message', async () => {
+ let pushFn!: (event: AgentEvent) => void;
+ const encoder = new TextEncoder();
+ const body = new ReadableStream({
+ start(controller) {
+ pushFn = (event) =>
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
+ },
+ });
+ const fetchFn = jest.fn(
+ async (): Promise =>
+ new Response(body, {
+ status: 200,
+ headers: { 'Content-Type': 'text/event-stream' },
+ })
+ );
+ const { result } = renderHook(() => useChat({ api: '/chat', fetch: fetchFn }));
+
+ act(() => {
+ void result.current.send('hi');
+ });
+ await waitFor(() => expect(fetchFn).toHaveBeenCalled());
+
+ pushFn({ type: 'agent_start' });
+ pushFn({ type: 'message_start', message: makePartialAssistant('') });
+ pushFn({
+ type: 'message_update',
+ message: makePartialAssistant('partial answer'),
+ assistantMessageEvent: {
+ type: 'text_delta',
+ contentIndex: 0,
+ delta: 'partial answer',
+ partial: makePartialAssistant('partial answer'),
+ },
+ });
+ await waitFor(() =>
+ expect(result.current.streamingMessage?.content).toEqual([
+ { type: 'text', text: 'partial answer' },
+ ])
+ );
+
+ act(() => {
+ result.current.abort();
+ });
+
+ expect(result.current.streamingMessage).toBeNull();
+ expect(result.current.isStreaming).toBe(false);
+ expect(result.current.messages).toMatchObject([
+ { role: 'user', content: 'hi' },
+ { role: 'assistant', content: [{ type: 'text', text: 'partial answer' }] },
+ ]);
+ });
+
+ it('drops in-flight tool calls so they do not re-pause as pending decisions', async () => {
+ let pushFn!: (event: AgentEvent) => void;
+ const encoder = new TextEncoder();
+ const body = new ReadableStream({
+ start(controller) {
+ pushFn = (event) =>
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
+ },
+ });
+ const fetchFn = jest.fn(
+ async (): Promise =>
+ new Response(body, {
+ status: 200,
+ headers: { 'Content-Type': 'text/event-stream' },
+ })
+ );
+ const { result } = renderHook(() => useChat({ api: '/chat', fetch: fetchFn }));
+
+ act(() => {
+ void result.current.send('hi');
+ });
+ await waitFor(() => expect(fetchFn).toHaveBeenCalled());
+
+ const partialWithTool = makeFakeAssistantMessage({
+ content: [
+ { type: 'text', text: 'preamble' },
+ {
+ type: 'toolCall',
+ id: 'call_inflight',
+ name: 'echo',
+ arguments: { text: 'hi' },
+ rawArguments: '{"text":"hi"}',
+ },
+ ],
+ });
+ pushFn({ type: 'agent_start' });
+ pushFn({ type: 'message_start', message: makePartialAssistant('') });
+ pushFn({
+ type: 'message_update',
+ message: partialWithTool,
+ assistantMessageEvent: {
+ type: 'text_delta',
+ contentIndex: 0,
+ delta: 'preamble',
+ partial: partialWithTool,
+ },
+ });
+
+ await waitFor(() =>
+ expect(result.current.streamingMessage?.content).toHaveLength(2)
+ );
+
+ act(() => {
+ result.current.abort();
+ });
+
+ expect(result.current.streamingMessage).toBeNull();
+ expect(result.current.messages).toHaveLength(2);
+ const committed = result.current.messages[1];
+ expect(committed.role).toBe('assistant');
+ expect((committed as AssistantMessage).content).toEqual([
+ { type: 'text', text: 'preamble' },
+ ]);
+ expect(result.current.pendingDecisions.size).toBe(0);
+ });
+
+ it('commits nothing when streamingMessage is empty or has no visible text', async () => {
+ let pushFn!: (event: AgentEvent) => void;
+ const encoder = new TextEncoder();
+ const body = new ReadableStream({
+ start(controller) {
+ pushFn = (event) =>
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
+ },
+ });
+ const fetchFn = jest.fn(
+ async (): Promise =>
+ new Response(body, {
+ status: 200,
+ headers: { 'Content-Type': 'text/event-stream' },
+ })
+ );
+ const { result } = renderHook(() => useChat({ api: '/chat', fetch: fetchFn }));
+
+ act(() => {
+ void result.current.send('hi');
+ });
+ await waitFor(() => expect(fetchFn).toHaveBeenCalled());
+
+ pushFn({ type: 'agent_start' });
+ pushFn({ type: 'message_start', message: makePartialAssistant('') });
+ await waitFor(() => expect(result.current.streamingMessage).not.toBeNull());
+
+ act(() => {
+ result.current.abort();
+ });
+
+ expect(result.current.messages).toMatchObject([{ role: 'user', content: 'hi' }]);
+ expect(result.current.messages).toHaveLength(1);
+ });
+
+ it('commits nothing when no streamingMessage exists', async () => {
+ const { result } = renderHook(() => useChat({ api: '/chat' }));
+
+ act(() => {
+ result.current.abort();
+ });
+
+ expect(result.current.messages).toEqual([]);
+ expect(result.current.streamingMessage).toBeNull();
+ expect(result.current.isStreaming).toBe(false);
+ });
+
+ it('drops events that arrive after abort', async () => {
+ let pushFn!: (event: AgentEvent) => void;
+ let closeFn!: () => void;
+ const encoder = new TextEncoder();
+ const body = new ReadableStream({
+ start(controller) {
+ pushFn = (event) =>
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
+ closeFn = () => controller.close();
+ },
+ });
+ const fetchFn = jest.fn(
+ async (): Promise =>
+ new Response(body, {
+ status: 200,
+ headers: { 'Content-Type': 'text/event-stream' },
+ })
+ );
+
+ const { result } = renderHook(() => useChat({ api: '/chat', fetch: fetchFn }));
+
+ let sendPromise!: Promise;
+ act(() => {
+ sendPromise = result.current.send('hi');
+ });
+ await waitFor(() => expect(fetchFn).toHaveBeenCalled());
+
+ act(() => {
+ result.current.abort();
+ });
+ expect(result.current.isStreaming).toBe(false);
+
+ const lateAssistant = makeFinalAssistant('late');
+ pushFn({ type: 'agent_end', messages: [makeUser('hi'), lateAssistant] });
+ closeFn();
+ await act(async () => {
+ await sendPromise;
+ });
+
+ expect(result.current.messages).toMatchObject([{ role: 'user', content: 'hi' }]);
+ expect(result.current.messages).toHaveLength(1);
+ });
+ });
+
+ describe('respondWithDecision', () => {
+ it('attaches the decision and re-POSTs with the augmented log', async () => {
+ const assistantWithToolCall = makeAssistantWithToolCall();
+ const final = makeFinalAssistant('done');
+ const userEcho = makeUser('hi');
+
+ const fetchFn = jest.fn(
+ async (_url: RequestInfo | URL, _init?: RequestInit): Promise =>
+ streamFromEvents([
+ { type: 'agent_start' },
+ { type: 'message_start', message: userEcho },
+ { type: 'message_end', message: userEcho },
+ { type: 'message_start', message: assistantWithToolCall },
+ { type: 'message_end', message: assistantWithToolCall },
+ {
+ type: 'tool_decision_pending',
+ toolCallId: 'call_1',
+ toolName: 'echo',
+ input: { text: 'hi' },
+ schema: { type: 'object' },
+ },
+ ])
+ );
+
+ const onDecisionPending = jest.fn();
+ const { result } = renderHook(() =>
+ useChat({ api: '/chat', fetch: fetchFn, onDecisionPending })
+ );
+
+ await act(async () => {
+ await result.current.send('hi');
+ });
+
+ expect(onDecisionPending).toHaveBeenCalledWith(
+ expect.objectContaining({ toolCallId: 'call_1', toolName: 'echo' })
+ );
+ expect(result.current.pendingDecisions.get('call_1')).toMatchObject({
+ toolCallId: 'call_1',
+ });
+ expect(result.current.isStreaming).toBe(false);
+
+ const resumedAssistant: AssistantMessage = {
+ ...assistantWithToolCall,
+ content: [
+ {
+ type: 'toolCall',
+ id: 'call_1',
+ name: 'echo',
+ arguments: { text: 'hi' },
+ rawArguments: '{"text":"hi"}',
+ decision: 'allow',
+ },
+ ],
+ };
+ const toolResult: Message = {
+ role: 'toolResult',
+ toolCallId: 'call_1',
+ toolName: 'echo',
+ content: [{ type: 'text', text: 'hi' }],
+ isError: false,
+ timestamp: 2,
+ };
+
+ fetchFn.mockImplementationOnce(
+ async (_url: RequestInfo | URL, _init?: RequestInit): Promise =>
+ streamFromEvents([
+ { type: 'agent_start' },
+ {
+ type: 'agent_end',
+ messages: [userEcho, resumedAssistant, toolResult, final],
+ },
+ ])
+ );
+
+ await act(async () => {
+ await result.current.respondWithDecision('call_1', 'allow');
+ });
+
+ expect(fetchFn).toHaveBeenCalledTimes(2);
+ const secondInit = fetchFn.mock.calls[1][1] as RequestInit;
+ const sent = JSON.parse(secondInit.body as string);
+ expect(sent.messages).toHaveLength(2);
+ expect(sent.messages[1].content[0]).toMatchObject({
+ type: 'toolCall',
+ id: 'call_1',
+ decision: 'allow',
+ });
+
+ expect(result.current.messages).toHaveLength(4);
+ expect(result.current.pendingDecisions.has('call_1')).toBe(false);
+ expect(result.current.isStreaming).toBe(false);
+ });
+
+ it('removes only the resolved id from pendingDecisions when multiple are pending', async () => {
+ const a1 = makeAssistantWithToolCall('call_1', 'first');
+ const a2 = makeAssistantWithToolCall('call_2', 'second');
+ const initial: Message[] = [makeUser('hi'), a1, a2];
+
+ const fetchFn = jest.fn(
+ async (): Promise =>
+ streamFromEvents([
+ { type: 'agent_start' },
+ { type: 'agent_end', messages: initial },
+ ])
+ );
+
+ const { result } = renderHook(() =>
+ useChat({ api: '/chat', fetch: fetchFn })
+ );
+
+ act(() => {
+ result.current.setMessages(initial);
+ });
+ expect(result.current.pendingDecisions.size).toBe(2);
+
+ await act(async () => {
+ await result.current.respondWithDecision('call_1', 'allow');
+ });
+
+ expect(result.current.pendingDecisions.has('call_1')).toBe(false);
+ });
+
+ it('finds the pending assistant by toolCallId when a later message was appended', async () => {
+ const assistantWithToolCall = makeAssistantWithToolCall();
+ const trailingNote = makeUser('queued note arrived after pause', 99);
+ const initial: Message[] = [
+ makeUser('hi'),
+ assistantWithToolCall,
+ trailingNote,
+ ];
+ const fetchFn = jest.fn(
+ async (_url: RequestInfo | URL, _init?: RequestInit): Promise =>
+ streamFromEvents([
+ { type: 'agent_start' },
+ { type: 'agent_end', messages: initial },
+ ])
+ );
+
+ const { result } = renderHook(() =>
+ useChat({ api: '/chat', fetch: fetchFn, initialMessages: initial })
+ );
+
+ await act(async () => {
+ await result.current.respondWithDecision('call_1', 'allow');
+ });
+
+ const sent = JSON.parse(fetchFn.mock.calls[0][1]!.body as string);
+ expect(sent.messages).toHaveLength(3);
+ expect(sent.messages[1].content[0]).toMatchObject({
+ type: 'toolCall',
+ id: 'call_1',
+ decision: 'allow',
+ });
+ expect(sent.messages[2]).toMatchObject({ role: 'user', content: 'queued note arrived after pause' });
+ });
+
+ it('throws when no assistant has a pending decision for the toolCallId', async () => {
+ const { result } = renderHook(() => useChat({ api: '/chat' }));
+
+ await expect(
+ act(async () => {
+ await result.current.respondWithDecision('call_unknown', 'allow');
+ })
+ ).rejects.toThrow(/No pending decision for toolCallId 'call_unknown'/);
+ });
+
+ it('skips assistants whose matching toolCall already has a decision', async () => {
+ const earlierWithResolvedDecision = makeFakeAssistantMessage({
+ stopReason: 'toolUse',
+ content: [
+ {
+ type: 'toolCall',
+ id: 'call_resolved',
+ name: 'echo',
+ arguments: { text: 'first' },
+ rawArguments: '{"text":"first"}',
+ decision: 'allow',
+ },
+ ],
+ });
+ const laterPending = makeAssistantWithToolCall();
+ const initial: Message[] = [
+ makeUser('first'),
+ earlierWithResolvedDecision,
+ makeUser('second'),
+ laterPending,
+ ];
+ const fetchFn = jest.fn(
+ async (_url: RequestInfo | URL, _init?: RequestInit): Promise =>
+ streamFromEvents([
+ { type: 'agent_start' },
+ { type: 'agent_end', messages: initial },
+ ])
+ );
+
+ const { result } = renderHook(() =>
+ useChat({ api: '/chat', fetch: fetchFn, initialMessages: initial })
+ );
+
+ await act(async () => {
+ await result.current.respondWithDecision('call_1', 'allow');
+ });
+
+ const sent = JSON.parse(fetchFn.mock.calls[0][1]!.body as string);
+ expect(sent.messages[1].content[0]).toMatchObject({
+ type: 'toolCall',
+ id: 'call_resolved',
+ decision: 'allow',
+ });
+ expect(sent.messages[3].content[0]).toMatchObject({
+ type: 'toolCall',
+ id: 'call_1',
+ decision: 'allow',
+ });
+ });
+ });
+
+ describe('error handling', () => {
+ it('sets error on a non-200 response and fires onError', async () => {
+ const fetchFn = jest.fn(
+ async (): Promise =>
+ new Response('boom', { status: 500, statusText: 'Internal Server Error' })
+ );
+ const onError = jest.fn();
+ const { result } = renderHook(() =>
+ useChat({ api: '/chat', fetch: fetchFn, onError })
+ );
+
+ await act(async () => {
+ await result.current.send('hi');
+ });
+
+ expect(result.current.error).toEqual(new Error('HTTP 500: Internal Server Error'));
+ expect(onError).toHaveBeenCalledWith(new Error('HTTP 500: Internal Server Error'));
+ expect(result.current.isStreaming).toBe(false);
+ expect(result.current.messages).toMatchObject([{ role: 'user', content: 'hi' }]);
+ });
+
+ it('sets error on a network failure and fires onError', async () => {
+ const fetchFn = jest.fn(async (): Promise => {
+ throw new Error('network down');
+ });
+ const onError = jest.fn();
+ const { result } = renderHook(() =>
+ useChat({ api: '/chat', fetch: fetchFn, onError })
+ );
+
+ await act(async () => {
+ await result.current.send('hi');
+ });
+
+ expect(result.current.error).toEqual(new Error('network down'));
+ expect(onError).toHaveBeenCalledWith(new Error('network down'));
+ expect(result.current.isStreaming).toBe(false);
+ });
+
+ it('sets error when response has no body', async () => {
+ const fetchFn = jest.fn(async (): Promise => {
+ const r = new Response(null, { status: 200 });
+ Object.defineProperty(r, 'body', { value: null, configurable: true });
+ return r;
+ });
+ const onError = jest.fn();
+ const { result } = renderHook(() =>
+ useChat({ api: '/chat', fetch: fetchFn, onError })
+ );
+
+ await act(async () => {
+ await result.current.send('hi');
+ });
+
+ expect(result.current.error).toEqual(new Error('Response has no body'));
+ expect(onError).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('lifecycle hooks', () => {
+ it('reads handlers from a ref so consumers do not need to memoize', async () => {
+ const final = makeFinalAssistant('ok');
+ const fetchFn = jest.fn(
+ async (): Promise =>
+ streamFromEvents([
+ { type: 'agent_start' },
+ { type: 'message_start', message: makeUser('hi') },
+ { type: 'message_end', message: makeUser('hi') },
+ { type: 'message_start', message: final },
+ { type: 'message_end', message: final },
+ { type: 'agent_end', messages: [makeUser('hi'), final] },
+ ])
+ );
+
+ const onMessage = jest.fn();
+ const { result, rerender } = renderHook(
+ ({ onMessage: handler }) => useChat({ api: '/chat', fetch: fetchFn, onMessage: handler }),
+ { initialProps: { onMessage } }
+ );
+
+ const newOnMessage = jest.fn();
+ rerender({ onMessage: newOnMessage });
+
+ await act(async () => {
+ await result.current.send('hi');
+ });
+
+ expect(onMessage).not.toHaveBeenCalled();
+ expect(newOnMessage).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/react/jest.config.js b/packages/react/jest.config.js
new file mode 100644
index 0000000..2f030f4
--- /dev/null
+++ b/packages/react/jest.config.js
@@ -0,0 +1,24 @@
+/** @type {import('ts-jest').JestConfigWithTsJest} */
+module.exports = {
+ preset: 'ts-jest',
+ testEnvironment: '/jest.environment.js',
+ transform: {
+ '^.+\\.tsx?$': [
+ 'ts-jest',
+ {
+ babelConfig: false,
+ tsconfig: '__tests__/tsconfig.json',
+ },
+ ],
+ },
+ transformIgnorePatterns: ['/node_modules/*'],
+ testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
+ modulePathIgnorePatterns: ['dist/*'],
+ moduleNameMapper: {
+ '^(\\.{1,2}/.*)\\.js$': '$1',
+ '^@test/(.*)$': '/../../tools/test/$1',
+ '^agentic-kit$': '/../agentic-kit/src',
+ '^@agentic-kit/(.*)$': '/../$1/src',
+ },
+};
diff --git a/packages/react/jest.environment.js b/packages/react/jest.environment.js
new file mode 100644
index 0000000..5f4e56e
--- /dev/null
+++ b/packages/react/jest.environment.js
@@ -0,0 +1,27 @@
+/* eslint-disable @typescript-eslint/no-require-imports */
+const JSDOMEnvironment = require('jest-environment-jsdom').default;
+const { ReadableStream, TransformStream, WritableStream } = require('node:stream/web');
+const { TextEncoder, TextDecoder } = require('node:util');
+
+class WebJSDOMEnvironment extends JSDOMEnvironment {
+ constructor(config, context) {
+ super(config, context);
+ Object.assign(this.global, {
+ TextEncoder,
+ TextDecoder,
+ ReadableStream,
+ TransformStream,
+ WritableStream,
+ fetch,
+ Response,
+ Request,
+ Headers,
+ FormData,
+ Blob,
+ AbortController,
+ AbortSignal,
+ });
+ }
+}
+
+module.exports = WebJSDOMEnvironment;
diff --git a/packages/react/package.json b/packages/react/package.json
new file mode 100644
index 0000000..79c8b5d
--- /dev/null
+++ b/packages/react/package.json
@@ -0,0 +1,57 @@
+{
+ "name": "@agentic-kit/react",
+ "version": "0.1.0",
+ "author": "Dan Lynch ",
+ "description": "React bindings for agentic-kit (useChat hook)",
+ "main": "index.js",
+ "module": "esm/index.js",
+ "types": "index.d.ts",
+ "exports": {
+ ".": {
+ "source": "./src/index.ts",
+ "types": "./index.d.ts",
+ "import": "./esm/index.js",
+ "require": "./index.js"
+ }
+ },
+ "homepage": "https://github.com/constructive-io/agentic-kit",
+ "license": "SEE LICENSE IN LICENSE",
+ "publishConfig": {
+ "access": "public",
+ "directory": "dist"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/constructive-io/agentic-kit"
+ },
+ "bugs": {
+ "url": "https://github.com/constructive-io/agentic-kit/issues"
+ },
+ "scripts": {
+ "clean": "makage clean",
+ "prepack": "npm run build",
+ "build": "makage build",
+ "build:dev": "makage build --dev",
+ "lint": "eslint . --fix",
+ "test": "jest",
+ "test:watch": "jest --watch"
+ },
+ "dependencies": {
+ "@agentic-kit/agent": "workspace:*",
+ "agentic-kit": "workspace:*"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "devDependencies": {
+ "@testing-library/dom": "10.4.1",
+ "@testing-library/react": "16.3.2",
+ "@types/react": "19.2.14",
+ "@types/react-dom": "19.2.3",
+ "jest-environment-jsdom": "^29.7.0",
+ "react": "19.2.5",
+ "react-dom": "19.2.5"
+ },
+ "keywords": []
+}
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
new file mode 100644
index 0000000..608a0dc
--- /dev/null
+++ b/packages/react/src/index.ts
@@ -0,0 +1,6 @@
+export {
+ type ToolDecisionPendingEvent,
+ useChat,
+ type UseChatOptions,
+ type UseChatResult,
+} from './use-chat.js';
diff --git a/packages/react/src/use-chat.ts b/packages/react/src/use-chat.ts
new file mode 100644
index 0000000..512fbfb
--- /dev/null
+++ b/packages/react/src/use-chat.ts
@@ -0,0 +1,413 @@
+import type { AgentEvent, AgentToolResult } from '@agentic-kit/agent';
+import { parseSSEStream } from '@agentic-kit/agent';
+import type { AssistantMessage, Message, ToolCallContent } from 'agentic-kit';
+import { createUserMessage } from 'agentic-kit';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+
+export type ToolDecisionPendingEvent = Extract<
+ AgentEvent,
+ { type: 'tool_decision_pending' }
+>;
+
+export interface ToolExecutionStartEvent {
+ toolCallId: string;
+ toolName: string;
+ args: Record;
+}
+
+export interface ToolExecutionEndEvent {
+ toolCallId: string;
+ toolName: string;
+ result: AgentToolResult;
+ isError: boolean;
+}
+
+export interface UseChatOptions {
+ api: string;
+ body?: () => Record;
+ initialMessages?: Message[];
+ fetch?: typeof globalThis.fetch;
+
+ onMessage?: (message: Message) => void;
+ onFinish?: (message: AssistantMessage) => void;
+ onDecisionPending?: (event: ToolDecisionPendingEvent) => void;
+ onToolExecutionStart?: (event: ToolExecutionStartEvent) => void;
+ onToolExecutionEnd?: (event: ToolExecutionEndEvent) => void;
+ onError?: (error: unknown) => void;
+}
+
+export interface UseChatResult {
+ messages: Message[];
+ streamingMessage: AssistantMessage | null;
+ isStreaming: boolean;
+ pendingDecisions: ReadonlyMap;
+ executingToolCallIds: ReadonlySet;
+ error: unknown;
+
+ send: (input: string | Message) => Promise;
+ sendMessages: (messages: Message[]) => Promise;
+ setMessages: (msgs: Message[] | ((prev: Message[]) => Message[])) => void;
+ respondWithDecision: (toolCallId: string, value: unknown) => Promise;
+ abort: () => void;
+}
+
+/**
+ * Compute the pendingDecisions map from a messages array. A toolCall counts as
+ * pending when it has neither a `decision` field nor a paired `toolResult`.
+ *
+ * Used on `setMessages` to recompute server-derived state from the new array.
+ * The synthesized event is a stub (no `input`, no `schema`) — the original
+ * decision-pending event is not recoverable from messages alone, but the map
+ * key (toolCallId) is what consumers actually need to render decision UI.
+ */
+function rederivePendingDecisions(
+ messages: Message[]
+): Map {
+ const completed = new Set();
+ for (const m of messages) {
+ if (m.role === 'toolResult') completed.add(m.toolCallId);
+ }
+ const pending = new Map();
+ for (const m of messages) {
+ if (m.role !== 'assistant') continue;
+ for (const block of m.content) {
+ if (block.type !== 'toolCall') continue;
+ if (completed.has(block.id)) continue;
+ if ('decision' in block && block.decision !== undefined) continue;
+ pending.set(block.id, {
+ type: 'tool_decision_pending',
+ toolCallId: block.id,
+ toolName: block.name,
+ input: block.arguments,
+ schema: { type: 'object' },
+ });
+ }
+ }
+ return pending;
+}
+
+export function useChat(options: UseChatOptions): UseChatResult {
+ const [messages, setMessagesState] = useState(
+ () => options.initialMessages ?? []
+ );
+ const [streamingMessage, setStreamingMessage] = useState(null);
+ const [isStreaming, setIsStreaming] = useState(false);
+ const [pendingDecisions, setPendingDecisions] = useState<
+ ReadonlyMap
+ >(() => new Map());
+ const [executingToolCallIds, setExecutingToolCallIds] = useState>(
+ () => new Set()
+ );
+ const [error, setError] = useState(undefined);
+
+ const messagesRef = useRef(messages);
+ useEffect(() => {
+ messagesRef.current = messages;
+ }, [messages]);
+
+ const streamingMessageRef = useRef(null);
+ useEffect(() => {
+ streamingMessageRef.current = streamingMessage;
+ }, [streamingMessage]);
+
+ const optionsRef = useRef(options);
+ useEffect(() => {
+ optionsRef.current = options;
+ }, [options]);
+
+ const runIdRef = useRef(0);
+ const abortControllerRef = useRef(null);
+
+ const failRun = useCallback((err: unknown) => {
+ setError(err);
+ optionsRef.current.onError?.(err);
+ setIsStreaming(false);
+ }, []);
+
+ const runStream = useCallback(
+ async (
+ requestMessages: Message[],
+ optimisticUserMessage: Message | null
+ ): Promise => {
+ const opts = optionsRef.current;
+ const myRun = ++runIdRef.current;
+
+ abortControllerRef.current?.abort();
+ const controller = new AbortController();
+ abortControllerRef.current = controller;
+
+ const isCurrent = () => runIdRef.current === myRun;
+
+ setIsStreaming(true);
+ setError(undefined);
+ setPendingDecisions(new Map());
+ setExecutingToolCallIds(new Set());
+ setStreamingMessage(null);
+ if (optimisticUserMessage) {
+ setMessagesState((prev) => [...prev, optimisticUserMessage]);
+ }
+
+ let skipUserEcho = optimisticUserMessage !== null;
+
+ const fetchFn = opts.fetch ?? globalThis.fetch;
+ const extraBody = opts.body?.() ?? {};
+
+ let response: Response;
+ try {
+ response = await fetchFn(opts.api, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ messages: requestMessages, ...extraBody }),
+ signal: controller.signal,
+ });
+ } catch (err) {
+ if (!isCurrent()) return;
+ if (controller.signal.aborted) {
+ if (isCurrent()) setIsStreaming(false);
+ return;
+ }
+ failRun(err);
+ return;
+ }
+
+ if (!isCurrent()) return;
+
+ if (!response.ok) {
+ failRun(new Error(`HTTP ${response.status}: ${response.statusText}`));
+ return;
+ }
+
+ if (!response.body) {
+ failRun(new Error('Response has no body'));
+ return;
+ }
+
+ try {
+ for await (const event of parseSSEStream(response.body)) {
+ if (!isCurrent()) return;
+
+ switch (event.type) {
+ case 'message_start': {
+ if (skipUserEcho && event.message.role === 'user') {
+ skipUserEcho = false;
+ break;
+ }
+ if (event.message.role === 'assistant') {
+ setStreamingMessage(event.message);
+ }
+ break;
+ }
+ case 'message_update': {
+ setStreamingMessage(event.message);
+ break;
+ }
+ case 'message_end': {
+ if (event.message.role === 'assistant') {
+ setStreamingMessage(null);
+ setMessagesState((prev) => [...prev, event.message]);
+ } else if (event.message.role === 'toolResult') {
+ setMessagesState((prev) => [...prev, event.message]);
+ }
+ opts.onMessage?.(event.message);
+ break;
+ }
+ case 'tool_execution_start': {
+ setExecutingToolCallIds((prev) => {
+ const next = new Set(prev);
+ next.add(event.toolCallId);
+ return next;
+ });
+ opts.onToolExecutionStart?.({
+ toolCallId: event.toolCallId,
+ toolName: event.toolName,
+ args: event.args,
+ });
+ break;
+ }
+ case 'tool_execution_end': {
+ setExecutingToolCallIds((prev) => {
+ if (!prev.has(event.toolCallId)) return prev;
+ const next = new Set(prev);
+ next.delete(event.toolCallId);
+ return next;
+ });
+ opts.onToolExecutionEnd?.({
+ toolCallId: event.toolCallId,
+ toolName: event.toolName,
+ result: event.result,
+ isError: event.isError,
+ });
+ break;
+ }
+ case 'tool_decision_pending': {
+ setPendingDecisions((prev) => {
+ const next = new Map(prev);
+ next.set(event.toolCallId, event);
+ return next;
+ });
+ opts.onDecisionPending?.(event);
+ break;
+ }
+ case 'agent_end': {
+ setStreamingMessage(null);
+ setMessagesState(() => {
+ if (!isCurrent()) return messagesRef.current;
+ return event.messages;
+ });
+ const lastAssistant = [...event.messages]
+ .reverse()
+ .find((m): m is AssistantMessage => m.role === 'assistant');
+ if (lastAssistant) {
+ opts.onFinish?.(lastAssistant);
+ }
+ break;
+ }
+ }
+ }
+ } catch (err) {
+ if (!isCurrent()) return;
+ if (controller.signal.aborted) return;
+ failRun(err);
+ return;
+ } finally {
+ if (isCurrent()) {
+ setIsStreaming(false);
+ abortControllerRef.current = null;
+ }
+ }
+ },
+ [failRun]
+ );
+
+ const sendMessages = useCallback(
+ async (msgs: Message[]): Promise => {
+ setMessagesState(msgs);
+ messagesRef.current = msgs;
+ await runStream(msgs, null);
+ },
+ [runStream]
+ );
+
+ const send = useCallback(
+ async (input: string | Message): Promise => {
+ const userMessage: Message =
+ typeof input === 'string' ? createUserMessage(input) : input;
+ const requestMessages = [...messagesRef.current, userMessage];
+ await runStream(requestMessages, userMessage);
+ },
+ [runStream]
+ );
+
+ const respondWithDecision = useCallback(
+ async (toolCallId: string, value: unknown): Promise => {
+ const current = messagesRef.current;
+ let targetIdx = -1;
+ for (let i = current.length - 1; i >= 0; i--) {
+ const msg = current[i];
+ if (msg.role !== 'assistant') continue;
+ const match = msg.content.find(
+ (block) => block.type === 'toolCall' && block.id === toolCallId
+ );
+ if (!match) continue;
+ if ('decision' in match && match.decision !== undefined) continue;
+ targetIdx = i;
+ break;
+ }
+ if (targetIdx === -1) {
+ throw new Error(`No pending decision for toolCallId '${toolCallId}'`);
+ }
+ const target = current[targetIdx] as AssistantMessage;
+ const updatedAssistant: AssistantMessage = {
+ ...target,
+ content: target.content.map((block) => {
+ if (block.type !== 'toolCall' || block.id !== toolCallId) {
+ return block;
+ }
+ return { ...(block as ToolCallContent), decision: value };
+ }),
+ };
+ const requestMessages = [
+ ...current.slice(0, targetIdx),
+ updatedAssistant,
+ ...current.slice(targetIdx + 1),
+ ];
+ setMessagesState(requestMessages);
+ messagesRef.current = requestMessages;
+ setPendingDecisions((prev) => {
+ if (!prev.has(toolCallId)) return prev;
+ const next = new Map(prev);
+ next.delete(toolCallId);
+ return next;
+ });
+ await runStream(requestMessages, null);
+ },
+ [runStream]
+ );
+
+ const setMessages = useCallback(
+ (update: Message[] | ((prev: Message[]) => Message[])): void => {
+ setMessagesState((prev) => {
+ const next = typeof update === 'function' ? update(prev) : update;
+ messagesRef.current = next;
+ setPendingDecisions(rederivePendingDecisions(next));
+ setExecutingToolCallIds(new Set());
+ setError(undefined);
+ return next;
+ });
+ },
+ []
+ );
+
+ const abort = useCallback(() => {
+ abortControllerRef.current?.abort();
+ abortControllerRef.current = null;
+ runIdRef.current++;
+
+ // Snapshot the in-flight assistant message before clearing so visible text
+ // survives the stop. Drop toolCall blocks — without results they'd surface
+ // as pending decisions via rederivePendingDecisions and re-pause the agent.
+ const partial = streamingMessageRef.current;
+ if (partial) {
+ const visible = partial.content.filter(
+ (b) => b.type === 'text' && b.text.length > 0
+ );
+ if (visible.length > 0) {
+ setMessagesState((prev) => [...prev, { ...partial, content: visible }]);
+ }
+ }
+
+ setIsStreaming(false);
+ setStreamingMessage(null);
+ setExecutingToolCallIds(new Set());
+ setPendingDecisions(new Map());
+ }, []);
+
+ return useMemo(
+ () => ({
+ messages,
+ streamingMessage,
+ isStreaming,
+ pendingDecisions,
+ executingToolCallIds,
+ error,
+ send,
+ sendMessages,
+ setMessages,
+ respondWithDecision,
+ abort,
+ }),
+ [
+ messages,
+ streamingMessage,
+ isStreaming,
+ pendingDecisions,
+ executingToolCallIds,
+ error,
+ send,
+ sendMessages,
+ setMessages,
+ respondWithDecision,
+ abort,
+ ]
+ );
+}
diff --git a/packages/react/tsconfig.esm.json b/packages/react/tsconfig.esm.json
new file mode 100644
index 0000000..624ab17
--- /dev/null
+++ b/packages/react/tsconfig.esm.json
@@ -0,0 +1,7 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "module": "es2022",
+ "outDir": "dist/esm"
+ }
+}
diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json
new file mode 100644
index 0000000..2391b80
--- /dev/null
+++ b/packages/react/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src",
+ "jsx": "react-jsx",
+ "lib": ["es2022", "dom"]
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2fad777..cbcc1f4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -60,6 +60,55 @@ importers:
specifier: ^6.0.3
version: 6.0.3
+ apps/nextjs-chat-demo:
+ dependencies:
+ '@agentic-kit/agent':
+ specifier: workspace:*
+ version: link:../../packages/agent/dist
+ '@agentic-kit/openai':
+ specifier: workspace:*
+ version: link:../../packages/openai/dist
+ '@agentic-kit/react':
+ specifier: workspace:*
+ version: link:../../packages/react/dist
+ agentic-kit:
+ specifier: workspace:*
+ version: link:../../packages/agentic-kit/dist
+ clsx:
+ specifier: ^2.1.1
+ version: 2.1.1
+ next:
+ specifier: 15.0.4
+ version: 15.0.4(@babel/core@7.29.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ react:
+ specifier: 19.0.0
+ version: 19.0.0
+ react-dom:
+ specifier: 19.0.0
+ version: 19.0.0(react@19.0.0)
+ tailwind-merge:
+ specifier: ^3.5.0
+ version: 3.5.0
+ devDependencies:
+ '@tailwindcss/postcss':
+ specifier: ^4.1.18
+ version: 4.2.4
+ '@types/node':
+ specifier: ^22.10.2
+ version: 22.19.17
+ '@types/react':
+ specifier: 19.0.0
+ version: 19.0.0
+ '@types/react-dom':
+ specifier: 19.0.0
+ version: 19.0.0
+ tailwindcss:
+ specifier: ^4.1.18
+ version: 4.2.2
+ typescript:
+ specifier: ^5.7.2
+ version: 5.9.3
+
apps/tanstack-chat-demo:
dependencies:
'@agentic-kit/ollama':
@@ -181,31 +230,47 @@ importers:
'@agentic-kit/openai':
specifier: workspace:*
version: link:../openai/dist
- devDependencies:
- cross-fetch:
- specifier: ^4.1.0
- version: 4.1.0(encoding@0.1.13)
publishDirectory: dist
packages/anthropic:
- dependencies:
- cross-fetch:
- specifier: ^4.1.0
- version: 4.1.0(encoding@0.1.13)
publishDirectory: dist
packages/ollama:
- dependencies:
- cross-fetch:
- specifier: ^4.1.0
- version: 4.1.0(encoding@0.1.13)
publishDirectory: dist
packages/openai:
+ publishDirectory: dist
+
+ packages/react:
dependencies:
- cross-fetch:
- specifier: ^4.1.0
- version: 4.1.0(encoding@0.1.13)
+ '@agentic-kit/agent':
+ specifier: workspace:*
+ version: link:../agent/dist
+ agentic-kit:
+ specifier: workspace:*
+ version: link:../agentic-kit/dist
+ devDependencies:
+ '@testing-library/dom':
+ specifier: 10.4.1
+ version: 10.4.1
+ '@testing-library/react':
+ specifier: 16.3.2
+ version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@types/react':
+ specifier: 19.2.14
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: 19.2.3
+ version: 19.2.3(@types/react@19.2.14)
+ jest-environment-jsdom:
+ specifier: ^29.7.0
+ version: 29.7.0
+ react:
+ specifier: 19.2.5
+ version: 19.2.5
+ react-dom:
+ specifier: 19.2.5
+ version: 19.2.5(react@19.2.5)
publishDirectory: dist
packages:
@@ -213,6 +278,10 @@ packages:
'@acemir/cssom@0.9.31':
resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==}
+ '@alloc/quick-lru@5.2.0':
+ resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
+ engines: {node: '>=10'}
+
'@asamuzakjp/css-color@5.1.11':
resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -765,6 +834,111 @@ packages:
resolution: {integrity: sha512-H9XAx3hc0BQHY6l+IFSWHDySypcXsvsuLhgYLUGywmJ5pswRVQJUHpOsobnLYp2ZUaUlKiKDrgWWhosOwAEM8Q==}
engines: {node: '>=6.9.0'}
+ '@img/sharp-darwin-arm64@0.33.5':
+ resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@img/sharp-darwin-x64@0.33.5':
+ resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@img/sharp-libvips-darwin-arm64@1.0.4':
+ resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@img/sharp-libvips-darwin-x64@1.0.4':
+ resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@img/sharp-libvips-linux-arm64@1.0.4':
+ resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-arm@1.0.5':
+ resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
+ cpu: [arm]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-s390x@1.0.4':
+ resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
+ cpu: [s390x]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-x64@1.0.4':
+ resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-libvips-linuxmusl-arm64@1.0.4':
+ resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-libvips-linuxmusl-x64@1.0.4':
+ resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-linux-arm64@0.33.5':
+ resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-linux-arm@0.33.5':
+ resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@img/sharp-linux-s390x@0.33.5':
+ resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [s390x]
+ os: [linux]
+
+ '@img/sharp-linux-x64@0.33.5':
+ resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-linuxmusl-arm64@0.33.5':
+ resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-linuxmusl-x64@0.33.5':
+ resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-wasm32@0.33.5':
+ resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [wasm32]
+
+ '@img/sharp-win32-ia32@0.33.5':
+ resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [ia32]
+ os: [win32]
+
+ '@img/sharp-win32-x64@0.33.5':
+ resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [win32]
+
'@inquirer/ansi@2.0.5':
resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
@@ -940,6 +1114,57 @@ packages:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
+ '@next/env@15.0.4':
+ resolution: {integrity: sha512-WNRvtgnRVDD4oM8gbUcRc27IAhaL4eXQ/2ovGbgLnPGUvdyDr8UdXP4Q/IBDdAdojnD2eScryIDirv0YUCjUVw==}
+
+ '@next/swc-darwin-arm64@15.0.4':
+ resolution: {integrity: sha512-QecQXPD0yRHxSXWL5Ff80nD+A56sUXZG9koUsjWJwA2Z0ZgVQfuy7gd0/otjxoOovPVHR2eVEvPMHbtZP+pf9w==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@next/swc-darwin-x64@15.0.4':
+ resolution: {integrity: sha512-pb7Bye3y1Og3PlCtnz2oO4z+/b3pH2/HSYkLbL0hbVuTGil7fPen8/3pyyLjdiTLcFJ+ymeU3bck5hd4IPFFCA==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@next/swc-linux-arm64-gnu@15.0.4':
+ resolution: {integrity: sha512-12oSaBFjGpB227VHzoXF3gJoK2SlVGmFJMaBJSu5rbpaoT5OjP5OuCLuR9/jnyBF1BAWMs/boa6mLMoJPRriMA==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@next/swc-linux-arm64-musl@15.0.4':
+ resolution: {integrity: sha512-QARO88fR/a+wg+OFC3dGytJVVviiYFEyjc/Zzkjn/HevUuJ7qGUUAUYy5PGVWY1YgTzeRYz78akQrVQ8r+sMjw==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@next/swc-linux-x64-gnu@15.0.4':
+ resolution: {integrity: sha512-Z50b0gvYiUU1vLzfAMiChV8Y+6u/T2mdfpXPHraqpypP7yIT2UV9YBBhcwYkxujmCvGEcRTVWOj3EP7XW/wUnw==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@next/swc-linux-x64-musl@15.0.4':
+ resolution: {integrity: sha512-7H9C4FAsrTAbA/ENzvFWsVytqRYhaJYKa2B3fyQcv96TkOGVMcvyS6s+sj4jZlacxxTcn7ygaMXUPkEk7b78zw==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@next/swc-win32-arm64-msvc@15.0.4':
+ resolution: {integrity: sha512-Z/v3WV5xRaeWlgJzN9r4PydWD8sXV35ywc28W63i37G2jnUgScA4OOgS8hQdiXLxE3gqfSuHTicUhr7931OXPQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@next/swc-win32-x64-msvc@15.0.4':
+ resolution: {integrity: sha512-NGLchGruagh8lQpDr98bHLyWJXOBSmkEAfK980OiNBa7vNm6PsNoPvzTfstT78WyOeMRQphEQ455rggd7Eo+Dw==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
'@noble/ciphers@1.3.0':
resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==}
engines: {node: ^14.21.3 || >=16}
@@ -1056,28 +1281,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@nx/nx-linux-arm64-musl@20.8.4':
resolution: {integrity: sha512-AlZZFolS/S0FahRKG7rJ0Z9CgmIkyzHgGaoy3qNEMDEjFhR3jt2ZZSLp90W7zjgrxojOo90ajNMrg2UmtcQRDA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@nx/nx-linux-x64-gnu@20.8.4':
resolution: {integrity: sha512-MSu+xVNdR95tuuO+eL/a/ZeMlhfrZ627On5xaCZXnJ+lFxNg/S4nlKZQk0Eq5hYALCd/GKgFGasRdlRdOtvGPg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@nx/nx-linux-x64-musl@20.8.4':
resolution: {integrity: sha512-KxpQpyLCgIIHWZ4iRSUN9ohCwn1ZSDASbuFCdG3mohryzCy8WrPkuPcb+68J3wuQhmA5w//Xpp/dL0hHoit9zQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [musl]
'@nx/nx-win32-arm64-msvc@20.8.4':
resolution: {integrity: sha512-ffLBrxM9ibk+eWSY995kiFFRTSRb9HkD5T1s/uZyxV6jfxYPaZDBAWAETDneyBXps7WtaOMu+kVZlXQ3X+TfIA==}
@@ -1906,42 +2127,36 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.15':
resolution: {integrity: sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15':
resolution: {integrity: sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15':
resolution: {integrity: sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.15':
resolution: {integrity: sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-rc.15':
resolution: {integrity: sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-rc.15':
resolution: {integrity: sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==}
@@ -2009,79 +2224,66 @@ packages:
resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==}
cpu: [arm]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.60.1':
resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==}
cpu: [arm]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.60.1':
resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.60.1':
resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.60.1':
resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==}
cpu: [loong64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.60.1':
resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==}
cpu: [loong64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.60.1':
resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.60.1':
resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==}
cpu: [ppc64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.60.1':
resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.60.1':
resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==}
cpu: [riscv64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.60.1':
resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.60.1':
resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.60.1':
resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==}
cpu: [x64]
os: [linux]
- libc: [musl]
'@rollup/rollup-openbsd-x64@4.60.1':
resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==}
@@ -2183,66 +2385,125 @@ packages:
peerDependencies:
solid-js: ^1.6.12
+ '@swc/counter@0.1.3':
+ resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
+
+ '@swc/helpers@0.5.13':
+ resolution: {integrity: sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==}
+
'@tailwindcss/node@4.2.2':
resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==}
+ '@tailwindcss/node@4.2.4':
+ resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==}
+
'@tailwindcss/oxide-android-arm64@4.2.2':
resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [android]
+ '@tailwindcss/oxide-android-arm64@4.2.4':
+ resolution: {integrity: sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [android]
+
'@tailwindcss/oxide-darwin-arm64@4.2.2':
resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [darwin]
+ '@tailwindcss/oxide-darwin-arm64@4.2.4':
+ resolution: {integrity: sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [darwin]
+
'@tailwindcss/oxide-darwin-x64@4.2.2':
resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [darwin]
+ '@tailwindcss/oxide-darwin-x64@4.2.4':
+ resolution: {integrity: sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [darwin]
+
'@tailwindcss/oxide-freebsd-x64@4.2.2':
resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==}
engines: {node: '>= 20'}
cpu: [x64]
os: [freebsd]
+ '@tailwindcss/oxide-freebsd-x64@4.2.4':
+ resolution: {integrity: sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [freebsd]
+
'@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2':
resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==}
engines: {node: '>= 20'}
cpu: [arm]
os: [linux]
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4':
+ resolution: {integrity: sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==}
+ engines: {node: '>= 20'}
+ cpu: [arm]
+ os: [linux]
+
'@tailwindcss/oxide-linux-arm64-gnu@4.2.2':
resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.2.4':
+ resolution: {integrity: sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [linux]
'@tailwindcss/oxide-linux-arm64-musl@4.2.2':
resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
- libc: [musl]
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.2.4':
+ resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [linux]
'@tailwindcss/oxide-linux-x64-gnu@4.2.2':
resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
- libc: [glibc]
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.2.4':
+ resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [linux]
'@tailwindcss/oxide-linux-x64-musl@4.2.2':
resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
- libc: [musl]
+
+ '@tailwindcss/oxide-linux-x64-musl@4.2.4':
+ resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [linux]
'@tailwindcss/oxide-wasm32-wasi@4.2.2':
resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==}
@@ -2256,22 +2517,53 @@ packages:
- '@emnapi/wasi-threads'
- tslib
+ '@tailwindcss/oxide-wasm32-wasi@4.2.4':
+ resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+ bundledDependencies:
+ - '@napi-rs/wasm-runtime'
+ - '@emnapi/core'
+ - '@emnapi/runtime'
+ - '@tybys/wasm-util'
+ - '@emnapi/wasi-threads'
+ - tslib
+
'@tailwindcss/oxide-win32-arm64-msvc@4.2.2':
resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [win32]
+ '@tailwindcss/oxide-win32-arm64-msvc@4.2.4':
+ resolution: {integrity: sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [win32]
+
'@tailwindcss/oxide-win32-x64-msvc@4.2.2':
resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==}
engines: {node: '>= 20'}
cpu: [x64]
os: [win32]
+ '@tailwindcss/oxide-win32-x64-msvc@4.2.4':
+ resolution: {integrity: sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [win32]
+
'@tailwindcss/oxide@4.2.2':
resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==}
engines: {node: '>= 20'}
+ '@tailwindcss/oxide@4.2.4':
+ resolution: {integrity: sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==}
+ engines: {node: '>= 20'}
+
+ '@tailwindcss/postcss@4.2.4':
+ resolution: {integrity: sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==}
+
'@tailwindcss/typography@0.5.19':
resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==}
peerDependencies:
@@ -2512,6 +2804,10 @@ packages:
'@types/react-dom':
optional: true
+ '@tootallnate/once@2.0.1':
+ resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==}
+ engines: {node: '>= 10'}
+
'@ts-morph/common@0.27.0':
resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==}
@@ -2580,6 +2876,9 @@ packages:
'@types/jest@29.5.14':
resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==}
+ '@types/jsdom@20.0.1':
+ resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==}
+
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -2598,11 +2897,17 @@ packages:
'@types/normalize-package-data@2.4.4':
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
+ '@types/react-dom@19.0.0':
+ resolution: {integrity: sha512-1KfiQKsH1o00p9m5ag12axHQSb3FOU9H20UTrujVSkNhuCrRHiQWFqgEnTNK5ZNfnzZv8UWrnXVqCmCF9fgY3w==}
+
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
'@types/react': ^19.2.0
+ '@types/react@19.0.0':
+ resolution: {integrity: sha512-MY3oPudxvMYyesqs/kW1Bh8y9VqSmf+tzqw3ae8a9DZW68pUe3zAdHeI1jc6iAysuRdACnVknHP8AhwD4/dxtg==}
+
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
@@ -2615,6 +2920,9 @@ packages:
'@types/statuses@2.0.6':
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
+ '@types/tough-cookie@4.0.5':
+ resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
+
'@types/validate-npm-package-name@4.0.2':
resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
@@ -2740,6 +3048,10 @@ packages:
resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==}
hasBin: true
+ abab@2.0.6:
+ resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
+ deprecated: Use your platform's native atob() and btoa() methods instead
+
abbrev@2.0.0:
resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@@ -2748,6 +3060,9 @@ packages:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
+ acorn-globals@7.0.1:
+ resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==}
+
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -2765,6 +3080,10 @@ packages:
add-stream@1.0.0:
resolution: {integrity: sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ==}
+ agent-base@6.0.2:
+ resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
+ engines: {node: '>= 6.0.0'}
+
agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
@@ -2985,6 +3304,10 @@ packages:
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
engines: {node: '>=18'}
+ busboy@1.6.0:
+ resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
+ engines: {node: '>=10.16.0'}
+
byte-size@8.1.1:
resolution: {integrity: sha512-tUkzZWK0M/qdoLEqikxBWe4kumyuwjl3HO6zHTr4yEI23EojPtLYXdG1+AQY7MN0cGyNDvEaJ8wiYQm6P2bPxg==}
engines: {node: '>=12.17'}
@@ -3112,6 +3435,9 @@ packages:
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
engines: {node: '>= 12'}
+ client-only@0.0.1:
+ resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
+
cliui@7.0.4:
resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==}
@@ -3152,10 +3478,17 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+ color-string@1.9.1:
+ resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
+
color-support@1.1.3:
resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==}
hasBin: true
+ color@4.2.3:
+ resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
+ engines: {node: '>=12.5.0'}
+
columnify@1.6.0:
resolution: {integrity: sha512-lomjuFZKfM6MSAnV9aCZC9sc0qGbmZdfygNv+nCpqVkSKdCxCklLtd16O0EILGkImHw9ZpHkAnHaB+8Zxq5W6Q==}
engines: {node: '>=8.0.0'}
@@ -3269,9 +3602,6 @@ packages:
create-require@1.1.1:
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
- cross-fetch@4.1.0:
- resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==}
-
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -3292,6 +3622,16 @@ packages:
engines: {node: '>=4'}
hasBin: true
+ cssom@0.3.8:
+ resolution: {integrity: sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==}
+
+ cssom@0.5.0:
+ resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==}
+
+ cssstyle@2.3.0:
+ resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==}
+ engines: {node: '>=8'}
+
cssstyle@6.2.0:
resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==}
engines: {node: '>=20'}
@@ -3307,6 +3647,10 @@ packages:
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
engines: {node: '>= 12'}
+ data-urls@3.0.2:
+ resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==}
+ engines: {node: '>=12'}
+
data-urls@7.0.0:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -3434,6 +3778,11 @@ packages:
domelementtype@2.3.0:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
+ domexception@4.0.0:
+ resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==}
+ engines: {node: '>=12'}
+ deprecated: Use your platform's native DOMException instead
+
domhandler@5.0.3:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
@@ -3583,6 +3932,11 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
+ escodegen@2.1.0:
+ resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==}
+ engines: {node: '>=6.0'}
+ hasBin: true
+
eslint-config-prettier@10.1.8:
resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==}
hasBin: true
@@ -4055,6 +4409,10 @@ packages:
resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==}
engines: {node: ^16.14.0 || >=18.0.0}
+ html-encoding-sniffer@3.0.0:
+ resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==}
+ engines: {node: '>=12'}
+
html-encoding-sniffer@6.0.0:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -4072,10 +4430,18 @@ packages:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
+ http-proxy-agent@5.0.0:
+ resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
+ engines: {node: '>= 6'}
+
http-proxy-agent@7.0.2:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'}
+ https-proxy-agent@5.0.1:
+ resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
+ engines: {node: '>= 6'}
+
https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
@@ -4166,6 +4532,9 @@ packages:
is-arrayish@0.2.1:
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
+ is-arrayish@0.3.4:
+ resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
+
is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
@@ -4396,6 +4765,15 @@ packages:
resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+ jest-environment-jsdom@29.7.0:
+ resolution: {integrity: sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==}
+ engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+ peerDependencies:
+ canvas: ^2.5.0
+ peerDependenciesMeta:
+ canvas:
+ optional: true
+
jest-environment-node@29.7.0:
resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -4508,6 +4886,15 @@ packages:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
+ jsdom@20.0.3:
+ resolution: {integrity: sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==}
+ engines: {node: '>=14'}
+ peerDependencies:
+ canvas: ^2.5.0
+ peerDependenciesMeta:
+ canvas:
+ optional: true
+
jsdom@28.1.0:
resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -4648,28 +5035,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -4984,6 +5367,28 @@ packages:
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
+ next@15.0.4:
+ resolution: {integrity: sha512-nuy8FH6M1FG0lktGotamQDCXhh5hZ19Vo0ht1AOIQWrYJLP598TIUagKtvJrfJ5AGwB/WmDqkKaKhMpVifvGPA==}
+ engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
+ deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.
+ hasBin: true
+ peerDependencies:
+ '@opentelemetry/api': ^1.1.0
+ '@playwright/test': ^1.41.2
+ babel-plugin-react-compiler: '*'
+ react: ^18.2.0 || 19.0.0-rc-66855b96-20241106 || ^19.0.0
+ react-dom: ^18.2.0 || 19.0.0-rc-66855b96-20241106 || ^19.0.0
+ sass: ^1.3.0
+ peerDependenciesMeta:
+ '@opentelemetry/api':
+ optional: true
+ '@playwright/test':
+ optional: true
+ babel-plugin-react-compiler:
+ optional: true
+ sass:
+ optional: true
+
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
@@ -4998,15 +5403,6 @@ packages:
encoding:
optional: true
- node-fetch@2.7.0:
- resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
- engines: {node: 4.x || >=6.0.0}
- peerDependencies:
- encoding: ^0.1.0
- peerDependenciesMeta:
- encoding:
- optional: true
-
node-fetch@3.3.2:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -5084,6 +5480,9 @@ packages:
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
+ nwsapi@2.2.23:
+ resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
+
nx@20.8.4:
resolution: {integrity: sha512-/++x0OM3/UTmDR+wmPeV13tSxeTr+QGzj3flgtH9DiOPmQnn2CjHWAMZiOhcSh/hHoE/V3ySL4757InQUsVtjQ==}
hasBin: true
@@ -5370,6 +5769,10 @@ packages:
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
engines: {node: '>=4'}
+ postcss@8.4.31:
+ resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
+ engines: {node: ^10 || ^12 || >=14}
+
postcss@8.5.10:
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
engines: {node: ^10 || ^12 || >=14}
@@ -5446,6 +5849,9 @@ packages:
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
+ psl@1.15.0:
+ resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==}
+
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -5457,6 +5863,9 @@ packages:
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
engines: {node: '>=0.6'}
+ querystringify@2.2.0:
+ resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
+
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -5485,6 +5894,11 @@ packages:
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
engines: {node: '>= 0.10'}
+ react-dom@19.0.0:
+ resolution: {integrity: sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==}
+ peerDependencies:
+ react: ^19.0.0
+
react-dom@19.2.5:
resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==}
peerDependencies:
@@ -5526,6 +5940,10 @@ packages:
'@types/react':
optional: true
+ react@19.0.0:
+ resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==}
+ engines: {node: '>=0.10.0'}
+
react@19.2.5:
resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
engines: {node: '>=0.10.0'}
@@ -5585,6 +6003,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
+ requires-port@1.0.0:
+ resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
+
resolve-cwd@3.0.0:
resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==}
engines: {node: '>=8'}
@@ -5677,6 +6098,9 @@ packages:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
+ scheduler@0.25.0:
+ resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==}
+
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -5728,6 +6152,10 @@ packages:
resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==}
engines: {node: '>=8'}
+ sharp@0.33.5:
+ resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -5770,6 +6198,9 @@ packages:
resolution: {integrity: sha512-8G+/XDU8wNsJOQS5ysDVO0Etg9/2uA5gR9l4ZwijjlwxBcrU6RPfwi2+jJmbP+Ap1Hlp/nVAaEO4Fj22/SL2gQ==}
engines: {node: ^16.14.0 || >=18.0.0}
+ simple-swizzle@0.2.4:
+ resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
+
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
@@ -5859,6 +6290,10 @@ packages:
resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==}
engines: {node: '>=18'}
+ streamsearch@1.1.0:
+ resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
+ engines: {node: '>=10.0.0'}
+
strict-event-emitter@0.5.1:
resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==}
@@ -5923,6 +6358,19 @@ packages:
strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
+ styled-jsx@5.1.6:
+ resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
+ engines: {node: '>= 12.0.0'}
+ peerDependencies:
+ '@babel/core': '*'
+ babel-plugin-macros: '*'
+ react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0'
+ peerDependenciesMeta:
+ '@babel/core':
+ optional: true
+ babel-plugin-macros:
+ optional: true
+
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -5948,6 +6396,9 @@ packages:
tailwindcss@4.2.2:
resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==}
+ tailwindcss@4.2.4:
+ resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==}
+
tapable@2.3.2:
resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==}
engines: {node: '>=6'}
@@ -6030,6 +6481,10 @@ packages:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
+ tough-cookie@4.1.4:
+ resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==}
+ engines: {node: '>=6'}
+
tough-cookie@6.0.1:
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
engines: {node: '>=16'}
@@ -6037,6 +6492,10 @@ packages:
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
+ tr46@3.0.0:
+ resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==}
+ engines: {node: '>=12'}
+
tr46@6.0.0:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
@@ -6201,6 +6660,10 @@ packages:
universal-user-agent@6.0.1:
resolution: {integrity: sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==}
+ universalify@0.2.0:
+ resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==}
+ engines: {node: '>= 4.0.0'}
+
universalify@2.0.1:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'}
@@ -6229,6 +6692,9 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+ url-parse@1.5.10:
+ resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==}
+
use-callback-ref@1.3.3:
resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
engines: {node: '>=10'}
@@ -6407,6 +6873,10 @@ packages:
jsdom:
optional: true
+ w3c-xmlserializer@4.0.0:
+ resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==}
+ engines: {node: '>=14'}
+
w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
@@ -6427,6 +6897,10 @@ packages:
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
+ webidl-conversions@7.0.0:
+ resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
+ engines: {node: '>=12'}
+
webidl-conversions@8.0.1:
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
engines: {node: '>=20'}
@@ -6434,11 +6908,20 @@ packages:
webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
+ whatwg-encoding@2.0.0:
+ resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==}
+ engines: {node: '>=12'}
+ deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
+
whatwg-encoding@3.1.1:
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
engines: {node: '>=18'}
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
+ whatwg-mimetype@3.0.0:
+ resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
+ engines: {node: '>=12'}
+
whatwg-mimetype@4.0.0:
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
engines: {node: '>=18'}
@@ -6447,6 +6930,10 @@ packages:
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
engines: {node: '>=20'}
+ whatwg-url@11.0.0:
+ resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==}
+ engines: {node: '>=12'}
+
whatwg-url@16.0.1:
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -6529,6 +7016,10 @@ packages:
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
engines: {node: '>=20'}
+ xml-name-validator@4.0.0:
+ resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
+ engines: {node: '>=12'}
+
xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
@@ -6603,6 +7094,8 @@ snapshots:
'@acemir/cssom@0.9.31': {}
+ '@alloc/quick-lru@5.2.0': {}
+
'@asamuzakjp/css-color@5.1.11':
dependencies:
'@asamuzakjp/generational-cache': 1.0.1
@@ -7136,6 +7629,81 @@ snapshots:
'@hutson/parse-repository-url@3.0.2': {}
+ '@img/sharp-darwin-arm64@0.33.5':
+ optionalDependencies:
+ '@img/sharp-libvips-darwin-arm64': 1.0.4
+ optional: true
+
+ '@img/sharp-darwin-x64@0.33.5':
+ optionalDependencies:
+ '@img/sharp-libvips-darwin-x64': 1.0.4
+ optional: true
+
+ '@img/sharp-libvips-darwin-arm64@1.0.4':
+ optional: true
+
+ '@img/sharp-libvips-darwin-x64@1.0.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-arm64@1.0.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-arm@1.0.5':
+ optional: true
+
+ '@img/sharp-libvips-linux-s390x@1.0.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-x64@1.0.4':
+ optional: true
+
+ '@img/sharp-libvips-linuxmusl-arm64@1.0.4':
+ optional: true
+
+ '@img/sharp-libvips-linuxmusl-x64@1.0.4':
+ optional: true
+
+ '@img/sharp-linux-arm64@0.33.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm64': 1.0.4
+ optional: true
+
+ '@img/sharp-linux-arm@0.33.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm': 1.0.5
+ optional: true
+
+ '@img/sharp-linux-s390x@0.33.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-s390x': 1.0.4
+ optional: true
+
+ '@img/sharp-linux-x64@0.33.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-x64': 1.0.4
+ optional: true
+
+ '@img/sharp-linuxmusl-arm64@0.33.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-arm64': 1.0.4
+ optional: true
+
+ '@img/sharp-linuxmusl-x64@0.33.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-x64': 1.0.4
+ optional: true
+
+ '@img/sharp-wasm32@0.33.5':
+ dependencies:
+ '@emnapi/runtime': 1.9.2
+ optional: true
+
+ '@img/sharp-win32-ia32@0.33.5':
+ optional: true
+
+ '@img/sharp-win32-x64@0.33.5':
+ optional: true
+
'@inquirer/ansi@2.0.5': {}
'@inquirer/confirm@6.0.11(@types/node@22.19.17)':
@@ -7196,7 +7764,7 @@ snapshots:
'@jest/console@29.7.0':
dependencies:
'@jest/types': 29.6.3
- '@types/node': 20.19.35
+ '@types/node': 22.19.17
chalk: 4.1.2
jest-message-util: 29.7.0
jest-util: 29.7.0
@@ -7209,14 +7777,14 @@ snapshots:
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
- '@types/node': 20.19.35
+ '@types/node': 22.19.17
ansi-escapes: 4.3.2
chalk: 4.1.2
ci-info: 3.9.0
exit: 0.1.2
graceful-fs: 4.2.11
jest-changed-files: 29.7.0
- jest-config: 29.7.0(@types/node@20.19.35)(ts-node@10.9.2(@types/node@20.19.35)(typescript@6.0.3))
+ jest-config: 29.7.0(@types/node@22.19.17)(ts-node@10.9.2(@types/node@20.19.35)(typescript@6.0.3))
jest-haste-map: 29.7.0
jest-message-util: 29.7.0
jest-regex-util: 29.6.3
@@ -7241,7 +7809,7 @@ snapshots:
dependencies:
'@jest/fake-timers': 29.7.0
'@jest/types': 29.6.3
- '@types/node': 20.19.35
+ '@types/node': 22.19.17
jest-mock: 29.7.0
'@jest/expect-utils@29.7.0':
@@ -7259,7 +7827,7 @@ snapshots:
dependencies:
'@jest/types': 29.6.3
'@sinonjs/fake-timers': 10.3.0
- '@types/node': 20.19.35
+ '@types/node': 22.19.17
jest-message-util: 29.7.0
jest-mock: 29.7.0
jest-util: 29.7.0
@@ -7281,7 +7849,7 @@ snapshots:
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@jridgewell/trace-mapping': 0.3.31
- '@types/node': 20.19.35
+ '@types/node': 22.19.17
chalk: 4.1.2
collect-v8-coverage: 1.0.3
exit: 0.1.2
@@ -7351,7 +7919,7 @@ snapshots:
'@jest/schemas': 29.6.3
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
- '@types/node': 20.19.35
+ '@types/node': 22.19.17
'@types/yargs': 17.0.35
chalk: 4.1.2
@@ -7492,17 +8060,43 @@ snapshots:
outvariant: 1.4.3
strict-event-emitter: 0.5.1
- '@napi-rs/wasm-runtime@0.2.4':
- dependencies:
- '@emnapi/core': 1.8.1
- '@emnapi/runtime': 1.8.1
- '@tybys/wasm-util': 0.9.0
+ '@napi-rs/wasm-runtime@0.2.4':
+ dependencies:
+ '@emnapi/core': 1.8.1
+ '@emnapi/runtime': 1.8.1
+ '@tybys/wasm-util': 0.9.0
+
+ '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)':
+ dependencies:
+ '@emnapi/core': 1.9.2
+ '@emnapi/runtime': 1.9.2
+ '@tybys/wasm-util': 0.10.1
+ optional: true
+
+ '@next/env@15.0.4': {}
+
+ '@next/swc-darwin-arm64@15.0.4':
+ optional: true
+
+ '@next/swc-darwin-x64@15.0.4':
+ optional: true
+
+ '@next/swc-linux-arm64-gnu@15.0.4':
+ optional: true
+
+ '@next/swc-linux-arm64-musl@15.0.4':
+ optional: true
+
+ '@next/swc-linux-x64-gnu@15.0.4':
+ optional: true
- '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)':
- dependencies:
- '@emnapi/core': 1.9.2
- '@emnapi/runtime': 1.9.2
- '@tybys/wasm-util': 0.10.1
+ '@next/swc-linux-x64-musl@15.0.4':
+ optional: true
+
+ '@next/swc-win32-arm64-msvc@15.0.4':
+ optional: true
+
+ '@next/swc-win32-x64-msvc@15.0.4':
optional: true
'@noble/ciphers@1.3.0': {}
@@ -8752,6 +9346,12 @@ snapshots:
dependencies:
solid-js: 1.9.12
+ '@swc/counter@0.1.3': {}
+
+ '@swc/helpers@0.5.13':
+ dependencies:
+ tslib: 2.8.1
+
'@tailwindcss/node@4.2.2':
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -8762,42 +9362,88 @@ snapshots:
source-map-js: 1.2.1
tailwindcss: 4.2.2
+ '@tailwindcss/node@4.2.4':
+ dependencies:
+ '@jridgewell/remapping': 2.3.5
+ enhanced-resolve: 5.20.1
+ jiti: 2.6.1
+ lightningcss: 1.32.0
+ magic-string: 0.30.21
+ source-map-js: 1.2.1
+ tailwindcss: 4.2.4
+
'@tailwindcss/oxide-android-arm64@4.2.2':
optional: true
+ '@tailwindcss/oxide-android-arm64@4.2.4':
+ optional: true
+
'@tailwindcss/oxide-darwin-arm64@4.2.2':
optional: true
+ '@tailwindcss/oxide-darwin-arm64@4.2.4':
+ optional: true
+
'@tailwindcss/oxide-darwin-x64@4.2.2':
optional: true
+ '@tailwindcss/oxide-darwin-x64@4.2.4':
+ optional: true
+
'@tailwindcss/oxide-freebsd-x64@4.2.2':
optional: true
+ '@tailwindcss/oxide-freebsd-x64@4.2.4':
+ optional: true
+
'@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2':
optional: true
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4':
+ optional: true
+
'@tailwindcss/oxide-linux-arm64-gnu@4.2.2':
optional: true
+ '@tailwindcss/oxide-linux-arm64-gnu@4.2.4':
+ optional: true
+
'@tailwindcss/oxide-linux-arm64-musl@4.2.2':
optional: true
+ '@tailwindcss/oxide-linux-arm64-musl@4.2.4':
+ optional: true
+
'@tailwindcss/oxide-linux-x64-gnu@4.2.2':
optional: true
+ '@tailwindcss/oxide-linux-x64-gnu@4.2.4':
+ optional: true
+
'@tailwindcss/oxide-linux-x64-musl@4.2.2':
optional: true
+ '@tailwindcss/oxide-linux-x64-musl@4.2.4':
+ optional: true
+
'@tailwindcss/oxide-wasm32-wasi@4.2.2':
optional: true
+ '@tailwindcss/oxide-wasm32-wasi@4.2.4':
+ optional: true
+
'@tailwindcss/oxide-win32-arm64-msvc@4.2.2':
optional: true
+ '@tailwindcss/oxide-win32-arm64-msvc@4.2.4':
+ optional: true
+
'@tailwindcss/oxide-win32-x64-msvc@4.2.2':
optional: true
+ '@tailwindcss/oxide-win32-x64-msvc@4.2.4':
+ optional: true
+
'@tailwindcss/oxide@4.2.2':
optionalDependencies:
'@tailwindcss/oxide-android-arm64': 4.2.2
@@ -8813,6 +9459,29 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.2
'@tailwindcss/oxide-win32-x64-msvc': 4.2.2
+ '@tailwindcss/oxide@4.2.4':
+ optionalDependencies:
+ '@tailwindcss/oxide-android-arm64': 4.2.4
+ '@tailwindcss/oxide-darwin-arm64': 4.2.4
+ '@tailwindcss/oxide-darwin-x64': 4.2.4
+ '@tailwindcss/oxide-freebsd-x64': 4.2.4
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.4
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.2.4
+ '@tailwindcss/oxide-linux-arm64-musl': 4.2.4
+ '@tailwindcss/oxide-linux-x64-gnu': 4.2.4
+ '@tailwindcss/oxide-linux-x64-musl': 4.2.4
+ '@tailwindcss/oxide-wasm32-wasi': 4.2.4
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4
+ '@tailwindcss/oxide-win32-x64-msvc': 4.2.4
+
+ '@tailwindcss/postcss@4.2.4':
+ dependencies:
+ '@alloc/quick-lru': 5.2.0
+ '@tailwindcss/node': 4.2.4
+ '@tailwindcss/oxide': 4.2.4
+ postcss: 8.5.10
+ tailwindcss: 4.2.4
+
'@tailwindcss/typography@0.5.19(tailwindcss@4.2.2)':
dependencies:
postcss-selector-parser: 6.0.10
@@ -9154,6 +9823,8 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
+ '@tootallnate/once@2.0.1': {}
+
'@ts-morph/common@0.27.0':
dependencies:
fast-glob: 3.3.3
@@ -9218,7 +9889,7 @@ snapshots:
'@types/graceful-fs@4.1.9':
dependencies:
- '@types/node': 20.19.35
+ '@types/node': 22.19.17
'@types/istanbul-lib-coverage@2.0.6': {}
@@ -9235,6 +9906,12 @@ snapshots:
expect: 29.7.0
pretty-format: 29.7.0
+ '@types/jsdom@20.0.1':
+ dependencies:
+ '@types/node': 22.19.17
+ '@types/tough-cookie': 4.0.5
+ parse5: 7.3.0
+
'@types/json-schema@7.0.15': {}
'@types/minimatch@3.0.5': {}
@@ -9251,10 +9928,18 @@ snapshots:
'@types/normalize-package-data@2.4.4': {}
+ '@types/react-dom@19.0.0':
+ dependencies:
+ '@types/react': 19.2.14
+
'@types/react-dom@19.2.3(@types/react@19.2.14)':
dependencies:
'@types/react': 19.2.14
+ '@types/react@19.0.0':
+ dependencies:
+ csstype: 3.2.3
+
'@types/react@19.2.14':
dependencies:
csstype: 3.2.3
@@ -9267,6 +9952,8 @@ snapshots:
'@types/statuses@2.0.6': {}
+ '@types/tough-cookie@4.0.5': {}
+
'@types/validate-npm-package-name@4.0.2': {}
'@types/yargs-parser@21.0.3': {}
@@ -9430,6 +10117,8 @@ snapshots:
jsonparse: 1.3.1
through: 2.3.8
+ abab@2.0.6: {}
+
abbrev@2.0.0: {}
accepts@2.0.0:
@@ -9437,6 +10126,11 @@ snapshots:
mime-types: 3.0.2
negotiator: 1.0.0
+ acorn-globals@7.0.1:
+ dependencies:
+ acorn: 8.16.0
+ acorn-walk: 8.3.5
+
acorn-jsx@5.3.2(acorn@8.16.0):
dependencies:
acorn: 8.16.0
@@ -9449,6 +10143,12 @@ snapshots:
add-stream@1.0.0: {}
+ agent-base@6.0.2:
+ dependencies:
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
agent-base@7.1.4: {}
aggregate-error@3.1.0:
@@ -9702,6 +10402,10 @@ snapshots:
dependencies:
run-applescript: 7.1.0
+ busboy@1.6.0:
+ dependencies:
+ streamsearch: 1.1.0
+
byte-size@8.1.1: {}
bytes@3.1.2: {}
@@ -9838,6 +10542,8 @@ snapshots:
cli-width@4.1.0: {}
+ client-only@0.0.1: {}
+
cliui@7.0.4:
dependencies:
string-width: 4.2.3
@@ -9874,8 +10580,20 @@ snapshots:
color-name@1.1.4: {}
+ color-string@1.9.1:
+ dependencies:
+ color-name: 1.1.4
+ simple-swizzle: 0.2.4
+ optional: true
+
color-support@1.1.3: {}
+ color@4.2.3:
+ dependencies:
+ color-convert: 2.0.1
+ color-string: 1.9.1
+ optional: true
+
columnify@1.6.0:
dependencies:
strip-ansi: 6.0.1
@@ -10006,12 +10724,6 @@ snapshots:
create-require@1.1.1: {}
- cross-fetch@4.1.0(encoding@0.1.13):
- dependencies:
- node-fetch: 2.7.0(encoding@0.1.13)
- transitivePeerDependencies:
- - encoding
-
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -10035,6 +10747,14 @@ snapshots:
cssesc@3.0.0: {}
+ cssom@0.3.8: {}
+
+ cssom@0.5.0: {}
+
+ cssstyle@2.3.0:
+ dependencies:
+ cssom: 0.3.8
+
cssstyle@6.2.0:
dependencies:
'@asamuzakjp/css-color': 5.1.11
@@ -10048,6 +10768,12 @@ snapshots:
data-uri-to-buffer@4.0.1: {}
+ data-urls@3.0.2:
+ dependencies:
+ abab: 2.0.6
+ whatwg-mimetype: 3.0.0
+ whatwg-url: 11.0.0
+
data-urls@7.0.0(@noble/hashes@1.8.0):
dependencies:
whatwg-mimetype: 5.0.0
@@ -10129,6 +10855,10 @@ snapshots:
domelementtype@2.3.0: {}
+ domexception@4.0.0:
+ dependencies:
+ webidl-conversions: 7.0.0
+
domhandler@5.0.3:
dependencies:
domelementtype: 2.3.0
@@ -10279,6 +11009,14 @@ snapshots:
escape-string-regexp@4.0.0: {}
+ escodegen@2.1.0:
+ dependencies:
+ esprima: 4.0.1
+ estraverse: 5.3.0
+ esutils: 2.0.3
+ optionalDependencies:
+ source-map: 0.6.1
+
eslint-config-prettier@10.1.8(eslint@9.39.3(jiti@2.6.1)):
dependencies:
eslint: 9.39.3(jiti@2.6.1)
@@ -10818,6 +11556,10 @@ snapshots:
dependencies:
lru-cache: 10.4.3
+ html-encoding-sniffer@3.0.0:
+ dependencies:
+ whatwg-encoding: 2.0.0
+
html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0):
dependencies:
'@exodus/bytes': 1.15.0(@noble/hashes@1.8.0)
@@ -10843,6 +11585,14 @@ snapshots:
statuses: 2.0.2
toidentifier: 1.0.1
+ http-proxy-agent@5.0.0:
+ dependencies:
+ '@tootallnate/once': 2.0.1
+ agent-base: 6.0.2
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.4
@@ -10850,6 +11600,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ https-proxy-agent@5.0.1:
+ dependencies:
+ agent-base: 6.0.2
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
@@ -10947,6 +11704,9 @@ snapshots:
is-arrayish@0.2.1: {}
+ is-arrayish@0.3.4:
+ optional: true
+
is-binary-path@2.1.0:
dependencies:
binary-extensions: 2.3.0
@@ -11114,7 +11874,7 @@ snapshots:
'@jest/expect': 29.7.0
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
- '@types/node': 20.19.35
+ '@types/node': 22.19.17
chalk: 4.1.2
co: 4.6.0
dedent: 1.7.1
@@ -11184,6 +11944,37 @@ snapshots:
- babel-plugin-macros
- supports-color
+ jest-config@29.7.0(@types/node@22.19.17)(ts-node@10.9.2(@types/node@20.19.35)(typescript@6.0.3)):
+ dependencies:
+ '@babel/core': 7.29.0
+ '@jest/test-sequencer': 29.7.0
+ '@jest/types': 29.6.3
+ babel-jest: 29.7.0(@babel/core@7.29.0)
+ chalk: 4.1.2
+ ci-info: 3.9.0
+ deepmerge: 4.3.1
+ glob: 7.2.3
+ graceful-fs: 4.2.11
+ jest-circus: 29.7.0
+ jest-environment-node: 29.7.0
+ jest-get-type: 29.6.3
+ jest-regex-util: 29.6.3
+ jest-resolve: 29.7.0
+ jest-runner: 29.7.0
+ jest-util: 29.7.0
+ jest-validate: 29.7.0
+ micromatch: 4.0.8
+ parse-json: 5.2.0
+ pretty-format: 29.7.0
+ slash: 3.0.0
+ strip-json-comments: 3.1.1
+ optionalDependencies:
+ '@types/node': 22.19.17
+ ts-node: 10.9.2(@types/node@20.19.35)(typescript@6.0.3)
+ transitivePeerDependencies:
+ - babel-plugin-macros
+ - supports-color
+
jest-diff@29.7.0:
dependencies:
chalk: 4.1.2
@@ -11203,12 +11994,27 @@ snapshots:
jest-util: 29.7.0
pretty-format: 29.7.0
+ jest-environment-jsdom@29.7.0:
+ dependencies:
+ '@jest/environment': 29.7.0
+ '@jest/fake-timers': 29.7.0
+ '@jest/types': 29.6.3
+ '@types/jsdom': 20.0.1
+ '@types/node': 22.19.17
+ jest-mock: 29.7.0
+ jest-util: 29.7.0
+ jsdom: 20.0.3
+ transitivePeerDependencies:
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+
jest-environment-node@29.7.0:
dependencies:
'@jest/environment': 29.7.0
'@jest/fake-timers': 29.7.0
'@jest/types': 29.6.3
- '@types/node': 20.19.35
+ '@types/node': 22.19.17
jest-mock: 29.7.0
jest-util: 29.7.0
@@ -11218,7 +12024,7 @@ snapshots:
dependencies:
'@jest/types': 29.6.3
'@types/graceful-fs': 4.1.9
- '@types/node': 20.19.35
+ '@types/node': 22.19.17
anymatch: 3.1.3
fb-watchman: 2.0.2
graceful-fs: 4.2.11
@@ -11257,7 +12063,7 @@ snapshots:
jest-mock@29.7.0:
dependencies:
'@jest/types': 29.6.3
- '@types/node': 20.19.35
+ '@types/node': 22.19.17
jest-util: 29.7.0
jest-pnp-resolver@1.2.3(jest-resolve@29.7.0):
@@ -11292,7 +12098,7 @@ snapshots:
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
- '@types/node': 20.19.35
+ '@types/node': 22.19.17
chalk: 4.1.2
emittery: 0.13.1
graceful-fs: 4.2.11
@@ -11320,7 +12126,7 @@ snapshots:
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
- '@types/node': 20.19.35
+ '@types/node': 22.19.17
chalk: 4.1.2
cjs-module-lexer: 1.4.3
collect-v8-coverage: 1.0.3
@@ -11366,7 +12172,7 @@ snapshots:
jest-util@29.7.0:
dependencies:
'@jest/types': 29.6.3
- '@types/node': 20.19.35
+ '@types/node': 22.19.17
chalk: 4.1.2
ci-info: 3.9.0
graceful-fs: 4.2.11
@@ -11385,7 +12191,7 @@ snapshots:
dependencies:
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
- '@types/node': 20.19.35
+ '@types/node': 22.19.17
ansi-escapes: 4.3.2
chalk: 4.1.2
emittery: 0.13.1
@@ -11394,7 +12200,7 @@ snapshots:
jest-worker@29.7.0:
dependencies:
- '@types/node': 20.19.35
+ '@types/node': 22.19.17
jest-util: 29.7.0
merge-stream: 2.0.0
supports-color: 8.1.1
@@ -11432,6 +12238,39 @@ snapshots:
dependencies:
argparse: 2.0.1
+ jsdom@20.0.3:
+ dependencies:
+ abab: 2.0.6
+ acorn: 8.16.0
+ acorn-globals: 7.0.1
+ cssom: 0.5.0
+ cssstyle: 2.3.0
+ data-urls: 3.0.2
+ decimal.js: 10.6.0
+ domexception: 4.0.0
+ escodegen: 2.1.0
+ form-data: 4.0.5
+ html-encoding-sniffer: 3.0.0
+ http-proxy-agent: 5.0.0
+ https-proxy-agent: 5.0.1
+ is-potential-custom-element-name: 1.0.1
+ nwsapi: 2.2.23
+ parse5: 7.3.0
+ saxes: 6.0.0
+ symbol-tree: 3.2.4
+ tough-cookie: 4.1.4
+ w3c-xmlserializer: 4.0.0
+ webidl-conversions: 7.0.0
+ whatwg-encoding: 2.0.0
+ whatwg-mimetype: 3.0.0
+ whatwg-url: 11.0.0
+ ws: 8.20.0
+ xml-name-validator: 4.0.0
+ transitivePeerDependencies:
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+
jsdom@28.1.0(@noble/hashes@1.8.0):
dependencies:
'@acemir/cssom': 0.9.31
@@ -11976,15 +12815,34 @@ snapshots:
neo-async@2.6.2: {}
- node-domexception@1.0.0: {}
-
- node-fetch@2.6.7(encoding@0.1.13):
+ next@15.0.4(@babel/core@7.29.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
- whatwg-url: 5.0.0
+ '@next/env': 15.0.4
+ '@swc/counter': 0.1.3
+ '@swc/helpers': 0.5.13
+ busboy: 1.6.0
+ caniuse-lite: 1.0.30001774
+ postcss: 8.4.31
+ react: 19.0.0
+ react-dom: 19.0.0(react@19.0.0)
+ styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.0.0)
optionalDependencies:
- encoding: 0.1.13
+ '@next/swc-darwin-arm64': 15.0.4
+ '@next/swc-darwin-x64': 15.0.4
+ '@next/swc-linux-arm64-gnu': 15.0.4
+ '@next/swc-linux-arm64-musl': 15.0.4
+ '@next/swc-linux-x64-gnu': 15.0.4
+ '@next/swc-linux-x64-musl': 15.0.4
+ '@next/swc-win32-arm64-msvc': 15.0.4
+ '@next/swc-win32-x64-msvc': 15.0.4
+ sharp: 0.33.5
+ transitivePeerDependencies:
+ - '@babel/core'
+ - babel-plugin-macros
- node-fetch@2.7.0(encoding@0.1.13):
+ node-domexception@1.0.0: {}
+
+ node-fetch@2.6.7(encoding@0.1.13):
dependencies:
whatwg-url: 5.0.0
optionalDependencies:
@@ -12097,6 +12955,8 @@ snapshots:
dependencies:
boolbase: 1.0.0
+ nwsapi@2.2.23: {}
+
nx@20.8.4:
dependencies:
'@napi-rs/wasm-runtime': 0.2.4
@@ -12434,6 +13294,12 @@ snapshots:
cssesc: 3.0.0
util-deprecate: 1.0.2
+ postcss@8.4.31:
+ dependencies:
+ nanoid: 3.3.11
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
postcss@8.5.10:
dependencies:
nanoid: 3.3.11
@@ -12497,6 +13363,10 @@ snapshots:
proxy-from-env@1.1.0: {}
+ psl@1.15.0:
+ dependencies:
+ punycode: 2.3.1
+
punycode@2.3.1: {}
pure-rand@6.1.0: {}
@@ -12505,6 +13375,8 @@ snapshots:
dependencies:
side-channel: 1.1.0
+ querystringify@2.2.0: {}
+
queue-microtask@1.2.3: {}
quick-lru@4.0.1: {}
@@ -12581,6 +13453,11 @@ snapshots:
iconv-lite: 0.7.2
unpipe: 1.0.0
+ react-dom@19.0.0(react@19.0.0):
+ dependencies:
+ react: 19.0.0
+ scheduler: 0.25.0
+
react-dom@19.2.5(react@19.2.5):
dependencies:
react: 19.2.5
@@ -12617,6 +13494,8 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
+ react@19.0.0: {}
+
react@19.2.5: {}
read-cmd-shim@4.0.0: {}
@@ -12691,6 +13570,8 @@ snapshots:
require-from-string@2.0.2: {}
+ requires-port@1.0.0: {}
+
resolve-cwd@3.0.0:
dependencies:
resolve-from: 5.0.0
@@ -12815,6 +13696,8 @@ snapshots:
dependencies:
xmlchars: 2.2.0
+ scheduler@0.25.0: {}
+
scheduler@0.27.0: {}
semver@5.7.2: {}
@@ -12907,6 +13790,33 @@ snapshots:
dependencies:
kind-of: 6.0.3
+ sharp@0.33.5:
+ dependencies:
+ color: 4.2.3
+ detect-libc: 2.1.2
+ semver: 7.7.4
+ optionalDependencies:
+ '@img/sharp-darwin-arm64': 0.33.5
+ '@img/sharp-darwin-x64': 0.33.5
+ '@img/sharp-libvips-darwin-arm64': 1.0.4
+ '@img/sharp-libvips-darwin-x64': 1.0.4
+ '@img/sharp-libvips-linux-arm': 1.0.5
+ '@img/sharp-libvips-linux-arm64': 1.0.4
+ '@img/sharp-libvips-linux-s390x': 1.0.4
+ '@img/sharp-libvips-linux-x64': 1.0.4
+ '@img/sharp-libvips-linuxmusl-arm64': 1.0.4
+ '@img/sharp-libvips-linuxmusl-x64': 1.0.4
+ '@img/sharp-linux-arm': 0.33.5
+ '@img/sharp-linux-arm64': 0.33.5
+ '@img/sharp-linux-s390x': 0.33.5
+ '@img/sharp-linux-x64': 0.33.5
+ '@img/sharp-linuxmusl-arm64': 0.33.5
+ '@img/sharp-linuxmusl-x64': 0.33.5
+ '@img/sharp-wasm32': 0.33.5
+ '@img/sharp-win32-ia32': 0.33.5
+ '@img/sharp-win32-x64': 0.33.5
+ optional: true
+
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -12960,6 +13870,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ simple-swizzle@0.2.4:
+ dependencies:
+ is-arrayish: 0.3.4
+ optional: true
+
sisteransi@1.0.5: {}
slash@3.0.0: {}
@@ -13042,6 +13957,8 @@ snapshots:
stdin-discarder@0.2.2: {}
+ streamsearch@1.1.0: {}
+
strict-event-emitter@0.5.1: {}
string-length@4.0.2:
@@ -13107,6 +14024,13 @@ snapshots:
dependencies:
js-tokens: 9.0.1
+ styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.0.0):
+ dependencies:
+ client-only: 0.0.1
+ react: 19.0.0
+ optionalDependencies:
+ '@babel/core': 7.29.0
+
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
@@ -13125,6 +14049,8 @@ snapshots:
tailwindcss@4.2.2: {}
+ tailwindcss@4.2.4: {}
+
tapable@2.3.2: {}
tar-stream@2.2.0:
@@ -13199,12 +14125,23 @@ snapshots:
toidentifier@1.0.1: {}
+ tough-cookie@4.1.4:
+ dependencies:
+ psl: 1.15.0
+ punycode: 2.3.1
+ universalify: 0.2.0
+ url-parse: 1.5.10
+
tough-cookie@6.0.1:
dependencies:
tldts: 7.0.28
tr46@0.0.3: {}
+ tr46@3.0.0:
+ dependencies:
+ punycode: 2.3.1
+
tr46@6.0.0:
dependencies:
punycode: 2.3.1
@@ -13340,6 +14277,8 @@ snapshots:
universal-user-agent@6.0.1: {}
+ universalify@0.2.0: {}
+
universalify@2.0.1: {}
unpipe@1.0.0: {}
@@ -13365,6 +14304,11 @@ snapshots:
dependencies:
punycode: 2.3.1
+ url-parse@1.5.10:
+ dependencies:
+ querystringify: 2.2.0
+ requires-port: 1.0.0
+
use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.5):
dependencies:
react: 19.2.5
@@ -13505,6 +14449,10 @@ snapshots:
- tsx
- yaml
+ w3c-xmlserializer@4.0.0:
+ dependencies:
+ xml-name-validator: 4.0.0
+
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0
@@ -13523,18 +14471,31 @@ snapshots:
webidl-conversions@3.0.1: {}
+ webidl-conversions@7.0.0: {}
+
webidl-conversions@8.0.1: {}
webpack-virtual-modules@0.6.2: {}
+ whatwg-encoding@2.0.0:
+ dependencies:
+ iconv-lite: 0.6.3
+
whatwg-encoding@3.1.1:
dependencies:
iconv-lite: 0.6.3
+ whatwg-mimetype@3.0.0: {}
+
whatwg-mimetype@4.0.0: {}
whatwg-mimetype@5.0.0: {}
+ whatwg-url@11.0.0:
+ dependencies:
+ tr46: 3.0.0
+ webidl-conversions: 7.0.0
+
whatwg-url@16.0.1(@noble/hashes@1.8.0):
dependencies:
'@exodus/bytes': 1.15.0(@noble/hashes@1.8.0)
@@ -13627,6 +14588,8 @@ snapshots:
is-wsl: 3.1.1
powershell-utils: 0.1.0
+ xml-name-validator@4.0.0: {}
+
xml-name-validator@5.0.0: {}
xmlbuilder2@4.0.3:
diff --git a/tools/test/README.md b/tools/test/README.md
new file mode 100644
index 0000000..58871fe
--- /dev/null
+++ b/tools/test/README.md
@@ -0,0 +1,19 @@
+# Shared test helpers
+
+Repo-internal helpers for unit tests. Imported via the `@test/*` tsconfig path
+alias (see each package's `__tests__/tsconfig.json` and `jest.config.js`).
+Not a workspace package, not published.
+
+## Helpers
+
+- `makeFakeModel(overrides?)` — `ModelDescriptor` with sane defaults.
+- `makeFakeAssistantMessage(overrides?)` — `AssistantMessage` with zero usage and stop reason.
+- `createScriptedProvider({ responses })` — `ProviderAdapter` that emits a derived event sequence per `AssistantMessage` in `responses` on successive `stream()` calls. `stopReason` of `error` or `aborted` produces an `error` terminal event; otherwise `done`.
+- `createScriptedSSEResponse(events)` — `Response` whose body serializes each `AgentEvent` as one SSE frame (`data: \n\n`).
+- `parseSSEStream(stream)` — async iterable that parses `AgentEvent` SSE frames from a `ReadableStream`. Handles split chunks, multi-line `data:`, comment lines, event-type framing, trailing newlines, and mid-event abort (incomplete trailing event is dropped, per SSE spec).
+
+## Adding a helper
+
+Promote a helper to `tools/test/` only when a third package needs the same
+idiom. Duplication of a 30-line scripted helper across two packages is fine;
+duplicating across three is the trigger for promotion.
diff --git a/tools/test/fixtures.ts b/tools/test/fixtures.ts
new file mode 100644
index 0000000..003fa2b
--- /dev/null
+++ b/tools/test/fixtures.ts
@@ -0,0 +1,40 @@
+import type { AssistantMessage, ModelDescriptor, Usage } from 'agentic-kit';
+
+const ZERO_USAGE: Usage = {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 0,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+};
+
+export function makeFakeModel(overrides: Partial = {}): ModelDescriptor {
+ return {
+ id: 'demo',
+ name: 'Demo',
+ api: 'fake-api',
+ provider: 'fake',
+ baseUrl: 'http://fake.local',
+ input: ['text'],
+ reasoning: false,
+ tools: true,
+ ...overrides,
+ };
+}
+
+export function makeFakeAssistantMessage(
+ overrides: Partial = {}
+): AssistantMessage {
+ return {
+ role: 'assistant',
+ api: 'fake-api',
+ provider: 'fake',
+ model: 'demo',
+ usage: { ...ZERO_USAGE, cost: { ...ZERO_USAGE.cost } },
+ stopReason: 'stop',
+ timestamp: Date.now(),
+ content: [{ type: 'text', text: '' }],
+ ...overrides,
+ };
+}
diff --git a/tools/test/index.ts b/tools/test/index.ts
new file mode 100644
index 0000000..a798b59
--- /dev/null
+++ b/tools/test/index.ts
@@ -0,0 +1,3 @@
+export { makeFakeAssistantMessage, makeFakeModel } from './fixtures';
+export { createScriptedProvider, type ScriptedProviderOptions } from './scripted-provider';
+export { createScriptedSSEResponse } from './scripted-sse';
diff --git a/tools/test/scripted-provider.ts b/tools/test/scripted-provider.ts
new file mode 100644
index 0000000..da43921
--- /dev/null
+++ b/tools/test/scripted-provider.ts
@@ -0,0 +1,115 @@
+import {
+ type AssistantMessage,
+ type AssistantMessageEvent,
+ createAssistantMessageEventStream,
+ type ModelDescriptor,
+ type ProviderAdapter,
+} from 'agentic-kit';
+
+import { makeFakeAssistantMessage, makeFakeModel } from './fixtures';
+
+export interface ScriptedProviderOptions {
+ responses: AssistantMessage[];
+ delayMs?: number;
+ api?: string;
+ provider?: string;
+}
+
+export function createScriptedProvider(opts: ScriptedProviderOptions): ProviderAdapter {
+ const api = opts.api ?? 'fake-api';
+ const provider = opts.provider ?? 'fake';
+ let callIndex = 0;
+
+ return {
+ api,
+ provider,
+ createModel: (modelId: string, overrides?: Partial) =>
+ makeFakeModel({ id: modelId, api, provider, ...overrides }),
+ stream: () => {
+ const stream = createAssistantMessageEventStream();
+ const message =
+ opts.responses[callIndex++] ??
+ makeFakeAssistantMessage({
+ api,
+ provider,
+ stopReason: 'error',
+ errorMessage: 'scripted provider: no response queued for this call',
+ content: [],
+ });
+
+ const events = deriveEventSequence(message);
+ const emit = () => {
+ for (const event of events) {
+ stream.push(event);
+ }
+ stream.end(message);
+ };
+
+ if (opts.delayMs && opts.delayMs > 0) {
+ setTimeout(emit, opts.delayMs);
+ } else {
+ queueMicrotask(emit);
+ }
+
+ return stream;
+ },
+ };
+}
+
+function deriveEventSequence(message: AssistantMessage): AssistantMessageEvent[] {
+ const events: AssistantMessageEvent[] = [];
+ events.push({ type: 'start', partial: message });
+
+ for (let i = 0; i < message.content.length; i++) {
+ const block = message.content[i];
+ if (block.type === 'text') {
+ events.push({ type: 'text_start', contentIndex: i, partial: message });
+ if (block.text.length > 0) {
+ events.push({
+ type: 'text_delta',
+ contentIndex: i,
+ delta: block.text,
+ partial: message,
+ });
+ }
+ events.push({
+ type: 'text_end',
+ contentIndex: i,
+ content: block.text,
+ partial: message,
+ });
+ } else if (block.type === 'thinking') {
+ events.push({ type: 'thinking_start', contentIndex: i, partial: message });
+ if (block.thinking.length > 0) {
+ events.push({
+ type: 'thinking_delta',
+ contentIndex: i,
+ delta: block.thinking,
+ partial: message,
+ });
+ }
+ events.push({
+ type: 'thinking_end',
+ contentIndex: i,
+ content: block.thinking,
+ partial: message,
+ });
+ } else if (block.type === 'toolCall') {
+ events.push({ type: 'toolcall_start', contentIndex: i, partial: message });
+ events.push({
+ type: 'toolcall_end',
+ contentIndex: i,
+ toolCall: block,
+ partial: message,
+ });
+ }
+ }
+
+ if (message.stopReason === 'error' || message.stopReason === 'aborted') {
+ events.push({ type: 'error', reason: message.stopReason, error: message });
+ } else {
+ events.push({ type: 'done', reason: message.stopReason, message });
+ }
+
+ return events;
+}
diff --git a/tools/test/scripted-sse.ts b/tools/test/scripted-sse.ts
new file mode 100644
index 0000000..6267084
--- /dev/null
+++ b/tools/test/scripted-sse.ts
@@ -0,0 +1,22 @@
+import type { AgentEvent } from '@agentic-kit/agent';
+
+export function createScriptedSSEResponse(events: AgentEvent[]): Response {
+ const encoder = new TextEncoder();
+ const body = new ReadableStream({
+ start(controller) {
+ for (const event of events) {
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
+ }
+ controller.close();
+ },
+ });
+
+ return new Response(body, {
+ status: 200,
+ headers: {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ Connection: 'keep-alive',
+ },
+ });
+}