fix: allow earlier timestamps in send observation matching

This commit is contained in:
YBF
2026-09-16 17:16:21 +08:00
parent 95d996249a
commit 4dfce37c07
3 changed files with 10 additions and 23 deletions
@@ -40,7 +40,7 @@ SDK 会先按 `normalized.conversationCode = input.cid || SDK.context.cid` 归
2. MAIN 只调用一次 sendUIMessages({ cid: conversationId, conversationCode: conversationId, content, ext }),不调用 sendMessage、send 或 sendTextMessage,不执行 DOM/fallback 发送;`cid``conversationCode` 必须同值。
3. SDK 调用前登记短生命周期的发送观察关联;现有 OneTalk WebSocket 旁路 observer 直接消费解析后的消息批次,不使用 confirmation waiter 或 window confirmation listener。
4. SDK Promise 返回值只表示本地受理(通常含 opId)。只有 WebSocket 旁路观察到目标会话的完整 direction: sent 消息,才能生成 confirmed_sent。
5. 观察消息必须匹配 command 的目标 conversationId(观察消息中的 conversationId),并通过完整 OneTalkMessage guard。若 SDK 返回候选 message ID,候选 ID 是主关联键:只按目标会话、`direction: "sent"`、完整消息和 ID 匹配确认,不再额外套正文或时间窗口;候选 ID 不匹配时不得退回正文匹配。只有没有候选 ID 时,才按正文与发送时间窗口匹配
5. 观察消息必须匹配 command 的目标 conversationId(观察消息中的 conversationId),并通过完整 OneTalkMessage guard。若 SDK 返回候选 message ID,候选 ID 是主关联键:只按目标会话、`direction: "sent"`、完整消息和 ID 匹配确认,不再额外套正文或时间窗口;候选 ID 不匹配时不得退回正文匹配。只有没有候选 ID 时,才按正文或媒体指纹匹配;不得把 `sentAtMs` 早于本地登记 pending 的时刻作为拒绝条件。仅拒绝晚于当前观测时刻五秒以上的未来 `sentAtMs`,防止未来时间戳误关联
6. 每个发送各自登记一个 pending。对每条完整 sent observation,只计算它与当前 pending 的匹配集合:唯一匹配时仅确认该项;零匹配时不改变任何 pending;多重匹配时仅将匹配集合结算为 delivery_unknown/send_ambiguous。观测消息不得结束未匹配的 pending。
7. 媒体的 target/current 身份检查只属于 native 发送前的不可逆调用围栏;不得传入 observer 作为清理其它 pending 的条件。SDK 异常、WebSocket 断线、旁路事实不完整、观察超时或无法唯一关联时,返回 delivery_unknown 加有限 reason;不得自动重试或伪造消息。
8. 发送观察关联器是 MAIN send handler 的必需依赖;必须先登记 pending observation 再调用 SDK。关联器缺失属于 wiring 错误,必须 fail closed,不得直调 SDK 并立即返回 unknown 作为 fallback。
@@ -60,6 +60,8 @@ SDK 会先按 `normalized.conversationCode = input.cid || SDK.context.cid` 归
| 仅返回 opId/undefined,没有匹配旁路事实 | delivery_unknown/send_state_lost |
| 有候选 message ID 且 ID 匹配,但消息超出正文时间窗口 | 仍可按 ID 确认;时间窗口不适用于候选 ID 关联 |
| 有候选 message ID 不匹配,但正文相同 | 不确认,不退回正文匹配,最终 delivery_unknown/send_state_lost |
| 无候选 ID 的完整匹配消息,其 `sentAtMs` 早于本地登记 pending 的时刻 | 仍确认;本地登记时刻不是关联条件 |
| 无候选 ID 的完整匹配消息,其 `sentAtMs` 晚于当前观测时刻五秒以上 | 不确认,防止未来时间戳误关联 |
| 观察消息错误会话、错误方向或缺必填字段 | 不确认,最终 delivery_unknown/send_state_lost |
| 一条完整 sent observation 仅匹配一个 pending,另有其它 pending 等待 | 只确认匹配项;其它 pending 保持等待 |
| 同一条 observation 匹配多个 pending,另有不匹配 pending 等待 | 仅匹配集合为 delivery_unknown/send_ambiguous;不匹配项保持等待 |
@@ -71,6 +73,7 @@ SDK 会先按 `normalized.conversationCode = input.cid || SDK.context.cid` 归
- Good:唯一同账号页面接收 commandMAIN 调用一次 sendUIMessagesWebSocket 旁路观察到目标会话完整 sent message,再发合法 send.confirmation。
- Good:当前打开 conversation-A、目标为 conversation-B;不改变页面 selected 状态,SDK input 使用 `cid=B`(并保留同值 `conversationCode=B`)。
- Good:SDK 未返回候选 ID 时,旁路观察的完整目标消息即使 `sentAtMs` 早于本地登记 pending 的时刻,仍按正文或媒体指纹确认。
- BaseSDK 返回 { opId } 但观察链尚未闭合,发送合法 delivery_unknown/send_state_lost,不承诺成功。
- Bad:只传 `conversationCode=B`、把 URL conversationId 当 SPA 会话、调用旧 API、把 opId 当 confirmed_sent、用页面文本当成功证据或自动重试不确定发送。
@@ -78,7 +81,7 @@ SDK 会先按 `normalized.conversationCode = input.cid || SDK.context.cid` 归
- Service Worker:发送只要求唯一同账号页面;当前 selected 为其它会话、零 selected、多 selected 时仍投递;同账号多页面不广播;历史 sync 仍保持精确路由。
- MAIN handler:断言只调用一次 sendUIMessages,参数包含 `cid`、同值的兼容 `conversationCode`、字符串 content 和可选 ext;不调用旧 API、DOM 或 fallback;缺少 cid 的输入不得进入 SDK。
- WebSocket observer/correlator:覆盖完整 sent、错误会话、received、不完整消息、候选 ID 匹配(含超出正文时间窗口)/不匹配(不得正文回退)、无候选 ID 的正文/时间窗口、legacy `sentAt`、超时,以及 text/image/file pending 的唯一匹配仅结算自身、多个命中只结算匹配集合、未命中项继续等待;断言缺失 correlator wiring 不会直调 SDK。
- WebSocket observer/correlator:覆盖完整 sent、错误会话、received、不完整消息、候选 ID 匹配(含超出正文时间窗口)/不匹配(不得正文回退)、无候选 ID 的正文或媒体指纹匹配、早于本地 pending 登记时刻的 `sentAtMs` 仍确认、晚于当前观测时刻五秒以上的 `sentAtMs` 拒绝、legacy `sentAt`、超时,以及 text/image/file pending 的唯一匹配仅结算自身、多个命中只结算匹配集合、未命中项继续等待;断言缺失 correlator wiring 不会直调 SDK。
- Service Worker/contract:只接受完整 sent message 作为 confirmed_sentopId/undefined/缺字段/错误方向/缺 reason 均收敛到合法三态。
- 联调 smoke:当前打开非目标会话时发送普通字符串,确认 SDK 由 `cid` 选中目标会话(而不是 SDK.context.cid)、WebSocket sent observation、server inbound confirmation 和随后 send.result;页面出现文本或 SDK 返回 opId 不能作为完成证据。
@@ -100,7 +103,8 @@ const accepted = await messageService.sendUIMessages({
ext,
});
void accepted;
// 有候选 ID 时只按 ID 关联;无候选 ID 时按正文和时间窗口关联。
// 有候选 ID 时只按 ID 关联;无候选 ID 时按正文或媒体指纹关联。
// 不用本地 pending 登记时刻拒绝较早的 sentAtMs;仅拒绝超过当前观测时刻五秒的未来值。
// 未注入 observer correlator 时应 fail closed,不能执行上面的 SDK 调用。
// 由现有 WebSocket observer 匹配完整 direction: "sent" 消息;否则 delivery_unknown。
\`\`\`
@@ -15,7 +15,6 @@ type PendingSend = {
kind: "text";
conversationId: string;
content: string;
sentAfterMs: number;
candidateMessageIds: Set<string>;
resolve: (result: PageCommandResult) => void;
timer?: ReturnType<typeof setTimeout>;
@@ -24,7 +23,6 @@ type PendingSend = {
type PendingImageSend = {
kind: "image";
conversationId: string;
sentAfterMs: number;
candidateMessageIds: Set<string>;
expected: {
sizeBytes: number;
@@ -40,7 +38,6 @@ type PendingImageSend = {
type PendingFileSend = {
kind: "file";
conversationId: string;
sentAfterMs: number;
candidateMessageIds: Set<string>;
expected: {
fileName: string;
@@ -161,12 +158,7 @@ const matches = (
);
}
const sentAtMs = message.sentAtMs;
if (
typeof sentAtMs !== "number" ||
sentAtMs < pending.sentAfterMs ||
sentAtMs > nowMs + MATCH_WINDOW_MS
)
return false;
if (typeof sentAtMs !== "number" || sentAtMs > nowMs + MATCH_WINDOW_MS) return false;
if (pending.kind === "text") return textOf(message) === pending.content;
return pending.kind === "image"
? matchesImage(pending.expected, message)
@@ -207,13 +199,11 @@ export const createSendObservationCorrelator = (
return {
execute: async (conversationId, content, ext, send) => {
const sentAfterMs = now();
const result = await new Promise<PageCommandResult>((resolve) => {
const record: PendingSend = {
kind: "text",
conversationId,
content,
sentAfterMs,
candidateMessageIds: new Set<string>(),
resolve,
};
@@ -245,12 +235,10 @@ export const createSendObservationCorrelator = (
return result;
},
executeImage: async (conversationId, expected, send, imageOptions = {}) => {
const sentAfterMs = now();
return new Promise<PageCommandResult>((resolve) => {
const record: PendingImageSend = {
kind: "image",
conversationId,
sentAfterMs,
candidateMessageIds: new Set<string>(),
expected,
resolve,
@@ -298,12 +286,10 @@ export const createSendObservationCorrelator = (
});
},
executeFile: async (conversationId, expected, send, fileOptions = {}) => {
const sentAfterMs = now();
return new Promise<PageCommandResult>((resolve) => {
const record: PendingFileSend = {
kind: "file",
conversationId,
sentAfterMs,
candidateMessageIds: new Set<string>(),
expected,
resolve,
@@ -121,7 +121,7 @@ test("matches a .PNM SDK send result to its canonical observed message ID", asyn
assert.equal((await resultPromise).status, "confirmed_sent");
});
test("uses a five-second window and never confirms a pre-send observation", async () => {
test("accepts earlier observed timestamps while keeping the five-second future window", async () => {
const beforeSend = createSendObservationCorrelator(20);
const beforeSendResult = beforeSend.execute(
"conversation-1",
@@ -131,10 +131,7 @@ test("uses a five-second window and never confirms a pre-send observation", asyn
);
await new Promise((resolve) => setImmediate(resolve));
beforeSend.observe([completeSent({ sentAtMs: Date.now() - 1_000 })]);
assert.deepEqual(await beforeSendResult, {
status: "delivery_unknown",
reason: "send_state_lost",
});
assert.equal((await beforeSendResult).status, "confirmed_sent");
const outsideWindow = createSendObservationCorrelator(20);
const outsideWindowResult = outsideWindow.execute(