三轮实测驱动迭代:种子信封透传(surfaceOp 校验)、每线程一 Tab 交互重做完、四连修(fork 丢 this / 自绘 history+save 图标 / collectOwnEvents 大页回源修重开丢工具行 / descriptor 入种子修目录 corrupt 行 + 前缀过滤保拓扑零噪音)。739 单测 + 挂载冒烟 12/12 全绿。
265 lines
12 KiB
TypeScript
265 lines
12 KiB
TypeScript
/**
|
||
* Side Chat transcript mapping (browser half): turns a thread child's
|
||
* history rows (`session.history` — the generic RPC, which reads the durable
|
||
* log without activating the child) into compact display rows.
|
||
*
|
||
* A thread child's log starts with the ENTIRE inherited parent log as its
|
||
* fork seed. The mapping therefore cuts everything up to the LAST
|
||
* `session/end-seed` marker and drops the "Side conversation boundary"
|
||
* prompt row, so the view shows only the thread's own conversation.
|
||
*
|
||
* Live streaming: `assistant/message` events only land when a step
|
||
* completes, but `assistant/chunk` events stream token-level text and
|
||
* reasoning deltas. The mapping accumulates both per block and supersedes
|
||
* them with the assembled message once it lands (settled rows).
|
||
*/
|
||
import type { SidebarHistoryEntry } from '../context-types.ts'
|
||
import { SIDE_BOUNDARY_PREFIX } from '../sidechat-core.ts'
|
||
|
||
/** One compact transcript row rendered in the thread view. `seq` is the
|
||
* source event's log sequence — stable row identity for React keys across
|
||
* polls (streaming caches ride the key, so window slides must not re-key
|
||
* rows). */
|
||
export type SidechatTranscriptRow =
|
||
| { kind: 'user'; seq: number; text: string }
|
||
/** `settled` distinguishes an assembled message from a still-streaming
|
||
* chunk accumulation (streaming rows are superseded by the settle). */
|
||
| { kind: 'assistant'; seq: number; text: string; settled: boolean }
|
||
| { kind: 'reasoning'; seq: number; text: string; settled: boolean }
|
||
| {
|
||
kind: 'tool'
|
||
seq: number
|
||
name: string
|
||
failed: boolean
|
||
/** Raw arguments JSON as the model produced it. */
|
||
args?: string
|
||
/** Plain text of the paired result. */
|
||
resultText?: string
|
||
/** True while the call's result has not landed yet. */
|
||
executing?: boolean
|
||
}
|
||
|
||
/** Extract the visible text of a content-block list (`text` blocks verbatim,
|
||
* joined by blank lines); empty reads `…` so rows never render blank. */
|
||
export function blockText(content: readonly unknown[]): string {
|
||
const parts: string[] = []
|
||
for (const block of content) {
|
||
if (block === null || typeof block !== 'object') continue
|
||
const candidate = block as { type?: unknown; text?: unknown }
|
||
if (candidate.type === 'text' && typeof candidate.text === 'string') {
|
||
parts.push(candidate.text)
|
||
}
|
||
}
|
||
const text = parts.join('\n\n')
|
||
return text === '' ? '…' : text
|
||
}
|
||
|
||
/** The plain text of a tool/result message (text blocks inside its
|
||
* `tool-result` content block). */
|
||
function resultTextOf(data: Record<string, unknown>): string {
|
||
const message = data.message as { content?: unknown } | undefined
|
||
const content = message?.content
|
||
if (!Array.isArray(content)) return ''
|
||
const parts: string[] = []
|
||
for (const block of content) {
|
||
if (block === null || typeof block !== 'object') continue
|
||
const candidate = block as { type?: unknown; content?: unknown }
|
||
if (candidate.type !== 'tool-result') continue
|
||
const inner = candidate.content
|
||
if (!Array.isArray(inner)) continue
|
||
for (const item of inner) {
|
||
if (item === null || typeof item !== 'object') continue
|
||
const textItem = item as { type?: unknown; text?: unknown }
|
||
if (textItem.type === 'text' && typeof textItem.text === 'string') {
|
||
parts.push(textItem.text)
|
||
}
|
||
}
|
||
}
|
||
return parts.join('\n')
|
||
}
|
||
|
||
/** Index of the last `session/end-seed` event (fork seed marker), or -1. */
|
||
function lastSeedEnd(events: readonly { type: string }[]): number {
|
||
for (let index = events.length - 1; index >= 0; index--) {
|
||
if (events[index]?.type === 'session/end-seed') return index
|
||
}
|
||
return -1
|
||
}
|
||
|
||
/**
|
||
* Collect the thread's OWN events on first attach: walk backward from the
|
||
* log tail (oldest-first accumulation) until the `session/end-seed` marker
|
||
* surfaces, then keep everything after it.
|
||
*
|
||
* Page size matters: cold reads re-expand persisted chunk-rows into one
|
||
* `assistant/chunk` event per delta, so a single streamed answer can be
|
||
* HUNDREDS of events. A small walk window (the old 8×32 = 256 events) let
|
||
* earlier `tool/call` events fall out of the loaded window — the tool rows
|
||
* vanished on re-entry while the settled text survived. The walk therefore
|
||
* pages big; tail polls stay small.
|
||
*
|
||
* Exhaustion (log start reached without a marker — a thread created before
|
||
* seeding existed, or a pathological log) returns `seedBoundary: 0` so the
|
||
* caller stops re-walking and renders the window as-is.
|
||
*
|
||
* @param fetchPage - one history page (newest-first window ending at
|
||
* `beforeSeq`, exclusive; omit for the tail page).
|
||
* @param pageCap - safety bound on backward pages.
|
||
*/
|
||
export async function collectOwnEvents(
|
||
fetchPage: (beforeSeq?: number) => Promise<readonly SidebarHistoryEntry[]>,
|
||
pageCap = 40,
|
||
): Promise<{ seedBoundary: number; entries: SidebarHistoryEntry[] }> {
|
||
const collected: SidebarHistoryEntry[] = []
|
||
let beforeSeq: number | undefined
|
||
for (let page = 0; page < pageCap; page++) {
|
||
const events = await fetchPage(beforeSeq)
|
||
if (events.length === 0) {
|
||
// Log start reached without a marker: the window IS the whole log.
|
||
return { seedBoundary: 0, entries: collected }
|
||
}
|
||
const olderThan = collected.length > 0 ? collected[0]!.event.seq : undefined
|
||
const fresh = olderThan === undefined
|
||
? [...events]
|
||
: events.filter(entry => entry.event.seq < olderThan)
|
||
const seedEnd = fresh.findLastIndex(entry => entry.event.type === 'session/end-seed')
|
||
if (seedEnd >= 0) {
|
||
collected.unshift(...fresh.slice(seedEnd + 1))
|
||
return { seedBoundary: fresh[seedEnd]!.event.seq, entries: collected }
|
||
}
|
||
collected.unshift(...fresh)
|
||
if (fresh.length === 0) {
|
||
// The page overlaps entirely with what we have: nothing older exists.
|
||
return { seedBoundary: 0, entries: collected }
|
||
}
|
||
beforeSeq = fresh[0]!.event.seq
|
||
}
|
||
// Cap hit: accept the window (it is overwhelmingly the thread's own tail)
|
||
// rather than re-walking on every poll.
|
||
return { seedBoundary: 0, entries: collected }
|
||
}
|
||
|
||
/**
|
||
* Map a thread child's history rows onto compact transcript rows: the
|
||
* inherited fork seed is cut at the last `session/end-seed`, the boundary
|
||
* prompt row is dropped, `assistant/chunk` deltas accumulate into streaming
|
||
* rows per (turn, step, block) and are superseded by the assembled
|
||
* `assistant/message`, and tool invocations render one expandable line each
|
||
* (arguments, paired result text, failure marker; a still-executing call is
|
||
* marked until its result lands).
|
||
* @param entries - history rows (event + host-computed view) in seq order.
|
||
* @returns display rows in log order.
|
||
*/
|
||
export function transcriptRows(entries: readonly SidebarHistoryEntry[]): SidechatTranscriptRow[] {
|
||
const events = entries.map(entry => entry.event)
|
||
const seedEnd = lastSeedEnd(events)
|
||
const rows: SidechatTranscriptRow[] = []
|
||
/** (turn, step, index, kind) key → index of its accumulating stream row. */
|
||
const streamRows = new Map<string, number>()
|
||
/** tool callId → index of its tool row in `rows` (result pairing). */
|
||
const callRows = new Map<string, number>()
|
||
for (let index = 0; index < events.length; index++) {
|
||
if (index <= seedEnd) continue
|
||
const event = events[index]
|
||
if (event === undefined) continue
|
||
const data = event.data as Record<string, unknown>
|
||
switch (event.type) {
|
||
case 'user/message': {
|
||
const text = blockText(Array.isArray(data.content) ? data.content : [])
|
||
if (text.startsWith(SIDE_BOUNDARY_PREFIX)) break
|
||
rows.push({ kind: 'user', seq: event.seq, text })
|
||
break
|
||
}
|
||
case 'assistant/chunk': {
|
||
const chunk = data.chunk as { type?: unknown; text?: unknown } | undefined
|
||
if (chunk === null || typeof chunk !== 'object') break
|
||
const kind = chunk.type === 'text-delta' ? 'assistant' : chunk.type === 'reasoning-delta' ? 'reasoning' : null
|
||
if (kind === null || typeof chunk.text !== 'string' || chunk.text === '') break
|
||
const turn = data.turn
|
||
const step = data.step
|
||
const blockIndex = (chunk as { index?: unknown }).index
|
||
const key = `${String(turn)}:${String(step)}:${String(blockIndex)}:${kind}`
|
||
const existing = streamRows.get(key)
|
||
if (existing !== undefined) {
|
||
const row = rows[existing]
|
||
if (row !== undefined && row.kind === kind && !row.settled) {
|
||
rows[existing] = { ...row, text: row.text + chunk.text }
|
||
}
|
||
} else {
|
||
streamRows.set(key, rows.length)
|
||
rows.push({ kind, seq: event.seq, text: chunk.text, settled: false })
|
||
}
|
||
break
|
||
}
|
||
case 'assistant/message': {
|
||
const prefix = `${String(data.turn)}:${String(data.step)}:`
|
||
const streamed = [...streamRows.entries()]
|
||
.filter(([key]) => key.startsWith(prefix))
|
||
.map(([, rowIndex]) => rowIndex)
|
||
for (const key of [...streamRows.keys()]) {
|
||
if (key.startsWith(prefix)) streamRows.delete(key)
|
||
}
|
||
const content = Array.isArray((data.message as { content?: unknown } | undefined)?.content)
|
||
? (data.message as { content: readonly unknown[] }).content
|
||
: []
|
||
const settled: SidechatTranscriptRow[] = content.flatMap((block): SidechatTranscriptRow[] => {
|
||
if (block === null || typeof block !== 'object') return []
|
||
const candidate = block as { type?: unknown; text?: unknown }
|
||
if (candidate.type === 'reasoning' && typeof candidate.text === 'string' && candidate.text !== '') {
|
||
return [{ kind: 'reasoning', seq: event.seq, text: candidate.text, settled: true }]
|
||
}
|
||
if (candidate.type === 'text' && typeof candidate.text === 'string' && candidate.text !== '') {
|
||
return [{ kind: 'assistant', seq: event.seq, text: candidate.text, settled: true }]
|
||
}
|
||
return []
|
||
})
|
||
if (streamed.length === 0) rows.push(...settled)
|
||
else rows.splice(Math.min(...streamed), streamed.length, ...settled)
|
||
break
|
||
}
|
||
case 'tool/call': {
|
||
const callId = data.callId
|
||
const name = typeof data.name === 'string' ? data.name : 'tool'
|
||
const args = typeof data.arguments === 'string' ? data.arguments : undefined
|
||
const rowIndex = rows.length
|
||
if (typeof callId === 'string') callRows.set(callId, rowIndex)
|
||
rows.push({ kind: 'tool', seq: event.seq, name, failed: false, args, executing: true })
|
||
break
|
||
}
|
||
case 'tool/result': {
|
||
const source = data.message as { source?: { callId?: unknown } } | undefined
|
||
const callId = typeof source?.source?.callId === 'string' ? source.source.callId : undefined
|
||
const rowIndex = callId === undefined ? undefined : callRows.get(callId)
|
||
const failed = data.error !== undefined
|
||
const resultText = resultTextOf(data)
|
||
if (rowIndex !== undefined) {
|
||
const row = rows[rowIndex]
|
||
if (row !== undefined && row.kind === 'tool') {
|
||
rows[rowIndex] = {
|
||
...row,
|
||
failed: row.failed || failed,
|
||
resultText: resultText === '' ? row.resultText : resultText,
|
||
executing: false,
|
||
}
|
||
}
|
||
} else if (failed || resultText !== '') {
|
||
// Orphan result (no call row in the window): surface it so the row
|
||
// stays informative and expandable.
|
||
rows.push({
|
||
kind: 'tool',
|
||
seq: event.seq,
|
||
name: callId === undefined ? 'tool' : `tool:${callId.slice(0, 8)}`,
|
||
failed,
|
||
resultText: resultText === '' ? undefined : resultText,
|
||
})
|
||
}
|
||
break
|
||
}
|
||
default: {
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return rows
|
||
}
|