Repository navigation
fix(cloud-agent-sdk): validate message time and permission scope at the event boundary #8872
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1fac735
9df0612
d86442f
b863b86
9afd182
65c842f
98ba3f4
e6ec87d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| import { createElement } from 'react'; | ||
| import type * as ReactI18next from 'react-i18next'; | ||
| import { describe, expect, it, vi } from 'vitest'; | ||
| import { act, TestRenderer } from '@/test/renderer'; | ||
|
|
||
| import { i18n } from '@/i18n'; | ||
| import { PermissionUnreadableNotice } from './permission-unreadable-notice'; | ||
|
|
||
| const platform = vi.hoisted(() => ({ os: 'android' as string })); | ||
| const announceForA11y = vi.hoisted(() => vi.fn<(message: string) => void>()); | ||
|
|
||
| vi.mock('react-i18next', async importOriginal => { | ||
| const actual = await importOriginal<typeof ReactI18next>(); | ||
| return { ...actual, useTranslation: () => ({ t: actual.getI18n().t.bind(actual.getI18n()) }) }; | ||
| }); | ||
| vi.mock('react-native', () => ({ | ||
| View: 'View', | ||
| Platform: { | ||
| get OS() { | ||
| return platform.os; | ||
| }, | ||
| }, | ||
| })); | ||
| vi.mock('@/lib/a11y/announce', () => ({ announceForA11y })); | ||
| vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); | ||
|
|
||
| async function renderNotice() { | ||
| const rendererRef: { current: TestRenderer.ReactTestRenderer | undefined } = { | ||
| current: undefined, | ||
| }; | ||
| await act(async () => { | ||
| await vi.waitFor(() => { | ||
| expect(i18n.t('agentChat.permissionCard.unreadableTitle')).toBe( | ||
| 'Permission request could not be read' | ||
| ); | ||
| }); | ||
| rendererRef.current = TestRenderer.create(createElement(PermissionUnreadableNotice)); | ||
| }); | ||
| const renderer = rendererRef.current; | ||
| if (!renderer) { | ||
| throw new Error('renderer was not created'); | ||
| } | ||
| return renderer; | ||
| } | ||
|
|
||
| describe('PermissionUnreadableNotice mounted', () => { | ||
| it('explains that the request cannot be shown and points at the CLI', async () => { | ||
| const renderer = await renderNotice(); | ||
|
|
||
| const text = renderer.root | ||
| .findAll(node => typeof node.props.children === 'string') | ||
| .map(node => node.props.children as string) | ||
| .join(' '); | ||
|
|
||
| expect(text).toContain('Permission request could not be read'); | ||
| expect(text).toContain('The agent is waiting for a permission decision this app cannot show.'); | ||
| expect(text).toContain('answer the request in the CLI'); | ||
| }); | ||
|
|
||
| it('carries the notice as an alert and renders no decision action', async () => { | ||
| const renderer = await renderNotice(); | ||
|
|
||
| const alerts = renderer.root.findAll(node => node.props.accessibilityRole === 'alert'); | ||
| expect(alerts).toHaveLength(1); | ||
| expect(alerts[0]?.props.accessibilityLiveRegion).toBe('polite'); | ||
| expect(renderer.root.findAll(node => node.props.accessibilityRole === 'button')).toHaveLength( | ||
| 0 | ||
| ); | ||
| }); | ||
|
|
||
| it('lets Android read the live region instead of announcing as well', async () => { | ||
| platform.os = 'android'; | ||
| announceForA11y.mockClear(); | ||
| await renderNotice(); | ||
|
|
||
| expect(announceForA11y).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('announces once on iOS, which has no live region', async () => { | ||
| platform.os = 'ios'; | ||
| announceForA11y.mockClear(); | ||
| await renderNotice(); | ||
|
|
||
| expect(announceForA11y).toHaveBeenCalledTimes(1); | ||
| expect(announceForA11y).toHaveBeenCalledWith( | ||
| 'The agent is waiting for a permission decision this app cannot show. Update the app or the CLI, or answer the request in the CLI to continue.' | ||
| ); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| import { useEffect } from 'react'; | ||
| import { useTranslation } from 'react-i18next'; | ||
| import { Platform, View } from 'react-native'; | ||
|
|
||
| import { Text } from '@/components/ui/text'; | ||
| import { announceForA11y } from '@/lib/a11y/announce'; | ||
|
|
||
| /** | ||
| * Shown while the agent waits on a permission request this app cannot read. No | ||
| * consent card is rendered, because a card would describe a scope the boundary | ||
| * never parsed; the CLI can still answer the request, so the notice points the | ||
| * user there instead of leaving the run stalled without explanation. | ||
| * | ||
| * The notice appears without any user action, so a screen reader has to learn | ||
| * about it. Android reads the polite live region as the node is added; iOS has | ||
| * no live region, so the same copy is announced once there. Announcing on both | ||
| * would read it twice on Android. | ||
| */ | ||
| export function PermissionUnreadableNotice() { | ||
| const { t } = useTranslation(); | ||
| const announcement = t('agentChat.permissionCard.unreadableBody'); | ||
|
|
||
| useEffect(() => { | ||
| if (Platform.OS !== 'android') { | ||
| announceForA11y(announcement); | ||
| } | ||
| }, [announcement]); | ||
|
|
||
| return ( | ||
| <View | ||
| accessible | ||
| accessibilityRole="alert" | ||
| accessibilityLiveRegion="polite" | ||
| className="mx-4 my-2 rounded-xl border border-warn-tile-border bg-warn-tile-bg p-4" | ||
| > | ||
| <Text className="text-sm font-medium text-warn"> | ||
| {t('agentChat.permissionCard.unreadableTitle')} | ||
| </Text> | ||
| <Text className="mt-1 text-xs text-muted-foreground">{announcement}</Text> | ||
| </View> | ||
| ); | ||
| } | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Uh oh!
There was an error while loading. Please reload this page.