feat(desktop): agent-to-agent messages render as attributed cards, not user bubbles (#85855)

* feat(desktop): render agent-to-agent messages as attributed cards, not user bubbles

Bot-to-bot deliveries arrive on the user role (alternation requires
it) but are not the human speaking — they rendered as if the user
typed them. Detect the delivery prefix ('Message from 🤖 <sender>: …',
emoji-less, and the legacy bracket form) and render an attributed
inter-agent card: left-aligned, robot + sender header, 'agent message'
label, body through the same minimal markdown pipeline. Anchored regex
cannot fire mid-prose. Same pattern as ProcessNotificationNote.
Presentational only; content/roles/caching untouched.

* reshape: inter-agent card -> Grok-style compact timeline notice

Per maintainer screenshot: the delivery renders as a subtle centered
'🤖 Message from <sender>' notice (ProcessNotificationNote's shape),
with the delivered text behind a 'show message' expander instead of a
full-width card. The recipient's reply remains a normal assistant
message below it.

* feat: sender avatar on the inter-agent notice

The delivery prefix may carry the sender's profile handle —
'Message from 🤖 <Display Name> (@<handle>): …'. The notice resolves
it through profiles.list (has_avatar) + profiles.get_asset and renders
the sender's actual avatar in place of the 🤖 glyph, with module-level
memoization (one resolution per sender per window) and inflight
de-dup. Glyph fallback covers handle-less prefixes, older gateways
without profiles.*, avatarless profiles, and failures. 'hermes'
resolves to the primary profile by convention. Tests 6/6.

* lint: sort the $gateway import (perfectionist/sort-imports)
This commit is contained in:
Teknium
2026-08-13 23:19:04 -07:00
committed by GitHub
parent 380e4da3d1
commit 7a9634568c
2 changed files with 198 additions and 1 deletions
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { AGENT_MESSAGE_RE } from './user-message'
// Agent-to-agent deliveries render as a compact attributed timeline notice,
// not a user bubble. This pins the detection contract: the Bot Mode prefix
// ("Message from 🤖 <sender>: …"), the handle-carrying form
// ("Message from 🤖 <sender> (@<handle>): …"), and the legacy bracket form
// all match; human prose that merely mentions the phrase does not.
describe('agent message detection', () => {
it('matches the Bot Mode delivery prefix with sender and body', () => {
const m = AGENT_MESSAGE_RE.exec('Message from 🤖 Hermes: hello there')
expect(m?.[1]?.trim()).toBe('Hermes')
expect(m?.[4]).toBe('hello there')
})
it('captures the @handle when present', () => {
const m = AGENT_MESSAGE_RE.exec('Message from 🤖 Eats Tests (@mr-tester): run them all')
expect(m?.[1]?.trim()).toBe('Eats Tests')
expect(m?.[2]).toBe('mr-tester')
expect(m?.[4]).toBe('run them all')
})
it('matches without the robot emoji', () => {
const m = AGENT_MESSAGE_RE.exec('Message from Turquoise: ready to work')
expect(m?.[1]?.trim()).toBe('Turquoise')
expect(m?.[4]).toBe('ready to work')
})
it('matches the legacy bracket form', () => {
const m = AGENT_MESSAGE_RE.exec("[Message from agent 'turqoise'] ping")
expect(m?.[3]).toBe('turqoise')
expect(m?.[4]).toBe('ping')
})
it('spans multi-line bodies', () => {
const m = AGENT_MESSAGE_RE.exec('Message from 🤖 Dev: line one\nline two')
expect(m?.[4]).toBe('line one\nline two')
})
it('does not match prose that merely contains the phrase', () => {
expect(AGENT_MESSAGE_RE.test('I got a Message from 🤖 Hermes: earlier')).toBe(false)
expect(AGENT_MESSAGE_RE.test('can you explain what Message from means?')).toBe(false)
})
})
@@ -1,5 +1,5 @@
import { ActionBarPrimitive, BranchPickerPrimitive, MessagePrimitive, useAuiState } from '@assistant-ui/react'
import { type FC, type ReactNode, useCallback, useRef, useState } from 'react'
import { type FC, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { DirectiveContent } from '@/components/assistant-ui/directive-text'
import { messageAttachmentRefs, messageContentText } from '@/components/assistant-ui/thread/content'
@@ -13,6 +13,7 @@ import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { StopFilled } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $gateway } from '@/store/gateway'
import { notifyThreadEditOpen } from '@/store/thread-scroll'
import { isWatchWindow } from '@/store/windows'
@@ -77,6 +78,139 @@ export const StopGlyph = <StopFilled aria-hidden className="size-3.5 -translate-
// Shape: see tools/process_registry.py format_process_notification().
const PROCESS_NOTIFICATION_RE = /^\[IMPORTANT: Background process [\s\S]*\]$/
// Agent-to-agent deliveries ("Message from 🤖 <sender>: …", the Bot Mode /
// multi-profile convention; optional "(@<handle>)" carries the sender's
// profile name for avatar resolution; legacy "[Message from agent
// '<sender>'] …" too). They arrive on the user role because the recipient's
// turn runs on it, but they are NOT the human speaking — render them as a
// compact attributed timeline notice instead of a user bubble.
export const AGENT_MESSAGE_RE =
/^(?:Message from (?:🤖\s*)?([^:\n(]{1,64}?)(?:\s*\(@([a-z0-9][a-z0-9_-]{0,63})\))?:\s*|\[Message from agent '([^']{1,64})'\]\s*)([\s\S]*)$/u
// sender handle -> avatar data URL (null = known absent). Module-level so a
// chat full of notices from one bot resolves once. Profiles change rarely;
// stale entries only persist for the window's lifetime.
const agentAvatarCache = new Map<string, null | string>()
const agentAvatarInflight = new Map<string, Promise<null | string>>()
async function resolveAgentAvatar(handle: string): Promise<null | string> {
const key = handle.trim().toLowerCase()
if (!key) {
return null
}
if (agentAvatarCache.has(key)) {
return agentAvatarCache.get(key) ?? null
}
const inflight = agentAvatarInflight.get(key)
if (inflight) {
return inflight
}
const run = (async (): Promise<null | string> => {
try {
const gateway = $gateway.get()
if (!gateway) {
return null
}
const res = await gateway.request<{ profiles?: Array<{ has_avatar?: boolean; name: string }> }>(
'profiles.list',
{ include_sessions: false }
)
const profiles = res?.profiles ?? []
let profile = profiles.find(p => p.name.toLowerCase() === key)
// 'hermes' is the conventional alias for the primary profile.
if (!profile && key === 'hermes') {
profile = profiles.find(p => p.name === 'default')
}
if (!profile?.has_avatar) {
return null
}
const asset = await gateway.request<{ data?: string; found?: boolean }>('profiles.get_asset', {
asset: 'avatar',
name: profile.name
})
return asset?.found && asset.data ? asset.data : null
} catch {
// Older gateway (no profiles.* RPCs) or transient failure — the 🤖
// glyph fallback is always correct.
return null
} finally {
agentAvatarInflight.delete(key)
}
})()
agentAvatarInflight.set(key, run)
const out = await run
agentAvatarCache.set(key, out)
return out
}
const AgentMessageNote: FC<{ text: string }> = ({ text }) => {
const match = AGENT_MESSAGE_RE.exec(text)
const sender = (match?.[1] || match?.[3] || 'agent').trim()
const handle = (match?.[2] || match?.[3] || sender).trim()
const body = (match?.[4] || '').trim()
const [avatar, setAvatar] = useState<null | string>(() => agentAvatarCache.get(handle.toLowerCase()) ?? null)
useEffect(() => {
let live = true
void resolveAgentAvatar(handle).then(url => {
if (live && url) {
setAvatar(url)
}
})
return () => {
live = false
}
}, [handle])
// Grok-bots shape: an inter-agent delivery is a timeline EVENT, not a
// conversation bubble — a subtle centered notice ("Message from 🤖 X"),
// with the delivered text one click away instead of shouting in the
// transcript. The recipient's reply below it stays a normal assistant
// message, so the exchange still reads in order.
return (
<div
className="flex max-w-[min(86%,44rem)] flex-col gap-0.5 self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/60"
data-slot="aui_agent-message-note"
>
<span className="flex items-center justify-center gap-1.5">
{avatar ? (
<img alt="" aria-hidden className="size-4 shrink-0 rounded-full object-cover" src={avatar} />
) : (
<span aria-hidden className="text-[0.8125rem] leading-none">
🤖
</span>
)}
<span className="wrap-anywhere">Message from {sender}</span>
</span>
{body && (
<details className="self-center">
<summary className="cursor-pointer select-none text-center text-muted-foreground/45 hover:text-muted-foreground/70">
show message
</summary>
<div className="mt-1 max-w-[36rem] rounded-lg border border-(--ui-stroke-tertiary) px-3 py-2 text-left text-[0.75rem] leading-5 text-foreground/85">
<UserMessageText text={body} />
</div>
</details>
)}
</div>
)
}
const ProcessNotificationNote: FC<{ text: string }> = ({ text }) => {
const body = text.replace(/^\[IMPORTANT:\s*/, '').replace(/\]$/, '')
const newline = body.indexOf('\n')
@@ -229,6 +363,19 @@ export const UserMessage: FC<{
)
}
// Agent-to-agent delivery, not a human prompt — attributed inter-agent card.
if (AGENT_MESSAGE_RE.test(messageText.trim())) {
return (
<MessagePrimitive.Root
className="flex w-full min-w-0 flex-col items-stretch pb-(--conversation-turn-gap)"
data-role="user"
data-slot="aui_user-message-root"
>
<AgentMessageNote text={messageText.trim()} />
</MessagePrimitive.Root>
)
}
const hasBody = messageText.trim().length > 0
const isLatestUser = messageId === latestUserId
const showStop = !readOnly && isLatestUser && threadRunning && Boolean(onCancel)