refactor(onetalk): separate collection and command flows

This commit is contained in:
YBF
2026-09-12 15:50:40 +08:00
parent bb4c7afd17
commit 15efc84e27
23 changed files with 1008 additions and 148 deletions
@@ -35,6 +35,8 @@ tsconfig.base.json
上述结构来自当前仓库文件,而不是预设的脚手架布局。
OneTalk 页面侧的流程入口位于 `src/onetalk/main-page/``page-script-entry.ts` 是组合根,`collection/entry.ts` 安装采集能力,`collection/observed-publisher.ts` 连接共享观察,`commands/index.ts` 分发页面命令,`commands/send.ts` 处理发送。现有 observer、历史功能、页面桥和 Service Worker 继续保留各自实现与状态,具体约束见 [MAIN 流程边界](./onetalk/runtime-sync.md#31-main-采集与命令流程边界)。
## 包边界
- 扩展相关代码、资源和脚本归 `apps/chrome-extension/` 管理。
@@ -6,6 +6,8 @@
本文件只描述页面桥、Port 生命周期、页面身份注册和命令路由。OneTalk 发送参数与发送事实遵循 [OneTalk PWA 出站发送 SOP](./send-sop.md);耐久同步遵循 [OneTalk 耐久同步](./durable-sync.md)。
MAIN 的采集安装、共用 publisher、命令分发和状态归属见 [运行时总览的 MAIN 流程边界](./runtime-sync.md#31-main-采集与命令流程边界)。bridge 只接收组合根注入的 command handleraction 分支位于 `main-page/commands/index.ts`,不在桥接层另建业务状态或 dispatcher。
## 2. Signatures
### Page bridge messages
@@ -5,7 +5,7 @@
OneTalk 插件同时需要以下能力时,遵循本总览和对应子规范:
- MAIN world 访问 OneTalk 页面 SDK
- TradeBright 主动向插件下发发送或同步命令;
- TradeBright 主动向插件下发发送命令,插件内部调度历史与资料采集命令;
- 插件向 TradeBright 上传页面事实;
- 历史、增量和实时消息在浏览器或 Service Worker 重启后恢复;
- Popup、Service Worker 和 Bright 授权链路使用稳定的扩展安装实例 deviceId;
@@ -66,6 +66,71 @@ Bright WebSocket
- Bright 是联系人资料当前事实源;Mind 只提供页面/插件授权上下文,不接收 profile delivery,也不作为 Bright read model 的 profile projection owner。
- 每个跨层业务概念必须只有一个 owner:页面 command 结果先在页面边界形成,发送三态先在 contract/adapter 边界收窄,服务端事实只在 server ingest 中提交。
### 3.1 MAIN 采集与命令流程边界
#### Scope / Trigger
新增采集能力、页面命令或调整 MAIN 组合入口时,按采集上报与命令执行识别业务入口,继续共享观察器、页面桥和既有状态 owner。`page_command` 还承载插件内部的同步与资料快照,不能把所有 page command 或 WS ACK 归成服务端业务命令。
#### Signatures
以下路径相对 `src/onetalk/main-page/`
| 文件 / 接口 | 职责 |
| --- | --- |
| `page-script-entry.ts` | 唯一 MAIN 组合根,创建共用实例并安排安装顺序 |
| `collection/entry.ts` / `installOneTalkCollectionObservers(pageWindow, publish, buyerFactSink)` | 安装 buyer/message observer 和公开手动历史入口 |
| `collection/observed-publisher.ts` / `createOneTalkObservedPublisher(observedSink, observeSentMessages)` | 返回共用 `OneTalkObservedMessageSink`,筛选批次并按原序通知两个消费者 |
| `commands/index.ts` / `createOneTalkPageCommandHandler(pageWindow, dependencies)` | 返回现有 `OneTalkPageCommandHandler`,只按 action 委派 |
| `commands/send.ts` / `handleOneTalkSendCommand(pageWindow, message, sendObservation)` | 校验发送输入并执行 text 或委派原 image/file 适配 |
| `current-conversation-history/page-command.ts` | 处理 discover/sync/sync.conversation,不接收发送关联器 |
| `contact-observer/page-command.ts` / `handleOneTalkContactProfileCommand(observer)` | 调用现有 snapshot,返回 completed/profileCount;不再承担 action 分发 |
#### Contracts
- 组合顺序固定为:页面 UI 能力 → 单份发送关联器 → profile observer → 共用 publisher 与命令 handler → page bridge → buyer/message/manual-history。profile 仍直接使用既有 installer,不能为合并入口而提前/推迟其安装或引入 prepare/start 状态机。
- `commands/index.ts` 将 send 委派给 send handler,将 contact.snapshot 委派给 profile handler,将三个 discover/sync 动作委派给 history handler。dispatcher 不解析业务字段,不用 handler 返回 null 逐个试路由,未知动作直接返回既有 invalid_request。
- publisher 对非空消息或有诊断的批次先调用 `observeSentMessages(batch.messages)`,再调用 `observedSink(batch)`;实时、历史命令和公开历史函数共用这一出口。采集只收到 observe 回调,不能调用 execute 或接管发送 pending。
- `message-observer/send-observation.ts` 保持发送 pending、候选 ID、timeout 和 settle 的唯一 owner;命令侧通过 execute/executeImage/executeFile 使用。`image-send.ts` 保持原生媒体上传拦截器和关联状态,不复制或搬迁模块级状态容器。
- profile snapshot 使用已安装的同一 observer,其 snapshot 自身更新 seen/CRM 相关状态并发布资料;命令只能返回统计,不能再 publish。
- 新 installer、publisher 和 dispatcher 不拥有 Map、Set、timer、账本或连接。消息投递、profile/buyer 账本、ACK、页面请求关联及 Bright 生命周期继续归各原 Service Worker owner。
#### Validation & Error Matrix
| 输入 / 条件 | 必须行为 |
| --- | --- |
| batch 有消息 | 先提供给原发送关联器,再正常上报;关联成功不吞掉事实 |
| batch 无消息但有 unsupported/invalid/anomaly 诊断 | 继续发布一次诊断批次 |
| batch 无消息且无诊断 | 两个消费者都不调用 |
| contact.snapshot 在 bridge 首次 hello 后立即到达 | 使用预先安装的 observer,发布一次资料并返回一个结果 |
| 未知 action | rejected_before_send/invalid_request,不访问 SDK 或 snapshot |
| send 的 SDK 本地接受、超时或歧义 | 保持既有发送 SOP,不增加重试或假成功 |
| 历史命令返回 batch | 使用同一 publisher,不新建发送关联器或上报管线 |
#### Good / Base / Bad Cases
- Good:同一 sent batch 完成唯一发送确认,同时经原采集管线投递;各状态 owner 独立处理自己的终态。
- Base:插件内部发 page command 请求 snapshot/history,仍复用已有采集能力与账本。
- Bad:命令侧再安装消息观察器、复制媒体 WeakMap、snapshot 后再 publish,或为两类流程创建第二个 WS/通用可配置流水线。
#### Tests Required
- `onetalk-page-flow-boundaries.test.js` 覆盖 publisher 双消费顺序、诊断-only/空批次、snapshot/未知动作与实际页面入口安装顺序。
- `onetalk-send-page.test.js` 保留 send/history 的输入、结果、tooltip 和发布行为;`onetalk-send-observation.test.js` 与媒体测试保留唯一确认、timeout/异常及不重试断言。
- 对构建后的 MAIN IIFE 执行 `scripts/verify-release-bundle.mjs`,验证实际 shared observation 驱动消息上报及单次发送确认;这属于无网络 Node VM 冒烟,真实 Chromium/OneTalk/Bright 联调需单独标记。
#### Wrong vs Correct
```ts
// Wrong: 两个实例各自持有 pending,观察无法确认已执行命令。
const send = createSendObservationCorrelator();
const publish = createOneTalkObservedPublisher(sink, createSendObservationCorrelator().observe);
// Correct: 一个状态 owner,通过窄回调供采集消费。
const sendObservation = createSendObservationCorrelator();
const publish = createOneTalkObservedPublisher(sink, sendObservation.observe);
```
## 4. Service Worker transport、frame router 与业务 Flow
### 4.1 Scope / Trigger
@@ -6,6 +6,8 @@
当前 PWA 的稳定入口是 sendUIMessages,不是页面上下文中不存在的 sendMessage,也不是在 web PWA 上实测无效的 sendTextMessage({ cid, content })。
页面发送入口为 `main-page/commands/send.ts`,由 `commands/index.ts` 按 action 委派;历史 handler 不再承担发送。发送 pending 仍由 `message-observer/send-observation.ts` 的单份关联器拥有,实时与历史观察经共用 publisher 提供给其 observe。完整组合与归属见 [MAIN 流程边界](./runtime-sync.md#31-main-采集与命令流程边界)。
> **Warning**OneTalk SDK 的 `sendUIMessages` 归一化逻辑按 `input.cid || SDK.context.cid` 选择会话。只传 `conversationCode` 不会指定目标,会回退到当前打开的对话;因此 `cid` 是不可省略的权威路由字段。
## 2. Signatures
@@ -0,0 +1,13 @@
{"file":".trellis/spec/project/architecture.md","reason":"项目架构入口与按变化原因划分职责"}
{"file":".trellis/spec/project/module-organization.md","reason":"入口、分发、功能和状态 owner 边界"}
{"file":".trellis/spec/project/module-ownership.md","reason":"窄类型依赖与 canonical import"}
{"file":".trellis/spec/project/async-state-boundaries.md","reason":"保持状态唯一 owner 与既有异步时序"}
{"file":".trellis/spec/project/source-file-conventions.md","reason":"源文件职责注释与 main-last"}
{"file":".trellis/spec/chrome-extension/frontend/index.md","reason":"扩展规范导航"}
{"file":".trellis/spec/chrome-extension/frontend/architecture.md","reason":"渠道与运行时边界"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md","reason":"两类业务链路、原 SW owner 与协议约束"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md","reason":"页面路由与桥接契约"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/send-sop.md","reason":"发送三态、SDK 与共享观察确认"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/contact-profile-sync.md","reason":"snapshot 发布与 profile 状态归属"}
{"file":".trellis/tasks/09-12-onetalk-flow-boundaries/research/current-boundaries.md","reason":"当前源码证据、启动顺序与独立检查发现"}
{"file":".trellis/spec/chrome-extension/frontend/quality-guidelines.md","reason":"行为回归、类型检查、构建与产物冒烟"}
@@ -0,0 +1,107 @@
# OneTalk 采集与命令流程边界设计
## 目标与决策
采用“入口和状态职责分开、底层能力共用”。这次重构集中于 MAIN 页面:提取采集安装、共享观察发布和页面命令分发,将发送处理移出历史模块。Service Worker 已有的业务分发、发送 Flow、各类账本和连接生命周期继续作为既有 owner。
两类是业务流程视角,不是两个独立运行时:
- 采集上报:页面事件或插件自动采集 → 归一化观察 → 既有投递状态管理 → Bright WS;ACK 和 anchor 是该流程的后续输入。
- 命令执行:Bright `send.command` → 既有 SW send Flow → 页面路由 → SDK 执行与观察确认 → 既有结果收窄 → `send.confirmation`
页面桥也承载插件内部发起的历史/资料命令,因此新的页面命令分发入口不能将每个 page command 都当成 Bright 外部业务命令。内部命令继续调用相同采集能力与状态 owner。
## 模块边界
下列路径相对 `apps/chrome-extension/src/onetalk/main-page/`
| 模块 | 变化与职责 | 不承担的责任 |
| --- | --- | --- |
| `page-script-entry.ts` | 保留唯一 MAIN 构建组合根;创建共用实例、安排既有启动顺序、注入窄接口 | 批次筛选、发送 payload 解析、action 分支 |
| 新增 `collection/entry.ts` | 安装 bridge 之后的 buyer/message observer 与公开手动历史入口;复用已有 installer | 连接、ACK、发送执行、pending 状态 |
| 新增 `collection/observed-publisher.ts` | 持有批次发布函数;筛选空批次并按原序调用发送观察回调、page observed sink | 创建发送关联器、解析 raw 数据、重新实现发送确认 |
| 新增 `commands/index.ts` | 创建单一页面命令处理函数;按 action 委派给发送、历史和资料 handler,未知 action 返回既有错误 | 通用命令框架、业务字段解析、复制各分支校验 |
| 新增 `commands/send.ts` | 承接原 history handler 内 `onetalk.send` 的输入校验、text SDK 调用和 image/file 委派 | 历史同步、WS 连接、重试策略 |
| `current-conversation-history/page-command.ts` | 仅承担发现和历史同步;移除 send 分支与 correlator 依赖 | 消息发送 |
| `contact-observer/page-command.ts` | 保留 snapshot 处理;需要时将参数缩窄为 snapshot 能力 | 新建 profile observer 或再次 publish |
| `message-observer/send-observation.ts` | 保留实现与路径;文档明确为发送关联状态 owner,采集仅得到 observe 回调 | 采集安装、上传账本 |
| `image-send.ts` | 保留原生图片/文件上传适配、拦截器与匹配状态 | 新建第二套 uploader |
当前发送关联器和媒体模块已各有独立实现,物理移动不会增加本次边界收益,且会扩大 import 与测试改动。文件归属进一步调整不纳入本次。`commands/index.ts` 作为真实业务分发入口,不新增只做层层 re-export 的 facade。
## 组合方式与启动次序
使用普通安装函数、闭包回调及既有对象,不引入 `prepare/start/dispose` 对象、启动标志或全局 Flow registry。组合根按以下次序执行:
1. 安装动作 tooltip、构造 history/connection 提示控制器、安装会话 ID 控件。
2. 创建单份 `SendObservationCorrelator`
3. 安装已有联系人观察器并连接已有 profile sink,供后续 snapshot 命令复用。
4. 创建一个共用 publisher:输入原 `OneTalkObservedMessageSink``sendObservation.observe` 回调。它不需要整个发送关联器。
5. 创建命令 handler,注入现有页面上下文、发送执行能力、profile snapshot、publisher 与 history tooltip。
6. 调用现有 `installOneTalkMainPageBridge`,绑定该 handler 和原 connection-status 回调。
7. 由 collection installer 按现有顺序安装 buyer DOM observer、message observer、公开手动历史函数。
profile 的提前安装显式留在组合根:其现有 installer 已是明确的采集入口,且必须在 bridge 尝试 hello 前准备好。无需为“所有采集在一个函数里”改变启动次序。
候选接口采用现有类型的 `Pick`、函数类型或注入回调,具体参数名允许实施时按 TypeScript 可读性调整:
```ts
createOneTalkObservedPublisher(observedSink, observeSentMessages)
// -> OneTalkObservedMessageSink
installOneTalkCollectionObservers(pageWindow, publish)
// -> void
createOneTalkPageCommandHandler(pageWindow, dependencies)
// -> 现有 OneTalkPageCommandHandler
handleOneTalkSendCommand(pageWindow, message, sendExecutor)
// -> Promise<PageCommandResult>
```
采集只收到 `observe` 回调;发送处理只需 `execute` / `executeImage` / `executeFile`snapshot handler 只需 `snapshot`。这些能力仍来自组合根创建的同一个实例,不增加状态副本。raw SDK 值仍在 MAIN 原适配点归一化,跨层类型继续来自现有 bridge 与 shared contract。
## 状态所有权
| 状态 | 唯一 owner | 两类入口如何使用 |
| --- | --- | --- |
| 页面 profile seen、账号与 CRM 观察缓存 | 原 contact observer | 采集监听更新;snapshot 命令委托同一实例 |
| 页面 buyer DOM 观察及去重 | 原 buyer observer | installer 仅注册 |
| 发送 pending、候选 ID、timeout、一次性 settle | 原 SendObservationCorrelator | 命令开始执行;共用 publisher 将观察传回其 observe |
| 媒体 uploader 拦截器、tmp key 关联 | 原 image-send 模块 | image/file 命令复用原函数及模块级 WeakMap |
| 页面命令 request 等待、页面注册与路由 | 原 SW runtime / page bridge | 新入口仅提供 handler,不创建另一份 request Map |
| 消息候选、checkpoint、anchor、ACK、bootstrap | 原 sync engine 内各协调器 | 采集继续投递,内部 page command 继续委派 |
| profile/buyer 耐久账本和 ACK | 原 profile/buyer coordinator | 保持其独立账本 |
| Bright socket、认证、重连 | 原 bright-client | 所有出站调用共用原 guarded send |
| 配置代际、实例替换和订阅释放 | 原 configured session / runtime composition | 页面重构不接管 |
publisher、采集 installer 和命令 dispatcher 均不新增 Map、Set、timer 或可变生命周期状态。
## 行为兼容契约
- 保留现有批次筛选:消息、unsupported/invalid 计数或 anomaly 任一存在时,执行 `observe(batch.messages)` 后执行 `observedSink(batch)`;诊断-only 批次继续上报。
- 实时、历史命令和公开手动历史函数使用同一个 publisher。命令确认和事实上传是两个输出,各自仍由原 owner 处理;不能因为关联成功而吞掉正常观察,不能再额外发布一份事实。
- profile `snapshot()` 本身更新 seen 并 emit;命令仅返回原 `completed/profileCount`,不重复读取或发送。
- `onetalk.send` 原字段校验、SDK this/cid/conversationCode、媒体委派、异常映射和三态结果原样保留。SDK 本地接受不视为 confirmed;不增加重试。
- `onetalk.discover-conversations``onetalk.sync``onetalk.sync.conversation` 继续调用原历史功能,保留原结果、history publisher 和 tooltip 错误隔离。
- unknown action 在单一 dispatcher 拒绝,不依赖 handler 调用链的 null fallback;已有底层 handler 的直接拒绝行为可保留,不能增加第二份路由清单或跨分支校验。
- page bridge 版本、requestId/sendRequestId 区分、canonical 内容与 WS frame 不变;MAIN/ISOLATED/SW 的源码构建入口及输出路径不变。
- 不修补本次范围以外的潜在生命周期问题;发现行为缺陷应记录并回到范围审阅,不能静默改变事件次序或失败路径。
## 范围、成本与取舍
预计产品代码集中为 4 个新模块、2 个既有主要模块及必要的窄类型注入调整,另有直接受影响的测试和规范。文件数量仅为设计估计,不作为完成标准。
选择该方案是因为入口可读性与发送/历史独立修改能力直接改善,同时保留已有状态机的验证基础。全面搬迁观察器、重写 SW 组合、为两类建立统一基类或可配置通用流水线,会增加本次回归范围而无额外验收收益。
本任务只有一个需要整体集成验证的重构交付,不拆父子任务。实现由一个代理负责,检查代理独立复核,避免两个写入者同时更改页面组合根。
## 风险与验证
1. **安装顺序变化**profile snapshot 命令可能过早到达未初始化对象,或采集在 bridge 前发布。保留显式顺序,并用组合行为/实际产物冒烟检查,不用源码字符串检查顺序。
2. **共享观察接线遗漏**:发送一直 timeout 或事实重复投递。用真实关联器配合测试 sink 验证同一次输入可完成确认和一次上报,并覆盖历史及诊断批次。
3. **抽取丢失错误或参数**text/image/file、history、unknown action 的原行为测试保留;命令 router 测试覆盖各动作仅进入一个 handler。
4. **类型与路径变更遗漏**:严格类型检查、import 搜索和扩展构建完成验证。无持久化或 wire 迁移。
既有测试没有直接完整覆盖页面安装顺序和 publisher 双消费,新增测试重点放在这两个组合边界。`verify-release-bundle.mjs` 验证实际 MAIN IIFE,无真实网络、Chrome 页面或 SDK 环境;真实 OneTalk/Bright 联调未执行时记录 deferred。
## 回退
回退本任务的单一结构重构提交即可恢复入口与 import;协议和持久化格式无需回退迁移。规划文件、规范和测试与该变更单元保持一致;不覆盖其他任务修改。
@@ -0,0 +1,12 @@
{"file":".trellis/spec/project/architecture.md","reason":"项目架构入口与按变化原因划分职责"}
{"file":".trellis/spec/project/module-organization.md","reason":"入口、分发、功能和状态 owner 边界"}
{"file":".trellis/spec/project/module-ownership.md","reason":"窄类型依赖与 canonical import"}
{"file":".trellis/spec/project/async-state-boundaries.md","reason":"保持状态唯一 owner 与既有异步时序"}
{"file":".trellis/spec/project/source-file-conventions.md","reason":"源文件职责注释与 main-last"}
{"file":".trellis/spec/chrome-extension/frontend/index.md","reason":"扩展规范导航"}
{"file":".trellis/spec/chrome-extension/frontend/architecture.md","reason":"渠道与运行时边界"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md","reason":"两类业务链路、原 SW owner 与协议约束"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md","reason":"页面路由与桥接契约"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/send-sop.md","reason":"发送三态、SDK 与共享观察确认"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/contact-profile-sync.md","reason":"snapshot 发布与 profile 状态归属"}
{"file":".trellis/tasks/09-12-onetalk-flow-boundaries/research/current-boundaries.md","reason":"当前源码证据、启动顺序与独立检查发现"}
@@ -0,0 +1,91 @@
# 实施计划
状态:实现、规范同步和独立最终检查已完成;剩余本地提交及 Trellis 收尾。
## 1. 启动门槛
- [x] 用户在最终规划摘要之后明确批准实施;已运行 `task.py start`
- [x] 重新核对工作区、当前分支及其他任务改动,保留本任务规划文件;已创建独立任务分支 `09-12-onetalk-flow-boundaries`,基线为本地 main 与缓存 origin/main 一致的 `bb4c7af`
- [x] 确认 `prd.md``design.md`、本文和两份 JSONL 完整,`task.py validate` 通过(12/13 个上下文条目)。
- [x] 主会话调度一个 `trellis-implement` 子代理负责整个页面重构及直接受影响测试;独立 `trellis-check` 子代理在实现完成后检查。原生上下文注入优先,缺失时由子代理加载。共享入口与测试改动存在依赖,不并行分配多个写入者。
- [x] 派发提示以 `Active task: .trellis/tasks/09-12-onetalk-flow-boundaries` 开头,明确文件所有权、规划约束以及不得覆盖其他人改动。
## 2. 特征基线与影响分析
基线结果:共享 contract 构建通过,4.1 的既有目标测试全部通过,日志 `/tmp/onetalk-flow-baseline.log`。页面入口、publishObservedBatch、hasObservedOutput 的 upstream 图分析为 LOW,直接调用者均在页面入口内部;history handler 与动态回调由实现前补查。
| Scope ID | 职责 | 拥有 / 排除 | 依赖 | 验收 | 研究证据 |
| --- | --- | --- | --- | --- | --- |
| page-flows | 分开采集、命令入口与发送处理,保留共享状态及行为 | 拥有 design 列出的 MAIN 入口/新模块、必要窄类型改动及直接相关扩展测试;排除 SW、contract、协议、配置、Git、任务材料及规范 | 已批准设计与既有测试基线 | R1–R4 / A1–A6;目标测试、类型/格式、构建/产物冒烟与独立全量检查 | research/current-boundaries.md |
- [x] 已对页面入口/观察/历史/profile/媒体调用运行 upstream impact,并完成刷新索引后的 staged detect_changes。媒体类型触碰的 HIGH 在最终检查中补报,已用编译后 JavaScript 等价及独立检查确认无运行变化。
- [x] 对移动文件检查全部生产与测试 import;若涉及符号改名,使用 GitNexus rename 预览,不全仓文本替换。
- [x] 运行现有发送、历史、观察、桥接、frame router 与配置生命周期相关测试作为基线。已有失败与本次重构失败分开记录。
- [x] 保留现有结果特征断言,再新增本次真正需要的入口组合行为测试,不用文件名或源码字符串断言代替行为。
## 3. 产品实现
- [x] 按 design 提取页面命令分发,只根据 action 委派,保持非法 action 的 `invalid_request`
- [x] 将发送命令解析与 text/image/file 执行从历史处理函数抽到发送功能;历史处理移除对发送关联器的依赖。
- [x] 明确发送 pending/候选 ID/timer/settle 的单一 owner;保留 send-observation.ts 与 image-send.ts 的实现和路径,通过窄回调及类型注入表达两类入口的使用范围,不移动状态容器。
- [x] 提取采集安装与共享观察发布接线;保持 profile → bridge → buyer/message/manual-history 的顺序,不创建新启动状态机。
- [x] 共享 publisher 同时供实时观察、公开历史函数和历史命令使用;保持有效/诊断批次筛选以及 observe → publish 的同步次序。
- [x] 逐处更新测试及生产 import;不留无消费者的兼容 re-export 或旧实现分支。
## 4. 验证顺序
所有命令从仓库根目录执行。首次直接运行 Node 测试前先构建共享 contract;新增的入口测试文件一并纳入 targeted set。
### 4.1 目标行为测试
```bash
pnpm --filter @trade-message-center/onetalk-contract build
node --experimental-strip-types --test --test-timeout=60000 apps/chrome-extension/test/onetalk-send-page.test.js apps/chrome-extension/test/onetalk-send-observation.test.js apps/chrome-extension/test/onetalk-image-send.test.js apps/chrome-extension/test/onetalk-file-send.test.js apps/chrome-extension/test/onetalk-websocket-tap.test.js apps/chrome-extension/test/onetalk-page-bridge.test.js apps/chrome-extension/test/onetalk-contact-profile-observer.test.js apps/chrome-extension/test/onetalk-buyer-fact-observer.test.js apps/chrome-extension/test/onetalk-frame-router.test.js apps/chrome-extension/test/onetalk-configured-sync-session.test.js apps/chrome-extension/test/onetalk-sync-runtime.test.js
```
重点断言:采集与命令共用观察、诊断批次保留、profile snapshot 发布一次、未知动作拒绝、发送仅 SDK 成功不能确认、完整观察只 settle 一次、超时/异常不重发、历史输出/进度/tooltip 不变。
### 4.2 静态检查
```bash
pnpm --filter @trade-message-center/chrome-extension typecheck
pnpm format:check
git diff --check
```
检查 canonical 类型来源、移动后 import、循环依赖、MAIN 中无 Bright/IndexedDB 依赖、入口不解析业务字段。格式失败仅修本任务文件;其他失败记录来源,不顺带修改无关产品代码。
### 4.3 受影响包构建
```bash
node scripts/with-build-hash.mjs --filter @trade-message-center/chrome-extension run build
```
构建 hash 和版本由根级 wrapper 注入;不要手写 `BUILD_HASH` 或改版本。Manifest 与构建入口路径保持稳定,generated dist 不进 Git。
### 4.4 最小产物冒烟
```bash
node apps/chrome-extension/scripts/verify-release-bundle.mjs apps/chrome-extension/dist
```
对实际 MAIN IIFE 验证消息观察及命令确认。它是 Node VM 无网络验证,不等同真实 Chrome/OneTalk SDK 或 Bright 网络联调。真实浏览器验收未运行时明确记录 deferred,不向真实客户发送消息来完成本次重构验收。
### 4.5 独立检查
- [x] 检查代理执行一次扩展全量测试 `pnpm --filter @trade-message-center/chrome-extension test`,覆盖未手动枚举的历史/profile/buyer/构建测试;同一最终版本已完成的检查不重复运行。
- [x] 检查全部差异与 design 的状态归属表,确认不存在第二份状态、隐藏 fallback、事件总线、通用流程框架或未声明的行为变更。
- [x] 若检查修改了测试或实现修复了问题,仅重跑受影响检查;跨层失败按证据扩大验证范围。
## 5. 规范与收尾
- [x] 主会话更新扩展 runtime-sync 的入口、共享观察和状态归属说明,以及直接受影响的 page-bridge/send-sop/目录文档链接;现有全局规范不另造平行规则。
- [x] 规范同步后完成最终 diff 审查,记录实际验证结果和限制。
- [ ] 提交前执行 GitNexus `detect_changes`,核对只影响预期页面流程;查看 `git log --oneline -5` 后使用既有前缀,以一个相关重构单元提交,不 amend。
- [ ] 按 Trellis finish-work 记录和归档;不自动推送、创建远端 PR、发布或部署。
## 回退与范围控制
本次不含持久化迁移和 wire 变更,回退以本任务的单一重构变更单元为边界。未提交阶段仅回退明确由本任务产生的文件差异,不使用全仓 reset/clean。
如果维持现有行为必须改变超时、错误、路由、并发或启动时序,暂停该变更并回到设计审阅;不能把行为修复混入本任务。
@@ -0,0 +1,41 @@
# OneTalk 采集与命令流程边界整理
## Goal
让维护者能分别定位插件侧的采集上报与命令执行入口,并明确每份状态的唯一所有者,降低新增采集能力、调整发送能力和排查跨层故障时的理解与回归成本。
## Background
当前 MAIN 页面入口同时组装观察器和页面命令处理。历史命令处理函数还包含发送分支,导致发送与同步因不同需求修改同一处理函数。Service Worker 已有独立的发送流程、帧路由和同步状态管理。
用户已选择“分清流程入口和状态归属,共用底层能力”。这是保持行为的结构重构,完整源码依据见 [研究记录](./research/current-boundaries.md),技术决策见 [设计](./design.md)。
## Requirements
- R1:采集上报与命令执行具有明确入口;命令分发只判断动作并委派,发送处理从历史同步模块中分离。
- R2:每份采集、发送关联、桥接路由和连接状态保持唯一所有者;两类流程共享同一份页面观察和既有桥接、WS 能力。
- R3:保留现有消息、联系人资料、买家事实、历史同步与发送行为,包括结果、错误、ACK、重试、请求关联、事件顺序和资源清理。
- R4:通过行为回归和构建产物验证保证结构变化未引入重复观察、重复发送或丢失确认;更新对应规范与直接受影响的导入。
## Acceptance Criteria
- [x] A1 / R1:从页面组合入口能识别采集与命令的委派关系;历史处理不再承担消息发送,发送处理不依赖历史同步的执行分支。
- [x] A2 / R2:一条归一化观察可同时进入采集上报与发送结果匹配;不新增重复 SDK/WS 观察器、解析器、投递账本或 pending-send 状态。
- [x] A3 / R3:同一有效发送命令仍按既有规则执行;完整、唯一匹配的 sent 观察产生一次确认;异常、超时和匹配歧义保持既有结果且不自动重发。
- [x] A4 / R3:联系人快照、会话发现、历史同步、未知动作与诊断批次保留既有返回值和发布行为;ACK 与插件自动同步继续归原采集状态所有者。
- [x] A5 / R3MAIN / ISOLATED / Service Worker 边界、账号路由、配置生命周期、wire 字段与协议版本保持不变。
- [x] A6 / R4:相关行为测试、扩展类型检查、格式检查、扩展构建及实际 MAIN IIFE 的无网络冒烟通过;真实浏览器验证是否执行单独记录。
## Out of Scope
- 不新增 WS 命令、采集字段、消息种类、UI 功能或协议能力。
- 不改变重试、超时、ACK、发送终态、并发、幂等、持久化和资源释放策略。
- 不全面重排 onetalk 目录,不重写 Service Worker 同步引擎,不合并既有消息、profile、buyer 状态管理。
- 不引入通用 Flow 框架、事件总线、第二套桥接或 WS 连接。
- 不发布版本、不推送远端、不部署;规划阶段不修改产品代码。
## Review Status
2026-09-12:产品范围、兼容要求与验收标准已收敛,PRD 已按最终结构完成收敛复读。用户在最终规划摘要后明确回复“同意”,已批准实施;任务已进入 in_progress,使用独立分支 `09-12-onetalk-flow-boundaries`
验收结果:单一 scope `page-flows` 经独立最终检查 accepted,A1A6 已满足。全量扩展 366/366、边界测试 6/6、类型/格式/构建及实际 MAIN IIFE 冒烟通过;详见 [检查报告](./research/check-report.md)。
@@ -0,0 +1,29 @@
# 独立检查:page-flows
## 首轮
- 检查基线:HEAD `bb4c7afd1703d242c075653624bc2385a892e44e`,分支 `09-12-onetalk-flow-boundaries`,已覆盖全部新增未跟踪模块与 4 份规范。
- 决定:`reopen:page-flows`,仅剩生产文件首行职责注释长度不符合规范。
- `PAGE-FLOWS-SOURCE-HEAD-COMMENT-LENGTH`collection/entry.ts、commands/index.ts、commands/send.ts 首行注释正文分别 38、32、35 字符,要求 10–30。修复轮次 1/2;仅允许缩短这三处注释。
- `PAGE-FLOWS-HIGH-SENDONETALKMEDIA`:已关闭。TypeScript 编译前后 JavaScript 一致,媒体变更仅类型收窄。
- Checker 已补测试:真实 correlator 与 publisher/sink 组合、dispatcher send/discovery 路由、缺失 correlator 不调用 SDK;同时修短测试首行注释。
- 全量扩展测试 passed,366/366;新增边界文件测试 passed6/6;根 `pnpm format:check` passed291 files`git diff --check` passed。
- typecheck/build/实际 MAIN IIFE 冒烟沿用相同生产版本已通过证据。
- 未发现行为 blocker、协议变化或重复状态 owner。
## 复用与后续义务
- `trellis mem usage /root/implement_page_flows --json` 返回 `invalid_agent_id``used_percentage: null`。原生代理工具只提供 canonical task name,缺少可查询的原始 agent ID;不猜测用量或套用百分比阈值。
- 本次回到相同 Implementer 仅处理三行注释,保持 finding ID 与所有权;这是证据缺口下的有界调度,不声称通过上下文比例检查。
- 同一 Checker 需复验三行长度和最终差异后明确 accepted。主会话提交前补 staged detect_changes,纳入新增文件。
- Checker 最终报告将真实浏览器联调列为缺失证据;已批准 design/implement/PRD 明确本次必须项是实际 MAIN IIFE 无网络冒烟,真实 Chromium/OneTalk/Bright 联调为 runtime-unverified/deferred。无需改变验收范围或向真实客户发送消息。
## 最终定点复验
- 决定:`accepted`Scope `page-flows`。首轮全范围审查与本轮定点复验共同覆盖最终验收标准。
- `PAGE-FLOWS-SOURCE-HEAD-COMMENT-LENGTH` 已关闭,三行正文长度分别 17/15/16;修复仅涉及注释。
- 无新发现,无未修复 blocking findingChecker 本轮未写文件。
- 受检 HEAD 仍为 `bb4c7af`,无 unstaged 修改,完整 staged 23 files。
- 当前工作区 GitNexus 索引已成功刷新,staged detect_changes 覆盖 41 graph symbols / 12 flows,标记 HIGH;新增 command/send、原媒体适配及页面组合流程均在预期范围,没有 SW/contract 改动。索引识别的 const/function 等图节点不能当成独立函数数量。
- 全量扩展 366/366、边界测试 6/6、全范围 format、typecheck/build/MAIN IIFE smoke 全部通过;注释修复未改变运行代码,未无理由重跑。
- 缺失必需证据:无。真实 Chromium/OneTalk/Bright 联调按设计保持 runtime-unverified/deferred。
@@ -0,0 +1,65 @@
# 当前流程与维护边界证据
核对基线:2026-09-12`/Users/ybf/work/trade-message-center``main`,产品源码未改动。本文记录源码及既有测试内容,不宣称测试或真实浏览器已运行。
## 页面组合与安装顺序
- `apps/chrome-extension/src/onetalk/main-page/page-script-entry.ts:49`:唯一 MAIN 构建入口中的组合函数安装 tooltip、会话 ID 控件,创建一个发送观察关联器。
- 同文件 `:58`:安装联系人资料观察器并绑定 profile sink`:67`:安装页面桥;`:87` 起:依次安装 buyer DOM observer、message observer 和公开的当前会话历史同步函数。
- `contact-observer/entry.ts:150`:联系人观察器拥有 EventBus 与 CRM 观察订阅、账号、seen fingerprint、CRM Map`:166``snapshot()` 会读取、更新状态并发布资料,不只是只读 getter。
- `current-conversation-history/entry.ts:15`:历史入口只向页面命名空间注册函数,不在注册时立即执行全量同步。
- 提取入口必须保持 profile 在 bridge 前准备完成、buyer/message/history 在 bridge 后安装;不得顺带提前采集、推迟注册、添加启动状态机或更改 pagehide/dispose。
## 共享消息观察
- `page-script-entry.ts:38`:有效批次先 `sendObservation.observe(batch.messages)`,再 `observedSink(batch)`。仅无消息且无任何受支持诊断的批次被忽略。
- 同一 publisher 注入实时消息观察、公开历史采集和历史命令处理。诊断-only 批次也必须上报。
- `message-observer/send-observation.ts:176`:关联器是发送操作的 pending Set、计时器、候选 ID 与一次性 settle 的唯一所有者。`:334` 的 observe 只在完整、唯一匹配时确认;SDK 返回 opId 不是确认依据。
- 它消费观察,但并不生产或安装观察器。所属业务是发送确认;观察器不需要获得其 execute 方法、pending 状态或整个命令模块。
## 命令与历史混杂
- `current-conversation-history/page-command.ts:136`:同一函数接收 history publisher、history tooltip 和 SendObservationCorrelator。
- `:144` 处理 discover`:168` 处理 send`:227` 后处理 sync/sync.conversation。发送分支同时拥有 outbound 校验、SDK text send 和 image/file 委派。
- `image-send.ts:1`image 与 file 共用已验证的 uploader/metadata 适配实现;本次仅允许调整归属和导入,不拆其内部算法。
- `contact-observer/page-command.ts:10`:snapshot 命令复用观察器实例,返回 completed/profileCount。新的命令入口应明确识别 action 后委派,未知 action 保持 invalid_request。
## Service Worker 的既有状态归属
| 责任 | 当前 owner | 本次处理 |
| --- | --- | --- |
| socket、认证、心跳、重连 | transport/bright-client.ts | 保持实现 |
| 入站业务帧唯一分发 | routing/frame-router.ts | 保持实现;ACK 到原状态 owner |
| send.command → routePageCommand → canonical confirmation | flows/send-command-flow.ts | 保持实现 |
| 页面注册、request 关联、路由等待 | runtime.ts | 保持实现 |
| observation/checkpoint/anchor/bootstrap/ACK | sync-engine.ts 及 sync-engine/ | 保持实现 |
| profile / buyer 账本与投递 | contact-profile-coordinator.ts / buyer-fact-coordinator.ts | 保持实现 |
| 配置替换和订阅装配 | configured-sync-session.ts / sync-runtime.ts | 保持实现 |
`sync-engine.ts:240` 的 anchor.snapshot 可继续驱动 bootstrap`bootstrap-coordinator.ts` 会发内部 page command。内部 page command 与 Bright 下发业务命令不是同一分类维度。今后命令触发采集时应委托现有采集能力,而不是新建状态源。
## 可复用验证
- `test/onetalk-send-page.test.js` 同时含发送、历史、页面身份及 tooltip 测试;拆职责时按实际动作更新测试导入/调用,不能删行为断言以通过重构。
- `test/onetalk-send-observation.test.js:50` 已断言 SDK 异常/超时为 unknown 且只调用一次 SDK;还覆盖候选 ID、唯一匹配、歧义、图片与文件。
- `test/onetalk-image-send.test.js``onetalk-file-send.test.js``onetalk-websocket-tap.test.js` 直接引用现有 send 文件,移动后同步导入。
- `test/onetalk-frame-router.test.js``onetalk-configured-sync-session.test.js``onetalk-sync-runtime.test.js` 可用于 WS owner、确认和生命周期回归。
- `scripts/verify-release-bundle.mjs` 可直接验证构建产物目录中的 MAIN IIFE,已有 live 双方向、诊断批次、命令确认一次、SDK 调用一次和 timer 释放断言。它是 Node VM 无网络冒烟,不能称真实 Chromium 验证。
## 影响分析与限制
本会话 GitNexus upstream 分析:`handleOneTalkHistoryCommand``createSendObservationCorrelator` 各有一个直接生产调用方 `installOneTalkPageFeatures`,图报告 LOW。实际 callback、动态 SDK 调用、跨 window/Port/WS 及测试 import 不由该结论完整覆盖,必须结合上述源码和集成验证。
## 独立页面边界检查
本任务的只读 explorer 已核对安装顺序与交叉依赖,建议保留唯一组合根、直接提前安装已有 profile observer,并用普通函数提取 bridge 后的采集安装及命令分发。其检查确认:
- `page-bridge/main.ts:246` 的 bridge 安装 consumer 后立即尝试 helloprofile 需提前准备。
- snapshot 本身会发布资料;不能在新命令入口再 publish。
- `image-send.ts` 使用模块级 WeakMap 维持同一 uploader 的唯一拦截器,不能复制模块或状态。
- 保留发送关联器与媒体模块的实现和路径即可满足本期状态归属要求,物理移动并无必要。
- 新增组合测试应覆盖共享 publisher 的双消费与页面启动顺序;保留既有发送、历史、profile、bridge 及媒体测试。
该检查为只读源码审阅,未运行测试。
上一轮尝试刷新索引时因无权写全局 `~/.gitnexus/registry.json` 未完整结束;本任务以当前源码为事实依据,不将图结果视为完整影响清单。实施前对实际修改或移动符号重新运行 impact,出现 HIGH/CRITICAL 先报告。
@@ -0,0 +1,40 @@
# Implement 报告:page-flows
状态:实现及独立检查完成,`page-flows` accepted。实现代理与 Checker 均已释放写权,无未完成生产代码修改;最终检查证据见 check-report.md。
## 范围与基线
- 工作区 `/Users/ybf/work/trade-message-center`,任务分支 `09-12-onetalk-flow-boundaries`
- 基线 `bb4c7afd1703d242c075653624bc2385a892e44e`;此前相对规划基线只有 Trellis 归档/日志变化。
- 新增 collection installer、publisher、commands dispatcher/send handler;历史去掉发送分支,profile handler 只接收 snapshot 能力。
- image-send.ts 只做媒体需要的关联器类型收窄,算法、异步边界、模块级状态均未更改。
- 产品代码未触及 SW、contract、协议或构建配置。
- 主会话完成 runtime-sync、page-bridge、send-sop、directory-structure 规范更新,待 Checker 一并核对。
## 已执行验证
| 检查 | 结果 |
| --- | --- |
| 改前 contract build + 原 targeted set | passed101/101`/tmp/onetalk-flow-baseline.log` |
| 改后 implement.md 4.1 targeted set + onetalk-page-flow-boundaries.test.js | passed104/104 |
| `pnpm --filter @trade-message-center/chrome-extension typecheck` | passed |
| `pnpm run format:check -- <本 scope 的 10 个产品/测试文件>` | passed;根级全范围格式检查交 Checker |
| `node scripts/with-build-hash.mjs --filter @trade-message-center/chrome-extension run build` | passedBUILD_HASH `885f8de6da6caa3b` |
| `node apps/chrome-extension/scripts/verify-release-bundle.mjs apps/chrome-extension/dist` | passedliveDirections=2、rejectedClasses=2、sdkCalls=1、confirmations=1 |
| `git diff --check` | passed |
| 扩展全量测试 | pending,交独立 Checker |
| 真实 Chromium / OneTalk SDK / Bright 联调 | runtime-unverified;本次最终必须的运行验证为实际 MAIN IIFE 无网络冒烟 |
## 风险与主会话审阅
- 页面安装顺序与共用 publisher、snapshot 唯一发布已有新增组合测试。
- 主会话要求移除 profile 路由的非空断言,现已通过 snapshot handler 的精确返回契约消除;unknown action 在 dispatcher 单点拒绝,没有新增 fallback。
- GitNexus 将媒体 `sendOneTalkMedia` 的类型触碰评为 HIGH,直接调用者是 sendOneTalkImage/sendOneTalkFile;主会话已向用户报告。独立 Checker 需核对 diff 确为类型变化、没有 SDK/状态语义变化。
- 未跟踪新增模块未进入原 detect_changes 结果;主会话提交前会暂存完整范围再跑 staged detect_changes,并结合源码/测试检查。
- 最初 git fetch 被自动审批服务的 503/auth_unavailable 拒绝;命令未执行。后续本地新建任务分支已通过审批,使用与缓存 origin/main 一致的本地 main,不声称已刷新远端。
## 独立检查重点
对所有 tracked diff 与新增 modules/tests/spec 逐项按 R1R4 / A1–A6 检查;全量扩展测试与全范围格式检查必须执行。已通过同一生产版本的 typecheck/build/bundle smoke 可复核并引用,只有新修改或明确疑点时重跑。
若现有新增测试仍遗漏关键 dispatcher/history 路由或真实 correlator 组合行为,可由 Checker 在测试范围内补充最小行为断言;不得添加第二套生产接口以方便测试。
@@ -0,0 +1,37 @@
{
"id": "onetalk-flow-boundaries",
"name": "onetalk-flow-boundaries",
"title": "OneTalk 采集与命令流程边界整理",
"description": "分清页面采集与命令入口及状态归属,共用观察、桥接与 Bright WS 能力,保持现有行为。",
"status": "in_progress",
"dev_type": "frontend",
"scope": "onetalk",
"package": "chrome-extension",
"priority": "P2",
"creator": "ybf",
"assignee": "ybf",
"createdAt": "2026-09-12",
"completedAt": null,
"branch": "09-12-onetalk-flow-boundaries",
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [
"apps/chrome-extension/src/onetalk/main-page/page-script-entry.ts",
"apps/chrome-extension/src/onetalk/main-page/collection/entry.ts",
"apps/chrome-extension/src/onetalk/main-page/collection/observed-publisher.ts",
"apps/chrome-extension/src/onetalk/main-page/commands/index.ts",
"apps/chrome-extension/src/onetalk/main-page/commands/send.ts",
"apps/chrome-extension/src/onetalk/main-page/current-conversation-history/page-command.ts",
"apps/chrome-extension/src/onetalk/main-page/contact-observer/page-command.ts",
"apps/chrome-extension/src/onetalk/main-page/image-send.ts",
"apps/chrome-extension/test/onetalk-send-page.test.js",
"apps/chrome-extension/test/onetalk-page-flow-boundaries.test.js"
],
"notes": "Final independent check accepted; extension 366/366 and MAIN IIFE smoke passed; real Chromium/OneTalk/Bright runtime deferred as planned.",
"meta": {}
}
@@ -0,0 +1,25 @@
// 安装页面采集观察器与公开历史入口。
import type { OneTalkBuyerFact } from "@trade-message-center/onetalk-contract";
import { installOneTalkBuyerFactDomObserver } from "../buyer-fact-observer/entry.ts";
import { installCurrentConversationHistorySync } from "../current-conversation-history/entry.ts";
import { createHistoryMessageBatch } from "../message-observer/history.ts";
import { installOneTalkMessageObserver } from "../message-observer/entry.ts";
import type { OneTalkObservedMessageSink } from "../message-observer/model.ts";
import type { OneTalkWebSocketPageWindow } from "../message-observer/websocket.ts";
type OneTalkBuyerFactSink = (facts: OneTalkBuyerFact[], channelAccountId: string) => void;
/** 按既有顺序安装 buyer、live message 与公开手动历史采集。 */
export const installOneTalkCollectionObservers = (
pageWindow: OneTalkWebSocketPageWindow,
publish: OneTalkObservedMessageSink,
buyerFactSink: OneTalkBuyerFactSink,
): void => {
installOneTalkBuyerFactDomObserver(pageWindow, buyerFactSink);
installOneTalkMessageObserver(pageWindow, publish);
installCurrentConversationHistorySync(pageWindow, {
onHistoryItems: (items) => publish(createHistoryMessageBatch(items, pageWindow)),
});
};
@@ -0,0 +1,28 @@
// 将同一归一化观察顺序送往发送确认与既有页面桥。
import type {
ObservedOneTalkMessage,
OneTalkObservedMessageSink,
OneTalkParsedMessageBatch,
} from "../message-observer/model.ts";
const hasObservedOutput = (batch: OneTalkParsedMessageBatch): boolean => {
return (
batch.messages.length > 0 ||
batch.diagnostics.unsupportedSkippedCount > 0 ||
batch.diagnostics.invalidObservationCount > 0 ||
batch.diagnostics.anomalies.length > 0
);
};
/** 复用一份观察批次:先给发送关联器,再发布到既有采集通道。 */
export const createOneTalkObservedPublisher = (
observedSink: OneTalkObservedMessageSink,
observeSentMessages: (messages: ObservedOneTalkMessage[]) => void,
): OneTalkObservedMessageSink => {
return (batch) => {
if (!hasObservedOutput(batch)) return;
observeSentMessages(batch.messages);
observedSink(batch);
};
};
@@ -0,0 +1,44 @@
// 分发页面发送、历史与资料命令。
import type { OneTalkPageCommandHandler } from "../../page-bridge/main.ts";
import type { OneTalkPageWindow } from "../model.ts";
import type { OneTalkObservedMessageSink } from "../message-observer/model.ts";
import type { SendObservationCorrelator } from "../message-observer/send-observation.ts";
import type { OneTalkContactProfileObserver } from "../contact-observer/entry.ts";
import { handleOneTalkContactProfileCommand } from "../contact-observer/page-command.ts";
import type { HistoryBootstrapProgressTooltip } from "../current-conversation-history/bootstrap-progress-tooltip.ts";
import { handleOneTalkHistoryCommand } from "../current-conversation-history/page-command.ts";
import { handleOneTalkSendCommand } from "./send.ts";
export type OneTalkPageCommandDependencies = {
sendObservation: Pick<SendObservationCorrelator, "execute" | "executeImage" | "executeFile">;
observedSink: OneTalkObservedMessageSink;
profileObserver: Pick<OneTalkContactProfileObserver, "snapshot">;
historyBootstrapProgress: HistoryBootstrapProgressTooltip;
};
/** 只依据 action 委派命令,分支自行保持既有输入校验与结果契约。 */
export const createOneTalkPageCommandHandler = (
pageWindow: OneTalkPageWindow,
dependencies: OneTalkPageCommandDependencies,
): OneTalkPageCommandHandler => {
return async (message) => {
switch (message.command.action) {
case "onetalk.send":
return handleOneTalkSendCommand(pageWindow, message, dependencies.sendObservation);
case "onetalk.contact.snapshot":
return handleOneTalkContactProfileCommand(dependencies.profileObserver);
case "onetalk.discover-conversations":
case "onetalk.sync":
case "onetalk.sync.conversation":
return handleOneTalkHistoryCommand(
pageWindow,
message,
dependencies.observedSink,
dependencies.historyBootstrapProgress,
);
default:
return { status: "rejected_before_send", reason: "invalid_request" };
}
};
};
@@ -0,0 +1,77 @@
// 执行页面发送命令并委派媒体适配。
import { isOneTalkOutboundContent, isPlainRecord } from "@trade-message-center/onetalk-contract";
import { isObjectRecord } from "../../../lib/guards.ts";
import type { OneTalkPageCommandMessage, PageCommandResult } from "../../page-bridge/model.ts";
import { sendOneTalkFile, sendOneTalkImage } from "../image-send.ts";
import type { OneTalkPageWindow } from "../model.ts";
import type { SendObservationCorrelator } from "../message-observer/send-observation.ts";
const isSendExtension = (value: unknown): value is Record<string, unknown> | null | undefined =>
value === undefined || value === null || isPlainRecord(value);
/** 执行 text SDK 调用或委派给既有 image/file 适配器。 */
export const handleOneTalkSendCommand = async (
pageWindow: OneTalkPageWindow,
message: OneTalkPageCommandMessage,
sendObservation: Pick<SendObservationCorrelator, "execute" | "executeImage" | "executeFile">,
): Promise<PageCommandResult> => {
const conversationId = message.command.conversationId;
const content = message.command.content;
const ext = message.command.ext;
if (
typeof conversationId !== "string" ||
conversationId.length === 0 ||
!isOneTalkOutboundContent(content) ||
!isSendExtension(ext)
) {
return { status: "rejected_before_send", reason: "invalid_request" };
}
if (content.kind === "image") {
return sendOneTalkImage({
pageWindow,
conversationId,
content,
sendObservation,
requestId: message.requestId,
});
}
if (content.kind === "file") {
return sendOneTalkFile({
pageWindow,
conversationId,
content,
sendObservation,
requestId: message.requestId,
});
}
try {
const sdkRoot = pageWindow.IcbuIM;
if (
!isObjectRecord(sdkRoot) ||
!isObjectRecord(sdkRoot.IMBaaSSDK) ||
!isObjectRecord(sdkRoot.IMBaaSSDK.default)
) {
return { status: "rejected_before_send", reason: "send_not_supported" };
}
const sdk = sdkRoot.IMBaaSSDK.default;
const getter = sdk.getMessageService;
if (typeof getter !== "function") {
return { status: "rejected_before_send", reason: "send_not_supported" };
}
const messageService = getter.call(sdk);
if (
!isObjectRecord(messageService) ||
typeof messageService.sendUIMessages !== "function"
) {
return { status: "rejected_before_send", reason: "send_not_supported" };
}
const sendUIMessages = messageService.sendUIMessages;
return await sendObservation.execute(conversationId, content.text, ext, (input) =>
sendUIMessages.call(messageService, input),
);
} catch {
return { status: "delivery_unknown", reason: "send_connection_lost" };
}
};
@@ -1,16 +1,12 @@
// 处理 Service Worker 请求的联系人资料快照命令
import type { OneTalkPageCommandMessage, PageCommandResult } from "../../page-bridge/model.ts";
import type { OneTalkPageWindow } from "../model.ts";
import type { PageCommandResult } from "../../page-bridge/model.ts";
import type { OneTalkContactProfileObserver } from "./entry.ts";
/** 执行联系人资料 account-level snapshot 命令。 */
export const handleOneTalkContactProfileCommand = (
_pageWindow: OneTalkPageWindow,
message: OneTalkPageCommandMessage,
observer: OneTalkContactProfileObserver,
): PageCommandResult | null => {
if (message.command.action !== "onetalk.contact.snapshot") return null;
observer: Pick<OneTalkContactProfileObserver, "snapshot">,
): PageCommandResult => {
const profiles = observer.snapshot();
return {
status: "completed",
@@ -1,7 +1,6 @@
// 处理 Service Worker 下发的历史同步命令
import { isOneTalkOutboundContent, isPlainRecord } from "@trade-message-center/onetalk-contract";
import { isObjectRecord } from "../../../lib/guards.ts";
import { isPlainRecord } from "@trade-message-center/onetalk-contract";
import { createOneTalkPageHistoryProgressSink } from "../../page-bridge/main.ts";
import type {
OneTalkPageBridgeWindow,
@@ -13,12 +12,10 @@ import type {
import type { OneTalkPageWindow } from "../model.ts";
import { createHistoryMessageBatch } from "../message-observer/history.ts";
import type { OneTalkObservedMessageSink } from "../message-observer/model.ts";
import type { SendObservationCorrelator } from "../message-observer/send-observation.ts";
import { syncCurrentConversationHistory, type AllConversationHistoryResult } from "./index.ts";
import { collectDirectConversations, syncCollectedConversation } from "./all-conversations.ts";
import type { ConversationAnchors, ConversationModes, ConversationSyncMode } from "./model.ts";
import type { HistoryBootstrapProgressTooltip } from "./bootstrap-progress-tooltip.ts";
import { sendOneTalkFile, sendOneTalkImage } from "../image-send.ts";
const isSyncMode = (value: unknown): value is ConversationSyncMode => {
return value === "full" || value === "incremental";
@@ -90,9 +87,6 @@ const stableFailureReason = (error: unknown): string => {
return error.message.startsWith("onetalk_history_") ? error.message : "history_sync_failed";
};
const isSendExtension = (value: unknown): value is JsonObject | null | undefined =>
value === undefined || value === null || isPlainRecord(value);
const decodeFinalAttempt = (command: JsonObject): boolean | null => {
if (!Object.hasOwn(command, "finalAttempt")) return true;
return typeof command.finalAttempt === "boolean" ? command.finalAttempt : null;
@@ -136,7 +130,6 @@ const observeHistoryItems = (
export const handleOneTalkHistoryCommand = async (
pageWindow: OneTalkPageWindow,
message: OneTalkPageCommandMessage,
sendObservation: SendObservationCorrelator,
observedSink?: OneTalkObservedMessageSink,
historyBootstrapProgress?: HistoryBootstrapProgressTooltip,
): Promise<PageCommandResult> => {
@@ -165,65 +158,6 @@ export const handleOneTalkHistoryCommand = async (
return { status: "failed", reason: stableFailureReason(error) };
}
}
if (action === "onetalk.send") {
const conversationId = message.command.conversationId;
const content = message.command.content;
const ext = message.command.ext;
if (
typeof conversationId !== "string" ||
conversationId.length === 0 ||
!isOneTalkOutboundContent(content) ||
!isSendExtension(ext)
) {
return { status: "rejected_before_send", reason: "invalid_request" };
}
if (content.kind === "image") {
return sendOneTalkImage({
pageWindow,
conversationId,
content,
sendObservation,
requestId: message.requestId,
});
}
if (content.kind === "file") {
return sendOneTalkFile({
pageWindow,
conversationId,
content,
sendObservation,
requestId: message.requestId,
});
}
try {
const sdkRoot = pageWindow.IcbuIM;
if (
!isObjectRecord(sdkRoot) ||
!isObjectRecord(sdkRoot.IMBaaSSDK) ||
!isObjectRecord(sdkRoot.IMBaaSSDK.default)
) {
return { status: "rejected_before_send", reason: "send_not_supported" };
}
const sdk = sdkRoot.IMBaaSSDK.default;
const getter = sdk.getMessageService;
if (typeof getter !== "function") {
return { status: "rejected_before_send", reason: "send_not_supported" };
}
const messageService = getter.call(sdk);
if (
!isObjectRecord(messageService) ||
typeof messageService.sendUIMessages !== "function"
) {
return { status: "rejected_before_send", reason: "send_not_supported" };
}
const sendUIMessages = messageService.sendUIMessages;
return await sendObservation.execute(conversationId, content.text, ext, (input) =>
sendUIMessages.call(messageService, input),
);
} catch {
return { status: "delivery_unknown", reason: "send_connection_lost" };
}
}
if (action !== "onetalk.sync" && action !== "onetalk.sync.conversation") {
return { status: "rejected_before_send", reason: "invalid_request" };
}
@@ -12,6 +12,11 @@ import {
type SendObservationCorrelator,
} from "./message-observer/send-observation.ts";
type MediaSendObservationCorrelator = Pick<
SendObservationCorrelator,
"executeImage" | "executeFile"
>;
export type OneTalkImageUploader = {
owner: Record<string, unknown>;
sendFileToOss: (input: Record<string, unknown>) => unknown;
@@ -54,7 +59,7 @@ type ImageTargetContext = {
type PendingImageUpload = {
kind: "image";
context: ImageTargetContext;
sendObservation: SendObservationCorrelator;
sendObservation: MediaSendObservationCorrelator;
deadlineMs: number;
isCurrent: () => boolean;
settle: (result: PageCommandResult) => void;
@@ -396,7 +401,7 @@ const sendOneTalkMedia = async (input: {
pageWindow: OneTalkPageWindow;
conversationId: string;
content: Extract<OneTalkOutboundContent, { kind: "image" | "file" }>;
sendObservation: SendObservationCorrelator;
sendObservation: MediaSendObservationCorrelator;
requestId?: string;
findUploader?: (pageWindow: OneTalkPageWindow) => OneTalkImageUploader | null;
runtime?: Partial<ImageSendRuntime>;
@@ -1,24 +1,17 @@
// 启动 OneTalk 页面监听与当前会话同步能力
import { installCurrentConversationHistorySync } from "./current-conversation-history/entry.ts";
import { installOneTalkConversationIdCopyControl } from "./conversation-id-copy.ts";
import {
installOneTalkActionStatusTooltip,
readOneTalkActionStatusTooltip,
} from "./action-status-tooltip.ts";
import { ConnectionStatusTooltip } from "./connection-status-tooltip.ts";
import { handleOneTalkHistoryCommand } from "./current-conversation-history/page-command.ts";
import { HistoryBootstrapProgressTooltip } from "./current-conversation-history/bootstrap-progress-tooltip.ts";
import { installOneTalkMessageObserver } from "./message-observer/entry.ts";
import { createHistoryMessageBatch } from "./message-observer/history.ts";
import { createSendObservationCorrelator } from "./message-observer/send-observation.ts";
import type { OneTalkParsedMessageBatch } from "./message-observer/model.ts";
import {
installOneTalkContactProfileObserver,
type OneTalkContactProfileObserver,
} from "./contact-observer/entry.ts";
import { handleOneTalkContactProfileCommand } from "./contact-observer/page-command.ts";
import { installOneTalkBuyerFactDomObserver } from "./buyer-fact-observer/entry.ts";
import { installOneTalkContactProfileObserver } from "./contact-observer/entry.ts";
import { installOneTalkCollectionObservers } from "./collection/entry.ts";
import { createOneTalkObservedPublisher } from "./collection/observed-publisher.ts";
import { createOneTalkPageCommandHandler } from "./commands/index.ts";
import {
createOneTalkPageBuyerFactsObservedSink,
createOneTalkPageObservedSink,
@@ -26,25 +19,6 @@ import {
installOneTalkMainPageBridge,
} from "../page-bridge/main.ts";
const hasObservedOutput = (batch: OneTalkParsedMessageBatch): boolean => {
return (
batch.messages.length > 0 ||
batch.diagnostics.unsupportedSkippedCount > 0 ||
batch.diagnostics.invalidObservationCount > 0 ||
batch.diagnostics.anomalies.length > 0
);
};
const publishObservedBatch = (
batch: OneTalkParsedMessageBatch,
sendObservation: ReturnType<typeof createSendObservationCorrelator>,
observedSink: (batch: OneTalkParsedMessageBatch) => void,
): void => {
if (!hasObservedOutput(batch)) return;
sendObservation.observe(batch.messages);
observedSink(batch);
};
/** 启动 main-page 目录下的全部 OneTalk 页面能力。 */
const installOneTalkPageFeatures = (): void => {
installOneTalkActionStatusTooltip(window);
@@ -54,41 +28,23 @@ const installOneTalkPageFeatures = (): void => {
const connectionStatus = new ConnectionStatusTooltip(tooltip);
installOneTalkConversationIdCopyControl(window);
const sendObservation = createSendObservationCorrelator();
let profileObserver: OneTalkContactProfileObserver;
profileObserver = installOneTalkContactProfileObserver(
const profileObserver = installOneTalkContactProfileObserver(
window,
createOneTalkPageProfileObservedSink(window),
);
const observedSink = createOneTalkPageObservedSink(window);
const buyerFactSink = createOneTalkPageBuyerFactsObservedSink(window);
const publish = (batch: OneTalkParsedMessageBatch): void => {
publishObservedBatch(batch, sendObservation, observedSink);
};
installOneTalkMainPageBridge(
window,
async (message) => {
const profileResult = handleOneTalkContactProfileCommand(
window,
message,
profileObserver,
);
if (profileResult) return profileResult;
return handleOneTalkHistoryCommand(
window,
message,
sendObservation,
publish,
historyBootstrapProgress,
);
},
undefined,
(disconnected) => connectionStatus.update(disconnected),
);
installOneTalkBuyerFactDomObserver(window, buyerFactSink);
installOneTalkMessageObserver(window, publish);
installCurrentConversationHistorySync(window, {
onHistoryItems: (items) => publish(createHistoryMessageBatch(items, window)),
const publish = createOneTalkObservedPublisher(observedSink, sendObservation.observe);
const onCommand = createOneTalkPageCommandHandler(window, {
sendObservation,
observedSink: publish,
profileObserver,
historyBootstrapProgress,
});
installOneTalkMainPageBridge(window, onCommand, undefined, (disconnected) =>
connectionStatus.update(disconnected),
);
installOneTalkCollectionObservers(window, publish, buyerFactSink);
};
installOneTalkPageFeatures();
@@ -0,0 +1,298 @@
// 验证 OneTalk 页面流程边界。
import assert from "node:assert/strict";
import test from "node:test";
import { createOneTalkPageCommandHandler } from "../src/onetalk/main-page/commands/index.ts";
import { createOneTalkObservedPublisher } from "../src/onetalk/main-page/collection/observed-publisher.ts";
import { createSendObservationCorrelator } from "../src/onetalk/main-page/message-observer/send-observation.ts";
import {
ONE_TALK_PAGE_BRIDGE_SOURCE,
ONE_TALK_PAGE_BRIDGE_VERSION,
} from "../src/onetalk/page-bridge/model.ts";
const emptyDiagnostics = () => ({
unsupportedSkippedCount: 0,
invalidObservationCount: 0,
anomalies: [],
});
test("shares each publishable batch with send observation before the page sink", () => {
const events = [];
const publisher = createOneTalkObservedPublisher(
(batch) => events.push(["publish", batch]),
(messages) => events.push(["observe", messages]),
);
const message = { messageId: "message-1" };
publisher({ messages: [message], diagnostics: emptyDiagnostics() });
publisher({
messages: [],
diagnostics: { ...emptyDiagnostics(), unsupportedSkippedCount: 1 },
});
publisher({ messages: [], diagnostics: emptyDiagnostics() });
assert.deepEqual(
events.map(([name, value]) => [
name,
Array.isArray(value) ? value.length : value.messages.length,
]),
[
["observe", 1],
["publish", 1],
["observe", 0],
["publish", 0],
],
);
});
test("connects one publisher batch to the real send correlator and one page sink", async () => {
const sinkBatches = [];
const correlator = createSendObservationCorrelator({
textTimeoutMs: 100,
now: () => 1_000,
});
const resultPromise = correlator.execute("conversation-1", "hello", undefined, () => ({
opId: "accepted-locally",
}));
const publisher = createOneTalkObservedPublisher(
(batch) => sinkBatches.push(batch),
correlator.observe,
);
const observed = {
messageType: "new",
upstreamType: 1,
messageId: "message-1",
conversationId: "conversation-1",
senderId: "seller@icbu",
direction: "sent",
sentAtMs: 1_000,
content: { version: 1, kind: "text", text: "hello" },
participantIds: ["buyer@icbu", "seller@icbu"],
readStatus: 0,
messageStatus: 1,
unreadCount: 0,
};
publisher({ messages: [observed], diagnostics: emptyDiagnostics() });
const result = await resultPromise;
const { messageType: _messageType, upstreamType: _upstreamType, ...confirmed } = observed;
assert.deepEqual(result, { status: "confirmed_sent", message: confirmed });
assert.equal(sinkBatches.length, 1);
assert.deepEqual(sinkBatches[0].messages, [observed]);
});
test("routes a profile snapshot once and rejects an unknown page action", async () => {
let snapshots = 0;
const handler = createOneTalkPageCommandHandler(
{},
{
sendObservation: {},
observedSink: () => undefined,
profileObserver: {
snapshot: () => {
snapshots += 1;
return [{ conversationId: "conversation-1" }];
},
},
historyBootstrapProgress: {},
},
);
assert.deepEqual(await handler({ command: { action: "onetalk.contact.snapshot" } }), {
status: "completed",
profileCount: 1,
});
assert.equal(snapshots, 1);
assert.deepEqual(await handler({ command: { action: "unexpected.action" } }), {
status: "rejected_before_send",
reason: "invalid_request",
});
assert.equal(snapshots, 1);
});
test("dispatches send and discovery actions to their dedicated handlers", async () => {
let sendCalls = 0;
let snapshots = 0;
const pageWindow = {
location: {
href: "https://onetalk.alibaba.com/?activeAccountId=account-1",
},
currentUserAccountId: "account-1",
IcbuIM: {
IMBaaSSDK: {
default: {
getMessageService: () => ({
sendUIMessages: () => {
sendCalls += 1;
throw new Error("send_failed");
},
}),
getConversationServiceV2: () => ({
getConversationListByPagination: async () => ({
list: [],
hasMore: false,
}),
}),
},
},
},
};
const handler = createOneTalkPageCommandHandler(pageWindow, {
sendObservation: createSendObservationCorrelator(100),
observedSink: () => undefined,
profileObserver: {
snapshot: () => {
snapshots += 1;
return [];
},
},
historyBootstrapProgress: {},
});
assert.deepEqual(
await handler({
requestId: "send-1",
command: {
action: "onetalk.send",
conversationId: "conversation-1",
content: { kind: "text", text: "hello" },
},
}),
{ status: "delivery_unknown", reason: "send_connection_lost" },
);
assert.equal(sendCalls, 1);
assert.deepEqual(await handler({ command: { action: "onetalk.discover-conversations" } }), {
status: "completed",
entries: [],
});
assert.equal(snapshots, 0);
});
test("does not call the SDK when send correlator wiring is missing", async () => {
let sendCalls = 0;
const pageWindow = {
IcbuIM: {
IMBaaSSDK: {
default: {
getMessageService: () => ({
sendUIMessages: () => {
sendCalls += 1;
},
}),
},
},
},
};
const handler = createOneTalkPageCommandHandler(pageWindow, {
sendObservation: {},
observedSink: () => undefined,
profileObserver: { snapshot: () => [] },
historyBootstrapProgress: {},
});
assert.deepEqual(
await handler({
command: {
action: "onetalk.send",
conversationId: "conversation-1",
content: { kind: "text", text: "hello" },
},
}),
{ status: "delivery_unknown", reason: "send_connection_lost" },
);
assert.equal(sendCalls, 0);
});
test("prepares the profile observer before bridge hello, then installs collection observers", async () => {
const previousWindow = globalThis.window;
const posted = [];
const listeners = [];
let manualHistoryAvailableDuringHello;
let messageObserverInstalledDuringHello;
class FakeWebSocket {
addEventListener() {}
}
const pageWindow = {
location: {
href: "https://onetalk.alibaba.com/message/weblitePWA.htm",
origin: "https://onetalk.alibaba.com",
},
currentUserAccountId: "account-1",
__conversationListData__: {
first: {
cid: "conversation-1",
aliId: "customer-1",
accountId: "customer-account-1",
loginId: "customer-login-1",
name: "Customer",
companyName: "Company",
complianceCountryCode: "CN",
currentTimeZone: 8,
serviceType: "service",
},
},
WebSocket: FakeWebSocket,
console: { log: () => undefined },
document: {
querySelectorAll: () => [],
querySelector: () => null,
addEventListener: (type, listener) => listeners.push({ type, listener }),
},
addEventListener: (type, listener) => listeners.push({ type, listener }),
clearTimeout,
setTimeout,
postMessage: (message, targetOrigin) => {
posted.push({ message, targetOrigin });
if (message.type !== "onetalk.page.hello") return;
manualHistoryAvailableDuringHello =
typeof pageWindow.__tradeMessageCenterOneTalk?.syncCurrentConversationHistory ===
"function";
messageObserverInstalledDuringHello =
pageWindow.__tradeMessageCenterOneTalkWebSocketTapInstalled === true;
for (const { type, listener } of listeners) {
if (type !== "message") continue;
listener({
source: pageWindow,
origin: pageWindow.location.origin,
data: {
source: ONE_TALK_PAGE_BRIDGE_SOURCE,
version: ONE_TALK_PAGE_BRIDGE_VERSION,
type: "onetalk.page.command",
requestId: "profile-snapshot",
command: { action: "onetalk.contact.snapshot" },
},
});
}
},
};
try {
globalThis.window = pageWindow;
await import("../src/onetalk/main-page/page-script-entry.ts");
await Promise.resolve();
await Promise.resolve();
assert.equal(manualHistoryAvailableDuringHello, false);
assert.equal(messageObserverInstalledDuringHello, false);
assert.equal(
typeof pageWindow.__tradeMessageCenterOneTalk.syncCurrentConversationHistory,
"function",
);
assert.equal(pageWindow.__tradeMessageCenterOneTalkWebSocketTapInstalled, true);
assert.equal(
posted.filter(({ message }) => message.type === "onetalk.page.profile-observed").length,
1,
);
assert.equal(
posted.filter(({ message }) => message.type === "onetalk.page.command-result").length,
1,
);
} finally {
globalThis.window = previousWindow;
}
});
@@ -3,6 +3,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { handleOneTalkSendCommand } from "../src/onetalk/main-page/commands/send.ts";
import { handleOneTalkHistoryCommand } from "../src/onetalk/main-page/current-conversation-history/page-command.ts";
import {
readCurrentConversationId,
@@ -65,7 +66,7 @@ const sendCommand = (conversationId = "conversation-1", text = "hello") => ({
});
const handleSendCommand = (pageWindow, message = sendCommand()) =>
handleOneTalkHistoryCommand(pageWindow, message, createSendObservationCorrelator(1));
handleOneTalkSendCommand(pageWindow, message, createSendObservationCorrelator(1));
const createHistorySyncPage = () => {
const conversation = {
@@ -207,7 +208,7 @@ test("does not use selected conversation as a send gate", async () => {
test("fails closed for file commands when MAIN cannot resolve the native uploader", async () => {
const { pageWindow, calls } = createSendPage(["conversation-2"]);
const result = await handleOneTalkHistoryCommand(
const result = await handleOneTalkSendCommand(
pageWindow,
{
requestId: "file-send",
@@ -269,7 +270,6 @@ test("publishes flat SDK history as a normalized bridge batch", async () => {
const result = await handleOneTalkHistoryCommand(
pageWindow,
{ command: { action: "onetalk.sync" } },
createSendObservationCorrelator(1),
(batch) => {
batches.push(batch);
events.push("observation");
@@ -344,7 +344,6 @@ test("settles bootstrap tooltip progress only after a successful or final histor
terminalConversationIds: ["buyer-seller#tenant@icbu"],
},
},
createSendObservationCorrelator(1),
undefined,
progress,
);
@@ -361,7 +360,6 @@ test("settles bootstrap tooltip progress only after a successful or final histor
finalAttempt: false,
},
},
createSendObservationCorrelator(1),
undefined,
progress,
),
@@ -382,7 +380,6 @@ test("settles bootstrap tooltip progress only after a successful or final histor
finalAttempt: true,
},
},
createSendObservationCorrelator(1),
undefined,
progress,
),
@@ -413,7 +410,6 @@ test("keeps history command results unchanged when the local tooltip boundary fa
await handleOneTalkHistoryCommand(
pageWindow,
{ command: { action: "onetalk.discover-conversations" } },
createSendObservationCorrelator(1),
undefined,
throwingProgress,
)
@@ -431,7 +427,6 @@ test("keeps history command results unchanged when the local tooltip boundary fa
finalAttempt: true,
},
},
createSendObservationCorrelator(1),
undefined,
throwingProgress,
)
@@ -450,7 +445,6 @@ test("keeps history command results unchanged when the local tooltip boundary fa
finalAttempt: true,
},
},
createSendObservationCorrelator(1),
undefined,
throwingProgress,
),
@@ -464,7 +458,6 @@ test("keeps history command results unchanged when the local tooltip boundary fa
await handleOneTalkHistoryCommand(
failedDiscovery.pageWindow,
{ command: { action: "onetalk.discover-conversations" } },
createSendObservationCorrelator(1),
undefined,
throwingProgress,
)
@@ -491,7 +484,6 @@ test("does not turn a successful history result into a failure when tooltip sett
await handleOneTalkHistoryCommand(
pageWindow,
{ command: { action: "onetalk.discover-conversations" } },
createSendObservationCorrelator(1),
undefined,
progress,
);
@@ -504,7 +496,6 @@ test("does not turn a successful history result into a failure when tooltip sett
conversationId: "buyer-seller#tenant@icbu",
},
},
createSendObservationCorrelator(1),
undefined,
progress,
);