dsh-mobile-plugin 0.1.93 → 0.1.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,9 +38,20 @@ const approvals = require('./approvals.js');
38
38
  const questions = require('./questions.js');
39
39
 
40
40
  // Android React Native's XHR may buffer tiny chunked responses until its
41
- // internal progress threshold. Pad each SSE record so onprogress exposes it
42
- // promptly; the padding is an SSE comment and is ignored by clients.
41
+ // internal progress threshold. Keep each window of records padded past that
42
+ // threshold so onprogress exposes them promptly; the padding is an SSE comment
43
+ // and is ignored by clients.
43
44
  const SSE_FLUSH_PADDING_BYTES = 2048;
45
+ /**
46
+ * 草稿增量的补齐窗口。
47
+ *
48
+ * **只对 `assistant/chunk` 生效**:一次流式回复每秒有几十条增量,每条都补 2048 字节
49
+ * 等于把正文放大十几倍,而这些字节两条腿(宿主→中转、中转→手机)都是实打实要走的。
50
+ * 按 120ms 一个窗口补齐,界面出字节奏不变(客户端本来就攒够一个阈值才渲染一次)。
51
+ * 带 seq 的事件、审批卡、排队/任务帧、心跳照旧补满整份 —— 它们慢一拍就是「消息不落地」
52
+ * 「卡不出现」。实现见 lib/shared/sse-write.js 的 takePadding。
53
+ */
54
+ const SSE_PAD_MIN_INTERVAL_MS = 120;
44
55
  const REPLAY_WINDOW = 3000; // 断线补偿回放的最大事件数
45
56
 
46
57
  /**
@@ -75,22 +86,51 @@ function logStream(sessionId, message, error) {
75
86
  } catch { /* ignore */ }
76
87
  }
77
88
 
89
+ /**
90
+ * 这一帧是不是「流式回复里的草稿增量」——唯一可以按窗口补齐的那一类。
91
+ *
92
+ * 判据与丢帧口径同源:没有 seq 的 `assistant/chunk` 是草稿,同一轮收尾的
93
+ * `assistant/message` 带完整正文、重连回放也能补齐;所以把它按窗口补齐最坏只是慢一拍,
94
+ * 而其它帧慢一拍就可能表现为「消息不落地」「审批卡不出现」。
95
+ */
96
+ function isStreamDraft(payload) {
97
+ if (!payload) return false;
98
+ // 实时帧写的是内层事件(`{type:'assistant/chunk', data}`),回放路径偶尔仍带一层
99
+ // `session/event` 外壳,两种形状都认。
100
+ const type = payload.type === 'session/event' && payload.event ? payload.event.type : payload.type;
101
+ return type === 'assistant/chunk';
102
+ }
103
+
78
104
  /**
79
105
  * 写一条 SSE 记录(策略声明,实现见 `lib/shared/sse-write.js`)。
80
106
  *
81
107
  * 这条流的策略:软水位只丢**没有 seq** 的增量(`assistant/chunk` 是可丢弃草稿,同一轮
82
- * 收尾的 `assistant/message` 带完整正文且有 seq,重连回放能补齐);每条记录补 2048 字节
83
- * 注释,让 RN 的 XHR 尽快吐出来。
108
+ * 收尾的 `assistant/message` 带完整正文且有 seq,重连回放能补齐);草稿增量按 120ms
109
+ * 一个窗口补到 2048 字节以上,**成批灌入的回放**同样按窗口补齐,其余帧补满整份,
110
+ * 让 RN 的 XHR 尽快吐出来(见 `SSE_PAD_MIN_INTERVAL_MS`)。
111
+ *
112
+ * `options.burst` = 成批灌入(断线回放)。回放是几百到几千条连着写出去的:客户端本来
113
+ * 就在持续收字节、进度事件照常触发,而逐条补满分摊到一次重连就是几 MB 白字节(实测一次
114
+ * 903 帧的回放共 2.06MB,其中约 1.85MB 是注释)。窗口机制照样保证「每 120ms 至少一窗
115
+ * 2048 字节」,而且这条流的**第一条**记录总是落在空窗口上、拿到整份填充,所以「重连后
116
+ * 界面立刻出内容」不受影响。实时流里那条「带 seq 的帧补满整份」的理由(它可能夹在一串
117
+ * 慢草稿后面单独到达,慢一拍就是「消息不落地」)在成批灌入里不成立,别把它抄到回放上。
84
118
  *
119
+ * @param {{burst?: boolean}} [options] 成批灌入(回放)标记,见上。
85
120
  * @returns {{ok: boolean, shed?: boolean, needDrain?: boolean}}
86
121
  * `ok:false` → 这条流该收尾(客户端走了,或积压超过硬水位);
87
122
  * `shed:true` → 这条**增量**帧被丢掉、连接还活着;
88
123
  * `needDrain:true` → 写成功了,但底下已经写不动,调用方要先 await 一次 drain 再继续。
89
124
  */
