/** * Message identity. * * Two bugs came from the same mistake: trusting a DOM position to mean "this is the * newest thing". `state().text` took the last assistant node *in document order* while * `turnNumber` took the highest `conversation-turn-N`, and during a re-render those * disagree, so a settled-looking state could hand back the previous reply. The repair * for that was applied to the copy path and never reached the read path. * * The second instrument was no better. Counting mounted nodes fails because the DOM * virtualizes old turns away; the count falls while a send succeeds. Switching to the * turn number failed for a different reason: `conversation-turn-N` indexes position * *inside a roughly five-turn window*, so it slides and retreats too. Both were * measured live by two lanes, independently. * * What does not move is the message id. Every message node carries `data-message-id`, * which is stable for the life of the message and independent of where the window * currently sits. So the questions become set membership questions: * * Did the send arrive? a user id appeared that was not there before * Is this reply new? an assistant id appeared that was not there before * Which assistant is newest? the one whose containing turn has the highest number * * The last one still uses the turn number, and that is fine: it is a *relative * comparison within one snapshot*, not a comparison across snapshots. A sliding window * cannot make it wrong, because both candidates are compared under the same window. * * The functions here are pure so the failure modes can be tested offline. That matters * more than usual: the two instruments that were wrong both looked correct, and neither * was caught by a test that only exercised the happy path. */ /** * `/c/` from a conversation URL, or null. * * The single implementation in the repository (verdict #11): both chatgpt.ts and * binding.ts used to carry a private copy, and copies drift. Also the lesson that * motivates keeping it pure and central: a conversation id copied from memory * instead of from the authoritative source was spliced (prefix of one id, suffix * of another), and every downstream instrument that only compared prefixes let * the wrong id through. An id is identity, and identity parsing has exactly one * home. */ export function conversationIdOf(url: string): string | null { const match = /\/c\/([0-9a-fA-F-]{8,})/.exec(url ?? ""); return match ? match[1] : null; } /** * Classify the browser route without changing conversation identity semantics. * * A `/c/WEB:` route is a ChatGPT draft locator, not a conversation id. The * draft uuid is deliberately retained only as route provenance: it must never enter * registry ownership or affinity keys because the durable `/c/` assigned after * the first send can be different (as observed live in BUG-031). * * `TRANSIENT` is the same kind of thing seen through a second door (BUG-060): the * 2026-10 UI also parks a brand-new conversation at `/c/local-chatgpt:` — with * the colon sometimes percent-encoded — while the first submit is in flight. It is a * client-side handle that the durable id replaces, so it is never an identity either: * the only difference between it and `DRAFT_WEB` is which rollout produced it. */ export type ConversationRouteKind = "BARE" | "DRAFT_WEB" | "TRANSIENT" | "DURABLE" | "OTHER"; /** * Non-durable route locators: valid places for a page to BE, never identities. * * Both spellings of the colon are accepted for both forms. The encoded one is what the * live page actually produced for `local-chatgpt%3A` (BUG-060); tolerating it on * `WEB:` as well cannot widen what counts as an identity — these are the routes that * are explicitly NOT identities — and leaving one form out would make the classifier's * answer depend on how the browser happened to encode a colon. */ const NON_DURABLE_ROUTE = /^\/c\/(?:WEB|local-chatgpt)(?::|%3[Aa])[0-9a-zA-Z_-]{8,}$/; export function classifyConversationRoute(url: string): ConversationRouteKind { try { const parsed = new URL(url ?? ""); if (parsed.protocol !== "https:" || parsed.hostname !== "chatgpt.com") return "OTHER"; if (parsed.pathname === "/" || parsed.pathname === "") return "BARE"; if (NON_DURABLE_ROUTE.test(parsed.pathname)) { return parsed.pathname.startsWith("/c/WEB") ? "DRAFT_WEB" : "TRANSIENT"; } if (/^\/c\/[0-9a-fA-F-]{8,}$/.test(parsed.pathname)) return "DURABLE"; return "OTHER"; } catch { return "OTHER"; } } /** * Is this URL a non-durable draft locator rather than a conversation address? * * One predicate instead of the same pair written out at every call site: BUG-060 existed * because three places each enumerated the non-durable routes by hand, and adding a route * meant finding all of them. Anything deciding "is this an identity" or "is this worth * recording as draft provenance" asks here. */ export function isDraftLocatorRoute(url: string): boolean { const route = classifyConversationRoute(url); return route === "DRAFT_WEB" || route === "TRANSIENT"; } /** Ownership/lock key: draft WEB routes are never durable conversation keys. */ export function conversationOwnershipKey(url: string, targetId?: string): string { const route = classifyConversationRoute(url); // Every non-durable locator keys on the pinned tab, never on the route. A brand-new // chat's address is the same for every brand-new chat, so keying on one would let two // threads drive one conversation -- which is the whole reason this function exists. // TRANSIENT joined the list with BUG-060: leaving it out (the pair was hardcoded) sent // the route itself to the registry as an ownership key. if (route === "BARE" || isDraftLocatorRoute(url)) { return targetId ? `target:${targetId}` : "target:unidentified"; } return conversationIdOf(url) ?? targetId ?? url; } export type MessageRole = "user" | "assistant"; export interface MessageRef { /** `data-message-id`. The stable identity. */ id: string; role: MessageRole; /** `conversation-turn-N` of the containing section, or -1 when absent. */ turn: number; text: string; } /** Message ids currently on screen, by role. */ export interface IdentitySnapshot { userIds: string[]; assistantIds: string[]; } export function emptySnapshot(): IdentitySnapshot { return { userIds: [], assistantIds: [] }; } /** * The assistant message inside the highest-numbered turn. * * Deliberately not "the last node in document order": observed live, a large paste * turned into a file attachment and left the DOM holding the old reply after the new * one, so the last node was the stale answer. */ export function newestAssistant(messages: MessageRef[]): MessageRef | null { let best: MessageRef | null = null; for (const message of messages) { if (message.role !== "assistant") continue; if (best === null || message.turn >= best.turn) best = message; } return best; } /** * A message of this role whose id is not in the baseline. * * Set membership rather than a count or an index, because both of those are window * relative and the window slides. When the window moves, ids leave it -- which is why * "the id is missing from the baseline" is the test and "the id is present now" is not * sufficient on its own either: the baseline is what carries the meaning. * * Returns the newest such message, so a rerender that mounts several at once still * yields the answer rather than an arbitrary one. */ export function findNewByRole( messages: MessageRef[], role: MessageRole, baselineIds: readonly string[], ): MessageRef | null { const known = new Set(baselineIds); let best: MessageRef | null = null; for (const message of messages) { if (message.role !== role) continue; if (known.has(message.id)) continue; if (best === null || message.turn >= best.turn) best = message; } return best; } /** An id-less node cannot be trusted to be new, so it is never returned. */ export function hasAnyIds(messages: MessageRef[], role: MessageRole): boolean { return messages.some((message) => message.role === role && message.id.length > 0); } export interface ReplyVerdict { /** The new assistant message, when one has appeared. */ reply: MessageRef | null; /** * The page exposes no message ids at all, so "is this new" cannot be answered. * * Fail closed: the caller must keep waiting rather than return the newest text, * because the newest text may be the previous reply -- which is the exact defect * this module exists to prevent. Timeout then reports a failure, which is honest, * instead of returning a plausible wrong answer, which is not. */ identityUnavailable: boolean; } export function judgeReply( messages: MessageRef[], baselineAssistantIds: readonly string[], ): ReplyVerdict { if (!hasAnyIds(messages, "assistant")) { return { reply: null, identityUnavailable: true }; } return { reply: findNewByRole(messages, "assistant", baselineAssistantIds), identityUnavailable: false }; } export interface DeliveryVerdict { delivered: boolean; identityUnavailable: boolean; } /** * Whether the send reached the server, judged by a new user message id. * * The two earlier instruments and why each failed: * mounted user nodes -- the count falls when old turns are virtualized away, so a * delivered send can read as a negative; * `conversation-turn-N` -- indexes position inside a sliding window, so it retreats; * measured max 16 -> 12 on the same thread and messages. * A negative here is not merely a wrong reading: its recovery is "send it again", so a * false negative turns a working send into a duplicate message. */ export function judgeDelivery( messages: MessageRef[], baselineUserIds: readonly string[], ): DeliveryVerdict { if (!hasAnyIds(messages, "user")) { return { delivered: false, identityUnavailable: true }; } return { delivered: findNewByRole(messages, "user", baselineUserIds) !== null, identityUnavailable: false }; }