refactor(session-turn-outline): bound oversized preview blocks, degrade malformed previews

preview() now slices a single text block to limit * 2 before joining and
normalizing, so one multi-megabyte block no longer pays a full-string pass;
the host projection and the client turn-navigation helper stay mirrored.
outlineEntry keeps dropping entries with damaged turn/seq (marks cannot
exist or jump without them) but degrades malformed prompt/response
previews to empty strings so the turn stays navigable.
This commit is contained in:
Yichen Jiang 2026-08-30 22:13:26 +08:00 • committed by imccyu
parent 8322f804cb
commit b7053eba79
5 changed files with 48 additions and 8 deletions

View file

@ -24,17 +24,22 @@ export interface TurnRailItem {
const EMPTY_ITEMS: readonly TurnRailItem[] = []
/** Structurally narrow one wire outline entry (projection values cross the wire). */
/**
* Structurally narrow one wire outline entry (projection values cross the
* wire). `turn` and `seq` are the load-bearing fields — a mark cannot exist
* or jump without them — so their damage drops the entry; the previews are
* decorative, so a malformed one degrades to `''` and the turn stays
* navigable by number.
*/
function outlineEntry(value: unknown): { turn: number; seq: number; prompt: string; response: string } | undefined {
if (typeof value !== 'object' || value === null) return undefined
const entry = value as { turn?: unknown; seq?: unknown; prompt?: unknown; response?: unknown }
if (typeof entry.turn !== 'number' || !Number.isSafeInteger(entry.turn) || entry.turn < 0) return undefined
if (typeof entry.seq !== 'number' || !Number.isSafeInteger(entry.seq) || entry.seq < 0) return undefined
if (typeof entry.prompt !== 'string') return undefined
return {
turn: entry.turn,
seq: entry.seq,
prompt: entry.prompt,
prompt: typeof entry.prompt === 'string' ? entry.prompt : '',
response: typeof entry.response === 'string' ? entry.response : '',
}
}

View file

@ -20,7 +20,16 @@ function preview(parts: Iterable<string>, limit: number): string {
unread = true
break
}
text += text === '' ? part : ` ${part}`
// Per-part bound: this runs on every structural rail update, so one huge
// text block must not be concatenated (and regex-normalized) whole for a
// preview this short.
const clipped = part.length > limit * 2
const chunk = clipped ? part.slice(0, limit * 2) : part
text += text === '' ? chunk : ` ${chunk}`
if (clipped) {
unread = true
break
}
}
const normalized = text.replace(/\s+/g, ' ').trim()
if (normalized.length > limit - 1) return `${normalized.slice(0, limit - 1).trimEnd()}…`

View file

@ -55,19 +55,20 @@ describe('mergeTurnRailItems', () => {
])
})
it('drops malformed wire entries and shapes without folding the rail', () => {
it('drops entries with damaged navigation fields but degrades malformed previews to empty', () => {
expect(mergeTurnRailItems([loadedItem(1)], 'not an outline')).toEqual([
{ turn: 1, prompt: 'p1', response: 'r1', anchor: { kind: 'loaded', key: 'anchor-1' } },
])
const items = mergeTurnRailItems([], [
{ turn: -1, seq: 0, prompt: 'negative turn', response: '' },
{ turn: 2, seq: 0.5, prompt: 'fractional seq', response: '' },
{ turn: 3, seq: 4, prompt: 5, response: '' },
{ turn: 3, seq: 4, prompt: 5, response: 6 },
{ turn: 6, seq: 7, prompt: 'kept', response: 8 },
null,
])
// A non-string response degrades to '' while the entry itself survives.
// turn/seq are load-bearing (drop); previews are decorative (degrade).
expect(items).toEqual([
{ turn: 3, prompt: '', response: '', anchor: { kind: 'unloaded', seq: 4 } },
{ turn: 6, prompt: 'kept', response: '', anchor: { kind: 'unloaded', seq: 7 } },
])
})

View file

@ -42,7 +42,16 @@ function preview(content: MessageContent, limit: number): string {
unread = true
break
}
text += text === '' ? block.text : ` ${block.text}`
// Per-block bound: the fold runs on every message event, so a single
// multi-megabyte block must not be concatenated (and regex-normalized)
// whole for a preview this short.
const clipped = block.text.length > limit * 2
const chunk = clipped ? block.text.slice(0, limit * 2) : block.text
text += text === '' ? chunk : ` ${chunk}`
if (clipped) {
unread = true
break
}
}
const normalized = text.replace(/\s+/g, ' ').trim()
if (normalized.length > limit - 1) return `${normalized.slice(0, limit - 1).trimEnd()}…`

View file

@ -90,6 +90,22 @@ describe('turn outline projection unit', () => {
expect(outlineOf(ctx, session)[0]?.response).toBe('streamed but unsettled')
})
it('reads a bounded slice of one oversized text block instead of the whole payload', async () => {
const { ctx, session } = await harness(true)
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `giant ${'g'.repeat(500_000)}` }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
appendAssistant(session, 1, 1, `answer ${'a'.repeat(500_000)}`)
endTurn(session, 1)
const entry = outlineOf(ctx, session)[0]
expect(entry?.prompt).toMatch(/^giant g+…$/)
expect(entry?.prompt).toHaveLength(50)
expect(entry?.response).toMatch(/^answer a+…$/)
expect(entry?.response).toHaveLength(120)
})
it('collapses whitespace and caps previews at their card budgets with an ellipsis', async () => {
const { ctx, session } = await harness(true)
session.append('turn/start', { turn: 1 })