import type { ServerEvent, ClientEvent } from '@optima-chat/gateway-protocol'; import type { ChatError, ConnectionState, TokenProviderOpts } from '../types.js'; import { type DeliveryState, type FailedCause } from './delivery-outbox.js'; export interface GatewayClientOptions { gatewayUrl: string; tokenProvider: (opts?: TokenProviderOpts) => Promise | string; onEvent: (event: ServerEvent) => void; onConnectionStateChange: (state: ConnectionState) => void; onError: (error: ChatError) => void; onAuthError: (error: ChatError) => void; onBeforeSend?: (event: ClientEvent) => ClientEvent | null; /** * Called whenever the reconnect attempt counter changes — on each scheduled * reconnect (incremented) and on successful reconnect (reset to 0). */ onReconnectAttempt?: (attempt: number) => void; /** * Returns the current userId for inclusion in update_token events. Invoked * every time token refresh runs so the returned value reflects the latest * store state. An empty string is sent if this is omitted or returns falsy. * * Also used to stamp `enduser.id` on OpenTelemetry client spans when an OTel * SDK is installed by the host (no-op otherwise). */ userIdProvider?: () => string | null | undefined; /** * Returns the current gateway sessionId. Used only to stamp `session.id` on * OpenTelemetry client spans (no-op when the host hasn't installed an OTel * SDK). Optional — omit it in headless/test contexts that don't trace. */ sessionIdProvider?: () => string | null | undefined; /** * gw#2313 S1 — multi-session attach addressing. When this returns a session * id, every socket open appends `?sessionId=` so the gateway ATTACHES to * that exact session (gateway three-intent leg, gw#905: attach / `?new=1` * create / default reuse). Return null/undefined for the default-reuse leg * (unchanged behaviour for existing hosts — this is why it is a separate * option from `sessionIdProvider`, which only feeds OTel and must not change * reconnect semantics). Non-string returns are treated as absent. * * Ownership mismatch or a dead session makes the gateway fall through to * default resolution — the client cannot attach to someone else's session. * * Multi-tab recipe (gw#2313 decision, 06-25): store the id in **sessionStorage** * (per-tab in the common case; localStorage would make tabs collide on one * session), write it on `session_ready`, and return it here so F5/reconnect * lands on the same session. Two caveats (#147 r1): "duplicate tab" and * same-origin `target=_blank` COPY sessionStorage — a copied tab attaches to * its parent's session unless the host detects the copy and calls * `startNewSession()`; and the storage key should be namespaced per * principal/environment (impersonation or personal↔enterprise switches leave * a stale id that the gateway will reject with a warn on every open). * `startNewSession()` takes precedence for its one open: an explicit "give * me a fresh session" must not be silently overridden by a stored attach id * (the caller should clear/overwrite its stored id when the new * `session_ready` arrives). Blank/whitespace ids are treated as absent; * 3 consecutive attach opens that upgraded but never reached `session_ready` * drop the id (also a `?sessionId=` baked into `gatewayUrl`) until the next * `session_ready` (escape hatch from owner-proxy pre-route dead loops); the * count is keyed by id, so a new id starts from zero. */ sessionAttachProvider?: () => string | null | undefined; reconnectOptions?: { maxRetries?: number; baseDelay?: number; maxDelay?: number; }; heartbeatInterval?: number; /** * gw#1587 投递保证:消息级 deliveryState 变化(pending/delivered/failed)。 * 消费方(provider→chat-store)只驱动消息级指示——绝不写 conv 级 processing/isThinking * (spec §5.4 分层)。cause 仅 failed 时有值。 */ onDeliveryStateChange?: (clientMsgId: string, state: DeliveryState, cause?: FailedCause) => void; /** * gw#1587 §5.1:一条消息真正投出(drain dispatch)时回调——host 在此置 thinking * (入队不置、投出才置;gap 入队后 failed 的消息从不 dispatch,不 wedge composer)。 */ onMessageDispatched?: (clientMsgId: string, conversationId: string) => void; } export declare class GatewayClient { private readonly options; private ws; private heartbeatTimer; private reconnectTimer; private reconnectAttempt; private intentionalDisconnect; /** * True while openSocket() is establishing a connection but has not yet * assigned this.ws — i.e. it is parked at `await tokenProvider()`. In that * window this.ws is still null, so handleTokenRefresh() would otherwise read * "socket down" and open a SECOND socket. Two concurrent sockets deliver every * event twice and interleave, hanging the stream (agentic-sdk#93 — reproduced * deterministically by LOCAL_AUTH_BYPASS's mount-time setTokens()). Read by * handleTokenRefresh() to suppress that redundant connect. Cleared the instant * the token fetch settles — success (the synchronous socket-construction tail * that follows has no await, so the `this.ws === null` guard takes over with no * reentrancy gap) or failure. Never held across `new WebSocket()`, so a * constructor throw can't wedge it true and disable reconnect. */ private connecting; /** * When set, socket opens append `new=1` so the gateway takes the * `createSession` path (COO toggle ENTER/EXIT #467/#1076; gw#2313 "give me a * fresh session"). Set via startNewSession(). * Consumed on the next **`session_ready`**, NOT when the URL is built and NOT * on `onopen` (#147 r2 finding 1: `onopen` is only the HTTP 101 upgrade; the * gateway rejects — `CONCURRENCY_LIMIT_EXCEEDED`, `AUTH_TOKEN_INVALID` — over * the already-open socket). So it **deliberately survives failed opens and * flows into auto-reconnect**: that leak IS the fix — do not "tidy" it back * to consume-on-build, that re-introduces the silent fall-back to the session * the caller just asked to leave. Bounded by FORCE_NEW_FAIL_ESCAPE_N (#147 r3 * finding 2): after N opens that upgraded but never reached `session_ready` * the intent is dropped and the next open resolves by attach/default. */ private forceNewNextConnect; /** r1(#147 🟠4):上一次 session_ready 报的 sessionId——跨 session 闸的判据。 */ private lastReadySessionId; /** * r1(#147 🟡):带 attach 的 open 连续失败计数——owner-proxy 预路由拒连时(1013 循环), * 每次重连都带同一个 sessionId 会撞同一判定直到重试耗尽。连续 ≥3 次失败后,下一次 * open 丢掉 attach id 按默认解析(gateway 本地可服务),成功后清零恢复 attach。 * r3 🟡:按 id 归键——宿主换了**新的有效 id** 时计数归零,不会因为旧 id 的失败被丢一轮。 * attach id 来源两种:provider 供的,或 README 临时通路烤在 `gatewayUrl` 里的 `?sessionId=` * (r3 finding 3:后者也必须计 streak + 可删,否则 owner-proxy 1013 循环对它无界)。 */ private attachFailStreak; /** 本次 open 是否带了 attach id(失败时归因给 streak 用)。 */ private lastOpenHadAttach; /** #147 r3 finding 2:带 new=1 的 open 连续「已 open 但没 ready」计数(见 FORCE_NEW_FAIL_ESCAPE_N)。 */ private forceNewFailStreak; /** * #147 r3 finding 4:startNewSession() 那一刻的 outbox switch 纪元——new 意图活过失败 open * 期间入队的帧都属于新 session,跨 session 闸只丢这个纪元之前的。随意图一起在 session_ready 清空。 */ private forceNewEpoch; /** 同上,attach 腿:宿主要求 attach 到与当前不同的 id 时的纪元(同 id 重试沿用)。 */ private attachSwitch; /** * 🔴 #147 r2 finding 1/2/3 共用的一块状态:**本次 open 的寻址意图**。 * 消费点必须是 `session_ready` 而不是 `onopen` —— `onopen` 只是 HTTP 101 升级, * gateway 的 `/ws` 是 `@fastify/websocket`,`handleWebSocket` 在 101 **之后**才跑, * `CONCURRENCY_LIMIT_EXCEEDED` / `AUTH_TOKEN_INVALID` 都是通过**已打开的 socket** * 发来的。在 onopen 消费 = 点「新建」→ 101 → 意图蒸发 → 上限错误关闭 → 重连带 * `sessionId=ses_old` → 静默回到刚要离开的那个 session。 */ private pendingOpenIntent; /** 当前 socket 的「见过 session_ready」回写钩子(随连接作用域创建/清空)。 */ private readyFlagForCurrentSocket; /** * `sub` of the token the CURRENT socket was opened with — the principal this * connection is bound to, since the gateway resolves the session from the * connect-URL token (gateway-core connection-handler → `userInfo.id`). * Written on every openSocket token fetch; read by handleTokenRefresh to tell * a same-identity renewal (→ push `update_token`) apart from a principal * switch (→ must reconnect, #128). null = unknown (non-JWT / unparseable), * which is deliberately never read as "different". */ private connectedSub; /** * FIFO queue of pending switch_config acks. The gateway answers a * `switch_config` with an `info` event carrying `code:'config_switched'` * (success) or `code:'config_switch_failed'` (persist failure) — these have * no correlation id (#1076 N3), so waiters are matched positionally in send * order. Rejected if the socket closes before the ack arrives. */ private configSwitchWaiters; /** * Resolver of the most recent openSocket() completion promise. The disown * block calls it when superseding a socket: detaching the old socket's * handlers also strands the resolve captured inside them, so an awaiter of * the losing openSocket() — `await reconnect()` on the COO-toggle / * startNewSession path — would hang forever (#102). Calling a resolver * whose promise already settled is a no-op, so a single field (no map) * covers every generation. */ private pendingOpenResolve; private readonly reconnectConfig; private readonly heartbeatInterval; /** * gw#1587 armed 三态(per-connection):connected 边沿置 undecided;本连接 session_ready * 处理完成(或 10s 兜底)后定 armed/disarmed。判定前窗口(undecided)sendMessage 只入队 * 不发(spec §5.1)。 */ private deliveryArmState; private armTimer; private readonly delivery; constructor(options: GatewayClientOptions); /** * gw#1587:drain 直发(绕过 send() 的入队拦截;帧已含 outbox 序列化的 resend 标记)。 * OTel per-frame span 有意省略(重投帧非用户新动作;接受的观测简化)。 */ private sendRawFrame; private cancelArmTimer; /** gw#1587 §5.1:10s 兜底——connected 锚点起计,session_ready 到达或断连即取消。 */ private startArmTimer; /** 手动重试:armed/undecided 走 outbox retry;disarmed 走旧语义重发(保 id+resend,出 outbox)。 */ retryDelivery(clientMsgId: string): boolean; /** PR-A「id ∈ 本 tab outbox」判定(duplicate finish 提示过滤)。 */ hasDeliveryEntry(clientMsgId: string): boolean; isDeliveryArmed(): boolean; /** * Current identity (userId/sessionId) for stamping OTel spans. Pulls the * latest values from the host-provided callbacks so spans reflect live store * state. Returns empty fields when callbacks are absent — the OTel helpers * omit empty attributes. */ private spanIdentity; connect(opts?: TokenProviderOpts): Promise; disconnect(): void; /** * 是否当前 WS 连接处于 OPEN 状态。 * 暴露给 chat-store action 判断是否能立即发送 restore_request。 * 见 docs/spec/active/2026-05-17-on-demand-restore.md §4.1 */ isConnected(): boolean; /** * Client-pull 主路径:请求 gateway restore 指定 conv 的历史。 * gateway 收到后走 single-flight + fast-path 流程,emit restore_complete / restore_failed * 带回相同 requestId(dual-mode 期老 server 可能不带)。 * 见 docs/spec/active/2026-05-17-on-demand-restore.md §3.2 §4.1 */ requestRestore(conversationId: string, requestId?: string): boolean; /** Returns true if the event was dispatched, false otherwise. */ send(event: ClientEvent): boolean; /** * Manually tear down the current WebSocket and establish a fresh one. * * Differs from the automatic reconnect path (which fires on unexpected * close): this is user-initiated, so we cancel any pending auto-reconnect, * reset the attempt counter, and bypass the "intentional disconnect" * suppression before reconnecting. */ reconnect(opts?: TokenProviderOpts): Promise; /** * Force a brand-new gateway session: appends `new=1` to the next connect URL * (→ gateway `createSession` instead of resolve/reuse) and reconnects. * ⚠️ This MOVES the client to a fresh session — it does not open an * additional one (one GatewayClient holds exactly one socket/session; a * multi-session host runs one client per tab/instance). The abandoned * session is NOT released server-side: it stays alive (own container, * counted by `GET /api/sessions/concurrency`) until idle-suspend/TTL — * repeated calls can hit the concurrency limit (gw#2315 default 10). * **General-purpose primitive** (gw#2313): any caller that wants a fresh * session uses this, and the COO * toggle (ENTER/EXIT, #467/#1076) uses it because mode is baked at session * creation. Takes precedence over `sessionAttachProvider` for its one open. * One-shot, but **deliberately survives failed opens**: the flag is consumed on * the next `session_ready`, not on `onopen` (#147 r2 finding 1). So if the first * attempt is rejected *after* the 101 upgrade — gw#2315 concurrency limit, * `AUTH_TOKEN_INVALID`, any drop between 101 and `session_ready` — auto-reconnect * keeps retrying with `new=1` instead of silently attaching back to the session * the caller just asked to leave. That "leak into auto-reconnect" **is** the fix; * do not undo it. Bounded (#147 r3 finding 2): after FORCE_NEW_FAIL_ESCAPE_N * opens that upgraded but never reached `session_ready`, the intent is dropped * and the next open resolves by attach/default — otherwise a sustained * `CONCURRENCY_LIMIT_EXCEEDED` would retry forever on the client side (every * 101 resets `reconnectAttempt`, so `maxRetries` never fires). * What the cap bounds is **client retries, not server cost**: a rejected open * mints **zero** server-side sessions — gateway's `SessionManager.createSession` * calls `concurrencyGate.acquire()` first and throws `ConcurrencyLimitError` * before `createSessionInner()` ever runs, so orphans stay at 0. * B1-freeness (no orphan always-on container) is a gateway invariant, not this * call's responsibility. * Resolves on `onopen` (before `session_ready`). Frames the host enqueues after * this call are for the NEW session: the cross-session outbox gate only drops * entries enqueued before it (#147 r3 finding 4). */ startNewSession(opts?: TokenProviderOpts): Promise; /** * Await the gateway's ack for the next `switch_config`. Resolves on * `info.code === 'config_switched'`, rejects on `'config_switch_failed'` * (COO-mode persist failure) or if the socket closes first. FIFO / positional * (acks carry no correlation id, #1076 N3) — callers must serialize * switch_config (the UI debounces via isSwitching). */ awaitConfigSwitch(): Promise; /** Reject + clear all pending switch_config waiters (socket torn down). */ private rejectConfigSwitchWaiters; /** * Exposed so ChatProvider can trigger token refresh manually if needed. * * @param nextToken The token the host just switched to, when the caller has * it in hand (ChatProvider passes `onTokenChange`'s argument). A HINT only: * it can cheaply reveal a principal change before a forced rotation is spent, * but every verdict is confirmed against `tokenProvider()`. The frame carries * the freshly minted token, never this one. */ handleTokenRefresh(nextToken?: string): Promise; /** * Terminal handling for a token refresh that could not produce a usable * `update_token`: report as an auth failure and tear the socket down (a later * token change re-establishes it via the `this.ws === null` path above). */ private failTokenRefresh; /** * True only when `token`'s principal is POSITIVELY known to differ from the * one the current socket was opened with. Unknown on either side (opaque or * unparseable token ⇒ null subject) yields false: renewal is by far the * common case and must never be disturbed by a guess (#128). */ private isForeignPrincipal; /** * On WS OPEN, send `client_hello` so the gateway can identify SDK version * and advertised capabilities (e.g. `on_demand_restore`). In dual-mode * `both`, the gateway uses this to decide whether to skip connection-time * auto-push of conversation history. * Fires *before* onConnectionStateChange('connected'). * See docs/spec/active/2026-05-17-on-demand-restore.md §3.1 §4.1 */ private sendClientHello; private openSocket; private handleMessage; private scheduleReconnect; private startHeartbeat; private stopHeartbeat; } //# sourceMappingURL=gateway-client.d.ts.map