fix(desktop): accumulate MoA reference reasoning blocks instead of replacing

Every moa.reference event called appendReasoningDelta(..., replace=true),
which wipes ALL existing reasoning-type message parts and seeds exactly one
new part. With two or more MoA reference models, each later reference
erased the reasoning disclosure built by earlier references, so only the
last advisor's output ever stayed visible instead of one labelled block per
reference (contradicting the multi-reference visibility behavior from
#53855).

Only the first reference (index <= 1, or missing) now replaces — preserving
the original "clear stale reasoning from before this turn" behavior. Every
later reference accumulates via the existing queue-then-flush path instead,
applied immediately since each reference arrives as one complete block
rather than incremental tokens.

Fixes #64658
This commit is contained in:
Simplicio, Wesley (ext) 2026-07-14 22:23:11 -03:00 committed by Teknium
parent fbf04ae079
commit 43be8d1dd9
2 changed files with 132 additions and 1 deletions

View file

@ -524,7 +524,25 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
const cnt = typeof payload?.count === 'number' ? payload.count : undefined
const header = idx && cnt ? `◇ Reference ${idx}/${cnt}${label}` : `◇ Reference — ${label}`
const body = coerceThinkingText(payload?.text)
appendReasoningDelta(sessionId, `${header}\n${body}\n\n`, true)
const text = `${header}\n${body}\n\n`
if (idx === undefined || idx <= 1) {
// First reference: clear any stale reasoning left over from
// before this turn's references start, same as before.
appendReasoningDelta(sessionId, text, true)
} else {
// Later references must accumulate, not replace — otherwise
// each new reference wipes out the ones already shown (#64658).
// Queue-then-flush (rather than the streamed/batched queue path)
// applies it immediately, since each reference arrives as one
// complete block rather than incremental tokens. reasoning.delta
// cannot be mid-flight here: MoAChatCompletions.reference_callback
// (agent/moa_loop.py) fires "moa.reference" once per reference's
// already-complete text, with no concurrent token stream for the
// reference-gathering phase, so there is no in-flight delta to
// collide with in the shared queue bucket.
appendReasoningDelta(sessionId, text, false)
flushQueuedDeltas(sessionId)
}
}
if (isActiveEvent) {

View file

@ -0,0 +1,113 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
const SID = 'session-1'
let handleEvent: ((event: RpcEvent) => void) | null = null
let latestState: ClientSessionState | null = null
function Harness() {
const activeSessionIdRef = useRef<string | null>(SID)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const queryClientRef = useRef(new QueryClient())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient: queryClientRef.current,
refreshHermesConfig: vi.fn(async () => undefined),
refreshSessions: vi.fn(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)
latestState = next
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
}, [stream.handleGatewayEvent])
return null
}
async function mountStream() {
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())
}
function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}) {
act(() => handleEvent!({ payload, session_id: SID, type }))
}
function reasoningText(): string {
const message = latestState?.messages.at(-1)
const part = message?.parts.find(p => p.type === 'reasoning')
return part?.type === 'reasoning' ? part.text : ''
}
describe('useMessageStream moa.reference accumulation (#64658)', () => {
beforeEach(() => {
handleEvent = null
latestState = null
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('keeps every reference model labelled block instead of only the latest one', async () => {
await mountStream()
emit('moa.reference', { count: 2, index: 1, label: 'model-a', text: 'advice-a' })
emit('moa.reference', { count: 2, index: 2, label: 'model-b', text: 'advice-b' })
const text = reasoningText()
expect(text).toContain('model-a')
expect(text).toContain('advice-a')
expect(text).toContain('model-b')
expect(text).toContain('advice-b')
})
it('handles a single-reference MoA turn (count=1) without regression', async () => {
await mountStream()
emit('moa.reference', { count: 1, index: 1, label: 'model-a', text: 'only-advice' })
const text = reasoningText()
expect(text).toContain('model-a')
expect(text).toContain('only-advice')
})
it('accumulates three or more references in order', async () => {
await mountStream()
emit('moa.reference', { count: 3, index: 1, label: 'model-a', text: 'advice-a' })
emit('moa.reference', { count: 3, index: 2, label: 'model-b', text: 'advice-b' })
emit('moa.reference', { count: 3, index: 3, label: 'model-c', text: 'advice-c' })
const text = reasoningText()
const orderOk =
text.indexOf('advice-a') < text.indexOf('advice-b') && text.indexOf('advice-b') < text.indexOf('advice-c')
expect(text).toContain('advice-a')
expect(text).toContain('advice-b')
expect(text).toContain('advice-c')
expect(orderOk).toBe(true)
})
})