mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 00:02:29 +00:00
fix(ai-vault): bound transcript text before joining message blocks (#24659)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { transcriptMessagesFromContent } from './session-transcript-message-content'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { boundedText, transcriptMessagesFromContent } from './session-transcript-message-content'
|
||||
import { asRecord } from './session-scanner-record-value'
|
||||
|
||||
const AT = '2026-05-01T10:00:00.000Z'
|
||||
const MESSAGE_LIMIT = 256 * 1024
|
||||
|
||||
it('keeps a plain string turn under the record role', () => {
|
||||
expect(transcriptMessagesFromContent('user', 'just words', AT)).toEqual([
|
||||
@@ -65,3 +67,174 @@ it('does not apply the list preview cap', () => {
|
||||
const [message] = transcriptMessagesFromContent('user', [{ type: 'text', text: long }], AT)
|
||||
expect(message.text).toHaveLength(5000)
|
||||
})
|
||||
|
||||
it('bounds joins for a large multi-block turn and keeps its later tool messages', () => {
|
||||
const blockText = 'x'.repeat(1024 * 1024)
|
||||
const content = [
|
||||
...Array.from({ length: 72 }, () => ({ type: 'text', text: blockText })),
|
||||
{ type: 'tool_use', name: 'Read', input: { path: 'after-large-text' } },
|
||||
{
|
||||
type: 'tool_result',
|
||||
content: [
|
||||
{ type: 'text', text: 'header' },
|
||||
{ type: 'text', text: blockText.repeat(50) },
|
||||
{ type: 'text', text: 'ignored after the existing result cap' }
|
||||
]
|
||||
},
|
||||
{ type: 'tool_use', name: 'Bash', input: { command: 'pwd' } },
|
||||
{ type: 'tool_result', content: 'finished' }
|
||||
]
|
||||
const join = Array.prototype.join
|
||||
let largestJoinedLength = 0
|
||||
const spy = vi.spyOn(Array.prototype, 'join').mockImplementation(function (
|
||||
this: unknown[],
|
||||
separator
|
||||
) {
|
||||
let length = Math.max(0, this.length - 1) * (separator ?? ',').length
|
||||
for (const value of this) {
|
||||
length += typeof value === 'string' ? value.length : 0
|
||||
}
|
||||
largestJoinedLength = Math.max(largestJoinedLength, length)
|
||||
return join.call(this, separator)
|
||||
})
|
||||
let messages
|
||||
try {
|
||||
messages = transcriptMessagesFromContent('assistant', content, AT)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
expect(messages).toEqual([
|
||||
{ role: 'assistant', text: 'x'.repeat(MESSAGE_LIMIT), timestamp: AT },
|
||||
{ role: 'tool', text: 'Read: after-large-text', timestamp: AT },
|
||||
{ role: 'tool', text: `header\n${'x'.repeat(MESSAGE_LIMIT - 7)}`, timestamp: AT },
|
||||
{ role: 'tool', text: 'Bash: pwd', timestamp: AT },
|
||||
{ role: 'tool', text: 'finished', timestamp: AT }
|
||||
])
|
||||
expect(largestJoinedLength).toBeLessThanOrEqual(MESSAGE_LIMIT + 1)
|
||||
})
|
||||
|
||||
function previousToolResultText(content: unknown): string | null {
|
||||
if (typeof content === 'string') {
|
||||
return boundedText(content)
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return null
|
||||
}
|
||||
const parts: string[] = []
|
||||
let length = 0
|
||||
for (const item of content) {
|
||||
const text = typeof item === 'string' ? item : asRecord(item)?.text
|
||||
if (typeof text === 'string' && text) {
|
||||
parts.push(text)
|
||||
length += text.length
|
||||
if (length >= MESSAGE_LIMIT) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return boundedText(parts.join('\n'))
|
||||
}
|
||||
|
||||
it('matches the previous tool-result collector at newline and surrogate boundaries', () => {
|
||||
const cases: unknown[][] = [
|
||||
['x'.repeat(MESSAGE_LIMIT - 2), '\ud800'],
|
||||
['x'.repeat(MESSAGE_LIMIT - 1), '\ud800'],
|
||||
[`${'x'.repeat(MESSAGE_LIMIT - 1)}\ud800`, 'later'],
|
||||
['x'.repeat(MESSAGE_LIMIT - 2), '\ud800\udc00', 'later'],
|
||||
['x'.repeat(MESSAGE_LIMIT), '', 'later'],
|
||||
['', '', '\ud800', '\udfff', '\u0000'],
|
||||
[' '.repeat(MESSAGE_LIMIT), 'visible beyond the cap'],
|
||||
[null, 10, { type: 'image', text: 'still collected' }, { content: 'ignored' }]
|
||||
]
|
||||
let seed = 0x6a09e667
|
||||
const random = (): number => {
|
||||
seed ^= seed << 13
|
||||
seed ^= seed >>> 17
|
||||
seed ^= seed << 5
|
||||
return seed >>> 0
|
||||
}
|
||||
const lengths = [0, 1, 12, 4096, MESSAGE_LIMIT - 1, MESSAGE_LIMIT, MESSAGE_LIMIT + 1]
|
||||
const atoms = ['a', ' ', '\n', '\ud800', '\udfff', '\ud83d\ude80', '\u0000', '漢']
|
||||
for (let sample = 0; sample < 120; sample++) {
|
||||
cases.push(
|
||||
Array.from({ length: 1 + (random() % 6) }, (_, index) => {
|
||||
const length = lengths[random() % lengths.length]
|
||||
const atom = atoms[random() % atoms.length]
|
||||
const text = atom.repeat(Math.ceil(length / atom.length)).slice(0, length)
|
||||
return index % 2 === 0 ? text : { type: 'text', text }
|
||||
})
|
||||
)
|
||||
}
|
||||
for (const content of cases) {
|
||||
const text = previousToolResultText(content)
|
||||
expect(
|
||||
transcriptMessagesFromContent(
|
||||
'user',
|
||||
[
|
||||
{ type: 'tool_result', content },
|
||||
{ type: 'tool_use', name: 'Read', input: { path: 'after' } },
|
||||
{ type: 'tool_result', content: 'finished' }
|
||||
],
|
||||
AT
|
||||
)
|
||||
).toEqual([
|
||||
...(text ? [{ role: 'tool', text, timestamp: AT }] : []),
|
||||
{ role: 'tool', text: 'Read: after', timestamp: AT },
|
||||
{ role: 'tool', text: 'finished', timestamp: AT }
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
it('matches join-then-bound at newline and raw surrogate boundaries', () => {
|
||||
const partsCases = [
|
||||
['x'.repeat(MESSAGE_LIMIT - 2), '\ud800'],
|
||||
['x'.repeat(MESSAGE_LIMIT - 1), '\ud800'],
|
||||
[`${'x'.repeat(MESSAGE_LIMIT - 1)}\ud800`, 'later'],
|
||||
['x'.repeat(MESSAGE_LIMIT - 2), '\ud800\udc00', 'later'],
|
||||
['x'.repeat(MESSAGE_LIMIT - 3), '\ud800\udc00', 'later'],
|
||||
['x'.repeat(MESSAGE_LIMIT), '', 'later'],
|
||||
['', '', '\ud800', '\udfff', '\u0000'],
|
||||
[' '.repeat(MESSAGE_LIMIT), 'visible beyond the cap']
|
||||
]
|
||||
for (const parts of partsCases) {
|
||||
const text = boundedText(parts.join('\n'))
|
||||
expect(transcriptMessagesFromContent('user', parts, AT)).toEqual(
|
||||
text ? [{ role: 'user', text, timestamp: AT }] : []
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('matches the previous join-then-bound output across generated block mixtures', () => {
|
||||
let seed = 0x6a09e667
|
||||
const random = (): number => {
|
||||
seed ^= seed << 13
|
||||
seed ^= seed >>> 17
|
||||
seed ^= seed << 5
|
||||
return seed >>> 0
|
||||
}
|
||||
const lengths = [0, 1, 12, 4096, MESSAGE_LIMIT - 1, MESSAGE_LIMIT, MESSAGE_LIMIT + 1]
|
||||
const atoms = ['a', ' ', '\n', '\ud800', '\udfff', '\ud83d\ude80', '\u0000', '漢']
|
||||
const roles = ['user', 'assistant', 'tool', 'system', 'unknown'] as const
|
||||
for (let sample = 0; sample < 120; sample++) {
|
||||
const parts = Array.from({ length: 2 + (random() % 7) }, () => {
|
||||
const length = lengths[random() % lengths.length]
|
||||
const atom = atoms[random() % atoms.length]
|
||||
return atom.repeat(Math.ceil(length / atom.length)).slice(0, length)
|
||||
})
|
||||
const role = roles[random() % roles.length]
|
||||
const content = parts.map((text, index) => (index % 2 === 0 ? text : { type: 'Text', text }))
|
||||
const text = boundedText(parts.filter((part, index) => index % 2 === 0 || part).join('\n'))
|
||||
expect(
|
||||
transcriptMessagesFromContent(
|
||||
role,
|
||||
[...content, { type: 'tool_use', name: 'Bash', input: { command: 'pwd' } }],
|
||||
AT
|
||||
)
|
||||
).toEqual([
|
||||
...(text && (role === 'user' || role === 'assistant' || role === 'tool')
|
||||
? [{ role, text, timestamp: AT }]
|
||||
: []),
|
||||
{ role: 'tool', text: 'Bash: pwd', timestamp: AT }
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
@@ -61,6 +61,9 @@ function toolResultText(content: unknown): string | null {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (length + parts.length - 1 > TRANSCRIPT_MESSAGE_TEXT_LIMIT) {
|
||||
return boundedJoinedText(parts)
|
||||
}
|
||||
const joined = parts.join('\n')
|
||||
return joined.trim() ? joined : null
|
||||
}
|
||||
@@ -113,7 +116,7 @@ export function transcriptMessagesFromContent(
|
||||
}
|
||||
if (textRole && textParts.length > 0) {
|
||||
// The record's own words lead; its tool blocks follow in transcript order.
|
||||
const text = boundedText(textParts.join('\n'))
|
||||
const text = boundedJoinedText(textParts)
|
||||
if (text) {
|
||||
messages.unshift({ role: textRole, text, timestamp })
|
||||
}
|
||||
@@ -121,6 +124,27 @@ export function transcriptMessagesFromContent(
|
||||
return messages
|
||||
}
|
||||
|
||||
function boundedJoinedText(parts: readonly string[]): string | null {
|
||||
if (parts.length === 1) {
|
||||
return boundedText(parts[0])
|
||||
}
|
||||
const prefixes: string[] = []
|
||||
// One extra code unit preserves truncation at a trailing high surrogate.
|
||||
let remaining = TRANSCRIPT_MESSAGE_TEXT_LIMIT + 1
|
||||
for (const part of parts) {
|
||||
if (prefixes.length > 0) {
|
||||
remaining--
|
||||
}
|
||||
const prefix = part.slice(0, remaining)
|
||||
prefixes.push(prefix)
|
||||
remaining -= prefix.length
|
||||
if (remaining === 0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return boundedText(prefixes.join('\n'))
|
||||
}
|
||||
|
||||
function pushMessage(
|
||||
messages: TranscriptMessage[],
|
||||
role: TranscriptMessageRole,
|
||||
|
||||
Reference in New Issue
Block a user