Jelajahi Sumber

完善流式 Markdown 分块渲染并补齐 Bot 分片定时刷新

LSamien 1 bulan lalu
induk
melakukan
1a96f22c8a

+ 73 - 12
src/client/components/messages/MarkdownContent.vue

@@ -2,7 +2,7 @@
 import DOMPurify from 'dompurify'
 import hljs from 'highlight.js'
 import MarkdownIt from 'markdown-it'
-import { computed, onMounted, onUpdated, ref } from 'vue'
+import { computed, nextTick, onBeforeUnmount, onMounted, onUpdated, ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import { copyTextToClipboard } from '@/utils/clipboard'
 import { normalizeAssistantMediaMarkdown } from '@/utils/mediaMarkdown'
@@ -18,10 +18,51 @@ const props = withDefaults(defineProps<{
   outlinePrefix?: string
 }>(), { streaming: false, legacyMedia: false, plain: false, fileCards: false, outlinePrefix: '' })
 
-const emit = defineEmits<{ fileLink: [name: string, url: string] }>()
+const emit = defineEmits<{ fileLink: [name: string, url: string]; rendered: [] }>()
 
 const root = ref<HTMLElement | null>(null)
 const copied = ref('')
+const displayedContent = ref(props.content)
+let renderTimer: ReturnType<typeof setTimeout> | undefined
+function cancelRenderTimer() {
+  if (renderTimer !== undefined) clearTimeout(renderTimer)
+  renderTimer = undefined
+}
+// Throttle, not debounce: a continuous token stream must keep making progress.
+watch(() => [props.content, props.streaming, props.plain] as const, ([content, streaming, plain]) => {
+  if (!streaming || plain || !content.startsWith(displayedContent.value) || !displayedContent.value) {
+    cancelRenderTimer()
+    displayedContent.value = content
+  } else if (renderTimer === undefined) {
+    renderTimer = setTimeout(() => {
+      renderTimer = undefined
+      displayedContent.value = props.content
+    }, 80)
+  }
+})
+onBeforeUnmount(cancelRenderTimer)
+
+// Completed code blocks retain their HTML as the trailing block grows.
+const highlightedCode = new Map<string, string>()
+let highlightCost = 0
+function highlightCode(source: string, language: string): string {
+  const key = `${language}\0${source}`
+  const cached = highlightedCode.get(key)
+  if (cached !== undefined) return cached
+  const html = language && hljs.getLanguage(language)
+    ? hljs.highlight(source, { language, ignoreIllegals: true }).value
+    : escapeHtml(source)
+  if (key.length + html.length <= 256_000) {
+    highlightedCode.set(key, html)
+    highlightCost += key.length + html.length
+    while (highlightedCode.size > 32 || highlightCost > 512_000) {
+      const oldest = highlightedCode.keys().next().value!
+      highlightCost -= oldest.length + highlightedCode.get(oldest)!.length
+      highlightedCode.delete(oldest)
+    }
+  }
+  return html
+}
 
 function escapeHtml(value: string): string {
   return value
@@ -38,9 +79,7 @@ const md = new MarkdownIt({
   typographer: false,
   highlight(str: string, lang: string): string {
     const language = lang.trim().split(/\s+/)[0]?.toLowerCase() || ''
-    const highlighted = language && hljs.getLanguage(language)
-      ? hljs.highlight(str, { language, ignoreIllegals: true }).value
-      : escapeHtml(str)
+    const highlighted = highlightCode(str, language)
     const label = language || 'text'
     return `<pre class="code-block"><div class="code-header"><span class="code-lang">${escapeHtml(label)}</span><button type="button" class="code-copy" aria-label="复制代码">复制</button></div><code class="hljs language-${escapeHtml(label)}">${highlighted}</code></pre>`
   },
@@ -59,8 +98,8 @@ md.renderer.rules.link_open = (tokens, index, options, env, self) => {
 
 const markdownSource = computed(() => {
   let source = props.legacyMedia
-    ? normalizeAssistantMediaMarkdown(props.content || '', props.streaming)
-    : props.content || ''
+    ? normalizeAssistantMediaMarkdown(displayedContent.value || '', props.streaming)
+    : displayedContent.value || ''
   source = repairMarkdownForRender(source, props.streaming)
   return source
 })
@@ -76,8 +115,7 @@ function highlightMentions(html: string): string {
   return html.replace(re, match => `<span class="mention-highlight">${match}</span>`)
 }
 
-const rendered = computed(() => {
-  const html = md.render(markdownSource.value)
+function sanitize(html: string): string {
   return DOMPurify.sanitize(highlightMentions(html), {
     USE_PROFILES: { html: true },
     // MarkdownIt generates the inert code-copy button above. User-provided HTML
@@ -87,7 +125,27 @@ const rendered = computed(() => {
     ALLOW_UNKNOWN_PROTOCOLS: false,
     ADD_ATTR: ['target'],
   })
+}
+
+const renderedBlocks = computed(() => {
+  if (props.plain) return []
+  const env = {}
+  const tokens = md.parse(markdownSource.value, env)
+  const blocks: string[] = []
+  let start = 0
+  let depth = 0
+  for (let index = 0; index < tokens.length; index++) {
+    depth += tokens[index].nesting
+    if (depth !== 0) continue
+    blocks.push(sanitize(md.renderer.render(tokens.slice(start, index + 1), md.options, env)))
+    start = index + 1
+  }
+  return blocks
 })
+watch(renderedBlocks, async () => {
+  await nextTick()
+  emit('rendered')
+}, { flush: 'post' })
 
 function decorateCopyButtons() {
   if (!root.value) return
@@ -177,14 +235,17 @@ onUpdated(() => { decorateCopyButtons(); decorateFileLinks(); decorateMediaPrevi
 
 <template>
   <div v-if="plain" class="plain-text">{{ content }}</div>
-  <div v-else ref="root" class="markdown" :class="{ 'markdown--streaming': streaming }" @click="onClick" v-html="rendered" />
+  <div v-else ref="root" class="markdown" :class="{ 'markdown--streaming': streaming }" @click="onClick">
+    <div v-for="(html, index) in renderedBlocks" :key="index" class="markdown-block" v-html="html" />
+  </div>
   <AppIcon v-if="streaming" class="stream-caret" name="arrow-up" :size="0" />
 </template>
 
 <style scoped>
 .plain-text { min-width: 0; color: inherit; font-size: 13px; line-height: 1.68; white-space: pre-wrap; overflow-wrap: anywhere; }
+.markdown-block { display: flow-root; }
 .markdown { min-width: 0; color: inherit; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; }
-.markdown :deep(p) { margin: 0 0 10px; }.markdown :deep(p:last-child) { margin-bottom: 0; }
+.markdown :deep(p) { margin: 0 0 10px; }.markdown-block:last-child :deep(> p:last-child) { margin-bottom: 0; }
 .markdown :deep(h1), .markdown :deep(h2), .markdown :deep(h3), .markdown :deep(h4), .markdown :deep(h5), .markdown :deep(h6) { margin: 1.5em 0 .55em; color: var(--text-primary); line-height: 1.35; letter-spacing: -.01em; }
 .markdown :deep(h1) { font-size: 1.5em; }.markdown :deep(h2) { font-size: 1.28em; }.markdown :deep(h3) { font-size: 1.12em; }.markdown :deep(h4), .markdown :deep(h5), .markdown :deep(h6) { font-size: 1em; }
 .markdown :deep(ul), .markdown :deep(ol) { margin: .45em 0 10px; padding-left: 1.55em; }.markdown :deep(li) { margin: .32em 0; }
@@ -205,7 +266,7 @@ onUpdated(() => { decorateCopyButtons(); decorateFileLinks(); decorateMediaPrevi
 .markdown :deep(img) { display: block; width: auto; max-width: min(100%, 560px); max-height: 360px; margin: .7em 0; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-soft); object-fit: contain; }.markdown :deep(img[data-media-preview]) { cursor: zoom-in; }
 .markdown :deep(.file-link-card) { display: flex; width: min(390px, 100%); min-height: 52px; align-items: center; gap: 9px; margin: 8px 0; padding: 8px 11px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); box-shadow: 0 3px 10px rgba(0,0,0,.035); color: var(--text-primary); text-decoration: none; }.markdown :deep(.file-link-card)::before { display: grid; width: 27px; height: 27px; flex: 0 0 27px; place-items: center; border-radius: 7px; background: var(--surface-soft); color: var(--text-secondary); content: '▤'; font-size: 15px; }.markdown :deep(.file-link-card)::after { margin-left: auto; color: var(--text-muted); content: '预览'; font-size: 9px; }.markdown :deep(.file-link-card:hover) { border-color: var(--line-strong); background: var(--surface-soft); text-decoration: none; }
 .markdown :deep(.mention-highlight) { padding: .08em .34em; border-radius: 6px; background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--text-primary); font-weight: 600; }
-.markdown--streaming :deep(p:last-child)::after { content: ''; display: inline-block; width: 5px; height: 14px; margin-left: 3px; border-radius: 1px; background: currentColor; vertical-align: -2px; animation: caret 1s step-end infinite; }
+.markdown--streaming > .markdown-block:last-child::after { content: ''; display: inline-block; width: 5px; height: 14px; margin-left: 3px; border-radius: 1px; background: currentColor; vertical-align: -2px; animation: caret 1s step-end infinite; }
 .stream-caret { display: none; }
 @keyframes caret { 50% { opacity: 0; } }
 @media (max-width: 600px) { .markdown :deep(img) { max-height: 280px; } }

+ 6 - 1
src/client/components/messages/MessageTimeline.vue

@@ -141,6 +141,10 @@ function scrollToBottom(behavior: ScrollBehavior = 'smooth') {
   el.scrollTo({ top: el.scrollHeight, behavior })
 }
 
+function onMarkdownRendered() {
+  if (pinnedToBottom.value) scrollToBottom('auto')
+}
+
 function scrollToMessage(id: string): boolean {
   const root = scroller.value
   if (!root) return false
@@ -311,7 +315,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 
             <details v-if="message.reasoning" class="message__reasoning">
               <summary><AppIcon name="brain" :size="13" />思考过程 · {{ message.reasoning.length }} 字</summary>
-              <MarkdownContent :content="message.reasoning" />
+              <MarkdownContent :content="message.reasoning" :streaming="message.status === 'streaming'" @rendered="onMarkdownRendered" />
             </details>
 
             <div v-if="message.role === 'user' && message.attachments?.length" class="message__attachments">
@@ -331,6 +335,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
                 :mention-names="mentionNames"
                 :outline-prefix="message.role === 'assistant' ? `outline-${message.id}` : ''"
                 file-cards
+                @rendered="onMarkdownRendered"
                 @file-link="(name, url) => emit('previewFile', { name, url })"
               />
             </div>

+ 1 - 1
src/client/components/messages/TurnTrace.vue

@@ -28,7 +28,7 @@ const statusLabel = computed(() => props.group.status === 'running' ? '进行中
       <template v-for="entry in group.entries" :key="entry.id">
         <section v-if="entry.type === 'reasoning'" class="turn-trace__item turn-trace__reasoning">
           <header><AppIcon name="brain" :size="12" />思考过程 · {{ entry.content.length }} 字</header>
-          <MarkdownContent :content="entry.content" />
+          <MarkdownContent :content="entry.content" :streaming="group.status === 'running'" />
         </section>
         <ToolTrace v-else class="turn-trace__item turn-trace__tool" :tool="entry.tool" expanded />
       </template>

+ 18 - 3
src/server/workspaceRuntime.ts

@@ -178,6 +178,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       completing = false,
       runtimeId = '',
       lastFlush = 0
+    let flushTimer: ReturnType<typeof setTimeout> | undefined
     let resolveTurn!: (m: Message) => void, rejectTurn!: (e: Error) => void
     const completion = new Promise<Message>((resolve, reject) => {
       resolveTurn = resolve
@@ -188,6 +189,8 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     const finish = (error?: Error) => {
       if (settled) return
       settled = true
+      clearTimeout(flushTimer)
+      flushTimer = undefined
       this.live.delete(key)
       try {
         const current = this.getWork(owner, run.id)
@@ -349,10 +352,22 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           this.store.put(owner, 'binding', key, binding)
         }
       }
-      if (Date.now() - lastFlush >= 100) {
-        this.store.saveMessage(owner, resultMessage)
-        lastFlush = Date.now()
+      const flush = () => {
+        clearTimeout(flushTimer)
+        flushTimer = undefined
+        if (settled || this.closing) return
+        try {
+          this.store.saveMessage(owner, resultMessage)
+          lastFlush = Date.now()
+        } catch (error) {
+          finish(error instanceof Error ? error : new Error('无法保存流式消息'))
+        }
       }
+      const remaining = 100 - (Date.now() - lastFlush)
+      if (remaining <= 0) flush()
+      // A stream may pause after any chunk. Publish the latest text even when
+      // no subsequent event arrives to trigger the next leading-edge flush.
+      else if (flushTimer === undefined) flushTimer = setTimeout(flush, remaining)
     }
     try {
       await gateway.connect()

+ 84 - 0
tests/client/markdownContent.test.ts

@@ -41,3 +41,87 @@ describe('MarkdownContent code copy', () => {
     wrapper.unmount()
   })
 })
+
+describe('streaming Markdown', () => {
+  afterEach(() => vi.useRealTimers())
+
+  it('formats before completion, preserves settled DOM blocks, and flushes the final token', async () => {
+    vi.useFakeTimers()
+    const prefix = '# 流式标题\n\n- 第一项\n- 第二项\n\n```ts\nconst x = '
+    const wrapper = mount(MarkdownContent, { props: { content: prefix, streaming: true } })
+    expect(wrapper.get('h1').text()).toBe('流式标题')
+    expect(wrapper.findAll('li')).toHaveLength(2)
+    expect(wrapper.get('pre code').element.textContent).toBe('const x = ')
+    const heading = wrapper.get('h1').element
+    const list = wrapper.get('ul').element
+    await wrapper.setProps({ content: `${prefix}42` })
+    await vi.advanceTimersByTimeAsync(80)
+    expect(wrapper.get('pre code').text()).toContain('42')
+    expect(wrapper.get('h1').element).toBe(heading)
+    expect(wrapper.get('ul').element).toBe(list)
+    await wrapper.setProps({ content: `${prefix}42\n\x60\x60\x60\n\n最后一个字`, streaming: false })
+    expect(wrapper.text()).toContain('最后一个字')
+    expect(wrapper.find('.markdown--streaming').exists()).toBe(false)
+    await vi.advanceTimersByTimeAsync(100)
+    expect(wrapper.text()).toContain('最后一个字')
+    wrapper.unmount()
+  })
+
+  it('does not starve while tokens arrive faster than the render interval', async () => {
+    vi.useFakeTimers()
+    const wrapper = mount(MarkdownContent, { props: { content: '# 连续输出\n\n', streaming: true } })
+    let content = '# 连续输出\n\n'
+    for (let index = 0; index < 20; index++) {
+      content += `${index} `
+      await wrapper.setProps({ content })
+      await vi.advanceTimersByTimeAsync(10)
+      if (index === 10) expect(wrapper.text()).toContain('7')
+    }
+    await vi.advanceTimersByTimeAsync(80)
+    expect(wrapper.text()).toContain('19')
+    wrapper.unmount()
+  })
+
+  it('handles replacement, interruption, literal user text, and unmount during a pending update', async () => {
+    vi.useFakeTimers()
+    const wrapper = mount(MarkdownContent, { props: { content: '```swift\nlet x = ', streaming: true } })
+    await wrapper.setProps({ content: '```swift\nlet x = 1' })
+    await wrapper.setProps({ streaming: false })
+    expect(wrapper.get('pre code').text()).toContain('let x = 1')
+    await wrapper.setProps({ content: '# 另一条消息', streaming: true })
+    expect(wrapper.get('h1').text()).toBe('另一条消息')
+    await vi.advanceTimersByTimeAsync(100)
+    expect(wrapper.find('pre').exists()).toBe(false)
+    await wrapper.setProps({ content: '**用户原文**', plain: true })
+    expect(wrapper.get('.plain-text').text()).toBe('**用户原文**')
+    expect(wrapper.find('strong').exists()).toBe(false)
+    await wrapper.setProps({ content: '# 新内容', plain: false })
+    await wrapper.setProps({ content: '# 新内容继续' })
+    wrapper.unmount()
+    expect(vi.getTimerCount()).toBe(0)
+  })
+
+  it('renders split tables, links and emphasis safely and preserves the full long reply', async () => {
+    vi.useFakeTimers()
+    const source = '# 标题\n\n**粗体** 和 `行内代码`\n\n> 引用\n\n| 名称 | 数量 |\n| --- | --- |\n| 项目 | 2 |\n\n[链接](https://example.com)\n\n<script>alert(1)</script>\n\n[危险](javascript:alert(1))'
+    const wrapper = mount(MarkdownContent, { props: { content: '', streaming: true } })
+    for (let index = 1; index <= source.length; index++) {
+      await wrapper.setProps({ content: source.slice(0, index) })
+      await vi.advanceTimersByTimeAsync(80)
+    }
+    expect(wrapper.get('strong').text()).toBe('粗体')
+    expect(wrapper.get('blockquote').text()).toBe('引用')
+    expect(wrapper.get('table').text()).toContain('项目')
+    expect(wrapper.get('a').attributes('href')).toBe('https://example.com')
+    expect(wrapper.find('script').exists()).toBe(false)
+    expect(wrapper.find('a[href^="javascript:"]').exists()).toBe(false)
+    const long = `${source}\n\n${'完整的长段落。\n\n'.repeat(400)}最终唯一标记`
+    await wrapper.setProps({ content: long })
+    await vi.advanceTimersByTimeAsync(80)
+    expect(wrapper.text()).toContain('最终唯一标记')
+    await wrapper.setProps({ streaming: false })
+    expect(wrapper.text()).toContain('最终唯一标记')
+    expect(wrapper.emitted('rendered')?.length).toBeGreaterThan(1)
+    wrapper.unmount()
+  })
+})

+ 46 - 0
tests/e2e/workspace-chat.spec.ts

@@ -423,3 +423,49 @@ test('subaccount allocation and Bot-only navigation work through the browser', a
   }
   await childContext.close()
 })
+
+for (const mode of ['普通聊天', 'Bot 单聊', '群聊', '普通长回复'] as const) {
+  test(`${mode} 在完成前格式化流式 Markdown 并保留最终内容`, async ({ page }, testInfo) => {
+    await login(page)
+    if (mode.startsWith('普通')) {
+      await page.goto('/chat')
+      await page.getByRole('button', { name: '新建聊天', exact: true }).click()
+    } else {
+      await createAgent(page, `${mode}流式验证助手`)
+      if (mode === '群聊') {
+        await createAgent(page, '群聊流式协作成员')
+        await openCreate(page, '新建群聊')
+        const dialog = page.getByRole('dialog')
+        await dialog.getByRole('textbox', { name: '名称', exact: true }).fill('流式验证群')
+        await dialog.getByRole('checkbox', { name: `${mode}流式验证助手`, exact: true }).check()
+        await dialog.getByRole('checkbox', { name: '群聊流式协作成员', exact: true }).check()
+        await dialog.getByRole('combobox', { name: '管理员', exact: true }).selectOption({ label: `${mode}流式验证助手` })
+        await dialog.getByRole('button', { name: '保存', exact: true }).click()
+        await expect(page.getByRole('heading', { name: '流式验证群', exact: true })).toBeVisible()
+      }
+    }
+    await page.getByPlaceholder('输入消息,Enter 发送,Shift + Enter 换行').fill(`[streaming-markdown] ${mode === '普通长回复' ? '[long]' : ''} 演示排版`)
+    await page.getByRole('button', { name: '发送消息', exact: true }).click()
+    const message = page.locator('.message--assistant').last()
+    await expect(message.locator('.markdown--streaming h1')).toHaveText('流式标题')
+    await expect(message.locator('strong').filter({ hasText: '即时格式化' })).toBeVisible()
+    await expect(message.locator('li')).toHaveText(['第一项', '第二项'])
+    await expect(message.locator('pre code').first()).toContainText('const answer = 42')
+    await expect(message).not.toContainText('最终完整标记')
+    await page.screenshot({ path: testInfo.outputPath('streaming-markdown.png'), fullPage: true })
+    if (mode === '普通长回复') {
+      await expect(message.getByText('流式末尾标记', { exact: true })).toBeInViewport()
+      await page.locator('.timeline').evaluate(el => { el.scrollTop = 0; el.dispatchEvent(new Event('scroll')) })
+      await expect(message.getByRole('heading', { name: '流式标题', exact: true })).toBeInViewport()
+    }
+    const released = await page.request.post('http://127.0.0.1:19120/__release')
+    expect(released.ok()).toBe(true)
+    await expect(message).toContainText('最终完整标记')
+    await expect(message.locator('table')).toContainText('项目')
+    await expect(message.locator('.markdown--streaming')).toHaveCount(0)
+    if (mode === '普通长回复') {
+      await expect(message.getByRole('heading', { name: '流式标题', exact: true })).toBeInViewport()
+      await expect(message.getByText('最终完整标记', { exact: true })).not.toBeInViewport()
+    }
+  })
+}

+ 6 - 3
tests/fixtures/workspace-server.ts

@@ -240,9 +240,12 @@ wss.on('connection', (socket) => {
       const name = /你是 (.*?)。/.exec(f.params.text)?.[1] || '助手'
       const requested = /本轮用户指定成员:([^\n]*)/.exec(f.params.text)?.[1] ?? ''
       const delegates = f.params.text.includes('你是管理员。') && !f.params.text.includes('本批次执行结果:') && requested.startsWith('@')
-      const text = `我是${name}。已按角色规则处理这条消息。\n\n- 会话独立保存\n- 可以继续交流\n\n[报告](/tmp/workspace-report.txt)${delegates ? `\n请${requested}处理任务。` : ''}`
+      const markdownStream = String(f.params.text).includes('[streaming-markdown]')
+      const markdownPrefix = '# 流式标题\n\n**即时格式化**\n\n- 第一项\n- 第二项\n\n```ts\nconst answer = 42' + (String(f.params.text).includes('[long]') ? `\n\x60\x60\x60\n\n${'这是持续生成的长回复段落。\n\n'.repeat(80)}流式末尾标记\n\n\x60\x60\x60ts\nconst tail = 1` : '')
+      const markdownFinal = `${markdownPrefix}\n\x60\x60\x60\n\n| 名称 | 数量 |\n| --- | --- |\n| 项目 | 2 |\n\n最终完整标记`
+      const text = markdownStream ? markdownFinal : `我是${name}。已按角色规则处理这条消息。\n\n- 会话独立保存\n- 可以继续交流\n\n[报告](/tmp/workspace-report.txt)${delegates ? `\n请${requested}处理任务。` : ''}`
       event('message.start', {}, f.params.session_id)
-      setTimeout(() => event('message.delta', { text: text.slice(0, 12) }, f.params.session_id), 80)
+      setTimeout(() => event('message.delta', { text: markdownStream ? markdownPrefix : text.slice(0, 12) }, f.params.session_id), 80)
       const complete = () => {
         stored!.running = false
         stored!.messages.push({
@@ -253,7 +256,7 @@ wss.on('connection', (socket) => {
         })
         event('message.complete', { text, status: 'complete' }, f.params.session_id)
       }
-      if (f.params.text.includes('[hold-workspace]')) heldReplies.push(complete)
+      if (markdownStream || f.params.text.includes('[hold-workspace]')) heldReplies.push(complete)
       else setTimeout(complete, 250)
       return
     }

+ 25 - 0
tests/server/workspace.test.ts

@@ -119,6 +119,31 @@ async function finished(id: string) {
   return store.require<WorkspaceRun>(owner, 'run', id)
 }
 describe('Web-owned workspace', () => {
+  it('flushes a paused delta before completion and cancels pending flush on finish', async () => {
+    let upstream: { socket: WebSocket; params: Record<string, any> } | undefined
+    reply = (socket, params) => {
+      upstream = { socket, params }
+      const event = (type: string, payload: Record<string, unknown>) => socket.send(JSON.stringify({
+        method: 'event', params: { type, session_id: params.session_id, payload },
+      }))
+      event('message.start', {})
+      event('message.delta', { text: '# 流式标题' })
+    }
+    const bot = agent('流式助手'), conversation = direct(bot.id)
+    const run = runtime.send(owner, conversation.id, { requestId: randomUUID(), content: '验证暂停分片' })
+    const assistant = () => store.list<WorkspaceMessage>(owner, 'message').find(m => m.runId === run.id && m.role === 'assistant')
+    await vi.waitFor(() => expect(assistant()?.content).toBe('# 流式标题'))
+    expect(assistant()?.status).toBe('streaming')
+    expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).not.toBe('complete')
+    const { socket, params } = upstream!
+    socket.send(JSON.stringify({ method: 'event', params: { type: 'message.delta', session_id: params.session_id, payload: { text: '\n\n最后一段' } } }))
+    completeReply(socket, params, '# 最终正文')
+    await finished(run.id)
+    await new Promise(resolve => setTimeout(resolve, 150))
+    expect(assistant()?.content).toBe('# 最终正文')
+    expect(assistant()?.status).toBe('complete')
+  })
+
   it('backfills legacy group history into the initial task instead of leaving a new empty task', () => {
     const legacyHome = mkdtempSync(join(tmpdir(), 'yaoyao-workspace-task-migration-'))
     let seededStore: WorkspaceStore | undefined = new WorkspaceStore(legacyHome)