mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
fix(native-chat): require complete image echo evidence
(cherry picked from commit 94f1cc66ee)
This commit is contained in:
@@ -124,4 +124,38 @@ describe('literal image-marker turns in native chat', () => {
|
||||
expect(rowText(rows[0]!)).toBe('')
|
||||
expect(rowImagePaths(rows[0]!)).toEqual(['/tmp/a.png'])
|
||||
})
|
||||
|
||||
it('keeps a captioned multi-image echo until the transcript accounts for every image', () => {
|
||||
const pending: NativeChatPendingSend[] = [
|
||||
{
|
||||
id: 'p1',
|
||||
text: 'compare these',
|
||||
imagePaths: ['/tmp/a.png', '/tmp/b.png', '/tmp/c.png'],
|
||||
sentAt: 100
|
||||
}
|
||||
]
|
||||
const transcript = [
|
||||
userMessage('source-a', '[Image: source: /tmp/a.png]'),
|
||||
userMessage('prompt', 'compare these [Image #1]')
|
||||
]
|
||||
|
||||
const rows = renderedUserRows(transcript, pending)
|
||||
|
||||
expect(rows.map(rowText)).toEqual(['compare these', 'compare these'])
|
||||
expect(rows.map(rowImagePaths)).toEqual([
|
||||
['/tmp/a.png'],
|
||||
['/tmp/a.png', '/tmp/b.png', '/tmp/c.png']
|
||||
])
|
||||
|
||||
const complete = [
|
||||
userMessage('source-a', '[Image: source: /tmp/a.png]'),
|
||||
userMessage('source-b', '[Image: source: /tmp/b.png]'),
|
||||
userMessage('source-c', '[Image: source: /tmp/c.png]'),
|
||||
userMessage('complete-prompt', 'compare these [Image #1] [Image #2] [Image #3]')
|
||||
]
|
||||
const completeRows = renderedUserRows(complete, pending)
|
||||
expect(completeRows).toHaveLength(1)
|
||||
expect(rowText(completeRows[0]!)).toBe('compare these')
|
||||
expect(rowImagePaths(completeRows[0]!)).toEqual(['/tmp/a.png', '/tmp/b.png', '/tmp/c.png'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
nativeChatUserMessageImageEvidenceCount,
|
||||
nativeChatUserMessageMatchText,
|
||||
nativeChatUserTextMatchText,
|
||||
normalizeNativeChatUserText,
|
||||
normalizeNativeChatUserTextWithLiteralFallback
|
||||
normalizedNativeChatUserMessageText
|
||||
} from '../../../../shared/native-chat-image-transcript-markers'
|
||||
import { isImageRefBlock, type NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
|
||||
@@ -25,36 +27,47 @@ export function normalizeNativeChatPendingText(text: string): string {
|
||||
function nativeChatPendingMatchText(
|
||||
pending: Pick<NativeChatPendingOccurrence, 'text' | 'imagePaths'>
|
||||
): string {
|
||||
return pending.imagePaths?.some(Boolean)
|
||||
? normalizeNativeChatPendingText(pending.text)
|
||||
: normalizeNativeChatUserTextWithLiteralFallback(pending.text)
|
||||
return nativeChatUserTextMatchText(pending.text, Boolean(pending.imagePaths?.some(Boolean)))
|
||||
}
|
||||
|
||||
function nativeChatTextContentKey(text: string, imageCount: number): string {
|
||||
return imageCount > 0 ? `text:${text}\0images:${imageCount}` : `text:${text}`
|
||||
}
|
||||
|
||||
export function nativeChatPendingContentKey(
|
||||
pending: Pick<NativeChatPendingOccurrence, 'text' | 'imagePaths'>
|
||||
): string {
|
||||
const text = nativeChatPendingMatchText(pending)
|
||||
if (text) {
|
||||
return `text:${text}`
|
||||
}
|
||||
const imagePaths = pending.imagePaths?.filter(Boolean) ?? []
|
||||
if (text) {
|
||||
return nativeChatTextContentKey(text, imagePaths.length)
|
||||
}
|
||||
return imagePaths.length > 0 ? `images:${JSON.stringify(imagePaths)}` : 'empty'
|
||||
}
|
||||
|
||||
function nativeChatUserMessageContentKey(message: NativeChatMessage): string | null {
|
||||
function nativeChatUserMessageContentKeys(message: NativeChatMessage): readonly string[] {
|
||||
if (message.role !== 'user') {
|
||||
return null
|
||||
return []
|
||||
}
|
||||
const text = nativeChatUserMessageMatchText(message) ?? ''
|
||||
if (text) {
|
||||
return `text:${text}`
|
||||
const keys = new Set<string>()
|
||||
const matchText = nativeChatUserMessageMatchText(message) ?? ''
|
||||
if (matchText) {
|
||||
keys.add(nativeChatTextContentKey(matchText, 0))
|
||||
}
|
||||
const imagePaths = message.blocks
|
||||
.filter(isImageRefBlock)
|
||||
.map((block) => block.path)
|
||||
.filter((path): path is string => Boolean(path))
|
||||
const key = nativeChatPendingContentKey({ text: '', imagePaths })
|
||||
return key === 'empty' ? null : key
|
||||
const imageCount = nativeChatUserMessageImageEvidenceCount(message)
|
||||
const imageText = normalizedNativeChatUserMessageText(message)
|
||||
if (imageText && imageCount > 0) {
|
||||
keys.add(nativeChatTextContentKey(imageText, imageCount))
|
||||
} else if (imageCount > 0) {
|
||||
const imagePaths = message.blocks
|
||||
.filter(isImageRefBlock)
|
||||
.map((block) => block.path)
|
||||
.filter((path): path is string => Boolean(path))
|
||||
if (imagePaths.length > 0) {
|
||||
keys.add(`images:${JSON.stringify(imagePaths)}`)
|
||||
}
|
||||
}
|
||||
return [...keys]
|
||||
}
|
||||
|
||||
export function matchingNativeChatUserContentCounts(
|
||||
@@ -62,8 +75,7 @@ export function matchingNativeChatUserContentCounts(
|
||||
): Map<string, number> {
|
||||
const counts = new Map<string, number>()
|
||||
for (const message of messages) {
|
||||
const key = nativeChatUserMessageContentKey(message)
|
||||
if (key) {
|
||||
for (const key of nativeChatUserMessageContentKeys(message)) {
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
@@ -77,8 +89,7 @@ export function advancedNativeChatUserContentCounts(
|
||||
const waiting = new Map<string, number>()
|
||||
for (const message of messages) {
|
||||
if (message.role === 'user') {
|
||||
const key = nativeChatUserMessageContentKey(message)
|
||||
if (key) {
|
||||
for (const key of nativeChatUserMessageContentKeys(message)) {
|
||||
waiting.set(key, (waiting.get(key) ?? 0) + 1)
|
||||
}
|
||||
continue
|
||||
@@ -180,10 +191,13 @@ export function selectPendingIndicesRepresentedByUserTexts(
|
||||
}
|
||||
const remaining = pending.map((entry, index) => ({
|
||||
index,
|
||||
text: nativeChatPendingMatchText(entry)
|
||||
text: nativeChatPendingMatchText(entry),
|
||||
hasImages: Boolean(entry.imagePaths?.some(Boolean))
|
||||
}))
|
||||
for (const userText of userTexts) {
|
||||
const open = remaining.filter((entry) => !represented.has(entry.index) && entry.text.length > 0)
|
||||
const open = remaining.filter(
|
||||
(entry) => !entry.hasImages && !represented.has(entry.index) && entry.text.length > 0
|
||||
)
|
||||
const gluedCount = countLeadingPendingTextsGluedToUserText(
|
||||
open.map((entry) => entry.text),
|
||||
userText
|
||||
|
||||
@@ -48,10 +48,16 @@ export function normalizeNativeChatUserText(text: string): string {
|
||||
/** Marker text that stripping would erase entirely is text the user typed
|
||||
* literally — a real marker always rides on an image turn, which normalization
|
||||
* has already folded away by the time echo matching runs. */
|
||||
export function normalizeNativeChatUserTextWithLiteralFallback(text: string): string {
|
||||
function normalizeNativeChatUserTextWithLiteralFallback(text: string): string {
|
||||
return normalizeNativeChatUserText(text) || text.trim().replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
export function nativeChatUserTextMatchText(text: string, hasImages: boolean): string {
|
||||
return hasImages
|
||||
? normalizeNativeChatUserText(text)
|
||||
: normalizeNativeChatUserTextWithLiteralFallback(text)
|
||||
}
|
||||
|
||||
function joinedUserText(message: NativeChatMessage): string {
|
||||
return message.blocks
|
||||
.filter(isTextBlock)
|
||||
@@ -74,11 +80,7 @@ export function nativeChatUserMessageMatchText(message: NativeChatMessage): stri
|
||||
return null
|
||||
}
|
||||
const joined = joinedUserText(message)
|
||||
const normalized = normalizeNativeChatUserText(joined)
|
||||
if (normalized || message.blocks.some(isImageRefBlock)) {
|
||||
return normalized || null
|
||||
}
|
||||
return normalizeNativeChatUserTextWithLiteralFallback(joined) || null
|
||||
return nativeChatUserTextMatchText(joined, message.blocks.some(isImageRefBlock)) || null
|
||||
}
|
||||
|
||||
function stripImagePromptMarkersFromTextBlocks(
|
||||
@@ -132,6 +134,14 @@ export function countImagePromptMarkers(message: NativeChatMessage): number {
|
||||
)
|
||||
}
|
||||
|
||||
export function nativeChatUserMessageImageEvidenceCount(message: NativeChatMessage): number {
|
||||
if (message.role !== 'user') {
|
||||
return 0
|
||||
}
|
||||
const imageRefCount = message.blocks.filter(isImageRefBlock).length
|
||||
return Math.max(imageRefCount, countImagePromptMarkers(message))
|
||||
}
|
||||
|
||||
/** Claude records image paths as source turns followed by a prompt carrying
|
||||
* image markers. Merge the whole run back into one native user turn. */
|
||||
export function normalizeImageTranscriptMessages(
|
||||
|
||||
Reference in New Issue
Block a user