dsh-plugin-mobile-gateway 0.7.4 → 0.7.5

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.
@@ -66,6 +66,10 @@
66
66
 
67
67
  这是最新历史窗口和活动生成状态在同一个上游切点的快照。没有正在生成的 attempt 时省略 `activeAttempt`,客户端应清空临时输出。`hasMore` 为 true 时同时返回 `nextBeforeSeq`,按普通历史请求补更早内容;`replace` 指当前订阅基线需要替换,不能把该窗口误认为完整历史。
68
68
 
69
+ 首屏窗口:网关向 Host `session.follow` 显式传入 `maxMessages: 12`,再将 conversation 事件限制在约 256 KiB 的最新连续后缀。单条最新消息不可拆分,独自超限时仍完整保留;活动 attempt 和 projections 不计入此正文预算,也不裁剪。被预算排除的较早记录通过 `hasMore/nextBeforeSeq` 按原协议分页获取。历史正文窗口可以缩小,但 `cursor` 始终保留 Host 的原子切点。
70
+
71
+ 读取更早页时,为获取当前切点而打开的短暂 follow 仅请求 1 条消息;实际 `session.page` 继续使用客户端请求的 `maxMessages`。此调整同时作用于 iOS 和 Android,不依赖本地历史磁盘缓存。
72
+
69
73
  客户端只处理当前 `subscriptionId`。收到新 snapshot 后更新 `streamId`,清理上一个 stream 的临时状态,并把持久流水位设为 `cursor`,不能使用精简 events 的最大 seq 代替它:精简视图可能隐藏了末尾的系统事件。
70
74
 
71
75
  未发送 `assistantStream: true` 的连接仍接收持久 `event`,不会收到伪装成持久事件的 token。control 连接不能订阅对话流。
@@ -104,7 +104,9 @@ export function createDshHostAdapter(typertGateway) {
104
104
  const history = async (payload, signal) => {
105
105
  const request = {
106
106
  address: { kind: 'session', sessionId: payload.sessionId },
107
- ...(payload.maxMessages === undefined ? {} : { maxMessages: payload.maxMessages }),
107
+ // Older-page requests need only the opening cursor/projections, not another large latest page.
108
+ ...(payload.beforeSeq !== undefined ? { maxMessages: 1 }
109
+ : payload.maxMessages === undefined ? {} : { maxMessages: payload.maxMessages }),
108
110
  }
109
111
  const snapshot = await sessionSnapshot(request, signal)
110
112
  let events = snapshot.events
@@ -270,7 +272,8 @@ export function createDshHostAdapter(typertGateway) {
270
272
  openSessionStream(sessionId, signal) {
271
273
  return typertGateway.stream({
272
274
  namespace: 'session', method: 'follow',
273
- args: requestArgs({ address: { kind: 'session', sessionId }, assistantStream: true }),
275
+ // Bound the opening window at the Host; older records remain available via session.page.
276
+ args: requestArgs({ address: { kind: 'session', sessionId }, maxMessages: 12, assistantStream: true }),
274
277
  signal,
275
278
  })
276
279
  },
package/lib/index.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { stringifyWireFrame } from './wire-json.mjs'
1
2
  // dsh-plugin-mobile-gateway — persistent Host plugin.
2
3
  //
3
4
  // Two directions:
@@ -278,6 +279,7 @@ function buildWireEvent(session, event) {
278
279
  type: 'user/message',
279
280
  text: textOf(d.content || []),
280
281
  source: d.source && d.source.kind,
282
+ ...(typeof d.id === 'string' && d.id ? { raw: { id: d.id } } : {}),
281
283
  ...(images.length ? { images } : {}),
282
284
  },
283
285
  })
@@ -562,6 +564,13 @@ async function proxyQuery(api, type, method, payload, signal) {
562
564
  }
563
565
  }
564
566
 
