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:
OrcaWin
2026-10-02 18:31:00 -07:00
committed by GitHub
co-authored by OrcaWin Neil
parent d9a3d3c3c9
commit 74609c6538
2 changed files with 200 additions and 3 deletions
@@ -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,