Interleave live parts per turn and mark commentary process on tool.call
Why: liveToMessage emitted all text buckets then all tools, while the persisted message interleaves per turn (thinking, text, calls), so parts reordered on reconcile with 2+ tool turns. Commentary stayed in the reply area until the next turn's text bucket appeared, because the process flag was positional (i < length - 1) instead of tool-driven. Evidence: LiveState now holds an arrival-ordered items stream (thinking/text deltas merge into their last segment; tools land in place), so event order - which matches the backend's per-turn append order - maps 1:1 onto persisted parts. A text segment is process iff a tool.call lands before the next thinking/text segment, aligning with the backend's Process: len(calls) > 0; the collapse predicate moved to testable showProcessInline. Long replies (>800 chars) now show a 100-char excerpt and label the count as Markdown source length. vitest 27/27 pass (was 5 live cases, now 12 incl. multi-turn order, immediate process marking, collapse predicate); tsc -b clean. Impact: streaming UI no longer reshuffles parts across turns or on final reconcile; tool-turn commentary folds into the process area as soon as the tool call arrives. Residual edge (documented in live.ts): a silent tool-only turn with no text and no thinking attributes its call to the previous text segment. Refs: code review of feature/chat-process-split-r1 (2026-09-30)
This commit is contained in:
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { marked } from 'marked'
|
||||
import { api } from '../api'
|
||||
import { store, useStore, activeRun, type LiveState, type ContextObs } from '../store'
|
||||
import { liveToMessage } from '../live'
|
||||
import { liveToMessage, showProcessInline } from '../live'
|
||||
import type { Message, Part, HITLCard } from '../contracts'
|
||||
import { UIRenderer, InlineConfirmCard, hasOpenConfirm } from '@ui'
|
||||
import { docBridge } from './Workbench'
|
||||
@@ -87,11 +87,14 @@ function ProcessCollapse({ parts }: { parts: Part[] }) {
|
||||
const LONG_REPLY_CHARS = 800
|
||||
|
||||
function LongReplyCollapse({ text }: { text: string }) {
|
||||
// 摘句取压缩空白后的前 100 字;字数按 Markdown 源码长度计。
|
||||
const excerpt = text.replace(/\s+/g, ' ').trim().slice(0, 100)
|
||||
return (
|
||||
<details className="process-collapse long-reply">
|
||||
<summary>
|
||||
<span>📄 长回复</span>
|
||||
<span>{text.length} 字(点击展开)</span>
|
||||
<span>{text.length} 字(按 Markdown 源码计)</span>
|
||||
{excerpt && <span style={{ color: 'var(--text-faint)' }}>{excerpt}…</span>}
|
||||
</summary>
|
||||
<div className="body">
|
||||
<div className="part-text" dangerouslySetInnerHTML={{ __html: marked.parse(text) }} />
|
||||
@@ -113,7 +116,7 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
|
||||
// 流式期间:工具/思考阶段过程实时展开;一旦进入 responding(最终回复开始流式)
|
||||
// 立即折叠过程,只显式显示回复(Cursor 式);工具恢复(acting)时重新展开。
|
||||
// run 终态后:按偏好折叠为摘要行(可回看)。
|
||||
const showProcessInline = live ? live.phase !== 'responding' : !autoCollapse
|
||||
const showProcess = showProcessInline(live ?? null, autoCollapse)
|
||||
const lastContentIdx = split.content.length - 1
|
||||
return (
|
||||
<div className="msg assistant">
|
||||
@@ -122,7 +125,7 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
|
||||
<span>助手</span>
|
||||
{live && <span className="live-bar"><span className="live-dot" />{live.phase}</span>}
|
||||
</div>
|
||||
{split.process.length > 0 && (showProcessInline
|
||||
{split.process.length > 0 && (showProcess
|
||||
? split.process.map((p, i) => <div className="part" key={i}><PartView part={p} live={!!live} /></div>)
|
||||
: <ProcessCollapse parts={split.process} />)}
|
||||
{split.content.map((p, i) => {
|
||||
@@ -250,7 +253,7 @@ export function Conversation() {
|
||||
|
||||
useEffect(() => {
|
||||
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight })
|
||||
}, [messages.length, live?.texts, live?.thinking, live?.toolCalls.length, live?.phase, activeHitl.length])
|
||||
}, [messages.length, live?.items, live?.phase, activeHitl.length])
|
||||
|
||||
const send = async () => {
|
||||
if (!text.trim() && attachmentIds.length === 0) return
|
||||
|
||||
+75
-20
@@ -1,36 +1,91 @@
|
||||
// live 流式投影纯逻辑(中栏过程/回复分离):按 delta index 分桶、合成临时消息。
|
||||
// 新文本桶只在新轮开启(必经工具轮),故除最后一桶外均为工具轮过程解说(process);
|
||||
// 最后一桶是正在流式的回复——与落库消息的 process 语义同构,终态对账无跳变。
|
||||
// live 流式投影纯逻辑(中栏过程/回复分离):事件按到达序并入 items——thinking/text
|
||||
// 增量各并入其末段,工具按当前位置落位。SSE 有序且后端每轮按 思考→正文→工具 追加落库
|
||||
// 部件,事件到达序即落库序,故合成消息与落库部件同构,终态对账不重排。
|
||||
// 文本段是否为过程(process)由「本段之后、下一个思考/文本段之前是否出现工具」判定,
|
||||
// 对齐落库的 Process: len(calls) > 0——工具轮解说的 tool.call 一到即并入过程区,
|
||||
// 无需等下一轮文本段开启。
|
||||
// 残余边界:某轮若既无正文也无思考、仅静默发起工具,其调用会被并入上一文本段(需协议
|
||||
// 携带轮次归属才能消除;现行事件流无法区分)。
|
||||
import type { Message, Part } from './contracts'
|
||||
import type { LiveState } from './store'
|
||||
|
||||
export interface TextBucket { index: number; text: string }
|
||||
// 到达序部件流的元素。
|
||||
export type LiveItem =
|
||||
| { kind: 'thinking'; text: string }
|
||||
| { kind: 'text'; index: number; text: string }
|
||||
| { kind: 'tool'; name: string; status?: string; summary?: string }
|
||||
|
||||
// applyTextDelta 把一段正文增量并入桶序列:index 与末桶相同则追加,否则开新桶。
|
||||
export function applyTextDelta(buckets: TextBucket[], index: number, delta: string): TextBucket[] {
|
||||
const last = buckets[buckets.length - 1]
|
||||
if (last && last.index === index) {
|
||||
const out = buckets.slice()
|
||||
out[out.length - 1] = { index, text: last.text + delta }
|
||||
// applyThinkingDelta 并入思考增量:末段是思考则追加,否则开启新思考段(新轮开始)。
|
||||
export function applyThinkingDelta(items: LiveItem[], delta: string): LiveItem[] {
|
||||
const last = items[items.length - 1]
|
||||
if (last && last.kind === 'thinking') {
|
||||
const out = items.slice()
|
||||
out[out.length - 1] = { kind: 'thinking', text: last.text + delta }
|
||||
return out
|
||||
}
|
||||
return [...buckets, { index, text: delta }]
|
||||
return [...items, { kind: 'thinking', text: delta }]
|
||||
}
|
||||
|
||||
// applyTextDelta 并入正文增量:末段是同 index 的文本段则追加,否则开启新文本段。
|
||||
export function applyTextDelta(items: LiveItem[], index: number, delta: string): LiveItem[] {
|
||||
const last = items[items.length - 1]
|
||||
if (last && last.kind === 'text' && last.index === index) {
|
||||
const out = items.slice()
|
||||
out[out.length - 1] = { kind: 'text', index, text: last.text + delta }
|
||||
return out
|
||||
}
|
||||
return [...items, { kind: 'text', index, text: delta }]
|
||||
}
|
||||
|
||||
export function applyToolCall(items: LiveItem[], name: string): LiveItem[] {
|
||||
return [...items, { kind: 'tool', name }]
|
||||
}
|
||||
|
||||
// applyToolResult 回填最近一个未决工具(与落库的尾部配对语义一致)。
|
||||
export function applyToolResult(items: LiveItem[], status: string, summary?: string): LiveItem[] {
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
const it = items[i]
|
||||
if (it.kind === 'tool' && it.status === undefined) {
|
||||
const out = items.slice()
|
||||
out[i] = { kind: 'tool', name: it.name, status, summary }
|
||||
return out
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// liveToMessage:run 进行中没有已落库的助手消息,把 LiveState 合成同构临时消息,
|
||||
// 复用 AssistantMessage 渲染(思考/工具/文本实时上屏);终态对账后由真消息替代。
|
||||
export function liveToMessage(live: LiveState): Message {
|
||||
const parts: Part[] = []
|
||||
if (live.thinking) parts.push({ type: 'thinking', text: live.thinking, process: true })
|
||||
for (let i = 0; i < live.texts.length; i++) {
|
||||
parts.push({ type: 'text', text: live.texts[i].text, process: i < live.texts.length - 1 || undefined })
|
||||
// 文本段是否为过程:本段之后、下一个思考/文本段之前出现工具(= 该轮带工具调用)。
|
||||
const textProcess = new Map<number, boolean>()
|
||||
for (let i = 0; i < live.items.length; i++) {
|
||||
if (live.items[i].kind !== 'text') continue
|
||||
let hasCalls = false
|
||||
for (let j = i + 1; j < live.items.length && live.items[j].kind === 'tool'; j++) hasCalls = true
|
||||
textProcess.set(i, hasCalls)
|
||||
}
|
||||
for (const tc of live.toolCalls) {
|
||||
parts.push({ type: 'tool_call', process: true, toolCall: { id: '', name: tc.name } })
|
||||
if (tc.status !== undefined) {
|
||||
parts.push({ type: 'tool_result', process: true, toolCall: { id: '', name: tc.name },
|
||||
toolResult: { callId: '', status: tc.status === 'error' ? 'error' : 'ok', summary: tc.summary } })
|
||||
const parts: Part[] = []
|
||||
for (let i = 0; i < live.items.length; i++) {
|
||||
const it = live.items[i]
|
||||
if (it.kind === 'thinking') {
|
||||
if (it.text) parts.push({ type: 'thinking', text: it.text, process: true })
|
||||
} else if (it.kind === 'tool') {
|
||||
parts.push({ type: 'tool_call', process: true, toolCall: { id: '', name: it.name } })
|
||||
if (it.status !== undefined) {
|
||||
parts.push({ type: 'tool_result', process: true, toolCall: { id: '', name: it.name },
|
||||
toolResult: { callId: '', status: it.status === 'error' ? 'error' : 'ok', summary: it.summary } })
|
||||
}
|
||||
} else {
|
||||
parts.push({ type: 'text', text: it.text, process: textProcess.get(i) || undefined })
|
||||
}
|
||||
}
|
||||
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' }
|
||||
}
|
||||
|
||||
// showProcessInline:流式期间过程区是否展开——thinking/acting 实时展开;一旦进入
|
||||
// responding(最终回复开始流式)立即折叠,只显示回复(Cursor 式),工具恢复再展开。
|
||||
// 非 live 消息随「自动折叠过程」偏好。
|
||||
export function showProcessInline(live: LiveState | null, autoCollapse: boolean): boolean {
|
||||
return live ? live.phase !== 'responding' : !autoCollapse
|
||||
}
|
||||
|
||||
+11
-17
@@ -2,7 +2,7 @@
|
||||
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { api, connectEvents } from './api'
|
||||
import { applyTextDelta } from './live'
|
||||
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolResult, type LiveItem } from './live'
|
||||
import type {
|
||||
Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList,
|
||||
AgentProfile, StreamEvent, PluginPanel, ActivityCard,
|
||||
@@ -17,11 +17,9 @@ export interface ContextObs {
|
||||
export interface LiveState {
|
||||
runId: string
|
||||
phase: string
|
||||
thinking: string
|
||||
// 按 delta index 分桶的文本:新桶只在新轮开启(必经工具轮),故除最后一桶外
|
||||
// 均为工具轮过程解说(process);最后一桶是正在流式的回复。
|
||||
texts: { index: number; text: string }[]
|
||||
toolCalls: { name: string; status?: string; summary?: string }[]
|
||||
// 到达序部件流(见 live.ts LiveItem):事件到达序即落库序,与落库部件的轮次穿插序
|
||||
// 同构;文本段是否为过程由「本段内是否出现工具调用」判定,tool.call 一到即改标。
|
||||
items: LiveItem[]
|
||||
agentNotes: Record<string, string>
|
||||
startedAt: number
|
||||
}
|
||||
@@ -256,26 +254,22 @@ export const store = {
|
||||
switch (e.type) {
|
||||
case 'message.delta': {
|
||||
const p = e.payload as { index: number; text: string }
|
||||
set({ live: touchLive(e.runId ?? '', l => { l.texts = applyTextDelta(l.texts, p.index, p.text) }) })
|
||||
set({ live: touchLive(e.runId ?? '', l => { l.items = applyTextDelta(l.items, p.index, p.text) }) })
|
||||
break
|
||||
}
|
||||
case 'thinking.delta': {
|
||||
const p = e.payload as { index: number; text: string }
|
||||
set({ live: touchLive(e.runId ?? '', l => { l.thinking += p.text }) })
|
||||
set({ live: touchLive(e.runId ?? '', l => { l.items = applyThinkingDelta(l.items, p.text) }) })
|
||||
break
|
||||
}
|
||||
case 'tool.call': {
|
||||
const p = e.payload as { callId: string; name: string }
|
||||
set({ live: touchLive(e.runId ?? '', l => { l.toolCalls.push({ name: p.name }) }) })
|
||||
set({ live: touchLive(e.runId ?? '', l => { l.items = applyToolCall(l.items, p.name) }) })
|
||||
break
|
||||
}
|
||||
case 'tool.result': {
|
||||
const p = e.payload as { callId: string; status: string; summary?: string }
|
||||
set({ live: touchLive(e.runId ?? '', l => {
|
||||
for (let i = l.toolCalls.length - 1; i >= 0; i--) {
|
||||
if (l.toolCalls[i].status === undefined) { l.toolCalls[i] = { ...l.toolCalls[i], status: p.status, summary: p.summary }; break }
|
||||
}
|
||||
}) })
|
||||
set({ live: touchLive(e.runId ?? '', l => { l.items = applyToolResult(l.items, p.status, p.summary) }) })
|
||||
break
|
||||
}
|
||||
case 'run.phase': {
|
||||
@@ -370,7 +364,7 @@ export const store = {
|
||||
// 竞态保护:POST 返回前 SSE 事件可能已先行到达并累积(同 run),
|
||||
// 此时保留已累积的 live,只补挂用户消息;否则初始化空 live。
|
||||
if (!state.live || state.live.runId !== out.run.id) {
|
||||
set({ live: { runId: out.run.id, phase: 'thinking', thinking: '', texts: [], toolCalls: [], agentNotes: {}, startedAt: Date.now() } })
|
||||
set({ live: { runId: out.run.id, phase: 'thinking', items: [], agentNotes: {}, startedAt: Date.now() } })
|
||||
}
|
||||
set({ messages: [...state.messages, out.message] })
|
||||
store.connectIfNeeded()
|
||||
@@ -504,8 +498,8 @@ export const store = {
|
||||
}
|
||||
|
||||
function touchLive(_runId: string, mutate: (l: LiveState) => void): LiveState | null {
|
||||
const base = state.live ?? { runId: _runId, phase: 'thinking', thinking: '', texts: [], toolCalls: [], agentNotes: {}, startedAt: Date.now() }
|
||||
const next: LiveState = { ...base, toolCalls: [...base.toolCalls], agentNotes: { ...base.agentNotes } }
|
||||
const base = state.live ?? { runId: _runId, phase: 'thinking', items: [], agentNotes: {}, startedAt: Date.now() }
|
||||
const next: LiveState = { ...base, agentNotes: { ...base.agentNotes } }
|
||||
mutate(next)
|
||||
return next
|
||||
}
|
||||
|
||||
+117
-31
@@ -1,48 +1,134 @@
|
||||
// live 流式投影纯逻辑测试(中栏过程/回复分离):分桶、process 标记、终态同构。
|
||||
// live 流式投影纯逻辑测试(中栏过程/回复分离):到达序并段、process 标记时机、
|
||||
// 轮次穿插同构、过程区折叠谓词。
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { applyTextDelta, liveToMessage, type TextBucket } from '../src/live'
|
||||
import {
|
||||
applyTextDelta, applyThinkingDelta, applyToolCall, applyToolResult,
|
||||
liveToMessage, showProcessInline, type LiveItem,
|
||||
} from '../src/live'
|
||||
import type { LiveState } from '../src/store'
|
||||
|
||||
function live(over: Partial<LiveState> = {}): LiveState {
|
||||
return { runId: 'r1', phase: 'thinking', thinking: '', texts: [], toolCalls: [], agentNotes: {}, startedAt: 0, ...over }
|
||||
return { runId: 'r1', phase: 'thinking', items: [], agentNotes: {}, startedAt: 0, ...over }
|
||||
}
|
||||
|
||||
describe('applyTextDelta', () => {
|
||||
it('appends to the last bucket when index matches', () => {
|
||||
let b: TextBucket[] = []
|
||||
b = applyTextDelta(b, 0, '先查')
|
||||
b = applyTextDelta(b, 0, '一下')
|
||||
expect(b).toEqual([{ index: 0, text: '先查一下' }])
|
||||
// 两轮流式事件回放:思考→解说文本→工具→思考→最终回复(与后端事件序一致)。
|
||||
function twoTurnItems(): LiveItem[] {
|
||||
let items: LiveItem[] = []
|
||||
items = applyThinkingDelta(items, '先想想')
|
||||
items = applyThinkingDelta(items, '一下')
|
||||
items = applyTextDelta(items, 0, '我先更新待办。')
|
||||
items = applyToolCall(items, 'update_todos')
|
||||
items = applyToolResult(items, 'ok', '已更新')
|
||||
items = applyThinkingDelta(items, '再想想')
|
||||
items = applyTextDelta(items, 1, '最终')
|
||||
items = applyTextDelta(items, 1, '回复')
|
||||
return items
|
||||
}
|
||||
|
||||
describe('applyThinkingDelta / applyTextDelta', () => {
|
||||
it('merges thinking deltas into the last thinking segment', () => {
|
||||
let items: LiveItem[] = []
|
||||
items = applyThinkingDelta(items, '先')
|
||||
items = applyThinkingDelta(items, '想想')
|
||||
expect(items).toEqual([{ kind: 'thinking', text: '先想想' }])
|
||||
})
|
||||
it('opens a new bucket when index advances (new turn)', () => {
|
||||
let b: TextBucket[] = applyTextDelta([], 0, '解说')
|
||||
b = applyTextDelta(b, 1, '最终')
|
||||
b = applyTextDelta(b, 1, '回复')
|
||||
expect(b).toEqual([{ index: 0, text: '解说' }, { index: 1, text: '最终回复' }])
|
||||
it('opens a new thinking segment after text/tool (turn boundary)', () => {
|
||||
let items: LiveItem[] = applyThinkingDelta([], '第一轮思考')
|
||||
items = applyTextDelta(items, 0, '解说')
|
||||
items = applyThinkingDelta(items, '第二轮思考')
|
||||
expect(items).toEqual([
|
||||
{ kind: 'thinking', text: '第一轮思考' },
|
||||
{ kind: 'text', index: 0, text: '解说' },
|
||||
{ kind: 'thinking', text: '第二轮思考' },
|
||||
])
|
||||
})
|
||||
it('appends text to the last segment when index matches', () => {
|
||||
let items: LiveItem[] = []
|
||||
items = applyTextDelta(items, 0, '先查')
|
||||
items = applyTextDelta(items, 0, '一下')
|
||||
expect(items).toEqual([{ kind: 'text', index: 0, text: '先查一下' }])
|
||||
})
|
||||
it('opens a new text segment when index advances (new turn)', () => {
|
||||
let items: LiveItem[] = applyTextDelta([], 0, '解说')
|
||||
items = applyTextDelta(items, 1, '最终')
|
||||
items = applyTextDelta(items, 1, '回复')
|
||||
expect(items).toEqual([
|
||||
{ kind: 'text', index: 0, text: '解说' },
|
||||
{ kind: 'text', index: 1, text: '最终回复' },
|
||||
])
|
||||
})
|
||||
it('fills the most recent pending tool call', () => {
|
||||
let items: LiveItem[] = []
|
||||
items = applyToolCall(items, 'a')
|
||||
items = applyToolCall(items, 'b')
|
||||
items = applyToolResult(items, 'ok', '结果')
|
||||
expect(items).toEqual([
|
||||
{ kind: 'tool', name: 'a' },
|
||||
{ kind: 'tool', name: 'b', status: 'ok', summary: '结果' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('liveToMessage', () => {
|
||||
it('marks all but the last text bucket as process (tool-turn commentary)', () => {
|
||||
const msg = liveToMessage(live({
|
||||
thinking: '思考',
|
||||
texts: [{ index: 0, text: '我先更新待办。' }, { index: 1, text: '完成了。' }],
|
||||
toolCalls: [{ name: 'update_todos', status: 'ok' }],
|
||||
}))
|
||||
const texts = msg.parts.filter(p => p.type === 'text')
|
||||
expect(texts.map(p => p.process)).toEqual([true, undefined])
|
||||
expect(texts.map(p => p.text)).toEqual(['我先更新待办。', '完成了。'])
|
||||
it('interleaves parts per turn in arrival order, matching persisted parts', () => {
|
||||
const msg = liveToMessage(live({ items: twoTurnItems() }))
|
||||
expect(msg.parts).toEqual([
|
||||
{ type: 'thinking', text: '先想想一下', process: true },
|
||||
{ type: 'text', text: '我先更新待办。', process: true },
|
||||
{ type: 'tool_call', process: true, toolCall: { id: '', name: 'update_todos' } },
|
||||
{ type: 'tool_result', process: true, toolCall: { id: '', name: 'update_todos' },
|
||||
toolResult: { callId: '', status: 'ok', summary: '已更新' } },
|
||||
{ type: 'thinking', text: '再想想', process: true },
|
||||
{ type: 'text', text: '最终回复' },
|
||||
])
|
||||
})
|
||||
it('single bucket (final turn only) carries no process flag', () => {
|
||||
const msg = liveToMessage(live({ texts: [{ index: 0, text: '直接回答' }] }))
|
||||
expect(msg.parts.filter(p => p.type === 'text').map(p => p.process)).toEqual([undefined])
|
||||
it('marks the in-flight segment as process the moment its tool call arrives', () => {
|
||||
let items: LiveItem[] = []
|
||||
items = applyTextDelta(items, 0, '我先查一下')
|
||||
const before = liveToMessage(live({ items }))
|
||||
expect(before.parts.map(p => p.process)).toEqual([undefined]) // 尚无工具:仍在回复位
|
||||
items = applyToolCall(items, 'web_search')
|
||||
const after = liveToMessage(live({ items }))
|
||||
expect(after.parts.map(p => p.process)).toEqual([true, true]) // tool.call 一到即改标
|
||||
})
|
||||
it('single final segment without tools carries no process flag', () => {
|
||||
let items: LiveItem[] = []
|
||||
items = applyTextDelta(items, 0, '直接回答')
|
||||
const msg = liveToMessage(live({ items }))
|
||||
expect(msg.parts.map(p => p.process)).toEqual([undefined])
|
||||
})
|
||||
it('tools between segments keep every non-final segment in the process area', () => {
|
||||
let items: LiveItem[] = []
|
||||
items = applyTextDelta(items, 0, '第一轮')
|
||||
items = applyToolCall(items, 'a')
|
||||
items = applyToolResult(items, 'ok')
|
||||
items = applyTextDelta(items, 1, '第二轮')
|
||||
items = applyToolCall(items, 'b')
|
||||
items = applyTextDelta(items, 2, '最终回复')
|
||||
const msg = liveToMessage(live({ items }))
|
||||
expect(msg.parts.filter(p => p.type === 'text').map(p => p.process)).toEqual([true, true, undefined])
|
||||
// 顺序必须与落库穿插序一致:text → tool → text → tool → text。
|
||||
expect(msg.parts.map(p => p.type)).toEqual(['text', 'tool_call', 'tool_result', 'text', 'tool_call', 'text'])
|
||||
})
|
||||
it('thinking and tool parts are always process', () => {
|
||||
const msg = liveToMessage(live({
|
||||
thinking: '想',
|
||||
texts: [{ index: 0, text: '答' }],
|
||||
toolCalls: [{ name: 'web_search', status: 'ok', summary: '命中' }],
|
||||
}))
|
||||
let items: LiveItem[] = []
|
||||
items = applyThinkingDelta(items, '想')
|
||||
items = applyTextDelta(items, 0, '答')
|
||||
items = applyToolCall(items, 'web_search')
|
||||
items = applyToolResult(items, 'ok', '命中')
|
||||
const msg = liveToMessage(live({ items }))
|
||||
for (const p of msg.parts.filter(p => p.type !== 'text')) expect(p.process).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('showProcessInline', () => {
|
||||
it('expands during thinking/acting and collapses the moment responding starts', () => {
|
||||
expect(showProcessInline(live({ phase: 'thinking' }), true)).toBe(true)
|
||||
expect(showProcessInline(live({ phase: 'acting' }), true)).toBe(true)
|
||||
expect(showProcessInline(live({ phase: 'responding' }), true)).toBe(false)
|
||||
})
|
||||
it('falls back to the auto-collapse preference when no live run', () => {
|
||||
expect(showProcessInline(null, true)).toBe(false)
|
||||
expect(showProcessInline(null, false)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/main.tsx","./src/passwords.ts","./src/store.ts","./src/tasklist.ts","./src/components/AdminUsersModal.tsx","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/live.ts","./src/main.tsx","./src/passwords.ts","./src/store.ts","./src/tasklist.ts","./src/components/AdminUsersModal.tsx","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/live.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user