diff --git a/web/src/realtime/hooks/contextFormatters.test.ts b/web/src/realtime/hooks/contextFormatters.test.ts
index c2b4ac75..b1692edf 100644
--- a/web/src/realtime/hooks/contextFormatters.test.ts
+++ b/web/src/realtime/hooks/contextFormatters.test.ts
@@ -125,7 +125,7 @@ describe('formatReadyEvent', () => {
})
describe('formatMessage', () => {
- it('formats codex stream-json assistant messages for voice context', () => {
+ it('uses the supplied agent label as the assistant prefix', () => {
const formatted = formatMessage(msg({
id: '1',
seq: 1,
@@ -139,12 +139,33 @@ describe('formatMessage', () => {
}
}
}
- }))
+ }), 'Codex')
- expect(formatted).toContain('Claude Code:')
+ expect(formatted).toContain('Codex:')
+ expect(formatted).not.toContain('Claude Code')
expect(formatted).toContain('Indexed 5,018 items in the search database.')
})
+ it('threads agentLabel for a different flavor (regression for #680)', () => {
+ const formatted = formatMessage(msg({
+ id: '1',
+ seq: 1,
+ content: {
+ role: 'agent',
+ content: {
+ type: 'codex',
+ data: {
+ type: 'message',
+ message: 'Cursor is generating a plan.'
+ }
+ }
+ }
+ }), 'Cursor')
+
+ expect(formatted).toContain('Cursor:')
+ expect(formatted).not.toContain('Claude Code')
+ })
+
it('ignores codex ready and tool-call payloads', () => {
expect(formatMessage(msg({
id: '1',
@@ -156,7 +177,7 @@ describe('formatMessage', () => {
data: { type: 'ready' }
}
}
- }))).toBeNull()
+ }), 'Codex')).toBeNull()
})
it('does not treat session status events as speakable assistant text', () => {
@@ -171,10 +192,10 @@ describe('formatMessage', () => {
data: { type: 'message', message: 'Aborting task.' }
}
}
- }))).toBeNull()
+ }), 'Codex')).toBeNull()
})
- it('preserves tool-call context for mixed text+tool_use content array', () => {
+ it('preserves tool-call context for mixed text+tool_use content array and uses the agent label', () => {
const formatted = formatMessage(msg({
id: '1',
seq: 1,
@@ -185,10 +206,11 @@ describe('formatMessage', () => {
{ type: 'tool_use', name: 'Bash', input: { command: 'ls' } }
]
}
- }))
+ }), 'Claude')
expect(formatted).toContain('Here is the result.')
- expect(formatted).toContain('Claude Code is using Bash')
+ expect(formatted).toContain('Claude is using Bash')
+ expect(formatted).not.toContain('Claude Code is using')
})
})
@@ -209,9 +231,11 @@ describe('formatNewMessages', () => {
}
}
})
- ])
+ ], 'Codex')
expect(update).toContain('New messages in session: session-1')
expect(update).toContain('Local database file size is 2.43 GiB.')
+ expect(update).toContain('Codex:')
+ expect(update).not.toContain('Claude Code')
})
})
diff --git a/web/src/realtime/hooks/contextFormatters.ts b/web/src/realtime/hooks/contextFormatters.ts
index 98768429..54443925 100644
--- a/web/src/realtime/hooks/contextFormatters.ts
+++ b/web/src/realtime/hooks/contextFormatters.ts
@@ -66,32 +66,39 @@ 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: string): string {
if (role === 'assistant') {
- return `Claude Code: \n${text}`
+ return `${agentLabel}: \n${text}`
}
return `User sent message: \n${text}`
}
/**
- * Format a permission request for natural language context
+ * Format a permission request for natural language context.
+ *
+ * `agentLabel` is the display label for the session's agent flavor
+ * (e.g. "Claude", "Cursor", "Codex"); voiceHooks computes it once per call.
*/
export function formatPermissionRequest(
sessionId: string,
requestId: string,
toolName: string,
- toolArgs: unknown
+ toolArgs: unknown,
+ agentLabel: string
): 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)}`
}
/**
- * Format a single message for voice context
+ * Format a single message for voice context.
+ *
+ * `agentLabel` is the display label for the session's agent flavor
+ * (e.g. "Claude", "Cursor", "Codex"); voiceHooks computes it once per call.
*/
-export function formatMessage(message: DecryptedMessage): string | null {
+export function formatMessage(message: DecryptedMessage, agentLabel: string): string | null {
const { role, content: wrappedContent } = unwrapRoleWrappedContent(message)
const { roleOverride, content } = unwrapOutputContent(wrappedContent)
const normalizedRole = roleOverride ?? role
@@ -103,7 +110,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 +129,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 +221,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: string): 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: string): 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 +240,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: string): 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: string): string {
if (!session) {
return 'Session not available'
}
@@ -262,7 +269,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')
}
diff --git a/web/src/realtime/hooks/voiceContextPlan.test.ts b/web/src/realtime/hooks/voiceContextPlan.test.ts
index 3caf0bd7..bd315d2a 100644
--- a/web/src/realtime/hooks/voiceContextPlan.test.ts
+++ b/web/src/realtime/hooks/voiceContextPlan.test.ts
@@ -25,17 +25,18 @@ describe('buildSessionVoiceContextPlan', () => {
const session = makeSession('sess-1')
const messages = Array.from({ length: 10 }, (_, i) => makeMessage(i + 1, `line ${i + 1}`))
- const plan = buildSessionVoiceContextPlan(session, messages)
+ const plan = buildSessionVoiceContextPlan(session, messages, 'Codex')
expect(plan.bootstrap).toContain('sess-1')
expect(plan.bootstrap).toContain('Auth refactor')
expect(utf8ByteLength(plan.bootstrap)).toBeLessThanOrEqual(ELEVENLABS_WEBRTC_CONTEXT_MAX_BYTES)
expect(plan.streamChunks.length).toBeGreaterThan(0)
expect(plan.messagesInBootstrap).toBeLessThanOrEqual(2)
+ expect(plan.bootstrap).not.toContain('Claude Code')
})
test('handles missing session', () => {
- const plan = buildSessionVoiceContextPlan(null, [])
+ const plan = buildSessionVoiceContextPlan(null, [], 'Claude')
expect(plan.bootstrap).toBe('Session not available')
expect(plan.streamChunks).toEqual([])
})
diff --git a/web/src/realtime/hooks/voiceContextPlan.ts b/web/src/realtime/hooks/voiceContextPlan.ts
index 5ef0a7d9..fd160f4b 100644
--- a/web/src/realtime/hooks/voiceContextPlan.ts
+++ b/web/src/realtime/hooks/voiceContextPlan.ts
@@ -66,10 +66,14 @@ function chunkTextByBytes(parts: string[], maxBytes: number): string[] {
/**
* Small handshake context for startSession; remainder is streamed after connect.
+ *
+ * `agentLabel` is the display label for the session's agent flavor
+ * (e.g. "Claude", "Cursor", "Codex"); voiceHooks computes it once per call.
*/
export function buildSessionVoiceContextPlan(
session: Session | null,
- messages: DecryptedMessage[]
+ messages: DecryptedMessage[],
+ agentLabel: string
): SessionVoiceContextPlan {
if (!session) {
return {
@@ -89,7 +93,7 @@ export function buildSessionVoiceContextPlan(
: all
const formatted = capped
- .map((m) => formatMessage(m))
+ .map((m) => formatMessage(m, agentLabel))
.filter((line): line is string => Boolean(line))
const recentCount = Math.min(BOOTSTRAP_RECENT_MESSAGES, formatted.length)
diff --git a/web/src/realtime/hooks/voiceHooks.ts b/web/src/realtime/hooks/voiceHooks.ts
index 4c502559..203871f3 100644
--- a/web/src/realtime/hooks/voiceHooks.ts
+++ b/web/src/realtime/hooks/voiceHooks.ts
@@ -11,7 +11,8 @@ import {
} from './contextFormatters'
import { VOICE_CONFIG } from '../voiceConfig'
import { buildSessionVoiceContextPlan, type SessionVoiceContextPlan } from './voiceContextPlan'
-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 }
@@ -19,6 +20,17 @@ interface SessionMetadata {
machineId?: string
}
+/**
+ * Resolve the display label for the session's agent flavor. Falls back to a
+ * generic "coding agent" string for unknown or missing flavors so the voice
+ * context never bottoms out with a literal "undefined" or the old hardcoded
+ * "Claude Code" (closes #680).
+ */
+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
@@ -66,7 +78,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)
}
@@ -106,13 +118,14 @@ export const voiceHooks = {
},
/**
- * Called when Claude requests permission for a tool use
+ * Called when the active agent requests permission for a tool use
*/
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)))
},
/**
@@ -121,8 +134,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 +150,7 @@ export const voiceHooks = {
const session = sessionGetter?.(sessionId) ?? null
const messages = messagesGetter?.(sessionId) ?? []
- const plan = buildSessionVoiceContextPlan(session, messages)
+ const plan = buildSessionVoiceContextPlan(session, messages, getAgentLabel(session))
shownSessions.add(sessionId)
return plan
},