diff --git a/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts index a6a460f0ea..0b23226cde 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts @@ -364,6 +364,13 @@ function projectAssistant(context: ConversationNodeContext): Ass } } +function publishedAssistantData( + context: ConversationNodeContext, +): Readonly | undefined { + const location = context.start?.location ?? context.matches.at(-1)?.location + return location?.kind === 'step' ? location.step.data.get('assistant-step') : undefined +} + /** Per-step Assistant streaming/final/interruption Definition. */ export const assistantDefinition: ConversationNodeDefinition = { kind: 'assistant-step', @@ -427,16 +434,19 @@ export const assistantDefinition: ConversationNodeDefinition = { } }, buildViewNode: (context) => { - const projected = projectAssistant(context) - if (projected === undefined) return null - if (projected.settled === undefined && !projected.visible) { - const state = context.state ?? fallbackState(context) - if (state === undefined) return null + const state = context.state ?? fallbackState(context) + if (state === undefined) return null + const data = publishedAssistantData(context) + if (data === undefined) return null + const settled = data.finalNode + const visible = settled === undefined ? state.visibleBlocks > 0 : hasVisibleContent(data.blocks) + if (settled === undefined && !visible) { const current = context.current.get('chat') if (!state.hidden || current === undefined || current === null) return null } - return chatNode(context, 'assistant-step', projected.anchorSeq, projected.data, { - visibility: projected.settled?.interrupted === true || projected.visible ? 'visible' : 'hidden', + const anchorSeq = settled?.seq ?? state.firstVisibleSeq ?? context.matches[0]?.event.seq ?? 0 + return chatNode(context, 'assistant-step', anchorSeq, data, { + visibility: settled?.interrupted === true || visible ? 'visible' : 'hidden', }) }, } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts index fd04874615..bacd360a57 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts @@ -7,7 +7,7 @@ import type {} from '@deepseek-ai/dsh-llm-retry/types' import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type {} from '@deepseek-ai/dsh-tools/types' import { hasAssistantReplyContent } from '../contract/assistant-content.ts' -import type { AssistantChatData, FinalAssistantChatData } from '../contract/chat-nodes.ts' +import type { AssistantChatData, ChatNode, FinalAssistantChatData } from '../contract/chat-nodes.ts' import { decodeTurnProcess, encodeTurnProcess, isSubagentDelegationTool, type TurnProcessSignature, type TurnProcessSpec, @@ -34,6 +34,8 @@ interface TurnProcessState { readonly assistantStartByStep: ReadonlyMap readonly messageCountByStep: ReadonlyMap readonly otherStartSeq?: number + readonly controlAnchorSeq?: number + readonly messageCount: number readonly toolCallCount: number readonly subagentCount: number } @@ -109,6 +111,7 @@ function fallbackState(context: ConversationNodeContext): Turn turn, assistantStartByStep: new Map(), messageCountByStep: new Map(), + messageCount: 0, toolCallCount: 0, subagentCount: 0, } @@ -130,15 +133,12 @@ function latestAnswer(turn: TurnLocation): Readonly | nu } function processSpec(state: TurnProcessState, turn: TurnLocation): TurnProcessSpec | null { - const controlAnchorSeq = Math.min( - state.otherStartSeq ?? Number.POSITIVE_INFINITY, - ...state.assistantStartByStep.values(), - ) - if (!Number.isFinite(controlAnchorSeq)) return null + const controlAnchorSeq = state.controlAnchorSeq + if (controlAnchorSeq === undefined) return null const answer = latestAnswer(turn) const counts = { messageCount: answer === null - ? [...state.messageCountByStep.values()].reduce((total, count) => total + count, 0) + ? state.messageCount : [...state.messageCountByStep] .filter(([step]) => step < answer.step) .reduce((total, [, count]) => total + count, 0), @@ -185,7 +185,7 @@ function updateProcessState(state: TurnProcessState, event: ConversationEvent): && hasAssistantReplyContent(toAssistantBlocks(event.data.message.content))) { const messageCountByStep = new Map(current.messageCountByStep) messageCountByStep.set(event.data.step, (messageCountByStep.get(event.data.step) ?? 0) + 1) - current = { ...current, messageCountByStep } + current = { ...current, messageCountByStep, messageCount: current.messageCount + 1 } } if (event.type === 'tool/call') { const subagent = isSubagentDelegationTool(event.data.name) @@ -198,12 +198,22 @@ function updateProcessState(state: TurnProcessState, event: ConversationEvent): const evidence = processEvidence(event) if (evidence === undefined) return current if (evidence.kind === 'other') { - return current.otherStartSeq === undefined ? { ...current, otherStartSeq: evidence.seq } : current + return current.otherStartSeq === undefined + ? { + ...current, + otherStartSeq: evidence.seq, + controlAnchorSeq: Math.min(current.controlAnchorSeq ?? Number.POSITIVE_INFINITY, evidence.seq), + } + : current } if (current.assistantStartByStep.has(evidence.step)) return current const assistantStartByStep = new Map(current.assistantStartByStep) assistantStartByStep.set(evidence.step, evidence.seq) - return { ...current, assistantStartByStep } + return { + ...current, + assistantStartByStep, + controlAnchorSeq: Math.min(current.controlAnchorSeq ?? Number.POSITIVE_INFINITY, evidence.seq), + } } /** Turn-scoped process range and answer-boundary Definition. */ @@ -233,6 +243,7 @@ export const turnProcessDefinition: ConversationNodeDefinition turn: match.event.data.turn, assistantStartByStep: new Map(), messageCountByStep: new Map(), + messageCount: 0, toolCallCount: 0, subagentCount: 0, } @@ -246,24 +257,54 @@ export const turnProcessDefinition: ConversationNodeDefinition } return 'immediate' }, - buildLocationData: (context, scope) => { + buildLocationData: (context, scope, previous) => { if (scope !== 'turn') return null const state = context.state ?? fallbackState(context) if (state === undefined) return null const turn = turnLocation(context) if (turn === undefined) return null + const current = context.current.get('chat') as ChatNode | null | undefined + const latestStep = turn.steps.at(-1) + if (previous?.kind === 'turn' + && previous.key === 'turn-process' + && current?.kind === 'turn-process' + && current.data.answerAnchorSeq === null + && current.data.controlAnchorSeq === state.controlAnchorSeq + && current.data.messageCount === state.messageCount + && current.data.toolCallCount === state.toolCallCount + && current.data.subagentCount === state.subagentCount + && turn.status !== 'closed' + && latestStep?.status !== 'closed') return previous const spec = processSpec(state, turn) - return spec === null ? null : { + if (spec === null) return null + const value = encodeTurnProcess(spec) + if (previous?.kind === 'turn' + && previous.turn === spec.turn + && previous.key === 'turn-process' + && previous.value === value) return previous + return { kind: 'turn', turn: turn.turn, key: 'turn-process', - value: encodeTurnProcess(spec), + value, } }, buildViewNode: (context) => { const turn = turnLocation(context) const signature = turn?.data.get('turn-process') if (turn === undefined || signature === undefined) return null + const current = context.current.get('chat') as ChatNode | null | undefined + const state = context.state + if (current?.kind === 'turn-process' + && state !== undefined + && current.data.answerAnchorSeq === null + && current.data.controlAnchorSeq === state.controlAnchorSeq + && current.data.messageCount === state.messageCount + && current.data.toolCallCount === state.toolCallCount + && current.data.subagentCount === state.subagentCount + && turn.status !== 'closed' + && turn.steps.at(-1)?.status !== 'closed' + && current.location === (context.start?.location ?? context.matches[0]?.location)) return current const data = decodeTurnProcess(signature) return chatNode( context, diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts index 9791ee5ceb..3bcc852ab1 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts @@ -134,8 +134,9 @@ function hasText(data: AssistantChatData): data is FinalAssistantChatData { } function tailData(context: ConversationNodeContext): TurnTailChatData | null { - const end = context.state?.end - ?? context.matches.find(match => match.event.type === 'turn/end') + const end = context.state === undefined + ? context.matches.find(match => match.event.type === 'turn/end') + : context.state.end if (end?.event.type !== 'turn/end') return null const turn = turnLocation(context) if (turn === undefined) return null diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index 00484dc6c5..ca637e1d4d 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -608,6 +608,30 @@ describe('built-in conversation node Definitions', () => { expect(settled.answerStep).toBe(1) }) + it('reuses the open Turn-process projection across continuing Assistant chunks', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'assistant/chunk', { + turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'first' }, + }), + ]) + const before = snapshot(value) + const processNode = node(before, 'turn-process') + const processSignature = before.timeline.turns.get(1)?.data.get('turn-process') + const assistantNode = node(before, 'assistant-step') + + value.append(at(4, 'assistant/chunk', { + turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: ' second' }, + })) + value.flush() + + const after = snapshot(value) + expect(after.timeline.turns.get(1)?.data.get('turn-process')).toBe(processSignature) + expect(node(after, 'turn-process')).toBe(processNode) + expect(node(after, 'assistant-step')).not.toBe(assistantNode) + }) + it('anchors a streamed non-text answer from its block start', () => { const value = assembler([ at(50, 'turn/start', { turn: 5 }), @@ -640,6 +664,9 @@ describe('built-in conversation node Definitions', () => { const runningSnapshot = snapshot(value) const running = node(runningSnapshot, 'assistant-step') expect(running?.data).toMatchObject({ status: 'running', blocks: [{ kind: 'text', text: 'streaming' }] }) + expect(running?.location.kind === 'step' + ? running.location.step.data.get('assistant-step') + : undefined).toBe(running?.data) const order = runningSnapshot.order value.append(at(4, 'assistant/message', { @@ -654,6 +681,9 @@ describe('built-in conversation node Definitions', () => { expect(settled?.key).toBe(running?.key) expect(settledSnapshot.order).toBe(order) expect(settled?.data).toMatchObject({ status: 'settled', blocks: [{ kind: 'text', text: 'settled' }] }) + expect(settled?.location.kind === 'step' + ? settled.location.step.data.get('assistant-step') + : undefined).toBe(settled?.data) const interruptedValue = assembler([ at(10, 'turn/start', { turn: 2 }), diff --git a/packages/client/ui-conversation/src/client/contract/conversation.ts b/packages/client/ui-conversation/src/client/contract/conversation.ts index 7864383947..ea3ec1c6e0 100644 --- a/packages/client/ui-conversation/src/client/contract/conversation.ts +++ b/packages/client/ui-conversation/src/client/contract/conversation.ts @@ -210,11 +210,14 @@ export interface ConversationNodeDefinition { * the same Location key. * @param context - latest complete Context. * @param scope - Location hierarchy level currently being materialized. - * @returns current Location value, or null while unavailable. + * @param previous - value from the preceding materialization; return it when unchanged. + * @returns current Location value, preserving value identity while unchanged, + * or null while unavailable. */ buildLocationData?( context: ConversationNodeContext, scope: ConversationLocationDataScope, + previous: ConversationLocationData | null, ): ConversationLocationData | null /** * Materialize one final Node for this Definition's declared view target. diff --git a/packages/client/ui-conversation/src/client/conversation/assembler.ts b/packages/client/ui-conversation/src/client/conversation/assembler.ts index df81e4b7b3..2f752245c4 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembler.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembler.ts @@ -826,9 +826,10 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore private buildLocationData( context: InternalContext, scope: ConversationLocationDataScope, + previous: ConversationLocationData | null, ): ConversationLocationData | null { if (context.definition.buildLocationData === undefined) return null - const data = context.definition.buildLocationData(contextSnapshot(context), scope) + const data = context.definition.buildLocationData(contextSnapshot(context), scope, previous) if (data === null) return null if (data.kind !== scope) { throw new Error( @@ -853,7 +854,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore const entries: { owner: string; data: ConversationLocationData }[] = [] for (const scope of LOCATION_DATA_SCOPES) { for (const context of this.contexts.values()) { - const data = this.buildLocationData(context, scope) + const data = this.buildLocationData(context, scope, context.locationData[scope]) context.locationData[scope] = data if (data !== null) entries.push({ owner: context.key, data }) } @@ -869,9 +870,10 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore const changes: ConversationLocationDataChange[] = [] for (const context of this.dirty) { const previous = context.locationData[scope] - const next = this.buildLocationData(context, scope) + const next = this.buildLocationData(context, scope, previous) + if (previous === next) continue context.locationData[scope] = next - if (previous !== next) changes.push({ owner: context.key, previous, next }) + changes.push({ owner: context.key, previous, next }) } changed = this.locationIndex.applyData(changes) || changed } diff --git a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts index 0e95e83465..1a98abd0e1 100644 --- a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts @@ -1005,6 +1005,52 @@ describe('ConversationNodeAssembler', () => { .toEqual({ step: 2, turn: 2 }) }) + it('does not republish an equivalent Location value', () => { + const apply = vi.fn() + const initialValue = { value: 1 } + const changedValue = { value: 2 } + const definition: ConversationNodeDefinition<{ readonly value: ScopeProbeStepData }> = { + kind: 'scope-probe', + match: (event) => { + if (event.type === 'step/start') return { id: '1:1', role: 'start' } + if ((event.type as string) === 'scope-probe/update') return { id: '1:1', role: 'update' } + return null + }, + start: () => ({ value: initialValue }), + update: (_context, match) => ({ + value: (match.event.data as unknown as { changed: boolean }).changed + ? changedValue + : initialValue, + }), + buildLocationData: (context, scope, previous) => { + if (scope !== 'step' || context.state === undefined) return null + if (previous?.kind === 'step' + && previous.turn === 1 + && previous.step === 1 + && previous.key === 'scope-probe' + && previous.value === context.state.value) return previous + return { kind: 'step', turn: 1, step: 1, key: 'scope-probe', value: context.state.value } + }, + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView(apply)]), + ) + assembler.replaceWindow([ + input(at(1, 'turn/start', { turn: 1 })), + input(at(2, 'step/start', { turn: 1, step: 1 })), + ], false) + assembler.flush() + + assembler.append(input(at(3, 'scope-probe/update', { turn: 1, step: 1, changed: false }))) + expect(assembler.flush()).toBe(false) + expect(apply).not.toHaveBeenCalled() + + assembler.append(input(at(4, 'scope-probe/update', { turn: 1, step: 1, changed: true }))) + expect(assembler.flush()).toBe(true) + expect(apply).toHaveBeenCalledOnce() + }) + it('updates existing turn Locations when their Step membership changes', () => { const apply = vi.fn() const definition: ConversationNodeDefinition = { diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index b7f74f305b..90e3248516 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -177,14 +177,19 @@ export const deliverablesDefinition: ConversationNodeDefinition scope !== 'turn' || context.state === undefined - ? null - : { + buildLocationData: (context, scope, previous) => { + if (scope !== 'turn' || context.state === undefined) return null + if (previous?.kind === 'turn' + && previous.turn === context.state.turn + && previous.key === 'deliverables' + && previous.value.produced === context.state.produced) return previous + return { kind: 'turn', turn: context.state.turn, key: 'deliverables', value: { produced: context.state.produced }, - }, + } + }, } /**