From bb8889997ac3ffe431cb1a582efc7bc91899d6e4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:50:14 -0700 Subject: [PATCH] fix(mobile): a resend of a not-sent message's text ends as one bubble The phone counted a row shown as not sent when it matched a send's echo, and took it as the newest row the echo had to land after. Once the resend lands, the host hides that row, so the echo expected one copy too many and never retired, leaving a plain duplicate bubble; an answer-lost resend read as unconfirmed. Not-sent rows no longer count toward either. --- .../mobile-native-chat-draft-reconcile.ts | 17 ++- ...mobile-native-chat-not-sent-resend.test.ts | 137 ++++++++++++++++++ .../mobile-native-chat-pending-baseline.ts | 7 +- .../session/use-mobile-native-chat-drafts.ts | 3 +- ...se-mobile-native-chat-launch-draft-seed.ts | 8 +- 5 files changed, 164 insertions(+), 8 deletions(-) create mode 100644 mobile/src/session/mobile-native-chat-not-sent-resend.test.ts diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts index 3ae08af6688..0d7d4038f21 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts @@ -21,8 +21,21 @@ export type UnconfirmedSend = { deadline: ReturnType | null } +/** A user row's text as a send's echo matches it. A row shown as not sent never lands a send: the + * host hides it once a later copy of its text is recorded, so counting it would strand an echo. */ export function normalizedUserText(message: NativeChatMessage): string | null { - return normalizedNativeChatUserMessageText(message) + return message.unsent === true ? null : normalizedNativeChatUserMessageText(message) +} + +/** The row a send's echo must land after: the newest one, past any shown as not sent, which the + * host may hide once a resend lands. */ +export function sendBaselineTailMessageId(messages: readonly NativeChatMessage[]): string | null { + for (let index = messages.length - 1; index >= 0; index -= 1) { + if (messages[index]?.unsent !== true) { + return messages[index]?.id ?? null + } + } + return null } export function countUserTextOccurrences( @@ -260,7 +273,7 @@ export function findLandedUnconfirmedSends( const userMessagesByText = new Map>() for (const [index, message] of messages.entries()) { messageIndexById.set(message.id, index) - if (message.role !== 'user') { + if (message.role !== 'user' || message.unsent === true) { continue } const key = isImageSourceUserTurn(message) ? '' : (normalizedUserText(message) ?? '') diff --git a/mobile/src/session/mobile-native-chat-not-sent-resend.test.ts b/mobile/src/session/mobile-native-chat-not-sent-resend.test.ts new file mode 100644 index 00000000000..69d0625d0f4 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-not-sent-resend.test.ts @@ -0,0 +1,137 @@ +// The phone draws a message the host recorded and then rejected in place, as not sent. The user's +// way on is to send its text again, after which the host hides the not-sent original: the phone's +// own echo of that resend must land on the new copy alone. + +import { describe, expect, it } from 'vitest' +import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../src/shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' +import { + countUserTextOccurrences, + findLandedUnconfirmedSends, + normalizeReconcileText, + sendBaselineTailMessageId +} from './mobile-native-chat-draft-reconcile' +import { appendMobileNativeChatPending } from './mobile-native-chat-pending-echo' +import { retireLandedMobileNativeChatPending } from './mobile-native-chat-pending-retirement' + +const TEXT = 'fix the test' + +function user(id: string, sequence: number, text: string): AgentJournalRenderItem { + return { + itemId: agentJournalSubmissionKey(id), + revision: 1, + sequence, + observedAt: sequence, + turnScope: { kind: 'thread' }, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } + } +} + +function answer(id: string, sequence: number, text: string): AgentJournalRenderItem { + return { + itemId: id, + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } + } +} + +function submission( + id: string, + text: string, + at: number, + patch: Partial = {} +): AgentJournalSubmission { + return { + clientMessageId: id, + fence: 1, + payloadFingerprint: `body:${text}`, + dispatchState: 'accepted', + providerItemId: `provider-${id}`, + reason: null, + submittedAt: at, + resolvedAt: at, + ...patch + } +} + +function phone( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[] +): NativeChatMessage[] { + return projectStructuredAgentSessionMessages(items, [], submissions, { + rejectedInPlace: true, + queuedMessageIds: [] + }) +} + +const REJECTED = submission('m1', TEXT, 10, { + dispatchState: 'rejected', + providerItemId: null, + reason: 'provider_write_failed: broken pipe', + resolvedAt: 11 +}) +const BEFORE_ITEMS = [answer('hi', 5, 'Hi'), user('m1', 11, TEXT)] +const AFTER_ITEMS = [...BEFORE_ITEMS, user('m2', 20, TEXT), answer('done', 21, 'Done.')] +const AFTER_SUBMISSIONS = [REJECTED, submission('m2', TEXT, 20)] + +describe('resending a not-sent message from the phone', () => { + it('ends as exactly one bubble once the resend lands', () => { + const before = phone(BEFORE_ITEMS, [REJECTED]) + const normalizedText = normalizeReconcileText(TEXT) + const origin = { + draftKey: 'draft', + draftEditGeneration: 0, + pendingKey: 'pending', + normalizedText, + baselineOccurrences: countUserTextOccurrences(before, normalizedText), + baselineTailMessageId: sendBaselineTailMessageId(before), + baselineResolved: true + } + const pending = appendMobileNativeChatPending({}, 'pending', 'pending-1', origin, TEXT).pending + + const after = phone(AFTER_ITEMS, AFTER_SUBMISSIONS) + expect( + after.filter((message) => message.role === 'user').map((message) => message.unsent ?? false) + ).toEqual([false]) + expect(retireLandedMobileNativeChatPending(after, pending ?? [], new Set())).toEqual([]) + }) + + it('sees an ack-lost resend that landed as landed, so no doubt is raised', () => { + const before = phone(BEFORE_ITEMS, [REJECTED]) + const landed = findLandedUnconfirmedSends(phone(AFTER_ITEMS, AFTER_SUBMISSIONS), [ + { + draftKey: 'draft', + pendingKey: 'pending', + text: TEXT, + normalizedText: normalizeReconcileText(TEXT), + baselineTailMessageId: sendBaselineTailMessageId(before), + deadline: null + } + ]) + expect(landed).toHaveLength(1) + }) + + it('keeps a not-sent row where the host placed it, above what came after', () => { + const items = [ + user('m1', 11, 'hello'), + user('m3', 20, 'are you there?'), + answer('a3', 21, 'Yes') + ] + const messages = phone(items, [ + { ...REJECTED, payloadFingerprint: 'body:hello' }, + submission('m3', 'are you there?', 20) + ]) + expect(messages.map((message) => [message.id, message.unsent ?? false])).toEqual([ + [agentJournalSubmissionKey('m1'), true], + [agentJournalSubmissionKey('m3'), false], + ['a3', false] + ]) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-pending-baseline.ts b/mobile/src/session/mobile-native-chat-pending-baseline.ts index 903bf9d8f32..be13032fdd9 100644 --- a/mobile/src/session/mobile-native-chat-pending-baseline.ts +++ b/mobile/src/session/mobile-native-chat-pending-baseline.ts @@ -1,5 +1,8 @@ import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { normalizeReconcileText } from './mobile-native-chat-draft-reconcile' +import { + normalizeReconcileText, + sendBaselineTailMessageId +} from './mobile-native-chat-draft-reconcile' import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo' /** @@ -39,7 +42,7 @@ export function rebaseMobileNativeChatPendingBaselines( if (current.every((item) => item.baselineResolved)) { return current } - const baselineTailMessageId = messages.at(-1)?.id ?? null + const baselineTailMessageId = sendBaselineTailMessageId(messages) return current.map((item) => { if (item.baselineResolved) { return item diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index f17e1826a67..221a39f121c 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -10,6 +10,7 @@ import { import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { countUserTextOccurrences, + sendBaselineTailMessageId, findLandedImagePreviewEchoes, mergeLandedImagePreviewEchoes, migrateImagePreviewMessageIds, @@ -174,7 +175,7 @@ export function useMobileNativeChatDrafts(args: { pendingKey, normalizedText, baselineOccurrences: countUserTextOccurrences(messagesRef.current, normalizedText), - baselineTailMessageId: messagesRef.current.at(-1)?.id ?? null, + baselineTailMessageId: sendBaselineTailMessageId(messagesRef.current), // Only a settled read makes this a boundary. Anything else — hydrating, // or a read that failed — hands back an empty list that reads as "the // conversation was empty", which lets any row claim this send later. diff --git a/mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts b/mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts index b75dbf12767..b76343c33df 100644 --- a/mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts +++ b/mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { normalizedUserText } from './mobile-native-chat-draft-reconcile' +import { normalizedNativeChatUserMessageText } from './mobile-native-chat-image-transcript-markers' export type MobileNativeChatLaunchDraftSeed = { text: string @@ -63,7 +63,7 @@ export function useMobileNativeChatLaunchDraftSeed(args: { } // A user turn already in the transcript means the TUI prefill was submitted // or deliberately cleared; decline instead of resurrecting it. - if (messages.some((message) => normalizedUserText(message) !== null)) { + if (messages.some((message) => normalizedNativeChatUserMessageText(message) !== null)) { seededLaunchDraftByKeyRef.current.set(draftKey, null) return } @@ -98,7 +98,9 @@ export function useMobileNativeChatLaunchDraftSeed(args: { if (!seeded) { return } - const hasUserTurn = messages.some((message) => normalizedUserText(message) !== null) + const hasUserTurn = messages.some( + (message) => normalizedNativeChatUserMessageText(message) !== null + ) if (!hasUserTurn && launchDraft?.trim()) { return }