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
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
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 { commentaryPartText, isCommentaryPart } 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 sessionStates: 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())

sessionStates = sessionStateByRuntimeIdRef.current

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())
}

const send = (event: RpcEvent) => act(() => handleEvent!(event))

const assistantMessage = () => {
const messages = sessionStates?.get(SID)?.messages ?? []

return messages.find(message => message.role === 'assistant')
}

describe('useMessageStream commentary lane', () => {
beforeEach(() => {
handleEvent = null
sessionStates = null
})

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

it('routes commentary.delta into a commentary part, never the reasoning part', async () => {
await mountStream()

send({ payload: { text: 'Planning the fix' }, session_id: SID, type: 'reasoning.delta' })
send({ payload: { text: 'Reading the screenshot first.' }, session_id: SID, type: 'commentary.delta' })
send({ payload: { text: 'The total is $42.' }, session_id: SID, type: 'message.complete' })

const message = assistantMessage()
expect(message).toBeDefined()

const reasoningParts = message!.parts.filter(part => part.type === 'reasoning')
const commentaryParts = message!.parts.filter(isCommentaryPart)
const textParts = message!.parts.filter(part => part.type === 'text')

expect(reasoningParts).toHaveLength(1)
expect((reasoningParts[0] as { text: string }).text).toBe('Planning the fix')
expect(commentaryParts).toHaveLength(1)
expect(commentaryPartText(commentaryParts[0])).toBe('Reading the screenshot first.')
expect(textParts).toHaveLength(1)
expect((textParts[0] as { text: string }).text).toBe('The total is $42.')
})

it('preserves reasoning → commentary → tool → commentary → answer ordering', async () => {
await mountStream()

send({ payload: { text: 'Planning' }, session_id: SID, type: 'reasoning.delta' })
send({ payload: { text: 'Checking the logs first.' }, session_id: SID, type: 'commentary.delta' })
// Tool events flush queued deltas synchronously, freezing the segment.
send({
payload: { name: 'terminal', tool_id: 'call_1' },
session_id: SID,
type: 'tool.start'
})
send({
payload: { name: 'terminal', result: 'ok', tool_id: 'call_1' },
session_id: SID,
type: 'tool.complete'
})
send({ payload: { text: 'Now writing the summary.' }, session_id: SID, type: 'commentary.delta' })
send({ payload: { text: 'All done.' }, session_id: SID, type: 'message.complete' })

const message = assistantMessage()
expect(message).toBeDefined()

const partTypes = message!.parts.map(part => (isCommentaryPart(part) ? 'commentary' : part.type))
expect(partTypes).toEqual(['reasoning', 'commentary', 'tool-call', 'commentary', 'text'])
expect(commentaryPartText(message!.parts[1])).toBe('Checking the logs first.')
expect(commentaryPartText(message!.parts[3])).toBe('Now writing the summary.')
expect((message!.parts[4] as { text: string }).text).toBe('All done.')
})

it('keeps commentary above the answer when both lanes land in one flush window', async () => {
await mountStream()

// No tool event between these, so both lanes batch into the same
// delta-flush window; commentary must still render above the answer.
send({ payload: { text: 'Wrapping up now.' }, session_id: SID, type: 'commentary.delta' })
send({ payload: { text: 'The answer.' }, session_id: SID, type: 'message.delta' })
send({ payload: { text: 'The answer.' }, session_id: SID, type: 'message.complete' })

const message = assistantMessage()
expect(message!.parts.map(part => (isCommentaryPart(part) ? 'commentary' : part.type))).toEqual([
'commentary',
'text'
])
expect(commentaryPartText(message!.parts[0])).toBe('Wrapping up now.')
})

it('coalesces consecutive commentary deltas into one part per segment', async () => {
await mountStream()

send({ payload: { text: 'Reading ' }, session_id: SID, type: 'commentary.delta' })
send({ payload: { text: 'the file.' }, session_id: SID, type: 'commentary.delta' })
send({ payload: { text: 'Done.' }, session_id: SID, type: 'message.complete' })

const message = assistantMessage()
const commentaryParts = message!.parts.filter(isCommentaryPart)

expect(commentaryParts).toHaveLength(1)
expect(commentaryPartText(commentaryParts[0])).toBe('Reading the file.')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ interface GatewayEventDeps {
lastCwdInfoSessionRef: MutableRefObject<string | null>
nativeSubagentSessionsRef: MutableRefObject<Set<string>>
appendAssistantDelta: (sessionId: string, delta: string) => void
appendCommentaryDelta: (sessionId: string, delta: string) => void
appendReasoningDelta: (sessionId: string, delta: string, replace?: boolean) => void
completeAssistantMessage: (sessionId: string, text: string, responsePreviewed?: boolean) => void
failAssistantMessage: (sessionId: string, errorMessage: string) => void
Expand All @@ -97,6 +98,7 @@ interface GatewayEventDeps {
export function useGatewayEventHandler(deps: GatewayEventDeps) {
const {
appendAssistantDelta,
appendCommentaryDelta,
appendReasoningDelta,
activeSessionIdRef,
compactedTurnRef,
Expand Down Expand Up @@ -430,6 +432,17 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
appendReasoningDelta(sessionId, coerceThinkingText(payload?.text))
}

if (isActiveEvent) {
setPetActivity({ reasoning: true })
}
} else if (event.type === 'commentary.delta') {
// Codex commentary / analysis narration — user-facing mid-turn
// progress. Its own "Working" lane, never appendReasoningDelta: the
// Thinking disclosure is reserved for genuine model reasoning.
if (sessionId) {
appendCommentaryDelta(sessionId, coerceGatewayText(payload?.text))
}

if (isActiveEvent) {
setPetActivity({ reasoning: true })
}
Expand Down
43 changes: 36 additions & 7 deletions apps/desktop/src/app/session/hooks/use-message-stream/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { translateNow } from '@/i18n'
import {
appendAssistantTextPart,
appendCommentaryPart,
appendReasoningPart,
assistantTextPart,
type ChatMessage,
type ChatMessagePart,
chatMessageText,
commentaryPart,
type GatewayEventPayload,
mergeFinalAssistantText,
reasoningPart,
Expand Down Expand Up @@ -51,6 +53,7 @@ interface MessageStreamOptions {

interface QueuedStreamDeltas {
assistant: string
commentary: string
reasoning: string
}

Expand Down Expand Up @@ -196,19 +199,32 @@ export function useMessageStream({

queue.delete(id)

if (queued.assistant) {
// Flush in semantic order — reasoning, then commentary, then answer
// text. Within a segment the model thinks, narrates, and only then
// answers (Codex item order: reasoning → commentary → final_answer),
// so when several lanes land in the same flush window the parts must
// not seed in reverse (e.g. the Working lane below the answer).
if (queued.reasoning) {
mutateStream(
id,
parts => dedupeGeneratedImageEchoesInParts(appendAssistantTextPart(parts, queued.assistant)),
() => [assistantTextPart(queued.assistant)]
parts => appendReasoningPart(parts, queued.reasoning),
() => [reasoningPart(queued.reasoning)]
)
}

if (queued.reasoning) {
if (queued.commentary) {
mutateStream(
id,
parts => appendReasoningPart(parts, queued.reasoning),
() => [reasoningPart(queued.reasoning)]
parts => appendCommentaryPart(parts, queued.commentary),
() => [commentaryPart(queued.commentary)]
)
}

if (queued.assistant) {
mutateStream(
id,
parts => dedupeGeneratedImageEchoesInParts(appendAssistantTextPart(parts, queued.assistant)),
() => [assistantTextPart(queued.assistant)]
)
}
}
Expand Down Expand Up @@ -257,7 +273,7 @@ export function useMessageStream({
return
}

const queued = queuedDeltasRef.current.get(sessionId) ?? { assistant: '', reasoning: '' }
const queued = queuedDeltasRef.current.get(sessionId) ?? { assistant: '', commentary: '', reasoning: '' }
queued[key] += delta
queuedDeltasRef.current.set(sessionId, queued)
scheduleDeltaFlush()
Expand Down Expand Up @@ -292,6 +308,17 @@ export function useMessageStream({
[queueDelta]
)

const appendCommentaryDelta = useCallback(
(sessionId: string, delta: string) => {
if (!delta) {
return
}

queueDelta(sessionId, 'commentary', delta)
},
[queueDelta]
)

const appendReasoningDelta = useCallback(
(sessionId: string, delta: string, replace = false) => {
if (!delta) {
Expand Down Expand Up @@ -614,6 +641,7 @@ export function useMessageStream({

const handleGatewayEvent = useGatewayEventHandler({
appendAssistantDelta,
appendCommentaryDelta,
appendReasoningDelta,
activeSessionIdRef,
compactedTurnRef,
Expand All @@ -633,6 +661,7 @@ export function useMessageStream({

return {
appendAssistantDelta,
appendCommentaryDelta,
appendReasoningDelta,
completeAssistantMessage,
handleGatewayEvent,
Expand Down
28 changes: 28 additions & 0 deletions apps/desktop/src/app/settings/appearance-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'

import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { LanguageSwitcher } from '@/components/language-switcher'
import { Button } from '@/components/ui/button'
import { SegmentedControl } from '@/components/ui/segmented-control'
Expand All @@ -13,6 +14,7 @@ import { selectableCardClass } from '@/lib/selectable-card'
import { normalize } from '@/lib/text'
import { cn } from '@/lib/utils'
import { $backdrop, setBackdrop } from '@/store/backdrop'
import { $commentaryLane, setCommentaryLane, syncCommentaryLane } from '@/store/commentary-lane'
import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedMode } from '@/store/embed-consent'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
Expand Down Expand Up @@ -244,6 +246,8 @@ function MarketplaceThemeResults({
export function AppearanceSettings() {
const { t, isSavingLocale } = useI18n()
const { themeName, mode, resolvedMode, availableThemes, setTheme, setMode } = useTheme()
const { requestGateway } = useGatewayRequest()
const commentaryLane = useStore($commentaryLane)
const toolViewMode = useStore($toolViewMode)
const zoomPercent = useStore($zoomPercent)
const embedMode = useStore($embedMode)
Expand All @@ -257,6 +261,12 @@ export function AppearanceSettings() {

const [query, setQuery] = useState('')

// The Working-lane toggle is backed by the gateway config (commentary_lane),
// so hydrate it from the backend on mount rather than trusting a local cache.
useEffect(() => {
void syncCommentaryLane(requestGateway).catch(() => undefined)
}, [requestGateway])

// One box does double duty: filter installed themes live (below), and run a
// name search against the VS Code Marketplace (the Cmd-K "Install theme…"
// backend) for anything not already installed.
Expand Down Expand Up @@ -486,6 +496,24 @@ export function AppearanceSettings() {
title={a.toolViewTitle}
/>

<ListRow
action={
<SegmentedControl
onChange={id => {
triggerHaptic('selection')
void setCommentaryLane(requestGateway, id === 'on').catch(() => undefined)
}}
options={[
{ id: 'off', label: t.common.off },
{ id: 'on', label: t.common.on }
]}
value={commentaryLane ? 'on' : 'off'}
/>
}
description={a.commentaryLaneDesc}
title={a.commentaryLaneTitle}
/>

<ListRow
action={
<div className="flex flex-col items-end gap-1.5">
Expand Down
Loading