From 9402685d0ae4bcc9652f77050f914b842cca16f5 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Mon, 25 May 2026 00:20:01 +0100 Subject: [PATCH] fix(web): use session flavor label in voice context formatters Replaces hardcoded 'Claude Code' strings in voice context injections with the active session's flavor label (Cursor, Codex, Gemini, etc.) via getFlavorLabel() from @hapi/protocol. Falls back to 'coding agent' for unknown or missing flavors. Threads an agentLabel param through formatMessage, formatPermissionRequest, formatReadyEvent, formatNewMessages, formatHistory, and formatSessionFull. voiceHooks resolves the label once per call via session.metadata.flavor. Fixes #680 via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- .../realtime/hooks/contextFormatters.test.ts | 78 ++++++++++++++++++- web/src/realtime/hooks/contextFormatters.ts | 41 +++++----- web/src/realtime/hooks/voiceHooks.ts | 21 +++-- 3 files changed, 111 insertions(+), 29 deletions(-) diff --git a/web/src/realtime/hooks/contextFormatters.test.ts b/web/src/realtime/hooks/contextFormatters.test.ts index c2b4ac759..98cd3d87c 100644 --- a/web/src/realtime/hooks/contextFormatters.test.ts +++ b/web/src/realtime/hooks/contextFormatters.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest' import type { DecryptedMessage } from '@/types/api' -import { extractLastAssistantSpeakable, formatMessage, formatNewMessages, formatReadyEvent } from './contextFormatters' +import { + extractLastAssistantSpeakable, + formatMessage, + formatNewMessages, + formatPermissionRequest, + formatReadyEvent, +} from './contextFormatters' function msg(partial: Pick): DecryptedMessage { return { @@ -122,6 +128,17 @@ describe('formatReadyEvent', () => { const event = formatReadyEvent(sessionId, ' ') expect(event).toContain('Use the latest agent message already present in context') }) + + it('uses the provided agent label', () => { + const event = formatReadyEvent(sessionId, null, 'Codex') + expect(event).toContain('Codex finished working') + expect(event).not.toContain('Claude Code') + }) + + it('defaults to coding agent label', () => { + const event = formatReadyEvent(sessionId) + expect(event).toContain('coding agent finished working') + }) }) describe('formatMessage', () => { @@ -141,7 +158,7 @@ describe('formatMessage', () => { } })) - expect(formatted).toContain('Claude Code:') + expect(formatted).toContain('coding agent:') expect(formatted).toContain('Indexed 5,018 items in the search database.') }) @@ -188,7 +205,54 @@ describe('formatMessage', () => { })) expect(formatted).toContain('Here is the result.') - expect(formatted).toContain('Claude Code is using Bash') + expect(formatted).toContain('coding agent is using Bash') + }) + + it('uses the provided label for assistant text', () => { + const formatted = formatMessage( + msg({ id: '1', seq: 1, content: { role: 'assistant', content: 'Refactor complete.' } }), + 'Cursor' + ) + expect(formatted).toContain('Cursor:') + expect(formatted).toContain('Refactor complete.') + expect(formatted).not.toContain('Claude Code') + }) + + it('defaults to coding agent when no label is given', () => { + const formatted = formatMessage( + msg({ id: '1', seq: 1, content: { role: 'assistant', content: 'Done.' } }) + ) + expect(formatted).toContain('coding agent:') + expect(formatted).not.toContain('Claude Code') + }) + + it('uses the provided label for tool-call lines', () => { + const formatted = formatMessage( + msg({ + id: '1', + seq: 1, + content: { + role: 'assistant', + content: [{ type: 'tool_use', name: 'Bash', input: { command: 'ls' } }] + } + }), + 'Gemini' + ) + expect(formatted).toContain('Gemini is using Bash') + expect(formatted).not.toContain('Claude Code') + }) +}) + +describe('formatPermissionRequest', () => { + it('uses the provided label', () => { + const result = formatPermissionRequest('sid', 'rid', 'Bash', {}, 'OpenCode') + expect(result).toContain('OpenCode is requesting permission') + expect(result).not.toContain('Claude Code') + }) + + it('defaults to coding agent', () => { + const result = formatPermissionRequest('sid', 'rid', 'Bash', {}) + expect(result).toContain('coding agent is requesting permission') }) }) @@ -214,4 +278,12 @@ describe('formatNewMessages', () => { expect(update).toContain('New messages in session: session-1') expect(update).toContain('Local database file size is 2.43 GiB.') }) + + it('uses the provided label in formatted message output', () => { + const result = formatNewMessages('session-1', [ + msg({ id: '1', seq: 1, content: { role: 'assistant', content: 'Build succeeded.' } }) + ], 'Cursor') + expect(result).toContain('Cursor:') + expect(result).not.toContain('Claude Code') + }) }) diff --git a/web/src/realtime/hooks/contextFormatters.ts b/web/src/realtime/hooks/contextFormatters.ts index 98768429f..b754bf3cb 100644 --- a/web/src/realtime/hooks/contextFormatters.ts +++ b/web/src/realtime/hooks/contextFormatters.ts @@ -66,9 +66,9 @@ function unwrapOutputContent(content: unknown): { roleOverride: NormalizedRole | return { roleOverride, content: messageContent } } -function formatPlainText(role: NormalizedRole | null, text: string): string { +function formatPlainText(role: NormalizedRole | null, text: string, agentLabel = 'coding agent'): string { if (role === 'assistant') { - return `Claude Code: \n${text}` + return `${agentLabel}: \n${text}` } return `User sent message: \n${text}` } @@ -80,9 +80,10 @@ export function formatPermissionRequest( sessionId: string, requestId: string, toolName: string, - toolArgs: unknown + toolArgs: unknown, + agentLabel = 'coding agent' ): string { - return `Claude Code is requesting permission to use ${toolName} (session ${sessionId}): + return `${agentLabel} is requesting permission to use ${toolName} (session ${sessionId}): ${requestId} ${toolName} ${JSON.stringify(toolArgs)}` @@ -91,7 +92,7 @@ export function formatPermissionRequest( /** * Format a single message for voice context */ -export function formatMessage(message: DecryptedMessage): string | null { +export function formatMessage(message: DecryptedMessage, agentLabel = 'coding agent'): string | null { const { role, content: wrappedContent } = unwrapRoleWrappedContent(message) const { roleOverride, content } = unwrapOutputContent(wrappedContent) const normalizedRole = roleOverride ?? role @@ -103,7 +104,7 @@ export function formatMessage(message: DecryptedMessage): string | null { const speakable = !isContentArray(content) ? extractSpeakableFromContent(content) : null if (speakable) { const roleForFormat = normalizedRole === 'user' ? 'user' : 'assistant' - return formatPlainText(roleForFormat, speakable) + return formatPlainText(roleForFormat, speakable, agentLabel) } if (!isContentArray(content)) { @@ -122,13 +123,13 @@ export function formatMessage(message: DecryptedMessage): string | null { for (const item of content) { if (item.type === 'text' && item.text) { - lines.push(formatPlainText(isAssistant ? 'assistant' : 'user', item.text)) + lines.push(formatPlainText(isAssistant ? 'assistant' : 'user', item.text, agentLabel)) } 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}`) + lines.push(`${agentLabel} is using ${name}`) } else { - lines.push(`Claude Code is using ${name} with arguments: ${JSON.stringify(item.input)}`) + lines.push(`${agentLabel} is using ${name} with arguments: ${JSON.stringify(item.input)}`) } } } @@ -214,18 +215,18 @@ export function extractLastAssistantSpeakable(messages: DecryptedMessage[]): str return null } -export function formatNewSingleMessage(sessionId: string, message: DecryptedMessage): string | null { - const formatted = formatMessage(message) +export function formatNewSingleMessage(sessionId: string, message: DecryptedMessage, agentLabel = 'coding agent'): string | null { + const formatted = formatMessage(message, agentLabel) if (!formatted) { return null } return 'New message in session: ' + sessionId + '\n\n' + formatted } -export function formatNewMessages(sessionId: string, messages: DecryptedMessage[]): string | null { +export function formatNewMessages(sessionId: string, messages: DecryptedMessage[], agentLabel = 'coding agent'): string | null { const formatted = [...messages] .sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0)) - .map(formatMessage) + .map(m => formatMessage(m, agentLabel)) .filter(Boolean) if (formatted.length === 0) { return null @@ -233,15 +234,15 @@ export function formatNewMessages(sessionId: string, messages: DecryptedMessage[ return 'New messages in session: ' + sessionId + '\n\n' + formatted.join('\n\n') } -export function formatHistory(sessionId: string, messages: DecryptedMessage[]): string { +export function formatHistory(sessionId: string, messages: DecryptedMessage[], agentLabel = 'coding agent'): string { const messagesToFormat = VOICE_CONFIG.MAX_HISTORY_MESSAGES > 0 ? messages.slice(-VOICE_CONFIG.MAX_HISTORY_MESSAGES) : messages - const formatted = messagesToFormat.map(formatMessage).filter(Boolean) + const formatted = messagesToFormat.map(m => formatMessage(m, agentLabel)).filter(Boolean) return 'History of messages in session: ' + sessionId + '\n\n' + formatted.join('\n\n') } -export function formatSessionFull(session: Session | null, messages: DecryptedMessage[]): string { +export function formatSessionFull(session: Session | null, messages: DecryptedMessage[], agentLabel = 'coding agent'): string { if (!session) { return 'Session not available' } @@ -262,7 +263,7 @@ export function formatSessionFull(session: Session | null, messages: DecryptedMe lines.push('## Our interaction history so far') lines.push('') - lines.push(formatHistory(session.id, messages)) + lines.push(formatHistory(session.id, messages, agentLabel)) return lines.join('\n\n') } @@ -279,10 +280,10 @@ export function formatSessionFocus(sessionId: string, _metadata?: SessionMetadat return `Session became focused: ${sessionId}` } -export function formatReadyEvent(sessionId: string, lastAssistantText?: string | null): string { +export function formatReadyEvent(sessionId: string, lastAssistantText?: string | null, agentLabel = 'coding agent'): string { const trimmed = lastAssistantText?.trim() if (trimmed) { - return `The coding agent finished working in session: ${sessionId}. Summarize this for the human immediately:\n${trimmed}` + return `${agentLabel} finished working in session: ${sessionId}. Summarize this for the human immediately:\n${trimmed}` } - return `The coding agent finished working in session: ${sessionId}. Use the latest agent message already present in context and summarize it for the human immediately.` + return `${agentLabel} finished working in session: ${sessionId}. Use the latest agent message already present in context and summarize it for the human immediately.` } diff --git a/web/src/realtime/hooks/voiceHooks.ts b/web/src/realtime/hooks/voiceHooks.ts index c6318d3c1..573ade09a 100644 --- a/web/src/realtime/hooks/voiceHooks.ts +++ b/web/src/realtime/hooks/voiceHooks.ts @@ -10,7 +10,8 @@ import { extractLastAssistantSpeakable } from './contextFormatters' import { VOICE_CONFIG } from '../voiceConfig' -import type { DecryptedMessage, Session } from '@/types/api' +import { getFlavorLabel, isKnownFlavor } from '@hapi/protocol' +import type { DecryptedMessage, Session, SessionMetadataSummary } from '@/types/api' interface SessionMetadata { summary?: { text?: string } @@ -18,6 +19,11 @@ interface SessionMetadata { machineId?: string } +function getAgentLabel(session: Session | null): string { + const flavor = (session?.metadata as SessionMetadataSummary | undefined)?.flavor + return isKnownFlavor(flavor) ? getFlavorLabel(flavor) : 'coding agent' +} + // Track which sessions have been reported const shownSessions = new Set() let lastFocusSession: string | null = null @@ -65,7 +71,7 @@ function reportSession(sessionId: string) { if (!session) return const messages = messagesGetter?.(sessionId) ?? [] - const contextUpdate = formatSessionFull(session, messages) + const contextUpdate = formatSessionFull(session, messages, getAgentLabel(session)) reportContextualUpdate(contextUpdate) } @@ -110,8 +116,9 @@ export const voiceHooks = { onPermissionRequested(sessionId: string, requestId: string, toolName: string, toolArgs: unknown) { if (VOICE_CONFIG.DISABLE_PERMISSION_REQUESTS) return + const session = sessionGetter?.(sessionId) ?? null reportSession(sessionId) - reportTextUpdate(formatPermissionRequest(sessionId, requestId, toolName, toolArgs)) + reportTextUpdate(formatPermissionRequest(sessionId, requestId, toolName, toolArgs, getAgentLabel(session))) }, /** @@ -120,8 +127,9 @@ export const voiceHooks = { onMessages(sessionId: string, messages: DecryptedMessage[]) { if (VOICE_CONFIG.DISABLE_MESSAGES) return + const session = sessionGetter?.(sessionId) ?? null reportSession(sessionId) - reportContextualUpdate(formatNewMessages(sessionId, messages)) + reportContextualUpdate(formatNewMessages(sessionId, messages, getAgentLabel(session))) }, /** @@ -136,7 +144,7 @@ export const voiceHooks = { const session = sessionGetter?.(sessionId) ?? null const messages = messagesGetter?.(sessionId) ?? [] - let prompt = 'THIS IS AN ACTIVE SESSION: \n\n' + formatSessionFull(session, messages) + const prompt = 'THIS IS AN ACTIVE SESSION: \n\n' + formatSessionFull(session, messages, getAgentLabel(session)) shownSessions.add(sessionId) return prompt @@ -148,10 +156,11 @@ export const voiceHooks = { onReady(sessionId: string) { if (VOICE_CONFIG.DISABLE_READY_EVENTS) return + const session = sessionGetter?.(sessionId) ?? null reportSession(sessionId) const messages = messagesGetter?.(sessionId) ?? [] const lastAssistantText = extractLastAssistantSpeakable(messages) - reportTextUpdate(formatReadyEvent(sessionId, lastAssistantText)) + reportTextUpdate(formatReadyEvent(sessionId, lastAssistantText, getAgentLabel(session))) }, /**