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 }