90
- function writeSse(res, payload, frame) {
125
+ function writeSse(res, payload, frame, options = {}) {
126
+ // 两条路走窗口补齐:可丢弃的草稿增量,与成批灌入的回放(见上面 options.burst)。
127
+ // 其余帧一律补满整份 —— 带 seq 的事件、审批卡、排队/任务帧、心跳是一次回复里真正
128
+ // 要落地的东西,宁可多花 2KB 也不让它们卡在客户端的缓冲里等下一次写入。
91
129
  return sharedWriteSse(res, payload, {
92
130
  frame,
93
- padding: ssePadding(SSE_FLUSH_PADDING_BYTES),
131
+ ...(options.burst === true || isStreamDraft(payload)
132
+ ? { paddingBytes: SSE_FLUSH_PADDING_BYTES, padEveryMs: SSE_PAD_MIN_INTERVAL_MS }
133
+ : { padding: ssePadding(SSE_FLUSH_PADDING_BYTES) }),
94
134
  // 审批帧没有 seq,按默认口径会在软水位上被丢掉(见上面那条策略)。但它是「人工决策
95
135
  // 的入口」不是草稿:丢一张,手机就再也看不到那张卡(软水位只在客户端跟不上时到,
96
136
  // 回放也不覆盖这一段)。它一次审批也就一两帧,不是撑爆写队列的那类帧。
@@ -155,6 +195,13 @@ async function handleStream(ctx, req, res) {
155
195
  const url = new URL(req.url, 'http://x');
156
196
  const sessionId = url.searchParams.get('sessionId');
157
197
  if (!sessionId) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少 sessionId' } });
198
+ // 子会话地址:App 打开子会话的流时带上父会话与模式(见 app/src/sse.ts 的
199
+ // openSessionStream 传参)。缺这两个字段就是普通会话,与历史那条同一套判定。
200
+ const parentSessionId = url.searchParams.get('parentSessionId');
201
+ const mode = url.searchParams.get('mode');
202
+ const streamAddress = typeof parentSessionId === 'string' && parentSessionId
203
+ ? { sessionId, parentSessionId, ...(mode === 'one-shot' || mode === 'continuable' ? { mode } : {}) }
204
+ : { sessionId };
158
205
  try {
159
206
  res.writeHead(200, {
160
207
  'content-type': 'text/event-stream; charset=utf-8',
@@ -218,7 +265,7 @@ async function handleStream(ctx, req, res) {
218
265
  try {
219
266
  // 断线续传:先回放 history 中 seq > resumeSeq 的事件(收敛状态)。
220
267
  if (resumeSeq != null && resumeSeq > 0) {
221
- const replay = await replaySince(ctx, sessionId, resumeSeq, res, ac.signal, (seq) => { lastDeliveredSeq = Math.max(lastDeliveredSeq, seq); });
268
+ const replay = await replaySince(ctx, sessionId, resumeSeq, res, ac.signal, (seq) => { lastDeliveredSeq = Math.max(lastDeliveredSeq, seq); }, streamAddress);
222
269
  if (replay.gap && !ac.signal.aborted) {
223
270
  const value = { type: 'session/resync-required', sessionId, resumeSeq, oldestSeq: replay.oldestSeq };
224
271
  // 旧实现在这里直接 `return`:既不清 keepAlive 也不 end,漏出一条永不收尾的连接。
@@ -230,7 +277,10 @@ async function handleStream(ctx, req, res) {
230
277
  // payload 里带 sessionId:0.2.0 的宿主没有设备级事件流了,兼容桥据此收窄成
231
278
  // 这一条会话的 follow + 投影 + 任务三条流(见 mobile/host-bridge.js 的 muxFrames)。
232
279
  // 不带就是设备级视角,只会给工作区基线与全局投影 —— 这里反正只要单会话。
233
- const frames = ctx.apiProxy.events.mux({ rpcId: 'mobile-stream', payload: { sessionId } }, ac.signal);
280
+ // 子会话必须带上父会话(`parentSessionId` + `mode`):宿主对 origin==='subagent'
281
+ // 的会话拿普通地址订阅一律回 agent-busy,表现是「页面打得开、内容不更新」。
282
+ // 地址的拼装与历史那条同源,见 host-bridge/event-synthesis.js 的 sessionAddress。
283
+ const frames = ctx.apiProxy.events.mux({ rpcId: 'mobile-stream', payload: streamAddress }, ac.signal);
234
284
  // 写一帧并把三种结果统一收口:丢增量、需要等 drain、该收尾。
235
285
  // 「需要等 drain」这一步是这次 OOM 的正解 —— 写不动就停手,等底层消化完再继续,
236
286
  // 而不是把帧继续往内存里塞。
@@ -312,9 +362,15 @@ async function handleStream(ctx, req, res) {
312
362
  * 从会话 history 回放 seq > sinceSeq 的事件(升序),逐个经附件解析后写 SSE。
313
363
  * 通过 onDelivered(seq) 回报已交付的最大 seq,供上层去重。
314
364
  */
315
- async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered) {
365
+ async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered, address) {
316
366
  try {
317
- const { value } = unwrap(await callSessions(ctx, 'history', { sessionId, maxMessages: REPLAY_WINDOW }));
367
+ // 子会话的重放也要带父会话(同 historyAddress 的口径),否则宿主回 agent-busy,
368
+ // 这条断线续传静默退化成「后面缺一段」。
369
+ const { value } = unwrap(await callSessions(ctx, 'history', {
370
+ sessionId,
371
+ maxMessages: REPLAY_WINDOW,
372
+ ...(address && address.parentSessionId ? { parentSessionId: address.parentSessionId, mode: address.mode || 'continuable' } : {}),
373
+ }));
318
374
  const events = (value && Array.isArray(value.events)) ? value.events : [];
319
375
  const seqs = events
320
376
  .map((entry) => (entry?.event || entry)?.seq)
@@ -336,7 +392,9 @@ async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered) {
336
392
  // Keep replay data identical to live SSE data: the client reducer consumes
337
393
  // the event's own type (turn/start, assistant/message, etc.).
338
394
  const payload = { type: 'session/event', sessionId, event };
339
- const sent = writeSse(res, event, { payload });
395
+ // 成批灌入:这一节几百条连着写,逐条补满 2048 字节注释等于给一次重连凭空加几 MB
396
+ // (见 writeSse 的 options.burst)。
397
+ const sent = writeSse(res, event, { payload }, { burst: true });
340
398
  if (!sent.ok) break;
341
399
  // 回放是「一次性灌几千条」,最容易把写队列撑爆:写不动就在这里等,别一口气塞完。
342
400
  if (sent.needDrain && !(await waitForDrain(res, signal))) break;