Skip to content
Open
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
48 changes: 47 additions & 1 deletion web/src/components/SessionChat.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import { describe, expect, it } from 'vitest'
import { shouldAutoClearPendingSchedule } from './SessionChat'
import { isVoiceReadyMessage, shouldAutoClearPendingSchedule } from './SessionChat'
import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
import type { DecryptedMessage } from '@/types/api'

function makeMessage(content: unknown): DecryptedMessage {
return {
id: 'msg-1',
seq: 1,
localId: null,
content,
createdAt: 1_742_372_800_000
}
}

/**
* Unit tests for shouldAutoClearPendingSchedule.
Expand Down Expand Up @@ -41,3 +52,38 @@ describe('shouldAutoClearPendingSchedule', () => {
expect(shouldAutoClearPendingSchedule(expired)).toBe(true)
})
})

describe('isVoiceReadyMessage', () => {
it('detects role-wrapped ready events', () => {
expect(isVoiceReadyMessage(makeMessage({
role: 'agent',
content: {
type: 'event',
data: { type: 'ready' }
}
}))).toBe(true)
})

it('detects Codex completion events', () => {
expect(isVoiceReadyMessage(makeMessage({
role: 'agent',
content: {
type: 'codex',
data: { type: 'task_complete' }
}
}))).toBe(true)
})

it('ignores normal Codex assistant messages', () => {
expect(isVoiceReadyMessage(makeMessage({
role: 'agent',
content: {
type: 'codex',
data: {
type: 'message',
message: 'Done.'
}
}
}))).toBe(false)
})
})
48 changes: 45 additions & 3 deletions web/src/components/SessionChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,35 @@ export function shouldAutoClearPendingSchedule(pending: PendingSchedule | null):
return pending !== null && pending.type === 'absolute'
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

export function isVoiceReadyMessage(message: DecryptedMessage): boolean {
const envelope = isRecord(message.content) && 'content' in message.content
? message.content
: null
const content = isRecord(envelope?.content)
? envelope.content
: isRecord(message.content)
? message.content
: null
if (!content) return false

const data = isRecord(content.data) ? content.data : null
if (!data) return false

if (content.type === 'event') {
return data.type === 'ready'
}

if (content.type === 'codex') {
return data.type === 'ready' || data.type === 'task_complete'
}

return false
}

function getOutlineTitle(session: Session): string {
if (session.metadata?.name) {
return session.metadata.name
Expand Down Expand Up @@ -195,17 +224,30 @@ export function SessionChat(props: {
// Track and report new messages to voice assistant
// Note: voiceHooks internally checks isVoiceSessionStarted() so we don't need to check voice.status here
const prevMessagesRef = useRef<DecryptedMessage[]>([])
const lastVoiceReadyAtRef = useRef(0)

const reportVoiceReady = useCallback(() => {
const now = Date.now()
if (now - lastVoiceReadyAtRef.current < 3000) {
return
}
lastVoiceReadyAtRef.current = now
voiceHooks.onReady(props.session.id)
}, [props.session.id])

useEffect(() => {
const prevIds = new Set(prevMessagesRef.current.map(m => m.id))
const newMessages = props.messages.filter(m => !prevIds.has(m.id))

if (newMessages.length > 0) {
voiceHooks.onMessages(props.session.id, newMessages)
if (newMessages.some(isVoiceReadyMessage)) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Major] Historical ready messages can trigger fresh voice-ready prompts

newMessages is only “not present in prevMessagesRef,” so it also includes messages introduced by initial hydration/refetch and older-history loads. With this new branch, any historical ready / task_complete in those batches calls reportVoiceReady() and can send a stale ready prompt to an active voice session.

Suggested fix:

// Move ready detection to the live SSE message path instead of scanning the
// whole visible message window in SessionChat.
if (event.type === 'message-received') {
    ingestIncomingMessages(event.sessionId, [event.message])
    if (
        event.sessionId === getCurrentRealtimeSessionId()
        && isVoiceReadyMessage(event.message)
    ) {
        voiceHooks.onReady(event.sessionId)
    }
}

reportVoiceReady()
}
}

prevMessagesRef.current = props.messages
}, [props.messages, props.session.id])
}, [props.messages, props.session.id, reportVoiceReady])

// Report ready event when thinking stops
// Note: voiceHooks internally checks isVoiceSessionStarted() so we don't need to check voice.status here
Expand All @@ -214,11 +256,11 @@ export function SessionChat(props: {
useEffect(() => {
// Detect transition: thinking → not thinking
if (prevThinkingRef.current && !props.session.thinking) {
voiceHooks.onReady(props.session.id)
reportVoiceReady()
}

prevThinkingRef.current = props.session.thinking
}, [props.session.thinking, props.session.id])
}, [props.session.thinking, reportVoiceReady])

// Report permission requests to voice assistant
// Note: voiceHooks internally checks isVoiceSessionStarted() so we don't need to check voice.status here
Expand Down
64 changes: 64 additions & 0 deletions web/src/realtime/hooks/contextFormatters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import { formatMessage, formatNewMessages } from './contextFormatters'
import type { DecryptedMessage } from '@/types/api'

function makeMessage(content: unknown, seq = 1): DecryptedMessage {
return {
id: `msg-${seq}`,
seq,
localId: null,
content,
createdAt: 1_742_372_800_000
}
}

describe('voice context formatters', () => {
it('formats Codex assistant messages for realtime voice context', () => {
const message = makeMessage({
role: 'agent',
content: {
type: 'codex',
data: {
type: 'message',
message: 'Finished the requested change.'
}
}
})

expect(formatMessage(message)).toContain('Finished the requested change.')
})

it('includes Codex assistant messages in new message batches', () => {
const formatted = formatNewMessages('session-1', [
makeMessage({
role: 'agent',
content: {
type: 'codex',
data: {
type: 'message',
message: 'The tests pass now.'
}
}
})
])

expect(formatted).toContain('New messages in session: session-1')
expect(formatted).toContain('The tests pass now.')
})

it('formats Codex tool calls consistently with Claude tool calls', () => {
const message = makeMessage({
role: 'agent',
content: {
type: 'codex',
data: {
type: 'tool-call',
name: 'apply_patch',
input: { file: 'app.ts' }
}
}
})

expect(formatMessage(message)).toContain('Claude Code is using apply_patch')
})
})
40 changes: 34 additions & 6 deletions web/src/realtime/hooks/contextFormatters.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages'
import { isObject } from '@hapi/protocol'
import { AGENT_MESSAGE_PAYLOAD_TYPE, isObject } from '@hapi/protocol'
import type { DecryptedMessage, Session } from '@/types/api'
import { VOICE_CONFIG } from '../voiceConfig'

Expand Down Expand Up @@ -73,6 +73,35 @@ function formatPlainText(role: NormalizedRole | null, text: string): string {
return `User sent message: \n<text>${text}</text>`
}

function formatToolCall(name: string, input: unknown): string {
if (VOICE_CONFIG.LIMITED_TOOL_CALLS) {
return `Claude Code is using ${name}`
}
return `Claude Code is using ${name} with arguments: <arguments>${JSON.stringify(input)}</arguments>`
}

function formatCodexContent(content: Record<string, unknown>): string | null {
if (content.type !== AGENT_MESSAGE_PAYLOAD_TYPE) {
return null
}

const data = isObject(content.data) ? content.data : null
if (!data || typeof data.type !== 'string') {
return null
}

if (data.type === 'message' && typeof data.message === 'string') {
return formatPlainText('assistant', data.message)
}

if (data.type === 'tool-call' && !VOICE_CONFIG.DISABLE_TOOL_CALLS) {
const name = typeof data.name === 'string' ? data.name : 'unknown'
return formatToolCall(name, data.input)
}

return null
}

/**
* Format a permission request for natural language context
*/
Expand Down Expand Up @@ -104,6 +133,9 @@ export function formatMessage(message: DecryptedMessage): string | null {
if (isObject(content) && content.type === 'text' && typeof content.text === 'string') {
return formatPlainText(normalizedRole, content.text)
}
if (isObject(content)) {
return formatCodexContent(content)
}
return null
}

Expand All @@ -120,11 +152,7 @@ export function formatMessage(message: DecryptedMessage): string | null {
lines.push(formatPlainText(isAssistant ? 'assistant' : 'user', item.text))
} else if (item.type === 'tool_use' && !VOICE_CONFIG.DISABLE_TOOL_CALLS) {
const name = item.name || 'unknown'
if (VOICE_CONFIG.LIMITED_TOOL_CALLS) {
lines.push(`Claude Code is using ${name}`)
} else {
lines.push(`Claude Code is using ${name} with arguments: <arguments>${JSON.stringify(item.input)}</arguments>`)
}
lines.push(formatToolCall(name, item.input))
}
}

Expand Down
Loading