# @optima-chat/agentic-sdk

React SDK for the Optima agentic chat gateway. Provides a `<ChatProvider>`, a set of hooks for reading/writing chat state, a Zustand store, a WebSocket client, and a workspace-file integration. Pluggable auth (works with [`@optima-chat/agentic-auth`](../auth) or any custom `tokenProvider`).

## Install

```bash
pnpm add @optima-chat/agentic-sdk
```

Peer dependencies: `react@^19`, `zustand@^5`.

## Usage

### 1. Wrap your app in `ChatProvider`

```tsx
import { ChatProvider, createDefaultWorkspaceProvider } from '@optima-chat/agentic-sdk';

<ChatProvider
  gatewayUrl="wss://gateway.optima.chat/ws"
  tokenProvider={async () => currentAccessToken}
  authenticatedFetch={fetchWithAuth}                    // optional
  workspaceProvider={createDefaultWorkspaceProvider(    // optional
    'https://gateway.optima.chat',
    fetchWithAuth,
  )}
  onFinish={({ conversationId, finish }) => { /* … */ }}
  onToolCall={({ toolName, args }) => { /* … */ }}
  onError={(error) => { /* … */ }}
  onNotification={(event) => { /* … */ }}
  onRawEvent={(event) => { /* intercept gateway events before store mutation */ }}
>
  {children}
</ChatProvider>
```

### 2. Consume via hooks

All hooks must be called inside a `<ChatProvider>` descendant.

| Hook | Returns |
|---|---|
| `useConnectionState()` | `{ connectionState, error, reconnectAttempt, reconnect }` |
| `useSession()` | `{ sessionId, userId, provider, model }` |
| `useConversations()` | `{ conversations, currentConversationId, createConversation, deleteConversation, renameConversation, switchConversation, isLoading, error }` |
| `useCurrentChat()` | `{ messages, isStreaming, isThinking, error, sendMessage, abort, resetConversation, loadHistory, hasMoreHistory, isLoadingHistory }` |
| `useQuestion()` | `{ pendingQuestion, answerQuestion, dismissQuestion }` — `pendingQuestion` is scoped to the current conversation (BREAKING in 0.26.0; was a global slot) |
| `usePendingQuestions()` | `QuestionRequest[]` — pending questions across all conversations (for cross-conversation badges); consumers filter the legacy `''` conversationId entry |
| `useApproval()` | `{ pendingApprovals, approve, reject, modify }` |
| `useSwitchConfig()` | `{ switchConfig }` |
| `useProcessingConversations()` | `string[]` — conversation ids with active streams (for sidebar spinners) |

### Example: send a message, stream response

```tsx
import { useConversations, useCurrentChat } from '@optima-chat/agentic-sdk';

function ChatInput() {
  const { currentConversationId, createConversation, switchConversation } = useConversations();
  const { sendMessage, isStreaming } = useCurrentChat();

  async function send(text: string) {
    if (!currentConversationId) {
      const conv = await createConversation();
      switchConversation(conv.id);
    }
    // 返回值 = 这条消息的 clientMsgId，也就是 gw#1587 `message_receipt` 回执对应的
    // 那个 id（边界见 sendMessage 的 JSDoc）。不需要就直接忽略（如这里）。
    await sendMessage(text);
  }

  return /* … */;
}
```

## ChatProvider props — callback summary

- **`onFinish`** — assistant turn completed. Receives `{ conversationId, finish: FinishInfo }`.
- **`onToolCall`** — tool invocation started. Receives `{ toolName, args, toolCallId }`. Useful for opening side panels when specific tools run.
- **`onError`** — mapped chat error with `{ code, message, details }`. For billing-category errors, `error.code` is the `BillingError.reason` and `error.details` is the full `BillingError`.
- **`onNotification`** — in-band notifications (`{ type: 'error' | 'warning' | 'info', message }`), typically rendered as toasts.
- **`onRawEvent`** — raw gateway event before store mutation. Use this to extract out-of-band data (progress events, custom info events, tool-result payloads) into your own store.

## Impersonation / reconnect

`<ChatProvider>` reads `tokenProvider` on every WebSocket connect. To switch users (e.g., admin impersonation) or force a reconnect with different credentials, **remount the provider** with a `key`:

```tsx
<ChatProvider key={impersonationId ?? 'normal'} tokenProvider={…} … />
```

## Multi-session（gw#2313：单用户多 session 寻址）

Gateway 支持单用户开 N 个独立 session（每 session 一个独立 agent 容器，与 COO 正交）。
客户端寻址三意图（gateway gw#905）：

| 意图 | 客户端做法 |
|---|---|
| 新建 session | `client.startNewSession()`——通用原语，`?new=1` 在 `session_ready` 兑现；101 后被拒会带着 `new=1` 自动重连（连续 3 次后降级为 attach/默认） |
| attach 指定 session | 配 `sessionAttachProvider: () => sessionStorage.getItem('gw-session-id')`——每次 open 拼 `?sessionId=`，F5/重连回到同一 session |
| 默认复用 | 两者都不配/返回 null（行为不变；URL 经 `new URL()` 规范化，边界见 changeset） |

