Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion apps/desktop/src/app/session/hooks/use-message-stream/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,8 +360,26 @@ export function useMessageStream({
const visibleFinalText = stripGeneratedImageEchoes(finalText, generatedImageEchoSources(parts)).trim()
const dedupeReference = normalize(visibleFinalText)

const kept = parts.filter(part => {
// Find the last tool-call part index. Text parts before it came from

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a hook-level regression test for pre-tool message.delta text followed by a tool event and message.complete; this boundary is the behavior the change must preserve.

// earlier API calls in the tool-calling loop and are NOT covered by
// the final_response in message.complete (which only contains the
// last API call's content). Preserve them so pre-tool-call narration
// isn't lost.
let lastToolCallIndex = -1
for (let i = parts.length - 1; i >= 0; i--) {
if (parts[i].type === 'tool-call') {
lastToolCallIndex = i
break
}
}

const kept = parts.filter((part, index) => {
if (part.type === 'text') {
// Keep text parts before the last tool-call — from earlier API calls.
if (lastToolCallIndex >= 0 && index < lastToolCallIndex) {
return true
}
// Text after last tool-call is from the final API call — covered by finalText.
return false
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { type MutableRefObject, 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 sessionStateRef: MutableRefObject<Map<string, ClientSessionState>> | null = null

function Harness() {
const activeSessionIdRef = useRef<string | null>(SID)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const queryClientRef = useRef(new QueryClient())

// Expose for tests
sessionStateRef = sessionStateByRuntimeIdRef

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)

return next
}
})

useEffect(() => {
handleEvent = stream.handleGatewayEvent
}, [stream.handleGatewayEvent])

return null
}

async function mountStream() {
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())
}

function getState() {
return sessionStateRef!.current.get(SID)!
}

describe('useMessageStream pre-tool-call text preservation', () => {
beforeEach(() => {
handleEvent = null
sessionStateRef = null
})

afterEach(() => {
cleanup()
vi.restoreAllMocks()
})

it('preserves text streamed before tool calls when the turn completes', async () => {
await mountStream()

// Simulate: message.start -> text delta (pre-tool narration) -> tool.start -> tool.complete -> text delta (post-tool) -> message.complete
act(() => handleEvent!({ payload: {}, session_id: SID, type: 'message.start' }))

// Pre-tool-call narration
act(() => handleEvent!({ payload: { text: 'Here is what I found: ' }, session_id: SID, type: 'message.delta' }))
act(() => handleEvent!({ payload: { text: 'let me check the files.' }, session_id: SID, type: 'message.delta' }))

// Tool call
act(() =>
handleEvent!({
payload: { name: 'search_files', tool_id: 'tool-1', args: { pattern: 'test' } },
session_id: SID,
type: 'tool.start'
})
)

act(() =>
handleEvent!({
payload: { name: 'search_files', tool_id: 'tool-1', result: { matches: [] } },
session_id: SID,
type: 'tool.complete'
})
)

// Post-tool-call narration
act(() => handleEvent!({ payload: { text: ' The results show X.' }, session_id: SID, type: 'message.delta' }))

// Completion — final_response only contains the last API call's text
act(() =>
handleEvent!({
payload: { text: 'The results show X.' },
session_id: SID,
type: 'message.complete'
})
)

const state = getState()
const assistantMsg = state.messages.find(m => m.role === 'assistant')
expect(assistantMsg).toBeDefined()

const parts = assistantMsg!.parts

// Should have: text (pre-tool), tool-call, text (post-tool)
const textParts = parts.filter(p => p.type === 'text')
const toolParts = parts.filter(p => p.type === 'tool-call')

// Pre-tool-call narration must survive
expect(textParts.length).toBeGreaterThanOrEqual(2)
const allText = textParts.map(p => p.text).join('')
expect(allText).toContain('Here is what I found:')
expect(allText).toContain('let me check the files.')
expect(allText).toContain('The results show X.')

// Tool row must survive
expect(toolParts.length).toBe(1)
expect(toolParts[0].toolName).toBe('search_files')
})
})