/** * ac#917 · 停止键的**逃生口**:按下停止后若 `timeoutMs` 内服务端一条终态都没回来, * 就地把本地态收敛掉(而不是继续举着一个谁也放不下来的锁)。 * https://github.com/Optima-Chat/agentic-chat/issues/917 * * ## 它解的是什么 * * 今天按停止键的链路是:gateway 把 abort 转发给容器 → 容器没有在飞 turn ⇒ 无回执 ⇒ * 10 秒后弹一条 toast「停止失败,请重试或**刷新页面**」,而**本地状态一个字节不动**。 * 于是 composer 继续锁着,用户唯一的出路就是照 toast 说的去刷新。 * * 本 hook 把「刷新」这一步自动化:超时后清掉该会话的本地在跑态(`settleStuckTurn`), * 消费方仍然弹自己的 toast。 * * ## 🔴 它翻了 ac#717 写死的一条红线,这里显式登记 * * #717 的注意事项原文:**不乐观改任何 SDK 态(isThinking/isStreaming)——收敛只信服务端 * finish 经 SDK 反映出的 `isCurrentSessionActive` 翻 false。** * * 为什么我认为可以翻(不是「忘了有这条」,是「知道并给理由」): * - **产品今天就在劝用户做同一件事** —— 那条 toast 的文案就是「请重试或刷新页面」。用户照做, * 刷新后 store 清空、composer 解锁,得到的**就是本 hook 的同款状态**。这里只是把产品 * 已经在建议的动作自动化,不是引入一个新状态。 * - #717 红线的立意是「别用乐观更新掩盖服务端真相」。这里不掩盖任何东西:它发生在 * **用户主动喊停 + 服务端 10 秒完全没有回应**之后,且照常弹那条 toast。 * * ## 🔴 诚实边界:会误解锁 * * 「10 秒拿不到回执 ⇒ 服务端没有可停的东西」**是推断不是事实**。反例结构性存在: * optima-gateway#1565 契约刻意把放锁延到**容器回执驱动的 finish**,而容器在长工具执行期间 * 无法立即响应 abort。所以「abort 回执比 10 秒慢」时这里会提前解锁。 * * 📊 **现网实测(cn-prod,08-12→08-17 北京时间,n=46 次 abort)**:有在飞轮次可停的 * **43 条,回执(`turn_completed` 且 `outcome:"aborted"`)全部在同一秒内到达,0 条超过 * 10 秒**;独立交叉验证——`turn obligation kept armed on aborted turn` 计数同为 43。 * 另外 3 条**压根没有回执**(无 `outcome=aborted`,中间夹了新 `turn_started`),正是本 * 逃生口要救的那一档 ⇒ **实测触发率 ~6.5%**。 * ⚠️ 边界:窗口只有 5 天(SLS 列存可分析窗口比日志保留期短,再往前 `content` 字段在 SQL * 层为 NULL)、秒级分辨率、n=46。**上面那个「长工具执行期回执超 10s」的场景在本窗口一次 * 都没出现,但 n 不足以排除它** —— 所以下面这两项后果保留,不因为没测到就删掉。 * * 后果有两项: * * 1. **用户可能多发一条** —— 撞上 gateway 的 per-(user,session) 并发锁、收到一条 * 「另一个对话正在处理中」。不是静默错误。 * 2. 🔴 **那一轮的回复会被劈成两条气泡** —— `settleStuckTurn` 把幻影消息标成 `'error'` * 之后,`findStreamingAssistantIndex` 就再也找不到它,迟到的 `text_delta` 于是**新建** * 一条 assistant 消息(`chat-store.ts` 的 `appendContent`,`idx === -1` 分支)。 * 用户看到的是「前半段标着失败 + 后半段另起一条」。这一项在设计定稿时**漏登记了**, * 见 `composer-wedge-reconcile-917.test.ts` 的 X1 特征用例(钉的是已知边界,不是期望行为)。 * * 窗口维持在 10 秒(拉长要连带改 toast 的出现时机,属 UX 改动)。 * * ## 取消条件(两条,照搬今天 agentic-chat 里那个定时器的语义) * * 1. **切走了**:`conversationId` 变了 ⇒ 撤销,不再对旧会话动手; * 2. **自己收敛了**:该会话不再处于在跑态 ⇒ 撤销。 * ⇒ 「正常按停止、服务端 10 秒内正常回执」这条主路径**永远走不到超时分支**。 * * 组件卸载由 effect 的 cleanup 自然覆盖。 * * @example * const { abort, abortingConversationId } = useAbortWithEscapeHatch({ * onEscape: () => toast.error(t('aiShell.abortTimeoutHint')), * }); * // 停止键 onClick 调 abort();abortingConversationId 非空即「停止中」态 */ export interface UseAbortWithEscapeHatchOptions { /** * 超时毫秒数。默认 10_000 —— 与 ac#717 那条 toast 的既有时机对齐,两者必须同步改, * 否则会出现「已经解锁了但 toast 还没弹」或反过来的错位。 */ timeoutMs?: number; /** * 超时且**确实收敛了本地态**之后调用。消费方在这里弹自己的文案 * (`/chat` 与 COO 两个 composer 的提示语不同,故不由 SDK 内置)。 */ onEscape?: (conversationId: string) => void; } export interface UseAbortWithEscapeHatchResult { /** 替代 `useCurrentChat().abort` 调用:发 abort 帧 + 武装逃生口定时器。 */ abort: () => void; /** 正在等待停止回执的会话 id;非空即「停止中」态,可用来置灰停止键。 */ abortingConversationId: string | null; } export declare function useAbortWithEscapeHatch(options?: UseAbortWithEscapeHatchOptions): UseAbortWithEscapeHatchResult; //# sourceMappingURL=use-abort-with-escape-hatch.d.ts.map