fix(web): restrict message forks to completed turn tails

This commit is contained in:
kingwl
2026-08-02 16:25:02 +08:00
parent ab320ba991
commit 76547dfe0c
39 changed files with 283 additions and 73 deletions

View File

@@ -20,7 +20,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts
import { createChatStore } from '../src/client/stores.ts'
import { ChatView } from '../src/client/chat/ChatView.tsx'
import { zh } from '../src/client/locales.ts'
import { assistantActionsSeqs, deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts'
import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs } from '../src/client/chat/chat-flow.ts'
afterEach(cleanup)
// Keyless create() persists under the bare declared key; clear between cases
@@ -33,7 +33,7 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}
@@ -211,6 +211,24 @@ describe('chat-flow derivation', () => {
])
expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7])
})
it('messageBranchSeqs keeps only message rows at completed transcript tails', () => {
const interruptedThink: AssistantMessageNode = {
kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2,
blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true,
}
const nodes = [
user(1, 'first'),
assistant(2, 'answer before tools'),
toolResult(3, 'a'),
interruptedThink,
user(6, 'second'),
assistant(7, 'clean tail', 2),
user(10, 'user-only tail'),
]
const seqs = messageBranchSeqs(nodes, new Map([[1, 5], [2, 8], [3, 11]]))
expect([...seqs]).toEqual([7, 10])
})
})
describe('ChatView', () => {
@@ -323,21 +341,38 @@ describe('ChatView', () => {
user(5, 'next'),
assistant(6, 'second turn', 2),
],
turnEnds: new Map([[1, 4], [2, 6]]),
})
const view = render(<h.ChatView {...h.props} />)
// 2 user + 2 turn-tail assistants; mid-turn text at seq 2 stays chrome-free.
// User rows keep copy/clock, while only the two completed assistant tails may branch.
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4)
expect(view.getAllByRole('button', { name: '在新对话中分支' })).toHaveLength(4)
expect(view.getAllByRole('button', { name: '在新对话中分支' })).toHaveLength(2)
})
it('forks from both user and finalized assistant message actions at their event seq', () => {
const h = makeHarness({ nodes: [user(1, 'question'), assistant(2, 'answer')] })
it('forks only from a finalized assistant at the completed transcript tail', () => {
const h = makeHarness({
nodes: [user(1, 'question'), assistant(2, 'answer')],
turnEnds: new Map([[1, 3]]),
})
const view = render(<h.ChatView {...h.props} />)
const buttons = view.getAllByRole('button', { name: '在新对话中分支' })
expect(buttons).toHaveLength(2)
expect(buttons).toHaveLength(1)
fireEvent.click(buttons[0]!)
fireEvent.click(buttons[1]!)
expect(h.forkAt.mock.calls).toEqual([[1], [2]])
expect(h.forkAt.mock.calls).toEqual([[2]])
})
it('keeps copy chrome but hides branch when tool and interrupted Think follow the response', () => {
const interruptedThink: AssistantMessageNode = {
kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2,
blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true,
}
const h = makeHarness({
nodes: [user(1, 'question'), assistant(2, 'answer'), toolResult(3, 'a'), interruptedThink],
turnEnds: new Map([[1, 5]]),
})
const view = render(<h.ChatView {...h.props} />)
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2)
expect(view.queryByRole('button', { name: '在新对话中分支' })).toBeNull()
})
it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => {