mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
While the agent's open reasoning block has text, the turn's live activity line is its disclosure: collapsed by default, expandable to the live text (capped and scrollable), and the block's row draws nothing meanwhile. When the block ends, its row appears in place, open if the reader opened it live, because the line and the row read one disclosure key. Which block the line discloses is derived from the line's own render condition, so a row is never hidden while nothing on screen shows it; any other open block (a subagent's, or one a prompt pushed off the line) draws as "Reasoning". Desktop and mobile alike; no host or wire change.
402 lines
16 KiB
TypeScript
402 lines
16 KiB
TypeScript
import { createElement } from 'react'
|
||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||
import { MAX_TOOL_DETAIL_LENGTH } from '../../../src/shared/native-chat-tool-summary'
|
||
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'
|
||
|
||
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('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('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()
|
||
})
|
||
|
||
// 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')
|
||
})
|
||
})
|
||
})
|