Files
orca/src/shared/native-chat-image-transcript-markers.test.ts
T

350 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from './native-chat-types'
import { imageReferenceToken } from './native-chat-image-reference'
import {
imageReferencesToPromptMarkers,
promptMarkersToImageReferences,
isImageSourceUserTurn,
normalizeImageTranscriptMessages,
normalizeNativeChatUserText,
normalizedNativeChatUserMessageText,
stripImagePromptMarker,
stripSentImagePromptMarkers
} from './native-chat-image-transcript-markers'
import { formatNativeChatFileReference } from './agent-image-paste'
function userText(id: string, text: string): NativeChatMessage {
return {
id,
role: 'user',
blocks: [{ type: 'text', text }],
timestamp: 1,
source: 'transcript'
}
}
describe('normalizeImageTranscriptMessages', () => {
it('merges the paired [Image: source]/[Image #1] turns into one image-ref turn', () => {
const out = normalizeImageTranscriptMessages([
userText('a', '[Image: source: /tmp/orca-paste-1-2.png]'),
userText('b', '[Image #1] describe this')
])
expect(out).toHaveLength(1)
expect(out[0]!.blocks).toEqual([
{ type: 'image-ref', path: '/tmp/orca-paste-1-2.png' },
{ type: 'text', text: 'describe this' }
])
})
it('merges a source turn into a prompt with a trailing image marker', () => {
const out = normalizeImageTranscriptMessages([
userText('a', '[Image: source: /tmp/orca-paste-1-2.png]'),
userText('b', 'describe this[Image #1]')
])
expect(out).toHaveLength(1)
expect(out[0]!.blocks).toEqual([
{ type: 'image-ref', path: '/tmp/orca-paste-1-2.png' },
{ type: 'text', text: 'describe this' }
])
})
it('folds and strips markers in later text blocks', () => {
const prompt: NativeChatMessage = {
...userText('prompt', 'unused'),
blocks: [
{ type: 'text', text: 'describe' },
{ type: 'image-ref', path: '/tmp/existing.png' },
{ type: 'text', text: '[Image #1] this' }
]
}
const out = normalizeImageTranscriptMessages([
userText('source', '[Image: source: /tmp/a.png]'),
prompt
])
expect(out).toHaveLength(1)
expect(out[0]?.blocks).toEqual([
{ type: 'image-ref', path: '/tmp/a.png' },
{ type: 'text', text: 'describe' },
{ type: 'image-ref', path: '/tmp/existing.png' },
{ type: 'text', text: 'this' }
])
})
it.each([
['[Image #1] describe this', 'describe this'],
['[Image #1]\t describe this', 'describe this'],
[' \t[Image #1] describe this', 'describe this'],
['describe this [Image #1]', 'describe this'],
['describe this \t[Image #1]', 'describe this'],
['describe this [Image #1]\t ', 'describe this'],
['describe [Image #1] this', 'describe this'],
['describe [Image #1]\t this', 'describe \t this'],
['describe[Image #1]\t this', 'describe\t this'],
['describe\n[Image #1]\nthis', 'describe\n\nthis'],
['com[Image #1]pare this', 'compare this'],
['[Image #1] [Image #2]', ''],
['literal [Image #x] text', 'literal [Image #x] text']
])('strips image prompt markers anywhere in text', (text, expected) => {
expect(stripImagePromptMarker(text)).toBe(expected)
})
it('returns long marker-free whitespace without regex backtracking', () => {
const text = ' '.repeat(50_000)
expect(stripImagePromptMarker(text)).toBe(text)
})
it('shares marker-aware text matching across multiple text blocks', () => {
const message: NativeChatMessage = {
...userText('prompt', 'unused'),
blocks: [
{ type: 'text', text: 'look' },
{ type: 'image-ref', path: '/tmp/a.png' },
{ type: 'text', text: '[Image #1] here' }
]
}
expect(normalizeNativeChatUserText(' look [Image #1] here ')).toBe('look here')
expect(normalizedNativeChatUserMessageText(message)).toBe('look here')
})
it('recognizes only sole-text image-source user turns', () => {
const source = userText('source', '[Image: source: /tmp/a.png]')
expect(isImageSourceUserTurn(source)).toBe(true)
expect(isImageSourceUserTurn({ ...source, role: 'assistant' })).toBe(false)
expect(
isImageSourceUserTurn({
...source,
blocks: [...source.blocks, { type: 'text', text: 'caption' }]
})
).toBe(false)
})
it('converts a lone [Image: source] turn (no prompt) into an image-ref instead of raw text', () => {
const out = normalizeImageTranscriptMessages([
userText('a', '[Image: source: /Users/me/Pictures/hero-image-2.png]')
])
expect(out).toHaveLength(1)
expect(out[0]!.blocks).toEqual([
{ type: 'image-ref', path: '/Users/me/Pictures/hero-image-2.png' }
])
})
it('folds every source and strips every prompt marker for a multi-image send', () => {
const out = normalizeImageTranscriptMessages([
userText('a', '[Image: source: /tmp/a.png]'),
userText('b', '[Image: source: /tmp/b.png]'),
userText('c', '[Image: source: /tmp/c.png]'),
userText('prompt', '[Image #1] [Image #2] [Image #3] compare these')
])
expect(out).toHaveLength(1)
expect(out[0]).toMatchObject({ id: 'prompt' })
expect(out[0]!.blocks).toEqual([
{ type: 'image-ref', path: '/tmp/a.png' },
{ type: 'image-ref', path: '/tmp/b.png' },
{ type: 'image-ref', path: '/tmp/c.png' },
{ type: 'text', text: 'compare these' }
])
})
it('keeps all image refs when a multi-image send has no caption', () => {
const out = normalizeImageTranscriptMessages([
userText('a', '[Image: source: /tmp/a.png]'),
userText('b', '[Image: source: /tmp/b.png]'),
userText('prompt', '[Image #1] [Image #2]')
])
expect(out).toHaveLength(1)
expect(out[0]!.blocks).toEqual([
{ type: 'image-ref', path: '/tmp/a.png' },
{ type: 'image-ref', path: '/tmp/b.png' }
])
})
it('preserves adjacent standalone image turns without a prompt marker', () => {
const out = normalizeImageTranscriptMessages([
userText('a', '[Image: source: /tmp/a.png]'),
userText('b', '[Image: source: /tmp/b.png]')
])
expect(out).toHaveLength(2)
expect(out.map((message) => message.id)).toEqual(['a', 'b'])
expect(out.map((message) => message.blocks)).toEqual([
[{ type: 'image-ref', path: '/tmp/a.png' }],
[{ type: 'image-ref', path: '/tmp/b.png' }]
])
})
it('folds a multi-block companion turn into one turn carrying every ref', () => {
// Claude records a multi-image paste as ONE companion message with a marker block
// per image, so a single-block-only rule missed every multi-image turn.
const out = normalizeImageTranscriptMessages([
{
id: 'companion',
role: 'user',
blocks: [
{ type: 'text', text: '[Image: source: /tmp/a.png]' },
{ type: 'text', text: '[Image: source: /tmp/b.png]' }
],
timestamp: 1,
source: 'transcript'
}
])
expect(out).toHaveLength(1)
expect(out[0]!.blocks).toEqual([
{ type: 'image-ref', path: '/tmp/a.png' },
{ type: 'image-ref', path: '/tmp/b.png' }
])
})
it('does not treat a turn mixing prose with a marker as an image-source turn', () => {
const messages = [
{
id: 'mixed',
role: 'user' as const,
blocks: [
{ type: 'text' as const, text: '[Image: source: /tmp/a.png]' },
{ type: 'text' as const, text: 'and here is what I think' }
],
timestamp: 1,
source: 'transcript' as const
}
]
const out = normalizeImageTranscriptMessages(messages)
expect(out[0]!.blocks.some((block) => block.type === 'image-ref')).toBe(false)
})
it('leaves ordinary user text untouched', () => {
const message = userText('a', 'how about this')
const messages = [message]
const out = normalizeImageTranscriptMessages(messages)
expect(out).toBe(messages)
expect(out[0]).toBe(message)
expect(out[0]!.blocks).toBe(message.blocks)
})
it('removes a whitespace-only first text block', () => {
const out = normalizeImageTranscriptMessages([userText('a', ' ')])
expect(out[0]?.blocks).toEqual([])
})
it('preserves unaffected rows when another row needs normalization', () => {
const before = userText('before', 'keep this row')
const marker = userText('marker', '[Image: source: /tmp/image.png]')
const after = userText('after', 'keep this row too')
const messages = [before, marker, after]
const out = normalizeImageTranscriptMessages(messages)
expect(out).not.toBe(messages)
expect(out[0]).toBe(before)
expect(out[2]).toBe(after)
})
it('leaves assistant messages untouched', () => {
const assistant: NativeChatMessage = {
id: 'a',
role: 'assistant',
blocks: [{ type: 'text', text: '[Image: source: /tmp/x.png]' }],
timestamp: 1,
source: 'transcript'
}
expect(normalizeImageTranscriptMessages([assistant])).toEqual([assistant])
})
})
describe('normalizeNativeChatUserText control bytes', () => {
it('drops the Ctrl+U a TUI pasted in front of the prompt', () => {
expect(normalizeNativeChatUserText('\u0015run the tests')).toBe('run the tests')
})
it('drops control bytes that landed inside the prompt', () => {
expect(normalizeNativeChatUserText('run\u0015 the\u0000 tests')).toBe('run the tests')
})
it('still finds the image marker behind a leading control byte', () => {
expect(normalizeNativeChatUserText('\u0015[Image #1] describe this')).toBe('describe this')
})
// Remove a bracketed-paste wrapper as one sequence so its printable tail cannot survive.
it('drops a bracketed-paste wrapper, not just its ESC introducer', () => {
expect(normalizeNativeChatUserText('\u001b[200~/tmp/orca-paste-1.png\u001b[201~')).toBe(
'/tmp/orca-paste-1.png'
)
})
it('preserves bracketed-paste-looking text without an ESC introducer', () => {
expect(normalizeNativeChatUserText('[200~literal[201~')).toBe('[200~literal[201~')
})
it('leaves tabs, newlines and carriage returns to the whitespace collapse', () => {
expect(normalizeNativeChatUserText('run\tthe\r\ntests')).toBe('run the tests')
})
})
describe('composer image chips at send and back', () => {
const chip = imageReferenceToken
it('numbers each chip by its image’s place among the images sent', () => {
expect(
imageReferencesToPromptMarkers(`after ${chip('b')} before ${chip('a')} again ${chip('b')}`, [
'a',
'b'
])
).toBe('after [Image #2] before [Image #1] again [Image #2]')
})
it('drops a chip whose image is not sent, and counts an image with no chip', () => {
expect(imageReferencesToPromptMarkers(`${chip('gone')}see ${chip('c')}`, ['a', 'c'])).toBe(
'see [Image #2]'
)
})
it('leaves typed text alone, even a typed marker or a path holding one', () => {
const text = 'compare [Image #2] with @/tmp/[Image #2].md and [Image](orca-image:bad id)'
expect(imageReferencesToPromptMarkers(text, ['a', 'b'])).toBe(text)
})
it('turns a returned message’s markers back into chips for its own images', () => {
expect(
promptMarkersToImageReferences('[Image #2] then [Image #1], not [Image #3]', ['r-1', 'r-3'])
).toBe(`${chip('r-3')} then ${chip('r-1')}, not [Image #3]`)
})
it('strips only the markers its images have, leaving a typed one past them', () => {
expect(stripSentImagePromptMarkers('[Image #1] then [Image #2], not [Image #3]', 2)).toBe(
'then , not [Image #3]'
)
expect(stripSentImagePromptMarkers('Explain the literal token [Image #1]', 0)).toBe(
'Explain the literal token [Image #1]'
)
})
it('leaves a quoted file path that reads like a marker as typed, back or stripped', () => {
const files = [
formatNativeChatFileReference('/repo/[Image #1].md'),
formatNativeChatFileReference('/repo/"q" [Image #1].md'),
formatNativeChatFileReference('/repo/"q" \'s\' [Image #1].md')
]
for (const file of files) {
expect(promptMarkersToImageReferences(`[Image #1] in ${file}`, ['r-1'])).toBe(
`${chip('r-1')} in ${file}`
)
expect(stripSentImagePromptMarkers(`[Image #1] in ${file} [Image #1]`, 1)).toBe(`in ${file}`)
}
})
it('sends a returned message with the numbers it had', () => {
const sent = 'compare [Image #1] with [Image #2]'
const ids = ['returned-m-1', 'returned-m-2']
expect(imageReferencesToPromptMarkers(promptMarkersToImageReferences(sent, ids), ids)).toBe(
sent
)
})
})