| 1 | export type LocalSubmissionStatus = "sending" | "accepted" | "unknown" | "failed"; |
| 2 | |
| 3 | export interface LocalSubmission { |
| 4 | submissionId: string; |
| 5 | localId: string; |
| 6 | text: string; |
| 7 | submitText?: string; |
| 8 | createdAt: number; |
| 9 | sequence: number; |
| 10 | anchorItemId?: string; |
| 11 | placement?: "start" | "after" | "latest"; |
| 12 | status: LocalSubmissionStatus; |
| 13 | messageId?: string; |
| 14 | turnId?: string; |
| 15 | checkpointTurn?: number; |
| 16 | /** The first terminal event consumed this submission's checkpoint authority. */ |
| 17 | settled?: boolean; |
| 18 | } |
| 19 | |
| 20 | export interface LocalSubmissionFields { |
| 21 | localSubmissions: Record<string, LocalSubmission>; |
| 22 | localSubmissionOrder: string[]; |
| 23 | localSubmissionSendRevision: number; |
| 24 | visibleSubmissionHandoffs: Record<string, { submissionId: string }>; |
| 25 | } |
| 26 | |
| 27 | export type CanonicalUserConfirmation = { messageId: string; submissionId?: string; turnId?: string }; |
| 28 | type CanonicalUserIdentity = { |
| 29 | kind: string; |
| 30 | messageId?: string; |
| 31 | submissionId?: string; |
| 32 | turnId?: string; |
| 33 | }; |
| 34 | |
| 35 | export function canonicalUserConfirmations(items: readonly CanonicalUserIdentity[]): CanonicalUserConfirmation[] { |
| 36 | return items.flatMap(item => item.kind === "user" && item.messageId ? [{ messageId: item.messageId, submissionId: item.submissionId, turnId: item.turnId }] : []); |
| 37 | } |
| 38 | |
| 39 | /** Identity matching is shared by state settlement and defensive presentation. */ |
| 40 | export function matchLocalSubmissions(submissions: readonly LocalSubmission[], confirmations: readonly CanonicalUserConfirmation[]) { |
| 41 | const consumed = new Set<string>(); |
| 42 | const matches: Array<{ submissionId: string; messageId: string }> = []; |
| 43 | for (const confirmation of confirmations) { |
| 44 | const available = (local: LocalSubmission) => !consumed.has(local.submissionId); |
| 45 | const local = submissions.find(local => available(local) && local.messageId === confirmation.messageId) |
| 46 | ?? submissions.find(local => available(local) && !local.messageId && Boolean(confirmation.submissionId) && local.submissionId === confirmation.submissionId); |
| 47 | if (!local) continue; |
| 48 | consumed.add(local.submissionId); |
| 49 | matches.push({ submissionId: local.submissionId, messageId: confirmation.messageId }); |
| 50 | } |
| 51 | return matches; |
| 52 | } |
| 53 | |
| 54 | export function pruneSubmissionHandoffs<T extends LocalSubmissionFields>(state: T, items: readonly CanonicalUserIdentity[]): T { |
| 55 | const visible = new Set(canonicalUserConfirmations(items).map(item => item.messageId)); |
| 56 | const entries = Object.entries(state.visibleSubmissionHandoffs).filter(([id]) => visible.has(id)); |
| 57 | return entries.length === Object.keys(state.visibleSubmissionHandoffs).length ? state |
| 58 | : { ...state, visibleSubmissionHandoffs: Object.fromEntries(entries) }; |
| 59 | } |
| 60 | |
| 61 | export function isUnknownSubmissionError(error: unknown): boolean { |
| 62 | return /timeout|timed out|network|connection|socket|channel.*closed|fetch failed|failed to fetch|\beof\b/i.test(error instanceof Error ? error.message : String(error)); |
| 63 | } |
| 64 | |
| 65 | export function orderedLocalSubmissions(state: LocalSubmissionFields): LocalSubmission[] { |
| 66 | return state.localSubmissionOrder.flatMap((submissionId) => { |
| 67 | const submission = state.localSubmissions[submissionId]; |
| 68 | return submission ? [submission] : []; |
| 69 | }); |
| 70 | } |
| 71 | |
| 72 | export function beginLocalSubmission<T extends LocalSubmissionFields>( |
| 73 | state: T, |
| 74 | submission: Omit<LocalSubmission, "status">, |
| 75 | ): T { |
| 76 | return { |
| 77 | ...state, |
| 78 | localSubmissions: { ...state.localSubmissions, [submission.submissionId]: { ...submission, status: "sending" } }, |
| 79 | localSubmissionOrder: state.localSubmissionOrder.includes(submission.submissionId) |
| 80 | ? state.localSubmissionOrder |
| 81 | : [...state.localSubmissionOrder, submission.submissionId], |
| 82 | localSubmissionSendRevision: state.localSubmissionSendRevision + 1, |
| 83 | }; |
| 84 | } |
| 85 | |
| 86 | export function updateLocalSubmission<T extends LocalSubmissionFields>( |
| 87 | state: T, |
| 88 | submissionId: string | undefined, |
| 89 | patch: Partial<LocalSubmission>, |
| 90 | ): T { |
| 91 | if (!submissionId) return state; |
| 92 | const current = state.localSubmissions[submissionId]; |
| 93 | if (!current) return state; |
| 94 | if (current.messageId && patch.messageId && current.messageId !== patch.messageId) return state; |
| 95 | return { |
| 96 | ...state, |
| 97 | localSubmissions: { ...state.localSubmissions, [submissionId]: { ...current, ...patch, submissionId } }, |
| 98 | }; |
| 99 | } |
| 100 | |
| 101 | export function removeLocalSubmission<T extends LocalSubmissionFields>(state: T, submissionId: string | undefined): T { |
| 102 | if (!submissionId || !state.localSubmissions[submissionId]) return state; |
| 103 | const localSubmissions = { ...state.localSubmissions }; |
| 104 | delete localSubmissions[submissionId]; |
| 105 | return { |
| 106 | ...state, |
| 107 | localSubmissions, |
| 108 | localSubmissionOrder: state.localSubmissionOrder.filter((candidate) => candidate !== submissionId), |
| 109 | }; |
| 110 | } |
| 111 | |
| 112 | /** Retire each local echo once its durable user message is installed. */ |
| 113 | export function settleLocalSubmissions<T extends LocalSubmissionFields>( |
| 114 | state: T, |
| 115 | items: readonly CanonicalUserIdentity[], |
| 116 | confirmations: readonly CanonicalUserConfirmation[] = canonicalUserConfirmations(items), |
| 117 | ): T { |
| 118 | state = pruneSubmissionHandoffs(state, items); |
| 119 | if (state.localSubmissionOrder.length === 0) return state; |
| 120 | const remaining = { ...state.localSubmissions }; |
| 121 | const matches = matchLocalSubmissions(orderedLocalSubmissions(state), confirmations); |
| 122 | const consumed = new Set(matches.map(match => match.submissionId)); |
| 123 | const visible = new Set(canonicalUserConfirmations(items).map(item => item.messageId)); |
| 124 | const handoffs = { ...state.visibleSubmissionHandoffs }; |
| 125 | for (const match of matches) { |
| 126 | delete remaining[match.submissionId]; |
| 127 | if (visible.has(match.messageId) && !handoffs[match.messageId]) handoffs[match.messageId] = { submissionId: match.submissionId }; |
| 128 | } |
| 129 | if (consumed.size === 0) return state; |
| 130 | return { |
| 131 | ...state, |
| 132 | localSubmissions: remaining, |
| 133 | visibleSubmissionHandoffs: handoffs, |
| 134 | localSubmissionOrder: state.localSubmissionOrder.filter((submissionId) => !consumed.has(submissionId)), |
| 135 | }; |
| 136 | } |
| 137 | |
| 138 | export type RebasedUserRecord = { submissionId?: string; text: string; submitText?: string }; |
| 139 | type EchoLike = { kind: string; id: string; text?: string; submitText?: string }; |
| 140 | |
| 141 | function submittedText(entry: { text: string; submitText?: string }): string { |
| 142 | return entry.submitText ?? entry.text; |
| 143 | } |
| 144 | |
| 145 | /** User echoes that were already on screen when this submission was created. */ |
| 146 | function preexistingEchoes(previousItems: readonly EchoLike[], local: LocalSubmission): EchoLike[] { |
| 147 | if (!local.anchorItemId) return local.placement === "start" ? [] : [...previousItems]; |
| 148 | const anchor = previousItems.findIndex(item => item.id === local.anchorItemId); |
| 149 | return anchor < 0 ? [...previousItems] : previousItems.slice(0, anchor + 1); |
| 150 | } |
| 151 | |
| 152 | /** |
| 153 | * A snapshot rebase (runtime rebuild, serve restart, model switch) may carry |
| 154 | * user records without the message ids settleLocalSubmissions keys on, so an |
| 155 | * echo the server already journaled would otherwise survive as a duplicate |
| 156 | * turn stuck at "processing". Retire only echoes the rebased projection |
| 157 | * provably owns: one whose submission id a durable record repeats, or — with |
| 158 | * no turn in flight — the oldest echo whose text the newest durable user |
| 159 | * record repeats. The text match is bounded by the echoes' own anchor: a |
| 160 | * record that already existed when the echo was created can never absorb it, |
| 161 | * so a lost re-send of an identical message stays visible. An active runtime |
| 162 | * never absorbs by text because the trailing record may be an older sibling |
| 163 | * of the in-flight submission. |
| 164 | */ |
| 165 | export function settleRebasedSubmissions<T extends LocalSubmissionFields>( |
| 166 | state: T, |
| 167 | previousItems: readonly EchoLike[], |
| 168 | durable: readonly RebasedUserRecord[], |
| 169 | idle: boolean, |
| 170 | ): T { |
| 171 | const pending = orderedLocalSubmissions(state).filter(local => local.status !== "failed"); |
| 172 | if (pending.length === 0) return state; |
| 173 | const consumed = new Set<string>(); |
| 174 | const durableSubmissionIds = new Set(durable.map(record => record.submissionId).filter(Boolean)); |
| 175 | for (const local of pending) if (durableSubmissionIds.has(local.submissionId)) consumed.add(local.submissionId); |
| 176 | const latest = durable[durable.length - 1]; |
| 177 | if (idle && latest) { |
| 178 | const text = submittedText(latest); |
| 179 | const durableCopies = durable.filter(record => submittedText(record) === text).length; |
| 180 | const echo = pending.find(local => !consumed.has(local.submissionId) && submittedText(local) === text); |
| 181 | if (echo) { |
| 182 | const priorCopies = preexistingEchoes(previousItems, echo) |
| 183 | .filter(item => item.kind === "user" && item.text !== undefined && submittedText({ text: item.text, submitText: item.submitText }) === text).length; |
| 184 | if (durableCopies > priorCopies) consumed.add(echo.submissionId); |
| 185 | } |
| 186 | } |
| 187 | if (consumed.size === 0) return state; |
| 188 | const localSubmissions = { ...state.localSubmissions }; |
| 189 | for (const submissionId of consumed) delete localSubmissions[submissionId]; |
| 190 | return { |
| 191 | ...state, |
| 192 | localSubmissions, |
| 193 | localSubmissionOrder: state.localSubmissionOrder.filter(submissionId => !consumed.has(submissionId)), |
| 194 | }; |
| 195 | } |
| 196 | |
| 197 | export function checkpointLocalSubmission<T extends LocalSubmissionFields>( |
| 198 | state: T, |
| 199 | submissionId: string | undefined, |
| 200 | checkpointTurn: number | undefined, |
| 201 | ): T { |
| 202 | if (!submissionId) return state; |
| 203 | const current = state.localSubmissions[submissionId]; |
| 204 | if (!current || current.settled) return state; |
| 205 | const validTurn = Number.isInteger(checkpointTurn) && checkpointTurn! >= 0; |
| 206 | return updateLocalSubmission(state, submissionId, { |
| 207 | checkpointTurn: validTurn ? checkpointTurn : current.checkpointTurn, |
| 208 | settled: true, |
| 209 | }); |
| 210 | } |
| 211 |