Files
orca/mobile/src/session/MobileNativeChatMessage.test.ts
T
Brennan Benson c893048aeb feat(native-chat): show which agent a chat message is from, and open it (#25888)
* feat(native-chat): show which agent a chat message is from

A message another agent sends into a native chat (today the orchestration
mail notice) looked exactly like the person's own: a right-hand bubble and a
queued card identical to theirs. The sender was recorded only on the queue
row, so a message sent to an idle chat, or a card once it was sent, had no
sender at all.

The sender now lives on the message itself (`from` on the message body):
- The send path carries it on both deliveries; an idle chat records it too,
  and it survives the queue, /clear's carry, restart and history.
- No fingerprint covers it: one shared payload projection leaves it out at
  every fingerprint site, so stored digests are unchanged and the provider's
  echo still folds into the same bubble. The provider never receives it.
- A client cannot set it: its send params stay strict.
- One reader normalizes it where it is read (journal load, queued row,
  client projection); a newer message kind keeps the sender an agent's.
- Each sender keeps a name snapshot from names Orca controls: a chat's
  conversation name or agent label, a terminal's own tab title or agent,
  a federated sender's task name. Never an agent-set terminal title.
- The separate queued-row sender column and its plumbing are removed; it
  never shipped.

Desktop shows such a message as a left-aligned "Message from <name>" row;
the name opens the sender: its chat through the existing open-chat flow
(after a host lookup that follows /clear and dispatch addresses), or its
terminal through the terminal-link path, with the existing feedback when
it is gone. A queued card shows a plain "From <name>" line. Mobile shows
the same attribution, not yet clickable.

* fix(native-chat): name and open an agent's message by what Orca shows for it

Review fixes for showing who an agent message is from.

- Names: a sender is named by its dispatch's task (local workers too, not
  only federated ones), else a chat by the label its tab shows, else a
  terminal by its tab's stored title, else its agent. The conversation
  name the snapshot read before has no writer, so every chat read
  "<Agent> Chat".
- One sender builder (agentMessageSender) and a public runtime naming
  object (orchestrationSenderNames) any send site can use; the terminal
  naming moves out of the unrelated runtime layer into its own module.
- Opening a terminal sender always asks the chat's host: a terminal handle
  is issued per run, so the host finds an earlier run's terminal again
  through the pane its mail was sent from. The pane key never leaves the
  host.
- Feedback follows what the host found: a gone chat or a gone pane by the
  kind it lost, "pane no longer available" only when the terminal answer
  proves it, and the existing "couldn't reach" / "needs a newer Orca"
  words otherwise.
- Another agent's message is not offered rewind (it would put the
  agent's text in the person's composer), and a Codex rewind keeps the
  sender on the messages it retains.
- The queued card and mobile name every sender, two that share a name
  included, and count in "+N" only those left out; the transcript row
  separates names with ", ".
- The host no longer normalizes the sender when loading the journal or
  the queue: nothing on the host reads it, and clients read it through
  the one reader at their projections.

* fix(native-chat): name a worker's final report by its task; clearer open-sender failures

- A worker's own worker_done settles its dispatch before the mail lane
  names who the report is from, so a terminal worker's completion read
  "Codex". A local worker is now named by the dispatch it holds, else the
  one it last held (handles are per run, so that is this run's). The
  coordinator mail rig now attests the worker's process, so its report is
  accepted and settles as it does in production.
- Opening a sender: a version block or an unknown answer says "Update Orca
  to open it.", since either side may be the older one; "This needs a
  newer Orca on the computer running this chat" stays only for a host
  that predates the lookup. "Orca couldn't reach the agent." is followed
  by "Try again.", each sentence translated on its own. No new copy.

* fix(native-chat): name a worker only by work it holds or just reported

A worker with no active dispatch was named by the newest dispatch ever
assigned to its terminal, at any status and any age: one that failed before
it ran, or a task finished long ago that its later, unrelated mail kept
showing. Now a worker is named by its active dispatch, else by the dispatch
its own worker_done in the announced mail names (only if that dispatch is
its own), else by its tab or agent as any terminal is.

* fix(native-chat): restore a rewound message's sender through its typed body; mobile card fixtures name their attribution

- The rewind's held-sender restore lives with the retained rows and reads both
  bodies through the journal's message-body check, instead of a shared helper
  taking any object (anti-slop: no-object-parameters).
- Mobile queued-card test fixtures carry the attribution field the card gained.

* fix(native-chat): never call a sender gone without proof; the sender builder moves out of the mail lane

- A federated sender (dispatch:<id>) runs on another host, which the chat's
  host cannot open: its name is plain text, and the host's lookup answers
  'not found' rather than 'gone' for a dispatch it does not run. Not seeing a
  worker is no evidence it stopped.
- The lookup calls a chat gone only when the identity is proven lost; any
  other failure reaches the client as 'could not reach'.
- A sender open that throws shows 'couldn't reach the agent' instead of an
  unhandled rejection.
- agentMessageSender, the one sender builder, lives in its own module, so the
  runtime's naming no longer imports from the mail lane.
2026-10-06 15:14:04 -07:00

490 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createElement } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentJournalToolCallItem } from '../../../src/shared/agent-session-journal-types'
import { MAX_TOOL_DETAIL_LENGTH } from '../../../src/shared/native-chat-tool-summary'
import { projectStructuredItemToNativeChat } from '../../../src/shared/structured-agent-session-projection'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { AGENT_SESSION_HOST_STATUS_COPY } from '../../../src/shared/agent-session-host-status-rows'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-native-chat-message-styles'
vi.mock('react-native', async () => {
const React = await import('react')
const Text = ({ children, ...props }: { children?: unknown }): unknown =>
React.createElement('Text', props, children)
return {
ActivityIndicator: 'ActivityIndicator',
Animated: {
Text,
Value: class {
setValue(): void {}
},
loop: (animation: unknown) => animation,
sequence: () => ({ start: vi.fn(), stop: vi.fn() }),
timing: () => ({ start: vi.fn(), stop: vi.fn() })
},
Image: 'Image',
Platform: { OS: 'ios' },
Pressable: 'Pressable',
ScrollView: ({ children, ...props }: { children?: unknown }) =>
React.createElement('ScrollView', props, children),
Text,
View: ({ children, ...props }: { children?: unknown }) =>
React.createElement('View', props, children),
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
}
})
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }))
vi.mock('lucide-react-native', () => ({
ArrowUp: 'ArrowUp',
Brain: 'Brain',
ChevronDown: 'ChevronDown',
Copy: 'Copy',
SquareChevronRight: 'SquareChevronRight',
SquareTerminal: 'SquareTerminal',
Wrench: 'Wrench',
ChevronRight: 'ChevronRight'
}))
vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' }))
vi.mock('./MobileNativeChatMessageActionsSheet', () => ({
MobileNativeChatMessageActionsSheet: 'MessageActionsSheet'
}))
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
function userMessage(blocks: NativeChatMessage['blocks']): NativeChatMessage {
return { id: 'u1', role: 'user', blocks, timestamp: null, source: 'transcript' }
}
function toolMessage(blocks: NativeChatMessage['blocks']): NativeChatMessage {
return { id: 'a1', role: 'assistant', blocks, timestamp: null, source: 'transcript' }
}
describe('MobileNativeChatMessage', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
function render(
message: NativeChatMessage,
props: {
fontScale?: number
toolsExpanded?: boolean
structuredActivityUi?: boolean
activeTurnIsWorking?: boolean
turnExpanded?: boolean
turnStatus?: {
startedAt: number | null
thinking: boolean
workedSeconds: number | null
} | null
onToggleTurn?: () => void
reasoningIsLive?: boolean
} = {}
): ReactTestRenderer {
act(() => {
renderer = create(createElement(MobileNativeChatMessage, { message, ...props }))
})
return renderer!
}
const textIn = (node: ReactTestInstance): string[] =>
node.findAllByType('Text' as never).map((text) => String(text.children.join('')))
it.each(['system', 'user'] as const)(
'renders a %s host notice as selectable muted text rather than a markdown answer',
(role) => {
const tree = render(
{
id: 'notice',
role,
timestamp: 1,
blocks: [
{ type: 'text', text: 'provider fallback', presentation: 'history-item-too-large' }
]
},
{ fontScale: 1.5 }
)
expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0)
const text = tree.root.find((node) => String(node.type) === 'Text')
expect(text.props.children).toBe(AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'])
expect(text.props.selectable).toBe(true)
expect(Object.assign({}, ...text.props.style)).toMatchObject({
color: colors.textMuted,
fontSize: 25.5
})
}
)
it("names another agent's message and sets it apart from the person's bubble", () => {
const agentMessage: NativeChatMessage = {
...userMessage([{ type: 'text', text: 'You have 1 orchestration message.' }]),
from: {
kind: 'agent',
senders: [
{
party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null },
name: 'Coder'
}
],
orchestration: null
}
}
const tree = render(agentMessage)
expect(textIn(tree.root)[0]).toBe('Message from Coder')
// Left-aligned agent prose, not the person's inverted bubble.
expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(1)
const rows = tree.root.findAll(
(node) => String(node.type) === 'View' && Array.isArray(node.props.style)
)
expect(rows.some((row) => row.props.style.includes(styles.rowUser))).toBe(false)
})
it('preserves an ordinary assistant answer without interpreting its text as a host notice', () => {
const tree = render(toolMessage([{ type: 'text', text: 'provider fallback' }]))
expect(tree.root.find((node) => String(node.type) === 'MobileMarkdown').props.content).toBe(
'provider fallback'
)
})
it('renders a loadable preview URI as an image thumbnail', () => {
const tree = render(userMessage([{ type: 'image-ref', url: 'file:///a.jpg', alt: 'a photo' }]))
const image = tree.root.findByType('Image' as never)
expect(image.props.source).toEqual({ uri: 'file:///a.jpg' })
expect(image.props.accessibilityLabel).toBe('a photo')
})
it('prefers the url over the path when both are present', () => {
const tree = render(
userMessage([{ type: 'image-ref', url: 'file:///local.jpg', path: '/tmp/host.png' }])
)
expect(tree.root.findByType('Image' as never).props.source).toEqual({
uri: 'file:///local.jpg'
})
})
it('falls back to a text placeholder for a bare host path', () => {
// A host temp path (e.g. on an SSH host) is not loadable on the device.
const tree = render(userMessage([{ type: 'image-ref', path: '/tmp/host.png' }]))
expect(tree.root.findAllByType('Image' as never)).toHaveLength(0)
const texts = tree.root
.findAllByType('Text' as never)
.map((node) => String(node.children.join('')))
expect(texts.some((text) => text.includes('/tmp/host.png'))).toBe(true)
})
it('makes user message text selectable', () => {
const tree = render(userMessage([{ type: 'text', text: 'Prompt I typed' }]))
const text = tree.root
.findAllByType('Text' as never)
.find((node) => String(node.children.join('')) === 'Prompt I typed')
expect(text?.props.selectable).toBe(true)
})
it('routes assistant prose through selectable Markdown', () => {
const tree = render(toolMessage([{ type: 'text', text: 'Agent reply prose' }]))
const markdown = tree.root.findByType('MobileMarkdown' as never)
expect(markdown.props.content).toBe('Agent reply prose')
expect(markdown.props.rangeSelectable).toBe(true)
})
it('labels a tool row with the target path instead of raw input JSON', () => {
const tree = render(
toolMessage([{ type: 'tool-call', name: 'Read', input: { file_path: 'src/index.ts' } }]),
{ toolsExpanded: true }
)
const texts = textIn(tree.root)
expect(texts).toContain('src/index.ts')
expect(texts.some((text) => text.includes('"file_path":"src/index.ts"'))).toBe(false)
})
it('bounds expanded diff-less tool input before native text layout', () => {
const tree = render(
toolMessage([
{ type: 'tool-call', name: 'CustomTool', input: { payload: 'x'.repeat(100_000) } }
]),
{ toolsExpanded: true }
)
const detail = textIn(tree.root).find((text) => text.startsWith('{\n'))
expect(detail).toHaveLength(MAX_TOOL_DETAIL_LENGTH + 1)
expect(detail?.endsWith('…')).toBe(true)
})
it('expands formatted detail for a collapsed JSON-string tool input', () => {
const tree = render(
toolMessage([
{
type: 'tool-call',
name: 'CustomTool',
input: '{"cmd":"git status","description":"Inspect changes"}'
}
])
)
const pressableWith = (label: string): ReactTestInstance =>
tree.root.findAllByType('Pressable' as never).find((node) => textIn(node).includes(label))!
act(() => pressableWith('1×').props.onPress())
// The row label is the command, and the detail stays closed until tapped.
expect(textIn(tree.root)).toContain('git status')
expect(textIn(tree.root).some((text) => text.startsWith('{\n'))).toBe(false)
act(() => pressableWith('CustomTool').props.onPress())
expect(textIn(tree.root)).toContain(
'{\n "cmd": "git status",\n "description": "Inspect changes"\n}'
)
})
it('does not echo the row label as detail when a row has nothing to expand', () => {
// The Tools toggle opens every row at once, bypassing the tap guard — a row
// whose formatted input is its own label would echo itself in a panel that
// no tap can dismiss.
const tree = render(toolMessage([{ type: 'tool-call', name: 'ListTodos', input: '{}' }]), {
toolsExpanded: true
})
expect(textIn(tree.root).filter((text) => text === '{}')).toHaveLength(1)
// The chevron has to agree with the panel, or the row claims to be open over
// nothing and the tap that would close it is guarded off. Only the run header
// is open here; the row itself stays collapsed.
expect(tree.root.findAllByType('ChevronDown' as never)).toHaveLength(1)
expect(tree.root.findAllByType('SquareChevronRight' as never)).toHaveLength(1)
})
it('does not expand a plain input that already fits in the row label', () => {
const input = 'x'.repeat(60)
const tree = render(toolMessage([{ type: 'tool-call', name: 'CustomTool', input }]), {
toolsExpanded: true
})
expect(textIn(tree.root).filter((text) => text === input)).toHaveLength(1)
expect(tree.root.findAllByType('ChevronDown' as never)).toHaveLength(1)
expect(tree.root.findAllByType('SquareChevronRight' as never)).toHaveLength(1)
})
describe('output a call left when it ended early', () => {
function projectedCall(ending: Pick<AgentJournalToolCallItem, 'endedAs'>): NativeChatMessage {
const body: AgentJournalToolCallItem = {
kind: 'tool-call',
name: 'shell',
input: { command: 'sleep 20' },
state: 'failed',
...ending,
output: { head: 'partial', byteLength: 7, digest: 'd', truncated: false }
}
const projected = projectStructuredItemToNativeChat({
itemId: 'call',
sequence: 1,
revision: 1,
observedAt: 100,
body
})
if (!projected) {
throw new Error('the call projects no message')
}
return { ...projected, id: 'a1', source: 'transcript' }
}
const outputStyle = (message: NativeChatMessage): unknown => {
const tree = render(message, { toolsExpanded: true })
const output = tree.root
.findAllByType('Text' as never)
.find((node) => node.children.join('') === 'partial')
// The tint is on the result box: the nearest View around the output text.
let box = output?.parent ?? null
while (box && String(box.type) !== 'View') {
box = box.parent
}
return box?.props.style
}
it('shows the output a stop cut short without the error tint', () => {
expect(outputStyle(projectedCall({ endedAs: 'interrupted' }))).toEqual([
expect.any(Object),
false
])
})
it('keeps the error tint on a call nothing proved was cut short', () => {
expect(outputStyle(projectedCall({ endedAs: 'unverifiable' }))).toEqual([
expect.any(Object),
expect.objectContaining({ backgroundColor: expect.any(String) })
])
})
})
describe('structured activity UI', () => {
const runningCall = {
type: 'tool-call' as const,
name: 'Bash',
input: { command: 'npm test' },
state: 'running' as const
}
const settledCall = {
type: 'tool-call' as const,
name: 'Read',
input: { file_path: 'a/b.ts' },
state: 'completed' as const
}
it('shows the live tool label with a terminal glyph while a command runs', () => {
const tree = render(toolMessage([runningCall]), {
structuredActivityUi: true,
activeTurnIsWorking: true
})
expect(textIn(tree.root)).toContain('Running npm test')
expect(tree.root.findAllByType('SquareTerminal' as never)).toHaveLength(1)
expect(tree.root.findAllByType('Wrench' as never)).toHaveLength(0)
})
it('uses the wrench glyph for a non-command tool', () => {
const tree = render(
toolMessage([
{ type: 'tool-call', name: 'Read', input: { file_path: 'a/b.ts' }, state: 'running' }
]),
{ structuredActivityUi: true, activeTurnIsWorking: true }
)
expect(textIn(tree.root)).toContain('Running Read a/b.ts')
expect(tree.root.findAllByType('Wrench' as never)).toHaveLength(1)
})
it('falls back to the collapsed count row once the run settles', () => {
const tree = render(toolMessage([settledCall]), {
structuredActivityUi: true,
activeTurnIsWorking: true
})
expect(textIn(tree.root)).not.toContain('Running Read a/b.ts')
expect(textIn(tree.root)).toContain('1×')
})
it("hides a completed turn's activity until the turn caret discloses it", () => {
const collapsed = render(toolMessage([settledCall]), {
structuredActivityUi: true,
activeTurnIsWorking: false
})
expect(textIn(collapsed.root)).not.toContain('1×')
act(() => collapsed.unmount())
const disclosed = render(toolMessage([settledCall]), {
structuredActivityUi: true,
activeTurnIsWorking: false,
turnExpanded: true
})
expect(textIn(disclosed.root)).toContain('1×')
})
it('lets the global Tools toggle reveal a hidden settled run', () => {
// Otherwise the composer's Tools control is a no-op on every settled turn.
const tree = render(toolMessage([settledCall]), {
structuredActivityUi: true,
activeTurnIsWorking: false,
toolsExpanded: true
})
expect(textIn(tree.root)).toContain('1\u00d7')
})
it('keeps the bridge lane on its always-visible tool run', () => {
const tree = render(toolMessage([settledCall]), { activeTurnIsWorking: false })
expect(textIn(tree.root)).toContain('1×')
expect(tree.root.findAllByType('Wrench' as never)).toHaveLength(0)
})
it('renders the settled turn status row under a user message', () => {
const tree = render(userMessage([{ type: 'text', text: 'go' }]), {
structuredActivityUi: true,
turnStatus: { startedAt: Date.now() - 3_000, thinking: false, workedSeconds: 3 }
})
expect(textIn(tree.root)).toContain('Worked for 3s')
})
it('does not render a turn status row without one', () => {
const tree = render(userMessage([{ type: 'text', text: 'go' }]), {
structuredActivityUi: true
})
expect(textIn(tree.root)).toEqual(['go'])
})
})
describe('a reasoning row', () => {
const reasoning = (fields: Partial<NativeChatMessage> = {}): NativeChatMessage => ({
id: 'r1',
role: 'reasoning',
blocks: [{ type: 'text', text: 'Weighing two approaches' }],
timestamp: 1_000,
source: 'transcript',
state: 'completed',
completedAt: 4_000,
...fields
})
const toggleOf = (tree: ReactTestRenderer): ReactTestInstance =>
tree.root.find(
(node) => String(node.type) === 'Pressable' && node.props.accessibilityRole === 'button'
)
const markdownIn = (tree: ReactTestRenderer): ReactTestInstance[] =>
tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')
it('starts collapsed to its headline, with its text unmounted', () => {
const tree = render(reasoning())
expect(textIn(tree.root)).toContain('Thought for 3s')
expect(toggleOf(tree).props.accessibilityState).toEqual({ expanded: false })
// Said with what it is, as desktop's screen-reader prefix does, on a 32 + 2 × 6 pt target.
expect(toggleOf(tree).props.accessibilityLabel).toBe('Reasoning: Thought for 3s')
expect(toggleOf(tree).props.hitSlop).toBe(6)
expect(markdownIn(tree)).toHaveLength(0)
})
it('leads its headline with the brain, as desktop does', () => {
const [first] = toggleOf(render(reasoning())).children
expect(typeof first === 'string' ? first : first?.type).toBe('Brain')
})
it('mounts its text once opened', () => {
const tree = render(reasoning())
act(() => toggleOf(tree).props.onPress())
expect(toggleOf(tree).props.accessibilityState).toEqual({ expanded: true })
expect(markdownIn(tree).map((node) => node.props.content)).toEqual([
'Weighing two approaches'
])
})
it('draws nothing while the live line discloses it, or when blank', () => {
expect(
render(reasoning({ state: 'running' }), {
activeTurnIsWorking: true,
reasoningIsLive: true
}).toJSON()
).toBeNull()
expect(render(reasoning({ blocks: [{ type: 'text', text: ' \n ' }] })).toJSON()).toBeNull()
})
// The turn's bar is not the block's: hiding the block must not hide the bar it sits on.
it("still draws its turn's bar while the live line discloses it", () => {
const tree = render(reasoning({ state: 'running' }), {
activeTurnIsWorking: true,
reasoningIsLive: true,
turnStatus: { startedAt: 1_000, thinking: true, workedSeconds: null }
})
expect(tree.root.findAll((node) => String(node.type) === 'Pressable')).toHaveLength(0)
expect(textIn(tree.root).some((text) => text.startsWith('Working for'))).toBe(true)
})
// Only the block the line discloses hides: a subagent's or a stale open block draws, unended.
it('draws any other open block in its working turn as Reasoning', () => {
const child = render(reasoning({ state: 'running', agentId: 'sub-1' }), {
activeTurnIsWorking: true
})
expect(textIn(child.root)).toContain('Reasoning')
expect(toggleOf(child).props.accessibilityLabel).toBe('Reasoning')
})
it('says only what the host saw', () => {
expect(textIn(render(reasoning({ state: 'running' })).root)).toContain('Thought')
const unknown = render(reasoning({ state: undefined, completedAt: undefined }))
expect(textIn(unknown.root)).toContain('Reasoning')
// No "Reasoning: Reasoning".
expect(toggleOf(unknown).props.accessibilityLabel).toBe('Reasoning')
expect(textIn(render(reasoning({ completedAt: 1_300 })).root)).toContain('Thought for 1s')
})
})
})