mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-24 16:54:43 +00:00
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:
parent
fbf04ae079
commit
43be8d1dd9
2 changed files with 132 additions and 1 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue