From e851ef8b7c024cc430a803af4c5e2ae6456142f2 Mon Sep 17 00:00:00 2001 From: skyc1e Date: Tue, 16 Jun 2026 12:34:36 +0200 Subject: [PATCH 1/2] fix(desktop): show late available reasoning --- .../session/hooks/use-message-stream.test.ts | 22 ++++++++++++++++ .../session/hooks/use-message-stream/index.ts | 26 +++++++++++-------- 2 files changed, 37 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/app/session/hooks/use-message-stream.test.ts diff --git a/apps/desktop/src/app/session/hooks/use-message-stream.test.ts b/apps/desktop/src/app/session/hooks/use-message-stream.test.ts new file mode 100644 index 0000000000000..d156490430003 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' + +import { reasoningPart, textPart } from '@/lib/chat-messages' + +import { applyReasoningAvailable } from './use-message-stream' + +describe('applyReasoningAvailable', () => { + it('inserts available reasoning before existing assistant text', () => { + const parts = applyReasoningAvailable([textPart('Final answer.')], 'Late reasoning.') + + expect(parts.map(part => part.type)).toEqual(['reasoning', 'text']) + expect(parts[0]).toEqual(reasoningPart('Late reasoning.')) + expect(parts[1]).toEqual(textPart('Final answer.')) + }) + + it('keeps the current reasoning part when one already exists', () => { + const existing = [reasoningPart('Streaming reasoning.'), textPart('Final answer.')] + const parts = applyReasoningAvailable(existing, 'Late reasoning.') + + expect(parts).toBe(existing) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts index 65a203a215ebc..08f342922cc42 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts @@ -53,6 +53,20 @@ interface QueuedStreamDeltas { reasoning: string } +export function applyReasoningAvailable(parts: ChatMessagePart[], text: string): ChatMessagePart[] { + if (parts.some(part => part.type === 'reasoning')) { + return parts + } + + const textIndex = parts.findIndex(part => part.type === 'text') + + if (textIndex < 0) { + return [...parts, reasoningPart(text)] + } + + return [...parts.slice(0, textIndex), reasoningPart(text), ...parts.slice(textIndex)] +} + export function useMessageStream({ activeSessionIdRef, hydrateFromStoredSession, @@ -267,17 +281,7 @@ export function useMessageStream({ mutateStream( sessionId, - (parts, message) => { - if (replace && chatMessageText(message).trim()) { - return parts - } - - if (replace) { - return [...parts.filter(part => part.type !== 'reasoning'), reasoningPart(delta)] - } - - return appendReasoningPart(parts, delta) - }, + parts => (replace ? applyReasoningAvailable(parts, delta) : appendReasoningPart(parts, delta)), () => [reasoningPart(delta)] ) }, From 09e63c9946e5ee9d072ea50b5a4f97398cee05b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C5=9Fit=20Akyol?= Date: Thu, 16 Jul 2026 09:48:50 +0300 Subject: [PATCH 2/2] fix(desktop): preserve streamed reasoning fallback --- agent/conversation_loop.py | 38 ++++--- .../reasoning-available-event.test.tsx | 106 ++++++++++++++++++ tests/agent/test_reasoning_available.py | 32 ++++++ 3 files changed, 158 insertions(+), 18 deletions(-) create mode 100644 apps/desktop/src/app/session/hooks/use-message-stream/reasoning-available-event.test.tsx create mode 100644 tests/agent/test_reasoning_available.py diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 420d12670e623..260d3a6c650dc 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -161,6 +161,25 @@ def _ra(): return run_agent +def _relay_available_reasoning(agent, assistant_content: str) -> None: + """Relay completed inline reasoning without truncating the live fallback.""" + think_text = re.sub( + r'', '', assistant_content.strip() + ).strip() + first_line = think_text.split('\n')[0][:80] if think_text else "" + + if first_line and getattr(agent, '_delegate_depth', 0) > 0: + try: + agent.tool_progress_callback("_thinking", first_line) + except Exception: + pass + elif think_text: + try: + agent.tool_progress_callback("reasoning.available", "_thinking", think_text, None) + except Exception: + pass + + def _nous_entitlement_message(capability: str) -> str: try: from hermes_cli.nous_account import ( @@ -4422,24 +4441,7 @@ def _perform_api_call(next_api_kwargs): # Notify progress callback of model's thinking (used by subagent # delegation to relay the child's reasoning to the parent display). if (assistant_message.content and agent.tool_progress_callback): - _think_text = assistant_message.content.strip() - # Strip reasoning XML tags that shouldn't leak to parent display - _think_text = re.sub( - r'', '', _think_text - ).strip() - # For subagents: relay first line to parent display (existing behaviour). - # For all agents with a structured callback: emit reasoning.available event. - first_line = _think_text.split('\n')[0][:80] if _think_text else "" - if first_line and getattr(agent, '_delegate_depth', 0) > 0: - try: - agent.tool_progress_callback("_thinking", first_line) - except Exception: - pass - elif _think_text: - try: - agent.tool_progress_callback("reasoning.available", "_thinking", _think_text[:500], None) - except Exception: - pass + _relay_available_reasoning(agent, assistant_message.content) # Check for incomplete (opened but never closed) # This means the model ran out of output tokens mid-reasoning — retry up to 2 times diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/reasoning-available-event.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/reasoning-available-event.test.tsx new file mode 100644 index 0000000000000..1363b6a154679 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream/reasoning-available-event.test.tsx @@ -0,0 +1,106 @@ +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 type { ChatMessagePart } from '@/lib/chat-messages' +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 sessionStateByRuntimeId: Map +let reasoningSnapshots: ChatMessagePart[] + +function Harness() { + const activeSessionIdRef = useRef(SID) + const sessionStateByRuntimeIdRef = useRef(sessionStateByRuntimeId) + 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) + + const message = next.messages.find(item => item.id === next.streamId) + const reasoning = message?.parts.find(part => part.type === 'reasoning') + + if (reasoning) { + reasoningSnapshots.push(reasoning) + } + + return next + } + }) + + useEffect(() => { + handleEvent = stream.handleGatewayEvent + }, [stream.handleGatewayEvent]) + + return null +} + +async function mountStream() { + render() + await waitFor(() => expect(handleEvent).not.toBeNull()) +} + +function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}) { + act(() => handleEvent!({ payload, session_id: SID, type })) +} + +function streamedParts(): ChatMessagePart[] { + const state = sessionStateByRuntimeId.get(SID) + const message = state?.messages.find(item => item.id === state.streamId) + + return message?.parts ?? [] +} + +describe('useMessageStream reasoning.available fallback', () => { + beforeEach(() => { + handleEvent = null + sessionStateByRuntimeId = new Map() + reasoningSnapshots = [] + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + it('preserves streamed reasoning when the late available fallback arrives', async () => { + await mountStream() + const streamedReasoning = 'streamed reasoning '.repeat(40) + + emit('reasoning.delta', { text: streamedReasoning }) + emit('reasoning.available', { text: streamedReasoning.slice(0, 500) }) + + const reasoningParts = streamedParts().filter(part => part.type === 'reasoning') + + expect(reasoningParts).toHaveLength(1) + expect(reasoningParts[0]).toMatchObject({ text: streamedReasoning }) + expect(reasoningSnapshots.at(-1)).toBe(reasoningSnapshots.at(-2)) + }) + + it('inserts late available reasoning before assistant text when no delta streamed', async () => { + await mountStream() + + emit('message.delta', { text: 'Final answer.' }) + emit('reasoning.available', { text: 'Late reasoning.' }) + + expect(streamedParts()).toMatchObject([ + { text: 'Late reasoning.', type: 'reasoning' }, + { text: 'Final answer.', type: 'text' } + ]) + }) +}) diff --git a/tests/agent/test_reasoning_available.py b/tests/agent/test_reasoning_available.py new file mode 100644 index 0000000000000..76cffb9b61903 --- /dev/null +++ b/tests/agent/test_reasoning_available.py @@ -0,0 +1,32 @@ +from types import SimpleNamespace + +from agent.conversation_loop import _relay_available_reasoning + + +def test_available_reasoning_relay_preserves_full_content(): + events = [] + reasoning = "reasoning " * 80 + agent = SimpleNamespace( + _delegate_depth=0, + tool_progress_callback=lambda *args: events.append(args), + ) + + _relay_available_reasoning( + agent, + f"{reasoning}", + ) + + assert events == [("reasoning.available", "_thinking", reasoning.strip(), None)] + assert len(events[0][2]) > 500 + + +def test_available_reasoning_relay_keeps_subagent_preview_bounded(): + events = [] + agent = SimpleNamespace( + _delegate_depth=1, + tool_progress_callback=lambda *args: events.append(args), + ) + + _relay_available_reasoning(agent, "first line " * 20 + "\nsecond line") + + assert events == [("_thinking", ("first line " * 20)[:80])]