mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
fix(native-chat): separate literal and image echo identity
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import {
|
||||
countUserTextOccurrences,
|
||||
findLandedImagePreviewEchoes,
|
||||
findLandedUnconfirmedSends,
|
||||
migrateImagePreviewMessageIds,
|
||||
@@ -89,6 +90,35 @@ describe('mobile native chat image preview reconciliation', () => {
|
||||
).toEqual([unconfirmed])
|
||||
})
|
||||
|
||||
it('does not treat literal marker text as a markerless unknown-ack echo', () => {
|
||||
const unconfirmed: UnconfirmedSend = {
|
||||
draftKey: 'draft',
|
||||
pendingKey: 'pending-key',
|
||||
text: 'keep literal',
|
||||
normalizedText: 'keep literal',
|
||||
imageCount: 0,
|
||||
baselineTailMessageId: null,
|
||||
deadline: null
|
||||
}
|
||||
|
||||
expect(
|
||||
findLandedUnconfirmedSends([userText('wrong', 'keep [Image #1] literal')], [unconfirmed])
|
||||
).toEqual([])
|
||||
expect(findLandedUnconfirmedSends([userText('right', 'keep literal')], [unconfirmed])).toEqual([
|
||||
unconfirmed
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps no-image occurrence baselines separate from attached-image turns', () => {
|
||||
const messages = [
|
||||
userText('source', '[Image: source: /tmp/a.png]'),
|
||||
userText('prompt', 'keep [Image #1] literal')
|
||||
]
|
||||
|
||||
expect(countUserTextOccurrences(messages, 'keep literal')).toBe(0)
|
||||
expect(countUserTextOccurrences(messages, 'keep literal', 1)).toBe(1)
|
||||
})
|
||||
|
||||
it('reconciles multiple transcript text blocks with desktop separators', () => {
|
||||
const prompt: NativeChatMessage = {
|
||||
...userText('prompt', 'unused'),
|
||||
@@ -114,6 +144,15 @@ describe('mobile native chat image preview reconciliation', () => {
|
||||
).toEqual([{ pendingId: 'pending', messageId: 'prompt', images: ['file:///a.jpg'] }])
|
||||
})
|
||||
|
||||
it('hands a captioned preview to a marker-only compatibility echo', () => {
|
||||
expect(
|
||||
findLandedImagePreviewEchoes(
|
||||
[userText('prompt', 'look [Image #1] here')],
|
||||
[{ ...pending('pending', ['file:///a.jpg']), text: 'look here' }]
|
||||
)
|
||||
).toEqual([{ pendingId: 'pending', messageId: 'prompt', images: ['file:///a.jpg'] }])
|
||||
})
|
||||
|
||||
it('keeps separate adjacent image-only sends independently reconcilable', () => {
|
||||
const landed = findLandedImagePreviewEchoes(
|
||||
[
|
||||
|
||||
@@ -34,8 +34,14 @@ export function countUserTextOccurrences(
|
||||
): number {
|
||||
let count = 0
|
||||
for (const message of normalizeImageTranscriptMessages(messages)) {
|
||||
const hasImageRefs = message.blocks.some(isImageRefBlock)
|
||||
const matchText =
|
||||
imageCount > 0
|
||||
? normalizedNativeChatUserMessageText(message)
|
||||
: nativeChatUserMessageMatchText(message)
|
||||
if (
|
||||
nativeChatUserMessageMatchText(message) === text &&
|
||||
matchText === text &&
|
||||
(imageCount > 0 || !hasImageRefs) &&
|
||||
nativeChatUserMessageImageEvidenceCount(message) >= imageCount
|
||||
) {
|
||||
count++
|
||||
@@ -176,7 +182,7 @@ export function findLandedImagePreviewEchoes(
|
||||
const imageCount = nativeChatUserMessageImageEvidenceCount(message)
|
||||
if (targetText) {
|
||||
return (
|
||||
nativeChatUserMessageMatchText(message) === targetText &&
|
||||
normalizedNativeChatUserMessageText(message) === targetText &&
|
||||
imageCount >= entry.images!.length
|
||||
)
|
||||
}
|
||||
@@ -222,7 +228,11 @@ export function findLandedUnconfirmedSends(
|
||||
// (`[Image: source: …]` or no text) keys under '' so an empty-text send can
|
||||
// claim it.
|
||||
const messageIndexById = new Map(messages.map((message, index) => [message.id, index]))
|
||||
const userMessagesByText = new Map<string, { id: string; index: number; imageCount: number }[]>()
|
||||
const literalMessagesByText = new Map<
|
||||
string,
|
||||
{ id: string; index: number; imageCount: number }[]
|
||||
>()
|
||||
const imageMessagesByText = new Map<string, { id: string; index: number; imageCount: number }[]>()
|
||||
for (const message of normalizeImageTranscriptMessages(messages)) {
|
||||
const index = messageIndexById.get(message.id)
|
||||
if (index === undefined) {
|
||||
@@ -232,15 +242,19 @@ export function findLandedUnconfirmedSends(
|
||||
continue
|
||||
}
|
||||
const isImageSource = isImageSourceUserTurn(message)
|
||||
const key = isImageSource ? '' : (normalizedUserText(message) ?? '')
|
||||
// A marker-only turn keys both ways: '' claims it for a caption-less image
|
||||
// send, and its literal text claims it for a send of that same text.
|
||||
const literalKey = isImageSource ? key : (nativeChatUserMessageMatchText(message) ?? key)
|
||||
const imageCount = nativeChatUserMessageImageEvidenceCount(message)
|
||||
for (const candidateKey of literalKey === key ? [key] : [key, literalKey]) {
|
||||
const current = userMessagesByText.get(candidateKey) ?? []
|
||||
current.push({ id: message.id, index, imageCount })
|
||||
userMessagesByText.set(candidateKey, current)
|
||||
const candidate = { id: message.id, index, imageCount }
|
||||
if (!message.blocks.some(isImageRefBlock)) {
|
||||
const literalKey = isImageSource ? '' : (nativeChatUserMessageMatchText(message) ?? '')
|
||||
const current = literalMessagesByText.get(literalKey) ?? []
|
||||
current.push(candidate)
|
||||
literalMessagesByText.set(literalKey, current)
|
||||
}
|
||||
if (imageCount > 0) {
|
||||
const imageKey = isImageSource ? '' : (normalizedUserText(message) ?? '')
|
||||
const current = imageMessagesByText.get(imageKey) ?? []
|
||||
current.push(candidate)
|
||||
imageMessagesByText.set(imageKey, current)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,7 +267,8 @@ export function findLandedUnconfirmedSends(
|
||||
if (tailIndex === undefined) {
|
||||
continue
|
||||
}
|
||||
const echo = userMessagesByText
|
||||
const messagesByText = entry.imageCount > 0 ? imageMessagesByText : literalMessagesByText
|
||||
const echo = messagesByText
|
||||
.get(entry.normalizedText)
|
||||
?.find(
|
||||
(message) =>
|
||||
|
||||
@@ -130,6 +130,18 @@ describe('mobile literal image-marker turns', () => {
|
||||
expect(rows(exact).map(rowText)).toEqual(['keep [Image #1] literal'])
|
||||
})
|
||||
|
||||
it('does not let an attached-image send shift a markerless send ordinal', async () => {
|
||||
await mount()
|
||||
send('keep [Image #1] literal', ['file:///a.jpg'])
|
||||
send('keep literal')
|
||||
|
||||
const messages = [userTextMessage('plain', 'keep literal')]
|
||||
await transcript(messages)
|
||||
|
||||
expect(rows(messages).map(rowText)).toEqual(['keep literal', 'keep [Image #1] literal'])
|
||||
expect(rows(messages).map(rowImages)).toEqual([[], ['file:///a.jpg']])
|
||||
})
|
||||
|
||||
it('holds every preview on the optimistic echo until the marker echo covers them all', async () => {
|
||||
await mount()
|
||||
send('', ['file:///a.jpg', 'file:///b.jpg'])
|
||||
|
||||
@@ -55,7 +55,9 @@ export function appendMobileNativeChatPending(
|
||||
const earlierOutstanding = current.filter(
|
||||
(pending) =>
|
||||
pendingMatchText(pending) === origin.normalizedText &&
|
||||
(pending.images?.length ?? 0) >= origin.imageCount &&
|
||||
(origin.imageCount > 0
|
||||
? (pending.images?.length ?? 0) >= origin.imageCount
|
||||
: !pending.images?.length) &&
|
||||
pending.expectedOccurrence > origin.baselineOccurrences
|
||||
).length
|
||||
// An image send whose caption is only markers still reconciles by image
|
||||
@@ -63,6 +65,7 @@ export function appendMobileNativeChatPending(
|
||||
const expectedImageEchoOrdinal =
|
||||
current.filter(
|
||||
(pending) =>
|
||||
origin.imageCount > 0 &&
|
||||
(pending.images?.length ?? 0) >= origin.imageCount &&
|
||||
normalizeNativeChatUserText(pending.text) === ''
|
||||
).length + 1
|
||||
|
||||
@@ -282,4 +282,28 @@ describe('retireLandedMobileNativeChatPending', () => {
|
||||
).toEqual(['p1'])
|
||||
expect(retireLandedMobileNativeChatPending(messages, pending, new Set(['p1']))).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps literal and attached-image turns in separate retirement lanes', () => {
|
||||
const markerPending = [pendingSend('marker', 'keep [Image #1] literal', null)]
|
||||
const plainPending = [pendingSend('plain', 'keep literal', null)]
|
||||
const literalMarker = [userTurn('literal', 'keep [Image #1] literal', 1000)]
|
||||
const attachedImage = [
|
||||
userTurn('source', '[Image: source: /tmp/a.png]', 1000),
|
||||
userTurn('prompt', 'keep [Image #1] literal', 1001)
|
||||
]
|
||||
|
||||
expect(
|
||||
retireLandedMobileNativeChatPending(literalMarker, plainPending, NO_IMAGE_ECHOES).map(
|
||||
(item) => item.id
|
||||
)
|
||||
).toEqual(['plain'])
|
||||
expect(
|
||||
retireLandedMobileNativeChatPending(literalMarker, markerPending, NO_IMAGE_ECHOES)
|
||||
).toEqual([])
|
||||
expect(
|
||||
retireLandedMobileNativeChatPending(attachedImage, plainPending, NO_IMAGE_ECHOES).map(
|
||||
(item) => item.id
|
||||
)
|
||||
).toEqual(['plain'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { countImageSourceTurnsAfter } from './mobile-native-chat-draft-reconcile'
|
||||
import {
|
||||
nativeChatUserMessageMatchText,
|
||||
@@ -33,7 +33,9 @@ export function selectGluedPendingIds(
|
||||
if (index === undefined) {
|
||||
continue
|
||||
}
|
||||
const text = nativeChatUserMessageMatchText(message)
|
||||
const text = message.blocks.some(isImageRefBlock)
|
||||
? null
|
||||
: nativeChatUserMessageMatchText(message)
|
||||
if (text) {
|
||||
turns.push({ index, text })
|
||||
}
|
||||
@@ -120,6 +122,9 @@ export function retireLandedMobileNativeChatPending(
|
||||
): MobileNativeChatPendingMessage[] {
|
||||
const landedCounts = new Map<string, number>()
|
||||
for (const message of normalizeImageTranscriptMessages(messages)) {
|
||||
if (message.blocks.some(isImageRefBlock)) {
|
||||
continue
|
||||
}
|
||||
const text = nativeChatUserMessageMatchText(message)
|
||||
if (text) {
|
||||
landedCounts.set(text, (landedCounts.get(text) ?? 0) + 1)
|
||||
|
||||
@@ -129,7 +129,7 @@ describe('useMobileNativeChatDrafts launch draft', () => {
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
tabId: 'a',
|
||||
messages: [userTextMessage('m1', '[Image #1]')],
|
||||
messages: [userTextMessage('m1', 'keep [Image #1] literal')],
|
||||
launchDraft: 'issue link'
|
||||
})
|
||||
)
|
||||
@@ -149,7 +149,7 @@ describe('useMobileNativeChatDrafts launch draft', () => {
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
tabId: 'a',
|
||||
messages: [userTextMessage('m1', '[Image #1]')],
|
||||
messages: [userTextMessage('m1', 'keep [Image #1] literal')],
|
||||
launchDraft: 'issue link'
|
||||
})
|
||||
)
|
||||
|
||||
@@ -219,7 +219,7 @@ describe('useMobileNativeChatDrafts', () => {
|
||||
createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] })
|
||||
)
|
||||
)
|
||||
const origin = state?.captureSendOrigin('')
|
||||
const origin = state?.captureSendOrigin('', ['file:///a.jpg'])
|
||||
act(() => {
|
||||
if (origin) {
|
||||
state?.acceptSend(origin, '', ['file:///a.jpg'])
|
||||
@@ -265,7 +265,7 @@ describe('useMobileNativeChatDrafts', () => {
|
||||
)
|
||||
)
|
||||
const textOrigin = state?.captureSendOrigin('ping')
|
||||
const imageOrigin = state?.captureSendOrigin('')
|
||||
const imageOrigin = state?.captureSendOrigin('', ['file:///a.jpg'])
|
||||
act(() => {
|
||||
if (textOrigin && imageOrigin) {
|
||||
state?.acceptSend(textOrigin, 'ping')
|
||||
@@ -308,7 +308,7 @@ describe('useMobileNativeChatDrafts', () => {
|
||||
createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] })
|
||||
)
|
||||
)
|
||||
const origin = state?.captureSendOrigin('look at this')
|
||||
const origin = state?.captureSendOrigin('look at this', ['file:///a.jpg'])
|
||||
act(() => {
|
||||
if (origin) {
|
||||
state?.acceptSend(origin, 'look at this', ['file:///a.jpg'])
|
||||
@@ -341,7 +341,7 @@ describe('useMobileNativeChatDrafts', () => {
|
||||
createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] })
|
||||
)
|
||||
)
|
||||
const origin = state?.captureSendOrigin('look at this')
|
||||
const origin = state?.captureSendOrigin('look at this', ['file:///a.jpg'])
|
||||
act(() => {
|
||||
if (origin) {
|
||||
state?.acceptSend(origin, 'look at this', ['file:///a.jpg'])
|
||||
@@ -367,7 +367,7 @@ describe('useMobileNativeChatDrafts', () => {
|
||||
|
||||
it('hands a marker-only image preview to the authoritative user bubble', async () => {
|
||||
await mount('a')
|
||||
const origin = state?.captureSendOrigin('')
|
||||
const origin = state?.captureSendOrigin('', ['file:///a.jpg'])
|
||||
act(() => {
|
||||
if (origin) {
|
||||
state?.acceptSend(origin, '', ['file:///a.jpg'])
|
||||
@@ -475,7 +475,7 @@ describe('useMobileNativeChatDrafts', () => {
|
||||
)
|
||||
)
|
||||
// Image-only send: empty text, so it can only reconcile against a new user turn.
|
||||
const origin = state?.captureSendOrigin('')
|
||||
const origin = state?.captureSendOrigin('', ['file:///a.jpg'])
|
||||
const onUnconfirmed = vi.fn()
|
||||
act(() => {
|
||||
if (origin) {
|
||||
@@ -492,7 +492,7 @@ describe('useMobileNativeChatDrafts', () => {
|
||||
})
|
||||
)
|
||||
)
|
||||
// ...but the user's own turn landing does, so the deadline never warns.
|
||||
// ...but the user's own image turn landing does, so the deadline never warns.
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
@@ -500,7 +500,7 @@ describe('useMobileNativeChatDrafts', () => {
|
||||
messages: [
|
||||
assistantTextMessage('a1', 'hi'),
|
||||
assistantTextMessage('a2', 'ok'),
|
||||
userTextMessage('u1', '')
|
||||
userTextMessage('u1', '[Image #1]')
|
||||
]
|
||||
})
|
||||
)
|
||||
@@ -519,7 +519,7 @@ describe('useMobileNativeChatDrafts', () => {
|
||||
createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] })
|
||||
)
|
||||
)
|
||||
const origin = state?.captureSendOrigin('')
|
||||
const origin = state?.captureSendOrigin('', ['file:///a.jpg'])
|
||||
act(() => {
|
||||
if (origin) {
|
||||
state?.acceptSend(origin, '', ['file:///a.jpg'])
|
||||
@@ -768,7 +768,7 @@ describe('useMobileNativeChatDrafts', () => {
|
||||
const images = ['file:///a.jpg', 'file:///b.jpg', 'file:///c.jpg']
|
||||
act(() => state?.setComposerText('look'))
|
||||
|
||||
const origin = state?.captureSendOrigin('look')
|
||||
const origin = state?.captureSendOrigin('look', images)
|
||||
expect(origin).toMatchObject({ pendingKey: null })
|
||||
act(() => {
|
||||
if (origin) {
|
||||
|
||||
@@ -88,6 +88,27 @@ describe('literal image-marker turns in native chat', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('does not let literal or attached-image marker rows hide markerless text', () => {
|
||||
const pending: NativeChatPendingSend[] = [{ id: 'p1', text: 'keep literal', sentAt: 100 }]
|
||||
const literalTranscript = [userMessage('literal', 'keep [Image #1] literal')]
|
||||
const imageTranscript = [
|
||||
userMessage('source', '[Image: source: /tmp/a.png]'),
|
||||
userMessage('prompt', 'keep[Image #1] literal')
|
||||
]
|
||||
|
||||
expect(renderedUserRows(literalTranscript, pending).map(rowText)).toEqual([
|
||||
'keep [Image #1] literal',
|
||||
'keep literal'
|
||||
])
|
||||
expect(renderedUserRows(imageTranscript, pending).map(rowText)).toEqual([
|
||||
'keep literal',
|
||||
'keep literal'
|
||||
])
|
||||
expect(
|
||||
prunePendingSends(pending, assembled([...imageTranscript, assistantMessage('a1', 'done')]))
|
||||
).toEqual(pending)
|
||||
})
|
||||
|
||||
it('prunes the marker-only echo once the assistant answers it', () => {
|
||||
const pending: NativeChatPendingSend[] = [{ id: 'p1', text: '[Image #1]', sentAt: 100 }]
|
||||
const transcript = [userMessage('m1', '[Image #1]'), assistantMessage('m2', 'preserved')]
|
||||
|
||||
@@ -51,7 +51,8 @@ function nativeChatUserMessageContentKeys(message: NativeChatMessage): readonly
|
||||
}
|
||||
const keys = new Set<string>()
|
||||
const matchText = nativeChatUserMessageMatchText(message) ?? ''
|
||||
if (matchText) {
|
||||
const hasImageRefs = message.blocks.some(isImageRefBlock)
|
||||
if (matchText && !hasImageRefs) {
|
||||
keys.add(nativeChatTextContentKey(matchText, 0))
|
||||
}
|
||||
const imageCount = nativeChatUserMessageImageEvidenceCount(message)
|
||||
@@ -110,7 +111,9 @@ export function advancedNativeChatUserTexts(
|
||||
const waiting: string[] = []
|
||||
for (const message of messages) {
|
||||
if (message.role === 'user') {
|
||||
const text = nativeChatUserMessageMatchText(message)
|
||||
const text = message.blocks.some(isImageRefBlock)
|
||||
? null
|
||||
: nativeChatUserMessageMatchText(message)
|
||||
if (text) {
|
||||
waiting.push(text)
|
||||
}
|
||||
@@ -128,7 +131,9 @@ export function matchingNativeChatUserTexts(
|
||||
): readonly string[] {
|
||||
const texts: string[] = []
|
||||
for (const message of messages) {
|
||||
const text = nativeChatUserMessageMatchText(message)
|
||||
const text = message.blocks.some(isImageRefBlock)
|
||||
? null
|
||||
: nativeChatUserMessageMatchText(message)
|
||||
if (text) {
|
||||
texts.push(text)
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ describe('prunePendingSends', () => {
|
||||
]
|
||||
}
|
||||
const next = prunePendingSends(
|
||||
[pendingOf('p1', 'what do you see')],
|
||||
[{ ...pendingOf('p1', 'what do you see'), imagePaths: ['/tmp/a.png'] }],
|
||||
[prompt, assistantMessage('m2', 'an image')]
|
||||
)
|
||||
|
||||
@@ -468,7 +468,7 @@ describe('launchPromptAsMessage', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('uses pending-send normalization for large multiline generated prompts', () => {
|
||||
it('does not strip literal markers from launch-prompt identity', () => {
|
||||
const prompt = [
|
||||
'[Image #1] Resolve the failing checks:',
|
||||
'',
|
||||
@@ -488,17 +488,17 @@ describe('launchPromptAsMessage', () => {
|
||||
{ ...assistantMessage('a1', 'I will fix it'), timestamp: 44 }
|
||||
]
|
||||
|
||||
expect(
|
||||
shouldPruneLaunchPrompt(
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
agent: 'codex',
|
||||
text: prompt,
|
||||
createdAt: 42
|
||||
},
|
||||
transcript
|
||||
)
|
||||
).toBe(true)
|
||||
const entry = { tabId: 'tab-1', agent: 'codex' as const, text: prompt, createdAt: 42 }
|
||||
|
||||
expect(launchPromptAsMessage(entry, transcript)).not.toBeNull()
|
||||
expect(shouldPruneLaunchPrompt(entry, transcript)).toBe(false)
|
||||
|
||||
const exact = [
|
||||
{ ...userMessage('u1', prompt), timestamp: 43 },
|
||||
{ ...assistantMessage('a1', 'I will fix it'), timestamp: 44 }
|
||||
]
|
||||
expect(launchPromptAsMessage(entry, exact)).toBeNull()
|
||||
expect(shouldPruneLaunchPrompt(entry, exact)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the launch prompt until the transcript advances past the user turn', () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from './native-chat-types'
|
||||
import {
|
||||
isImageSourceUserTurn,
|
||||
nativeChatUserMessageMatchText,
|
||||
nativeChatUserTextMatchText,
|
||||
normalizeImageTranscriptMessages,
|
||||
normalizeNativeChatUserText,
|
||||
@@ -110,6 +111,14 @@ describe('normalizeImageTranscriptMessages', () => {
|
||||
'keep [Image #1] literal'
|
||||
)
|
||||
expect(nativeChatUserTextMatchText(' keep [Image #1] literal ', true)).toBe('keep literal')
|
||||
|
||||
const literal = userText('literal', 'keep [Image #1] literal')
|
||||
const attached = {
|
||||
...literal,
|
||||
blocks: [{ type: 'image-ref' as const, path: '/tmp/a.png' }, ...literal.blocks]
|
||||
}
|
||||
expect(nativeChatUserMessageMatchText(literal)).toBe('keep [Image #1] literal')
|
||||
expect(nativeChatUserMessageMatchText(attached)).toBe('keep literal')
|
||||
})
|
||||
|
||||
it('recognizes only sole-text image-source user turns', () => {
|
||||
|
||||
Reference in New Issue
Block a user