mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
Improve notice details
This commit is contained in:
@@ -303,6 +303,13 @@
|
||||
"safetyReviewApproved": "{{agent}} hat diese Aktion genehmigt. Details anzeigen.",
|
||||
"safetyReview": "{{agent}} hat eine Warnung zur Sicherheitsprüfung gemeldet. Details anzeigen.",
|
||||
"truncatedResponse": "Anbieterantwort gekürzt (ursprüngliche Größe: {{bytes}} Bytes).",
|
||||
"retrying": "Neuer Versuch. Details anzeigen.",
|
||||
"retryingCount": "Neuer Versuch ({{attempt}} von {{max}}). Details anzeigen.",
|
||||
"rateLimitedRetrying": "Ratenlimit erreicht, neuer Versuch. Details anzeigen.",
|
||||
"rateLimitedRetryingCount": "Ratenlimit erreicht, neuer Versuch ({{attempt}} von {{max}}). Details anzeigen.",
|
||||
"codexConfigurationWarning": "Codex-Konfigurationswarnung. Details anzeigen.",
|
||||
"codexDeprecationNotice": "Codex-Hinweis auf veraltete Funktion. Details anzeigen.",
|
||||
"providerResponse": "Anbieterantwort",
|
||||
"modelFallback": "Claude hat das Modell gewechselt: {{from}} → {{to}}.",
|
||||
"signIn": "Anmelden",
|
||||
"signInHint": "Öffnet ein Terminal auf dem Computer, auf dem dieser Chat läuft",
|
||||
|
||||
@@ -3158,6 +3158,13 @@
|
||||
"safetyReviewApproved": "{{agent}} approved this action. View details.",
|
||||
"safetyReview": "{{agent}} reported a safety review warning. View details.",
|
||||
"truncatedResponse": "Provider response truncated (original size: {{bytes}} bytes).",
|
||||
"retrying": "Retrying. View details.",
|
||||
"retryingCount": "Retrying ({{attempt}} of {{max}}). View details.",
|
||||
"rateLimitedRetrying": "Rate limited, retrying. View details.",
|
||||
"rateLimitedRetryingCount": "Rate limited, retrying ({{attempt}} of {{max}}). View details.",
|
||||
"codexConfigurationWarning": "Codex configuration warning. View details.",
|
||||
"codexDeprecationNotice": "Codex deprecation notice. View details.",
|
||||
"providerResponse": "Provider response",
|
||||
"modelFallback": "Claude switched models: {{from}} → {{to}}.",
|
||||
"forkedFrom": "Forked from",
|
||||
"signIn": "Sign in",
|
||||
|
||||
@@ -307,6 +307,13 @@
|
||||
"safetyReviewApproved": "{{agent}} aprobó esta acción. Ver detalles.",
|
||||
"safetyReview": "{{agent}} notificó una advertencia de revisión de seguridad. Ver detalles.",
|
||||
"truncatedResponse": "Respuesta del proveedor truncada (tamaño original: {{bytes}} bytes).",
|
||||
"retrying": "Reintentando. Ver detalles.",
|
||||
"retryingCount": "Reintentando ({{attempt}} de {{max}}). Ver detalles.",
|
||||
"rateLimitedRetrying": "Límite de solicitudes alcanzado, reintentando. Ver detalles.",
|
||||
"rateLimitedRetryingCount": "Límite de solicitudes alcanzado, reintentando ({{attempt}} de {{max}}). Ver detalles.",
|
||||
"codexConfigurationWarning": "Advertencia de configuración de Codex. Ver detalles.",
|
||||
"codexDeprecationNotice": "Aviso de obsolescencia de Codex. Ver detalles.",
|
||||
"providerResponse": "Respuesta del proveedor",
|
||||
"modelFallback": "Claude cambió de modelo: {{from}} → {{to}}.",
|
||||
"signIn": "Iniciar sesión",
|
||||
"signInHint": "Abre una terminal en el ordenador que ejecuta este chat",
|
||||
|
||||
@@ -307,6 +307,13 @@
|
||||
"safetyReviewApproved": "{{agent}} a approuvé cette action. Voir les détails.",
|
||||
"safetyReview": "{{agent}} a signalé un avertissement de sécurité. Voir les détails.",
|
||||
"truncatedResponse": "Réponse du fournisseur tronquée (taille d’origine : {{bytes}} octets).",
|
||||
"retrying": "Nouvelle tentative. Voir les détails.",
|
||||
"retryingCount": "Nouvelle tentative ({{attempt}} sur {{max}}). Voir les détails.",
|
||||
"rateLimitedRetrying": "Limite de débit atteinte, nouvelle tentative. Voir les détails.",
|
||||
"rateLimitedRetryingCount": "Limite de débit atteinte, nouvelle tentative ({{attempt}} sur {{max}}). Voir les détails.",
|
||||
"codexConfigurationWarning": "Avertissement de configuration Codex. Voir les détails.",
|
||||
"codexDeprecationNotice": "Avis d’obsolescence Codex. Voir les détails.",
|
||||
"providerResponse": "Réponse du fournisseur",
|
||||
"modelFallback": "Claude a changé de modèle : {{from}} → {{to}}.",
|
||||
"signIn": "Se connecter",
|
||||
"signInHint": "Ouvre un terminal sur l’ordinateur qui exécute ce chat",
|
||||
|
||||
@@ -303,6 +303,13 @@
|
||||
"safetyReviewApproved": "{{agent}} がこの操作を承認しました。詳細を表示。",
|
||||
"safetyReview": "{{agent}} が安全性レビューの警告を報告しました。詳細を表示。",
|
||||
"truncatedResponse": "プロバイダーの応答は切り詰められています(元のサイズ:{{bytes}} バイト)。",
|
||||
"retrying": "再試行中。詳細を表示。",
|
||||
"retryingCount": "再試行中({{attempt}}/{{max}})。詳細を表示。",
|
||||
"rateLimitedRetrying": "レート制限のため再試行中。詳細を表示。",
|
||||
"rateLimitedRetryingCount": "レート制限のため再試行中({{attempt}}/{{max}})。詳細を表示。",
|
||||
"codexConfigurationWarning": "Codex の設定に関する警告。詳細を表示。",
|
||||
"codexDeprecationNotice": "Codex の非推奨に関する通知。詳細を表示。",
|
||||
"providerResponse": "プロバイダーの応答",
|
||||
"modelFallback": "Claudeがモデルを切り替えました: {{from}} → {{to}}。",
|
||||
"signIn": "サインイン",
|
||||
"signInHint": "このチャットを実行しているコンピューターでターミナルを開きます",
|
||||
|
||||
@@ -303,6 +303,13 @@
|
||||
"safetyReviewApproved": "{{agent}}이(가) 이 작업을 승인했습니다. 세부 정보 보기.",
|
||||
"safetyReview": "{{agent}}이(가) 안전 검토 경고를 보고했습니다. 세부 정보 보기.",
|
||||
"truncatedResponse": "공급자 응답이 잘렸습니다(원래 크기: {{bytes}}바이트).",
|
||||
"retrying": "다시 시도 중. 세부 정보 보기.",
|
||||
"retryingCount": "다시 시도 중({{attempt}}/{{max}}). 세부 정보 보기.",
|
||||
"rateLimitedRetrying": "요청 한도 도달, 다시 시도 중. 세부 정보 보기.",
|
||||
"rateLimitedRetryingCount": "요청 한도 도달, 다시 시도 중({{attempt}}/{{max}}). 세부 정보 보기.",
|
||||
"codexConfigurationWarning": "Codex 구성 경고. 세부 정보 보기.",
|
||||
"codexDeprecationNotice": "Codex 지원 중단 알림. 세부 정보 보기.",
|
||||
"providerResponse": "공급자 응답",
|
||||
"modelFallback": "Claude가 모델을 변경했습니다: {{from}} → {{to}}.",
|
||||
"signIn": "로그인",
|
||||
"signInHint": "이 채팅을 실행 중인 컴퓨터에서 터미널을 엽니다",
|
||||
|
||||
@@ -325,6 +325,13 @@
|
||||
"safetyReviewApproved": "{{agent}} aprovou esta ação. Ver detalhes.",
|
||||
"safetyReview": "{{agent}} relatou um aviso de revisão de segurança. Ver detalhes.",
|
||||
"truncatedResponse": "Resposta do provedor truncada (tamanho original: {{bytes}} bytes).",
|
||||
"retrying": "Tentando novamente. Ver detalhes.",
|
||||
"retryingCount": "Tentando novamente ({{attempt}} de {{max}}). Ver detalhes.",
|
||||
"rateLimitedRetrying": "Limite de requisições atingido, tentando novamente. Ver detalhes.",
|
||||
"rateLimitedRetryingCount": "Limite de requisições atingido, tentando novamente ({{attempt}} de {{max}}). Ver detalhes.",
|
||||
"codexConfigurationWarning": "Aviso de configuração do Codex. Ver detalhes.",
|
||||
"codexDeprecationNotice": "Aviso de descontinuação do Codex. Ver detalhes.",
|
||||
"providerResponse": "Resposta do provedor",
|
||||
"modelFallback": "Claude mudou de modelo: {{from}} → {{to}}.",
|
||||
"signIn": "Entrar",
|
||||
"signInHint": "Abre um terminal no computador que executa este chat",
|
||||
|
||||
@@ -311,6 +311,13 @@
|
||||
"safetyReviewApproved": "{{agent}} одобрил это действие. Подробнее.",
|
||||
"safetyReview": "{{agent}} сообщил о предупреждении проверки безопасности. Подробнее.",
|
||||
"truncatedResponse": "Ответ поставщика обрезан (исходный размер: {{bytes}} байт).",
|
||||
"retrying": "Повторная попытка. Подробнее.",
|
||||
"retryingCount": "Повторная попытка ({{attempt}} из {{max}}). Подробнее.",
|
||||
"rateLimitedRetrying": "Превышен лимит запросов, повторная попытка. Подробнее.",
|
||||
"rateLimitedRetryingCount": "Превышен лимит запросов, повторная попытка ({{attempt}} из {{max}}). Подробнее.",
|
||||
"codexConfigurationWarning": "Предупреждение о конфигурации Codex. Подробнее.",
|
||||
"codexDeprecationNotice": "Уведомление Codex об устаревшей функции. Подробнее.",
|
||||
"providerResponse": "Ответ поставщика",
|
||||
"modelFallback": "Claude переключил модель: {{from}} → {{to}}.",
|
||||
"signIn": "Войти",
|
||||
"signInHint": "Открывает терминал на компьютере с этим чатом",
|
||||
|
||||
@@ -303,6 +303,13 @@
|
||||
"safetyReviewApproved": "{{agent}} bu işleme onay verdi. Ayrıntıları görüntüleyin.",
|
||||
"safetyReview": "{{agent}} bir güvenlik incelemesi uyarısı bildirdi. Ayrıntıları görüntüleyin.",
|
||||
"truncatedResponse": "Sağlayıcı yanıtı kısaltıldı (özgün boyut: {{bytes}} bayt).",
|
||||
"retrying": "Yeniden deneniyor. Ayrıntıları görüntüleyin.",
|
||||
"retryingCount": "Yeniden deneniyor ({{attempt}}/{{max}}). Ayrıntıları görüntüleyin.",
|
||||
"rateLimitedRetrying": "İstek sınırına ulaşıldı, yeniden deneniyor. Ayrıntıları görüntüleyin.",
|
||||
"rateLimitedRetryingCount": "İstek sınırına ulaşıldı, yeniden deneniyor ({{attempt}}/{{max}}). Ayrıntıları görüntüleyin.",
|
||||
"codexConfigurationWarning": "Codex yapılandırma uyarısı. Ayrıntıları görüntüleyin.",
|
||||
"codexDeprecationNotice": "Codex kullanımdan kaldırma bildirimi. Ayrıntıları görüntüleyin.",
|
||||
"providerResponse": "Sağlayıcı yanıtı",
|
||||
"modelFallback": "Claude model değiştirdi: {{from}} → {{to}}.",
|
||||
"signIn": "Oturum aç",
|
||||
"signInHint": "Bu sohbeti çalıştıran bilgisayarda bir terminal açar",
|
||||
|
||||
@@ -303,6 +303,13 @@
|
||||
"safetyReviewApproved": "{{agent}} 已核准此操作。查看詳細資訊。",
|
||||
"safetyReview": "{{agent}} 回報了安全審查警告。查看詳細資訊。",
|
||||
"truncatedResponse": "供應商回應已截斷(原始大小:{{bytes}} 位元組)。",
|
||||
"retrying": "正在重試。查看詳細資訊。",
|
||||
"retryingCount": "正在重試(第 {{attempt}} 次,共 {{max}} 次)。查看詳細資訊。",
|
||||
"rateLimitedRetrying": "已達速率限制,正在重試。查看詳細資訊。",
|
||||
"rateLimitedRetryingCount": "已達速率限制,正在重試(第 {{attempt}} 次,共 {{max}} 次)。查看詳細資訊。",
|
||||
"codexConfigurationWarning": "Codex 設定警告。查看詳細資訊。",
|
||||
"codexDeprecationNotice": "Codex 淘汰通知。查看詳細資訊。",
|
||||
"providerResponse": "供應商回應",
|
||||
"modelFallback": "Claude 已切換模型:{{from}} → {{to}}。",
|
||||
"signIn": "登入",
|
||||
"signInHint": "在執行此聊天的電腦上開啟終端機",
|
||||
|
||||
@@ -303,6 +303,13 @@
|
||||
"safetyReviewApproved": "{{agent}} 已批准此操作。查看详情。",
|
||||
"safetyReview": "{{agent}} 报告了安全审查警告。查看详情。",
|
||||
"truncatedResponse": "提供方响应已截断(原始大小:{{bytes}} 字节)。",
|
||||
"retrying": "正在重试。查看详情。",
|
||||
"retryingCount": "正在重试(第 {{attempt}} 次,共 {{max}} 次)。查看详情。",
|
||||
"rateLimitedRetrying": "已达速率限制,正在重试。查看详情。",
|
||||
"rateLimitedRetryingCount": "已达速率限制,正在重试(第 {{attempt}} 次,共 {{max}} 次)。查看详情。",
|
||||
"codexConfigurationWarning": "Codex 配置警告。查看详情。",
|
||||
"codexDeprecationNotice": "Codex 弃用通知。查看详情。",
|
||||
"providerResponse": "提供方响应",
|
||||
"modelFallback": "Claude 已切换模型:{{from}} → {{to}}。",
|
||||
"signIn": "登录",
|
||||
"signInHint": "在运行此聊天的电脑上打开终端",
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useId } from 'react'
|
||||
import { Text, View } from 'react-native'
|
||||
import { agentSessionFailureDetailText } from '../../../../../src/shared/agent-session/failures/agent-session-failure-quoted-detail'
|
||||
import { readWholeAgentSessionFailureFact } from '../../../../../src/shared/agent-session/failures/agent-session-failure'
|
||||
import type { NativeChatNoticeLook } from '../../../../../src/shared/native-chat/native-chat-notice'
|
||||
import type { NativeChatTextBlock } from '../../../../../src/shared/native-chat/native-chat-types'
|
||||
import { INLINE_TEXT_SELECTION } from '../../../components/inline-text-selection'
|
||||
import type { Translate } from '../../../i18n/mobile-i18n'
|
||||
import { useMobileTranslation } from '../../../i18n/use-mobile-translation'
|
||||
import { colors, spacing } from '../../../theme/mobile-theme'
|
||||
import { themedStyleSheet } from '../../../theme/themed-styles'
|
||||
import { useMobileColorScheme } from '../../../theme/use-mobile-color-scheme'
|
||||
import { useMobileNativeChatDisclosure } from './mobile-native-chat-disclosure-store'
|
||||
import { MONO_SIZE, TEXT_SIZE, scaledType } from './mobile-native-chat-message-styles'
|
||||
import { toolStyles } from './mobile-native-chat-tool-styles'
|
||||
import { MobileNativeChatNoticeDisclosure } from './MobileNativeChatNoticeDisclosure'
|
||||
import { MobileNativeChatOwnedScrollView } from './MobileNativeChatOwnedScrollView'
|
||||
|
||||
type CollapsedNoticeLook = Extract<NativeChatNoticeLook, { kind: 'collapsed-notice' }>
|
||||
|
||||
function collapsedNoticeSummary(look: CollapsedNoticeLook, t: Translate): string {
|
||||
const counts =
|
||||
look.attempt && look.maxRetries ? { attempt: look.attempt, max: look.maxRetries } : undefined
|
||||
switch (look.label) {
|
||||
case 'retrying':
|
||||
return counts
|
||||
? t('chat.notice.retryingCount', 'Retrying ({{attempt}} of {{max}}). View details.', counts)
|
||||
: t('chat.notice.retrying', 'Retrying. View details.')
|
||||
case 'rate-limited':
|
||||
return counts
|
||||
? t(
|
||||
'chat.notice.rateLimitedRetryingCount',
|
||||
'Rate limited, retrying ({{attempt}} of {{max}}). View details.',
|
||||
counts
|
||||
)
|
||||
: t('chat.notice.rateLimitedRetrying', 'Rate limited, retrying. View details.')
|
||||
case 'configuration':
|
||||
return t(
|
||||
'chat.notice.codexConfigurationWarning',
|
||||
'Codex configuration warning. View details.'
|
||||
)
|
||||
case 'deprecation':
|
||||
return t('chat.notice.codexDeprecationNotice', 'Codex deprecation notice. View details.')
|
||||
}
|
||||
}
|
||||
|
||||
/** A retry or Codex configuration/deprecation notice: one summary line that opens the host's own text. */
|
||||
export function MobileNativeChatCollapsedNotice({
|
||||
notice,
|
||||
look,
|
||||
fontScale,
|
||||
onLongPress,
|
||||
disclosureKeys
|
||||
}: {
|
||||
notice: NativeChatTextBlock
|
||||
look: CollapsedNoticeLook
|
||||
fontScale: number
|
||||
onLongPress?: () => void
|
||||
/** Where the open choice is kept, newest first. */
|
||||
disclosureKeys?: readonly string[]
|
||||
}): React.JSX.Element {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const [open, setOpen] = useMobileNativeChatDisclosure(disclosureKeys?.[0], false, disclosureKeys)
|
||||
const detailId = useId()
|
||||
const failureDetail = agentSessionFailureDetailText(
|
||||
readWholeAgentSessionFailureFact(notice.failure)
|
||||
)
|
||||
const detail =
|
||||
failureDetail && !notice.text.trim().includes(failureDetail.trim()) ? failureDetail : undefined
|
||||
const payload = notice.providerFrame?.payload
|
||||
return (
|
||||
<View>
|
||||
<MobileNativeChatNoticeDisclosure
|
||||
summary={collapsedNoticeSummary(look, t)}
|
||||
expanded={open}
|
||||
fontScale={fontScale}
|
||||
onPress={() => setOpen(!open)}
|
||||
onLongPress={onLongPress}
|
||||
style={[styles.summary, look.label === 'configuration' && styles.configuration]}
|
||||
/>
|
||||
{open ? (
|
||||
<MobileNativeChatOwnedScrollView
|
||||
positionKey={detailId}
|
||||
style={[toolStyles.codeBox, toolStyles.codeScroll]}
|
||||
nestedScrollEnabled
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Text
|
||||
selectable={INLINE_TEXT_SELECTION}
|
||||
style={[styles.prose, scaledType(TEXT_SIZE, fontScale)]}
|
||||
>
|
||||
{notice.text}
|
||||
{detail ? `\n\n${detail}` : ''}
|
||||
</Text>
|
||||
{payload ? (
|
||||
<>
|
||||
<Text style={[styles.caption, scaledType(TEXT_SIZE, fontScale)]}>
|
||||
{t('chat.notice.providerResponse', 'Provider response')}
|
||||
</Text>
|
||||
<Text
|
||||
selectable={INLINE_TEXT_SELECTION}
|
||||
style={[toolStyles.codeText, scaledType(MONO_SIZE, fontScale)]}
|
||||
>
|
||||
{payload.truncated
|
||||
? `${t(
|
||||
'chat.notice.truncatedResponse',
|
||||
'Provider response truncated (original size: {{bytes}} bytes).',
|
||||
{ bytes: payload.byteLength }
|
||||
)}\n`
|
||||
: ''}
|
||||
{payload.head}
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
</MobileNativeChatOwnedScrollView>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = themedStyleSheet(() => ({
|
||||
summary: {
|
||||
minHeight: 44,
|
||||
paddingVertical: 0
|
||||
},
|
||||
// The rule sits in the gutter so the summary stays aligned with its neighbours.
|
||||
configuration: {
|
||||
marginLeft: -(spacing.sm + 2),
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: colors.statusAmber,
|
||||
paddingLeft: spacing.sm
|
||||
},
|
||||
prose: {
|
||||
color: colors.textPrimary
|
||||
},
|
||||
caption: {
|
||||
marginTop: spacing.sm,
|
||||
color: colors.textMuted
|
||||
}
|
||||
}))
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
import { createElement, type ReactNode } from 'react'
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { agentSessionFailureWords } from '../../../../../src/shared/agent-session/failures/agent-session-failure-words'
|
||||
import type { AgentJournalRenderItem } from '../../../../../src/shared/agent-session/journal/agent-session-journal-types'
|
||||
import { createStructuredAgentSessionMessageProjection } from '../../../../../src/shared/agent-session/view/structured-agent-session-message-stream'
|
||||
|
||||
vi.mock('react-native', async () => {
|
||||
const React = await import('react')
|
||||
const Text = ({ children, ...props }: { children?: ReactNode }): ReactNode =>
|
||||
React.createElement('Text', props, children)
|
||||
return {
|
||||
AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) },
|
||||
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?: ReactNode }) =>
|
||||
React.createElement('ScrollView', props, children),
|
||||
Text,
|
||||
View: ({ children, ...props }: { children?: ReactNode }) =>
|
||||
React.createElement('View', props, children),
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
||||
useWindowDimensions: () => ({ width: 400, height: 800 })
|
||||
}
|
||||
})
|
||||
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }))
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
ArrowUp: 'ArrowUp',
|
||||
Bot: 'Bot',
|
||||
Brain: 'Brain',
|
||||
Check: 'Check',
|
||||
ChevronDown: 'ChevronDown',
|
||||
ChevronRight: 'ChevronRight',
|
||||
Copy: 'Copy',
|
||||
Eye: 'Eye',
|
||||
FileMinus2: 'FileMinus2',
|
||||
FilePen: 'FilePen',
|
||||
FilePlus2: 'FilePlus2',
|
||||
Folder: 'Folder',
|
||||
Globe: 'Globe',
|
||||
ListChecks: 'ListChecks',
|
||||
MessageSquareMore: 'MessageSquareMore',
|
||||
Pencil: 'Pencil',
|
||||
Plug: 'Plug',
|
||||
Search: 'Search',
|
||||
SquareChevronRight: 'SquareChevronRight',
|
||||
SquareTerminal: 'SquareTerminal',
|
||||
Wrench: 'Wrench'
|
||||
}))
|
||||
vi.mock('../../../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' }))
|
||||
vi.mock('../../../components/MobileSourceCopyButton', () => ({
|
||||
MobileSourceCopyButton: 'CopyButton'
|
||||
}))
|
||||
vi.mock('./MobileNativeChatMessageActionsSheet', () => ({
|
||||
MobileNativeChatMessageActionsSheet: 'MessageActionsSheet'
|
||||
}))
|
||||
|
||||
import { MobileNativeChatCollapsedNotice } from './MobileNativeChatCollapsedNotice'
|
||||
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
|
||||
import {
|
||||
MobileNativeChatDisclosureContext,
|
||||
useMobileNativeChatDisclosures
|
||||
} from './mobile-native-chat-disclosure-store'
|
||||
|
||||
function item(itemId: string, body: AgentJournalRenderItem['body']): AgentJournalRenderItem {
|
||||
const sequence = Number(itemId.slice(1))
|
||||
return { itemId, revision: 1, sequence, observedAt: sequence, body }
|
||||
}
|
||||
|
||||
function codexRetry(itemId: string): AgentJournalRenderItem {
|
||||
const head = `{"error":{"message":"stream disconnected ${itemId}"},"willRetry":true}`
|
||||
return item(itemId, {
|
||||
kind: 'status',
|
||||
tone: 'warning',
|
||||
...agentSessionFailureWords(
|
||||
{
|
||||
kind: 'providerRetrying',
|
||||
detail: { text: `stream disconnected ${itemId}`, audience: 'person' }
|
||||
},
|
||||
{ surface: 'row', agentName: 'Codex' }
|
||||
),
|
||||
providerFrame: {
|
||||
provider: 'codex',
|
||||
kind: 'notification:error',
|
||||
payload: { head, byteLength: head.length, digest: 'synthetic', truncated: false }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const claudeRetry = item('c1', {
|
||||
kind: 'status',
|
||||
tone: 'warning',
|
||||
...agentSessionFailureWords(
|
||||
{
|
||||
kind: 'providerRetrying',
|
||||
detail: { text: '{"type":"system","subtype":"api_retry"}', audience: 'log' },
|
||||
retry: { error: 'overloaded', status: 529, attempt: 2, maxRetries: 10 }
|
||||
},
|
||||
{ surface: 'row', agentName: 'Claude' }
|
||||
)
|
||||
})
|
||||
|
||||
const reply = (itemId: string): AgentJournalRenderItem =>
|
||||
item(itemId, {
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: 'Partial answer' }]
|
||||
})
|
||||
|
||||
const project = createStructuredAgentSessionMessageProjection()
|
||||
|
||||
function Transcript({ items }: { items: AgentJournalRenderItem[] }) {
|
||||
const disclosures = useMobileNativeChatDisclosures('chat')
|
||||
return createElement(
|
||||
MobileNativeChatDisclosureContext.Provider,
|
||||
{ value: disclosures },
|
||||
project(items, [], [])
|
||||
.filter((message) => message.role === 'system')
|
||||
.map((message) =>
|
||||
createElement(
|
||||
'Row',
|
||||
{ key: message.id, id: message.id },
|
||||
createElement(MobileNativeChatMessage, { message, rowKey: message.id })
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
function draw(items: AgentJournalRenderItem[]): void {
|
||||
act(() => {
|
||||
const element = createElement(Transcript, { items })
|
||||
if (renderer) {
|
||||
renderer.update(element)
|
||||
} else {
|
||||
renderer = create(element)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function summary(id: string): ReactTestInstance {
|
||||
return renderer!.root
|
||||
.find((node) => String(node.type) === 'Row' && node.props.id === id)
|
||||
.find((node) => String(node.type) === 'Pressable')
|
||||
}
|
||||
|
||||
function bodyText(id: string): string {
|
||||
return renderer!.root
|
||||
.find((node) => String(node.type) === 'Row' && node.props.id === id)
|
||||
.findAll((node) => String(node.type) === 'ScrollView')
|
||||
.flatMap((box) => box.findAll((node) => String(node.type) === 'Text'))
|
||||
.flatMap((node) => node.children.filter((part) => typeof part === 'string'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
it('keeps an opened retry row open through new attempts, older pages and trims that keep its attempt', () => {
|
||||
const [r1, r2, r3, r4, r6] = ['r1', 'r2', 'r3', 'r4', 'r6'].map(codexRetry)
|
||||
draw([r2!, r3!])
|
||||
expect(summary('r3').props['aria-expanded']).toBe(false)
|
||||
act(() => summary('r3').props.onPress())
|
||||
expect(summary('r3').props['aria-expanded']).toBe(true)
|
||||
|
||||
draw([r1!, r2!, r3!])
|
||||
expect(summary('r3').props['aria-expanded']).toBe(true)
|
||||
|
||||
draw([r1!, r2!, r3!, r4!])
|
||||
expect(summary('r4').props['aria-expanded']).toBe(true)
|
||||
expect(bodyText('r4')).toContain('Codex is retrying: stream disconnected r4.')
|
||||
|
||||
draw([r3!, r4!])
|
||||
expect(summary('r4').props['aria-expanded']).toBe(true)
|
||||
|
||||
draw([r3!, r4!, reply('r5'), r6!])
|
||||
expect(summary('r4').props['aria-expanded']).toBe(true)
|
||||
expect(summary('r6').props['aria-expanded']).toBe(false)
|
||||
|
||||
// The attempt that held the choice is no longer loaded: the documented reset.
|
||||
draw([r4!])
|
||||
expect(summary('r4').props['aria-expanded']).toBe(false)
|
||||
})
|
||||
|
||||
it('shows a retry with no provider frame as its own text once opened', () => {
|
||||
draw([claudeRetry])
|
||||
act(() => summary('c1').props.onPress())
|
||||
expect(bodyText('c1')).toBe(
|
||||
'Claude hit a temporary problem and is retrying. Retry 2 of 10.\nLast error: HTTP 529 overloaded.'
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
' Codex is retrying: connection interrupted. ',
|
||||
'connection interrupted',
|
||||
'Codex is retrying: connection interrupted.'
|
||||
],
|
||||
['Retry scheduled.', 'connection interrupted', 'Retry scheduled.\n\nconnection interrupted']
|
||||
])(
|
||||
'shows distinct person-facing retry detail without repeating embedded detail',
|
||||
(text, detail, expected) => {
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatCollapsedNotice, {
|
||||
notice: {
|
||||
type: 'text',
|
||||
tone: 'warning',
|
||||
text,
|
||||
failure: { kind: 'providerRetrying', detail: { text: detail, audience: 'person' } }
|
||||
},
|
||||
look: { kind: 'collapsed-notice', label: 'retrying' },
|
||||
fontScale: 1
|
||||
})
|
||||
)
|
||||
})
|
||||
act(() => renderer!.root.find((node) => String(node.type) === 'Pressable').props.onPress())
|
||||
const body = renderer!.root
|
||||
.find((node) => String(node.type) === 'ScrollView')
|
||||
.findAll((node) => String(node.type) === 'Text')
|
||||
.flatMap((node) => node.children.filter((part) => typeof part === 'string'))
|
||||
.join('')
|
||||
expect(body.trim()).toBe(expected)
|
||||
}
|
||||
)
|
||||
@@ -6,6 +6,11 @@ import { splitNativeChatBlocks } from '../../../../../src/shared/native-chat/too
|
||||
import { nativeChatToolRunViewedImages } from '../../../../../src/shared/native-chat/tools/native-chat-tool-viewed-images'
|
||||
import { selectActiveToolCall } from '../../../../../src/shared/native-chat/tools/native-chat-tool-activity'
|
||||
import * as Chat from '../../../../../src/shared/native-chat/native-chat-types'
|
||||
import {
|
||||
nativeChatNoticeBlock,
|
||||
nativeChatNoticeLook,
|
||||
nativeChatNoticeDisclosureKeys
|
||||
} from '../../../../../src/shared/native-chat/native-chat-notice'
|
||||
import {
|
||||
isRenderableSubagentGroup,
|
||||
withoutSubagentGroupTwins
|
||||
@@ -287,13 +292,15 @@ function MobileNativeChatMessageImpl({
|
||||
/>
|
||||
)
|
||||
}
|
||||
const notice = isSystem ? nativeChatNoticeBlock(drawnProse) : undefined
|
||||
const collapsedNotice = notice && nativeChatNoticeLook(notice).kind === 'collapsed-notice'
|
||||
const proseRows = (blocks: Chat.NativeChatBlock[]) =>
|
||||
blocks.map((block, index) => proseRow(block, index, Chat.isTextBlock(block) ? block.text : ''))
|
||||
return (
|
||||
<>
|
||||
{turnStatusAbove ? statusRow : null}
|
||||
<View
|
||||
style={[styles.row, isUser && styles.rowUser]}
|
||||
style={[styles.row, collapsedNotice && styles.collapsedNoticeRow, isUser && styles.rowUser]}
|
||||
testID={mobileNativeChatRowId(rowKey ?? message.id)}
|
||||
>
|
||||
<Content onLongPress={onLongPress} style={[styles.content, isUser && styles.userBubble]}>
|
||||
@@ -320,6 +327,7 @@ function MobileNativeChatMessageImpl({
|
||||
<MobileNativeChatNoticeRow
|
||||
blocks={drawnProse}
|
||||
fontScale={fontScale}
|
||||
disclosureKeys={nativeChatNoticeDisclosureKeys(message)}
|
||||
onLongPress={onLongPress}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import { Pressable, Text, View, type StyleProp, type ViewStyle } from 'react-native'
|
||||
import { ChevronRight } from 'lucide-react-native'
|
||||
import { colors, pressedStyle, spacing, typography } from '../../../theme/mobile-theme'
|
||||
import { themedStyleSheet } from '../../../theme/themed-styles'
|
||||
@@ -12,13 +12,15 @@ export function MobileNativeChatNoticeDisclosure({
|
||||
expanded,
|
||||
fontScale,
|
||||
onPress,
|
||||
onLongPress
|
||||
onLongPress,
|
||||
style
|
||||
}: {
|
||||
summary: string
|
||||
expanded: boolean
|
||||
fontScale: number
|
||||
onPress: () => void
|
||||
onLongPress?: () => void
|
||||
style?: StyleProp<ViewStyle>
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Pressable
|
||||
@@ -27,7 +29,7 @@ export function MobileNativeChatNoticeDisclosure({
|
||||
aria-expanded={expanded}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
style={({ pressed }) => [styles.disclosure, pressed && pressedStyle]}
|
||||
style={({ pressed }) => [styles.disclosure, style, pressed && pressedStyle]}
|
||||
>
|
||||
<View style={expanded ? toolStyles.caretOpen : undefined}>
|
||||
<ChevronRight size={16 * fontScale} color={colors.textMuted} />
|
||||
|
||||
@@ -13,31 +13,24 @@ import {
|
||||
} from '../../../../../src/shared/native-chat/native-chat-notice'
|
||||
import {
|
||||
isTextBlock,
|
||||
type NativeChatBlock,
|
||||
type NativeChatTextBlock
|
||||
type NativeChatBlock
|
||||
} from '../../../../../src/shared/native-chat/native-chat-types'
|
||||
import {
|
||||
agentSessionFailureDetailText,
|
||||
readWholeAgentSessionFailureFact
|
||||
} from '../../../../../src/shared/agent-session/failures/agent-session-failure'
|
||||
import { agentSessionFailureSentence } from '../../../../../src/shared/agent-session/failures/agent-session-failure-words'
|
||||
import { agentSessionSignInCommand } from '../../../../../src/shared/agent-session/lifecycle/agent-session-setup-commands'
|
||||
import { nativeChatOrcaStopRowText } from '../../../../../src/shared/native-chat/native-chat-orca-stop-words'
|
||||
import {
|
||||
MobileNativeChatForkedFromContext,
|
||||
MobileNativeChatNoticeContext,
|
||||
type MobileNativeChatNoticeView
|
||||
MobileNativeChatNoticeContext
|
||||
} from './mobile-native-chat-notice-context'
|
||||
import { mobileNativeChatNoticeLineText } from './mobile-native-chat-notice-line-text'
|
||||
import { MobileNativeChatMarkdown } from './MobileNativeChatMarkdown'
|
||||
import { INLINE_TEXT_SELECTION } from '../../../components/inline-text-selection'
|
||||
import { colors, radii, spacing, typography } from '../../../theme/mobile-theme'
|
||||
import { themedStyleSheet } from '../../../theme/themed-styles'
|
||||
import { useMobileColorScheme } from '../../../theme/use-mobile-color-scheme'
|
||||
import { nativeChatHostStatusCopy, nativeChatNoticeCopy } from './native-chat-notice-copy'
|
||||
import { nativeChatOrcaStopSay } from './native-chat-orca-stop-copy'
|
||||
import { nativeChatUsageLimitText } from './native-chat-usage-limit-copy'
|
||||
import { sayMobileAgentSessionFailure } from '../agent-session/agent-session-failure-copy'
|
||||
import type { Translate } from '../../../i18n/mobile-i18n'
|
||||
import { useMobileTranslation } from '../../../i18n/use-mobile-translation'
|
||||
import { NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION } from '../../../../../src/shared/native-chat/native-chat-stopped-before-start'
|
||||
import { MONO_SIZE, TEXT_SIZE, scaledType } from './mobile-native-chat-message-styles'
|
||||
@@ -45,61 +38,25 @@ import { MobileNativeChatOwnedScrollView } from './MobileNativeChatOwnedScrollVi
|
||||
import { toolStyles } from './mobile-native-chat-tool-styles'
|
||||
import { MobileNativeChatUsageLimitStatus } from './MobileNativeChatUsageLimitStatus'
|
||||
import { MobileNativeChatNoticeDisclosure } from './MobileNativeChatNoticeDisclosure'
|
||||
import { MobileNativeChatCollapsedNotice } from './MobileNativeChatCollapsedNotice'
|
||||
import { sheetPrimaryButtonStyles } from '../../../components/SheetPrimaryButton'
|
||||
|
||||
function toneColor(tone: NativeChatNoticeTone): string {
|
||||
return { warning: colors.statusAmber, error: colors.statusRed, notice: colors.textMuted }[tone]
|
||||
}
|
||||
|
||||
// The phone always reads its Orca host as remote.
|
||||
function noticeLineText(
|
||||
notice: NativeChatTextBlock,
|
||||
names: MobileNativeChatNoticeView,
|
||||
t: Translate
|
||||
): string {
|
||||
if (notice.orcaStop) {
|
||||
const say = nativeChatOrcaStopSay(t)
|
||||
return nativeChatOrcaStopRowText(
|
||||
notice.orcaStop.cause,
|
||||
names.hostName ?? say('unnamedServer'),
|
||||
{ continueAvailable: names.continuation?.continueAvailable === true, remoteHost: true },
|
||||
say
|
||||
)
|
||||
}
|
||||
const failure = readWholeAgentSessionFailureFact(notice.failure)
|
||||
if (failure?.kind === 'usageLimitReached') {
|
||||
return nativeChatUsageLimitText(failure.usageLimit, names.agentName, t)
|
||||
}
|
||||
// Only reword text fully described by its fact; other host text stays as written.
|
||||
const context = { agentName: names.agentName }
|
||||
const surface =
|
||||
failure &&
|
||||
[
|
||||
'savedConversationMissing',
|
||||
'pluginInputRequired',
|
||||
'modelUnavailable',
|
||||
'providerUnavailable',
|
||||
'providerSafetyStopped',
|
||||
'providerResponseFailed'
|
||||
].includes(failure.kind)
|
||||
? (['row', 'rejection'] as const).find(
|
||||
(candidate) => notice.text === agentSessionFailureSentence(failure, candidate, context)
|
||||
)
|
||||
: undefined
|
||||
return failure && surface
|
||||
? agentSessionFailureSentence(failure, surface, context, sayMobileAgentSessionFailure)
|
||||
: notice.text
|
||||
}
|
||||
|
||||
// Render system notices separately so they cannot read as the agent’s own prose.
|
||||
export function MobileNativeChatNoticeRow({
|
||||
blocks,
|
||||
fontScale,
|
||||
onLongPress
|
||||
onLongPress,
|
||||
disclosureKeys
|
||||
}: {
|
||||
blocks: readonly NativeChatBlock[]
|
||||
fontScale: number
|
||||
onLongPress?: () => void
|
||||
/** Where a collapsed notice's open choice is kept, newest first. */
|
||||
disclosureKeys?: readonly string[]
|
||||
}): React.JSX.Element | null {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
@@ -122,7 +79,7 @@ export function MobileNativeChatNoticeRow({
|
||||
) : null
|
||||
}
|
||||
const look = nativeChatNoticeLook(notice)
|
||||
const lineText = noticeLineText(notice, names, t)
|
||||
const lineText = mobileNativeChatNoticeLineText(notice, names, t)
|
||||
const failure = readWholeAgentSessionFailureFact(notice.failure)
|
||||
const failureDetail = agentSessionFailureDetailText(failure)
|
||||
const signInCommand =
|
||||
@@ -193,6 +150,16 @@ export function MobileNativeChatNoticeRow({
|
||||
</View>
|
||||
)
|
||||
}
|
||||
case 'collapsed-notice':
|
||||
return (
|
||||
<MobileNativeChatCollapsedNotice
|
||||
notice={notice}
|
||||
look={look}
|
||||
fontScale={fontScale}
|
||||
onLongPress={onLongPress}
|
||||
disclosureKeys={disclosureKeys}
|
||||
/>
|
||||
)
|
||||
case 'divider': {
|
||||
const label = copy[look.label]
|
||||
return (
|
||||
|
||||
@@ -77,13 +77,20 @@ export function useMobileNativeChatDisclosures(scopeKey: string): MobileNativeCh
|
||||
|
||||
export function useMobileNativeChatDisclosure(
|
||||
key: string | undefined,
|
||||
initialOpen: boolean
|
||||
initialOpen: boolean,
|
||||
/** Read-only, in order, when `key` holds no choice: a replaced row's keys. */
|
||||
inheritedKeys?: readonly string[]
|
||||
): [boolean, (next: boolean) => void] {
|
||||
const store = useContext(MobileNativeChatDisclosureContext)
|
||||
const [local, setLocal] = useState({ key, initialOpen, open: initialOpen })
|
||||
const localOpen =
|
||||
local.key === key && local.initialOpen === initialOpen ? local.open : initialOpen
|
||||
const open = key !== undefined && store ? (store.read(key) ?? localOpen) : localOpen
|
||||
const open =
|
||||
key !== undefined && store
|
||||
? (store.read(key) ??
|
||||
inheritedKeys?.map(store.read).find((choice) => choice !== undefined) ??
|
||||
localOpen)
|
||||
: localOpen
|
||||
const write = store?.write
|
||||
const setOpen = useCallback(
|
||||
(next: boolean) => {
|
||||
|
||||
@@ -16,6 +16,9 @@ export const styles = themedStyleSheet(() => ({
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm
|
||||
},
|
||||
collapsedNoticeRow: {
|
||||
paddingVertical: 0
|
||||
},
|
||||
rowUser: {
|
||||
alignItems: 'flex-end'
|
||||
},
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { readWholeAgentSessionFailureFact } from '../../../../../src/shared/agent-session/failures/agent-session-failure'
|
||||
import { agentSessionFailureSentence } from '../../../../../src/shared/agent-session/failures/agent-session-failure-words'
|
||||
import { nativeChatOrcaStopRowText } from '../../../../../src/shared/native-chat/native-chat-orca-stop-words'
|
||||
import type { NativeChatTextBlock } from '../../../../../src/shared/native-chat/native-chat-types'
|
||||
import type { Translate } from '../../../i18n/mobile-i18n'
|
||||
import { sayMobileAgentSessionFailure } from '../agent-session/agent-session-failure-copy'
|
||||
import type { MobileNativeChatNoticeView } from './mobile-native-chat-notice-context'
|
||||
import { nativeChatOrcaStopSay } from './native-chat-orca-stop-copy'
|
||||
import { nativeChatUsageLimitText } from './native-chat-usage-limit-copy'
|
||||
|
||||
// The phone always reads its Orca host as remote.
|
||||
export function mobileNativeChatNoticeLineText(
|
||||
notice: NativeChatTextBlock,
|
||||
names: MobileNativeChatNoticeView,
|
||||
t: Translate
|
||||
): string {
|
||||
if (notice.orcaStop) {
|
||||
const say = nativeChatOrcaStopSay(t)
|
||||
return nativeChatOrcaStopRowText(
|
||||
notice.orcaStop.cause,
|
||||
names.hostName ?? say('unnamedServer'),
|
||||
{ continueAvailable: names.continuation?.continueAvailable === true, remoteHost: true },
|
||||
say
|
||||
)
|
||||
}
|
||||
const failure = readWholeAgentSessionFailureFact(notice.failure)
|
||||
if (failure?.kind === 'usageLimitReached') {
|
||||
return nativeChatUsageLimitText(failure.usageLimit, names.agentName, t)
|
||||
}
|
||||
// Only reword text fully described by its fact; other host text stays as written.
|
||||
const context = { agentName: names.agentName }
|
||||
const surface =
|
||||
failure &&
|
||||
[
|
||||
'savedConversationMissing',
|
||||
'pluginInputRequired',
|
||||
'modelUnavailable',
|
||||
'providerUnavailable',
|
||||
'providerSafetyStopped',
|
||||
'providerResponseFailed'
|
||||
].includes(failure.kind)
|
||||
? (['row', 'rejection'] as const).find(
|
||||
(candidate) => notice.text === agentSessionFailureSentence(failure, candidate, context)
|
||||
)
|
||||
: undefined
|
||||
return failure && surface
|
||||
? agentSessionFailureSentence(failure, surface, context, sayMobileAgentSessionFailure)
|
||||
: notice.text
|
||||
}
|
||||
+32
-4
@@ -50,18 +50,46 @@ export function NativeChatDetailsDisclosure({
|
||||
summary,
|
||||
lead,
|
||||
trail,
|
||||
className,
|
||||
summaryClassName,
|
||||
bodyClassName,
|
||||
bodyAs = 'pre',
|
||||
open,
|
||||
onOpenChange,
|
||||
children
|
||||
}: {
|
||||
summary: string
|
||||
lead?: React.ReactNode
|
||||
trail?: React.ReactNode
|
||||
className?: string
|
||||
summaryClassName?: string
|
||||
bodyClassName?: string
|
||||
bodyAs?: 'pre' | 'div'
|
||||
/** Given with `onOpenChange` when the reader's choice is kept outside the row. */
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const Body = bodyAs
|
||||
return (
|
||||
<details className="group text-xs text-chat-foreground-faint">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2 rounded-md px-2 py-1 font-mono hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<details
|
||||
className={cn('group text-xs text-chat-foreground-faint', className)}
|
||||
open={open}
|
||||
onToggle={(event) => {
|
||||
// Mounting already open also fires; only a reader's toggle is a choice.
|
||||
if (event.currentTarget.open !== open) {
|
||||
onOpenChange?.(event.currentTarget.open)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<summary
|
||||
aria-expanded={open}
|
||||
title={summary}
|
||||
className={cn(
|
||||
'flex cursor-pointer list-none items-center gap-2 rounded-md px-2 py-1 font-mono hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring',
|
||||
summaryClassName
|
||||
)}
|
||||
>
|
||||
<span className="transition-transform motion-reduce:transition-none group-open:rotate-90">
|
||||
›
|
||||
</span>
|
||||
@@ -69,7 +97,7 @@ export function NativeChatDetailsDisclosure({
|
||||
<span className="min-w-0 truncate">{summary}</span>
|
||||
{trail}
|
||||
</summary>
|
||||
<pre
|
||||
<Body
|
||||
data-native-chat-code-content
|
||||
className={cn(
|
||||
'scrollbar-sleek mt-1 max-h-64 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-muted p-2 font-mono text-chat-code text-foreground',
|
||||
@@ -77,7 +105,7 @@ export function NativeChatDetailsDisclosure({
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
</Body>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
+8
-2
@@ -54,7 +54,9 @@ export type NativeChatDisclosure = {
|
||||
|
||||
export function useNativeChatDisclosure(
|
||||
key: string | undefined,
|
||||
initialOpen: boolean
|
||||
initialOpen: boolean,
|
||||
/** Read-only, in order, when `key` holds no choice: a replaced row's keys. */
|
||||
inheritedKeys?: readonly string[]
|
||||
): NativeChatDisclosure {
|
||||
const store = useContext(NativeChatDisclosureContext)
|
||||
const [local, setLocal] = useState({ key, initialOpen, open: initialOpen })
|
||||
@@ -62,7 +64,11 @@ export function useNativeChatDisclosure(
|
||||
const isStored = key !== undefined && store !== null
|
||||
const localOpen =
|
||||
local.key === key && local.initialOpen === initialOpen ? local.open : initialOpen
|
||||
const open = isStored ? (store.read(key) ?? localOpen) : localOpen
|
||||
const open = isStored
|
||||
? (store.read(key) ??
|
||||
inheritedKeys?.map(store.read).find((choice) => choice !== undefined) ??
|
||||
localOpen)
|
||||
: localOpen
|
||||
const setOpen = useCallback(
|
||||
(next: boolean) => {
|
||||
setLocal({ key, initialOpen, open: next })
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import { useTranslate, type Translate } from '@/i18n/use-translate'
|
||||
import { cn } from '@/lib/class-names'
|
||||
import { agentSessionFailureDetailText } from '../../../../../../shared/agent-session/failures/agent-session-failure-quoted-detail'
|
||||
import { readWholeAgentSessionFailureFact } from '../../../../../../shared/agent-session/failures/agent-session-failure'
|
||||
import type { NativeChatNoticeLook } from '../../../../../../shared/native-chat/native-chat-notice'
|
||||
import type { NativeChatTextBlock } from '../../../../../../shared/native-chat/native-chat-types'
|
||||
import { NativeChatDetailsDisclosure } from '../diagnostics/NativeChatProviderDetails'
|
||||
import { useNativeChatDisclosure } from '../disclosure/native-chat-disclosure-store'
|
||||
|
||||
type CollapsedNoticeLook = Extract<NativeChatNoticeLook, { kind: 'collapsed-notice' }>
|
||||
|
||||
function collapsedNoticeSummary(look: CollapsedNoticeLook, t: Translate): string {
|
||||
const counts =
|
||||
look.attempt && look.maxRetries ? { attempt: look.attempt, max: look.maxRetries } : undefined
|
||||
switch (look.label) {
|
||||
case 'retrying':
|
||||
return counts
|
||||
? t(
|
||||
'components.native-chat.notices.retryingCount',
|
||||
'Retrying ({{attempt}} of {{max}}). View details.',
|
||||
counts
|
||||
)
|
||||
: t('components.native-chat.notices.retrying', 'Retrying. View details.')
|
||||
case 'rate-limited':
|
||||
return counts
|
||||
? t(
|
||||
'components.native-chat.notices.rateLimitedRetryingCount',
|
||||
'Rate limited, retrying ({{attempt}} of {{max}}). View details.',
|
||||
counts
|
||||
)
|
||||
: t(
|
||||
'components.native-chat.notices.rateLimitedRetrying',
|
||||
'Rate limited, retrying. View details.'
|
||||
)
|
||||
case 'configuration':
|
||||
return t(
|
||||
'components.native-chat.notices.codexConfigurationWarning',
|
||||
'Codex configuration warning. View details.'
|
||||
)
|
||||
case 'deprecation':
|
||||
return t(
|
||||
'components.native-chat.notices.codexDeprecationNotice',
|
||||
'Codex deprecation notice. View details.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A retry or Codex configuration/deprecation notice: one summary line that opens the host's own text. */
|
||||
export function NativeChatCollapsedNotice({
|
||||
block,
|
||||
look,
|
||||
disclosureKeys
|
||||
}: {
|
||||
block: NativeChatTextBlock
|
||||
look: CollapsedNoticeLook
|
||||
/** Where the open choice is kept, newest first. */
|
||||
disclosureKeys?: readonly string[]
|
||||
}): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const disclosure = useNativeChatDisclosure(disclosureKeys?.[0], false, disclosureKeys)
|
||||
const payload = block.providerFrame?.payload
|
||||
const failureDetail = agentSessionFailureDetailText(
|
||||
readWholeAgentSessionFailureFact(block.failure)
|
||||
)
|
||||
const detail =
|
||||
failureDetail && !block.text.trim().includes(failureDetail.trim()) ? failureDetail : undefined
|
||||
return (
|
||||
<NativeChatDetailsDisclosure
|
||||
summary={collapsedNoticeSummary(look, t)}
|
||||
bodyAs="div"
|
||||
summaryClassName={
|
||||
look.label === 'configuration' ? '-ml-0.5 border-l-2 border-chat-warning' : undefined
|
||||
}
|
||||
// The payload scrolls on its own, so the box around it does not.
|
||||
bodyClassName={cn(
|
||||
'font-sans text-sm [overflow-wrap:anywhere]',
|
||||
payload && 'max-h-none overflow-visible'
|
||||
)}
|
||||
open={disclosure.open}
|
||||
onOpenChange={disclosure.setOpen}
|
||||
>
|
||||
{block.text}
|
||||
{detail ? `\n\n${detail}` : ''}
|
||||
{payload ? (
|
||||
<>
|
||||
<span className="mt-2 block text-xs text-muted-foreground">
|
||||
{t('components.native-chat.notices.providerResponse', 'Provider response')}
|
||||
</span>
|
||||
<span className="scrollbar-sleek mt-1 block max-h-60 overflow-y-auto font-mono text-chat-code">
|
||||
{payload.truncated
|
||||
? `${t(
|
||||
'components.native-chat.notices.truncatedResponse',
|
||||
'Provider response truncated (original size: {{bytes}} bytes).',
|
||||
{ bytes: payload.byteLength }
|
||||
)}\n`
|
||||
: ''}
|
||||
{payload.head}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</NativeChatDetailsDisclosure>
|
||||
)
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { agentSessionFailureWords } from '../../../../../../shared/agent-session/failures/agent-session-failure-words'
|
||||
import type { AgentJournalRenderItem } from '../../../../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import { createStructuredAgentSessionMessageProjection } from '../../../../../../shared/agent-session/view/structured-agent-session-message-stream'
|
||||
import {
|
||||
nativeChatNoticeBlock,
|
||||
nativeChatNoticeDisclosureKeys
|
||||
} from '../../../../../../shared/native-chat/native-chat-notice'
|
||||
import {
|
||||
NativeChatDisclosureContext,
|
||||
useNativeChatDisclosures
|
||||
} from '../disclosure/native-chat-disclosure-store'
|
||||
import { NativeChatNoticeRow } from './NativeChatNoticeRow'
|
||||
import { NativeChatCollapsedNotice } from './NativeChatCollapsedNotice'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function item(itemId: string, body: AgentJournalRenderItem['body']): AgentJournalRenderItem {
|
||||
const sequence = Number(itemId.slice(1))
|
||||
return { itemId, revision: 1, sequence, observedAt: sequence, body }
|
||||
}
|
||||
|
||||
function codexRetry(itemId: string): AgentJournalRenderItem {
|
||||
const head = `{"error":{"message":"stream disconnected ${itemId}"},"willRetry":true}`
|
||||
return item(itemId, {
|
||||
kind: 'status',
|
||||
tone: 'warning',
|
||||
...agentSessionFailureWords(
|
||||
{
|
||||
kind: 'providerRetrying',
|
||||
detail: { text: `stream disconnected ${itemId}`, audience: 'person' }
|
||||
},
|
||||
{ surface: 'row', agentName: 'Codex' }
|
||||
),
|
||||
providerFrame: {
|
||||
provider: 'codex',
|
||||
kind: 'notification:error',
|
||||
payload: { head, byteLength: head.length, digest: 'synthetic', truncated: false }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const reply = (itemId: string): AgentJournalRenderItem =>
|
||||
item(itemId, {
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: 'Partial answer' }]
|
||||
})
|
||||
|
||||
const project = createStructuredAgentSessionMessageProjection()
|
||||
|
||||
function Transcript({ items }: { items: AgentJournalRenderItem[] }) {
|
||||
const disclosures = useNativeChatDisclosures()
|
||||
return (
|
||||
<NativeChatDisclosureContext.Provider value={disclosures}>
|
||||
{project(items, [], []).map((message) => {
|
||||
const notice = nativeChatNoticeBlock(message.blocks)
|
||||
return notice ? (
|
||||
<div key={message.id} data-row={message.id}>
|
||||
<NativeChatNoticeRow
|
||||
block={notice}
|
||||
messageId={message.id}
|
||||
disclosureKeys={nativeChatNoticeDisclosureKeys(message)}
|
||||
/>
|
||||
</div>
|
||||
) : null
|
||||
})}
|
||||
</NativeChatDisclosureContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
it('keeps an opened retry row open through new attempts, older pages and trims that keep its attempt', () => {
|
||||
const [r1, r2, r3, r4, r6] = ['r1', 'r2', 'r3', 'r4', 'r6'].map(codexRetry)
|
||||
const { container, rerender } = render(<Transcript items={[r2, r3]} />)
|
||||
const row = (id: string) =>
|
||||
container.querySelector<HTMLDetailsElement>(`[data-row="${id}"] details`)!
|
||||
expect(row('r3').open).toBe(false)
|
||||
fireEvent.click(row('r3').querySelector('summary')!)
|
||||
expect(row('r3').open).toBe(true)
|
||||
|
||||
rerender(<Transcript items={[r1!, r2!, r3!]} />)
|
||||
expect(row('r3').open).toBe(true)
|
||||
|
||||
rerender(<Transcript items={[r1!, r2!, r3!, r4!]} />)
|
||||
expect(row('r4').open).toBe(true)
|
||||
expect(row('r4')).toHaveTextContent('Codex is retrying: stream disconnected r4.')
|
||||
|
||||
rerender(<Transcript items={[r3!, r4!]} />)
|
||||
expect(row('r4').open).toBe(true)
|
||||
|
||||
rerender(<Transcript items={[r3!, r4!, reply('r5'), r6!]} />)
|
||||
expect(row('r4').open).toBe(true)
|
||||
expect(row('r6').open).toBe(false)
|
||||
|
||||
// The attempt that held the choice is no longer loaded: the documented reset.
|
||||
rerender(<Transcript items={[r4!]} />)
|
||||
expect(row('r4').open).toBe(false)
|
||||
})
|
||||
|
||||
it('shows a retry with no provider frame as its own text once opened', () => {
|
||||
const { container } = render(
|
||||
<NativeChatNoticeRow
|
||||
block={{
|
||||
type: 'text',
|
||||
tone: 'warning',
|
||||
text: 'Claude hit a temporary problem and is retrying. Retry 2 of 10.\nLast error: HTTP 529 overloaded.',
|
||||
failure: {
|
||||
kind: 'providerRetrying',
|
||||
detail: { text: '{"type":"system","subtype":"api_retry"}', audience: 'log' },
|
||||
retry: { error: 'overloaded', status: 529, attempt: 2, maxRetries: 10 }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
const details = container.querySelector('details')!
|
||||
expect(details.open).toBe(false)
|
||||
expect(details.querySelector('[data-native-chat-code-content]')).toHaveTextContent(
|
||||
'Claude hit a temporary problem and is retrying. Retry 2 of 10. Last error: HTTP 529 overloaded.'
|
||||
)
|
||||
expect(details.querySelector('[data-native-chat-code-content]')).not.toHaveTextContent(
|
||||
'api_retry'
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
' Codex is retrying: connection interrupted. ',
|
||||
'connection interrupted',
|
||||
'Codex is retrying: connection interrupted.'
|
||||
],
|
||||
['Retry scheduled.', 'connection interrupted', 'Retry scheduled.\n\nconnection interrupted']
|
||||
])(
|
||||
'shows distinct person-facing retry detail without repeating embedded detail',
|
||||
(text, detail, expected) => {
|
||||
const { container } = render(
|
||||
<NativeChatCollapsedNotice
|
||||
look={{ kind: 'collapsed-notice', label: 'retrying' }}
|
||||
block={{
|
||||
type: 'text',
|
||||
tone: 'warning',
|
||||
text,
|
||||
failure: { kind: 'providerRetrying', detail: { text: detail, audience: 'person' } }
|
||||
}}
|
||||
/>
|
||||
)
|
||||
expect(container.querySelector('[data-native-chat-code-content]')?.textContent?.trim()).toBe(
|
||||
expected
|
||||
)
|
||||
}
|
||||
)
|
||||
+105
@@ -1,3 +1,8 @@
|
||||
import '../../appearance/native-chat-appearance.css'
|
||||
import {
|
||||
NativeChatDisclosureContext,
|
||||
useNativeChatDisclosures
|
||||
} from '../disclosure/native-chat-disclosure-store'
|
||||
import { defineFixture } from '../../../../component-fixtures/component-fixture'
|
||||
import { i18n } from '../../../../i18n/i18n'
|
||||
import { agentSessionFailureFact } from '../../../../../../shared/agent-session/failures/agent-session-failure'
|
||||
@@ -92,3 +97,103 @@ export const guardianReviewsExpanded = defineFixture({
|
||||
frame.querySelectorAll('summary').forEach((summary) => summary.click())
|
||||
}
|
||||
})
|
||||
|
||||
function codexFrame(kind: string, head: string, byteLength = head.length) {
|
||||
return {
|
||||
provider: 'codex',
|
||||
kind,
|
||||
payload: { head, byteLength, digest: 'synthetic', truncated: byteLength > head.length }
|
||||
}
|
||||
}
|
||||
|
||||
const collapsedNotices: NativeChatTextBlock[] = [
|
||||
{
|
||||
type: 'text',
|
||||
tone: 'warning',
|
||||
text: 'Claude hit a temporary problem and is retrying. Retry 2 of 10.\nLast error: HTTP 529 overloaded.',
|
||||
failure: {
|
||||
kind: 'providerRetrying',
|
||||
detail: { text: '{"type":"system","subtype":"api_retry"}', audience: 'log' },
|
||||
retry: { error: 'overloaded', status: 529, attempt: 2, maxRetries: 10 }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
tone: 'warning',
|
||||
text: 'Codex is retrying: stream disconnected before completion.',
|
||||
failure: {
|
||||
kind: 'providerRetrying',
|
||||
detail: { text: 'stream disconnected before completion', audience: 'person' }
|
||||
},
|
||||
providerFrame: codexFrame(
|
||||
'notification:error',
|
||||
JSON.stringify({
|
||||
error: { message: 'stream disconnected before completion', codexErrorInfo: 'other' },
|
||||
willRetry: true,
|
||||
threadId: 'synthetic-thread-0000000000000000000000000000000000000000000000000000'
|
||||
}).repeat(12),
|
||||
48_213
|
||||
)
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
tone: 'warning',
|
||||
text: 'Unknown key `experimental_resume` in config.toml; it will be ignored.',
|
||||
providerFrame: codexFrame(
|
||||
'notification:configWarning',
|
||||
'{"summary":"Unknown key `experimental_resume` in config.toml; it will be ignored."}'
|
||||
)
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
tone: 'notice',
|
||||
text: '`approval_policy = "on-failure"` is deprecated and will be removed.',
|
||||
providerFrame: codexFrame(
|
||||
'notification:deprecationNotice',
|
||||
'{"summary":"`approval_policy = \\"on-failure\\"` is deprecated and will be removed."}'
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
function CollapsedNotices() {
|
||||
return (
|
||||
<div className="native-chat-appearance space-y-2 p-4">
|
||||
{collapsedNotices.map((block) => (
|
||||
<NativeChatNoticeRow key={block.text} block={block} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const collapsedNoticesClosed = defineFixture({
|
||||
width: 720,
|
||||
render: () => <CollapsedNotices />
|
||||
})
|
||||
|
||||
export const collapsedNoticesOpened = defineFixture({
|
||||
width: 720,
|
||||
render: () => <CollapsedNotices />,
|
||||
interact: (frame) => {
|
||||
frame.querySelectorAll('summary').forEach((summary) => summary.click())
|
||||
}
|
||||
})
|
||||
|
||||
function StoredRetryNotice() {
|
||||
const store = useNativeChatDisclosures()
|
||||
return (
|
||||
<NativeChatDisclosureContext.Provider value={store}>
|
||||
<div className="native-chat-appearance p-4">
|
||||
<NativeChatNoticeRow
|
||||
block={collapsedNotices[1]!}
|
||||
disclosureKeys={['synthetic-retry:notice']}
|
||||
/>
|
||||
<output data-disclosure-choice={String(store.read('synthetic-retry:notice'))} />
|
||||
</div>
|
||||
</NativeChatDisclosureContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const storedRetryNotice = defineFixture({
|
||||
width: 720,
|
||||
render: () => <StoredRetryNotice />
|
||||
})
|
||||
|
||||
@@ -41,6 +41,7 @@ import { readWholeAgentSessionFailureFact } from '../../../../../../shared/agent
|
||||
import { agentSessionFailureSentence } from '../../../../../../shared/agent-session/failures/agent-session-failure-words'
|
||||
import { sayAgentSessionFailureTranslated } from './agent-session-failure-words-text'
|
||||
import { nativeChatAntigravitySignInContent } from '../../host-access/native-chat-antigravity-settings'
|
||||
import { NativeChatCollapsedNotice } from './NativeChatCollapsedNotice'
|
||||
import { formatUsageLimitResetClock } from '../../../../../../shared/agent-session/failures/agent-session-usage-limit'
|
||||
|
||||
const HOST_STATUS_WORDS: Record<AgentSessionHostStatusPresentation, () => string> = {
|
||||
@@ -77,12 +78,15 @@ function toneClass(tone: NativeChatNoticeTone): string {
|
||||
export function NativeChatNoticeRow({
|
||||
block,
|
||||
messageId,
|
||||
agentName
|
||||
agentName,
|
||||
disclosureKeys
|
||||
}: {
|
||||
block: NativeChatTextBlock
|
||||
/** The transcript row's message, so a plan's code blocks keep their wrap past the row's mount. */
|
||||
messageId?: string
|
||||
agentName?: string
|
||||
/** Where a collapsed notice's open choice is kept, newest first. */
|
||||
disclosureKeys?: readonly string[]
|
||||
}): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
useTranslation()
|
||||
@@ -218,6 +222,9 @@ export function NativeChatNoticeRow({
|
||||
</NativeChatDetailsDisclosure>
|
||||
)
|
||||
}
|
||||
if (look.kind === 'collapsed-notice') {
|
||||
return <NativeChatCollapsedNotice block={block} look={look} disclosureKeys={disclosureKeys} />
|
||||
}
|
||||
// Name Orca and its host as the stop cause; without host context, preserve the original words.
|
||||
const { orcaStop } = block
|
||||
const { hostLabel, remoteHost, continueAvailable, wslDistro } = orcaStopView
|
||||
|
||||
@@ -11,7 +11,10 @@ import type {
|
||||
import { deriveNativeChatRowContent } from '../../../../../../shared/native-chat/transcript/native-chat-row-content'
|
||||
import { NativeChatReasoningRow } from '../reasoning/NativeChatReasoningRow'
|
||||
import { NativeChatNoticeRow } from '../notices/NativeChatNoticeRow'
|
||||
import { nativeChatNoticeBlock } from '../../../../../../shared/native-chat/native-chat-notice'
|
||||
import {
|
||||
nativeChatNoticeBlock,
|
||||
nativeChatNoticeDisclosureKeys
|
||||
} from '../../../../../../shared/native-chat/native-chat-notice'
|
||||
import { nativeChatMessageRowBlocks } from './native-chat-message-row-words'
|
||||
import { ProviderFrameRow } from '../diagnostics/NativeChatProviderDetails'
|
||||
import type { NativeChatDiffReveal } from '../../../../../../shared/native-chat/tools/native-chat-turn-diffs'
|
||||
@@ -143,7 +146,12 @@ export const MessageRow = memo(function MessageRow(
|
||||
if (notice) {
|
||||
return (
|
||||
<div ref={rowRef}>
|
||||
<NativeChatNoticeRow block={notice} messageId={message.id} agentName={agentName} />
|
||||
<NativeChatNoticeRow
|
||||
block={notice}
|
||||
messageId={message.id}
|
||||
agentName={agentName}
|
||||
disclosureKeys={nativeChatNoticeDisclosureKeys(message)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19516,6 +19516,13 @@
|
||||
"safetyReviewApproved": "{{agent}} hat diese Aktion genehmigt. Details anzeigen.",
|
||||
"safetyReview": "{{agent}} hat eine Warnung zur Sicherheitsprüfung gemeldet. Details anzeigen.",
|
||||
"truncatedResponse": "Anbieterantwort gekürzt (ursprüngliche Größe: {{bytes}} Bytes).",
|
||||
"retrying": "Neuer Versuch. Details anzeigen.",
|
||||
"retryingCount": "Neuer Versuch ({{attempt}} von {{max}}). Details anzeigen.",
|
||||
"rateLimitedRetrying": "Ratenlimit erreicht, neuer Versuch. Details anzeigen.",
|
||||
"rateLimitedRetryingCount": "Ratenlimit erreicht, neuer Versuch ({{attempt}} von {{max}}). Details anzeigen.",
|
||||
"codexConfigurationWarning": "Codex-Konfigurationswarnung. Details anzeigen.",
|
||||
"codexDeprecationNotice": "Codex-Hinweis auf veraltete Funktion. Details anzeigen.",
|
||||
"providerResponse": "Anbieterantwort",
|
||||
"forkedFrom": "Abgezweigt von"
|
||||
},
|
||||
"composer": {
|
||||
|
||||
@@ -20649,6 +20649,13 @@
|
||||
"safetyReviewApproved": "{{agent}} approved this action. View details.",
|
||||
"safetyReview": "{{agent}} reported a safety review warning. View details.",
|
||||
"truncatedResponse": "Provider response truncated (original size: {{bytes}} bytes).",
|
||||
"retrying": "Retrying. View details.",
|
||||
"retryingCount": "Retrying ({{attempt}} of {{max}}). View details.",
|
||||
"rateLimitedRetrying": "Rate limited, retrying. View details.",
|
||||
"rateLimitedRetryingCount": "Rate limited, retrying ({{attempt}} of {{max}}). View details.",
|
||||
"codexConfigurationWarning": "Codex configuration warning. View details.",
|
||||
"codexDeprecationNotice": "Codex deprecation notice. View details.",
|
||||
"providerResponse": "Provider response",
|
||||
"modelFallback": "Claude switched models: {{from}} → {{to}}.",
|
||||
"forkedFrom": "Forked from"
|
||||
},
|
||||
|
||||
@@ -19757,6 +19757,13 @@
|
||||
"safetyReviewApproved": "{{agent}} aprobó esta acción. Ver detalles.",
|
||||
"safetyReview": "{{agent}} notificó una advertencia de revisión de seguridad. Ver detalles.",
|
||||
"truncatedResponse": "Respuesta del proveedor truncada (tamaño original: {{bytes}} bytes).",
|
||||
"retrying": "Reintentando. Ver detalles.",
|
||||
"retryingCount": "Reintentando ({{attempt}} de {{max}}). Ver detalles.",
|
||||
"rateLimitedRetrying": "Límite de solicitudes alcanzado, reintentando. Ver detalles.",
|
||||
"rateLimitedRetryingCount": "Límite de solicitudes alcanzado, reintentando ({{attempt}} de {{max}}). Ver detalles.",
|
||||
"codexConfigurationWarning": "Advertencia de configuración de Codex. Ver detalles.",
|
||||
"codexDeprecationNotice": "Aviso de obsolescencia de Codex. Ver detalles.",
|
||||
"providerResponse": "Respuesta del proveedor",
|
||||
"forkedFrom": "Rama creada desde"
|
||||
},
|
||||
"providerFrame": {
|
||||
|
||||
@@ -19857,6 +19857,13 @@
|
||||
"safetyReviewApproved": "{{agent}} a approuvé cette action. Voir les détails.",
|
||||
"safetyReview": "{{agent}} a signalé un avertissement de sécurité. Voir les détails.",
|
||||
"truncatedResponse": "Réponse du fournisseur tronquée (taille d’origine : {{bytes}} octets).",
|
||||
"retrying": "Nouvelle tentative. Voir les détails.",
|
||||
"retryingCount": "Nouvelle tentative ({{attempt}} sur {{max}}). Voir les détails.",
|
||||
"rateLimitedRetrying": "Limite de débit atteinte, nouvelle tentative. Voir les détails.",
|
||||
"rateLimitedRetryingCount": "Limite de débit atteinte, nouvelle tentative ({{attempt}} sur {{max}}). Voir les détails.",
|
||||
"codexConfigurationWarning": "Avertissement de configuration Codex. Voir les détails.",
|
||||
"codexDeprecationNotice": "Avis d’obsolescence Codex. Voir les détails.",
|
||||
"providerResponse": "Réponse du fournisseur",
|
||||
"forkedFrom": "Branche créée à partir de"
|
||||
},
|
||||
"providerFrame": {
|
||||
|
||||
@@ -19117,6 +19117,13 @@
|
||||
"safetyReviewApproved": "{{agent}} がこの操作を承認しました。詳細を表示。",
|
||||
"safetyReview": "{{agent}} が安全性レビューの警告を報告しました。詳細を表示。",
|
||||
"truncatedResponse": "プロバイダーの応答は切り詰められています(元のサイズ:{{bytes}} バイト)。",
|
||||
"retrying": "再試行中。詳細を表示。",
|
||||
"retryingCount": "再試行中({{attempt}}/{{max}})。詳細を表示。",
|
||||
"rateLimitedRetrying": "レート制限のため再試行中。詳細を表示。",
|
||||
"rateLimitedRetryingCount": "レート制限のため再試行中({{attempt}}/{{max}})。詳細を表示。",
|
||||
"codexConfigurationWarning": "Codex の設定に関する警告。詳細を表示。",
|
||||
"codexDeprecationNotice": "Codex の非推奨に関する通知。詳細を表示。",
|
||||
"providerResponse": "プロバイダーの応答",
|
||||
"forkedFrom": "分岐元"
|
||||
},
|
||||
"providerFrame": {
|
||||
|
||||
@@ -19114,6 +19114,13 @@
|
||||
"safetyReviewApproved": "{{agent}}이(가) 이 작업을 승인했습니다. 세부 정보 보기.",
|
||||
"safetyReview": "{{agent}}이(가) 안전 검토 경고를 보고했습니다. 세부 정보 보기.",
|
||||
"truncatedResponse": "공급자 응답이 잘렸습니다(원래 크기: {{bytes}}바이트).",
|
||||
"retrying": "다시 시도 중. 세부 정보 보기.",
|
||||
"retryingCount": "다시 시도 중({{attempt}}/{{max}}). 세부 정보 보기.",
|
||||
"rateLimitedRetrying": "요청 한도 도달, 다시 시도 중. 세부 정보 보기.",
|
||||
"rateLimitedRetryingCount": "요청 한도 도달, 다시 시도 중({{attempt}}/{{max}}). 세부 정보 보기.",
|
||||
"codexConfigurationWarning": "Codex 구성 경고. 세부 정보 보기.",
|
||||
"codexDeprecationNotice": "Codex 지원 중단 알림. 세부 정보 보기.",
|
||||
"providerResponse": "공급자 응답",
|
||||
"forkedFrom": "분기 원본"
|
||||
},
|
||||
"providerFrame": {
|
||||
|
||||
@@ -20712,6 +20712,13 @@
|
||||
"safetyReviewApproved": "{{agent}} aprovou esta ação. Ver detalhes.",
|
||||
"safetyReview": "{{agent}} relatou um aviso de revisão de segurança. Ver detalhes.",
|
||||
"truncatedResponse": "Resposta do provedor truncada (tamanho original: {{bytes}} bytes).",
|
||||
"retrying": "Tentando novamente. Ver detalhes.",
|
||||
"retryingCount": "Tentando novamente ({{attempt}} de {{max}}). Ver detalhes.",
|
||||
"rateLimitedRetrying": "Limite de requisições atingido, tentando novamente. Ver detalhes.",
|
||||
"rateLimitedRetryingCount": "Limite de requisições atingido, tentando novamente ({{attempt}} de {{max}}). Ver detalhes.",
|
||||
"codexConfigurationWarning": "Aviso de configuração do Codex. Ver detalhes.",
|
||||
"codexDeprecationNotice": "Aviso de descontinuação do Codex. Ver detalhes.",
|
||||
"providerResponse": "Resposta do provedor",
|
||||
"forkedFrom": "Ramificado de",
|
||||
"modelFallback": "Claude mudou de modelo: {{from}} → {{to}}."
|
||||
},
|
||||
|
||||
@@ -20084,6 +20084,13 @@
|
||||
"safetyReviewApproved": "{{agent}} одобрил это действие. Подробнее.",
|
||||
"safetyReview": "{{agent}} сообщил о предупреждении проверки безопасности. Подробнее.",
|
||||
"truncatedResponse": "Ответ поставщика обрезан (исходный размер: {{bytes}} байт).",
|
||||
"retrying": "Повторная попытка. Подробнее.",
|
||||
"retryingCount": "Повторная попытка ({{attempt}} из {{max}}). Подробнее.",
|
||||
"rateLimitedRetrying": "Превышен лимит запросов, повторная попытка. Подробнее.",
|
||||
"rateLimitedRetryingCount": "Превышен лимит запросов, повторная попытка ({{attempt}} из {{max}}). Подробнее.",
|
||||
"codexConfigurationWarning": "Предупреждение о конфигурации Codex. Подробнее.",
|
||||
"codexDeprecationNotice": "Уведомление Codex об устаревшей функции. Подробнее.",
|
||||
"providerResponse": "Ответ поставщика",
|
||||
"modelFallback": "Claude переключил модель: {{from}} → {{to}}.",
|
||||
"forkedFrom": "Ответвление от"
|
||||
},
|
||||
|
||||
@@ -19494,6 +19494,13 @@
|
||||
"safetyReviewApproved": "{{agent}} bu işleme onay verdi. Ayrıntıları görüntüleyin.",
|
||||
"safetyReview": "{{agent}} bir güvenlik incelemesi uyarısı bildirdi. Ayrıntıları görüntüleyin.",
|
||||
"truncatedResponse": "Sağlayıcı yanıtı kısaltıldı (özgün boyut: {{bytes}} bayt).",
|
||||
"retrying": "Yeniden deneniyor. Ayrıntıları görüntüleyin.",
|
||||
"retryingCount": "Yeniden deneniyor ({{attempt}}/{{max}}). Ayrıntıları görüntüleyin.",
|
||||
"rateLimitedRetrying": "İstek sınırına ulaşıldı, yeniden deneniyor. Ayrıntıları görüntüleyin.",
|
||||
"rateLimitedRetryingCount": "İstek sınırına ulaşıldı, yeniden deneniyor ({{attempt}}/{{max}}). Ayrıntıları görüntüleyin.",
|
||||
"codexConfigurationWarning": "Codex yapılandırma uyarısı. Ayrıntıları görüntüleyin.",
|
||||
"codexDeprecationNotice": "Codex kullanımdan kaldırma bildirimi. Ayrıntıları görüntüleyin.",
|
||||
"providerResponse": "Sağlayıcı yanıtı",
|
||||
"forkedFrom": "Çatallandığı kaynak"
|
||||
},
|
||||
"composer": {
|
||||
|
||||
@@ -19254,6 +19254,13 @@
|
||||
"safetyReviewApproved": "{{agent}} 已核准此操作。查看詳細資訊。",
|
||||
"safetyReview": "{{agent}} 回報了安全審查警告。查看詳細資訊。",
|
||||
"truncatedResponse": "供應商回應已截斷(原始大小:{{bytes}} 位元組)。",
|
||||
"retrying": "正在重試。查看詳細資訊。",
|
||||
"retryingCount": "正在重試(第 {{attempt}} 次,共 {{max}} 次)。查看詳細資訊。",
|
||||
"rateLimitedRetrying": "已達速率限制,正在重試。查看詳細資訊。",
|
||||
"rateLimitedRetryingCount": "已達速率限制,正在重試(第 {{attempt}} 次,共 {{max}} 次)。查看詳細資訊。",
|
||||
"codexConfigurationWarning": "Codex 設定警告。查看詳細資訊。",
|
||||
"codexDeprecationNotice": "Codex 淘汰通知。查看詳細資訊。",
|
||||
"providerResponse": "供應商回應",
|
||||
"forkedFrom": "分支來源"
|
||||
},
|
||||
"composer": {
|
||||
|
||||
@@ -19375,6 +19375,13 @@
|
||||
"safetyReviewApproved": "{{agent}} 已批准此操作。查看详情。",
|
||||
"safetyReview": "{{agent}} 报告了安全审查警告。查看详情。",
|
||||
"truncatedResponse": "提供方响应已截断(原始大小:{{bytes}} 字节)。",
|
||||
"retrying": "正在重试。查看详情。",
|
||||
"retryingCount": "正在重试(第 {{attempt}} 次,共 {{max}} 次)。查看详情。",
|
||||
"rateLimitedRetrying": "已达速率限制,正在重试。查看详情。",
|
||||
"rateLimitedRetryingCount": "已达速率限制,正在重试(第 {{attempt}} 次,共 {{max}} 次)。查看详情。",
|
||||
"codexConfigurationWarning": "Codex 配置警告。查看详情。",
|
||||
"codexDeprecationNotice": "Codex 弃用通知。查看详情。",
|
||||
"providerResponse": "提供方响应",
|
||||
"forkedFrom": "分支来源"
|
||||
},
|
||||
"providerFrame": {
|
||||
|
||||
@@ -12,6 +12,10 @@ export function withRetryCause(sentence: string, cause: string | undefined): str
|
||||
return cause ? `${sentence}\n${cause}` : sentence
|
||||
}
|
||||
|
||||
export function isProviderRetryRateLimited(retry: AgentSessionProviderRetry | undefined): boolean {
|
||||
return retry?.error === 'rate_limit' || retry?.status === 429
|
||||
}
|
||||
|
||||
export function providerRetryWords(
|
||||
say: AgentSessionFailureSay,
|
||||
agent: AgentSessionFailureCopyValues,
|
||||
@@ -19,12 +23,7 @@ export function providerRetryWords(
|
||||
): string {
|
||||
return withRetryCause(
|
||||
joinSentences([
|
||||
say(
|
||||
retry?.error === 'rate_limit' || retry?.status === 429
|
||||
? 'providerRateLimited'
|
||||
: 'providerRetrying',
|
||||
agent
|
||||
),
|
||||
say(isProviderRetryRateLimited(retry) ? 'providerRateLimited' : 'providerRetrying', agent),
|
||||
...retryNumber(say, retry)
|
||||
]),
|
||||
retry?.cause ?? retryLastError(say, retry)
|
||||
|
||||
@@ -8,7 +8,10 @@ import { STRUCTURED_AGENT_SESSION_SUBMISSION_MISSING } from '../requests/structu
|
||||
import { agentJournalSubmissionKey } from '../journal/agent-session-journal-item-key'
|
||||
import { selectAgentSessionCurrentRequests } from '../requests/agent-session-current-requests'
|
||||
import { isQueuedAgentJournalSubmission } from '../requests/agent-session-queued-submission'
|
||||
import { collapseProviderRetryRuns } from '../../native-chat/native-chat-provider-retry-runs'
|
||||
import {
|
||||
collapseProviderRetryRuns,
|
||||
type NativeChatProviderRetryStamps
|
||||
} from '../../native-chat/native-chat-provider-retry-runs'
|
||||
import {
|
||||
keepStoppedSendsInSendOrder,
|
||||
placeStoppedSends,
|
||||
@@ -117,7 +120,8 @@ export function projectStructuredAgentSessionMessageRows(
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
optimistic: readonly StructuredAgentSessionOptimisticMessage[],
|
||||
submissions: readonly AgentJournalSubmission[],
|
||||
projectItems: StructuredAgentSessionItemProjector
|
||||
projectItems: StructuredAgentSessionItemProjector,
|
||||
retryStamps?: NativeChatProviderRetryStamps
|
||||
): StructuredAgentSessionMessageRows {
|
||||
// Keep stopped-before-start sends in conversation history; queued handoffs stay in their cards.
|
||||
const stoppedBeforeStart = new Map(
|
||||
@@ -198,9 +202,10 @@ export function projectStructuredAgentSessionMessageRows(
|
||||
if (shownStopped.size > 0) {
|
||||
moved = keepStoppedSendsInSendOrder(delivered, stoppedBeforeStart, shownStopped) || moved
|
||||
}
|
||||
const collapsed = collapseProviderRetryRuns(delivered, retryStamps)
|
||||
const ordered = moved
|
||||
? Array.from(collapseProviderRetryRuns(delivered)).sort(compareNativeChatTranscriptMessages)
|
||||
: collapseProviderRetryRuns(delivered)
|
||||
? Array.from(collapsed).sort(compareNativeChatTranscriptMessages)
|
||||
: collapsed
|
||||
const conversation =
|
||||
shownStopped.size > 0 ? withStopRowsAfterStoppedSends(ordered, shownStopped) : ordered
|
||||
// Merge unsent rows at journal positions after stop grouping so each run retains one stop notice.
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
isAgentSessionContextClear,
|
||||
isNativeChatContextClear
|
||||
} from '../agent-session-context-clear'
|
||||
import { isNativeChatProviderRetryRow } from '../../native-chat/native-chat-provider-retry-runs'
|
||||
import {
|
||||
createNativeChatProviderRetryStamps,
|
||||
isNativeChatProviderRetryRow
|
||||
} from '../../native-chat/native-chat-provider-retry-runs'
|
||||
import {
|
||||
compareNativeChatTranscriptMessages,
|
||||
sameNativeChatRowPlacement
|
||||
@@ -171,6 +174,7 @@ export function createStructuredAgentSessionMessageProjection(
|
||||
projectItems: StructuredAgentSessionItemProjector = projectStructuredItemsToNativeChat
|
||||
) {
|
||||
let previous: MessageStream | null = null
|
||||
const retryStamps = createNativeChatProviderRetryStamps()
|
||||
return (
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
optimistic: readonly StructuredAgentSessionOptimisticMessage[],
|
||||
@@ -185,7 +189,13 @@ export function createStructuredAgentSessionMessageProjection(
|
||||
previous =
|
||||
reused ??
|
||||
withRowIndex(
|
||||
projectStructuredAgentSessionMessageRows(items, optimistic, submissions, projectItems),
|
||||
projectStructuredAgentSessionMessageRows(
|
||||
items,
|
||||
optimistic,
|
||||
submissions,
|
||||
projectItems,
|
||||
retryStamps
|
||||
),
|
||||
items,
|
||||
optimistic,
|
||||
submissions
|
||||
|
||||
@@ -35,3 +35,77 @@ it('reads only approved Codex automatic reviews as neutral notices', () => {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const frame = (kind: string): NonNullable<NativeChatTextBlock['providerFrame']> => ({
|
||||
provider: 'codex',
|
||||
kind,
|
||||
payload: { head: '{}', byteLength: 2, digest: 'synthetic', truncated: false }
|
||||
})
|
||||
|
||||
it('collapses only provider retries and Codex configuration or deprecation notices behind a summary', () => {
|
||||
expect(
|
||||
nativeChatNoticeLook({
|
||||
type: 'text',
|
||||
tone: 'warning',
|
||||
text: 'Claude hit a temporary problem and is retrying. Retry 2 of 10.',
|
||||
failure: {
|
||||
kind: 'providerRetrying',
|
||||
detail: { text: '{"type":"system","subtype":"api_retry"}', audience: 'log' },
|
||||
retry: { error: 'overloaded', status: 529, attempt: 2, maxRetries: 10 }
|
||||
}
|
||||
})
|
||||
).toEqual({ kind: 'collapsed-notice', label: 'retrying', attempt: 2, maxRetries: 10 })
|
||||
expect(
|
||||
nativeChatNoticeLook({
|
||||
type: 'text',
|
||||
tone: 'warning',
|
||||
text: 'Claude is rate limited.',
|
||||
failure: { kind: 'providerRetrying', retry: { status: 429, attempt: 1, maxRetries: 10 } }
|
||||
})
|
||||
).toEqual({ kind: 'collapsed-notice', label: 'rate-limited', attempt: 1, maxRetries: 10 })
|
||||
expect(
|
||||
nativeChatNoticeLook({
|
||||
type: 'text',
|
||||
tone: 'warning',
|
||||
text: 'Codex is retrying: Reconnecting... 2/5.',
|
||||
failure: {
|
||||
kind: 'providerRetrying',
|
||||
detail: { text: 'Reconnecting... 2/5', audience: 'person' }
|
||||
},
|
||||
providerFrame: frame('notification:error')
|
||||
})
|
||||
).toEqual({ kind: 'collapsed-notice', label: 'retrying' })
|
||||
expect(
|
||||
nativeChatNoticeLook({
|
||||
type: 'text',
|
||||
tone: 'warning',
|
||||
text: 'Unknown key in config.toml.',
|
||||
providerFrame: frame('notification:configWarning')
|
||||
})
|
||||
).toEqual({ kind: 'collapsed-notice', label: 'configuration' })
|
||||
expect(
|
||||
nativeChatNoticeLook({
|
||||
type: 'text',
|
||||
tone: 'notice',
|
||||
text: 'This option will be removed.',
|
||||
providerFrame: frame('notification:deprecationNotice')
|
||||
})
|
||||
).toEqual({ kind: 'collapsed-notice', label: 'deprecation' })
|
||||
// Errors and other notifications keep the boxed line.
|
||||
expect(
|
||||
nativeChatNoticeLook({
|
||||
type: 'text',
|
||||
tone: 'error',
|
||||
text: 'The configuration could not be read.',
|
||||
providerFrame: frame('notification:configWarning')
|
||||
})
|
||||
).toEqual({ kind: 'line', tone: 'error' })
|
||||
expect(
|
||||
nativeChatNoticeLook({
|
||||
type: 'text',
|
||||
tone: 'warning',
|
||||
text: 'Disk is almost full.',
|
||||
providerFrame: frame('notification:warning')
|
||||
})
|
||||
).toEqual({ kind: 'line', tone: 'warning' })
|
||||
})
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
import { AGENT_SESSION_ORCA_STOP_PRESENTATION } from '../agent-session/lifecycle/agent-session-orca-stop'
|
||||
import { NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION } from './native-chat-stopped-before-start'
|
||||
import { AGENT_SESSION_FORKED_FROM_PRESENTATION } from '../agent-session/agent-session-fork'
|
||||
import type { NativeChatBlock, NativeChatTextBlock } from './native-chat-types'
|
||||
import { readProviderRetry } from '../agent-session/failures/agent-session-failure'
|
||||
import { isProviderRetryRateLimited } from '../agent-session/failures/agent-session-provider-retry-words'
|
||||
import type { NativeChatBlock, NativeChatMessage, NativeChatTextBlock } from './native-chat-types'
|
||||
|
||||
export const NATIVE_CHAT_NOTICE_COPY = {
|
||||
compaction: 'Context compacted',
|
||||
@@ -39,6 +41,13 @@ export type NativeChatNoticeLook =
|
||||
agent: 'Codex' | 'Claude'
|
||||
tone: NativeChatNoticeTone
|
||||
}
|
||||
/** One summary line that opens the host's own text; counts only when the provider gave both. */
|
||||
| {
|
||||
kind: 'collapsed-notice'
|
||||
label: 'retrying' | 'rate-limited' | 'configuration' | 'deprecation'
|
||||
attempt?: number
|
||||
maxRetries?: number
|
||||
}
|
||||
/** A boxed line with its icon when it has a tone; plain text when it has none. */
|
||||
| { kind: 'line'; tone: NativeChatNoticeTone | null }
|
||||
|
||||
@@ -93,9 +102,28 @@ export function nativeChatNoticeLook(block: NativeChatTextBlock): NativeChatNoti
|
||||
if (modelFallback) {
|
||||
return { kind: 'model-fallback', ...modelFallback }
|
||||
}
|
||||
const collapsible = presentation === undefined && !block.orcaStop && block.text.trim() !== ''
|
||||
if (collapsible && block.failure?.kind === 'providerRetrying') {
|
||||
const retry = readProviderRetry(block.failure.retry)
|
||||
return {
|
||||
kind: 'collapsed-notice',
|
||||
label: isProviderRetryRateLimited(retry) ? 'rate-limited' : 'retrying',
|
||||
...(retry?.attempt && retry.maxRetries
|
||||
? { attempt: retry.attempt, maxRetries: retry.maxRetries }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
// Declared notification kinds select disclosure; prose and nested failures do not.
|
||||
if (presentation === undefined && !block.failure && !block.orcaStop) {
|
||||
const frame = block.providerFrame
|
||||
if (collapsible && frame?.provider === 'codex' && block.tone !== 'error') {
|
||||
if (frame.kind === 'notification:configWarning') {
|
||||
return { kind: 'collapsed-notice', label: 'configuration' }
|
||||
}
|
||||
if (frame.kind === 'notification:deprecationNotice') {
|
||||
return { kind: 'collapsed-notice', label: 'deprecation' }
|
||||
}
|
||||
}
|
||||
if (frame?.provider === 'codex' && frame.kind === 'notification:guardianWarning') {
|
||||
// Codex also reports approvals here; its own TUI hides them by this prefix.
|
||||
const approved = block.text.startsWith('Automatic approval review approved (')
|
||||
@@ -117,6 +145,14 @@ export function nativeChatNoticeLook(block: NativeChatTextBlock): NativeChatNoti
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a notice row's open choice may be stored, newest first: its own id, then its run's earlier retries. */
|
||||
export function nativeChatNoticeDisclosureKeys(
|
||||
message: Pick<NativeChatMessage, 'id' | 'providerRetryRunIds'>
|
||||
): string[] {
|
||||
const ids = message.providerRetryRunIds ?? [message.id]
|
||||
return ids.map((_, index) => `notice:${ids[ids.length - 1 - index]}`)
|
||||
}
|
||||
|
||||
/** The host writes "Forked from <name>"; the name is what links to the source. */
|
||||
function forkedFromSourceName(text: string): string {
|
||||
const lead = `${NATIVE_CHAT_NOTICE_COPY.forkedFrom} `
|
||||
|
||||
@@ -129,3 +129,35 @@ describe('a run of provider retry rows', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("a drawn retry row's run", () => {
|
||||
const named = (itemId: string, row: AgentJournalRenderItem): AgentJournalRenderItem => ({
|
||||
...row,
|
||||
itemId
|
||||
})
|
||||
const lead = named('lead', assistant('Working'))
|
||||
const [r1, r2, r3, r4] = ['r1', 'r2', 'r3', 'r4'].map((id, index) => named(id, retry(index + 1)))
|
||||
const runs = (items: AgentJournalRenderItem[]) =>
|
||||
projectStructuredAgentSessionMessages(items, [], [])
|
||||
.filter((message) => message.role === 'system')
|
||||
.map((message) => [message.id, message.providerRetryRunIds])
|
||||
|
||||
it('lists the loaded retries it replaced, oldest first, wherever loaded history starts', () => {
|
||||
expect(runs([lead, r2, r3])).toEqual([['r3', ['r2', 'r3']]])
|
||||
expect(runs([lead, r1, r2, r3])).toEqual([['r3', ['r1', 'r2', 'r3']]])
|
||||
expect(runs([r3, r4])).toEqual([['r4', ['r3', 'r4']]])
|
||||
expect(runs([r4])).toEqual([['r4', undefined]])
|
||||
})
|
||||
|
||||
it('never shares a retry between two runs or two agents', () => {
|
||||
const worker = [named('w1', retry(1, 'worker')), named('w2', retry(2, 'worker'))]
|
||||
expect(runs([r1, r2, named('reply', assistant('Partial answer')), r3, r4])).toEqual([
|
||||
['r2', ['r1', 'r2']],
|
||||
['r4', ['r3', 'r4']]
|
||||
])
|
||||
expect(runs([r1, worker[0]!, r2, worker[1]!])).toEqual([
|
||||
['r2', ['r1', 'r2']],
|
||||
['w2', ['w1', 'w2']]
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,15 +13,41 @@ export function isNativeChatProviderRetryRow(message: NativeChatMessage): boolea
|
||||
)
|
||||
}
|
||||
|
||||
/** One consumer's stamped rows from its last pass, so an unchanged run keeps its row object. */
|
||||
export type NativeChatProviderRetryStamps = {
|
||||
generation: Map<NativeChatMessage, NativeChatMessage>
|
||||
}
|
||||
|
||||
export function createNativeChatProviderRetryStamps(): NativeChatProviderRetryStamps {
|
||||
return { generation: new Map() }
|
||||
}
|
||||
|
||||
type ProviderRetryRun = { at: number; source: NativeChatMessage; ids: string[] }
|
||||
|
||||
function sameIds(left: readonly string[] | undefined, right: readonly string[]): boolean {
|
||||
return (
|
||||
left !== undefined &&
|
||||
left.length === right.length &&
|
||||
left.every((id, index) => id === right[index])
|
||||
)
|
||||
}
|
||||
|
||||
/** Track runs per agent: interleaved conversation/subagent sections cannot end or hide one another’s runs. */
|
||||
export function collapseProviderRetryRuns(
|
||||
messages: readonly NativeChatMessage[]
|
||||
messages: readonly NativeChatMessage[],
|
||||
stamps?: NativeChatProviderRetryStamps
|
||||
): readonly NativeChatMessage[] {
|
||||
const previous = stamps?.generation
|
||||
// Replaced on every pass so no stamp outlives the output that draws it.
|
||||
const generation = new Map<NativeChatMessage, NativeChatMessage>()
|
||||
if (stamps) {
|
||||
stamps.generation = generation
|
||||
}
|
||||
if (!messages.some(isNativeChatProviderRetryRow)) {
|
||||
return messages
|
||||
}
|
||||
// Each agent's open run, as the index of its latest row in `drawn`.
|
||||
const openRuns = new Map<string | null, number>()
|
||||
const openRuns = new Map<string | null, ProviderRetryRun>()
|
||||
const runs: ProviderRetryRun[] = []
|
||||
const drawn: (NativeChatMessage | null)[] = []
|
||||
for (const message of messages) {
|
||||
if (isNativeChatContextClear(message)) {
|
||||
@@ -35,12 +61,30 @@ export function collapseProviderRetryRuns(
|
||||
continue
|
||||
}
|
||||
const agent = agentJournalItemSubagentId(message)
|
||||
const previous = openRuns.get(agent)
|
||||
if (previous !== undefined) {
|
||||
drawn[previous] = null
|
||||
const run = openRuns.get(agent)
|
||||
if (run) {
|
||||
drawn[run.at] = null
|
||||
run.at = drawn.length
|
||||
run.source = message
|
||||
run.ids.push(message.id)
|
||||
} else {
|
||||
const started = { at: drawn.length, source: message, ids: [message.id] }
|
||||
openRuns.set(agent, started)
|
||||
runs.push(started)
|
||||
}
|
||||
openRuns.set(agent, drawn.length)
|
||||
drawn.push(message)
|
||||
}
|
||||
for (const run of runs) {
|
||||
if (run.ids.length < 2) {
|
||||
continue
|
||||
}
|
||||
const kept = previous?.get(run.source)
|
||||
const row =
|
||||
kept && sameIds(kept.providerRetryRunIds, run.ids)
|
||||
? kept
|
||||
: { ...run.source, providerRetryRunIds: run.ids }
|
||||
generation.set(run.source, row)
|
||||
drawn[run.at] = row
|
||||
}
|
||||
return drawn.filter((message) => message !== null)
|
||||
}
|
||||
|
||||
@@ -255,6 +255,8 @@ export type NativeChatMessage = AgentJournalProducerLinkage & {
|
||||
awaitsRetry?: true
|
||||
/** Withdrawn sends have no rail tick because the host conversation outline excludes them. */
|
||||
stoppedBeforeStart?: true
|
||||
/** Client-derived on a drawn retry row: its run's loaded retry ids, oldest first, ending with its own. */
|
||||
providerRetryRunIds?: readonly string[]
|
||||
/** Structured journal ordering outranks timestamps; terminal transcripts omit it and worker reads strip it. */
|
||||
journalPosition?: AgentJournalPosition
|
||||
/** Newest absorbed tool row’s position; the folded row still sorts by its own position. */
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('Enter and Space keep the retry summary and disclosure choice in sync', async ({ page }) => {
|
||||
test.setTimeout(120_000)
|
||||
await page.goto(
|
||||
'/fixtures.html?id=native-chat/messages/notices/NativeChatNoticeRow:storedRetryNotice&theme=light'
|
||||
)
|
||||
await expect(page.locator('html')).toHaveAttribute('data-fixture-state', 'ready', {
|
||||
timeout: 90_000
|
||||
})
|
||||
const summary = page.locator('summary')
|
||||
const choice = page.locator('[data-disclosure-choice]')
|
||||
await summary.focus()
|
||||
await expect(summary).toBeFocused()
|
||||
await expect(summary).toHaveAttribute('aria-expanded', 'false')
|
||||
await expect(choice).toHaveAttribute('data-disclosure-choice', 'undefined')
|
||||
await summary.press('Enter')
|
||||
await expect(summary).toHaveAttribute('aria-expanded', 'true')
|
||||
await expect(choice).toHaveAttribute('data-disclosure-choice', 'true')
|
||||
await summary.press('Space')
|
||||
await expect(summary).toHaveAttribute('aria-expanded', 'false')
|
||||
await expect(choice).toHaveAttribute('data-disclosure-choice', 'false')
|
||||
})
|
||||
Reference in New Issue
Block a user