import { type RawEvent } from "../shared/projection"; import type { MessageContent } from "../schemas/message"; import type { PatchEnvelope, TurnProjection } from "../schemas/projection"; import type { ReplaySpeed, ReplayState, SessionStatus } from "../schemas/session"; import { type CommandHandler } from "../commands/registry"; import { type AgentSessionEvents } from "./events"; import type { AppCliAttachment, AppCliDefinition, FileEntry, HistoricalCommand, SessionTurnIndex, SessionTurnIndexItem, SessionTurnsPage, SessionTurnsQuery } from "../resources/sessions"; import { type QueueOperationResult } from "./queue"; import { type ConnectionStatus, type SessionState } from "./state"; /** SessionHub 注入给 AgentSession 的运行时依赖(socket 收发 + REST 补数)。 */ export interface SessionRuntime { emitEvent(event: string, data: unknown): void; emitWithAck(event: string, data: unknown, timeoutMs: number): Promise | undefined>; ensureConnected(): void; fetchTurnsPage(sessionId: string, query?: SessionTurnsQuery, init?: RequestInit): Promise; fetchTurnIndex(sessionId: string): Promise; fetchCommands(sessionId: string): Promise; /** 会话详情,目前只用来取回放状态。 */ fetchSessionInfo(sessionId: string): Promise<{ replay_state?: ReplayState | null; viewer_role?: "owner" | "viewer"; }>; updateReplay(sessionId: string, payload: { speed?: ReplaySpeed; status?: "autonomous"; }): Promise; listDir(sessionId: string, dirPath: string): Promise; countFiles(sessionId: string, dirPath?: string): Promise; downloadFile(sessionId: string, filePath: string, downloadName?: string): Promise; attachAppCli(sessionId: string, definition: AppCliDefinition): Promise; /** * 归还一份持有。返回 true 表示这是最后一份、实例可以真正释放; * 返回 false 表示还有别的持有者,实例必须继续工作。 */ onDispose(session: AgentSession): boolean; } export interface AttachAppOptions { /** 可选;只需要页面上下文时无需提供 CLI。 */ cli?: AppCliDefinition; /** 每次发送前执行一次;返回当前页面状态的 JSON 对象快照。 */ getContext: () => Record | Promise>; } export interface SendOptions { /** planning / executing 模式。 */ mode?: string; /** AskUserQuestion 的作答(tool_call_id + 选项)。 */ askUserAnswer?: Record; /** 本次运行的模型覆盖。 */ model?: string; /** 本次运行是否强制开/关思考。 */ thinkingOverride?: boolean; whatif?: { from_step: number; quotes: Array>; deprecate_entry_ids: string[]; }; replayDecision?: "keep_replay" | "continue_replay"; /** 用户显式选择的知识库 id;后端将元信息作为 once Context 提供给模型。 */ kbIds?: number[]; /** * 声明"会话在跑就把这条消息放进等待队列"。默认不排队、运行中直接报错: * 是否真的入队由服务端按运行状态判定(等待回答时发送即作答,不会被误排队)。 */ queueWhenRunning?: boolean; } export declare function sessionStatusFromChatEnd(status: string | undefined): SessionStatus; /** * 一个会话的实时状态机:封装 Socket.IO 订阅、历史 + 流式合流、断线补数, * 对外只有三样东西——状态快照(getState/subscribe)、动作(send/stop/…)、 * 语义事件(on/onCommand)。 * * 状态完全归属实例:同一页面创建多个 AgentSession 互不干扰。 */ export declare class AgentSession { readonly sessionId: string; private state; private readonly listeners; private readonly emitter; private readonly commands; private readonly runtime; private readonly projectionBuilder; private readonly pendingTurnPatches; private patchFlushHandle; private patchFlushCancel; private joined; private joinPending; private subscriptionQueue; private replaceQueue; private timelineEpoch; /** 定位请求的流水号:被后一次选择取代的那次定位不再提交窗口(见 locateEntry)。 */ private locateToken; /** 在途那次定位的请求句柄。**只**服务定位:权威刷新不共用它(见 cancelPendingLocate)。 */ private locateAbort; private pagingGeneration; private liveRevision; private modeRevision; private readonly liveRevisions; private readonly pendingOptimisticTurnIds; private olderPending; private replaceInFlight; private replayQueue; private commandRestoreQueue; private commandRecoveryGeneration; private commandRestoreRetryAttempt; private commandRestoreRetryHandle; private bufferedCommandDeliveries; private pendingReplayMessage; private pendingReplayMode; private pendingReplayKbIds; private recentChatEndAt; /** * 最近一次已经通过 `error` 事件投递过的服务端失败(`message` + `code`)。 * * `chat:send` 被代理时服务端会先发 `system:error`、再用同一条失败回 ack,两条 * 通道都会到达这里:前者走 `_handleSystemError`,后者走发送失败的回滚。不去重 * 的话调用方会收到两次同一个 `error`(重复提示、重试逻辑跑两遍)。 * * 带代际边界,只认「本次 `emitWithAck` 等待期间」投递的那一条:否则队列/停止 * 失败留下的标记会一直挂着,之后一次无关的订阅失败只是文案码相同就被当成 * 重复而静音。用单调递增的代际号而不是时间戳,同一毫秒内的两条也不会混淆。 */ private lastDeliveredSystemError; /** 每次投递 `system:error` 递增,用于判定某条失败是否属于本次等待。 */ private systemErrorGeneration; private subscriptionReplaySource; private disposed; private getAppContext; private sendPending; private manualCompactionPending; private readonly deliveredCommandKeys; private readonly deliveredToolPreviews; private readonly oomNotifiedKeys; private readonly seenToolCallIds; private readonly finishedToolCallIds; private readonly emittedMessageTurnIds; constructor(sessionId: string, runtime: SessionRuntime, connection: ConnectionStatus, options?: { staticHistory?: boolean; }); /** * 静态历史(分享页 / `fromTurns`):没有目录接口,也**没有**"目录还没到位"这个中间态。 * * 宿主据此决定要不要拿已加载窗口当兜底条目——真实会话在 timeline 被重写后,窗口里还是 * 旧分支的发言,拿它兜底会把已经失效的 entry id 重新摆到轨道上,点了必然找不到。 */ readonly isStatic: boolean; /** 从一份静态 turn 快照构造只展示历史的会话(例如公开分享页)。 */ static fromTurns(sessionId: string, turns: TurnProjection[]): AgentSession; /** 返回当前会话快照。引用稳定:状态未变化时返回同一对象。 */ getState(): SessionState; get hasOlderHistory(): boolean; /** Load the page immediately preceding the current display window. */ loadOlderHistory(options?: { beforeCommit?: () => void; }): Promise; /** * 定位到一条窗口外的历史发言:请服务端返回"从这条发言到最新"的窗口并整窗替换。 * * 返回 false 表示这次定位没有落地(请求失败,或期间 timeline 被重写、会话已释放), * 当前窗口保持不变,调用方可以原样重试。已加载的发言不要走这里——宿主直接滚动, * 一次网络请求都不该发。 */ locateEntry(entryId: string): Promise; /** * 取消在途定位:用户改选了另一条发言(哪怕那条已经加载、不需要请求), * 先前那次定位既不该再落地,也不该继续占着替换队列——排队中的下一次定位 * 不然要等它跑完(用户看到的是"点了新目标一直转圈")。 * * **只**中止定位自己的请求:权威刷新(运行结束 / 重连补数)不共用这个 controller, * 否则"运行刚结束时点一下侧栏"就会把终态对账一起中止,流式与历史对不上。 */ cancelPendingLocate(): void; /** * timeline 被整体改写(rewind / checkout / 释放):推进纪元,并把在途定位请求一并中止 * ——它已经属于旧 timeline,却还占着替换队列,会让改写后的权威窗口排在后面等它跑完。 * 作废号仍然保留:中止只是让它更早结束。 */ private invalidateTimeline; /** * 拉取当前分支的用户发言目录(消息导航侧栏用)。 * * 不在这里缓存:什么时候该刷新只有调用方知道(会话切换、历史被替换、新发言), * 缓存只会让旧条目在 rewind 之后继续可点。 */ loadTurnIndex(): Promise; /** 订阅状态变化。返回取消订阅函数。 */ subscribe(listener: () => void): () => void; private setState; private update; /** 监听语义事件(底层逃生舱,见 events.ts)。返回取消函数。 */ on(name: K, handler: (event: AgentSessionEvents[K]) => void): () => void; off(name: K, handler: (event: AgentSessionEvents[K]) => void): void; /** * 监听智能体下发给宿主页面的指令。 * * action 是技能作者在工具返回值 `_meta.bridge` 里写的契约字符串(如 * "map.highlight"),由你和技能作者约定;注册前到达的指令会被缓冲补投。 */ onCommand(action: string, handler: CommandHandler): () => void; /** * 声明当前应用提供的页面上下文和业务 CLI。 * * CLI 安装成功后才保存 getContext;之后每次 send 前恰好读取一次。 */ attachApp(options: AttachAppOptions): Promise; /** 发送一条消息。字符串或多模态内容均可。 */ send(content: MessageContent, options?: SendOptions): Promise; /** * 入队一条等待消息(运行中排队 / 暂停期间照常入队)。 * * ack 超时或传输失败时结果未知:用**同一个** `client_request_id` 重试一次, * 服务端按 `(session_id, client_request_id)` 幂等,不会制造第二条。 */ queueMessage(content: string): Promise; /** 修改一条等待消息的正文;`expectedVersion` 是客户端看到的条目版本。 */ updateQueuedMessage(itemId: string, expectedVersion: number, content: string): Promise; /** * 重排等待消息。`orderedIds` 必须是当前 pending 条目 id 的**完整集合**(顺序任意), * 服务端按 `expectedRevision` 校验后整体替换顺序。 * * 拖动排序就该像本地操作一样落地:版本冲突(运行中的任务刚领走一条 / 另一个端也在 * 动队列)不甩给用户「刷新后重试」,而是拿冲突响应里那份**最新快照**重算一次再提交 * ——掉队的条目(已经被领取执行的)从顺序里剔除,剩下的保持用户拖出来的相对次序。 * 最多补两次;仍旧冲突才把结果交回调用方。 */ reorderQueuedMessages(expectedRevision: number, orderedIds: string[]): Promise; /** 删除一条等待消息;不传版本时由服务端自行判定。 */ cancelQueuedMessage(itemId: string, expectedVersion?: number): Promise; /** 立即补充:把等待消息交给当前运行,在下一个接收点消费,不停止也不另起一轮。 */ deliverQueuedMessage(itemId: string, expectedVersion: number): Promise; /** 继续执行:清除暂停,并按当前顺序恢复逐条接续。 */ resumeQueue(): Promise; /** 拉取一份权威队列快照。首次连接与每次重连后由实例自动调用,也可以手动刷新。 */ refreshQueue(): Promise; /** * 发出一条队列命令并安装 ack 里的快照。 * 传输层失败(超时 / 断线)会 reject,由调用方决定是否重试。 */ private requestQueueAck; /** 队列命令的对外入口:传输失败只体现为失败结果,不向调用方抛异常。 */ private runQueueCommand; private sendUnlocked; private rollbackOptimisticSend; /** 撤回一次乐观提交:移除乐观消息、恢复提问作答、清掉回放草稿。 */ private discardOptimisticSend; /** 停止当前回复。 */ stop(): Promise>; /** 手动触发上下文压缩。 */ compact(): Promise>; /** * 把一段业务数据作为附件放进聊天输入框(比如地图上选中的点位)。 * 附件随下一条消息一起发给智能体。需要 ChatView / useChat 渲染层配合展示; * 无 UI 场景可直接用 send()。 */ attach(label: string, data: unknown): void; /** 往聊天输入框追加一段文字(不发送)。 */ insertText(text: string): void; /** 列出工作区的一层目录。 */ listDir(dirPath?: string): Promise; /** 递归统计工作区文件数(不含目录)。 */ countFiles(dirPath?: string): Promise; /** 在浏览器中下载一个工作区文件。 */ downloadFile(filePath: string, downloadName?: string): Promise; /** * 归还一份持有。同一个会话被多处 connect 时按引用计数释放:只有最后 * 一份归还才真正断开订阅与监听器,之前的归还不影响仍在使用的一方。 */ dispose(): void; get isDisposed(): boolean; /** @internal 连接状态变化(同 client 下所有会话共享一条连接)。 */ _setConnection(connection: ConnectionStatus): void; /** * @internal 服务端广播的权威队列快照。 * 结构不合法或 revision 不新的一律丢弃,绝不用旧快照覆盖新状态。 */ _handleQueueUpdated(payload: unknown): void; /** @internal socket 重连后由 Hub 调用:重新加入房间并补数。 */ _rejoinAfterReconnect(): Promise; /** @internal 首次连接:加载历史 + 加入房间。 */ _bootstrap(): Promise; /** 订阅生命周期串行执行;一次失败只由自己的调用方观察,不截断后续重连。 */ private enqueueSubscription; /** 改回放是 owner 专属(服务端 PATCH 走 _get_owned_session)。 */ private get canControlReplay(); /** 拉一次会话详情,填回放状态和只读身份;失败按非回放会话处理,不挡启动。 */ private syncReplayFromSession; /** 调整回放倍速;不是回放会话、或只读身份时什么都不做。 */ setReplaySpeed(speed: ReplaySpeed): Promise; /** 退出回放,之后的对话交给真实模型运行;不是回放会话、或只读身份时什么都不做。 */ exitReplay(): Promise; /** 把一次回放控制请求排进队列;前一个完成后才发出,响应直接安装。 */ private applyReplayControl; /** @internal */ _handleTurnStart(turn: TurnProjection): void; /** @internal */ _handleTurnPatch(patch: PatchEnvelope): void; /** @internal */ _handleTurnEnd(turn: TurnProjection): void; /** @internal 历史回放通道(event_format: raw)。 */ _handleTurnEvents(events: RawEvent[], replayed?: boolean): void; /** @internal */ _handleChatStart(): void; /** @internal */ _handleChatEnd(status: string): void; /** @internal */ _handleSystemError(message: string, code?: string): void; /** @internal */ _handleTaskUpdated(tasks: unknown[]): void; /** @internal */ _handleWorkspaceFilesChanged(filePath?: string): void; /** @internal */ _handleSessionUpdated(data: { workspace_path?: string; chat_group_id?: string | null; intent?: string; status?: SessionStatus; model?: string | null; bound_skill_id?: string | null; replay_state?: Record | null; }): void; /** @internal */ _handleRewind(checkpointId: string): void; /** @internal */ _handleReplayMismatch(data: { actual_message?: string; expected_message?: string; }): void; /** @internal */ _handleSystemNotification(loopId: string, notification: Record): void; /** @internal */ _handleBackgroundTask(task: AgentSessionEvents["backgroundTask"]): void; /** @internal */ _handleArtifact(event: AgentSessionEvents["artifact"]): void; /** @internal 加入房间的应答里带 is_running,据此校准流式状态。 */ _applyRunningState(isRunning: boolean, liveRevisionAtJoin?: number): void; private markRunningIfIdle; private finishStreaming; private completeChat; private markTurnFresh; private markOptimisticTurnPending; private pruneOptimisticLiveRevisions; private applyTurn; private schedulePatchFlush; private cancelPatchFlush; private flushPatches; /** turn 数据落地后统一派生语义事件(指令 / 工具调用 / 消息 / 模式 / 预览 / OOM)。 */ private emitTurnDerivedEvents; private dispatchCommand; private dispatchRawCommands; private deliverCommand; private deliverCommandNow; private notifySandboxOom; /** * 权威刷新(运行结束 / 重连补数):**按当前窗口第一条发言取窗口**,不再另记"用户想看哪段"。 * * 窗口本身就是"用户在看哪段":首屏就是最新一页,向上翻页 / 定位之后首条就是用户滚到的位置。 * 直接拿 `from_entry_id = 当前窗口第一条 turn_id` 去取,运行结束、重连补数、翻页之后的对账 * 是同一条规则,不需要意图、锚点、已落地锚点这些状态;窗口首条已经不在分支上(404 / 409)时 * 回落到最新一页。 */ private refreshAuthoritativeHistory; private replaceFromHistory; private invalidateOlderPaging; private beginCommandRecovery; private scheduleHistoricalCommandRestore; private flushBufferedCommands; /** 首次订阅前的 REST 历史建立语义事件基线,随后同一工具的订阅回放不再投递。 */ private seedToolCallDeliveryBaseline; private ensureJoined; private ensureIdleBeforeSend; }