Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions mobile/src/session/mobile-native-chat-pending-baseline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { rebaseMobileNativeChatPendingBaselines } from './mobile-native-chat-pending-baseline'
import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo'

function userTurn(id: string, text: string, timestamp: number): NativeChatMessage {
return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' }
}

function assistantTurn(id: string, text: string, timestamp: number): NativeChatMessage {
return {
id,
role: 'assistant',
blocks: [{ type: 'text', text }],
timestamp,
source: 'transcript'
}
}

function unresolved(
id: string,
text: string,
expectedOccurrence = 1
): MobileNativeChatPendingMessage {
return { id, text, expectedOccurrence, baselineTailMessageId: null, baselineResolved: false }
}

describe('rebaseMobileNativeChatPendingBaselines', () => {
const history = [userTurn('m1', 'run the tests', 1000), assistantTurn('m2', 'passed', 1100)]

it('returns the same array when every baseline is already resolved', () => {
const pending: MobileNativeChatPendingMessage[] = [
{
id: 'p1',
text: 'hi',
expectedOccurrence: 1,
baselineTailMessageId: 'm2',
baselineResolved: true
}
]
expect(rebaseMobileNativeChatPendingBaselines(history, pending)).toBe(pending)
})

it('pins an unresolved send to the loaded tail and past the rows it never saw', () => {
expect(
rebaseMobileNativeChatPendingBaselines(history, [unresolved('p1', 'run the tests')])
).toEqual([
{
id: 'p1',
text: 'run the tests',
// The one row already in history is not this send's echo; it waits for the second.
expectedOccurrence: 2,
baselineTailMessageId: 'm2',
baselineResolved: true
}
])
})

it('pins to null when the authoritative transcript is empty', () => {
expect(rebaseMobileNativeChatPendingBaselines([], [unresolved('p1', 'hi')])).toEqual([
{
id: 'p1',
text: 'hi',
expectedOccurrence: 1,
baselineTailMessageId: null,
baselineResolved: true
}
])
})

it('gives identical queued sends consecutive ordinals', () => {
expect(
rebaseMobileNativeChatPendingBaselines(history, [
unresolved('p1', 'run the tests'),
unresolved('p2', 'run the tests')
]).map((item) => item.expectedOccurrence)
).toEqual([2, 3])
})

it('normalizes whitespace when counting a send against the loaded rows', () => {
expect(
rebaseMobileNativeChatPendingBaselines(history, [unresolved('p1', ' run the tests \n')])[0]
?.expectedOccurrence
).toBe(2)
})

it('leaves already-resolved neighbours untouched but still counts them', () => {
const resolved: MobileNativeChatPendingMessage = {
id: 'p1',
text: 'run the tests',
expectedOccurrence: 9,
baselineTailMessageId: 'm1',
baselineResolved: true
}
const rebased = rebaseMobileNativeChatPendingBaselines(history, [
resolved,
unresolved('p2', 'run the tests')
])
expect(rebased[0]).toBe(resolved)
expect(rebased[1]?.expectedOccurrence).toBe(3)
})

it('ordinals caption-less image echoes among themselves, not against text rows', () => {
const images = { ...unresolved('p1', ''), images: ['file:///a.png'] }
const rebased = rebaseMobileNativeChatPendingBaselines(history, [
images,
{ ...unresolved('p2', ''), images: ['file:///b.png'] }
])
expect(rebased.map((item) => item.expectedOccurrence)).toEqual([1, 2])
expect(rebased.map((item) => item.baselineTailMessageId)).toEqual(['m2', 'm2'])
})
})
52 changes: 52 additions & 0 deletions mobile/src/session/mobile-native-chat-pending-baseline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
countUserTextOccurrences,
normalizeReconcileText
} from './mobile-native-chat-draft-reconcile'
import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo'

/**
* Pin the sends issued while the transcript was still hydrating onto the first
* authoritative read of this session's history.
*
* Such a send has no usable baseline at capture time: `messages` was empty, or
* still the previously active tab's, so both its tail and its occurrence count
* describe somebody else's transcript. Rebasing them here — rather than marking
* the send permanently untrustworthy, which stranded it as a queued bubble and
* as a glue barrier for its neighbours for the rest of the session — leaves it
* matching exactly the rows that arrive from now on.
*/
export function rebaseMobileNativeChatPendingBaselines(
messages: readonly NativeChatMessage[],
current: MobileNativeChatPendingMessage[]
): MobileNativeChatPendingMessage[] {
if (current.every((item) => item.baselineResolved)) {
return current
}
const baselineTailMessageId = messages.at(-1)?.id ?? null
const earlierByText = new Map<string, number>()
let earlierImageEchoes = 0
return current.map((item) => {
const normalized = normalizeReconcileText(item.text)
const earlier = normalized === '' ? earlierImageEchoes : (earlierByText.get(normalized) ?? 0)
if (normalized === '') {
earlierImageEchoes += item.images?.length ? 1 : 0
} else {
earlierByText.set(normalized, earlier + 1)
}
if (item.baselineResolved) {
return item
}
return {
...item,
baselineTailMessageId,
baselineResolved: true,
// Ordinals stay relative to the queue: earlier still-pending sends of the
// same text claim the earlier rows, so this one waits for its own.
expectedOccurrence:
normalized === ''
? earlier + 1
: countUserTextOccurrences(messages, normalized) + earlier + 1
}
})
}
7 changes: 7 additions & 0 deletions mobile/src/session/mobile-native-chat-pending-echo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ export type MobileNativeChatPendingMessage = {
/** Local preview URIs carried by the send for its optimistic echo. */
images?: string[]
baselineTailMessageId: string | null
/** Whether the transcript this baseline was captured from was already this
* session's own history. A send issued mid-hydration is captured unresolved
* and rebased onto the first authoritative read instead of reconciling
* against rows that may belong to another tab. */
baselineResolved: boolean
}

export type MobileNativeChatSendOrigin = {
Expand All @@ -13,6 +18,7 @@ export type MobileNativeChatSendOrigin = {
normalizedText: string
baselineOccurrences: number
baselineTailMessageId: string | null
baselineResolved: boolean
}

type PendingByKey = Record<string, MobileNativeChatPendingMessage[]>
Expand Down Expand Up @@ -56,6 +62,7 @@ export function appendMobileNativeChatPending(
? expectedImageEchoOrdinal
: origin.baselineOccurrences + earlierOutstanding + 1,
baselineTailMessageId: origin.baselineTailMessageId,
baselineResolved: origin.baselineResolved,
...(images?.length ? { images } : {})
}
]
Expand Down
Loading
Loading