多 tab 配方：**每个新 tab 先调 `startNewSession()`**（否则默认复用腿会让所有 tab 汇聚到
同一个 session）；`session_ready` 时把 sessionId 写进 **`sessionStorage`**（天然 per-tab；
localStorage 会让多 tab 撞同一 session）并由 `sessionAttachProvider` 返回——F5/断线重连
回到同一 session。`startNewSession()` 的 new 意图优先于 attach（含 `gatewayUrl` 自带的
sessionId），且在收到 **`session_ready`** 之后才消费 —— 注意**不是** `onopen`：gateway 的
`/ws` 走 `@fastify/websocket`，socket 先完成 101 升级（`onopen` 触发），
`CONCURRENCY_LIMIT_EXCEEDED` / `AUTH_TOKEN_INVALID` 都是通过**已打开的 socket** 发来的。
所以 101 之后被拒的这些情形，自动重连仍保持 new 意图，不会静默退回旧 session。
但这有上限：连续 3 次带 `new=1` 的 open「已建立连接却没等到 `session_ready`」后，new 意图
被丢弃、下一次 open 按 attach/默认解析（否则持续撞并发上限会**在客户端**无限重连）。
⚠️ 这个上限封的是**客户端重试次数，不是服务端成本**：被并发上限拒掉的 open 在服务端
**零 session** —— gateway 的 `SessionManager.createSession` 先 `concurrencyGate.acquire()`，
拒绝时当场 throw、`createSessionInner()` 从不执行，所以孤儿 session 恒为 0、不占并发预算。
之所以仍要封口，是因为 `onopen` 每次 101 都会把 `reconnectAttempt` 清零，`maxRetries`
永远不触发，客户端会一直转下去。
provider 抛异常（sessionStorage 被禁）或返回非字符串（JSON.parse 出的数字/对象）都按
无 attach 处理。越权/已死的 sessionId 由 gateway 落回默认解析，attach 不到别人的 session。

连续 3 次「已建立连接却始终没等到 `session_ready`」且带 attach（典型是 owner-proxy 把
socket 以 1013 关掉）→ 下一次 open 丢掉 attach 按默认解析（逃生口），**包括 `gatewayUrl`
里自带的那个 `sessionId`**（它同样计入这 3 次）。计数按 id 归键：provider 换了一个新 id
就从零计。纯网络失败（连都没连上）不计入，否则一次 wifi 抖动就会把新 tab 甩进别的 tab
的 session。`disconnect()` 清掉所有寻址意图与计数（登出再登入不带旧意图）。

**跨 session 的待发消息**：只有你**主动要求换 session** 时（`startNewSession()`，或
`sessionAttachProvider` 返回了与当前不同的 id），旧 session 的未确认帧才会被终结并丢弃
（宿主按条拿到 `failed` 终态）—— 且只丢**换 session 请求之前**入队的：`startNewSession()`
在 `onopen` 就 resolve（先于 `session_ready`），之后用户发的消息是给新 session 的，照常投递。
没配 attach 的宿主只是断线重连、gateway 给了新 session id
时，这些帧照常在下次重投 —— conversation 是 user 域的，在新 session 里仍可恢复。

未升级 SDK 的临时通路：`gatewayUrl` 直接写成 `wss://…/ws?sessionId=ses_xxx`（SDK 保留
调用方 query；`startNewSession()` 会对那一次 open 移除它以保 new 语义）。撞并发上限时
gateway 抛 `CONCURRENCY_LIMIT_EXCEEDED`（429 语义），上限只读端点
`GET /api/sessions/concurrency` 返 `{active, limit}`。
⚠️ `limit` 可能是 `null` —— 那表示 gateway 侧**查不到权益、按 fail-open 放行**
（`routes/sessions.ts` 的 `limit: null` 分支），**不是**「无限制」也不是「限制为 0」。
宿主拿 `limit` 做 UI 提示时必须处理这个值，别拿它去算剩余额度。

## Workspace integration

`workspaceProvider` is optional. If you need file attachments on messages or skill-backed file access, wire up `createDefaultWorkspaceProvider(gatewayHttpBase, authenticatedFetch)` or implement the `WorkspaceProvider` interface yourself.

## Type exports

```ts
import type {
  ChatProviderProps,
  ConnectionState, MessageStatus, ToolCallStatus,
  ChatError, Conversation, Message, MessageAttachment, ToolCall,
  QuestionRequest, ApprovalRequestWithId,
  SendMessageOptions, UploadResult,
  WorkspaceProvider,
  // re-exports from @optima-chat/gateway-protocol
  ServerEvent, ClientEvent, FinishInfo, Question, ApprovalRequest,
  ApprovalResponse, TokenProgress, BillingError,
} from '@optima-chat/agentic-sdk';
```

## Relation to `agentic-auth`

`agentic-sdk` does **not** depend on `agentic-auth`. You can pair them for the standard OAuth 2.0 / email-OTP flow, or provide any other `tokenProvider: () => Promise<string>` implementation.

## Development

```bash
pnpm --filter @optima-chat/agentic-sdk test
pnpm --filter @optima-chat/agentic-sdk typecheck
pnpm --filter @optima-chat/agentic-sdk build
```

Tests run under vitest with jsdom.
