Kaynağa Gözat

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.
Yichen Jiang 1 ay önce
ebeveyn
işleme
b7053eba79

+ 8 - 3
packages/client/ui-chat/src/client/chat/turn-rail-items.ts

@@ -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 : '',
   }
 }

+ 10 - 1
packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts

@@ -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()}…`

+ 4 - 3
packages/client/ui-chat/tests/turn-rail-items.client.spec.ts

@@ -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 } },
     ])
   })

+ 10 - 1
packages/session/session-turn-outline/src/projection.ts

@@ -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()}…`

+ 16 - 0
packages/session/session-turn-outline/tests/projection.spec.ts

@@ -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 })