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:
parent
8322f804cb
commit
b7053eba79
5 changed files with 48 additions and 8 deletions
|
|
@ -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 : '',
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()}…`
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()}…`
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue