Files
deepseek-harness/packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts
T
imccyu c62f26aaa1 fix(ui-chat): keep notice-woken Turns in the human exchange
Under "On next message" folding, a Turn woken by a notice (settled
subagent, goal round, schedule, background job) advanced the bottom Turn
and folded the finished Turn without an own-input driver, so the generic
fold room left a following reader looking at blank space.

Chat Nodes now declare a group-tree position (`groupTree`, ordered
`exchange` > `turn` > `process`). Human input claimed in Step 1 marks its
Turn as opening an exchange; notices and later steering carry no
position. `GroupTreeSegments` splits the loaded Turns at opening Turns,
and `bottomSource` covers the whole latest exchange, so notice-woken
Turns no longer fold earlier ones while the next human message folds the
exchange through the own-input path. Only `exchange` is read today; the
`turn` and `process` positions are reserved for the existing kind lists.
2026-10-12 10:35:05 +08:00

184 lines
8.9 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import type { ChatConversationViewNode } from '../src/client/contract/chat-nodes.ts'
import type { ConversationTimelineSnapshot, TurnLocation } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ChatSnapshotBuilder } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
function turn(index: number): TurnLocation {
return {
turn: index, start: undefined, end: undefined, status: 'closed', steps: [],
data: { get: () => undefined, source: () => ({ getSnapshot: () => undefined, subscribe: () => () => {} }) },
}
}
function node(index: number, owner = turn(index), text = ''): ChatConversationViewNode {
return {
key: `user:${index}`, id: String(index), kind: 'user', target: 'chat', anchorSeq: index,
visibility: 'visible', location: { kind: 'turn', turn: owner },
data: { kind: 'user', messageId: `message:${index}`, seq: index, time: index, content: [{ type: 'text', text }], source: null },
}
}
/** A Node whose Turn opens an exchange. */
function opening(index: number, owner = turn(index), text = ''): ChatConversationViewNode {
return { ...node(index, owner, text), groupTree: 'exchange' }
}
function timeline(turns: readonly TurnLocation[]): ConversationTimelineSnapshot {
return { turnOrder: turns.map(value => value.turn), turns: new Map(turns.map(value => [value.turn, value])) }
}
describe('assembler-owned Chat bottom membership', () => {
it('notifies only the Turns leaving or entering the latest exchange among 4,000 observed historical Nodes', () => {
const builder = new ChatSnapshotBuilder()
const turns = Array.from({ length: 4_000 }, (_, index) => turn(index + 1))
const nodes = turns.map(value => opening(value.turn, value))
const snapshot = builder.replace({ nodes, timeline: timeline(turns) })
builder.publish()
const sources = nodes.map(value => snapshot.nodes.bottomSource(value.key))
const listeners = sources.map((source) => {
const listener = vi.fn()
source.subscribe(listener)
return listener
})
const nextTurn = turn(4_001)
const nextNode = opening(4_001, nextTurn)
const nextSource = snapshot.nodes.bottomSource(nextNode.key)
const nextChanged = vi.fn()
nextSource.subscribe(nextChanged)
expect(sources.at(-1)?.getSnapshot()).toBe(true)
expect(nextSource.getSnapshot()).toBe(false)
builder.apply({ upserts: [nextNode], timeline: timeline([...turns, nextTurn]) })
expect(listeners.every(listener => listener.mock.calls.length === 0)).toBe(true)
expect(nextChanged).not.toHaveBeenCalled()
builder.publish()
expect(listeners.slice(0, -1).every(listener => listener.mock.calls.length === 0)).toBe(true)
expect(listeners.at(-1)).toHaveBeenCalledOnce()
expect(nextChanged).toHaveBeenCalledOnce()
expect(sources.at(-1)?.getSnapshot()).toBe(false)
expect(nextSource.getSnapshot()).toBe(true)
expect(snapshot.nodes.bottomSource(nextNode.key)).toBe(nextSource)
nextChanged.mockClear()
builder.apply({ upserts: [opening(4_001, nextTurn, 'updated')], timeline: timeline([...turns, nextTurn]) })
builder.publish()
expect(nextChanged).not.toHaveBeenCalled()
expect(listeners.at(-1)).toHaveBeenCalledOnce()
})
it('keeps a bare Turn boundary and continuation Turns in the latest exchange until an opening Node arrives', () => {
const builder = new ChatSnapshotBuilder()
const first = turn(1)
const second = turn(2)
const initial = builder.replace({ nodes: [opening(1, first)], timeline: timeline([first]) })
builder.publish()
const oldBottom = initial.nodes.bottomSource('user:1')
const nextBottom = initial.nodes.bottomSource('user:2')
const changed = vi.fn()
const unsubscribe = oldBottom.subscribe(changed)
builder.apply({ upserts: [], timeline: timeline([first, second]) })
builder.publish()
expect(changed).not.toHaveBeenCalled()
expect(oldBottom.getSnapshot()).toBe(true)
builder.apply({ upserts: [node(2, second)], timeline: timeline([first, second]) })
builder.publish()
expect(changed).not.toHaveBeenCalled()
expect([oldBottom.getSnapshot(), nextBottom.getSnapshot()]).toEqual([true, true])
builder.apply({ upserts: [opening(2, second)], timeline: timeline([first, second]) })
builder.publish()
expect(changed).toHaveBeenCalledOnce()
expect([oldBottom.getSnapshot(), nextBottom.getSnapshot()]).toEqual([false, true])
unsubscribe()
const replaced = builder.replace({ nodes: [opening(1, first)], timeline: timeline([first]) })
builder.publish()
expect(replaced.nodes.bottomSource('user:1')).toBe(oldBottom)
expect([oldBottom.getSnapshot(), nextBottom.getSnapshot()]).toEqual([true, false])
builder.replace({ nodes: [node(1, first), node(2, second)], timeline: timeline([first, second]) })
builder.publish()
expect([oldBottom.getSnapshot(), nextBottom.getSnapshot()]).toEqual([true, true])
})
it('starts the latest exchange at the first loaded Turn while no opening Node is loaded', () => {
const builder = new ChatSnapshotBuilder()
const turns = [turn(3), turn(4), turn(5)]
const initial = builder.replace({ nodes: turns.map(value => node(value.turn, value)), timeline: timeline(turns) })
builder.publish()
const bottoms = ['user:3', 'user:4', 'user:5'].map(key => initial.nodes.bottomSource(key))
expect(bottoms.map(source => source.getSnapshot())).toEqual([true, true, true])
const older = [turn(1), turn(2), ...turns]
const loaded = builder.replace({ nodes: older.map(value => node(value.turn, value)), timeline: timeline(older) })
builder.publish()
expect(['user:1', 'user:2'].map(key => loaded.nodes.bottomSource(key).getSnapshot())).toEqual([true, true])
builder.replace({ nodes: [], timeline: timeline([]) })
builder.publish()
expect(bottoms.map(source => source.getSnapshot())).toEqual([false, false, false])
})
it('follows the latest remaining opening Turn when opening Nodes leave, move, or repeat in one Turn', () => {
const builder = new ChatSnapshotBuilder()
const turns = [turn(1), turn(2), turn(3), turn(4)]
const second: ChatConversationViewNode = { ...opening(22, turns[1]), key: 'user:2b' }
const nodes = [opening(1, turns[0]), opening(2, turns[1]), second, node(3, turns[2]), opening(4, turns[3])]
const initial = builder.replace({ nodes, timeline: timeline(turns) })
builder.publish()
const bottom = (key: string): boolean => initial.nodes.bottomSource(key).getSnapshot()
const membership = (): boolean[] => ['user:1', 'user:2', 'user:3', 'user:4'].map(bottom)
expect(membership()).toEqual([false, false, false, true])
const before = vi.fn()
const unchanged = vi.fn()
initial.nodes.bottomSource('user:2').subscribe(before)
initial.nodes.bottomSource('user:1').subscribe(unchanged)
builder.apply({ upserts: [node(4, turns[3])], timeline: initial.timeline })
builder.publish()
expect(membership()).toEqual([false, true, true, true])
expect(before).toHaveBeenCalledOnce()
expect(unchanged).not.toHaveBeenCalled()
builder.apply({ upserts: [node(2, turns[1])], timeline: initial.timeline })
builder.publish()
expect(membership()).toEqual([false, true, true, true])
builder.apply({ upserts: [{ ...second, location: { kind: 'session' } }], timeline: initial.timeline })
builder.publish()
expect(membership()).toEqual([true, true, true, true])
builder.apply({ upserts: [{ ...opening(3, turns[2]), visibility: 'hidden' }], timeline: initial.timeline })
builder.publish()
expect(membership()).toEqual([false, false, false, true])
expect(bottom('user:2b')).toBe(false)
builder.replace({ nodes: [opening(1, turns[0]), node(2, turns[1]), node(4, turns[3])], timeline: initial.timeline })
builder.publish()
expect(membership()).toEqual([true, true, false, true])
})
it('updates same-key Turn moves without changing root order and excludes unplaced or hidden Nodes', () => {
const builder = new ChatSnapshotBuilder()
const first = turn(1)
const second = turn(2)
const initial = builder.replace({ nodes: [node(1, first), opening(2, second)], timeline: timeline([first, second]) })
builder.publish()
const bottom = initial.nodes.bottomSource('user:1')
expect(bottom.getSnapshot()).toBe(false)
const moved = builder.apply({ upserts: [node(1, second)], timeline: initial.timeline })
builder.publish()
expect(moved.order).toBe(initial.order)
expect(bottom.getSnapshot()).toBe(true)
builder.apply({ upserts: [{ ...node(1, second), visibility: 'hidden' }], timeline: initial.timeline })
builder.publish()
expect(bottom.getSnapshot()).toBe(false)
builder.apply({ upserts: [{ ...node(1), location: { kind: 'session' } }], timeline: initial.timeline })
builder.publish()
expect(bottom.getSnapshot()).toBe(false)
expect(initial.nodes.bottomSource('missing').getSnapshot()).toBe(false)
expect(new ChatSnapshotBuilder().empty.nodes.bottomSource('user:1').getSnapshot()).toBe(false)
})
})