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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user