dsh-plugin-mobile-gateway 0.7.4 → 0.7.6
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.
- package/PROTOCOL.md +43 -0
- package/README.md +2 -0
- package/docs/dsh-0.1.6-alpha.1-compatibility-audit.md +122 -0
- package/docs/dsh-rc2-mobile-integration.md +4 -0
- package/docs/session-agent-preset-app-integration.md +93 -0
- package/lib/dsh-host-adapter.mjs +59 -6
- package/lib/index.mjs +79 -24
- package/lib/session-follower.mjs +2 -0
- package/lib/wire-json.mjs +7 -0
- package/package.json +3 -3
package/PROTOCOL.md
CHANGED
|
@@ -956,6 +956,8 @@ WebUI 中的“任务”与“进行中的目标”分别对应 DSH 的 `todos`
|
|
|
956
956
|
| type | 参数 | 说明 |
|
|
957
957
|
|---|---|---|
|
|
958
958
|
| `agent-presets` | — | preset 名册(含 `isDefault` 标记) |
|
|
959
|
+
| `session-agent-preset` | `sessionId`, `requestId?` | 读取当前会话的 `agentPreset` 和 `locked` |
|
|
960
|
+
| `select-agent-preset` | `sessionId`, `agentPreset`, `requestId?` | 在首次对话前修改当前会话模式,保持 session ID |
|
|
959
961
|
| `defaults` | — | 读取默认 agent 预设 + 默认权限 |
|
|
960
962
|
| `set-default` | `target`(agent-preset\|permission), `value` | 修改默认预设/默认权限(全局) |
|
|
961
963
|
|
|
@@ -967,6 +969,47 @@ WebUI 中的“任务”与“进行中的目标”分别对应 DSH 的 `todos`
|
|
|
967
969
|
→ { "kind": "set-default", "target": "agent-preset", "value": "minimal", "applied": true }
|
|
968
970
|
```
|
|
969
971
|
|
|
972
|
+
### 当前会话模式选择
|
|
973
|
+
|
|
974
|
+
`hello.capabilities` 包含 `session-agent-preset` 时,可以进入已创建的空白会话后选模式。
|
|
975
|
+
上述两个新增请求走 Control 通道,成功和错误响应均携带 `sessionId` 与原始可选 `requestId`。
|
|
976
|
+
|
|
977
|
+
```json
|
|
978
|
+
{ "type": "session-agent-preset", "sessionId": "s1", "requestId": "get-mode-1" }
|
|
979
|
+
→ { "kind": "session-agent-preset", "sessionId": "s1", "agentPreset": "standard", "locked": false, "requestId": "get-mode-1" }
|
|
980
|
+
|
|
981
|
+
{ "type": "select-agent-preset", "sessionId": "s1", "agentPreset": "minimal", "requestId": "set-mode-1" }
|
|
982
|
+
→ { "kind": "select-agent-preset", "sessionId": "s1", "agentPreset": "minimal", "requestId": "set-mode-1" }
|
|
983
|
+
```
|
|
984
|
+
|
|
985
|
+
候选项使用 `agent-presets.presets`:显示 `name`(缺失时使用 `id`),提交 `id`;带 `broken` 的项不可选。
|
|
986
|
+
当前值以 `session-agent-preset.agentPreset` 为准,不能用全局 `isDefault` 或 Session header 代替。
|
|
987
|
+
Host 在同一 Session 内重组 Agent 并持久化选择,不通过修改全局默认模式实现。
|
|
988
|
+
|
|
989
|
+
首次消息被 gateway 接受后即锁定;已记录的用户输入或 `turn/start` 也会锁定。
|
|
990
|
+
gateway 对同一 Session 的模式切换与消息提交串行处理,Host 同时保留自身的对话边界检查。
|
|
991
|
+
未知/损坏的模式由 Host 拒绝,之前的模式保持;停止或完成对话不会恢复可修改状态。
|
|
992
|
+
如果状态投影缺失,返回 `agent-preset/unavailable`,客户端不得把未知状态当作可修改。
|
|
993
|
+
|
|
994
|
+
```json
|
|
995
|
+
{ "kind": "error", "requestType": "select-agent-preset", "sessionId": "s1", "requestId": "set-mode-2", "code": "agent-preset/locked", "message": "session has already started; its agent preset is fixed" }
|
|
996
|
+
```
|
|
997
|
+
|
|
998
|
+
Control/旧单连接接收全局模式状态增量,不受所订阅会话过滤;只合并实际携带的字段:
|
|
999
|
+
|
|
1000
|
+
```json
|
|
1001
|
+
{ "kind": "session-agent-preset-updated", "sessionId": "s1", "agentPreset": "minimal", "seq": 12, "time": 1000 }
|
|
1002
|
+
{ "kind": "session-agent-preset-updated", "sessionId": "s1", "locked": true, "seq": 13, "time": 1100 }
|
|
1003
|
+
```
|
|
1004
|
+
|
|
1005
|
+
模式变化后,App 应丢弃该会话的命令/技能缓存,重新查询;对话通道的 `agent-preset/selected` 事件也保留 `event.agentPreset`。
|
|
1006
|
+
增量没有连接基线,进入会话及重连后必须重查 `session-agent-preset`;收到锁定通知或错误后锁定控件。
|
|
1007
|
+
发送首条消息和切换模式期间暂时禁用操作,等待切换成功再发送消息;不要用旧查询响应覆盖后到的锁定通知。
|
|
1008
|
+
|
|
1009
|
+
`session-create` 和不带 `sessionId` 的首条 `message` 也支持可选 `agentPreset`。
|
|
1010
|
+
已带 `sessionId` 的 `message` 不接受该字段;应提前调用 `select-agent-preset`。
|
|
1011
|
+
详细 App 状态与验收步骤见 [会话模式接入说明](docs/session-agent-preset-app-integration.md)。
|
|
1012
|
+
|
|
970
1013
|
---
|
|
971
1014
|
|
|
972
1015
|
## 11. 分支(fork)
|
package/README.md
CHANGED
|
@@ -28,6 +28,8 @@ DeepSeek Harness 的设备鉴权移动网关,支持会话与实时事件、排
|
|
|
28
28
|
- Linux 服务器公网:`wss://<公网 IP>/ws/mobile`
|
|
29
29
|
- 协议文档:[PROTOCOL.md](PROTOCOL.md)
|
|
30
30
|
|
|
31
|
+
当前源码新增会话模式选择:App 创建空白 Session 后,可查询并修改该 Session 的 Agent preset;首次对话后锁定。通过 `session-agent-preset` 能力发现接入,详见 [App 接入说明](docs/session-agent-preset-app-integration.md)。App 界面需按此说明接入,本次源码尚未发布。
|
|
32
|
+
|
|
31
33
|
## 多网关第一阶段(当前源码)
|
|
32
34
|
|
|
33
35
|
插件提供稳定 `gatewayId`、可配置 `gatewayName`、配对候选地址列表,以及可持久化的“关闭 / 临时开启 / 常驻开启”运行模式。一个 App 可以分别配对不同机器上的网关;客户端多网关管理仍需按 [App 对接说明](docs/multi-gateway-app-integration.md) 实现。本次源码尚未发布新的 npm 版本。
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# DSH 0.1.6-alpha.1 协议兼容性审计
|
|
2
|
+
|
|
3
|
+
核对日期:2026-09-16。
|
|
4
|
+
|
|
5
|
+
## 结论
|
|
6
|
+
|
|
7
|
+
当前 gateway `0.7.5` **不能直接声明完整兼容 DSH `0.1.6-alpha.1`**。确定的主要阻断是权限选择协议拆分:新版会话投影不再包含候选列表,而 gateway 的权限菜单仍依赖该列表,导致读取菜单和通过菜单切换权限均失败。
|
|
8
|
+
|
|
9
|
+
核心会话、消息提交、历史分页、Assistant 独立流、普通问答和人工审批没有发现本次升级引入的接口阻断。另有默认模式的条件性语义差异、SSH 文件访问缺口、版本误报及新增事件元数据丢失。
|
|
10
|
+
|
|
11
|
+
本次仅增加审计文档;没有修改生产代码、升级本机 DSH 或迁移用户会话。
|
|
12
|
+
|
|
13
|
+
## 基线与验证范围
|
|
14
|
+
|
|
15
|
+
- gateway:提交 `a92a039332bcfa4a1fadc55888e6b7a7e80dfd3d`,版本 `0.7.5`;开始审计时工作区干净。
|
|
16
|
+
- 上游:对比官方 `dsh-v0.1.5-rc.2` 与 `dsh-v0.1.6-alpha.1` 两个 tag 的完整源码。
|
|
17
|
+
- 发布包:分别下载两版 Session Controller、Workspace Controller、Settings Controller、Commands、Goal、Agent Presets、LLM 共七个包,比较生成的 `typert.host.js`;另外检查新版 Permission Presets 发布包。
|
|
18
|
+
- 七组包的旧版 55 个 Remote 端点没有删除;新版增加 `workspace/unarchiveSession`。现有入参 schema 唯一差异为 `session/updateQueue` 的图片块增加可选 `offloaded: true`,没有要求旧调用增加必填参数。此数量不代表 DSH 全部端点;新 Permission Presets 包另提供 `permissionPresets/catalog`。
|
|
19
|
+
- 用现有 Host Adapter 测试的真实调用,附加新版发布描述符的参数名与 schema 校验:18 次调用、14 个不同端点通过。
|
|
20
|
+
- `npm test` 全部通过,包括 125 项 gateway dispatch 检查、认证配对、LAN、多网关 11 项检查,以及 Host Adapter、Session Follower、Unicode wire、setup 测试。首次运行被沙箱禁止监听端口;允许本地监听后完整运行成功。
|
|
21
|
+
- 使用当前生产 `handleQuery()` 配合新版权限投影样例,复现下述权限菜单问题。
|
|
22
|
+
|
|
23
|
+
验证边界:没有执行新版真实 Host 与手机全流程联调。发布描述符输入验证及旧测试通过,不代表业务行为完整兼容;权限问题恰好说明旧测试夹具可能掩盖新版差异。
|
|
24
|
+
|
|
25
|
+
官方依据:[发布说明](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.1.6-alpha.1)、[版本比较](https://github.com/deepseek-ai/deepseek-harness/compare/dsh-v0.1.5-rc.2...dsh-v0.1.6-alpha.1)。
|
|
26
|
+
|
|
27
|
+
## 1. 必须适配:权限目录从会话投影拆出
|
|
28
|
+
|
|
29
|
+
旧版 `projections.values.permissions`:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{"options":[{"value":"workspace-write","name":"Workspace Write"}],"currentValue":"workspace-write"}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
新版会话投影只保留:
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{"currentValue":"workspace-write"}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
候选项改从新的进程级目录读取:
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
typertGateway.invoke({
|
|
45
|
+
namespace: 'permissionPresets',
|
|
46
|
+
method: 'catalog',
|
|
47
|
+
args: {},
|
|
48
|
+
})
|
|
49
|
+
// 返回 { options: [{ value, name, description? }] }
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
源码在 `packages/interaction/permission-presets/src/index.ts` 注册的投影视图明确只有 `currentValue`;这不是单纯的类型重命名。新版 `auto` 由运行中的 Auto review 插件动态贡献,不属于默认权限设置中的静态候选表。`custom` 是派生的当前状态,不是可切换候选项。
|
|
53
|
+
|
|
54
|
+
gateway 受影响位置:
|
|
55
|
+
|
|
56
|
+
- `lib/index.mjs:1211` 的 `loadCommandOptions()` 强制要求 `permissions.options`。
|
|
57
|
+
- `lib/index.mjs:1245` 的 `selectCommandOption()` 先查询上述菜单,因此写入前就失败。
|
|
58
|
+
- `lib/index.mjs:1629` 的旧 `permission-options` 接口直接透传投影,新版返回值不再包含 `sessionPermissions.options`;它读取的 settings namespace 也无法补全动态 Auto 候选。
|
|
59
|
+
|
|
60
|
+
生产函数复现结果:
|
|
61
|
+
|
|
62
|
+
| 请求 | 当前结果 |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `command-options`,`command=permission` | `command-options-unavailable` |
|
|
65
|
+
| `command-select`,`command=permission` | 同样失败,尚未执行写入;还沿用 `requestType=command-options` |
|
|
66
|
+
| `permission-options` | 成功,但 `sessionPermissions` 只剩 `currentValue` |
|
|
67
|
+
| 直接 `permission`,明确传入有效名称 | 可到达原有 `/permission` 执行入口;没有被菜单前置检查阻断 |
|
|
68
|
+
|
|
69
|
+
建议:Host Adapter 增加权限目录查询,将 catalog 的候选项与会话投影的 `currentValue` 合成现有移动菜单和兼容响应。保留 `/permission` 写入路径;动态目录需在使用前刷新,若缓存则处理 `permission-presets/catalog-changed`。会话 Auto 选项不能写成全局默认权限。
|
|
70
|
+
|
|
71
|
+
这可以在 gateway 内适配,现有 `command-options` / `command-select` 移动消息形状可继续使用。若 App 自行解析历史中的权限投影,则也需要核对其是否依赖旧 `options` 字段。
|
|
72
|
+
|
|
73
|
+
依据:[权限类型变更](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.1.6-alpha.1/packages/interaction/permission-presets/src/types.ts)、[权限目录与投影实现](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.1.6-alpha.1/packages/interaction/permission-presets/src/index.ts)。
|
|
74
|
+
|
|
75
|
+
## 2. 条件性不一致与元数据缺口
|
|
76
|
+
|
|
77
|
+
| 项目 | 新版变化及当前影响 | 建议 |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| 默认 Agent 模式 | 增加 `modeSelectionEnabled`。为 false 时,Host 对未指定 preset 的新会话使用配置默认值,忽略保存的用户默认值。gateway 的 `defaults` 仍返回 settings 中保存的 `default`;`set-default` 返回 `applied: true` 也不代表后续新会话采用该值 | 查询有效默认值时结合 `agentPresets/list` 的 `isDefault` 和 `modeSelectionEnabled`;区分已保存与实际生效。`agent-presets` 原始响应已经透传新字段 |
|
|
80
|
+
| SSH 工作区 | 新版文件/进程可通过 SSH provider 操作远端。gateway `resolveSessionRoot()`、文件列表、下载及目录操作仍使用本机 `node:fs` | 启用 SSH provider 的部署不应宣称文件功能兼容;接入 Host 文件能力或明确禁用。远端路径本机不存在会失败,同名本机路径存在时可能访问错误目录。普通本地工作区不受此项影响 |
|
|
81
|
+
| 宿主版本误报 | `lib/dsh-host-adapter.mjs:16` 将 `DSH_VERSION` 写死为 `0.1.5-rc.2`;`host.describe` 和 `hello` 均使用该值 | 改为真实宿主版本或明确的兼容目标字段。这不是运行时版本拒绝逻辑,但会误导诊断/版本判断 |
|
|
82
|
+
| `image/offload` | 新增持久事件,`data.targets` 包含消息 `seq` 和 `imageIndexes`。gateway 接受其真实序号,历史保留原始数据;实时 `buildWireEvent()` 的默认分支只发事件类型,丢弃 targets | 基础聊天不因未知类型直接中断,但实时 offload 语义缺失;需要展示模型实际保留的图片上下文时补齐映射 |
|
|
83
|
+
| Auto review 拒绝原因 | `tool/result.data.error` 增加可选 `reason`,位于模型消息之外。实时映射只保留 `isError` 和模型内容 preview,丢失结构化错误名、代码和原始原因 | 保留必要的 error 元信息。历史仍保留这些数据,实时展示存在缺口 |
|
|
84
|
+
|
|
85
|
+
Auto review 使用 `tools/pre-execute` 的自动决策入口。普通 `approval/request` / `user-questions/request` 的签名及回答形状没有改变,不能据此把所有审批流判定为失效。
|
|
86
|
+
|
|
87
|
+
依据:[默认模式选择策略](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.1.6-alpha.1/packages/preset/agent-presets/src/index.ts)、[SSH 文件系统提供方](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.1.6-alpha.1/packages/ssh/fs-ssh/README.zh.md)、[image/offload 定义](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.1.6-alpha.1/packages/compaction/compaction-image-offload/src/projection.ts)、[Session 工具错误定义](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.1.6-alpha.1/packages/core/session/src/types.ts)、[Auto review 实现](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.1.6-alpha.1/packages/experimental/auto-review/src/index.ts)。
|
|
88
|
+
|
|
89
|
+
## 3. 保持兼容或仅增加能力的部分
|
|
90
|
+
|
|
91
|
+
| 范围 | 核对结果 |
|
|
92
|
+
|---|---|
|
|
93
|
+
| 会话格式 | `SESSION_FORMAT_VERSION` 仍为 3。本次没有 rc.2 那次跨格式的历史游标迁移;不要把 DSH 软件版本、Session 格式和移动握手值混为一谈 |
|
|
94
|
+
| 会话读取和控制 | `session/list` 仍使用 `{ _request: {} }`;`follow/page/control` 入参保持不变。Assistant 的快照及 `start/chunk/end` 协定保持不变 |
|
|
95
|
+
| 消息、命令、队列 | prompt 原有文字和图片结构仍有效;命令仍使用 `submittedAttachments`;队列图片块新增可选 `offloaded`,现有文字编辑请求不需修改 |
|
|
96
|
+
| Session fork | 参数不变,切点改为所选边界 `seq + 1`,不再继续带入下次 `turn/start` 前的后续输入/设置。这是上游行为修复,gateway 原有 atSeq 转发无需改协议 |
|
|
97
|
+
| 工作区归档 | 原有 archive 和归档集合推送保持;增加 `workspace/unarchiveSession({ request: { sessionId } })`。gateway 尚未提供恢复归档请求,但从 Web 恢复后,可通过已有 workspace stream 同步归档集合 |
|
|
98
|
+
| Skill / 命令目录 | Skill 增加可选 `path`,CommandDescriptor 增加可选 `definitionId`;当前按名字执行不受影响,移动组合菜单没有保留这两个新元信息 |
|
|
99
|
+
| 插件生命周期 | `agent/session-start` 移除,`agent/created` 改为等待完成的异步串行初始化;gateway 不监听这两个事件,不需要修改该 hook |
|
|
100
|
+
| 同步 Session 历史 API | `snapshotEvents/eventAt/ownEvents` 是弃用,非本版本全部删除。gateway 通过 Remote follow/page 读取,不直接依赖这些同步 API |
|
|
101
|
+
| Host 服务及 Web UI | `typertGateway.invoke/stream`、`agentDefaultModel`、`webServer.register/registerUpgrade` 及当前 sidebar/footer、shell/overlay 集成入口未发现阻断变化 |
|
|
102
|
+
|
|
103
|
+
依据:[Session Remote 类型](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.1.6-alpha.1/packages/api/session-controller/src/types.ts)、[fork 实现](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.1.6-alpha.1/packages/api/session-controller/src/commands.ts)、[Workspace Controller](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.1.6-alpha.1/packages/api/workspace-controller/src/index.ts)、[Agent 生命周期](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.1.6-alpha.1/packages/core/agent/src/runtime-types.ts)。
|
|
104
|
+
|
|
105
|
+
## 4. 模型协议和部署配置变化
|
|
106
|
+
|
|
107
|
+
DeepSeek 默认由 Chat Completions 改为 Messages,并增加 Files API 图片复用。这发生在 Host 到模型服务之间;gateway 使用 Host 的统一 Session/LLM 数据模型,不需要把移动 WebSocket 改成 Anthropic Messages。
|
|
108
|
+
|
|
109
|
+
显式配置旧官方根地址的部署,需要移除覆盖或使用 `https://api.deepseek.com/anthropic`;自定义 API 地址会保留,因此要核对其与所选协议是否匹配。否则可能出现手机正常连接、模型请求失败的情况。本次没有读取用户凭据或验证实际模型服务配置。
|
|
110
|
+
|
|
111
|
+
PTC 包/服务统一改为 `ptc-runtime`、工作流执行器改为 `workflow-ptc`、E2B 移除、Ralph 默认关闭,以及 Sandbox/Shell 异步接口变化,均需使用相关自定义插件或配置的部署另行适配。当前 gateway 没有这些直接依赖,bundle patch 也没有引用这些旧名称。终端、MCP resources、Browser/Computer Use 属于新增能力,不能据此认定当前移动端已经支持。
|
|
112
|
+
|
|
113
|
+
依据:[DeepSeek 协议默认配置](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.1.6-alpha.1/packages/llm/llm-deepseek/src/config.ts)、[官方发布说明](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.1.6-alpha.1)。
|
|
114
|
+
|
|
115
|
+
## 建议顺序
|
|
116
|
+
|
|
117
|
+
1. 优先修复权限目录适配,覆盖新版只有 `currentValue` 的投影、动态 Auto、派生 custom、菜单查询与选择及兼容接口。
|
|
118
|
+
2. 修正宿主版本信息和默认 Agent 模式的有效值语义。
|
|
119
|
+
3. 本地工作区完成真实 Host 与 App 的消息、生成中重连、历史分页、fork、权限切换、问答/审批、文件下载及归档同步联调。
|
|
120
|
+
4. 单独决定是否接入恢复归档、SSH 文件访问、image/offload 展示和 Auto review 错误详情。
|
|
121
|
+
|
|
122
|
+
临时证据目录:`/private/tmp/dsh-alpha1-audit/`,含双版本源码、npm 包、`check-contract.mjs`、`schema-deltas.json`、`reproduce-permissions.mjs`、`permission-reproduction.json` 和完整测试日志。临时探针没有改动仓库生产实现或原有测试。
|
|
@@ -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 连接不能订阅对话流。
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# App 会话运行模式接入说明
|
|
2
|
+
|
|
3
|
+
## 实现范围
|
|
4
|
+
|
|
5
|
+
gateway 已增加同一 Session 内的 Agent 模式查询、切换、锁定与通知。App 界面尚未修改;本次源码没有发布 npm,也没有升级本机 DSH。
|
|
6
|
+
|
|
7
|
+
用户流程:创建空白会话 → 进入会话页 → 选择已有模式,可重复修改 → 发送第一条消息 → 模式固定,后续完成、停止、重连均不可再次修改。
|
|
8
|
+
|
|
9
|
+
这里的模式是 Agent preset,例如 `standard`、`minimal`,与模型选择、权限模式及 gateway 的常驻/临时运行模式相互独立。
|
|
10
|
+
|
|
11
|
+
## 接入顺序
|
|
12
|
+
|
|
13
|
+
1. 检查 `hello.capabilities` 是否包含 `session-agent-preset`。没有则不提供当前会话切换入口。
|
|
14
|
+
2. 用现有 `session-create` 创建会话,收到 `session-created` 后以其 `sessionId` 进入会话页。
|
|
15
|
+
3. 在 Control 通道查询 `agent-presets` 和 `session-agent-preset`;查询未完成期间禁止选择。
|
|
16
|
+
4. 显示当前模式的名称;只有 `locked: false` 才允许打开选择器。名册使用 `presets`,显示 `name/description`,提交 `id`,有 `broken` 的项禁用并显示原因。
|
|
17
|
+
5. 选择后发送 `select-agent-preset`,携带唯一 `requestId`。保存期间禁止再次切换和发送消息;以成功响应的 `agentPreset` 更新当前值。失败保留原值并展示错误。
|
|
18
|
+
6. 成功切换后重新读取该 Session 的 `commands` 和 `skills`,因为模式决定工具、命令及技能集合。
|
|
19
|
+
7. 点击发送第一条消息立即在 App 暂时禁用模式选择,收到 `sent` 后保持锁定。若提交报错,重新查询状态再决定是否解锁;连接断开或请求超时也必须先重新查询。
|
|
20
|
+
8. 每次进入会话或重连时重查当前模式状态,不能依赖上次连接的本地可编辑标记。
|
|
21
|
+
|
|
22
|
+
`agent-presets` 的 `modeSelectionEnabled: false` 表示 Host 隐藏模式选择 UI 的设置,App 可遵循同一设置隐藏入口;旧 Host 没有该字段时默认显示。它不改变 `locked` 的含义。全局 `isDefault` 只用于未指定模式时的默认提示,不能覆盖已有会话当前值。
|
|
23
|
+
|
|
24
|
+
## 请求与响应
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{"type":"session-create","requestId":"create-1","workspaceId":"w1","agentPreset":"standard"}
|
|
28
|
+
{"kind":"session-created","requestId":"create-1","sessionId":"s1","agentPreset":"standard"}
|
|
29
|
+
|
|
30
|
+
{"type":"agent-presets"}
|
|
31
|
+
{"kind":"agent-presets","presets":[{"id":"standard","name":"标准模式","isDefault":true},{"id":"minimal","name":"精简模式","isDefault":false}],"modeSelectionEnabled":true}
|
|
32
|
+
|
|
33
|
+
{"type":"session-agent-preset","sessionId":"s1","requestId":"state-1"}
|
|
34
|
+
{"kind":"session-agent-preset","sessionId":"s1","requestId":"state-1","agentPreset":"standard","locked":false}
|
|
35
|
+
|
|
36
|
+
{"type":"select-agent-preset","sessionId":"s1","agentPreset":"minimal","requestId":"select-1"}
|
|
37
|
+
{"kind":"select-agent-preset","sessionId":"s1","requestId":"select-1","agentPreset":"minimal"}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
创建时的 `agentPreset` 可省略,沿用 Host 默认值。选择成功响应不带 `locked: false`,避免把并发到达的开始对话通知重新解锁。
|
|
41
|
+
|
|
42
|
+
如果沿用“首条消息自动创建会话”的流程,只能在不带 `sessionId` 的 `message` 上附带 `agentPreset`;已有 Session 的模式修改必须使用独立选择接口。
|
|
43
|
+
|
|
44
|
+
## 多端更新及缓存
|
|
45
|
+
|
|
46
|
+
Control 通道(或旧版单连接)会收到:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{"kind":"session-agent-preset-updated","sessionId":"s1","agentPreset":"minimal","seq":12,"time":1000}
|
|
50
|
+
{"kind":"session-agent-preset-updated","sessionId":"s1","locked":true,"seq":13,"time":1100}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- 第一种表示模式已提交,包括从 WebUI 切换产生的事件;仅更新模式,清除命令/技能缓存。
|
|
54
|
+
- 第二种表示已开始对话,仅更新锁定状态。未携带的字段保持原值。
|
|
55
|
+
- 更新按 `gatewayId + sessionId` 定位;这些控制增量不受对话订阅过滤,不要错误应用到当前显示的其他会话。
|
|
56
|
+
- `seq` 是该通知对应的持久事件序号,不是独立的控制通知连续序列。
|
|
57
|
+
- 同一页面只保留最新 `requestId` 的查询/选择响应;离开页面、切换 gateway 或 session 后忽略旧响应。查询发出后若收到锁定通知,不可被该查询的迟到 `locked: false` 覆盖。
|
|
58
|
+
- 通知不重放连接基线。重连查询负责恢复当前值和锁定状态,历史 `projections.values.agentPreset` 也可更新模式。
|
|
59
|
+
- Session header 的 preset 是创建事实;用户在空白阶段切换后,当前值来自投影,不能只读 header。
|
|
60
|
+
|
|
61
|
+
## 错误处理
|
|
62
|
+
|
|
63
|
+
| code | App 行为 |
|
|
64
|
+
|---|---|
|
|
65
|
+
| `agent-preset/locked` | 锁定模式入口,提示对话开始后不可修改 |
|
|
66
|
+
| `agent-preset/not-found` | 保留原模式,刷新已有模式列表 |
|
|
67
|
+
| `agent-preset/invalid` | 保留原模式,展示 Host 返回的损坏或不可用原因 |
|
|
68
|
+
| `agent-preset/unavailable` | 状态未知,保持禁用;不可假定为空白会话 |
|
|
69
|
+
| `session/not-found` | 刷新会话列表并退出无效会话 |
|
|
70
|
+
| `bad-request` | 请求缺少有效的 sessionId 或 agentPreset,应修正客户端参数 |
|
|
71
|
+
| 其他 Host/网络错误 | 保留原模式,重查状态;不自动重复有副作用的写入 |
|
|
72
|
+
|
|
73
|
+
新增查询和选择接口的成功及 Host 错误响应均保留有效 `sessionId` 和传入的字符串 `requestId`。本地参数错误至少保留 `requestType/requestId`。
|
|
74
|
+
|
|
75
|
+
## Host 对接与锁定规则
|
|
76
|
+
|
|
77
|
+
- 切换调用官方 `agentPresets/select`,参数为 `{ agentId: sessionId, agentPreset }`;它重组 Agent、写入 `agent-preset/selected`,不更换 ID、不修改全局默认值。
|
|
78
|
+
- 查询读取 `session/follow` 首帧的当前 preset 和 `sessionListMetadata`,不以“历史 events 数组为空”判断可编辑,因为历史会分页,空白会话也有初始化事件。
|
|
79
|
+
- 历史含对话轮次或用户输入即锁定。gateway 还覆盖 prompt 已受理但尚未写入轮次事件的间隙。
|
|
80
|
+
- 同一 gateway 内,模式选择与消息提交按 Session 串行,Control/Conversation 两个 socket 的竞争不会让第一条消息越过尚未完成的模式切换。
|
|
81
|
+
- Host 自身仍会检查会话轮次边界。WebUI 等绕过 gateway 的并发操作由 Host 自身负责;本次没有修改 Host 的并发实现。
|
|
82
|
+
|
|
83
|
+
## 验收
|
|
84
|
+
|
|
85
|
+
- 新建 → 查询标准模式 → 改成精简模式 → 改回标准模式,全程 session ID 不变。
|
|
86
|
+
- 发送第一条消息后,UI 立即锁定;绕过 UI 直接调用选择接口也返回 `agent-preset/locked`。
|
|
87
|
+
- 轮次完成、停止、App 重连、gateway 重启后仍锁定。
|
|
88
|
+
- 未知/损坏模式切换失败,原模式与会话保留;之后仍可选择有效模式。
|
|
89
|
+
- 保存模式期间发送消息不会抢先执行;两个设备并发操作能按响应和通知收敛。
|
|
90
|
+
- WebUI 切换模式后,App 当前模式、命令和技能缓存同步更新;WebUI 开始对话后 App 锁定。
|
|
91
|
+
- 切换到另一个 Session 时,旧 Session 的迟到响应不能修改新页面。
|
|
92
|
+
|
|
93
|
+
自动测试覆盖 Adapter 真实参数、消息/选择排序、历史锁定、输入校验、错误透传和真实本地 WebSocket 的模式查询、切换通知、锁定、双通道分流。未执行新版真实 DSH 与手机联调。
|
package/lib/dsh-host-adapter.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
@@ -131,6 +133,31 @@ export function createDshHostAdapter(typertGateway) {
|
|
|
131
133
|
'session/modelCatalog',
|
|
132
134
|
)
|
|
133
135
|
|
|
136
|
+
// Control and conversation sockets share this adapter. Admit a prompt only
|
|
137
|
+
// after an earlier preset switch finishes, and re-check later switches.
|
|
138
|
+
const sessionOperations = new Map()
|
|
139
|
+
const admittedPrompts = new Set()
|
|
140
|
+
const serializeSession = (sessionId, operation) => {
|
|
141
|
+
const task = (sessionOperations.get(sessionId) ?? Promise.resolve()).then(operation)
|
|
142
|
+
const guard = task.catch(() => {})
|
|
143
|
+
sessionOperations.set(sessionId, guard)
|
|
144
|
+
return task.finally(() => {
|
|
145
|
+
if (sessionOperations.get(sessionId) === guard) sessionOperations.delete(sessionId)
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
const sessionPreset = async (sessionId, signal) => {
|
|
149
|
+
const snapshot = await sessionSnapshot({ address: { kind: 'session', sessionId }, maxMessages: 1 }, signal)
|
|
150
|
+
const values = snapshot.projections.values
|
|
151
|
+
const metadata = values?.sessionListMetadata
|
|
152
|
+
if (typeof values?.agentPreset !== 'string' || !metadata || typeof metadata.blank !== 'boolean'
|
|
153
|
+
|| !(metadata.lastPromptAt === null || Number.isFinite(metadata.lastPromptAt))) {
|
|
154
|
+
throw Object.assign(new Error('session preset state is unavailable'), { code: 'agent-preset/unavailable' })
|
|
155
|
+
}
|
|
156
|
+
const durableLock = !metadata.blank || metadata.lastPromptAt !== null
|
|
157
|
+
if (durableLock) admittedPrompts.delete(sessionId)
|
|
158
|
+
return { sessionId, agentPreset: values.agentPreset, locked: durableLock || admittedPrompts.has(sessionId) }
|
|
159
|
+
}
|
|
160
|
+
|
|
134
161
|
const commands = {
|
|
135
162
|
list: (sessionId, signal) => invoke('commands', 'list', { agentId: sessionId }, signal),
|
|
136
163
|
// DSH 0.1.5-rc.2 accepts `submittedAttachments`. The parsed
|
|
@@ -175,10 +202,18 @@ export function createDshHostAdapter(typertGateway) {
|
|
|
175
202
|
list: (payload = {}, signal) => invoke('session', 'list', sessionListArgs(payload), signal),
|
|
176
203
|
search: (payload, signal) => invoke('session', 'search', requestArgs(payload), signal),
|
|
177
204
|
create: (payload, signal) => invoke('session', 'create', requestArgs(payload), signal),
|
|
178
|
-
prompt: (payload, signal) =>
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
205
|
+
prompt: (payload, signal) => serializeSession(payload.sessionId, async () => {
|
|
206
|
+
// Accepted input may not have reached turn/start yet. Keep that gap
|
|
207
|
+
// locked; the persistent event or a subsequent read retires the marker.
|
|
208
|
+
const alreadyAdmitted = admittedPrompts.has(payload.sessionId)
|
|
209
|
+
admittedPrompts.add(payload.sessionId)
|
|
210
|
+
try {
|
|
211
|
+
return await invoke('session', 'prompt', requestArgs({ requestId: crypto.randomUUID(), ...payload }), signal)
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (!alreadyAdmitted) admittedPrompts.delete(payload.sessionId)
|
|
214
|
+
throw error
|
|
215
|
+
}
|
|
216
|
+
}),
|
|
182
217
|
attachment: (payload, signal) => invoke('session', 'attachment', requestArgs(payload), signal),
|
|
183
218
|
fork: (payload, signal) => invoke('session', 'fork', requestArgs(payload), signal),
|
|
184
219
|
cancel: (payload, signal) => invoke('session', 'cancel', requestArgs(payload), signal),
|
|
@@ -223,6 +258,20 @@ export function createDshHostAdapter(typertGateway) {
|
|
|
223
258
|
},
|
|
224
259
|
agentPresets: {
|
|
225
260
|
list: (_payload = {}, signal) => invoke('agentPresets', 'list', {}, signal),
|
|
261
|
+
session: (payload, signal) => serializeSession(payload.sessionId, () => sessionPreset(payload.sessionId, signal)),
|
|
262
|
+
select: (payload, signal) => serializeSession(payload.sessionId, async () => {
|
|
263
|
+
const state = await sessionPreset(payload.sessionId, signal)
|
|
264
|
+
if (state.locked) {
|
|
265
|
+
throw Object.assign(new Error('session has already started; its agent preset is fixed'), { code: 'agent-preset/locked' })
|
|
266
|
+
}
|
|
267
|
+
// Host validates the live catalog, mounts the composition, checks its
|
|
268
|
+
// own turn boundary and persists agent-preset/selected on this ID.
|
|
269
|
+
const selected = await invoke('agentPresets', 'select', {
|
|
270
|
+
agentId: payload.sessionId, agentPreset: payload.agentPreset,
|
|
271
|
+
}, signal)
|
|
272
|
+
if (typeof selected !== 'string' || !selected) throw new Error('agentPresets/select returned an invalid preset')
|
|
273
|
+
return { sessionId: payload.sessionId, agentPreset: selected }
|
|
274
|
+
}),
|
|
226
275
|
},
|
|
227
276
|
llm: {
|
|
228
277
|
async models(_payload = {}, signal) {
|
|
@@ -267,10 +316,14 @@ export function createDshHostAdapter(typertGateway) {
|
|
|
267
316
|
describe: (_payload = {}, signal) => describeHost(signal),
|
|
268
317
|
},
|
|
269
318
|
describeHost,
|
|
319
|
+
observeSessionEvent(sessionId, event) {
|
|
320
|
+
if (event.type === 'turn/start') admittedPrompts.delete(sessionId)
|
|
321
|
+
},
|
|
270
322
|
openSessionStream(sessionId, signal) {
|
|
271
323
|
return typertGateway.stream({
|
|
272
324
|
namespace: 'session', method: 'follow',
|
|
273
|
-
|
|
325
|
+
// Bound the opening window at the Host; older records remain available via session.page.
|
|
326
|
+
args: requestArgs({ address: { kind: 'session', sessionId }, maxMessages: 12, assistantStream: true }),
|
|
274
327
|
signal,
|
|
275
328
|
})
|
|
276
329
|
},
|
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
|
})
|
|
@@ -312,6 +314,8 @@ function buildWireEvent(session, event) {
|
|
|
312
314
|
...(d.source ? { source: d.source } : {}),
|
|
313
315
|
},
|
|
314
316
|
})
|
|
317
|
+
case 'agent-preset/selected':
|
|
318
|
+
return Object.assign(base, { event: { type: event.type, agentPreset: d.agentPreset } })
|
|
315
319
|
case 'tool/call':
|
|
316
320
|
return Object.assign(base, {
|
|
317
321
|
event: { type: 'tool/call', turn: d.turn, step: d.step, callId: d.callId, name: d.name, arguments: d.arguments },
|
|
@@ -494,6 +498,12 @@ async function admitCommand(host, msg) {
|
|
|
494
498
|
// Handle one mobile -> agent message through the official host API. Returns
|
|
495
499
|
// the wire frame to send back, or null when nothing should be sent.
|
|
496
500
|
async function admitMessage(api, msg) {
|
|
501
|
+
const preset = parseAgentPreset(msg, 'message')
|
|
502
|
+
if (preset.error) return preset.error
|
|
503
|
+
if (preset.value !== undefined && typeof msg.sessionId === 'string' && msg.sessionId.trim()) {
|
|
504
|
+
return { kind: 'error', code: 'bad-request', requestType: 'message',
|
|
505
|
+
message: 'use select-agent-preset before sending a message to an existing session' }
|
|
506
|
+
}
|
|
497
507
|
const text = typeof msg.text === 'string' ? msg.text.trim() : ''
|
|
498
508
|
const parsedImages = parseWireImages(msg.images, 'message')
|
|
499
509
|
if (parsedImages.error) return parsedImages.error
|
|
@@ -509,6 +519,7 @@ async function admitMessage(api, msg) {
|
|
|
509
519
|
// New session: optionally place it in a workspace (at most one of
|
|
510
520
|
// workspaceId / cwd, per the host create contract; workspaceId wins).
|
|
511
521
|
const createPayload = {}
|
|
522
|
+
if (preset.value !== undefined) createPayload.agentPreset = preset.value
|
|
512
523
|
if (typeof msg.workspaceId === 'string' && msg.workspaceId.trim() !== '') {
|
|
513
524
|
createPayload.workspaceId = msg.workspaceId.trim()
|
|
514
525
|
}
|
|
@@ -562,6 +573,13 @@ async function proxyQuery(api, type, method, payload, signal) {
|
|
|
562
573
|
}
|
|
563
574
|
}
|
|
564
575
|
|
|
576
|
+
// Projection queries must keep the caller's request identity on failure.
|
|
577
|
+
// They need the opening cut and projections, not the full conversation body.
|
|
578
|
+
async function querySessionProjection(api, requestType, sessionId) {
|
|
579
|
+
const history = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId, maxMessages: 1 })
|
|
580
|
+
return history.kind === 'error' ? { ...history, requestType, sessionId } : history
|
|
581
|
+
}
|
|
582
|
+
|
|
565
583
|
function fileTransferError(code, message, requestType, sessionId) {
|
|
566
584
|
return {
|
|
567
585
|
kind: 'error',
|
|
@@ -1025,6 +1043,9 @@ async function mutateGoal(api, type, method, payload) {
|
|
|
1025
1043
|
// and truncates oversized tool-result text.
|
|
1026
1044
|
// ---------------------------------------------------------------------------
|
|
1027
1045
|
const HISTORY_DEFAULT_MAX_BYTES = 4 * 1024 * 1024 // 4 MiB per frame
|
|
1046
|
+
// Opening snapshots should be readable before older history is requested.
|
|
1047
|
+
// Keep the newest indivisible message even when it alone exceeds this budget.
|
|
1048
|
+
const HISTORY_OPENING_MAX_BYTES = 256 * 1024
|
|
1028
1049
|
const HISTORY_TOOL_RESULT_MAX_CHARS = 2000 // per text block in conversation view
|
|
1029
1050
|
|
|
1030
1051
|
function eventBytes(event) {
|
|
@@ -1295,13 +1316,36 @@ async function selectCommandOption(api, host, command, sessionId, optionId) {
|
|
|
1295
1316
|
return { kind: 'error', code: 'bad-request', message: `command is not selectable: ${command}`, requestType: 'command-select', sessionId }
|
|
1296
1317
|
}
|
|
1297
1318
|
|
|
1319
|
+
function parseAgentPreset(msg, requestType, required = false) {
|
|
1320
|
+
if (msg.agentPreset === undefined && !required) return {}
|
|
1321
|
+
if (typeof msg.agentPreset !== 'string' || !msg.agentPreset.trim()) {
|
|
1322
|
+
return { error: { kind: 'error', code: 'bad-request', requestType,
|
|
1323
|
+
message: 'agentPreset must be a non-empty preset ID' } }
|
|
1324
|
+
}
|
|
1325
|
+
return { value: msg.agentPreset.trim() }
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1298
1328
|
// Dispatch one mobile query frame; returns the wire frame to send back.
|
|
1299
1329
|
async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
1330
|
+
if (msg.type === 'session-agent-preset' || msg.type === 'select-agent-preset') {
|
|
1331
|
+
const session = requireSessionId(msg)
|
|
1332
|
+
const preset = parseAgentPreset(msg, msg.type, msg.type === 'select-agent-preset')
|
|
1333
|
+
const requestId = typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}
|
|
1334
|
+
if (session.error || preset.error) return { ...(session.error || preset.error), ...requestId }
|
|
1335
|
+
const method = msg.type === 'session-agent-preset' ? 'session' : 'select'
|
|
1336
|
+
const frame = await proxyQuery(api, msg.type, api.agentPresets[method].bind(api.agentPresets), {
|
|
1337
|
+
sessionId: session.value, ...(preset.value === undefined ? {} : { agentPreset: preset.value }),
|
|
1338
|
+
})
|
|
1339
|
+
return { ...frame, sessionId: session.value, ...requestId }
|
|
1340
|
+
}
|
|
1300
1341
|
if (msg.type === 'session-create') {
|
|
1301
1342
|
if (typeof msg.requestId !== 'string' || !msg.requestId.trim()) {
|
|
1302
1343
|
return { kind: 'error', code: 'bad-request', message: 'session-create requires a requestId', requestType: 'session-create' }
|
|
1303
1344
|
}
|
|
1304
1345
|
const payload = {}
|
|
1346
|
+
const preset = parseAgentPreset(msg, msg.type)
|
|
1347
|
+
if (preset.error) return { ...preset.error, requestId: msg.requestId }
|
|
1348
|
+
if (preset.value !== undefined) payload.agentPreset = preset.value
|
|
1305
1349
|
if (typeof msg.workspaceId === 'string' && msg.workspaceId.trim()) {
|
|
1306
1350
|
payload.workspaceId = msg.workspaceId.trim()
|
|
1307
1351
|
} else if (typeof msg.cwd === 'string' && msg.cwd.trim()) {
|
|
@@ -1624,7 +1668,9 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
|
1624
1668
|
}
|
|
1625
1669
|
const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
|
|
1626
1670
|
if (sessionId) {
|
|
1627
|
-
|
|
1671
|
+
out.sessionId = sessionId
|
|
1672
|
+
const hist = await querySessionProjection(api, msg.type, sessionId)
|
|
1673
|
+
if (hist.kind === 'error') return hist
|
|
1628
1674
|
if (hist.kind === 'history' && hist.projections && hist.projections.values) {
|
|
1629
1675
|
out.sessionPermissions = hist.projections.values.permissions || null
|
|
1630
1676
|
}
|
|
@@ -1672,7 +1718,7 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
|
1672
1718
|
if (msg.type === 'context-usage') {
|
|
1673
1719
|
const sessionId = requireSessionId(msg)
|
|
1674
1720
|
if (sessionId.error) return sessionId.error
|
|
1675
|
-
const hist = await
|
|
1721
|
+
const hist = await querySessionProjection(api, msg.type, sessionId.value)
|
|
1676
1722
|
if (hist.kind !== 'history') return hist
|
|
1677
1723
|
const values = (hist.projections && hist.projections.values) || {}
|
|
1678
1724
|
return {
|
|
@@ -1686,7 +1732,7 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
|
1686
1732
|
if (msg.type === 'session-stats') {
|
|
1687
1733
|
const sessionId = requireSessionId(msg)
|
|
1688
1734
|
if (sessionId.error) return sessionId.error
|
|
1689
|
-
const hist = await
|
|
1735
|
+
const hist = await querySessionProjection(api, msg.type, sessionId.value)
|
|
1690
1736
|
if (hist.kind !== 'history') return hist
|
|
1691
1737
|
const values = (hist.projections && hist.projections.values) || {}
|
|
1692
1738
|
return {
|
|
@@ -2179,7 +2225,7 @@ const plugin = {
|
|
|
2179
2225
|
})
|
|
2180
2226
|
|
|
2181
2227
|
const broadcastInteractionFrame = (frame) => {
|
|
2182
|
-
const wire =
|
|
2228
|
+
const wire = stringifyWireFrame(frame)
|
|
2183
2229
|
for (const client of clients) {
|
|
2184
2230
|
if (client.mobileChannel === 'conversation') continue
|
|
2185
2231
|
if (client.filterSessionId && client.filterSessionId !== frame.sessionId) continue
|
|
@@ -2191,7 +2237,7 @@ const plugin = {
|
|
|
2191
2237
|
// subscribed to one conversation. Subscriptions only scope the heavier
|
|
2192
2238
|
// conversation and interaction streams.
|
|
2193
2239
|
const broadcastSessionMetadataFrame = (frame) => {
|
|
2194
|
-
const wire =
|
|
2240
|
+
const wire = stringifyWireFrame(frame)
|
|
2195
2241
|
for (const client of clients) {
|
|
2196
2242
|
if (client.mobileChannel === 'conversation') continue
|
|
2197
2243
|
if (client.readyState === 1) client.send(wire)
|
|
@@ -2204,12 +2250,12 @@ const plugin = {
|
|
|
2204
2250
|
let approvalCount = 0
|
|
2205
2251
|
for (const pending of pendingQuestions.values()) {
|
|
2206
2252
|
if (ws.filterSessionId && ws.filterSessionId !== pending.sessionId) continue
|
|
2207
|
-
ws.send(
|
|
2253
|
+
ws.send(stringifyWireFrame(questionFrameFor(pending.rpcId, pending, true)))
|
|
2208
2254
|
questionCount += 1
|
|
2209
2255
|
}
|
|
2210
2256
|
for (const pending of pendingApprovals.values()) {
|
|
2211
2257
|
if (ws.filterSessionId && ws.filterSessionId !== pending.sessionId) continue
|
|
2212
|
-
ws.send(
|
|
2258
|
+
ws.send(stringifyWireFrame(approvalFrameFor(pending.rpcId, pending, true)))
|
|
2213
2259
|
approvalCount += 1
|
|
2214
2260
|
}
|
|
2215
2261
|
log(`interaction replay: trigger=${trigger} filtered=${Boolean(ws.filterSessionId)} questions=${questionCount} approvals=${approvalCount}`)
|
|
@@ -2565,12 +2611,12 @@ const plugin = {
|
|
|
2565
2611
|
}
|
|
2566
2612
|
|
|
2567
2613
|
const sendFrame = frame => {
|
|
2568
|
-
if (ws.readyState === 1) ws.send(
|
|
2614
|
+
if (ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
2569
2615
|
}
|
|
2570
2616
|
ws.sessionFollower = createSessionFollower(api, {
|
|
2571
2617
|
onFrame(frame, context) {
|
|
2572
2618
|
if (frame.type === 'snapshot') {
|
|
2573
|
-
sendFrame({ ...historyPage(frame.history, { sessionId: context.sessionId, view: 'conversation' }),
|
|
2619
|
+
sendFrame({ ...historyPage(frame.history, { sessionId: context.sessionId, view: 'conversation', maxBytes: HISTORY_OPENING_MAX_BYTES }),
|
|
2574
2620
|
kind: 'session-snapshot', ...context, assistantStream: frame.assistantStream, replace: true })
|
|
2575
2621
|
} else if (frame.type === 'event') {
|
|
2576
2622
|
sendFrame({ ...buildWireEvent({ id: context.sessionId }, frame.event), ...context })
|
|
@@ -2589,19 +2635,19 @@ const plugin = {
|
|
|
2589
2635
|
try {
|
|
2590
2636
|
msg = JSON.parse(data.toString())
|
|
2591
2637
|
} catch (error) {
|
|
2592
|
-
ws.send(
|
|
2638
|
+
ws.send(stringifyWireFrame({ kind: 'error', message: 'invalid json' }))
|
|
2593
2639
|
return
|
|
2594
2640
|
}
|
|
2595
2641
|
if (!msg || typeof msg.type !== 'string') return
|
|
2596
2642
|
const conversationRequest = ['message', 'history', 'subscribe', 'unsubscribe'].includes(msg.type)
|
|
2597
2643
|
if ((ws.mobileChannel === 'control' && conversationRequest) ||
|
|
2598
2644
|
(ws.mobileChannel === 'conversation' && !conversationRequest && msg.type !== 'ping')) {
|
|
2599
|
-
ws.send(
|
|
2645
|
+
ws.send(stringifyWireFrame({ kind: 'error', code: 'wrong-channel', requestType: msg.type, message: 'Request belongs to the other mobile channel' }))
|
|
2600
2646
|
return
|
|
2601
2647
|
}
|
|
2602
2648
|
|
|
2603
2649
|
if (msg.type === 'ping') {
|
|
2604
|
-
ws.send(
|
|
2650
|
+
ws.send(stringifyWireFrame({ kind: 'pong', at: Date.now() }))
|
|
2605
2651
|
} else if (msg.type === 'subscribe') {
|
|
2606
2652
|
if (msg.assistantStream !== undefined && typeof msg.assistantStream !== 'boolean') {
|
|
2607
2653
|
sendFrame({ kind: 'error', code: 'bad-request', requestType: 'subscribe', message: 'assistantStream must be a boolean' })
|
|
@@ -2631,37 +2677,38 @@ const plugin = {
|
|
|
2631
2677
|
sendFrame({ kind: 'subscribed', sessionId: null, assistantStream: false })
|
|
2632
2678
|
} else if (msg.type === 'question-answer' || msg.type === 'question-cancel') {
|
|
2633
2679
|
respondToQuestion(msg, msg.type === 'question-cancel').then((frame) => {
|
|
2634
|
-
if (frame && ws.readyState === 1) ws.send(
|
|
2680
|
+
if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
2635
2681
|
})
|
|
2636
2682
|
} else if (msg.type === 'approval-response') {
|
|
2637
2683
|
respondToApproval(msg).then((frame) => {
|
|
2638
|
-
if (frame && ws.readyState === 1) ws.send(
|
|
2684
|
+
if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
2639
2685
|
})
|
|
2640
2686
|
} else if (msg.type === 'file-list' || msg.type === 'file-download-open' ||
|
|
2641
2687
|
msg.type === 'file-download-read' || msg.type === 'file-download-cancel') {
|
|
2642
2688
|
fileTransfers.handle(ws, msg).then((frame) => {
|
|
2643
|
-
if (frame && ws.readyState === 1) ws.send(
|
|
2689
|
+
if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
2644
2690
|
})
|
|
2645
2691
|
} else if (msg.type === 'message') {
|
|
2646
2692
|
admitMessage(api, msg).then((frame) => {
|
|
2647
|
-
if (frame && ws.readyState === 1) ws.send(
|
|
2693
|
+
if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
2648
2694
|
})
|
|
2649
2695
|
} else if (msg.type === 'workspaces' || msg.type === 'sessions' || msg.type === 'history' || msg.type === 'attachment' ||
|
|
2650
2696
|
msg.type === 'search' || msg.type === 'host' || msg.type === 'directories' || msg.type === 'directory-create' ||
|
|
2651
2697
|
msg.type === 'workspace-create' || msg.type === 'session-create' || msg.type === 'models' || msg.type === 'commands' || msg.type === 'command-execute' ||
|
|
2652
2698
|
msg.type === 'command-options' || msg.type === 'command-select' || msg.type === 'select-model' ||
|
|
2653
2699
|
msg.type === 'permission-options' || msg.type === 'permission' || msg.type === 'context-usage' ||
|
|
2654
|
-
msg.type === 'agent-presets' || msg.type === '
|
|
2700
|
+
msg.type === 'agent-presets' || msg.type === 'session-agent-preset' || msg.type === 'select-agent-preset' ||
|
|
2701
|
+
msg.type === 'defaults' || msg.type === 'set-default' ||
|
|
2655
2702
|
msg.type === 'session-stats' || msg.type === 'default-model' ||
|
|
2656
2703
|
msg.type === 'save-default-model' || msg.type === 'fork' || msg.type === 'session-cancel' || msg.type === 'queue-update' ||
|
|
2657
2704
|
msg.type === 'session-archive' || msg.type === 'session-rename' || msg.type === 'providers' ||
|
|
2658
2705
|
msg.type === 'tasks' || msg.type === 'goal' || msg.type === 'goal-edit' ||
|
|
2659
2706
|
msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear') {
|
|
2660
2707
|
handleQuery(api, host, agentDefaultModel, msg).then((frame) => {
|
|
2661
|
-
if (frame && ws.readyState === 1) ws.send(
|
|
2708
|
+
if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
2662
2709
|
})
|
|
2663
2710
|
} else {
|
|
2664
|
-
ws.send(
|
|
2711
|
+
ws.send(stringifyWireFrame({ kind: 'error', message: 'unknown message type: ' + msg.type }))
|
|
2665
2712
|
}
|
|
2666
2713
|
})
|
|
2667
2714
|
|
|
@@ -2678,14 +2725,14 @@ const plugin = {
|
|
|
2678
2725
|
|
|
2679
2726
|
log(`client connected (id=${id}, total=${clients.size})${device ? ' device=' + device.name : ''}`)
|
|
2680
2727
|
if (paired) {
|
|
2681
|
-
ws.send(
|
|
2728
|
+
ws.send(stringifyWireFrame({
|
|
2682
2729
|
kind: 'paired',
|
|
2683
2730
|
token: paired.token,
|
|
2684
2731
|
device: paired.device,
|
|
2685
2732
|
...gatewayIdentity,
|
|
2686
2733
|
}))
|
|
2687
2734
|
}
|
|
2688
|
-
ws.send(
|
|
2735
|
+
ws.send(stringifyWireFrame({
|
|
2689
2736
|
kind: 'hello',
|
|
2690
2737
|
...gatewayIdentity,
|
|
2691
2738
|
protocol: 3,
|
|
@@ -2698,6 +2745,7 @@ const plugin = {
|
|
|
2698
2745
|
'projection-baseline',
|
|
2699
2746
|
'images',
|
|
2700
2747
|
'session-create',
|
|
2748
|
+
'session-agent-preset',
|
|
2701
2749
|
'commands',
|
|
2702
2750
|
'tasks',
|
|
2703
2751
|
'goals',
|
|
@@ -2713,10 +2761,10 @@ const plugin = {
|
|
|
2713
2761
|
...(device ? { device: { id: device.id, name: device.name } } : {}),
|
|
2714
2762
|
}))
|
|
2715
2763
|
if (ws.mobileChannel !== 'conversation' && archivedSessionIds !== null) {
|
|
2716
|
-
ws.send(
|
|
2764
|
+
ws.send(stringifyWireFrame({ kind: 'session-archives', archivedSessionIds }))
|
|
2717
2765
|
}
|
|
2718
2766
|
if (ws.mobileChannel !== 'conversation' && sessionQueues !== null) {
|
|
2719
|
-
ws.send(
|
|
2767
|
+
ws.send(stringifyWireFrame({ kind: 'session-queues', queues: Object.fromEntries(sessionQueues) }))
|
|
2720
2768
|
}
|
|
2721
2769
|
if (ws.mobileChannel !== 'conversation') {
|
|
2722
2770
|
sendFrame({ kind: 'projection-baseline', projections: sessionProjectionBaselines })
|
|
@@ -2775,6 +2823,7 @@ const plugin = {
|
|
|
2775
2823
|
}
|
|
2776
2824
|
|
|
2777
2825
|
const disposeEvents = ctx.on('session/event', (session, event) => {
|
|
2826
|
+
api.observeSessionEvent(String(session.id), event)
|
|
2778
2827
|
if (clients.size === 0) return
|
|
2779
2828
|
const wire = buildWireEvent(session, event)
|
|
2780
2829
|
if (!wire) return
|
|
@@ -2788,7 +2837,13 @@ const plugin = {
|
|
|
2788
2837
|
...(event.data.source ? { source: event.data.source } : {}),
|
|
2789
2838
|
})
|
|
2790
2839
|
}
|
|
2791
|
-
|
|
2840
|
+
if (event.type === 'agent-preset/selected' || event.type === 'turn/start') {
|
|
2841
|
+
broadcastSessionMetadataFrame({
|
|
2842
|
+
kind: 'session-agent-preset-updated', sessionId: String(session.id), seq: event.seq, time: event.time,
|
|
2843
|
+
...(event.type === 'turn/start' ? { locked: true } : { agentPreset: event.data.agentPreset }),
|
|
2844
|
+
})
|
|
2845
|
+
}
|
|
2846
|
+
const payload = stringifyWireFrame(wire)
|
|
2792
2847
|
for (const client of clients) {
|
|
2793
2848
|
if (client.mobileChannel === 'control' || client.sessionFollowerActive) continue
|
|
2794
2849
|
if (client.filterSessionId && client.filterSessionId !== String(session.id)) continue
|
package/lib/session-follower.mjs
CHANGED
|
@@ -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
|
-
"description": "
|
|
3
|
+
"version": "0.7.6",
|
|
4
|
+
"description": "Mobile gateway for dsh 0.1.5-rc.1/2 with per-session Agent preset selection before the first conversation, automatic preset locking after conversation starts, and live preset updates",
|
|
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-preset.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",
|