567
+ // Projection queries must keep the caller's request identity on failure.
568
+ // They need the opening cut and projections, not the full conversation body.
569
+ async function querySessionProjection(api, requestType, sessionId) {
570
+ const history = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId, maxMessages: 1 })
571
+ return history.kind === 'error' ? { ...history, requestType, sessionId } : history
572
+ }
573
+
565
574
  function fileTransferError(code, message, requestType, sessionId) {
566
575
  return {
567
576
  kind: 'error',
@@ -1025,6 +1034,9 @@ async function mutateGoal(api, type, method, payload) {
1025
1034
  // and truncates oversized tool-result text.
1026
1035
  // ---------------------------------------------------------------------------
1027
1036
  const HISTORY_DEFAULT_MAX_BYTES = 4 * 1024 * 1024 // 4 MiB per frame
1037
+ // Opening snapshots should be readable before older history is requested.
1038
+ // Keep the newest indivisible message even when it alone exceeds this budget.
1039
+ const HISTORY_OPENING_MAX_BYTES = 256 * 1024
1028
1040
  const HISTORY_TOOL_RESULT_MAX_CHARS = 2000 // per text block in conversation view
1029
1041
 
1030
1042
  function eventBytes(event) {
@@ -1624,7 +1636,9 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
1624
1636
  }
1625
1637
  const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
1626
1638
  if (sessionId) {
1627
- const hist = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId })
1639
+ out.sessionId = sessionId
1640
+ const hist = await querySessionProjection(api, msg.type, sessionId)
1641
+ if (hist.kind === 'error') return hist
1628
1642
  if (hist.kind === 'history' && hist.projections && hist.projections.values) {
1629
1643
  out.sessionPermissions = hist.projections.values.permissions || null
1630
1644
  }
@@ -1672,7 +1686,7 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
1672
1686
  if (msg.type === 'context-usage') {
1673
1687
  const sessionId = requireSessionId(msg)
1674
1688
  if (sessionId.error) return sessionId.error
1675
- const hist = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId: sessionId.value })
1689
+ const hist = await querySessionProjection(api, msg.type, sessionId.value)
1676
1690
  if (hist.kind !== 'history') return hist
1677
1691
  const values = (hist.projections && hist.projections.values) || {}
1678
1692
  return {
@@ -1686,7 +1700,7 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
1686
1700
  if (msg.type === 'session-stats') {
1687
1701
  const sessionId = requireSessionId(msg)
1688
1702
  if (sessionId.error) return sessionId.error
1689
- const hist = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId: sessionId.value })
1703
+ const hist = await querySessionProjection(api, msg.type, sessionId.value)
1690
1704
  if (hist.kind !== 'history') return hist
1691
1705
  const values = (hist.projections && hist.projections.values) || {}
1692
1706
  return {
@@ -2179,7 +2193,7 @@ const plugin = {
2179
2193
  })
2180
2194
 
2181
2195
  const broadcastInteractionFrame = (frame) => {
2182
- const wire = JSON.stringify(frame)
2196
+ const wire = stringifyWireFrame(frame)
2183
2197
  for (const client of clients) {
2184
2198
  if (client.mobileChannel === 'conversation') continue
2185
2199
  if (client.filterSessionId && client.filterSessionId !== frame.sessionId) continue
@@ -2191,7 +2205,7 @@ const plugin = {
2191
2205
  // subscribed to one conversation. Subscriptions only scope the heavier
2192
2206
  // conversation and interaction streams.
2193
2207
  const broadcastSessionMetadataFrame = (frame) => {
2194
- const wire = JSON.stringify(frame)
2208
+ const wire = stringifyWireFrame(frame)
2195
2209
  for (const client of clients) {
2196
2210
  if (client.mobileChannel === 'conversation') continue
2197
2211
  if (client.readyState === 1) client.send(wire)
@@ -2204,12 +2218,12 @@ const plugin = {
2204
2218
  let approvalCount = 0
2205
2219
  for (const pending of pendingQuestions.values()) {
2206
2220
  if (ws.filterSessionId && ws.filterSessionId !== pending.sessionId) continue
2207
- ws.send(JSON.stringify(questionFrameFor(pending.rpcId, pending, true)))
2221
+ ws.send(stringifyWireFrame(questionFrameFor(pending.rpcId, pending, true)))
2208
2222
  questionCount += 1
2209
2223
  }
2210
2224
  for (const pending of pendingApprovals.values()) {
2211
2225
  if (ws.filterSessionId && ws.filterSessionId !== pending.sessionId) continue
2212
- ws.send(JSON.stringify(approvalFrameFor(pending.rpcId, pending, true)))
2226
+ ws.send(stringifyWireFrame(approvalFrameFor(pending.rpcId, pending, true)))
2213
2227
  approvalCount += 1
2214
2228
  }
2215
2229
  log(`interaction replay: trigger=${trigger} filtered=${Boolean(ws.filterSessionId)} questions=${questionCount} approvals=${approvalCount}`)
@@ -2565,12 +2579,12 @@ const plugin = {
2565
2579
  }
2566
2580
 
2567
2581
  const sendFrame = frame => {
2568
- if (ws.readyState === 1) ws.send(JSON.stringify(frame))
2582
+ if (ws.readyState === 1) ws.send(stringifyWireFrame(frame))
2569
2583
  }
2570
2584
  ws.sessionFollower = createSessionFollower(api, {
2571
2585
  onFrame(frame, context) {
2572
2586
  if (frame.type === 'snapshot') {
2573
- sendFrame({ ...historyPage(frame.history, { sessionId: context.sessionId, view: 'conversation' }),
2587
+ sendFrame({ ...historyPage(frame.history, { sessionId: context.sessionId, view: 'conversation', maxBytes: HISTORY_OPENING_MAX_BYTES }),
2574
2588
  kind: 'session-snapshot', ...context, assistantStream: frame.assistantStream, replace: true })
2575
2589
  } else if (frame.type === 'event') {
2576
2590
  sendFrame({ ...buildWireEvent({ id: context.sessionId }, frame.event), ...context })
@@ -2589,19 +2603,19 @@ const plugin = {
2589
2603
  try {
2590
2604
  msg = JSON.parse(data.toString())
2591
2605
  } catch (error) {
2592
- ws.send(JSON.stringify({ kind: 'error', message: 'invalid json' }))
2606
+ ws.send(stringifyWireFrame({ kind: 'error', message: 'invalid json' }))
2593
2607
  return
2594
2608
  }
2595
2609
  if (!msg || typeof msg.type !== 'string') return
2596
2610
  const conversationRequest = ['message', 'history', 'subscribe', 'unsubscribe'].includes(msg.type)
2597
2611
  if ((ws.mobileChannel === 'control' && conversationRequest) ||
2598
2612
  (ws.mobileChannel === 'conversation' && !conversationRequest && msg.type !== 'ping')) {
2599
- ws.send(JSON.stringify({ kind: 'error', code: 'wrong-channel', requestType: msg.type, message: 'Request belongs to the other mobile channel' }))
2613
+ ws.send(stringifyWireFrame({ kind: 'error', code: 'wrong-channel', requestType: msg.type, message: 'Request belongs to the other mobile channel' }))
2600
2614
  return
2601
2615
  }
2602
2616
 
2603
2617
  if (msg.type === 'ping') {
2604
- ws.send(JSON.stringify({ kind: 'pong', at: Date.now() }))
2618
+ ws.send(stringifyWireFrame({ kind: 'pong', at: Date.now() }))
2605
2619
  } else if (msg.type === 'subscribe') {
2606
2620
  if (msg.assistantStream !== undefined && typeof msg.assistantStream !== 'boolean') {
2607
2621
  sendFrame({ kind: 'error', code: 'bad-request', requestType: 'subscribe', message: 'assistantStream must be a boolean' })
@@ -2631,20 +2645,20 @@ const plugin = {
2631
2645
  sendFrame({ kind: 'subscribed', sessionId: null, assistantStream: false })
2632
2646
  } else if (msg.type === 'question-answer' || msg.type === 'question-cancel') {
2633
2647
  respondToQuestion(msg, msg.type === 'question-cancel').then((frame) => {
2634
- if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
2648
+ if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
2635
2649
  })
2636
2650
  } else if (msg.type === 'approval-response') {
2637
2651
  respondToApproval(msg).then((frame) => {
2638
- if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
2652
+ if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
2639
2653
  })
2640
2654
  } else if (msg.type === 'file-list' || msg.type === 'file-download-open' ||
2641
2655
  msg.type === 'file-download-read' || msg.type === 'file-download-cancel') {
2642
2656
  fileTransfers.handle(ws, msg).then((frame) => {
2643
- if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
2657
+ if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
2644
2658
  })
2645
2659
  } else if (msg.type === 'message') {
2646
2660
  admitMessage(api, msg).then((frame) => {
2647
- if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
2661
+ if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
2648
2662
  })
2649
2663
  } else if (msg.type === 'workspaces' || msg.type === 'sessions' || msg.type === 'history' || msg.type === 'attachment' ||
2650
2664
  msg.type === 'search' || msg.type === 'host' || msg.type === 'directories' || msg.type === 'directory-create' ||
@@ -2658,10 +2672,10 @@ const plugin = {
2658
2672
  msg.type === 'tasks' || msg.type === 'goal' || msg.type === 'goal-edit' ||
2659
2673
  msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear') {
2660
2674
  handleQuery(api, host, agentDefaultModel, msg).then((frame) => {
2661
- if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
2675
+ if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
2662
2676
  })
2663
2677
  } else {
2664
- ws.send(JSON.stringify({ kind: 'error', message: 'unknown message type: ' + msg.type }))
2678
+ ws.send(stringifyWireFrame({ kind: 'error', message: 'unknown message type: ' + msg.type }))
2665
2679
  }
2666
2680
  })
2667
2681
 
@@ -2678,14 +2692,14 @@ const plugin = {
2678
2692
 
2679
2693
  log(`client connected (id=${id}, total=${clients.size})${device ? ' device=' + device.name : ''}`)
2680
2694
  if (paired) {
2681
- ws.send(JSON.stringify({
2695
+ ws.send(stringifyWireFrame({
2682
2696
  kind: 'paired',
2683
2697
  token: paired.token,
2684
2698
  device: paired.device,
2685
2699
  ...gatewayIdentity,
2686
2700
  }))
2687
2701
  }
2688
- ws.send(JSON.stringify({
2702
+ ws.send(stringifyWireFrame({
2689
2703
  kind: 'hello',
2690
2704
  ...gatewayIdentity,
2691
2705
  protocol: 3,
@@ -2713,10 +2727,10 @@ const plugin = {
2713
2727
  ...(device ? { device: { id: device.id, name: device.name } } : {}),
2714
2728
  }))
2715
2729
  if (ws.mobileChannel !== 'conversation' && archivedSessionIds !== null) {
2716
- ws.send(JSON.stringify({ kind: 'session-archives', archivedSessionIds }))
2730
+ ws.send(stringifyWireFrame({ kind: 'session-archives', archivedSessionIds }))
2717
2731
  }
2718
2732
  if (ws.mobileChannel !== 'conversation' && sessionQueues !== null) {
2719
- ws.send(JSON.stringify({ kind: 'session-queues', queues: Object.fromEntries(sessionQueues) }))
2733
+ ws.send(stringifyWireFrame({ kind: 'session-queues', queues: Object.fromEntries(sessionQueues) }))
2720
2734
  }
2721
2735
  if (ws.mobileChannel !== 'conversation') {
2722
2736
  sendFrame({ kind: 'projection-baseline', projections: sessionProjectionBaselines })
@@ -2788,7 +2802,7 @@ const plugin = {
2788
2802
  ...(event.data.source ? { source: event.data.source } : {}),
2789
2803
  })
2790
2804
  }
2791
- const payload = JSON.stringify(wire)
2805
+ const payload = stringifyWireFrame(wire)
2792
2806
  for (const client of clients) {
2793
2807
  if (client.mobileChannel === 'control' || client.sessionFollowerActive) continue
2794
2808
  if (client.filterSessionId && client.filterSessionId !== String(session.id)) continue
@@ -118,6 +118,8 @@ export function createSessionFollower(api, { onFrame, onError, retryMs = 1000 })
118
118
  } catch (error) {
119
119
  if (current !== state || signal.aborted) break
120
120
  permanent = ['unsupported-session-format', 'session/not-found', 'gateway/bad-request'].includes(error.code)
121
+ || (error.code === 'SESSION_QUERY_PERSISTENCE_FAILED'
122
+ && /refuses this format .*Session/.test(error.message || ''))
121
123
  onError(error, { ...context, retrying: !permanent })
122
124
  } finally {
123
125
  // Abort before return: the producer may be waiting for another event.
@@ -0,0 +1,7 @@
1
+ // Host 的摘要截断可能留下单个 UTF-16 代理项。JSON.stringify 会将其转义,
2
+ // 但 Swift/Foundation 仍拒绝整帧;在传输边界统一输出有效 Unicode。
3
+ // 不修改原对象、历史正文、有效 emoji 或字面量反斜杠转义文本。
4
+ export function stringifyWireFrame(frame) {
5
+ return JSON.stringify(frame, (_key, value) =>
6
+ typeof value === 'string' ? value.replace(/[\uD800-\uDFFF]/gu, '\uFFFD') : value)
7
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-mobile-gateway",
3
- "version": "0.7.4",
4
- "description": "Updated for dsh 0.1.5-rc.1/2 with dedicated realtime streams, reconnect recovery, history format and cursor validation, and fixes for command attachments and session state sync",
3
+ "version": "0.7.5",
4
+ "description": "Mobile gateway for dsh 0.1.5-rc.1/2 with smaller opening history snapshots, accurate query error reporting, Unicode-safe wire frames, and preserved user message IDs",
5
5
  "main": "lib/index.mjs",
6
6
  "files": [
7
7
  "bin",
@@ -26,7 +26,7 @@
26
26
  },
27
27
  "bin": "bin/setup-ip.mjs",
28
28
  "scripts": {
29
- "test": "node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/session-follower.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/multi-gateway.test.mjs"
29
+ "test": "node test/wire-json.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/session-follower.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/multi-gateway.test.mjs"
30
30
  },
31
31
  "exports": {
32
32
  ".": "./lib/index.mjs",