/** * Proving that a sent message actually reached the server. * * Asking and being answered used to be one indivisible step. That made a send which * never arrived indistinguishable from a slow reply, because the page draws an * optimistic "thinking" turn either way -- so the bridge reported "no plan text", which * points at the copy path. A real investigation went down that branch and concluded the * reply extraction was at fault when the question had never left. * * The first two instruments for this were both wrong, and both looked right: * * mounted user nodes -- the DOM virtualizes old turns away, so the count FALLS while * a send succeeds. Observed live: 3 before sending, 2 after, * and the reply to that message arrived normally. * `conversation-turn-N` -- indexes position *inside a roughly five-turn window*, so it * slides and retreats. Measured on the same thread: the same * messages read as turn-12..16, then as turn-8..12 (max 16 to * 12). A comment here claimed the opposite -- "the absolute * turn index, which only grows" -- and was disproved by * measurement. * * That second mistake is worth stating plainly because of what it fed: the failure branch * ends in a resend, so a false negative does not merely mislead, it turns a working send * into a duplicate message. A check added to prevent duplicates would have manufactured * them. * * So the instrument is abandoned rather than tuned. Every message carries * `data-message-id`, which is stable for the life of the message and independent of where * the window sits, and the question becomes set membership: did a user id appear that was * not there before. The predicate itself lives in identity.ts, where the window-slide * cases can be tested offline -- these failures were not caught by tests that only * exercised the happy path, and they are the reason that is no longer acceptable. */ import { judgeDelivery, type MessageRef } from "./identity.ts"; export interface DeliveryProbe { /** Every message currently on screen, with its stable identity. */ readMessages: () => Promise; sleep: (ms: number) => Promise; now: () => number; } export interface DeliveryResult { ok: boolean; /** How many messages the last sample saw, for diagnosis. */ sampledMessages: number; waitedMs: number; /** How many read attempts were made, successful or not. */ samples: number; /** * The page exposed no message ids, so delivery could not be judged by identity. * * Reported rather than hidden: it means the decision fell back to something weaker, * and the caller may want to say so. */ identityUnavailable: boolean; } export const DEFAULT_DELIVERY_TIMEOUT_MS = 20_000; const POLL_INTERVAL_MS = 500; /** * Wait for a user message id that was not in `baselineUserIds`. * * Reads once before the first sleep: a page that already mounted the turn should not pay * a poll interval for it. A read that throws is not treated as "not delivered" -- it is * retried until the deadline, because a transient evaluate failure says nothing about * whether the message arrived, and calling that a lost send would resend a message that * did land. */ export async function confirmDelivery( baselineUserIds: readonly string[], probe: DeliveryProbe, options: { timeoutMs?: number } = {}, ): Promise { const timeoutMs = options.timeoutMs ?? DEFAULT_DELIVERY_TIMEOUT_MS; const startedAt = probe.now(); let samples = 0; let sampledMessages = 0; let identityUnavailable = false; for (;;) { // Counted before the read, so it records attempts rather than successes. When a // page is unreadable, the attempts are the number that explains the delay; the // successes would just read as "one sample, twenty seconds". samples += 1; try { const messages = await probe.readMessages(); sampledMessages = messages.length; const verdict = judgeDelivery(messages, baselineUserIds); identityUnavailable = verdict.identityUnavailable; if (verdict.delivered) { return { ok: true, sampledMessages, waitedMs: probe.now() - startedAt, samples, identityUnavailable }; } } catch { // Keep going: an unreadable page is not evidence that the message failed to send. } if (probe.now() - startedAt >= timeoutMs) { return { ok: false, sampledMessages, waitedMs: probe.now() - startedAt, samples, identityUnavailable }; } await probe.sleep(POLL_INTERVAL_MS); } } /** * The failure text for a send that did not arrive. * * Lives here so the classification in cycle.ts and the message in the page adapter are the * same string by construction. They have to agree: the adapter decides *that* it failed, * the ladder decides *what to do*, and it can only pick the safe recovery if it recognises * this as a delivery failure rather than a page problem. */ export function deliveryFailureMessage(input: { baselineCount: number; waitedMs: number; identityUnavailable: boolean; }): string { const why = input.identityUnavailable ? "页面没有提供 data-message-id,无法用稳定身份判断投递" : `没有出现新的用户消息 id(发送前有 ${input.baselineCount} 条)`; return `消息没有送达(等待 ${input.waitedMs}ms:${why})。`; } /* ------------------------------------------------------------------ */ /* Was it submitted? Three answers, not two */ /* ------------------------------------------------------------------ */ /** * What a probe of the composer can conclude about the submission. * * `not-submitted` and `unknown` are different claims and must stay different: the first * says the page was read and no new turn appeared, the second says the page could not be * read at all. Collapsing them (the shape BUG-062 filed) reported "消息没有发出去" for a * message that had in fact been delivered — the probe had merely failed to observe — and * an operator reading that would reasonably send it again. */ export type SubmitObservation = "submitted" | "not-submitted" | "unknown"; /** * The decision, given what the probes saw. * * One observation that showed no new turn outranks any number of failed reads: we have * evidence about the page, and it says nothing arrived. Only a window in which EVERY read * failed is `unknown`. A new user-message identity (or a new turn number, on a UI without * per-message ids) is `submitted` — that predicate is BUG-056's and is not relaxed here. */ export function judgeSubmitProbe(input: { sawNewTurn: boolean; observations: number; unobservable: number }): SubmitObservation { if (input.sawNewTurn) return "submitted"; if (input.observations === 0) return "unknown"; return "not-submitted"; } /** * The error for a submission nobody could confirm. * * It names the one thing that is true — the page could not be read — and explicitly drops * the two inferences the old wording made: that the message did not go out, and that * pressing Enter again is therefore safe. Nothing is retried (MESSAGE_AUTO_RETRY is 0, and * this path does not press Enter again even once), because a duplicate turn on the user's * account is a worse outcome than a report that says "I could not tell". */ export function submitUnconfirmedMessage(input: { waitedMs: number; unobservable: number }): string { return ( `送达状态无法确认:${input.waitedMs}ms 内 ${input.unobservable} 次读不到页面状态,` + "既不能证明已送达、也不能证明没有送出。不自动重发、不重复按回车。" + "请查看会话页面或运行 /brb doctor 后自行决定下一步。" ); }