import type { StoreApi } from 'zustand/vanilla'; import type { ChatError, ConnectionState, Conversation, ConversationLastMessageKind, Message, QuestionRequest, ApprovalRequestWithId, ToolCall, RecoveryStatus } from '../types.js'; import type { NormalizedTask } from '../tasks/types.js'; import type { MidTurnState } from '../client/midturn-settlement.js'; import type { QueuedEntry } from '../client/outbound-queue.js'; /** * ac#1021:store 里两个「会话收尾」原语通知 midTurn coordinator 的出口。store 不认识 * coordinator(client/ 不依赖 provider/,store 也不依赖两者),由 ChatProvider 在 connect * 的 effect 里注册一份实现、卸载时置 null。 */ export interface MidTurnSink { onEscapeHatch(conversationId: string): void; onConversationGone(conversationId: string): void; } /** ac#1053:最近一次「起了新回合」的本地排队放出(Δ6),宿主据此重置思考计时。全局单槽,`seq` 单调。 */ export interface OutboundRelease { seq: number; conversationId: string; at: number; } /** * ac#1053 Plan B:能力关档本地排队的状态(逻辑在 `client/outbound-queue.ts` 的 `OutboundQueue`, * 私有的 `terminatedIds` / `tracked` 不在这里)。按会话键的三张表**键缺席 = 空**:写成空时删键。 */ export interface OutboundState { /** 排队中、尚未出网的消息(按入队顺序)。 */ queue: Record; /** 在途账:已出网、尚未观察到回合终态的 tracked 消息 id,**按入账顺序**(Δ14 依赖)。 */ outstanding: Record; /** R5 / Δ15 抑制位:下一个使在途账恰好变空的终态删只删不放。 */ localAbortPending: Record; /** session 级:见过带 `finish.clientMsgId` 的 finish(Δ10 / Δ14)。 */ turnCmidSupported: boolean; lastRelease: OutboundRelease | null; } export interface ChatStoreState { connectionState: ConnectionState; connectionError: ChatError | null; reconnectAttempt: number; /** * Session-level recovery flag (#29). Set true on `session_recovering` server * event, cleared on `session_recovered`. Orthogonal to `connectionState`: * the WS can be perfectly connected while the server is replaying interrupted * conversation context. Decoupling these lets a missed `session_recovered` * (gateway bug, network blip) not strand the transport-status UI. */ isRecovering: boolean; sessionId: string | null; userId: string | null; provider: string | null; model: string | null; sessionMode: string | null; conversations: Conversation[]; currentConversationId: string | null; /** * Latest in-flight restore requestId (`${Date.now()}-${seq}` from * setCurrentConversationId side-effect). event-parser compares incoming * restore_complete/failed.requestId against this to filter stale responses * when the user rapidly switches conversations. null when no conversation * is selected or the last setCurrentConversationId fired with gatewayClient * disconnected (no restore was requested). */ latestRestoreRequestId: string | null; conversationsLoading: boolean; conversationsError: ChatError | null; /** 会话置顶能力位(spec I8),来自 session_ready.session.supportsConversationPin;仅由 setConversationPinSupported 写入。 */ supportsConversationPin: boolean; messages: Record; isStreaming: boolean; isThinking: boolean; currentChatError: ChatError | null; hasMoreHistory: Record; historyLoading: boolean; /** #161B: per-conversation question slot (was a single global slot). Keyed by * conversationId; '' holds legacy events the parser couldn't attribute to a * conversation (see event-parser.ts `?? activeConversationId ?? ''` fallback). */ pendingQuestions: Record; pendingApprovals: ApprovalRequestWithId[]; processingConversationIds: Set; /** * L1 能力位三态:`true` = 本 session 的 gateway 支持生成中插话;`false` = 明确不支持; * `null` = 还没见过 `session_ready`,或那一帧根本没带这个键(老 gateway)。 * 只有 `true` 才允许建机器 —— `null` 与 `false` 一样按「不支持」处理。 */ midTurnSteerSupported: boolean | null; /** 见 `MidTurnSink`。未挂 provider(裸 store 单测 / 卸载后)时为 null,两个原语照常工作。 */ midTurnSink: MidTurnSink | null; outbound: OutboundState; tasks: NormalizedTask[]; subagentProgress: Record; recoveryStatus: Record; restoreFailureCount: Record; explicitFailureCount: Record; outstandingRestores: Record; restoringStartedAt: Record; } export interface ChatStoreActions { setConnectionState: (state: ConnectionState) => void; setConnectionError: (error: ChatError | null) => void; setReconnectAttempt: (n: number) => void; setRecovering: (recovering: boolean) => void; setSessionInfo: (info: { sessionId: string; userId: string; provider?: string; model?: string; mode?: string; }) => void; setConversations: (conversations: Conversation[]) => void; addConversation: (conversation: Conversation) => void; removeConversation: (conversationId: string) => void; mergeConversations: (conversations: Conversation[]) => void; setConversationPinSupported: (supported: boolean) => void; setConversationPinnedAt: (conversationId: string, pinnedAt: number | null) => void; updateConversationTitle: (conversationId: string, title: string) => void; updateConversationActivity: (conversationId: string, patch: { lastMessageAt: number; lastMessagePreview?: string; lastMessageKind?: ConversationLastMessageKind; messageCount?: number; }) => void; setCurrentConversationId: (id: string | null) => void; addMessage: (conversationId: string, message: Message) => void; /** * gw#1587:按消息 id(=client_msg_id,同一 UUID)更新投递状态。消息可能尚未定位到 * conv(跨 conv 扫描);只写消息级字段,绝不触 processingConversationIds(spec §5.4)。 */ setDeliveryState: (messageId: string, state: 'pending' | 'delivered' | 'failed', cause?: string) => void; createAssistantMessage: (conversationId: string) => void; updateMessage: (conversationId: string, messageId: string, updates: Partial) => void; appendContent: (conversationId: string, text: string) => void; appendReasoning: (conversationId: string, text: string) => void; addToolCall: (conversationId: string, toolCall: ToolCall) => void; updateToolCall: (conversationId: string, toolCallId: string, updates: Partial) => void; setStreaming: (streaming: boolean) => void; setThinking: (thinking: boolean) => void; setCurrentChatError: (error: ChatError | null) => void; prependMessages: (conversationId: string, messages: Message[]) => void; setHasMoreHistory: (conversationId: string, hasMore: boolean) => void; setHistoryLoading: (loading: boolean) => void; /** #161B: add-or-replace the slot for `question.conversationId` (same-conversation * REPLACE; other conversations' slots untouched). */ upsertPendingQuestion: (question: QuestionRequest) => void; /** #161B: remove whichever conversation's slot holds `requestId` (answer/dismiss * self-address by requestId, possibly not the current conversation). Miss = no-op. */ clearPendingQuestionByRequestId: (requestId: string) => void; addPendingApproval: (approval: ApprovalRequestWithId) => void; removePendingApproval: (requestId: string) => void; addProcessingConversation: (conversationId: string) => void; removeProcessingConversation: (conversationId: string) => void; clearProcessingConversations: () => void; /** * ac#917:把一个「本地以为还在跑、实际早已不在跑」的会话**就地收尾**,一次 `set()` 原子完成 * 三件事。这是 composer 解锁的**唯一**共用原语,两个调用方: * - **对账**(`session_ready` 带 `inFlightTurnConversationId` 时,服务端说没在跑的那些); * - **逃生口**(用户按停止键后 10 秒仍无终态,见 `useAbortWithEscapeHatch`)。 * * 三件事缺一不可 —— 少做任何一件,composer 看起来还是锁着的: * 1. **移出 `processingConversationIds`** —— 侧栏「新建对话」与跨会话发送闸读它; * 2. **settle 幻影 assistant 消息** —— `thinking` 一到就建好了那条 `status:'streaming'` * 的空消息,只清集合它还会一直转。收成 **`'error'` 而不是 `'complete'`**:这一轮并没有 * 确认结束,标 complete 会让一段可能残缺的正文**看起来是完整回复**,而 ac#670 的重拉 * 不会再覆盖它。 * 3. **复位全局 `isStreaming`/`isThinking`** —— 只在收尾的是**当前打开的会话**时动 * (门同 `finishMessage`:后台会话的收尾不该改全局 flag)。 * * ⚠️ 三件事都做成幂等:不在集合里 / 没有幻影消息 / flag 本就是 false 时各自跳过, * 整体是 no-op(返回空 patch,不churn store)。 */ settleStuckTurn: (conversationId: string, /** * ac#1021:`'escape_hatch'`(用户按停止键后 10s 兜底)才向 `midTurnSink` 发 E10; * `'reconcile'`(`session_ready` 对账)不发 —— 对账腿的语义由 E6 独占,重复打一发 * E10 会给机器盖上 `escape_hatch` 成因,把「服务端说它没在跑」谎报成「用户放弃了」。 */ source?: 'escape_hatch' | 'reconcile') => void; /** `session_ready.session.midTurnSteer` 三态写入(键缺席 ⇒ null)。 */ setMidTurnSteerSupported: (supported: boolean | null) => void; /** ChatProvider 注册/注销 coordinator 出口。 */ setMidTurnSink: (sink: MidTurnSink | null) => void; /** 写 `messages[convId]` 里 `id===msgId` 那条的 `midTurn`;找不到 ⇒ no-op。 */ setMidTurn: (conversationId: string, msgId: string, state: MidTurnState) => void; /** gw#2824:给 `messages[convId]` 里 `id===msgId` 的 user 消息置 `abortDropped: true`;找不到或已置位 ⇒ no-op(同引用)。 */ markAbortDropped: (conversationId: string, msgId: string) => void; setOutboundQueue: (conversationId: string, list: QueuedEntry[]) => void; setOutstanding: (conversationId: string, ids: string[]) => void; setLocalAbortPending: (conversationId: string, pending: boolean) => void; setTurnCmidSupported: (supported: boolean) => void; setLastRelease: (release: OutboundRelease | null) => void; /** 有参:清该会话的 queue / outstanding / localAbortPending;无参:清全部并 `turnCmidSupported=false`(`lastRelease` 保留)。 */ clearOutbound: (conversationId?: string) => void; /** 按 id 删 `messages[conv]` 里的一条(排队放出失败时撤回乐观气泡);找不到 ⇒ no-op。 */ removeMessage: (conversationId: string, messageId: string) => void; registerRestoreAttempt(conversationId: string, requestId: string): void; recordRestoreFailure(conversationId: string, requestId?: string): void; recordRestoreSuccess(conversationIds: string[]): void; resolveOutstandingAsDropped(): void; _tryAutoRestore(conversationId: string): void; retryRestore(conversationId: string): void; /** Replace all tasks (used by B4 REST hydration). */ setTasks: (tasks: NormalizedTask[]) => void; /** * Add-or-replace a task row with status 'running'. * `conversationId` (COO_TASK_CONV_ENABLED) binds the task to its dedicated * conversation; absent when the gateway flag is off. */ upsertTask: (task: { id: string; name: string; startedAt: string; conversationId?: string; }) => void; /** * Set task to 'completed'. Upserts if id absent (a completion may arrive * before the corresponding task_started event). A `conversationId` on the * patch overrides; otherwise any value from task_started is preserved. */ completeTask: (id: string, patch: { completedAt: string; result: NormalizedTask['result']; conversationId?: string; }) => void; /** * Set task to 'failed'. Upserts if id absent. * Design choice: `failedAt` (WS delta field) is stored as `completedAt` so * the row always has a terminal timestamp for display ordering. The REST * WorkerTaskState type has no `failedAt` field. * `conversationId` semantics match completeTask. */ failTask: (id: string, patch: { failedAt: string; error: string; conversationId?: string; }) => void; /** * 写 agentId 的最新进度相。phase ∈ {done,failed} → 清键(done() 早于 * task_completed,写进去会造「空白卡」窗口)。gateway flag 关时无来源。 */ setSubagentProgress: (agentId: string, progress: { phase: string; detail?: string; }) => void; } export type ChatStore = ChatStoreState & ChatStoreActions; /** * Create a fresh Zustand store. Each ChatProvider instance gets its own store * — this ensures test isolation and supports multiple providers on a page. * * `opts.gatewayClient` (optional) wires setCurrentConversationId side-effect * for on-demand restore: when connected, switching conversation triggers * `gatewayClient.requestRestore(id, requestId)`. Callers that don't pass it * (legacy tests, standalone use) get pure state-update behavior. */ export declare function createChatStore(opts?: { gatewayClient?: { isConnected(): boolean; requestRestore(id: string, reqId?: string): boolean; }; }): StoreApi; //# sourceMappingURL=chat-store.d.ts.map