diff --git a/config/scripts/structured-agent-session-start-failure-row-render.test.mjs b/config/scripts/structured-agent-session-start-failure-row-render.test.mjs index 4f28a6cb747f..0d4a21f808bb 100644 --- a/config/scripts/structured-agent-session-start-failure-row-render.test.mjs +++ b/config/scripts/structured-agent-session-start-failure-row-render.test.mjs @@ -28,47 +28,60 @@ describe('startup sign-in rows from host to renderer', () => { ], ['OMP', 'Connectez-vous à OMP.'] ])( - 'translates an actual %s startup row with its diagnostic and resend advice', + 'translates an actual %s startup row with plain detail and resend advice, hiding technical detail', async (agentName, guidance) => { await i18n.changeLanguage('fr') - const detail = 'Provider diagnostic: {{agent}} must remain literal.' - const startup = structuredAgentSessionStartFailure( - { - failure: { - kind: 'notSignedIn', - account: 'system', - detail: { text: detail, audience: 'person' } - } - }, - { agentName } - ) - const mutation = structuredAgentSessionStartFailureRow('generation', startup) - if (mutation.kind !== 'item' || mutation.body.kind !== 'status') { - throw new Error('Expected the startup writer to produce a status row') - } - expect(mutation.body.text).toContain('Then send your message again.') - const [message] = projectStructuredItemsToNativeChat([ - { - itemId: agentJournalItemKey(mutation.identity), - sequence: 1, - revision: 1, - observedAt: 1, - body: mutation.body, - turnScope: mutation.turnScope - } - ]) - render( - createElement(MessageRow, { - message, - agentName, - expandSignal: false, - onScrollMessageToTop: vi.fn() - }) - ) + renderStartupRow(agentName, 'Sign-in note: {{agent}} must remain literal.') expect( - screen.getByText(`${guidance} ${detail} Puis envoyez à nouveau votre message.`) + screen.getByText( + `${guidance} Sign-in note: {{agent}} must remain literal. Puis envoyez à nouveau votre message.` + ) ).toBeInTheDocument() - expect(screen.queryByText(mutation.body.text)).toBeNull() + cleanup() + + // Technical detail stays off the row; the agent-named sign-in step and resend advice remain. + renderStartupRow(agentName, 'Provider diagnostic: {{agent}} must remain literal.') + expect( + screen.getByText(`${guidance} Puis envoyez à nouveau votre message.`) + ).toBeInTheDocument() + expect(screen.queryByText(/provider|diagnostic/i)).toBeNull() } ) }) + +function renderStartupRow(agentName, detail) { + const startup = structuredAgentSessionStartFailure( + { + failure: { + kind: 'notSignedIn', + account: 'system', + detail: { text: detail, audience: 'person' } + } + }, + { agentName } + ) + const mutation = structuredAgentSessionStartFailureRow('generation', startup) + if (mutation.kind !== 'item' || mutation.body.kind !== 'status') { + throw new Error('Expected the startup writer to produce a status row') + } + expect(mutation.body.text).toContain('Then send your message again.') + const [message] = projectStructuredItemsToNativeChat([ + { + itemId: agentJournalItemKey(mutation.identity), + sequence: 1, + revision: 1, + observedAt: 1, + body: mutation.body, + turnScope: mutation.turnScope + } + ]) + render( + createElement(MessageRow, { + message, + agentName, + expandSignal: false, + onScrollMessageToTop: vi.fn() + }) + ) + expect(screen.queryByText(mutation.body.text)).toBeNull() +} diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 6d81a92daf20..21a74d7e341d 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -159,6 +159,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-stop-failure-copy.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.ts', diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index b5a8e8b395c6..d7263e283729 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -84,6 +84,7 @@ describe('MobileNativeChatMessage', () => { } | null onToggleTurn?: () => void reasoningIsLive?: boolean + unsentNotice?: string } = {} ): ReactTestRenderer { act(() => { @@ -168,6 +169,28 @@ describe('MobileNativeChatMessage', () => { expect(rows.some((row) => row.props.style.includes(styles.rowUser))).toBe(false) }) + // As on the desktop: an orchestration task the host recorded and rejected reads as not sent too. + it("says under another agent's message the host never delivered that it was not sent", () => { + const tree = render( + { + ...userMessage([{ type: 'text', text: 'Fix the parser.' }]), + unsent: true, + from: { + kind: 'agent', + senders: [ + { + party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null }, + name: 'Coder' + } + ], + orchestration: null + } + }, + { unsentNotice: "Claude couldn't start." } + ) + expect(textIn(tree.root)).toContain("Claude couldn't start.") + }) + it('preserves an ordinary assistant answer without interpreting its text as a host notice', () => { const tree = render(toolMessage([{ type: 'text', text: 'provider fallback' }])) expect(tree.root.find((node) => String(node.type) === 'MobileMarkdown').props.content).toBe( @@ -201,6 +224,36 @@ describe('MobileNativeChatMessage', () => { expect(texts.some((text) => text.includes('/tmp/host.png'))).toBe(true) }) + it('says under a message the host recorded but never delivered why it was not sent', () => { + const tree = render( + { ...userMessage([{ type: 'text', text: 'hello' }]), unsent: true }, + { unsentNotice: "Claude couldn't receive this message. Send it again." } + ) + expect(textIn(tree.root)).toEqual([ + 'hello', + "Claude couldn't receive this message. Send it again." + ]) + }) + + it('still says it was not sent when no words for it are loaded', () => { + const tree = render({ ...userMessage([{ type: 'text', text: 'hello' }]), unsent: true }) + expect(textIn(tree.root)).toEqual(['hello', 'Your message was not sent.']) + }) + + it('says it was not sent as a muted label, not an error', () => { + const tree = render({ ...userMessage([{ type: 'text', text: 'hello' }]), unsent: true }) + const [label] = tree.root.findAll( + (node) => + node.props.style !== undefined && node.children.join('') === 'Your message was not sent.' + ) + expect(label?.props.style).toMatchObject({ color: colors.textMuted }) + }) + + it('says nothing more under a delivered message', () => { + const tree = render(userMessage([{ type: 'text', text: 'hello' }])) + expect(textIn(tree.root)).toEqual(['hello']) + }) + it('makes user message text selectable', () => { const tree = render(userMessage([{ type: 'text', text: 'Prompt I typed' }])) const text = tree.root diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index 315b6aa7168b..18052a231e1e 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -31,6 +31,7 @@ import type { NativeChatTurnStatus } from './use-mobile-native-chat-turn-status' import { isRenderableImageUri } from './mobile-native-chat-image-preview' import { styles, TEXT_SIZE } from './mobile-native-chat-message-styles' import { agentMessageAttribution } from './mobile-agent-message-attribution' +import { AGENT_SESSION_WRITE_NOTICE_COPY } from '../../../src/shared/agent-session-write-notice-copy' import { withoutPendingNativeChatVisualDirectiveTail } from '../../../src/shared/native-chat-visual-directive' import { MobileNativeChatVisualContext, @@ -146,6 +147,7 @@ function MobileNativeChatMessageImpl({ reasoningIsLive = false, reasoningExpanded, onToggleReasoning, + unsentNotice, subagentGroupsOpen, onToggleSubagentGroup }: { @@ -175,6 +177,8 @@ function MobileNativeChatMessageImpl({ /** The transcript-held disclosure of a reasoning row; one stable handler takes its key. */ reasoningExpanded?: boolean onToggleReasoning?: (key: string) => void + /** Why the host did not deliver this message, when it is shown as not sent. */ + unsentNotice?: string /** The roster groups the reader opened, held by the transcript; only a roster row gets it. */ subagentGroupsOpen?: ReadonlySet onToggleSubagentGroup?: (groupId: string) => void @@ -308,6 +312,11 @@ function MobileNativeChatMessageImpl({ /> ) : null} + {message.role === 'user' && message.unsent === true ? ( + + {unsentNotice ?? AGENT_SESSION_WRITE_NOTICE_COPY.notDoneSend} + + ) : null} {actionsOpen ? ( { it("lets a returned card's reason wrap whole, in the destructive color, beside an alert", async () => { const reason = - 'The provider did not accept this message: Claude does not support the image type .bmp in a steering message.' + "Claude didn't accept this message: Claude does not support the image type .bmp in a steering message." const mounted = await mount({ cards: [ card({ messageId: 'r', state: 'returned', needsAttention: true, caption: reason }), diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 8f040776b4e9..9e46cc5fa923 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -21,6 +21,7 @@ import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn import { colors } from '../theme/mobile-theme' import { styles } from './mobile-native-chat-view-styles' import { mobileNativeChatListFooter } from './mobile-native-chat-list-footer' +import { mobileNativeChatListHeader } from './mobile-native-chat-list-header' import { buildMobileNativeChatTransientData, mobileNativeChatEmptyState, @@ -44,6 +45,7 @@ import type { MobileChatPermission } from './mobile-native-chat-permission' import type { MobileChatQuestion } from './mobile-native-chat-question' import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers' import { MobileNativeChatMessage } from './MobileNativeChatMessage' +import { mobileNativeChatUnsentNotices } from './mobile-native-chat-unsent-notices' import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' /** Why the composer input is locked: the transport is disconnected, or the @@ -297,6 +299,10 @@ export function MobileNativeChatView({ scopeKey: sendSurfaceId }) + const unsentNotices = useMemo( + () => mobileNativeChatUnsentNotices(turnJournal, agent ?? null), + [turnJournal, agent] + ) const renderItem = useCallback( ({ item, index }: { item: NativeChatMessage; index: number }) => ( ), - [toolsExpanded, fontScale, onOpenFile, structuredActivityUi, turns] + [toolsExpanded, fontScale, onOpenFile, structuredActivityUi, turns, unsentNotices] ) const liveStatus = turns.liveLine ? ( @@ -368,21 +375,7 @@ export function MobileNativeChatView({ scrollEventThrottle={32} onContentSizeChange={pinToTailAfterContentResize} onLayout={pinToTail} - ListHeaderComponent={ - hasMore ? ( - - {loadingEarlier ? ( - - ) : ( - Load earlier messages - )} - - ) : null - } + ListHeaderComponent={mobileNativeChatListHeader(hasMore, loadingEarlier, loadEarlier)} ListFooterComponent={mobileNativeChatListFooter( liveStatus, turns.waitingRows, diff --git a/mobile/src/session/MobileNativeChatView.unsent-notice.test.ts b/mobile/src/session/MobileNativeChatView.unsent-notice.test.ts new file mode 100644 index 000000000000..9dbe2dada9aa --- /dev/null +++ b/mobile/src/session/MobileNativeChatView.unsent-notice.test.ts @@ -0,0 +1,117 @@ +// The view words the line under each not-sent row from the loaded journal, so the row and the +// desktop say the same thing; a delivered row gets no line. + +import { createElement, isValidElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, expect, it, vi } from 'vitest' +import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key' +import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { MobileNativeChatView } from './MobileNativeChatView' + +vi.mock('react-native', async () => { + const React = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + FlatList: React.forwardRef((props, ref) => { + React.useImperativeHandle(ref, () => ({ scrollToEnd: vi.fn(), scrollToOffset: vi.fn() }), []) + return React.createElement('FlatList', props) + }), + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' + } +}) +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) +})) +vi.mock('react-native-gesture-handler', () => { + const chain = { runOnJS: () => chain, onStart: () => chain, onUpdate: () => chain } + return { + Gesture: { Simultaneous: () => ({}), Native: () => ({}), Pinch: () => chain }, + GestureDetector: 'GestureDetector', + GestureHandlerRootView: 'GestureHandlerRootView' + } +}) +vi.mock('lucide-react-native', () => ({ + ArrowDown: 'ArrowDown', + ChevronsDownUp: 'ChevronsDownUp', + ChevronsUpDown: 'ChevronsUpDown', + Square: 'Square' +})) +vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) +vi.mock('./MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' })) +vi.mock('./MobileNativeChatComposer', () => ({ MobileNativeChatComposer: 'Composer' })) +// The queue's action sheet pulls in the animation runtime, which this react-native mock can't host. +vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) +vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) +vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) +vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) +vi.mock('./MobileAgentWorkingIndicator', () => ({ + MobileAgentWorkingIndicator: 'WorkingIndicator' +})) + +let renderer: ReactTestRenderer | null = null +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null +}) + +function user(clientMessageId: string, unsent: boolean): NativeChatMessage { + return { + id: agentJournalSubmissionKey(clientMessageId), + role: 'user', + blocks: [{ type: 'text', text: clientMessageId }], + timestamp: 1, + source: 'transcript', + ...(unsent ? { unsent: true as const } : {}) + } +} + +const REJECTED: AgentJournalSubmission = { + clientMessageId: 'lost', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + reason: 'provider_write_failed: broken pipe', + submittedAt: 1, + resolvedAt: 1 +} + +it('puts the host words under a not-sent row, and nothing under a delivered one', () => { + const folded = [user('lost', true), user('sent', false)] + act(() => { + renderer = create( + createElement(MobileNativeChatView, { + messages: [], + folded, + status: 'ready', + streaming: null, + onSend: vi.fn().mockResolvedValue(true), + sendSurfaceId: 'tab-a', + getSendCompletionGeneration: () => 0, + getComposerEditGeneration: () => 0, + pending: [], + composerText: '', + onComposerTextChange: vi.fn(), + structuredActivityUi: true, + agent: 'claude', + turnJournal: { + items: [], + submissions: [REJECTED, { ...REJECTED, clientMessageId: 'sent' }] + } + }) + ) + }) + const list = renderer!.root.find((node) => String(node.type) === 'FlatList') + const notice = (index: number): string | undefined => { + const element: unknown = list.props.renderItem({ item: folded[index], index }) + return isValidElement<{ unsentNotice?: string }>(element) + ? element.props.unsentNotice + : undefined + } + expect(notice(0)).toBe("Claude couldn't receive this message. Send it again.") + expect(notice(1)).toBeUndefined() +}) diff --git a/mobile/src/session/__mocks__/mobile-prompt-controller.ts b/mobile/src/session/__mocks__/mobile-prompt-controller.ts index 0c34182ec844..55f2e50cb699 100644 --- a/mobile/src/session/__mocks__/mobile-prompt-controller.ts +++ b/mobile/src/session/__mocks__/mobile-prompt-controller.ts @@ -88,6 +88,7 @@ const structured = { isWorking: false, turnId: null, turnIndicator: null, + submissions: [], workingStartedAt: null, settledTurns: null, turnJournal: null, @@ -97,7 +98,7 @@ const structured = { respondPermission: vi.fn(async () => true), respondQuestion: vi.fn(async () => true), cancelPrompt: vi.fn(async () => true), - sendWithOutcome: vi.fn(async () => 'accepted'), + sendWithOutcome: vi.fn(async () => ({ outcome: 'accepted', clientMessageId: 'op-1' })), cancel: vi.fn(), setStructuredOption: vi.fn(), invokeStructuredOption: vi.fn() diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts index 3ae08af66881..6ed38f33197c 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts @@ -1,3 +1,4 @@ +import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key' import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types' import { hasImagePromptMarker, @@ -8,6 +9,12 @@ import { } from './mobile-native-chat-image-transcript-markers' export { normalizeNativeChatUserText as normalizeReconcileText } from './mobile-native-chat-image-transcript-markers' +/** Whether the host's journal holds the structured send made under this id: a submission recorded + * under it, in any state, or one its queued draft went out as. Its own row may be hidden. */ +export type MobileStructuredSendReachedHost = (clientMessageId: string) => boolean + +export const NO_STRUCTURED_SENDS: MobileStructuredSendReachedHost = () => false + /** An ack-lost ('unknown' outcome) send held until its transcript echo lands or * the deadline surfaces the uncertainty. */ export type UnconfirmedSend = { @@ -18,6 +25,8 @@ export type UnconfirmedSend = { baselineTailMessageId: string | null /** Queued-draft cards on screen at send time; see `findQueuedUnconfirmedSends`. */ baselineQueuedMessageIds?: readonly string[] + /** Structured lane: settled by the journal's record of it or by its own card, never by text. */ + clientMessageId?: string deadline: ReturnType | null } @@ -63,6 +72,7 @@ export type PendingImagePreviewEcho = { images?: string[] expectedOccurrence: number baselineTailMessageId: string | null + clientMessageId?: string } export type LandedImagePreviewEcho = { @@ -176,6 +186,13 @@ export function findLandedImagePreviewEchoes( if (!entry.images?.length) { continue } + if (entry.clientMessageId !== undefined) { + const ownId = agentJournalSubmissionKey(entry.clientMessageId) + if (messages.some((message) => message.id === ownId)) { + landed.push({ pendingId: entry.id, messageId: ownId, images: entry.images }) + } + continue + } const targetText = normalizeNativeChatUserText(entry.text) const candidates = normalized.filter((message) => { if (message.role !== 'user') { @@ -234,11 +251,12 @@ export function findQueuedUnconfirmedSends( const claimed = new Set() const held: UnconfirmedSend[] = [] for (const entry of entries) { - const card = cards.find( - (candidate) => - !claimed.has(candidate.messageId) && - !(entry.baselineQueuedMessageIds ?? []).includes(candidate.messageId) && - normalizeNativeChatUserText(candidate.text) === entry.normalizedText + const card = cards.find((candidate) => + entry.clientMessageId !== undefined + ? candidate.messageId === entry.clientMessageId + : !claimed.has(candidate.messageId) && + !(entry.baselineQueuedMessageIds ?? []).includes(candidate.messageId) && + normalizeNativeChatUserText(candidate.text) === entry.normalizedText ) if (card) { claimed.add(card.messageId) @@ -250,7 +268,8 @@ export function findQueuedUnconfirmedSends( export function findLandedUnconfirmedSends( messages: readonly NativeChatMessage[], - entries: readonly UnconfirmedSend[] + entries: readonly UnconfirmedSend[], + reachedHost: MobileStructuredSendReachedHost = NO_STRUCTURED_SENDS ): UnconfirmedSend[] { // Why: pagination prepends old equal text; only unclaimed matches after each // captured tail prove new echoes. User turns are keyed by text; an image echo @@ -272,6 +291,12 @@ export function findLandedUnconfirmedSends( const claimedMessageIds = new Set() const landed: UnconfirmedSend[] = [] for (const entry of entries) { + if (entry.clientMessageId !== undefined) { + if (reachedHost(entry.clientMessageId)) { + landed.push(entry) + } + continue + } const tailIndex = entry.baselineTailMessageId ? messageIndexById.get(entry.baselineTailMessageId) : -1 diff --git a/mobile/src/session/mobile-native-chat-list-header.tsx b/mobile/src/session/mobile-native-chat-list-header.tsx new file mode 100644 index 000000000000..bb3e61c374fe --- /dev/null +++ b/mobile/src/session/mobile-native-chat-list-header.tsx @@ -0,0 +1,24 @@ +import type { ReactElement } from 'react' +import { ActivityIndicator, Pressable, Text } from 'react-native' +import { colors } from '../theme/mobile-theme' +import { styles } from './mobile-native-chat-view-styles' + +/** The "Load earlier messages" control, while older history remains. */ +export function mobileNativeChatListHeader( + hasMore: boolean | undefined, + loadingEarlier: boolean | undefined, + loadEarlier: () => void +): ReactElement | null { + if (!hasMore) { + return null + } + return ( + + {loadingEarlier ? ( + + ) : ( + Load earlier messages + )} + + ) +} diff --git a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx index 5e933c94e49b..3c6a3bc09d90 100644 --- a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx +++ b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx @@ -191,7 +191,8 @@ async function mountPhone(): Promise { tabId: 'tab', sessionId: STOP_JOURNAL_SESSION, messages: session.session.messages, - transcriptSettled: session.session.status === 'ready' + transcriptSettled: session.session.status === 'ready', + submissions: session.submissions }) latest = drafts // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every member the list's rows, bars and footer read comes from the two hooks above, as the controller passes them; showNativeChat, nativeChatAgent, nativeChatCanStop, nativeChatStructured and the scope key are constants; the overlay's other members stay undefined: the composer's and Stop's handlers, files and options never reach the list, the streaming text is undefined on a structured tab too, and the prompts, which would hide the live status, are absent in this flow. diff --git a/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts b/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts index 9d75bb02d7d6..d69fd88dbf58 100644 --- a/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts +++ b/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts @@ -53,7 +53,7 @@ function apply( } /** The rows the phone's transcript reads, as its session hook builds them: the journal plus the - * notice a cut turn gets, projected with no other rejected send drawn in place. */ + * notice a cut turn gets, with a recorded, rejected send drawn in place as not sent. */ function phoneTranscript(state: StructuredAgentSessionState) { const items = withNativeChatCutTurnNotices(state.items, { agentName: TUI_AGENT_DISPLAY_NAMES.codex @@ -61,7 +61,7 @@ function phoneTranscript(state: StructuredAgentSessionState) { return { items, messages: projectStructuredAgentSessionMessages(items, [], state.submissions, { - rejectedInPlace: false + rejectedInPlace: true }) } } diff --git a/mobile/src/session/mobile-native-chat-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts index ad2325ffbbeb..908b52ad03a0 100644 --- a/mobile/src/session/mobile-native-chat-message-styles.ts +++ b/mobile/src/session/mobile-native-chat-message-styles.ts @@ -75,6 +75,11 @@ export const styles = StyleSheet.create({ color: colors.textMuted, fontSize: typography.metaSize }, + unsentLabel: { + marginTop: spacing.xs, + color: colors.textMuted, + fontSize: typography.metaSize + }, toolRun: { marginTop: spacing.xs }, diff --git a/mobile/src/session/mobile-native-chat-own-row-echo.test.ts b/mobile/src/session/mobile-native-chat-own-row-echo.test.ts new file mode 100644 index 000000000000..4bda522faf1d --- /dev/null +++ b/mobile/src/session/mobile-native-chat-own-row-echo.test.ts @@ -0,0 +1,357 @@ +// A structured send is recorded under the id it went out under. The phone settles the send's bubble +// and any unconfirmed hold once its journal holds that record, or a hand-off of its card, whatever +// the text and wherever, or whether, its row is drawn. Its photo binds to that row when drawn. + +import { describe, expect, it } from 'vitest' +import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../src/shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' +import { + countUserTextOccurrences, + findLandedImagePreviewEchoes, + findLandedUnconfirmedSends, + findQueuedUnconfirmedSends, + normalizeReconcileText, + type MobileStructuredSendReachedHost +} from './mobile-native-chat-draft-reconcile' +import { + appendMobileNativeChatPending, + type MobileNativeChatPendingMessage +} from './mobile-native-chat-pending-echo' +import { retireLandedMobileNativeChatPending } from './mobile-native-chat-pending-retirement' +import { mobileStructuredSendReceipts } from './mobile-structured-send-receipts' + +const TEXT = 'fix the test' + +function user( + id: string, + sequence: number, + blocks: Extract['blocks'] +): AgentJournalRenderItem { + return { + itemId: agentJournalSubmissionKey(id), + revision: 1, + sequence, + observedAt: sequence, + turnScope: { kind: 'thread' }, + body: { kind: 'message', role: 'user', blocks } + } +} + +function said(id: string, sequence: number, text: string): AgentJournalRenderItem { + return user(id, sequence, [{ type: 'text', text }]) +} + +function answer(id: string, sequence: number): AgentJournalRenderItem { + return { + itemId: id, + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'ok' }] } + } +} + +function submission( + id: string, + text: string, + at: number, + patch: Partial = {} +): AgentJournalSubmission { + return { + clientMessageId: id, + fence: 1, + payloadFingerprint: `body:${text}`, + dispatchState: 'accepted', + providerItemId: `provider-${id}`, + reason: null, + submittedAt: at, + resolvedAt: at, + ...patch + } +} + +function rejected(id: string, text: string, at: number): AgentJournalSubmission { + return submission(id, text, at, { + dispatchState: 'rejected', + providerItemId: null, + reason: 'provider_write_failed: broken pipe', + resolvedAt: at + 1 + }) +} + +type Chat = { messages: NativeChatMessage[]; reachedHost: MobileStructuredSendReachedHost } + +/** What the phone holds: the journal drawn with a recorded, rejected send in place as not sent, + * and what that journal says reached the host. */ +function phone(items: AgentJournalRenderItem[], submissions: AgentJournalSubmission[]): Chat { + const sends = mobileStructuredSendReceipts(submissions) + return { + messages: projectStructuredAgentSessionMessages(items, [], submissions, { + rejectedInPlace: true + }), + reachedHost: (clientMessageId) => sends.has(clientMessageId) + } +} + +/** One bubble for a structured send, captured against `before`. */ +function bubble( + before: Chat, + clientMessageId: string, + text: string, + images?: string[] +): MobileNativeChatPendingMessage[] { + const normalizedText = normalizeReconcileText(text) + return ( + appendMobileNativeChatPending( + {}, + 'pending', + `pending-${clientMessageId}`, + { + draftKey: 'draft', + draftEditGeneration: 0, + pendingKey: 'pending', + normalizedText, + baselineOccurrences: countUserTextOccurrences(before.messages, normalizedText), + baselineTailMessageId: before.messages.at(-1)?.id ?? null, + baselineResolved: true + }, + text, + images, + clientMessageId + ).pending ?? [] + ) +} + +/** The bubbles still drawn once `after` is the chat. */ +function left(after: Chat, pending: MobileNativeChatPendingMessage[]): string[] { + const bound = new Set( + findLandedImagePreviewEchoes(after.messages, pending).map((echo) => echo.pendingId) + ) + return retireLandedMobileNativeChatPending(after.messages, pending, bound, after.reachedHost).map( + (item) => item.id + ) +} + +/** Whether an ack-lost send under `clientMessageId` is still held once `after` is the chat. */ +function held( + before: Chat, + after: Chat, + clientMessageId: string, + text: string, + cards: { messageId: string; text: string }[] = [] +): boolean { + const entry = { + draftKey: 'draft', + pendingKey: 'pending', + text, + normalizedText: normalizeReconcileText(text), + baselineTailMessageId: before.messages.at(-1)?.id ?? null, + clientMessageId, + deadline: null + } + return ( + findLandedUnconfirmedSends(after.messages, [entry], after.reachedHost).length === 0 && + findQueuedUnconfirmedSends(cards, [entry]).length === 0 + ) +} + +const drawn = (chat: Chat, clientMessageId: string): boolean => + chat.messages.some((message) => message.id === agentJournalSubmissionKey(clientMessageId)) + +const ORIGINAL = rejected('m1', TEXT, 10) +const BEFORE = phone([answer('hi', 5), said('m1', 11, TEXT)], [ORIGINAL]) + +describe('a same-text resend after a not-sent message', () => { + it('stays unsettled until the journal holds it, never settled by the original', () => { + expect(left(BEFORE, bubble(BEFORE, 'm2', TEXT))).toEqual(['pending-m2']) + expect(held(BEFORE, BEFORE, 'm2', TEXT)).toBe(true) + }) + + // The projection drops the original once the resend is recorded after its rejection. + it('settles once the journal holds it, with the original gone', () => { + const after = phone( + [answer('hi', 5), said('m1', 11, TEXT), said('m2', 20, TEXT), answer('done', 21)], + [ORIGINAL, submission('m2', TEXT, 20)] + ) + expect(drawn(after, 'm1')).toBe(false) + expect(left(after, bubble(BEFORE, 'm2', TEXT))).toEqual([]) + expect(held(BEFORE, after, 'm2', TEXT)).toBe(false) + }) +}) + +describe('a send whose own row arrives already not sent', () => { + const after = phone( + [answer('hi', 5), said('m1', 11, TEXT), said('m2', 21, 'later')], + [ORIGINAL, rejected('m2', 'later', 20)] + ) + + it('settles an ack-lost send, so no "Delivery unconfirmed" banner follows', () => { + expect(after.messages.at(-1)).toMatchObject({ + id: agentJournalSubmissionKey('m2'), + unsent: true + }) + expect(held(BEFORE, after, 'm2', 'later')).toBe(false) + }) + + it('retires its bubble, so no copy stays beside the not-sent row', () => { + expect(left(after, bubble(BEFORE, 'm2', 'later'))).toEqual([]) + }) +}) + +// The newest row at send time can be one the host rejects only later: it moves that row to the +// rejection, past the new send's own row (R1P-1). +describe('a send whose boundary row the host moves past it', () => { + const before = phone([answer('hi', 5), said('m1', 10, 'first')], []) + const after = phone( + [ + answer('hi', 5), + user('m2', 20, [{ type: 'image-ref', path: '/host/a.png' }]), + said('m1', 21, 'first') + ], + [rejected('m1', 'first', 20), submission('m2', '', 20)] + ) + + it('binds its photo to its own row and retires its bubble', () => { + const pending = bubble(before, 'm2', '', ['file:///a.jpg']) + expect(findLandedImagePreviewEchoes(after.messages, pending)).toEqual([ + { + pendingId: 'pending-m2', + messageId: agentJournalSubmissionKey('m2'), + images: ['file:///a.jpg'] + } + ]) + expect(left(after, pending)).toEqual([]) + }) + + it('settles an ack-lost send there too', () => { + expect(held(before, after, 'm2', '')).toBe(false) + }) +}) + +describe('a captioned photo send', () => { + const photo = (id: string, sequence: number) => + user(id, sequence, [ + { type: 'text', text: 'look' }, + { type: 'image-ref', path: `/host/${id}.png` } + ]) + + it('binds to its own row, never an older one with the same caption, not sent or delivered', () => { + const before = phone([photo('m1', 10), photo('m3', 12)], [rejected('m1', 'look', 10)]) + const pending = bubble(before, 'm2', 'look', ['file:///b.jpg']) + expect(left(before, pending)).toEqual(['pending-m2']) + const after = phone( + [photo('m1', 10), photo('m3', 12), photo('m2', 21)], + [rejected('m1', 'look', 10), rejected('m2', 'look', 20)] + ) + expect(findLandedImagePreviewEchoes(after.messages, pending)).toEqual([ + { + pendingId: 'pending-m2', + messageId: agentJournalSubmissionKey('m2'), + images: ['file:///b.jpg'] + } + ]) + expect(left(after, pending)).toEqual([]) + }) +}) + +// Two quick sends of the same text: the first recorded and rejected, the second delivered. Each +// settles on its own record, whichever state the phone first sees (R1P-3). +describe('two quick sends of the same text', () => { + const before = phone([answer('a0', 5)], []) + const pending = () => [...bubble(before, 'm1', TEXT), ...bubble(before, 'm2', TEXT)] + + it("settles only the first while only the first's record is there", () => { + const firstOnly = phone([answer('a0', 5), said('m1', 11, TEXT)], [rejected('m1', TEXT, 10)]) + expect(left(firstOnly, pending())).toEqual(['pending-m2']) + }) + + it('settles both at once, though the first row is hidden by the copy sent after it', () => { + const both = phone( + [answer('a0', 5), said('m1', 11, TEXT), said('m2', 12, TEXT)], + [rejected('m1', TEXT, 10), submission('m2', TEXT, 12)] + ) + expect(drawn(both, 'm1')).toBe(false) + expect(left(both, pending())).toEqual([]) + }) +}) + +// A send's own row can be hidden, or never exist, before the phone ever draws it: the journal still +// holds the send, or the hand-off of the card the host made of it. +describe('a send the chat never draws under its own id', () => { + it('settles a held send whose card went out under a fresh id after its answer was lost', () => { + const after = phone( + [answer('hi', 5), said('drained', 20, 'run tests')], + [submission('drained', 'run tests', 20, { queuedMessageId: 'op-a' })] + ) + expect(held(BEFORE, after, 'op-a', 'run tests')).toBe(false) + }) + + it('retires a bubble whose row a same-text copy hid before the phone drew it', () => { + const after = phone( + [said('o', 5, 'go'), said('n', 7, 'go')], + [{ ...rejected('o', 'go', 5), resolvedAt: 6 }, submission('n', 'go', 7)] + ) + expect(drawn(after, 'o')).toBe(false) + expect(left(after, bubble(BEFORE, 'o', 'go', ['file:///o.jpg']))).toEqual([]) + }) + + it('retires a bubble kept as a card that then went out under a fresh id', () => { + const after = phone( + [said('o', 5, 'go'), said('fresh', 8, 'go')], + [ + { ...rejected('o', 'go', 5), keptAsQueuedMessageId: 'o' }, + submission('fresh', 'go', 8, { queuedMessageId: 'o' }) + ] + ) + expect(drawn(after, 'o')).toBe(false) + expect(left(after, bubble(BEFORE, 'o', 'go'))).toEqual([]) + }) + + it('settles a held send on its own card, never on another card with its text', () => { + expect( + held(BEFORE, BEFORE, 'op-a', 'run tests', [{ messageId: 'op-a', text: 'run tests' }]) + ).toBe(false) + expect( + held(BEFORE, BEFORE, 'op-a', 'run tests', [{ messageId: 'other', text: 'run tests' }]) + ).toBe(true) + }) +}) + +describe('a send the host has recorded but not yet answered', () => { + it('settles on its pending record: its row is drawn as sent', () => { + const after = phone( + [answer('hi', 5), said('m2', 20, 'later')], + [submission('m2', 'later', 20, { dispatchState: 'pending', resolvedAt: null })] + ) + expect(left(after, bubble(BEFORE, 'm2', 'later'))).toEqual([]) + expect(held(BEFORE, after, 'm2', 'later')).toBe(false) + }) +}) + +describe('what never settles a send', () => { + it('another send of the same text', () => { + const after = phone([said('z', 5, 'go')], [submission('z', 'go', 5)]) + expect(left(after, bubble(BEFORE, 'o', 'go'))).toEqual(['pending-o']) + expect(held(BEFORE, after, 'o', 'go')).toBe(true) + }) + + it('a journal without its record yet', () => { + const empty = phone([], []) + expect(left(empty, bubble(BEFORE, 'o', 'go'))).toEqual(['pending-o']) + expect(held(BEFORE, empty, 'o', 'go')).toBe(true) + }) + + it("another card's hand-off", () => { + const after = phone( + [said('fresh', 5, 'go')], + [submission('fresh', 'go', 5, { queuedMessageId: 'card-other' })] + ) + expect(left(after, bubble(BEFORE, 'o', 'go'))).toEqual(['pending-o']) + expect(held(BEFORE, after, 'o', 'go')).toBe(true) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-pending-echo.ts b/mobile/src/session/mobile-native-chat-pending-echo.ts index 01b8c7d4bc23..5fe7005cfa7d 100644 --- a/mobile/src/session/mobile-native-chat-pending-echo.ts +++ b/mobile/src/session/mobile-native-chat-pending-echo.ts @@ -12,6 +12,10 @@ export type MobileNativeChatPendingMessage = { * and rebased onto the first authoritative read instead of reconciling * against rows that may belong to another tab. */ baselineResolved: boolean + /** Structured lane: the id the host records the send under. The journal's record of it, or of + * its card's hand-off, settles it, whether or not its row is drawn; text and position are not + * consulted. */ + clientMessageId?: string } export type MobileNativeChatSendOrigin = { @@ -46,7 +50,8 @@ export function appendMobileNativeChatPending( id: string, origin: MobileNativeChatSendOrigin, text: string, - images?: string[] + images?: string[], + clientMessageId?: string ): PendingByKey { const current = previous[key] ?? [] // Count outstanding repeats with the same normalized key. @@ -73,7 +78,8 @@ export function appendMobileNativeChatPending( : origin.baselineOccurrences + earlierOutstanding + 1, baselineTailMessageId: origin.baselineTailMessageId, baselineResolved: origin.baselineResolved, - ...(images?.length ? { images } : {}) + ...(images?.length ? { images } : {}), + ...(clientMessageId !== undefined ? { clientMessageId } : {}) } ] } diff --git a/mobile/src/session/mobile-native-chat-pending-retirement.ts b/mobile/src/session/mobile-native-chat-pending-retirement.ts index 7f720302eccc..96b8d64cf428 100644 --- a/mobile/src/session/mobile-native-chat-pending-retirement.ts +++ b/mobile/src/session/mobile-native-chat-pending-retirement.ts @@ -1,8 +1,11 @@ +import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { countImageSourceTurnsAfter, normalizeReconcileText, - normalizedUserText + normalizedUserText, + NO_STRUCTURED_SENDS, + type MobileStructuredSendReachedHost } from './mobile-native-chat-draft-reconcile' import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo' @@ -52,7 +55,9 @@ export function selectGluedPendingIds( // unbound it stays a barrier: retiring it early would drop the phone-local photo, // which the transcript's host path cannot render. const unboundImage = Boolean(item.images?.length) && !reboundImagePendingIds.has(item.id) + // A structured send is settled by its own row and never glued onto another. return excludedPendingIds.has(item.id) || + item.clientMessageId !== undefined || !item.baselineResolved || unboundImage || text === '' || @@ -144,10 +149,13 @@ function matchGluedRun( export function retireLandedMobileNativeChatPending( messages: readonly NativeChatMessage[], current: MobileNativeChatPendingMessage[], - landedImagePendingIds: ReadonlySet + landedImagePendingIds: ReadonlySet, + reachedHost: MobileStructuredSendReachedHost = NO_STRUCTURED_SENDS ): MobileNativeChatPendingMessage[] { const landedCounts = new Map() + const messageIds = new Set() for (const message of messages) { + messageIds.add(message.id) const text = normalizedUserText(message) if (text) { landedCounts.set(text, (landedCounts.get(text) ?? 0) + 1) @@ -163,6 +171,17 @@ export function retireLandedMobileNativeChatPending( landedPendingIds.add(item.id) continue } + if (item.clientMessageId !== undefined) { + // A photo whose own row is drawn waits to bind to it above; one the chat hides retires unbound. + const photoWaitsForRow = + Boolean(item.images?.length) && + messageIds.has(agentJournalSubmissionKey(item.clientMessageId)) + if (reachedHost(item.clientMessageId) && !photoWaitsForRow) { + landedPendingIds.add(item.id) + exactLandedIds.add(item.id) + } + continue + } // Keep image echoes until their local preview reaches the authoritative message. // An unresolved baseline has nothing to count against yet — `messages` is not // known to be the transcript this send was issued into. diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index 19a37120be34..e1a78e2161b6 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -32,11 +32,22 @@ type MobileNativeChatSendArgs = { * desktop (ack loss after a write, or a cutover that cannot tell whether the * frame was written) — callers must not present it as a definite send failure. * 'queued' = structured lane only: the host holds the message as a queued - * draft, so it shows as a card above the composer, never a transcript echo. */ -export type MobileNativeChatSendOutcome = 'accepted' | 'rejected' | 'unknown' | 'queued' + * draft, so it shows as a card above the composer, never a transcript echo. + * 'recorded-unsent' = structured lane only: the host recorded the message and + * did not deliver it; its row in the chat holds the text and says it was not + * sent, so nothing is handed back. */ +export type MobileNativeChatSendOutcome = + | 'accepted' + | 'rejected' + | 'unknown' + | 'queued' + | 'recorded-unsent' -/** What a terminal write can answer: the PTY lane has no draft queue. */ -export type MobileNativeChatWriteOutcome = Exclude +/** What a terminal write can answer: the PTY lane has no draft queue and no recorded rows. */ +export type MobileNativeChatWriteOutcome = Exclude< + MobileNativeChatSendOutcome, + 'queued' | 'recorded-unsent' +> /** Without an explicit timeout `sendRequest` waits for reconnect indefinitely, and * the composer holds `sending` (send arrow dimmed, no error) for as long as it diff --git a/mobile/src/session/mobile-native-chat-stopped-before-start.test.tsx b/mobile/src/session/mobile-native-chat-stopped-before-start.test.tsx index 8d7d2cc56591..e2edfa659eb8 100644 --- a/mobile/src/session/mobile-native-chat-stopped-before-start.test.tsx +++ b/mobile/src/session/mobile-native-chat-stopped-before-start.test.tsx @@ -151,7 +151,7 @@ describe('a send a Stop took back before the agent started it, on the phone', () it('stays where it was sent, with the stop row after it and no turn status of its own', () => { const messages = projectStructuredAgentSessionMessages(items, [], submissions, { - rejectedInPlace: false + rejectedInPlace: true }) expect(messages.map((message) => message.id)).toEqual([ agentJournalSubmissionKey('warm-up'), diff --git a/mobile/src/session/mobile-native-chat-unsent-notices.test.ts b/mobile/src/session/mobile-native-chat-unsent-notices.test.ts new file mode 100644 index 000000000000..4500427bcfec --- /dev/null +++ b/mobile/src/session/mobile-native-chat-unsent-notices.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionFailureFact } from '../../../src/shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../src/shared/agent-session-journal-item-key' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../src/shared/agent-session-journal-types' +import { structuredAgentSessionStartFailureRowIdentity } from '../../../src/shared/structured-agent-session-start-failure-row-key' +import { mobileNativeChatUnsentNotices } from './mobile-native-chat-unsent-notices' + +function rejected(patch: Partial = {}): AgentJournalSubmission { + return { + clientMessageId: 'sent-elsewhere', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + reason: 'provider_write_failed: broken pipe', + submittedAt: 1, + resolvedAt: 1, + ...patch + } +} + +describe('the line under a message the host recorded and did not deliver', () => { + it.each([ + 'The provider did not accept this message: provider_write_failed: stand-in rejected the turn.', + 'The provider stopped before it finished starting.', + 'The provider did not accept this message.' + ])('normalizes legacy host wording with the chat’s agent: %s', (reason) => { + const notices = mobileNativeChatUnsentNotices( + { items: [], submissions: [rejected({ reason })] }, + 'codex' + ) + expect(notices.get(agentJournalSubmissionKey('sent-elsewhere'))).toBe( + "Codex didn't accept this message." + ) + }) + it('names the agent with a resend step after the host classifies an unwritten send', () => { + const rejection: AgentSessionFailureFact = { + kind: 'writeFailed', + detail: { text: 'provider_write_failed: stand-in rejected the turn.', audience: 'log' } + } + const notices = mobileNativeChatUnsentNotices( + { + items: [], + submissions: [rejected({ rejection })] + }, + 'codex' + ) + expect(notices.get(agentJournalSubmissionKey('sent-elsewhere'))).toBe( + "Codex couldn't receive this message. Send it again." + ) + }) + + it('never shows the stand-in marker from a previously misclassified fact', () => { + const text = 'provider_write_failed: stand-in rejected the turn.' + const rejection: AgentSessionFailureFact = { + kind: 'providerRejected', + detail: { text, audience: 'person' } + } + const notices = mobileNativeChatUnsentNotices( + { + items: [], + submissions: [rejected({ rejection })] + }, + 'codex' + ) + expect(notices.get(agentJournalSubmissionKey('sent-elsewhere'))).toBe( + "Codex didn't accept this message." + ) + }) + + it("says why, in the host's words, keyed by the message's row", () => { + const notices = mobileNativeChatUnsentNotices( + { items: [], submissions: [rejected()] }, + 'claude' + ) + expect(notices.get(agentJournalSubmissionKey('sent-elsewhere'))).toBe( + "Claude couldn't receive this message. Send it again." + ) + }) + + it('says only that it was not sent when the start-failure row already says why', () => { + const startFailed: AgentSessionFailureFact = { kind: 'startFailed' } + const startRow: AgentJournalRenderItem = { + itemId: agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('gen')), + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'status', + tone: 'error', + ...agentSessionFailureWords(startFailed, { agentName: 'Claude', surface: 'row' }) + } + } + const notices = mobileNativeChatUnsentNotices( + { + items: [startRow], + submissions: [rejected({ reason: 'Written by the host.', rejection: startFailed })] + }, + 'claude' + ) + expect(notices.get(agentJournalSubmissionKey('sent-elsewhere'))).toBe( + 'Your message was not sent.' + ) + }) + + it.each([ + [ + 'keeps the sign-in steps when only a subagent row states them', + 'child-1', + "Codex isn't signed in. Run `codex login`." + ], + [ + 'says only that it was not sent under a visible sign-in row', + undefined, + 'Your message was not sent.' + ] + ])('%s', (_name, agentId, expected) => { + const notSignedIn: AgentSessionFailureFact = { kind: 'notSignedIn' } + const authRow: AgentJournalRenderItem = { + itemId: 'auth-row', + ...(agentId ? { agentId } : {}), + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'status', + tone: 'error', + ...agentSessionFailureWords(notSignedIn, { agentName: 'Codex', surface: 'row' }) + } + } + const notices = mobileNativeChatUnsentNotices( + { + items: [authRow], + submissions: [rejected({ reason: 'Written by the host.', rejection: notSignedIn })] + }, + 'codex' + ) + expect(notices.get(agentJournalSubmissionKey('sent-elsewhere'))).toBe(expected) + }) + + it('has nothing to say when no send was rejected', () => { + expect( + mobileNativeChatUnsentNotices( + { items: [], submissions: [rejected({ dispatchState: 'accepted' })] }, + 'claude' + ).size + ).toBe(0) + expect(mobileNativeChatUnsentNotices(null, 'claude').size).toBe(0) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-unsent-notices.ts b/mobile/src/session/mobile-native-chat-unsent-notices.ts new file mode 100644 index 000000000000..f0fc41fb3318 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-unsent-notices.ts @@ -0,0 +1,32 @@ +// The line under each message the host recorded and then did not deliver: the desktop row's words, +// in English. Only the transcript says it, so the phone's own send never repeats it in a banner. + +import { formatAgentTypeLabel } from '../../../src/shared/agent-type-label' +import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key' +import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice' +import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership' +import { structuredAgentSessionRecordedRejectionParts } from '../../../src/shared/structured-agent-session-rejection-words' +import { structuredAgentSessionStartFailureFacts } from '../../../src/shared/structured-agent-session-start-failure-facts' + +const NO_NOTICES: ReadonlyMap = new Map() + +/** Keyed by the message id the transcript renders each recorded send under. */ +export function mobileNativeChatUnsentNotices( + journal: NativeChatTurnJournal | null, + agent: string | null +): ReadonlyMap { + const rejected = journal?.submissions.filter((row) => row.dispatchState === 'rejected') ?? [] + if (!journal || rejected.length === 0) { + return NO_NOTICES + } + const startFailures = structuredAgentSessionStartFailureFacts(journal.items) + const context = agent ? { agentName: formatAgentTypeLabel(agent) } : {} + return new Map( + rejected.map((submission) => [ + agentJournalSubmissionKey(submission.clientMessageId), + agentSessionWriteNoticeEnglish( + structuredAgentSessionRecordedRejectionParts(submission, context, startFailures) + ) + ]) + ) +} diff --git a/mobile/src/session/mobile-structured-agent-session-contract.ts b/mobile/src/session/mobile-structured-agent-session-contract.ts index 5c6ccec5ab12..f0b7c79242d2 100644 --- a/mobile/src/session/mobile-structured-agent-session-contract.ts +++ b/mobile/src/session/mobile-structured-agent-session-contract.ts @@ -1,4 +1,5 @@ -import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import type { MobileStructuredSendResult } from './mobile-structured-agent-session-send' +import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' import type { MobileNativeChatVisualSource } from './mobile-native-chat-visual-read' import type { MobileChatPermission } from './mobile-native-chat-permission' import type { MobileChatQuestion } from './mobile-native-chat-question' @@ -18,12 +19,14 @@ export type StructuredMobileSession = ReturnType Promise + ) => Promise cancel: () => void permission: MobileChatPermission | null question: MobileChatQuestion | null diff --git a/mobile/src/session/mobile-structured-agent-session-rpc.ts b/mobile/src/session/mobile-structured-agent-session-rpc.ts index 7b2da967d56c..9fb803012b63 100644 --- a/mobile/src/session/mobile-structured-agent-session-rpc.ts +++ b/mobile/src/session/mobile-structured-agent-session-rpc.ts @@ -127,6 +127,7 @@ export async function requestStructuredAgentSessionMutation(args: { fields: Record clientOperationId?: string timeoutMs?: number + agentName?: string }): Promise> { const { client, @@ -136,7 +137,8 @@ export async function requestStructuredAgentSessionMutation(args: { expectedRuntimeFence, fields, clientOperationId, - timeoutMs + timeoutMs, + agentName } = args try { const result = await callAgentSession>( @@ -171,7 +173,8 @@ export async function requestStructuredAgentSessionMutation(args: { code: result.refusal.code, message: agentSessionRefusalNotice( result.refusal, - phoneWriteKind(fingerprintMethod, fields) + phoneWriteKind(fingerprintMethod, fields), + { agentName } ) } } catch (error) { @@ -184,7 +187,9 @@ export async function requestStructuredAgentSessionMutation(args: { return { status: 'failed', message: agentSessionWriteNoticeEnglish( - agentSessionWriteNoticeParts(answered, phoneWriteKind(fingerprintMethod, fields)) + agentSessionWriteNoticeParts(answered, phoneWriteKind(fingerprintMethod, fields), { + agentName + }) ) } } diff --git a/mobile/src/session/mobile-structured-agent-session-send.ts b/mobile/src/session/mobile-structured-agent-session-send.ts index fcba60c16485..bae437712277 100644 --- a/mobile/src/session/mobile-structured-agent-session-send.ts +++ b/mobile/src/session/mobile-structured-agent-session-send.ts @@ -9,9 +9,21 @@ import { requestStructuredAgentSessionMutation, timeoutForDeadline } from './mobile-structured-agent-session-rpc' +import { structuredSessionOperationId } from './structured-session-operation-id' import { mobileStructuredSendDelivery } from './mobile-structured-send-delivery' import type { MobileNativeChatSendErrorReporter } from './use-mobile-native-chat-send-error' +/** The current Send's id lets temporary text/photo rows settle against the host record. */ +export type MobileStructuredSendResult = { + outcome: MobileNativeChatSendOutcome + clientMessageId: string | null +} + +export const MOBILE_STRUCTURED_SEND_NOT_SENT: MobileStructuredSendResult = { + outcome: 'rejected', + clientMessageId: null +} + export async function sendMobileStructuredAgentSessionMessage(input: { client: RpcClient sessionId: string @@ -22,13 +34,14 @@ export async function sendMobileStructuredAgentSessionMessage(input: { delivery?: 'queue-if-active' deadline?: number onError: MobileNativeChatSendErrorReporter -}): Promise { +}): Promise { const timeoutMs = timeoutForDeadline(input.deadline) if (timeoutMs === null) { input.onError('Message not sent') - return 'rejected' + return MOBILE_STRUCTURED_SEND_NOT_SENT } - // Each Send is a new action; the shared mutation sender mints its operation id once. + // Each Send owns one fresh id, also returned when its answer is unknown. + const clientMessageId = structuredSessionOperationId() const result = await requestStructuredAgentSessionMutation({ client: input.client, method: 'agentSession.send', @@ -39,6 +52,7 @@ export async function sendMobileStructuredAgentSessionMessage(input: { body: structuredAgentSessionSendBody(input.text, input.attachments), ...(input.delivery ? { delivery: input.delivery } : {}) }, + clientOperationId: clientMessageId, timeoutMs }) const outcome = mobileStructuredSendDelivery(result) @@ -49,5 +63,5 @@ export async function sendMobileStructuredAgentSessionMessage(input: { input.onError(outcome.error) } } - return outcome.outcome + return { outcome: outcome.outcome, clientMessageId } } diff --git a/mobile/src/session/mobile-structured-composer-command.test.ts b/mobile/src/session/mobile-structured-composer-command.test.ts index ec36a48dfe2c..e5e6bb8bd3b5 100644 --- a/mobile/src/session/mobile-structured-composer-command.test.ts +++ b/mobile/src/session/mobile-structured-composer-command.test.ts @@ -50,6 +50,30 @@ describe('mobile structured conversation commands', () => { beforeEach(() => { vi.clearAllMocks() }) + it.each(['turnActive', 'messagesUnsettled', 'promptPending'] as const)( + 'names the agent when the host refuses a command for %s', + async (reason) => { + const { input, sendRequest } = setup() + sendRequest.mockResolvedValueOnce({ + ok: true, + result: { + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + message: 'raw host diagnostic', + details: { reason } + } + } + }) + expect(await dispatchMobileStructuredCommand(input)).toBe('rejected') + expect(input.onError).toHaveBeenCalledWith( + reason === 'promptPending' + ? "Answer Codex's question or approval, then run /compact." + : "Codex is still working. Run /compact when it's done.", + undefined + ) + } + ) it.each(['/clear', '/compact'])( 'uses the command RPC for %s without an ordinary send', async (text) => { @@ -148,19 +172,19 @@ describe('mobile structured conversation commands', () => { input.busy = () => 'working' expect(await dispatchMobileStructuredCommand({ ...input, text: '/clear' })).toBe('rejected') expect(input.onError).toHaveBeenLastCalledWith( - "The agent is still working. Run /clear when it's done.", + "Codex is still working. Run /clear when it's done.", { refusedWhile: 'working' } ) input.busy = () => 'prompt' expect(await dispatchMobileStructuredCommand({ ...input, text: '/clear' })).toBe('rejected') expect(input.onError).toHaveBeenLastCalledWith( - "Answer the agent's question or approval, then run /clear.", + "Answer Codex's question or approval, then run /clear.", { refusedWhile: 'prompt' } ) input.busy = () => 'working' expect(await dispatchMobileStructuredCommand(input)).toBe('rejected') expect(input.onError).toHaveBeenLastCalledWith( - "The agent is still working. Run /compact when it's done.", + "Codex is still working. Run /compact when it's done.", { refusedWhile: 'working' } ) expect(sendRequest).not.toHaveBeenCalled() @@ -174,7 +198,7 @@ describe('mobile structured conversation commands', () => { value: { command: 'compact', state: 'completed', - error: "The agent is still working. Run /compact when it's done.", + error: "Codex is still working. Run /compact when it's done.", failure: { kind: 'commandRefused', refusal: { code: 'agent_session_operation_invalid', details: { reason: 'turnActive' } } @@ -187,14 +211,14 @@ describe('mobile structured conversation commands', () => { input.busy = () => 'working' expect(await dispatchMobileStructuredCommand(input)).toBe('rejected') expect(input.onError).toHaveBeenLastCalledWith( - "The agent is still working. Run /compact when it's done.", + "Codex is still working. Run /compact when it's done.", { refusedWhile: 'working' } ) // Ahead of the phone: said as any failure, so it can't go before it is read. input.busy = () => null expect(await dispatchMobileStructuredCommand(input)).toBe('rejected') expect(input.onError).toHaveBeenLastCalledWith( - "The agent is still working. Run /compact when it's done.", + "Codex is still working. Run /compact when it's done.", undefined ) }) diff --git a/mobile/src/session/mobile-structured-composer-command.ts b/mobile/src/session/mobile-structured-composer-command.ts index 048fb8cc1407..336920bfcce0 100644 --- a/mobile/src/session/mobile-structured-composer-command.ts +++ b/mobile/src/session/mobile-structured-composer-command.ts @@ -8,7 +8,10 @@ import { type StructuredAgentSessionComposerOptions } from '../../../src/shared/structured-agent-session-composer' import { structuredAgentSessionCommandHostRefusalCause } from '../../../src/shared/structured-agent-session-command-refusal-cause' -import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice' +import { sayAgentSessionFailureEnglish } from '../../../src/shared/agent-session-failure-copy' +import { agentSessionFailureSentence } from '../../../src/shared/agent-session-failure-words' +import { readWholeAgentSessionFailureFact } from '../../../src/shared/agent-session-failure' +import { tuiAgentDisplayName } from '../../../src/shared/tui-agent-display-names' import type { RpcClient } from '../transport/rpc-client' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { requestStructuredAgentSessionMutation } from './mobile-structured-agent-session-rpc' @@ -17,18 +20,25 @@ import type { MobileNativeChatSendErrorReporter } from './use-mobile-native-chat /** What the person sees and can do, in the words the desktop uses. */ function busyCommandText( command: AgentSessionConversationCommand, - busy: 'working' | 'prompt' + busy: 'working' | 'prompt', + agentName: string ): string { - const clear = command === 'clear' - return agentSessionWriteNoticeEnglish( - busy === 'prompt' - ? [clear ? 'clearAfterAnswer' : 'compactAfterAnswer'] - : ['agentStillWorking', clear ? 'runClearWhenDone' : 'runCompactWhenDone'] + return agentSessionFailureSentence( + { + kind: 'commandRefused', + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: busy === 'prompt' ? 'promptPending' : 'turnActive' } + } + }, + 'row', + { agentName, command } ) } export async function dispatchMobileStructuredCommand(input: { text: string + agentName?: string hasAttachments: boolean client: RpcClient sessionId: string @@ -53,6 +63,11 @@ export async function dispatchMobileStructuredCommand(input: { input.onError('Remove attachments before using a chat-session command.') return 'rejected' } + const agentName = + input.agentName ?? + (input.controller.agent + ? (tuiAgentDisplayName(input.controller.agent) ?? input.controller.agent) + : sayAgentSessionFailureEnglish('theAgent')) let unknown = false const outcome = await dispatchStructuredAgentSessionComposerCommand(input.text, { ...input.controller, @@ -61,7 +76,11 @@ export async function dispatchMobileStructuredCommand(input: { const waitsInLine = input.waitsInLine(command) const busy = waitsInLine ? null : shown if (busy) { - return { accepted: false, error: busyCommandText(command, busy), refusedWhile: busy } + return { + accepted: false, + error: busyCommandText(command, busy, agentName), + refusedWhile: busy + } } input.pending.current = true try { @@ -73,6 +92,7 @@ export async function dispatchMobileStructuredCommand(input: { method: 'agentSession.conversationCommand', fingerprintMethod: 'agentSession.conversationCommand', fields: waitsInLine ? { command, delivery: 'queue-if-active' } : { command }, + agentName, timeoutMs: Math.max(input.timeoutMs, 195_000) }) if ( @@ -90,12 +110,14 @@ export async function dispatchMobileStructuredCommand(input: { } // A refusal names its cause only when the phone showed it, as on desktop. const cause = structuredAgentSessionCommandHostRefusalCause(result.value) + const fact = readWholeAgentSessionFailureFact(result.value.failure) + const error = fact + ? agentSessionFailureSentence(fact, 'row', { agentName, command }) + : (result.value.error ?? null) return { - accepted: !result.value.error, - error: result.value.error ?? null, - ...(result.value.error && cause !== undefined && cause === shown - ? { refusedWhile: cause } - : {}) + accepted: !error, + error, + ...(error && cause !== undefined && cause === shown ? { refusedWhile: cause } : {}) } } finally { input.pending.current = false diff --git a/mobile/src/session/mobile-structured-queued-message-cards.test.ts b/mobile/src/session/mobile-structured-queued-message-cards.test.ts index ce3eda3c57b2..172411fb818d 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -30,6 +30,30 @@ function draft(overrides: Partial & { messageId: stri } describe('mobileQueuedMessageCards', () => { + it.each([ + 'The provider did not accept this message: provider_write_failed: stand-in rejected the turn.', + 'The provider stopped before it finished starting.', + 'The provider did not accept this message.' + ])('names the agent on a reason-only legacy card: %s', (returnedReason) => { + const [card] = mobileQueuedMessageCards( + [draft({ messageId: 'a', state: 'returned', returnedReason })], + [], + { pendingPrompt: false, agentName: 'Codex' } + ) + expect(card?.caption).toBe("Codex didn't accept this message.") + }) + it.each(['Codex', 'Claude'])( + 'names %s on a returned write failure without duplicating Send', + (agentName) => { + const [card] = mobileQueuedMessageCards( + [draft({ messageId: 'a', ...returnedAs(agentSessionFailureFact('writeFailed')) })], + [], + { pendingPrompt: false, agentName } + ) + expect(card?.caption).toBe(`${agentName} couldn't receive this message.`) + } + ) + it.each([ ['Claude', 'claude auth login'], ['Codex', 'codex login'], @@ -117,9 +141,11 @@ describe('mobileQueuedMessageCards', () => { expect( mobileQueuedMessageCards([failed], [], { pendingPrompt: false, agentWorking: true })[0] ?.caption - ).toBe("Couldn't send — tap Send to retry once the agent finishes") + ).toBe( + "The agent couldn't receive this message. Use Send to try again once The agent finishes." + ) expect(mobileQueuedMessageCards([failed], [], { pendingPrompt: false })[0]?.caption).toBe( - "Couldn't send — tap Send to retry" + "The agent couldn't receive this message. Use Send to try again." ) }) @@ -169,7 +195,10 @@ describe('mobileQueuedMessageCards', () => { [], { pendingPrompt: true, queuePaused: true } ) - expect(cards.map((card) => card.caption)).toEqual(["Couldn't send — tap Send to retry", null]) + expect(cards.map((card) => card.caption)).toEqual([ + "The agent couldn't receive this message. Use Send to try again.", + null + ]) expect(cards[0]?.paused).toBe(true) }) @@ -361,7 +390,7 @@ describe('mobileQueuedMessageCards', () => { { pendingPrompt: false } ) expect(cards.map((card) => card.caption)).toEqual([ - "Couldn't send — tap Send to retry", + "The agent couldn't receive this message. Use Send to try again.", 'Paused' ]) // Only a failed send alerts; a plain pause is not the card's fault. diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index 42883b4ed4df..46557f1d7443 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -20,6 +20,7 @@ import { type AgentSessionQueuePause } from '../../../src/shared/agent-session-wire' import { readAgentMessageSource } from '../../../src/shared/agent-session-message-source' +import { sayAgentSessionFailureEnglish } from '../../../src/shared/agent-session-failure-copy' import { agentMessageAttribution } from './mobile-agent-message-attribution' export type MobileQueuedMessageCard = { @@ -69,11 +70,20 @@ function returnedCaption( } /** One card's own hold: a failed conversion; the queue's pause is the list's first row. */ -function pausedCaption(reason: string | undefined, waitsForAgent: boolean): string { +function pausedCaption( + reason: string | undefined, + waitsForAgent: boolean, + agentName?: string +): string { if (reason === QUEUED_MESSAGE_PAUSED_SEND_FAILED) { - return waitsForAgent - ? "Couldn't send — tap Send to retry once the agent finishes" - : "Couldn't send — tap Send to retry" + const values = { agent: agentName ?? sayAgentSessionFailureEnglish('theAgent') } + return [ + sayAgentSessionFailureEnglish('writeFailed', values), + sayAgentSessionFailureEnglish( + waitsForAgent ? 'queueSendRetryWhenDone' : 'queueSendRetry', + values + ) + ].join(' ') } // Absent or unknown (newer host) marker: a plain pause, promising no release rule. return 'Paused' @@ -142,7 +152,8 @@ export function mobileQueuedMessageCards( : paused ? pausedCaption( draft.pausedReason, - draft.body.command !== undefined && facts.agentWorking === true + draft.body.command !== undefined && facts.agentWorking === true, + facts.agentName ) : behindReturned ? 'Waiting — a message ahead needs attention' diff --git a/mobile/src/session/mobile-structured-send-delivery.test.ts b/mobile/src/session/mobile-structured-send-delivery.test.ts index 57b18015f571..7fa3a1a1ef33 100644 --- a/mobile/src/session/mobile-structured-send-delivery.test.ts +++ b/mobile/src/session/mobile-structured-send-delivery.test.ts @@ -14,7 +14,7 @@ function accepted( } describe('mobileStructuredSendDelivery', () => { - it('keeps the typed auth fact beside guidance until the transcript can explain it', () => { + it('leaves a recorded auth rejection and its guidance in the transcript', () => { const result = structuredSendResultFixture('rejected', 'Sign in to Grok with `grok login`.') if (!('submission' in result)) { throw new Error('expected submission') @@ -24,15 +24,16 @@ describe('mobileStructuredSendDelivery', () => { detail: { text: 'Key expired.', audience: 'person' as const } } result.submission.rejection = fact + expect(mobileStructuredSendDelivery({ status: 'accepted', value: result })).toEqual({ + outcome: 'recorded-unsent', + error: null + }) + result.submission.queuedMessageId = 'earlier-card' expect(mobileStructuredSendDelivery({ status: 'accepted', value: result })).toEqual({ outcome: 'rejected', error: 'Sign in to Grok with `grok login`.', failure: fact }) - result.submission.rejection = { kind: 'providerRejected' } - expect(mobileStructuredSendDelivery({ status: 'accepted', value: result }).error).toContain( - 'Sign in' - ) }) it('reports transport and host uncertainty on this send', () => { expect(mobileStructuredSendDelivery({ status: 'unknown' })).toEqual({ @@ -106,14 +107,15 @@ describe('mobileStructuredSendDelivery', () => { }) }) - it('withholds internal provider write reasons', () => { - expect( - mobileStructuredSendDelivery(accepted('rejected', 'provider_write_failed: broken pipe')) - ).toEqual({ - outcome: 'rejected', - error: "Orca couldn't reach the agent. Your message was not sent. Send it again." - }) - }) + it.each(['provider_write_failed: broken pipe', 'Claude does not support .bmp'])( + 'leaves a recorded rejection to its row without a banner: %s', + (reason) => { + expect(mobileStructuredSendDelivery(accepted('rejected', reason))).toEqual({ + outcome: 'recorded-unsent', + error: null + }) + } + ) it('lets a kept queued card hold the text after a provider rejection', () => { const value = structuredSendResultFixture('rejected', 'Orca restarted before this was sent.') @@ -141,12 +143,6 @@ describe('mobileStructuredSendDelivery', () => { }) }) - it('shows an actionable content rejection', () => { - expect( - mobileStructuredSendDelivery(accepted('rejected', 'Claude does not support .bmp')) - ).toEqual({ outcome: 'rejected', error: 'Claude does not support .bmp' }) - }) - it.each([ ['agent_session_operation_invalid', 'Invalid operation'], ['agent_session_checkpoint_stale', 'Fence moved'], diff --git a/mobile/src/session/mobile-structured-send-delivery.ts b/mobile/src/session/mobile-structured-send-delivery.ts index c4fd5e450f38..4205ef5c39c9 100644 --- a/mobile/src/session/mobile-structured-send-delivery.ts +++ b/mobile/src/session/mobile-structured-send-delivery.ts @@ -45,9 +45,12 @@ export function mobileStructuredSendDelivery( if (submission.keptAsQueuedMessageId !== undefined) { return { outcome: 'queued', error: null } } - if (submission.queuedMessageId === undefined && dispatchWasWithdrawn(submission)) { - // The host transcript already owns this send's stopped row. - return { outcome: 'accepted', error: null } + if (submission.queuedMessageId === undefined) { + // The host row holds recorded failures and withdrawals; neither returns text or a banner. + return { + outcome: dispatchWasWithdrawn(submission) ? 'accepted' : 'recorded-unsent', + error: null + } } const failure = readWholeAgentSessionFailureFact(submission.rejection) return { diff --git a/mobile/src/session/mobile-structured-send-new-action.test.ts b/mobile/src/session/mobile-structured-send-new-action.test.ts index 71827af8640c..89c28b1b8f35 100644 --- a/mobile/src/session/mobile-structured-send-new-action.test.ts +++ b/mobile/src/session/mobile-structured-send-new-action.test.ts @@ -73,8 +73,6 @@ describe('a new phone send after an acknowledgement was lost', () => { const onError = vi.fn() const message = { sessionId: 'session', - sessionKey: 'remote-host:folder:session', - callerIdentity: 'phone', expectedRuntimeFence: 3, text: 'please continue', attachments: [], @@ -82,11 +80,15 @@ describe('a new phone send after an acknowledgement was lost', () => { } const send = () => sendMobileStructuredAgentSessionMessage({ ...message, client }) - expect(await send()).toBe('unknown') + const first = await send() + expect(first.outcome).toBe('unknown') + expect(first.clientMessageId).toBe(ids[0]) if (relaunch) { client = clientFor() } - expect(await send()).toBe('accepted') + const second = await send() + expect(second.outcome).toBe('accepted') + expect(second.clientMessageId).toBe(ids[1]) expect(delivered).toHaveLength(2) expect(new Set(ids).size).toBe(2) expect(onError).not.toHaveBeenCalled() diff --git a/mobile/src/session/mobile-structured-send-receipts.ts b/mobile/src/session/mobile-structured-send-receipts.ts new file mode 100644 index 000000000000..7d0c36c5ffb2 --- /dev/null +++ b/mobile/src/session/mobile-structured-send-receipts.ts @@ -0,0 +1,13 @@ +import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' +import { handedOffQueuedMessageIds } from '../../../src/shared/structured-agent-session-draft-hand-off' + +/** The host holds every published submission and draft hand-off, whatever its dispatch state. */ +export function mobileStructuredSendReceipts( + submissions: readonly AgentJournalSubmission[] +): ReadonlySet { + const ids = handedOffQueuedMessageIds(submissions) + for (const submission of submissions) { + ids.add(submission.clientMessageId) + } + return ids +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index 4adc88dca23a..e8938377ec20 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -91,6 +91,7 @@ vi.mock('./use-mobile-structured-agent-session', () => ({ session: structuredSessionState, ...structuredActivity, queued: { cards: [], send: vi.fn(), delete: vi.fn(), edit: vi.fn() }, + submissions: [{ clientMessageId: 'op-1' }], sendWithOutcome: structuredSendWithOutcome, cancel: structuredCancel, cancelPrompt: structuredCancelPrompt, @@ -221,7 +222,7 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { }) resetMobileNativeChatStaleInputForTests() captureSendOrigin.mockReturnValue(ORIGIN) - structuredSendWithOutcome.mockResolvedValue('accepted') + structuredSendWithOutcome.mockResolvedValue({ outcome: 'accepted', clientMessageId: 'op-1' }) act(() => { renderer = create(createElement(Harness)) }) @@ -606,7 +607,7 @@ describe('useMobileNativeChatController launch-draft wiring', () => { sessionState.transcriptLoading = true render(chatTab) - expect(draftsArgs.at(-1)).toMatchObject({ transcriptLoading: true }) + expect(draftsArgs.at(-1)).toMatchObject({ transcriptLoading: true, submissions: [{}] }) }) it('forwards a null draft for a tab that publishes none', () => { diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 9a7328c8450c..fddbd50778a6 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -133,7 +133,8 @@ export function useMobileNativeChatController(args: { chatActive: showNativeChat, transcriptLoading: nativeChatSession.transcriptLoading, transcriptSettled: nativeChatSession.status === 'ready', - queuedCards: structuredNativeChat.queued.cards + queuedCards: structuredNativeChat.queued.cards, + submissions: structuredNativeChat.submissions }) // Deliberately not gated on the chat view being visible: the streaming gate diff --git a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts index 21fc23efe724..c4fc0d88fb57 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts @@ -71,6 +71,7 @@ describe('useMobileNativeChatDrafts glued pending sends', () => { transcriptSettled?: boolean }): null { state = useMobileNativeChatDrafts({ + submissions: [], hostId: 'host', worktreeId: 'worktree', tabId: 'tab', diff --git a/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts index 0c8d3bf674a2..741d4df72dcf 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts @@ -49,6 +49,7 @@ describe('useMobileNativeChatDrafts launch draft', () => { transcriptSettled?: boolean }): null { state = useMobileNativeChatDrafts({ + submissions: [], hostId: 'host', worktreeId: 'worktree', tabId, diff --git a/mobile/src/session/use-mobile-native-chat-drafts-own-row.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-own-row.test.ts new file mode 100644 index 000000000000..0e28c5d74bc5 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-drafts-own-row.test.ts @@ -0,0 +1,191 @@ +// A structured send's bubble and its unconfirmed hold are settled by the journal's record of the +// send's own id (or of its card's hand-off), drawn or not, and never by another row with its text. + +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key' +import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' + +type QueuedCard = { messageId: string; text: string } + +function user(clientMessageId: string, text: string, unsent = false): NativeChatMessage { + return { + id: agentJournalSubmissionKey(clientMessageId), + role: 'user', + source: 'transcript', + timestamp: null, + blocks: [{ type: 'text', text }], + ...(unsent ? { unsent: true as const } : {}) + } +} + +function recorded( + clientMessageId: string, + patch: Partial = {} +): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: `body:${clientMessageId}`, + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: 1, + ...patch + } +} + +const ANSWER: NativeChatMessage = { + id: 'answer', + role: 'assistant', + source: 'transcript', + timestamp: null, + blocks: [{ type: 'text', text: 'Hi' }] +} + +describe('useMobileNativeChatDrafts with a structured send id', () => { + let renderer: ReactTestRenderer | null = null + let state: ReturnType | null = null + + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + state = null + vi.useRealTimers() + }) + + function Harness(props: { + messages: NativeChatMessage[] + queuedCards: QueuedCard[] + submissions: AgentJournalSubmission[] + }): null { + state = useMobileNativeChatDrafts({ + hostId: 'host', + worktreeId: 'worktree', + tabId: 'a', + sessionId: 'session-a', + messages: props.messages, + launchDraft: null, + transcriptLoading: false, + transcriptSettled: true, + queuedCards: props.queuedCards, + submissions: props.submissions + }) + return null + } + + async function render( + messages: NativeChatMessage[], + queuedCards: QueuedCard[] = [], + submissions: AgentJournalSubmission[] = SUBMITTED + ) { + await act(async () => { + const element = createElement(Harness, { messages, queuedCards, submissions }) + if (renderer) { + renderer.update(element) + } else { + renderer = create(element) + } + }) + } + + function origin(text: string) { + const captured = state?.captureSendOrigin(text) + if (!captured) { + throw new Error('no send origin') + } + return captured + } + + /** Holds a lost send; the returned call runs out its deadline and hands back the warning spy. */ + function holdLostSend(text: string, clientMessageId: string): () => ReturnType { + const onUnconfirmed = vi.fn() + const captured = origin(text) + act(() => state?.holdUnconfirmedSend(captured, text, onUnconfirmed, clientMessageId)) + return () => { + act(() => { + vi.advanceTimersByTime(30_000) + }) + return onUnconfirmed + } + } + + const BEFORE = [ANSWER, user('m1', 'fix the test', true)] + const SUBMITTED = [recorded('m1', { dispatchState: 'rejected' })] + + it('retires the bubble on its own row, even once the not-sent original is gone', async () => { + await render(BEFORE) + const captured = origin('fix the test') + act(() => state?.acceptSend(captured, 'fix the test', undefined, 'm2')) + expect(state?.pending.map((item) => item.text)).toEqual(['fix the test']) + // Once the resend is recorded the projection drops the not-sent original it copies. + await render([ANSWER, user('m2', 'fix the test')], [], [...SUBMITTED, recorded('m2')]) + expect(state?.pending).toEqual([]) + }) + + it('stays quiet when its own row arrives already shown as not sent', async () => { + await render(BEFORE) + const settle = holdLostSend('later', 'm2') + await render( + [...BEFORE, user('m2', 'later', true)], + [], + [...SUBMITTED, recorded('m2', { dispatchState: 'rejected' })] + ) + expect(settle()).not.toHaveBeenCalled() + }) + + it('retires the bubble on its record while the host has not answered it yet', async () => { + await render(BEFORE) + act(() => state?.acceptSend(origin('later'), 'later', undefined, 'm2')) + await render( + [...BEFORE, user('m2', 'later')], + [], + [...SUBMITTED, recorded('m2', { dispatchState: 'pending', resolvedAt: null })] + ) + expect(state?.pending).toEqual([]) + }) + + it('still warns when only another row with its text is there', async () => { + await render(BEFORE) + const settle = holdLostSend('fix the test', 'm2') + await render([...BEFORE, user('m3', 'fix the test')], [], [...SUBMITTED, recorded('m3')]) + expect(settle()).toHaveBeenCalledTimes(1) + }) + + it('stays quiet when the host holds it as its own card, not one with the same text', async () => { + await render(BEFORE, [{ messageId: 'other', text: 'queued words' }]) + const settle = holdLostSend('queued words', 'm2') + await render(BEFORE, [ + { messageId: 'other', text: 'queued words' }, + { messageId: 'm2', text: 'queued words' } + ]) + expect(settle()).not.toHaveBeenCalled() + }) + + // Its card went out under a fresh id before the phone saw the card, so no row carries its id. + it('stays quiet when its card was handed off before the phone saw it', async () => { + await render(BEFORE) + const settle = holdLostSend('queued words', 'm2') + await render( + [...BEFORE, user('fresh', 'queued words')], + [], + [...SUBMITTED, recorded('fresh', { queuedMessageId: 'm2' })] + ) + expect(settle()).not.toHaveBeenCalled() + }) + + it('still warns when a new card has its text but is not its own', async () => { + await render(BEFORE) + const settle = holdLostSend('queued words', 'm2') + await render(BEFORE, [{ messageId: 'another-phone', text: 'queued words' }]) + expect(settle()).toHaveBeenCalledTimes(1) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-drafts-photo-race.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-photo-race.test.ts new file mode 100644 index 000000000000..dd1935ed749f --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-drafts-photo-race.test.ts @@ -0,0 +1,126 @@ +// A photo send's answer can be handled after the frame carrying its record has rendered but before +// that frame's effects run, while another bubble is pending. Its photo must still bind to its own +// drawn row; the journal's record alone retires only a photo whose row the chat hides. + +import { createElement, useEffect } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it } from 'vitest' +import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key' +import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' + +const OWN_ROW = agentJournalSubmissionKey('I') +const PHOTO = 'file:///photo.jpg' + +const ROW: NativeChatMessage = { + id: OWN_ROW, + role: 'user', + source: 'transcript', + timestamp: null, + blocks: [ + { type: 'text', text: 'look' }, + { type: 'image-ref', path: '/host/photo.png' } + ] +} + +const RECORD: AgentJournalSubmission = { + clientMessageId: 'I', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null +} + +type Drafts = ReturnType +type Origin = NonNullable> + +describe('a photo send answered around the frame that carries its record', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it.each([ + { earlierBubble: false, answerBeforeEffects: false }, + { earlierBubble: false, answerBeforeEffects: true }, + { earlierBubble: true, answerBeforeEffects: false }, + { earlierBubble: true, answerBeforeEffects: true } + ])( + 'binds its photo to its own row (another bubble pending: $earlierBubble, answered before the effects: $answerBeforeEffects)', + async ({ earlierBubble, answerBeforeEffects }) => { + // The hook's latest result, through an object: a local the harness assigns would be narrowed to + // null at the reads below. + const current: { drafts?: Drafts } = {} + let origin: Origin | null = null + let answered = false + const accept = (drafts: Drafts | null): void => { + if (drafts && origin) { + drafts.acceptSend(origin, 'look', [PHOTO], 'I') + } + } + function Harness(props: { + messages: NativeChatMessage[] + submissions: AgentJournalSubmission[] + }): null { + // Declared before the hook, so it runs ahead of the hook's own effects in the same flush. + useEffect(() => { + if (answerBeforeEffects && !answered && props.messages.includes(ROW)) { + answered = true + accept(current.drafts ?? null) + } + }) + current.drafts = useMobileNativeChatDrafts({ + hostId: 'host', + worktreeId: 'worktree', + tabId: 'tab', + sessionId: 'session', + messages: props.messages, + launchDraft: null, + transcriptLoading: false, + transcriptSettled: true, + queuedCards: [], + submissions: props.submissions + }) + return null + } + const render = async (messages: NativeChatMessage[], submissions: AgentJournalSubmission[]) => + act(async () => { + const element = createElement(Harness, { messages, submissions }) + if (renderer) { + renderer.update(element) + } else { + renderer = create(element) + } + }) + + await render([], []) + if (earlierBubble) { + // An earlier send still waiting for its record keeps the retirement effect running. + const first = current.drafts?.captureSendOrigin('first') + await act(async () => { + if (first) { + current.drafts?.acceptSend(first, 'first', undefined, 'P') + } + }) + } + origin = current.drafts?.captureSendOrigin('look') ?? null + // The record, written before the send is dispatched, reaches the phone first. + await render([ROW], [RECORD]) + if (!answerBeforeEffects) { + await act(async () => accept(current.drafts ?? null)) + } + await render([ROW], [RECORD]) + + expect(current.drafts?.pending.map((item) => item.text)).toEqual( + earlierBubble ? ['first'] : [] + ) + expect(current.drafts?.imagePreviewsByMessageId[OWN_ROW]).toEqual([PHOTO]) + } + ) +}) diff --git a/mobile/src/session/use-mobile-native-chat-drafts-queued-hold.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-queued-hold.test.ts index 77c95595ded9..78017e20d61c 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts-queued-hold.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts-queued-hold.test.ts @@ -26,6 +26,7 @@ describe('useMobileNativeChatDrafts unconfirmed hold with queued cards', () => { function Harness({ queuedCards }: { queuedCards: QueuedCard[] }): null { state = useMobileNativeChatDrafts({ + submissions: [], hostId: 'host', worktreeId: 'worktree', tabId: 'a', diff --git a/mobile/src/session/use-mobile-native-chat-drafts.test.ts b/mobile/src/session/use-mobile-native-chat-drafts.test.ts index a693069dd8c7..393b8bfbe734 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.test.ts @@ -54,6 +54,7 @@ describe('useMobileNativeChatDrafts', () => { transcriptSettled?: boolean }): null { state = useMobileNativeChatDrafts({ + submissions: [], hostId: 'host', worktreeId: 'worktree', tabId, diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index 027e6a9a1d89..930b37000fcb 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -7,6 +7,7 @@ import { type Dispatch, type SetStateAction } from 'react' +import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { appendReturnedDraftText } from '../../../src/shared/returned-draft-text' import { @@ -16,6 +17,7 @@ import { migrateImagePreviewMessageIds, normalizeReconcileText } from './mobile-native-chat-draft-reconcile' +import { useMobileStructuredSendReachedHost } from './use-mobile-structured-send-reached-host' import { useMobileNativeChatUnconfirmedSends } from './use-mobile-native-chat-unconfirmed-sends' import { rebaseMobileNativeChatPendingBaselines } from './mobile-native-chat-pending-baseline' import { retireLandedMobileNativeChatPending } from './mobile-native-chat-pending-retirement' @@ -59,6 +61,9 @@ export function useMobileNativeChatDrafts(args: { transcriptSettled: boolean /** The active pane's host-held queued-draft cards (structured lane only). */ queuedCards?: readonly { messageId: string; text: string }[] + /** The structured journal's submissions (none on the terminal lane): a send recorded there, or + * handed off from its card, is settled whether or not its row is drawn. */ + submissions: readonly AgentJournalSubmission[] }): { composerText: string setComposerText: Dispatch> @@ -80,11 +85,18 @@ export function useMobileNativeChatDrafts(args: { clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void /** Put the text back after a definite rejection, after whatever the composer holds now. */ restoreRejectedDraft: (origin: MobileNativeChatSendOrigin, text: string) => void - acceptSend: (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => void + /** `clientMessageId`: the structured send's own id; the journal's record of it retires the echo. */ + acceptSend: ( + origin: MobileNativeChatSendOrigin, + text: string, + images?: string[], + clientMessageId?: string + ) => void holdUnconfirmedSend: ( origin: MobileNativeChatSendOrigin, text: string, - onUnconfirmed: () => void + onUnconfirmed: () => void, + clientMessageId?: string ) => void } { const { @@ -98,7 +110,8 @@ export function useMobileNativeChatDrafts(args: { chatActive = true, transcriptLoading, transcriptSettled, - queuedCards + queuedCards, + submissions } = args const draftKey = mobileNativeChatScopeKey(hostId, worktreeId, tabId) const pendingKey = draftKey && sessionId ? `${draftKey}\0${sessionId}` : null @@ -210,31 +223,44 @@ export function useMobileNativeChatDrafts(args: { }, []) const acceptSend = useCallback( - (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => { + ( + origin: MobileNativeChatSendOrigin, + text: string, + images?: string[], + clientMessageId?: string + ) => { if (!origin.pendingKey && !images?.length) { return } pendingCounterRef.current += 1 const id = `pending-${pendingCounterRef.current}` - const key = origin.pendingKey - if (key) { - setPendingBySession((previous) => - appendMobileNativeChatPending(previous, key, id, origin, text, images) + // Before the session is known, it waits under the draft. + const append = (previous: Record) => + appendMobileNativeChatPending( + previous, + origin.pendingKey ?? origin.draftKey, + id, + origin, + text, + images, + clientMessageId ) + if (origin.pendingKey) { + setPendingBySession(append) } else { - setPendingWaitingForSession((previous) => - appendMobileNativeChatPending(previous, origin.draftKey, id, origin, text, images) - ) + setPendingWaitingForSession(append) } }, [] ) + const reachedHost = useMobileStructuredSendReachedHost(submissions) const { holdUnconfirmedSend } = useMobileNativeChatUnconfirmedSends({ draftKey, pendingKey, messages, - ...(queuedCards ? { queuedCards } : {}) + ...(queuedCards ? { queuedCards } : {}), + reachedHost }) const waitingForSession = draftKey @@ -273,7 +299,7 @@ export function useMobileNativeChatDrafts(args: { // excluding the send's own echo, which is a separate change. const landedImagePreviews = findLandedImagePreviewEchoes( messages, - pending.filter((item) => item.baselineResolved) + pending.filter((item) => item.baselineResolved || item.clientMessageId !== undefined) ) const landedImagePendingIds = new Set(landedImagePreviews.map((preview) => preview.pendingId)) if (landedImagePreviews.length > 0) { @@ -288,7 +314,12 @@ export function useMobileNativeChatDrafts(args: { const rebased = transcriptSettled ? rebaseMobileNativeChatPendingBaselines(messages, current) : current - const next = retireLandedMobileNativeChatPending(messages, rebased, landedImagePendingIds) + const next = retireLandedMobileNativeChatPending( + messages, + rebased, + landedImagePendingIds, + reachedHost + ) if (next === current) { return previous } @@ -299,7 +330,7 @@ export function useMobileNativeChatDrafts(args: { delete remaining[pendingKey] return remaining }) - }, [messages, pending, pendingKey, transcriptSettled]) + }, [messages, pending, pendingKey, reachedHost, transcriptSettled]) return { composerText: draftKey ? (drafts[draftKey] ?? '') : '', diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure-opening-send.test.tsx b/mobile/src/session/use-mobile-native-chat-turn-disclosure-opening-send.test.tsx index 1c8e285179f1..00e55e2cd0a1 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure-opening-send.test.tsx +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure-opening-send.test.tsx @@ -88,12 +88,12 @@ function Harness(props: { pending: MobileNativeChatPendingItem[] seen: (disclosure: Disclosure) => void }): null { - // As the phone projects: no other rejected send drawn in place. + // As the phone projects: a recorded, rejected send drawn in place as not sent. const messages: NativeChatMessage[] = projectStructuredAgentSessionMessages( props.items, [], props.submissions, - { rejectedInPlace: false } + { rejectedInPlace: true } ) const { data } = buildMobileNativeChatTransientData({ messages, diff --git a/mobile/src/session/use-mobile-native-chat-unconfirmed-sends.ts b/mobile/src/session/use-mobile-native-chat-unconfirmed-sends.ts index f1b0e501fce5..e5be12ee00d6 100644 --- a/mobile/src/session/use-mobile-native-chat-unconfirmed-sends.ts +++ b/mobile/src/session/use-mobile-native-chat-unconfirmed-sends.ts @@ -10,6 +10,8 @@ import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { findLandedUnconfirmedSends, findQueuedUnconfirmedSends, + NO_STRUCTURED_SENDS, + type MobileStructuredSendReachedHost, type UnconfirmedSend } from './mobile-native-chat-draft-reconcile' import type { MobileNativeChatSendOrigin } from './mobile-native-chat-pending-echo' @@ -22,9 +24,10 @@ type QueuedCardText = { messageId: string; text: string } function settledUnconfirmedSends( messages: readonly NativeChatMessage[], queuedCards: readonly QueuedCardText[], - entries: readonly UnconfirmedSend[] + entries: readonly UnconfirmedSend[], + reachedHost: MobileStructuredSendReachedHost ): UnconfirmedSend[] { - const landed = findLandedUnconfirmedSends(messages, entries) + const landed = findLandedUnconfirmedSends(messages, entries, reachedHost) const landedSet = new Set(landed) return [ ...landed, @@ -41,29 +44,47 @@ export function useMobileNativeChatUnconfirmedSends(args: { messages: readonly NativeChatMessage[] /** The active pane's queued-draft cards; a send the host holds as one is delivered. */ queuedCards?: readonly QueuedCardText[] + /** Structured lane: whether the journal holds the send made under an id. */ + reachedHost?: MobileStructuredSendReachedHost }): { holdUnconfirmedSend: ( origin: MobileNativeChatSendOrigin, text: string, - onUnconfirmed: () => void + onUnconfirmed: () => void, + /** Structured lane: the id the send went out under; the journal's record of it, or its own + * card, settles the hold. */ + clientMessageId?: string ) => void } { - const { draftKey, pendingKey, messages, queuedCards = NO_QUEUED_CARDS } = args + const { + draftKey, + pendingKey, + messages, + queuedCards = NO_QUEUED_CARDS, + reachedHost = NO_STRUCTURED_SENDS + } = args const messagesRef = useRef(messages) const queuedCardsRef = useRef(queuedCards) + const reachedHostRef = useRef(reachedHost) const activeDraftKeyRef = useRef(draftKey) const activePendingKeyRef = useRef(pendingKey) // Read only by the post-send hold, which runs after the commit that set them. useLayoutEffect(() => { messagesRef.current = messages queuedCardsRef.current = queuedCards + reachedHostRef.current = reachedHost activeDraftKeyRef.current = draftKey activePendingKeyRef.current = pendingKey - }, [draftKey, messages, pendingKey, queuedCards]) + }, [draftKey, messages, pendingKey, queuedCards, reachedHost]) const mountedRef = useRef(false) const unconfirmedRef = useRef([]) const holdUnconfirmedSend = useCallback( - (origin: MobileNativeChatSendOrigin, text: string, onUnconfirmed: () => void) => { + ( + origin: MobileNativeChatSendOrigin, + text: string, + onUnconfirmed: () => void, + clientMessageId?: string + ) => { if (!mountedRef.current) { return } @@ -79,12 +100,18 @@ export function useMobileNativeChatUnconfirmedSends(args: { ...(origin.baselineQueuedMessageIds ? { baselineQueuedMessageIds: origin.baselineQueuedMessageIds } : {}), + ...(clientMessageId !== undefined ? { clientMessageId } : {}), deadline: null } // Why: the transcript event (or the card) can beat the lost RPC acknowledgement. if ( isActiveTranscript && - settledUnconfirmedSends(messagesRef.current, queuedCardsRef.current, [entry]).length > 0 + settledUnconfirmedSends( + messagesRef.current, + queuedCardsRef.current, + [entry], + reachedHostRef.current + ).length > 0 ) { return } @@ -106,7 +133,7 @@ export function useMobileNativeChatUnconfirmedSends(args: { entry.draftKey === draftKey && (entry.pendingKey === null || entry.pendingKey === pendingKey) ) - const landed = settledUnconfirmedSends(messages, queuedCards, relevant) + const landed = settledUnconfirmedSends(messages, queuedCards, relevant, reachedHost) if (landed.length === 0) { return } @@ -115,7 +142,7 @@ export function useMobileNativeChatUnconfirmedSends(args: { for (const entry of landed) { clearTimeout(entry.deadline ?? undefined) } - }, [messages, queuedCards, draftKey, pendingKey]) + }, [messages, queuedCards, draftKey, pendingKey, reachedHost]) useEffect(() => { mountedRef.current = true diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx index 89e99de2a50e..117860d49432 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx @@ -185,7 +185,7 @@ describe('mobile structured queued messages', () => { }) await mountSession(CAPABLE) await act(async () => { - expect(await hook!.sendWithOutcome('queue me')).toBe('queued') + expect((await hook!.sendWithOutcome('queue me')).outcome).toBe('queued') }) const { params, envelope } = requestOf('agentSession.send') expect(params.delivery).toBe('queue-if-active') @@ -220,7 +220,7 @@ describe('mobile structured queued messages', () => { }) await mountSession(LEGACY) await act(async () => { - expect(await hook!.sendWithOutcome('plain send')).toBe('accepted') + expect((await hook!.sendWithOutcome('plain send')).outcome).toBe('accepted') }) const { params, envelope } = requestOf('agentSession.send') expect('delivery' in params).toBe(false) @@ -247,12 +247,12 @@ describe('mobile structured queued messages', () => { }) await mountSession(CAPABLE) await act(async () => { - expect(await hook!.sendWithOutcome('again')).toBe('unknown') + expect((await hook!.sendWithOutcome('again')).outcome).toBe('unknown') }) unmountSession() await mountSession(LEGACY) await act(async () => { - expect(await hook!.sendWithOutcome('again')).toBe('accepted') + expect((await hook!.sendWithOutcome('again')).outcome).toBe('accepted') }) const first = requestOf('agentSession.send', 0) const second = requestOf('agentSession.send', 1) @@ -279,12 +279,12 @@ describe('mobile structured queued messages', () => { }) await mountSession(CAPABLE, snapshotEvent({ runningTurn: true })) await act(async () => { - expect(await hook!.sendWithOutcome('held')).toBe('unknown') + expect((await hook!.sendWithOutcome('held')).outcome).toBe('unknown') }) const firstId = String(requestOf('agentSession.send').envelope.clientOperationId) act(() => listener?.(batchEvent([queuedDraft({ messageId: firstId })]))) await act(async () => { - expect(await hook!.sendWithOutcome('held')).toBe('queued') + expect((await hook!.sendWithOutcome('held')).outcome).toBe('queued') }) expect(attempts).toBe(2) expect(requestOf('agentSession.send', 1).envelope.clientOperationId).not.toBe(firstId) diff --git a/mobile/src/session/use-mobile-structured-agent-session-retry-rows.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-retry-rows.test.tsx index ee0ed68bda6d..a946fae8b73d 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-retry-rows.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-retry-rows.test.tsx @@ -69,7 +69,7 @@ afterEach(() => { listener = null }) -it('draws one row for a run of provider retries, with what failed on its second line', async () => { +it('draws one row for provider retries without a technical transport cause', async () => { const client: RpcClient = { sendRequest: async () => ({ id: 'request-1', @@ -113,7 +113,7 @@ it('draws one row for a run of provider retries, with what failed on its second [ expect.objectContaining({ type: 'text', - text: 'Codex is retrying: Reconnecting... 3/5.\nstream disconnected before completion' + text: 'Codex is retrying: Reconnecting... 3/5.' }) ] ]) diff --git a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx index 1af770c669c0..a7c83375b47c 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx @@ -7,6 +7,7 @@ import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-sessi import type { RpcClient } from '../transport/rpc-client' import { fieldsOf, ok } from './use-mobile-structured-agent-session-queued.test-fixture' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key' import { structuredSendResultFixture } from './structured-agent-send-result.test-fixture' import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' @@ -150,6 +151,65 @@ describe('mobile structured send actions', () => { listener = null }) + // The host's row is where a recorded, rejected message lives on the phone, as on the desktop. + it('shows a message the host recorded and then rejected as not sent, in place', async () => { + await mountSession() + const event = snapshotEvent() + if (event.type !== 'snapshot') { + throw new Error('expected a snapshot') + } + const itemId = agentJournalSubmissionKey('rejected-1') + act(() => + listener?.({ + ...event, + page: { + ...event.page, + items: [ + { + itemId, + revision: 1, + sequence: 1, + observedAt: 10, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'lost' }] } + } + ], + submissions: [ + { + clientMessageId: 'rejected-1', + fence: 3, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + reason: 'provider_write_failed: broken pipe', + submittedAt: 10, + resolvedAt: 10 + } + ] + } + }) + ) + expect(hook!.session.messages).toEqual([expect.objectContaining({ id: itemId, unsent: true })]) + }) + + // The transcript owns a message the host recorded, so the composer never gets it back. + it('answers a recorded, rejected send as not sent in the chat: no banner and no hand-back', async () => { + sendRequest.mockImplementation(async (method) => + method === 'agentSession.send' + ? sendResult('rejected', 'provider_write_failed: broken pipe') + : method === 'agentSession.options' + ? ok({ models: [], current: {} }) + : ok({}) + ) + await mountSession() + + await act(async () => { + expect((await hook!.sendWithOutcome('recorded, then rejected')).outcome).toBe( + 'recorded-unsent' + ) + }) + expect(onSendError).not.toHaveBeenCalled() + }) + it.each(['unknown', 'pending', 'accepted'] as const)( 'a later Send owns a new id even when the earlier host answer was %s', async (state) => { @@ -159,8 +219,8 @@ describe('mobile structured send actions', () => { await mountSession() await act(async () => { const outcome = state === 'unknown' ? 'unknown' : 'accepted' - expect(await hook!.sendWithOutcome('same text')).toBe(outcome) - expect(await hook!.sendWithOutcome('same text')).toBe(outcome) + expect((await hook!.sendWithOutcome('same text')).outcome).toBe(outcome) + expect((await hook!.sendWithOutcome('same text')).outcome).toBe(outcome) }) expect(new Set(sentIds()).size).toBe(2) } @@ -190,7 +250,7 @@ describe('mobile structured send actions', () => { const messages = hook!.session.messages expect(messages).toHaveLength(1) await act(async () => { - expect(await hook!.sendWithOutcome('same text')).toBe('accepted') + expect((await hook!.sendWithOutcome('same text')).outcome).toBe('accepted') }) expect(hook!.session.messages).toEqual(messages) expect(event.page.submissions[0]?.dispatchState).toBe('unknown') @@ -237,16 +297,16 @@ describe('mobile structured send actions', () => { }) await mountSession() await act(async () => { - expect(await hook!.sendWithOutcome('my message')).toBe('rejected') + expect((await hook!.sendWithOutcome('my message')).outcome).toBe('recorded-unsent') }) - expect(onSendError).toHaveBeenCalledExactlyOnceWith(words.text, { failure: fact }) + expect(onSendError).not.toHaveBeenCalled() if (order === 'reply-before-row') { - expect(renderer!.root.findByType('span').children.join('')).toBe(words.text) + expect(renderer!.root.findByType('span').children).toEqual([]) act(() => listener?.(event)) } - expect(renderer!.root.findByType('span').children.join('')).toBe('Your message was not sent.') + expect(renderer!.root.findByType('span').children).toEqual([]) expect(JSON.stringify(hook!.session.messages)).toContain(words.text) - expect(onSendError).toHaveBeenCalledTimes(1) + expect(onSendError).not.toHaveBeenCalled() } ) @@ -259,14 +319,14 @@ describe('mobile structured send actions', () => { }) await mountSession() await act(async () => { - expect(await hook!.sendWithOutcome('survive remount')).toBe('unknown') + expect((await hook!.sendWithOutcome('survive remount')).outcome).toBe('unknown') }) act(() => renderer?.unmount()) renderer = null listener = null await mountSession() await act(async () => { - expect(await hook!.sendWithOutcome('survive remount')).toBe('unknown') + expect((await hook!.sendWithOutcome('survive remount')).outcome).toBe('unknown') }) expect(new Set(sentIds()).size).toBe(2) }) @@ -279,12 +339,14 @@ describe('mobile structured send actions', () => { await act(async () => { for (const path of ['/tmp/original.png', '/tmp/reuploaded.png']) { expect( - await hook!.sendWithOutcome('describe', undefined, undefined, [ - { - path, - previewUri: 'file:///photo.jpg' - } - ]) + ( + await hook!.sendWithOutcome('describe', undefined, undefined, [ + { + path, + previewUri: 'file:///photo.jpg' + } + ]) + ).outcome ).toBe('unknown') } }) @@ -304,8 +366,8 @@ describe('mobile structured send actions', () => { ) await mountSession() await act(async () => { - expect(await hook!.sendWithOutcome('stopped')).toBe('accepted') - expect(await hook!.sendWithOutcome('stopped')).toBe('accepted') + expect((await hook!.sendWithOutcome('stopped')).outcome).toBe('accepted') + expect((await hook!.sendWithOutcome('stopped')).outcome).toBe('accepted') }) expect(calls()).toHaveLength(2) expect(new Set(sentIds()).size).toBe(2) @@ -330,8 +392,8 @@ describe('mobile structured send actions', () => { }) await mountSession() await act(async () => { - expect(await hook!.sendWithOutcome('again')).toBe('rejected') - expect(await hook!.sendWithOutcome('again')).toBe('accepted') + expect((await hook!.sendWithOutcome('again')).outcome).toBe('rejected') + expect((await hook!.sendWithOutcome('again')).outcome).toBe('accepted') }) expect(new Set(sentIds()).size).toBe(2) } @@ -356,8 +418,8 @@ describe('mobile structured send actions', () => { }) await mountSession() await act(async () => { - expect(await hook!.sendWithOutcome('again')).toBe('rejected') - expect(await hook!.sendWithOutcome('again')).toBe('accepted') + expect((await hook!.sendWithOutcome('again')).outcome).toBe('rejected') + expect((await hook!.sendWithOutcome('again')).outcome).toBe('accepted') }) expect(new Set(sentIds()).size).toBe(2) }) @@ -370,7 +432,7 @@ describe('mobile structured send actions', () => { ) await mountSession() await act(async () => { - expect(await hook!.sendWithOutcome('new action')).toBe('accepted') + expect((await hook!.sendWithOutcome('new action')).outcome).toBe('accepted') }) expect(asyncStorage.getItem).not.toHaveBeenCalled() expect(asyncStorage.setItem).not.toHaveBeenCalled() @@ -380,7 +442,9 @@ describe('mobile structured send actions', () => { it('sends nothing once the action budget expires', async () => { await mountSession() await act(async () => { - expect(await hook!.sendWithOutcome('never attempted', undefined, 0)).toBe('rejected') + expect((await hook!.sendWithOutcome('never attempted', undefined, 0)).outcome).toBe( + 'rejected' + ) }) expect(calls()).toHaveLength(0) }) diff --git a/mobile/src/session/use-mobile-structured-agent-session.test.tsx b/mobile/src/session/use-mobile-structured-agent-session.test.tsx index db9657a4dd8b..c797f1931cb4 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session.test.tsx @@ -366,7 +366,7 @@ describe('useMobileStructuredAgentSession', () => { let outcome: MobileNativeChatSendOutcome = 'rejected' await act(async () => { - outcome = await hook!.sendWithOutcome('hello') + outcome = (await hook!.sendWithOutcome('hello')).outcome }) expect(outcome).toBe('accepted') @@ -497,9 +497,11 @@ describe('useMobileStructuredAgentSession', () => { let outcome: MobileNativeChatSendOutcome = 'rejected' await act(async () => { - outcome = await hook.sendWithOutcome('look at this', undefined, undefined, [ - { path: '/tmp/a.png', previewUri: 'file:///a.jpg' } - ]) + outcome = ( + await hook.sendWithOutcome('look at this', undefined, undefined, [ + { path: '/tmp/a.png', previewUri: 'file:///a.jpg' } + ]) + ).outcome }) expect(outcome).toBe('accepted') @@ -535,7 +537,7 @@ describe('useMobileStructuredAgentSession', () => { let outcome: MobileNativeChatSendOutcome = 'accepted' await act(async () => { - outcome = await hook!.sendWithOutcome('look at this', ['file:///a.jpg']) + outcome = (await hook!.sendWithOutcome('look at this', ['file:///a.jpg'])).outcome }) expect(outcome).toBe('rejected') diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index fb3c69c51aa6..65ffe88bdb31 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -142,10 +142,10 @@ export function useMobileStructuredAgentSession(args: { [agent, state.items] ) const messages = useMemo( - // Off: the phone hands a rejected message back to its composer, so a row would show it twice. + // As on the desktop: a message the host recorded lives on its row, never back in the composer. () => projectStructuredAgentSessionMessages(transcriptItems, [], state.submissions, { - rejectedInPlace: false + rejectedInPlace: true }), [transcriptItems, state.submissions] ) @@ -273,6 +273,7 @@ export function useMobileStructuredAgentSession(args: { isWorking, turnId, turnIndicator, + submissions: state.submissions, ...turnTiming, sendWithOutcome, cancel: () => { diff --git a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts index aadd8de4a1a9..6e0e2d0deec0 100644 --- a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts +++ b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts @@ -18,7 +18,7 @@ const ORIGIN: MobileNativeChatSendOrigin = { describe('useMobileStructuredNativeChatSendBridge', () => { let renderer: ReactTestRenderer | null = null - let sendWithOutcome: (text: string) => Promise + let sendWithOutcome: (text: string, images?: string[]) => Promise const acceptSend = vi.fn() const captureSendOrigin = vi.fn(() => ORIGIN) const clearDraftForSend = vi.fn() @@ -57,29 +57,34 @@ describe('useMobileStructuredNativeChatSendBridge', () => { }) it('optimistically echoes accepted commands owned by the active agent', async () => { - sendStructured.mockResolvedValue('accepted') + sendStructured.mockResolvedValue({ outcome: 'accepted', clientMessageId: 'op-1' }) mount('claude') await expect(sendWithOutcome('/init')).resolves.toBe('accepted') - expect(acceptSend).toHaveBeenCalledWith(ORIGIN, '/init', undefined) + expect(acceptSend).toHaveBeenCalledWith(ORIGIN, '/init', undefined, 'op-1') expect(restoreRejectedDraft).not.toHaveBeenCalled() }) it('holds unknown delivery for commands owned by the active agent', async () => { - sendStructured.mockResolvedValue('unknown') + sendStructured.mockResolvedValue({ outcome: 'unknown', clientMessageId: 'op-1' }) mount('claude') await expect(sendWithOutcome('/review')).resolves.toBe('unknown') - expect(holdUnconfirmedSend).toHaveBeenCalledWith(ORIGIN, '/review', expect.any(Function)) + expect(holdUnconfirmedSend).toHaveBeenCalledWith( + ORIGIN, + '/review', + expect.any(Function), + 'op-1' + ) expect(restoreRejectedDraft).not.toHaveBeenCalled() }) it('never echoes a queued send as an optimistic transcript bubble', async () => { // The host holds the draft and publishes it as a card above the composer; // a `pending-N` bubble here would show the message twice and retire never. - sendStructured.mockResolvedValue('queued') + sendStructured.mockResolvedValue({ outcome: 'queued', clientMessageId: 'op-1' }) mount('claude') await expect(sendWithOutcome('queue me')).resolves.toBe('queued') @@ -90,7 +95,7 @@ describe('useMobileStructuredNativeChatSendBridge', () => { }) it('keeps host-command reconciliation for Codex', async () => { - sendStructured.mockResolvedValue('unknown') + sendStructured.mockResolvedValue({ outcome: 'unknown', clientMessageId: 'op-1' }) mount('codex') await expect(sendWithOutcome('/review')).resolves.toBe('unknown') @@ -98,4 +103,17 @@ describe('useMobileStructuredNativeChatSendBridge', () => { expect(restoreRejectedDraft).toHaveBeenCalledWith(ORIGIN, '/review') expect(holdUnconfirmedSend).not.toHaveBeenCalled() }) + + // Its row in the chat holds the text, as not sent: the bubble, with its local photo, waits for + // that row by id, and nothing goes back to the composer. + it('echoes a recorded, rejected send under its own id and hands nothing back', async () => { + sendStructured.mockResolvedValue({ outcome: 'recorded-unsent', clientMessageId: 'op-2' }) + mount('claude') + + await expect(sendWithOutcome('look', ['file:///photo.jpg'])).resolves.toBe('recorded-unsent') + + expect(acceptSend).toHaveBeenCalledWith(ORIGIN, 'look', ['file:///photo.jpg'], 'op-2') + expect(restoreRejectedDraft).not.toHaveBeenCalled() + expect(onSendError).not.toHaveBeenCalled() + }) }) diff --git a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts index 274390eca6ba..5d4698a2ae65 100644 --- a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts +++ b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts @@ -2,6 +2,7 @@ import { useCallback } from 'react' import type { AgentSessionHandleProvider } from '../../../src/shared/agent-session-provider-handle' import { isStructuredAgentSessionComposerCommand } from '../../../src/shared/structured-agent-session-composer' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import type { MobileStructuredSendResult } from './mobile-structured-agent-session-send' import type { MobileNativeChatSendOrigin } from './use-mobile-native-chat-drafts' type StructuredNativeChatAttachment = { @@ -17,14 +18,20 @@ export function useMobileStructuredNativeChatSendBridge(args: { images?: string[], deadline?: number, attachments?: readonly StructuredNativeChatAttachment[] - ) => Promise + ) => Promise captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void - acceptSend: (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => void + acceptSend: ( + origin: MobileNativeChatSendOrigin, + text: string, + images?: string[], + clientMessageId?: string + ) => void holdUnconfirmedSend: ( origin: MobileNativeChatSendOrigin, text: string, - onUnconfirmed: () => void + onUnconfirmed: () => void, + clientMessageId?: string ) => void restoreRejectedDraft: (origin: MobileNativeChatSendOrigin, text: string) => void onSendError: (message: string) => void @@ -61,7 +68,7 @@ export function useMobileStructuredNativeChatSendBridge(args: { } const isHostCommand = isStructuredAgentSessionComposerCommand(text, agent) clearDraftForSend(origin, text) - const outcome = + const { outcome, clientMessageId } = attachments !== undefined ? await sendStructured(text, images, deadline, attachments) : deadline !== undefined @@ -69,11 +76,15 @@ export function useMobileStructuredNativeChatSendBridge(args: { : images !== undefined ? await sendStructured(text, images) : await sendStructured(text) - if (outcome === 'accepted') { + // Its own row settles the bubble or the hold, whatever its text, place or state. + const ownId = clientMessageId ?? undefined + if (outcome === 'accepted' || outcome === 'recorded-unsent') { + // A recorded, rejected send is drawn as not sent; the bubble keeps its local photo until + // that row arrives. if (!isHostCommand) { - acceptSend(origin, text.trimEnd(), images) + acceptSend(origin, text.trimEnd(), images, ownId) } - return 'accepted' + return outcome } if (outcome === 'queued') { // The host holds the draft and publishes it as a card above the @@ -85,8 +96,11 @@ export function useMobileStructuredNativeChatSendBridge(args: { restoreRejectedDraft(origin, text) return 'unknown' } - holdUnconfirmedSend(origin, text.trimEnd(), () => - onSendError('Delivery unconfirmed — check chat before retrying') + holdUnconfirmedSend( + origin, + text.trimEnd(), + () => onSendError('Delivery unconfirmed — check chat before retrying'), + ownId ) return 'unknown' } diff --git a/mobile/src/session/use-mobile-structured-send-reached-host.ts b/mobile/src/session/use-mobile-structured-send-reached-host.ts new file mode 100644 index 000000000000..a074f2a23faa --- /dev/null +++ b/mobile/src/session/use-mobile-structured-send-reached-host.ts @@ -0,0 +1,15 @@ +import { useMemo } from 'react' +import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' +import type { MobileStructuredSendReachedHost } from './mobile-native-chat-draft-reconcile' +import { mobileStructuredSendReceipts } from './mobile-structured-send-receipts' + +/** Whether the loaded journal holds the structured send made under an id, in any state; the same + * record the phone's id reconciliation reads. Rebuilt only when the submissions change. */ +export function useMobileStructuredSendReachedHost( + submissions: readonly AgentJournalSubmission[] +): MobileStructuredSendReachedHost { + return useMemo(() => { + const sends = mobileStructuredSendReceipts(submissions) + return (clientMessageId) => sends.has(clientMessageId) + }, [submissions]) +} diff --git a/mobile/src/session/use-mobile-structured-send-with-outcome.ts b/mobile/src/session/use-mobile-structured-send-with-outcome.ts index c6c8e9604e07..3262339bc29d 100644 --- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts +++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts @@ -4,6 +4,7 @@ // host advertises the queue and no pending prompt is one this build cannot answer. import { useCallback } from 'react' +import { tuiAgentDisplayName } from '../../../src/shared/tui-agent-display-names' import { runningStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn' import { pendingPromptsAllUnanswerableHere } from '../../../src/shared/agent-session-approval-subject' import { @@ -13,10 +14,13 @@ import { import type { StructuredAgentSessionComposerOptions } from '../../../src/shared/structured-agent-session-composer' import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' import type { RpcClient } from '../transport/rpc-client' -import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { dispatchMobileStructuredCommand } from './mobile-structured-composer-command' +import { + MOBILE_STRUCTURED_SEND_NOT_SENT, + sendMobileStructuredAgentSessionMessage, + type MobileStructuredSendResult +} from './mobile-structured-agent-session-send' import type { MobileNativeChatSendErrorReporter } from './use-mobile-native-chat-send-error' -import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-agent-session-send' import { timeoutForDeadline } from './mobile-structured-agent-session-rpc' import { pendingStructuredApproval, @@ -56,7 +60,7 @@ export function useMobileStructuredSendWithOutcome(args: { images?: string[], deadline?: number, attachments?: readonly StructuredMobileSendAttachment[] -) => Promise { +) => Promise { const { agent, client, @@ -75,24 +79,25 @@ export function useMobileStructuredSendWithOutcome(args: { images?: string[], deadline?: number, attachments?: readonly StructuredMobileSendAttachment[] - ): Promise => { + ): Promise => { const currentFence = stateRef.current.fence if (!client || !sessionId || !enabled || currentFence === null) { onSendError('Message not sent (disconnected)') - return 'rejected' + return MOBILE_STRUCTURED_SEND_NOT_SENT } const timeoutMs = timeoutForDeadline(deadline) if (timeoutMs === null) { onSendError('Message not sent') - return 'rejected' + return MOBILE_STRUCTURED_SEND_NOT_SENT } if (attachments === undefined && images !== undefined && images.length > 0) { onSendError('Message not sent') - return 'rejected' + return MOBILE_STRUCTURED_SEND_NOT_SENT } const sendAttachments = attachments ?? [] const commandOutcome = await dispatchMobileStructuredCommand({ text, + agentName: agent ? (tuiAgentDisplayName(agent) ?? agent) : undefined, hasAttachments: Boolean(sendAttachments.length || images?.length), client, sessionId, @@ -119,11 +124,11 @@ export function useMobileStructuredSendWithOutcome(args: { timeoutMs }) if (commandOutcome !== null) { - return commandOutcome + return { outcome: commandOutcome, clientMessageId: null } } const body = structuredAgentSessionSendBody(text, sendAttachments) if (body.blocks.length === 0) { - return 'rejected' + return MOBILE_STRUCTURED_SEND_NOT_SENT } return sendMobileStructuredAgentSessionMessage({ client, diff --git a/src/main/acp/acp-structured-lane.ts b/src/main/acp/acp-structured-lane.ts index 0fe2c6295f8b..db83ed5df436 100644 --- a/src/main/acp/acp-structured-lane.ts +++ b/src/main/acp/acp-structured-lane.ts @@ -59,6 +59,7 @@ export class AcpStructuredLane { this.logger = neverThrowingStructuredAgentSessionLogger(deps.logger) this.translator = new AcpTimelineTranslator({ sessionId: deps.providerSessionId, + agent: deps.agent, dialect: deps.dialect, agentName: deps.agentName, onTextDropped: (drop) => this.reportTextDrop(drop) diff --git a/src/main/acp/acp-structured-prompt-failure.test.ts b/src/main/acp/acp-structured-prompt-failure.test.ts index 44c452504803..eb4d8fc61c32 100644 --- a/src/main/acp/acp-structured-prompt-failure.test.ts +++ b/src/main/acp/acp-structured-prompt-failure.test.ts @@ -59,18 +59,45 @@ const OPENCODE_PROVIDER_ERROR = { data: { service: 'session', errorName: 'APIError' } } -describe.each(['opencode', 'grok'])('%s prompt failure detail', (agent) => { +const CHECK_THE_CHAT = 'Check the chat before trying again.' + +describe.each([ + ['opencode', 'OpenCode'], + ['grok', 'Grok'] +])('%s prompt failure detail', (agent, name) => { const spec = specFor(agent) + // The line names the agent and quotes only readable words; the agent's own words ride Details. it.each([ - ['data.details', { code: -32603, message: 'Internal error', data: { details: WORDS } }, WORDS], - ['string data', { code: -32603, message: 'Internal error', data: WORDS }, WORDS], - ['no data', { code: -32603, message: 'Internal error' }, 'Internal error'], - ['metadata-only data', OPENCODE_PROVIDER_ERROR, 'Internal error: CAPTURE_PROVIDER_400'] - ])('shows the agent words from %s', async (_label, error, text) => { + [ + 'data.details', + { code: -32603, message: 'Internal error', data: { details: WORDS } }, + WORDS, + `${name} ran into a problem: ${WORDS} ${CHECK_THE_CHAT}` + ], + [ + 'string data', + { code: -32603, message: 'Internal error', data: WORDS }, + WORDS, + `${name} ran into a problem: ${WORDS} ${CHECK_THE_CHAT}` + ], + [ + 'no data', + { code: -32603, message: 'Internal error' }, + 'Internal error', + `${name} ran into a problem: Internal error. ${CHECK_THE_CHAT}` + ], + [ + 'metadata-only data', + OPENCODE_PROVIDER_ERROR, + 'Internal error: CAPTURE_PROVIDER_400', + `${name} ran into a problem. ${CHECK_THE_CHAT}` + ] + ])('keeps the agent words from %s', async (_label, error, words, line) => { const { failures } = await failFirstPrompt(spec, error) expect(failures).toHaveLength(1) - expect(failures[0]?.text).toBe(text) + expect(failures[0]?.text).toBe(line) + expect(failures[0]?.providerFrame?.payload.head).toBe(words) expect(failures[0]?.failure?.kind).not.toBe('notSignedIn') }) @@ -91,15 +118,25 @@ describe.each(['opencode', 'grok'])('%s prompt failure detail', (agent) => { describe('prompt refused before its turn began', () => { const spec = specFor('grok') + // The rejection keeps the agent's words; its sentence quotes them only when a person can read them. it.each([ - [{ code: -32603, message: 'Internal error', data: { details: WORDS } }, WORDS], - [OPENCODE_PROVIDER_ERROR, 'Internal error: CAPTURE_PROVIDER_400'] - ])('quotes the agent words: %j', async (error, text) => { + [ + { code: -32603, message: 'Internal error', data: { details: WORDS } }, + WORDS, + `Grok didn't accept this message: ${WORDS}` + ], + [ + OPENCODE_PROVIDER_ERROR, + 'Internal error: CAPTURE_PROVIDER_400', + "Grok didn't accept this message." + ] + ])('records the agent words: %j', async (error, text, reason) => { const { rig, failures } = await failFirstPrompt(spec, error, true) expect(failures).toHaveLength(0) const rejected = rig.settled.find((item) => 'state' in item && item.state === 'rejected') expect(rejected).toMatchObject({ clientMessageId: 'first', + reason, rejection: expect.objectContaining({ kind: 'providerRejected', detail: { text, audience: 'person' } diff --git a/src/main/acp/acp-structured-sign-in.test.ts b/src/main/acp/acp-structured-sign-in.test.ts index f7349b78470c..bcf9069a06f8 100644 --- a/src/main/acp/acp-structured-sign-in.test.ts +++ b/src/main/acp/acp-structured-sign-in.test.ts @@ -103,10 +103,10 @@ it.each([ 'No model selected.\n\nUse /login, set an API key environment variable, or create /host/agent.db\n\nThen use /model to select a model.', 'notSignedIn' ], - ['No model selected', undefined], - ['Rate limit exceeded', undefined], - ['Network connection failed', undefined], - ['No API key found elsewhere', undefined] + ['No model selected', 'providerError'], + ['Rate limit exceeded', 'providerError'], + ['Network connection failed', 'providerError'], + ['No API key found elsewhere', 'providerError'] ] as const)('reads OMP internal-error detail without guessing auth: %s', async (detail, kind) => { const spec = ACP_LAUNCH_SPECS.find((entry) => entry.agent === 'omp') if (!spec) { diff --git a/src/main/acp/acp-timeline-fixture.test-support.ts b/src/main/acp/acp-timeline-fixture.test-support.ts index c611cfabbb99..158581a3bf5c 100644 --- a/src/main/acp/acp-timeline-fixture.test-support.ts +++ b/src/main/acp/acp-timeline-fixture.test-support.ts @@ -71,7 +71,12 @@ export async function openAcpFixtureRig() { const rig = await openProviderTimelineRig() let providerSessionId = 'session-1' const translator = () => - new AcpTimelineTranslator({ sessionId: providerSessionId, dialect: GROK_ACP_DIALECT }) + new AcpTimelineTranslator({ + sessionId: providerSessionId, + dialect: GROK_ACP_DIALECT, + agentName: 'Grok', + agent: 'grok' + }) let lane = translator() const requests = new Map< string | number, diff --git a/src/main/acp/acp-timeline-generic.test.ts b/src/main/acp/acp-timeline-generic.test.ts index b7f6fc8a666a..c1c6fcc65f21 100644 --- a/src/main/acp/acp-timeline-generic.test.ts +++ b/src/main/acp/acp-timeline-generic.test.ts @@ -169,11 +169,11 @@ describe('generic ACP translation', () => { ) it.each([ - [undefined, 'The agent ended this turn with an error.'], - ['Agent X', 'Agent X ended this turn with an error.'] + [undefined, 'The agent'], + ['Agent X', 'Agent X'] ])( "writes a failed turn row in the agent's words, or names the agent (%s) without any", - async (agentName, fallback) => { + async (agentName, named) => { const { rig, translator, apply } = await genericRig(agentName ? { agentName } : {}) apply(translator.openPrompt('send-1', 1000).events) apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1100)) @@ -181,14 +181,40 @@ describe('generic ACP translation', () => { apply(translator.promptFailed('send-2', new AcpAgentError(-32603, 'Upstream failed'), 1300)) apply(translator.openPrompt('send-3', 1400).events) apply(translator.promptFailed('send-3', new AcpAgentError(-32603, ''), 1500)) + apply(translator.openPrompt('send-4', 1600).events) + const technical = 'provider_write_failed: write EPIPE' + apply(translator.promptFailed('send-4', new AcpAgentError(-32603, technical), 1700)) const rows = (await rig.rows()).filter((row) => row.body.kind === 'status') + const details = (text: string) => ({ + provider: 'acp', + kind: 'turn:failed', + payload: expect.objectContaining({ head: text, truncated: false }) + }) expect(rows.map((row) => row.body)).toEqual([ - { kind: 'status', tone: 'error', text: 'Upstream failed' }, - { kind: 'status', tone: 'error', text: fallback } + { + kind: 'status', + tone: 'error', + text: `${named} ran into a problem: Upstream failed. Check the chat before trying again.`, + failure: { + kind: 'providerError', + detail: { text: 'Upstream failed', audience: 'person' } + }, + providerFrame: details('Upstream failed') + }, + { kind: 'status', tone: 'error', text: `${named} ended this turn with an error.` }, + { + kind: 'status', + tone: 'error', + text: `${named} ran into a problem. Check the chat before trying again.`, + failure: { kind: 'providerError', detail: { text: technical, audience: 'person' } }, + // Hidden from the line, the technical reason stays readable behind Details. + providerFrame: details(technical) + } ]) expect((await rig.turns()).map((turn) => turn.outcome)).toEqual([ 'success', 'failure', + 'failure', 'failure' ]) } diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index b0f5e8804dc0..5161c7b276d7 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -14,7 +14,8 @@ import { AcpToolTimeline } from './acp-tool-timeline' import { AcpTurnFailures, acpAuthenticationRequired, - acpPromptErrorDetail + acpPromptErrorDetail, + type AcpTurnFailureSource } from './acp-turn-failures' import { acpNamedTextKey, AcpTurnMessages, type AcpTextDrop } from './acp-turn-messages' import type { PromptResponse } from './generated/acp-protocol.generated' @@ -32,11 +33,8 @@ const SUBSTANTIVE_UPDATES = [ 'plan' ] -export type AcpTimelineTranslatorOptions = { - sessionId: string +export type AcpTimelineTranslatorOptions = AcpTurnFailureSource & { dialect?: AcpDialect - /** The agent's display name, for a failed turn the provider gave no words for. */ - agentName?: string onTextDropped?: (drop: AcpTextDrop) => void } @@ -57,7 +55,7 @@ export class AcpTimelineTranslator { constructor(private readonly options: AcpTimelineTranslatorOptions) { this.dialect = options.dialect ?? GENERIC_ACP_DIALECT - this.failures = new AcpTurnFailures(options.sessionId, this.dialect, options.agentName) + this.failures = new AcpTurnFailures(this.dialect, options) this.backgroundTasks = new AcpBackgroundTaskTimeline((callId) => this.tools.turn(callId)) this.prompts = new AcpPromptTurns( options.sessionId, diff --git a/src/main/acp/acp-timeline-turn-failures.test.ts b/src/main/acp/acp-timeline-turn-failures.test.ts index 801b85fd9fb8..02a3022119f3 100644 --- a/src/main/acp/acp-timeline-turn-failures.test.ts +++ b/src/main/acp/acp-timeline-turn-failures.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import { closeProviderTimelineRigs, @@ -9,6 +9,7 @@ import { openAcpFixtureRig } from './acp-timeline-fixture.test-support' import { AcpAgentError } from './acp-errors' afterEach(closeProviderTimelineRigs) +afterEach(() => vi.restoreAllMocks()) // The shape Grok sends for a prompt its API refused, with the reason scrubbed to a placeholder. const REASON = 'API error (status 400 Bad Request): invalid_request_error: placeholder reason' @@ -40,6 +41,28 @@ const promptComplete = (promptId: string, stopReason: string, agentResult: strin agentResult }) const rpcError = new AcpAgentError(-32603, 'Internal error', { message: REASON }) +// Grok's API record for a usage limit, with a constructed tail. +const LIMIT_REASON = + 'API error (status 429 Too Many Requests): rate_limit_exceeded: You have reached your usage limit.' +const details = (text: string) => ({ + provider: 'grok', + kind: 'turn:failed', + payload: expect.objectContaining({ head: text, truncated: false }) +}) +// The reason is an API record, so the row names Grok and keeps the reason on its fact and Details. +const FAILED_ROW = { + kind: 'status', + tone: 'error', + text: 'Grok ran into a problem. Check the chat before trying again.', + failure: { kind: 'providerError', detail: { text: REASON, audience: 'person' } }, + providerFrame: details(REASON) +} +const LIMIT_ROW = { + kind: 'status', + tone: 'error', + text: 'Grok usage limit reached.', + providerFrame: details(LIMIT_REASON) +} function statusRows(rows: AgentJournalRenderItem[]) { return rows.flatMap((row) => (row.body.kind === 'status' ? [{ row, body: row.body }] : [])) @@ -55,7 +78,7 @@ describe('a failed ACP turn says why', () => { f.apply( lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'error', REASON), 1003) ) - expect(statusRows(await f.rig.rows())[0]?.body.text).toBe(REASON) + expect(statusRows(await f.rig.rows())[0]?.body).toEqual(FAILED_ROW) f.apply( lane.notification( '_x.ai/session/prompt_complete', @@ -82,8 +105,8 @@ describe('a failed ACP turn says why', () => { const rows = await f.rig.rows() const failures = statusRows(rows) expect(failures).toHaveLength(1) - // The turn ran, so the row reads as Codex's turn-ending error does: no refusal sentence or fact. - expect(failures[0]?.body).toEqual({ kind: 'status', tone: 'error', text: REASON }) + // The turn ran, so the row reads as Codex's turn-ending error does: no refusal sentence. + expect(failures[0]?.body).toEqual(FAILED_ROW) const turns = await f.rig.turns() expect(turns.map((turn) => [turn.state, turn.outcome])).toEqual([ ['completed', 'failure'], @@ -113,7 +136,7 @@ describe('a failed ACP turn says why', () => { f.apply(lane.promptFailed('c1', rpcError, 1004)) const failures = statusRows(await f.rig.rows()) expect(failures).toHaveLength(1) - expect(failures[0]?.body).toEqual({ kind: 'status', tone: 'error', text: REASON }) + expect(failures[0]?.body).toEqual(FAILED_ROW) }) it.each(['retry_state', 'turn_completed', 'prompt_complete', 'prompt error answer'] as const)( @@ -149,7 +172,7 @@ describe('a failed ACP turn says why', () => { ) ) const failures = statusRows(await f.rig.rows()) - expect(failures.map((failure) => failure.body.text)).toEqual([REASON]) + expect(failures.map((failure) => failure.body)).toEqual([FAILED_ROW]) } ) @@ -162,7 +185,7 @@ describe('a failed ACP turn says why', () => { f.apply(lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'error'), 1003)) f.apply(lane.promptFailed('c1', new AcpAgentError(-32603, 'Internal error'), 1004)) const failures = statusRows(await f.rig.rows()) - expect(failures.map((failure) => failure.body.text)).toEqual([REASON]) + expect(failures.map((failure) => failure.body)).toEqual([FAILED_ROW]) }) it('names a rate-limited turn the provider gave no words for, without inventing a reason', async () => { @@ -187,6 +210,112 @@ describe('a failed ACP turn says why', () => { expect((await f.rig.turns())[0]?.outcome).toBe('failure') }) + it('quotes a reason a person can read, and keeps it in Details', async () => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + const readable = 'The prompt is too long for this model.' + f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) + f.apply( + lane.notification( + '_x.ai/session_notification', + ended(prompt.promptId, 'error', readable), + 1002 + ) + ) + expect(statusRows(await f.rig.rows()).map((failure) => failure.body)).toEqual([ + { + kind: 'status', + tone: 'error', + text: `Grok ran into a problem: ${readable} Check the chat before trying again.`, + failure: { kind: 'providerError', detail: { text: readable, audience: 'person' } }, + providerFrame: details(readable) + } + ]) + }) + + it('logs each reason once, however many copies Grok sends', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) + f.apply(lane.notification('_x.ai/session_notification', retryFailed, 1002)) + f.apply( + lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'error', REASON), 1003) + ) + f.apply(lane.promptFailed('c1', rpcError, 1004)) + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0]).toContain(REASON) + }) + + it('leads with the usage limit when Grok words it as an API record', async () => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) + f.apply( + lane.notification( + '_x.ai/session_notification', + ended(prompt.promptId, 'rate_limit', LIMIT_REASON), + 1002 + ) + ) + f.apply( + lane.notification( + '_x.ai/session/prompt_complete', + promptComplete(prompt.promptId, 'rate_limit', LIMIT_REASON), + 1003 + ) + ) + expect(statusRows(await f.rig.rows()).map((failure) => failure.body)).toEqual([LIMIT_ROW]) + }) + + it('keeps the usage limit lead when a later copy of the reason arrives as an error', async () => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) + f.apply( + lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'rate_limit'), 1002) + ) + f.apply( + lane.promptFailed( + 'c1', + new AcpAgentError(-32603, 'Internal error', { message: LIMIT_REASON }), + 1003 + ) + ) + expect(statusRows(await f.rig.rows()).map((failure) => failure.body)).toEqual([LIMIT_ROW]) + }) + + it('turns an earlier error row into the usage limit when the end says so', async () => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) + f.apply( + lane.notification( + '_x.ai/session_notification', + grokUpdate({ + sessionUpdate: 'retry_state', + type: 'failed', + error_type: 'api', + message: LIMIT_REASON + }), + 1002 + ) + ) + f.apply( + lane.notification( + '_x.ai/session_notification', + ended(prompt.promptId, 'rate_limit', LIMIT_REASON), + 1003 + ) + ) + expect(statusRows(await f.rig.rows()).map((failure) => failure.body)).toEqual([LIMIT_ROW]) + }) + it('writes no failure row for a turn the provider ended on purpose', async () => { const f = await openAcpFixtureRig() const lane = f.lane() diff --git a/src/main/acp/acp-turn-failures.ts b/src/main/acp/acp-turn-failures.ts index ba87ec6a5050..212c51cd113b 100644 --- a/src/main/acp/acp-turn-failures.ts +++ b/src/main/acp/acp-turn-failures.ts @@ -1,10 +1,15 @@ import { agentSessionFailureFact, providerDiagnostic, - withProviderDiagnostic + withProviderDiagnostic, + type ProviderDiagnostic } from '../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' import { BoundedMap } from '../../shared/bounded-map' +import { + boundPayload, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpDialect } from './acp-dialects/acp-dialect' import { AcpAgentError, AcpAuthRequiredError } from './acp-errors' @@ -55,20 +60,30 @@ export function acpSignInRequiredRefusal( ) } -/** One error row per failed turn, in the provider's own words, as a Codex turn-ending error reads: - * the message was accepted and the turn ran, so it is no refusal. Providers send that reason several - * times (beside the end, after it, in the prompt's error answer), so a later copy only adds a - * reason the row still lacks. */ +/** Whose turns these are: the provider session, and the agent its rows and Details name. */ +export type AcpTurnFailureSource = { + sessionId: string + /** The agent's id, naming whose words a failed turn's Details hold. */ + agent?: string + /** The agent's display name, for a failed turn the provider gave no words for. */ + agentName?: string +} + +/** One error row per failed turn, named and quoting the agent's reason only when a person can read + * it, as a Codex turn-ending error reads: the message was accepted and the turn ran, so it is no + * refusal. The reason itself rides the row's Details. Providers send that reason several times + * (beside the end, after it, in the prompt's error answer), so a later copy only adds what the row + * still lacks: a reason, sign-in, or a usage limit. */ export class AcpTurnFailures { - /** The reason each failed turn's row holds; '' for none yet. */ - private readonly rows = new BoundedMap({ - maxEntries: 128 - }) + /** What each failed turn's row holds; `text` '' for no reason yet. */ + private readonly rows = new BoundedMap< + string, + { text: string; notSignedIn: boolean; rateLimited: boolean } + >({ maxEntries: 128 }) constructor( - private readonly sessionId: string, private readonly dialect: AcpDialect, - private readonly agentName: string | undefined + private readonly source: AcpTurnFailureSource ) {} has(turn: string): boolean { @@ -93,20 +108,30 @@ export class AcpTurnFailures { ): ProviderTimelineEvent[] { const written = this.rows.peek(turn) const detail = text === undefined ? undefined : providerDiagnostic(text, 'person') + const rateLimited = stopReason === 'rate_limit' if ( written !== undefined && (written.text !== '' || !detail) && - (!notSignedIn || written.notSignedIn) + (!notSignedIn || written.notSignedIn) && + (!rateLimited || written.rateLimited) ) { return [] } const diagnostic = detail ?? (written?.text ? providerDiagnostic(written.text, 'person') : undefined) - const authenticationRequired = notSignedIn || written?.notSignedIn === true - this.rows.set(turn, { + const agent = this.source.agent ?? 'acp' + const state = { text: diagnostic?.text ?? '', - notSignedIn: authenticationRequired - }) + notSignedIn: notSignedIn || written?.notSignedIn === true, + rateLimited: rateLimited || written?.rateLimited === true + } + this.rows.set(turn, state) + if (detail && detail.text !== written?.text) { + console.warn( + `[acp] ${this.source.agentName ?? agent} turn failed (${stopReason}):`, + detail.text + ) + } return [ { type: 'item.update', @@ -114,20 +139,39 @@ export class AcpTurnFailures { body: { kind: 'status', tone: 'error', - ...(authenticationRequired - ? agentSessionFailureWords( - agentSessionFailureFact('notSignedIn', { detail: diagnostic }), - { agentName: this.agentName, surface: 'row' } - ) - : { - text: - diagnostic?.text ?? - this.dialect.failedTurnText?.(stopReason) ?? - `${this.agentName ?? 'The agent'} ended this turn with an error.` - }) + ...this.words(state, diagnostic), + ...(diagnostic + ? { + providerFrame: { + provider: agent, + kind: 'turn:failed', + payload: boundPayload(diagnostic.text, DEFAULT_JOURNAL_PAYLOAD_LIMITS) + } + } + : {}) }, - join: { thread: this.sessionId, turn } + join: { thread: this.source.sessionId, turn } } ] } + + private words( + { notSignedIn, rateLimited }: { notSignedIn: boolean; rateLimited: boolean }, + diagnostic: ProviderDiagnostic | undefined + ) { + // A usage limit the agent named is the cause to lead with; its own words stay in Details. + if (!notSignedIn && (rateLimited || !diagnostic)) { + return { + text: + this.dialect.failedTurnText?.(rateLimited ? 'rate_limit' : 'error') ?? + `${this.source.agentName ?? 'The agent'} ended this turn with an error.` + } + } + return agentSessionFailureWords( + agentSessionFailureFact(notSignedIn ? 'notSignedIn' : 'providerError', { + detail: diagnostic + }), + { agentName: this.source.agentName, surface: 'row' } + ) + } } diff --git a/src/main/claude/claude-api-retry-row.test.ts b/src/main/claude/claude-api-retry-row.test.ts index 26ad6d3a26a4..603d9363494b 100644 --- a/src/main/claude/claude-api-retry-row.test.ts +++ b/src/main/claude/claude-api-retry-row.test.ts @@ -77,7 +77,7 @@ describe('a Claude api_retry frame', () => { expect(rows).toHaveLength(1) const [row] = rows expect(row).toMatchObject({ - text: 'Claude is rate-limited and retrying. Retry 3 of 10.\nLast error: HTTP 429 rate limit.', + text: 'Claude reached a request limit and is retrying. Retry 3 of 10.', tone: 'warning', failure: { kind: 'providerRetrying', @@ -90,21 +90,19 @@ describe('a Claude api_retry frame', () => { expect(row.failure?.detail?.text).toContain('"attempt":3') }) - it('says how far a gateway failure has got, and the code it failed with', async () => { + it('says how far a gateway retry has got without showing its protocol code', async () => { const [row] = await statusRowsFor([ apiRetry(1, { error: 'server_error', error_status: 502 }), apiRetry(2, { error: 'server_error', error_status: 502 }) ]) - expect(row?.text).toBe( - 'Claude hit a temporary problem and is retrying. Retry 2 of 10.\nLast error: HTTP 502 server error.' - ) + expect(row?.text).toBe('Claude hit a temporary problem and is retrying. Retry 2 of 10.') }) it('starts a new row when a later run starts over', async () => { const rows = await statusRowsFor([apiRetry(1), apiRetry(2), apiRetry(1)]) expect(rows.map((row) => row.text.split('\n')[0])).toEqual([ - 'Claude is rate-limited and retrying. Retry 2 of 10.', - 'Claude is rate-limited and retrying. Retry 1 of 10.' + 'Claude reached a request limit and is retrying. Retry 2 of 10.', + 'Claude reached a request limit and is retrying. Retry 1 of 10.' ]) }) @@ -116,10 +114,10 @@ describe('a Claude api_retry frame', () => { apiRetry(1, { error: 'something_new', error_status: 429 }) ]) expect(rows.map((row) => row.text)).toEqual([ - 'Claude hit a temporary problem and is retrying. Retry 1 of 10.\nLast error: HTTP 529 overloaded.', - 'Claude hit a temporary problem and is retrying. Retry 1 of 10.\nLast error: HTTP 500 server error.', 'Claude hit a temporary problem and is retrying. Retry 1 of 10.', - 'Claude is rate-limited and retrying. Retry 1 of 10.\nLast error: HTTP 429 something new.' + 'Claude hit a temporary problem and is retrying. Retry 1 of 10.', + 'Claude hit a temporary problem and is retrying. Retry 1 of 10.', + 'Claude reached a request limit and is retrying. Retry 1 of 10.' ]) expect(rows[0]?.failure).toMatchObject({ retry: { error: 'overloaded', status: 529 } }) }) diff --git a/src/main/claude/claude-command-turn.test.ts b/src/main/claude/claude-command-turn.test.ts index d777e8a423d7..67362c4b525f 100644 --- a/src/main/claude/claude-command-turn.test.ts +++ b/src/main/claude/claude-command-turn.test.ts @@ -174,7 +174,7 @@ describe('a captured /compact as the open Claude turn', () => { expect.objectContaining({ body: { kind: 'status', - text: 'Compaction failed: API Error: Request was aborted.', + text: "Claude couldn't shorten this chat's history.", failure: { kind: 'compactionFailed', detail: { text: 'API Error: Request was aborted.', audience: 'person' } @@ -303,7 +303,7 @@ describe('the command turn at each point the ordinary result path threads throug expect(worded.drawn().map((row) => row.body)).toEqual([ { kind: 'status', - text: 'Compaction failed: Not enough messages to compact.', + text: "Claude couldn't shorten this chat's history: Not enough messages to compact.", failure: { kind: 'compactionFailed', detail: { text: 'Not enough messages to compact.', audience: 'person' } @@ -319,7 +319,7 @@ describe('the command turn at each point the ordinary result path threads throug expect(unworded.drawn().map((row) => row.body)).toEqual([ { kind: 'status', - text: 'Compaction failed.', + text: "Claude couldn't shorten this chat's history.", failure: { kind: 'compactionFailed' }, tone: 'error' } @@ -334,7 +334,7 @@ describe('the command turn at each point the ordinary result path threads throug expect(drawn().map((row) => row.body)).toEqual([ { kind: 'status', - text: 'Compaction completion is unconfirmed.', + text: "Claude hasn't confirmed that this chat's history was shortened. Check the chat before running /compact again.", failure: { kind: 'compactionUnconfirmed' }, tone: 'error' } diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index 68c2726a4f58..bc1718e0b52f 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -339,8 +339,8 @@ describe('openCodexAppServerConnection', () => { expect(isCodexAppServerRequestError(refusal)).toBe(true) expect((refusal as Error).message).toContain('bad params') - // Codex's own words, apart from Orca's prefix, for a person to read. - expect(providerDiagnosticOf(refusal)).toEqual({ text: 'bad params', audience: 'person' }) + // Protocol errors are kept for Details, apart from Orca's prefix. + expect(providerDiagnosticOf(refusal)).toEqual({ text: 'bad params', audience: 'log' }) expect(providerDiagnosticOf(missing)).toBeUndefined() expect(isCodexAppServerUnsupportedError(missing)).toBe(true) expect(isCodexAppServerRequestError(missing)).toBe(false) diff --git a/src/main/codex/codex-app-server-request-error.ts b/src/main/codex/codex-app-server-request-error.ts index 0e7cc719017e..4e3cd8926d60 100644 --- a/src/main/codex/codex-app-server-request-error.ts +++ b/src/main/codex/codex-app-server-request-error.ts @@ -1,4 +1,7 @@ import { providerDiagnostic, type ProviderDiagnostic } from '../../shared/agent-session-failure' +import { isProviderDiagnosticPersonText } from '../../shared/provider-diagnostic-person-text' + +const JSON_RPC_PROTOCOL_ERROR_CODES = new Set([-32700, -32600, -32601, -32602, -32603]) /** Codex answered the call and refused it, rather than timing out or exiting. */ export class CodexAppServerRequestError extends Error { @@ -14,7 +17,15 @@ export class CodexAppServerRequestError extends Error { super(message) this.name = 'CodexAppServerRequestError' const diagnostic = - providerMessage === undefined ? undefined : providerDiagnostic(providerMessage, 'person') + providerMessage === undefined + ? undefined + : providerDiagnostic( + providerMessage, + !JSON_RPC_PROTOCOL_ERROR_CODES.has(code ?? 0) && + isProviderDiagnosticPersonText(providerMessage) + ? 'person' + : 'log' + ) if (diagnostic) { this.providerDiagnostic = diagnostic } diff --git a/src/main/codex/codex-command-turn-claim.test.ts b/src/main/codex/codex-command-turn-claim.test.ts index 078060497f2c..a72b3a9bb831 100644 --- a/src/main/codex/codex-command-turn-claim.test.ts +++ b/src/main/codex/codex-command-turn-claim.test.ts @@ -194,10 +194,10 @@ describe('a Codex turn a conversation command claims', () => { expect(resultRows(writes).map((write) => write.body)).toEqual([ { kind: 'status', - text: 'Compaction failed: Unavailable.', + text: "Codex couldn't shorten this chat's history.", failure: { kind: 'compactionFailed', - detail: { text: 'Unavailable', audience: 'person' } + detail: { text: 'Unavailable', audience: 'log' } }, tone: 'error' } @@ -214,7 +214,7 @@ describe('a Codex turn a conversation command claims', () => { expect(resultRows(writes).map((write) => write.body)).toEqual([ { kind: 'status', - text: 'Compaction failed.', + text: "Codex couldn't shorten this chat's history.", failure: { kind: 'compactionFailed' }, tone: 'error' } @@ -231,7 +231,7 @@ describe('a Codex turn a conversation command claims', () => { expect(resultRows(writes).map((write) => write.body)).toEqual([ { kind: 'status', - text: 'Compaction completion is unconfirmed.', + text: "Codex hasn't confirmed that this chat's history was shortened. Check the chat before running /compact again.", failure: { kind: 'compactionUnconfirmed' }, tone: 'error' } @@ -268,7 +268,12 @@ describe('a Codex turn a conversation command claims', () => { // Codex's own error row, in the command's turn; the completion adds none. expect(resultRows(writes)).toEqual([ expect.objectContaining({ - body: expect.objectContaining({ kind: 'status', text: 'Unavailable', tone: 'error' }), + body: expect.objectContaining({ + kind: 'status', + text: 'Codex ran into a problem. Check the chat before trying again.', + tone: 'error', + failure: { kind: 'providerError', detail: { text: 'Unavailable', audience: 'log' } } + }), turnScope: { kind: 'turn', turnItemId: COMMAND_TURN_KEY } }) ]) diff --git a/src/main/codex/codex-journal-command-turn.ts b/src/main/codex/codex-journal-command-turn.ts index da37a90242df..f4c1a656e92b 100644 --- a/src/main/codex/codex-journal-command-turn.ts +++ b/src/main/codex/codex-journal-command-turn.ts @@ -100,7 +100,7 @@ export class CodexJournalCommandTurn { return [] } const { command } = carried - const detail = ended.error === null ? undefined : providerDiagnostic(ended.error, 'person') + const detail = ended.error === null ? undefined : providerDiagnostic(ended.error, 'log') const verdict = structuredCompactionOutcome({ compacted: carried.compacted, // Codex reports the user's stop as the turn's own status. diff --git a/src/main/codex/codex-provider-diagnostic-copy.test.ts b/src/main/codex/codex-provider-diagnostic-copy.test.ts new file mode 100644 index 000000000000..804b16ec84a4 --- /dev/null +++ b/src/main/codex/codex-provider-diagnostic-copy.test.ts @@ -0,0 +1,36 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { agentSessionWriteNoticeEnglish } from '../../shared/agent-session-refusal-notice' +import { structuredAgentSessionRejectionParts } from '../../shared/structured-agent-session-rejection-words' +import { readCodexJournalRecord } from './codex-structured-journal-translation-values' +import { codexTurnEndRejection, readCodexTurnEnd } from './codex-structured-turn-end-settlement' + +describe('captured Codex transport diagnostics on a send not echoed before failure', () => { + it('labels the failed-turn detail for the log and keeps it off the shared notice', () => { + const frames = readFileSync( + join(__dirname, '__fixtures__', 'codex-app-server-turn-endings.jsonl'), + 'utf8' + ) + .split('\n') + .filter(Boolean) + .map((line) => readCodexJournalRecord(JSON.parse(line))) + const frame = frames.find( + (entry) => entry.case === '0.141.0-conn-refused' && entry.method === 'turn/completed' + ) + const end = readCodexTurnEnd('turn/completed', frame?.params) + expect(end).toMatchObject({ status: 'failed', detail: { audience: 'log' } }) + const rejection = end ? codexTurnEndRejection(end) : null + expect(rejection?.reason).toBe("Codex didn't accept this message.") + expect( + agentSessionWriteNoticeEnglish( + structuredAgentSessionRejectionParts( + rejection?.reason ?? null, + 'send', + rejection?.rejection, + { agentName: 'Codex' } + ) + ) + ).toBe("Codex didn't accept this message.") + }) +}) diff --git a/src/main/codex/codex-provider-retry-row.test.ts b/src/main/codex/codex-provider-retry-row.test.ts index 7a49e60f279f..35b5173eaa7c 100644 --- a/src/main/codex/codex-provider-retry-row.test.ts +++ b/src/main/codex/codex-provider-retry-row.test.ts @@ -84,18 +84,18 @@ describe('a Codex stream error it is about to retry', () => { expect(written).toHaveLength(3) expect(new Set(written.map((row) => row.key)).size).toBe(3) expect(written.map((row) => row.body.kind === 'status' && row.body.text)).toEqual([ - 'Codex is retrying: Reconnecting... 1/5.\nstream disconnected', - 'Codex is retrying: Reconnecting... 2/5.\nstream disconnected', - 'Codex is retrying: Reconnecting... 3/5.\nstream disconnected' + 'Codex hit a temporary problem and is retrying.', + 'Codex hit a temporary problem and is retrying.', + 'Codex hit a temporary problem and is retrying.' ]) expect(written[2]?.body).toEqual({ kind: 'status', tone: 'warning', - text: 'Codex is retrying: Reconnecting... 3/5.\nstream disconnected', + text: 'Codex hit a temporary problem and is retrying.', failure: { kind: 'providerRetrying', - detail: { text: 'Reconnecting... 3/5', audience: 'person' }, - retry: { error: 'responseStreamDisconnected', status: 502, cause: 'stream disconnected' } + detail: { text: 'Reconnecting... 3/5', audience: 'log' }, + retry: { error: 'responseStreamDisconnected', status: 502 } }, providerFrame: { provider: 'codex', @@ -116,8 +116,8 @@ describe('a Codex stream error it is about to retry', () => { translator.handle(retrying('Reconnecting... 2/5', 'Reconnecting... 2/5')) expect(retryRows(rows).map((row) => row.body.kind === 'status' && row.body.text)).toEqual([ - 'Codex is retrying: Reconnecting... 1/5.', - 'Codex is retrying: Reconnecting... 2/5.' + 'Codex hit a temporary problem and is retrying.', + 'Codex hit a temporary problem and is retrying.' ]) }) @@ -155,9 +155,9 @@ describe('a Codex stream error it is about to retry', () => { // Each write is a new row: nothing the first connection wrote is revised. expect(retryRows(rows).map((row) => row.body.kind === 'status' && row.body.text)).toEqual([ - 'Codex is retrying: Reconnecting... 1/5.', - 'Codex is retrying: Reconnecting... 2/5.', - 'Codex is retrying: Reconnecting... 1/5.' + 'Codex hit a temporary problem and is retrying.', + 'Codex hit a temporary problem and is retrying.', + 'Codex hit a temporary problem and is retrying.' ]) }) @@ -222,10 +222,20 @@ describe('a Codex stream error it is about to retry', () => { : [] ) expect(errorFrameRows).toEqual([ - { tone: 'warning', text: `Codex is retrying: Reconnecting... 1/2.\n${cause}` }, - { tone: 'warning', text: `Codex is retrying: Reconnecting... 2/2.\n${cause}` }, - { tone: 'error', text: cause } + { tone: 'warning', text: 'Codex hit a temporary problem and is retrying.' }, + { tone: 'warning', text: 'Codex hit a temporary problem and is retrying.' }, + { + tone: 'error', + text: 'Codex ran into a problem. Check the chat before trying again.' + } ]) + const finalError = reduced(rows).find( + (row) => row.body.kind === 'status' && row.body.failure?.kind === 'providerError' + ) + expect(finalError?.body).toMatchObject({ + failure: { kind: 'providerError', detail: { text: cause, audience: 'log' } }, + providerFrame: { payload: { head: expect.stringContaining(cause) } } + }) expect(reduced(rows).map((row) => row.body)).toContainEqual( expect.objectContaining({ kind: 'turn', state: 'completed', outcome: 'failure' }) ) diff --git a/src/main/codex/codex-provider-retry-row.ts b/src/main/codex/codex-provider-retry-row.ts index 48b0c8eef40b..cd762b30e293 100644 --- a/src/main/codex/codex-provider-retry-row.ts +++ b/src/main/codex/codex-provider-retry-row.ts @@ -1,12 +1,12 @@ -// The row a Codex stream error it is about to retry writes: Codex's own progress sentence, what -// failed on the line under it, and a `providerRetrying` fact. Every attempt is its own row; the -// transcript draws only the latest of a run. +// Codex error notifications keep diagnostics in Details and state whether another attempt follows. import { agentSessionFailureFact, providerDiagnostic, readProviderRetry } from '../../shared/agent-session-failure' +import { codexAuthenticationFailure } from './codex-authentication-failure' +import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' import type { AgentJournalStatusItem } from '../../shared/agent-session-journal-types' import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' @@ -16,7 +16,6 @@ import { } from '../native-chat/agent-session-journal/journal-payload-bounds' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' import { - readCodexErrorAdditionalDetails, readCodexErrorInfo, readCodexErrorMessage, readCodexErrorWillRetry @@ -32,26 +31,38 @@ export function isCodexProviderRetryFrame(event: CodexStructuredSessionEvent): b } export function codexProviderRetryRowBody(payload: unknown): AgentJournalStatusItem { + return codexProviderErrorRowBody(payload, 'providerRetrying') +} + +export function codexProviderFinalErrorRowBody( + payload: unknown, + account?: AgentSessionAccountKind +): AgentJournalStatusItem { + return codexProviderErrorRowBody(payload, 'providerError', account) +} + +function codexProviderErrorRowBody( + payload: unknown, + kind: 'providerRetrying' | 'providerError', + account?: AgentSessionAccountKind +): AgentJournalStatusItem { const message = readCodexErrorMessage(payload) - const detail = message ? providerDiagnostic(message, 'person') : undefined - const additionalDetails = readCodexErrorAdditionalDetails(payload) - const retry = readProviderRetry({ - ...readCodexErrorInfo(payload), - // Details that only repeat the message would print the same words twice. - ...(additionalDetails && additionalDetails.trim() !== message?.trim() - ? { cause: additionalDetails } - : {}) - }) + const info = readCodexErrorInfo(payload) + // A final capacity refusal explains how to continue; transport and retry details stay in Details. + const audience = kind === 'providerError' && info?.error === 'serverOverloaded' ? 'person' : 'log' + const detail = message ? providerDiagnostic(message, audience) : undefined + const retry = kind === 'providerRetrying' ? readProviderRetry(info) : undefined const words = agentSessionFailureWords( - agentSessionFailureFact('providerRetrying', { - ...(detail ? { detail } : {}), - ...(retry ? { retry } : {}) - }), + (kind === 'providerError' ? codexAuthenticationFailure(payload, account) : null) ?? + agentSessionFailureFact(kind, { + ...(detail ? { detail } : {}), + ...(retry ? { retry } : {}) + }), { surface: 'row', agentName: TUI_AGENT_DISPLAY_NAMES.codex } ) return { kind: 'status', - tone: 'warning', + tone: kind === 'providerRetrying' ? 'warning' : 'error', ...words, providerFrame: { provider: 'codex', diff --git a/src/main/codex/codex-structured-compaction-refusal.test.ts b/src/main/codex/codex-structured-compaction-refusal.test.ts index fa8800f9605f..542530ee104b 100644 --- a/src/main/codex/codex-structured-compaction-refusal.test.ts +++ b/src/main/codex/codex-structured-compaction-refusal.test.ts @@ -32,10 +32,10 @@ describe('Codex compaction refused at the request', () => { }) ).resolves.toEqual({ state: 'rejected', - reason: 'The provider did not accept this message: thread has nothing to compact.', + reason: "Codex didn't accept this message.", rejection: { kind: 'providerRejected', - detail: { text: 'thread has nothing to compact', audience: 'person' } + detail: { text: 'thread has nothing to compact', audience: 'log' } } }) }) diff --git a/src/main/codex/codex-structured-conversation-stop.test.ts b/src/main/codex/codex-structured-conversation-stop.test.ts index 1e548dd0909f..9e5d1b6a2435 100644 --- a/src/main/codex/codex-structured-conversation-stop.test.ts +++ b/src/main/codex/codex-structured-conversation-stop.test.ts @@ -52,7 +52,7 @@ describe('a Codex Stop that names no turn', () => { refusal: { detail: { text: 'expected active turn id turn-journal but found turn-1', - audience: 'person' + audience: 'log' }, // An invalid-request refusal: the named turn is not the one Codex is running. turnNotRunning: true diff --git a/src/main/codex/codex-structured-dispatch-admission.test.ts b/src/main/codex/codex-structured-dispatch-admission.test.ts index 80bf0cee097d..1be3d1b02bc9 100644 --- a/src/main/codex/codex-structured-dispatch-admission.test.ts +++ b/src/main/codex/codex-structured-dispatch-admission.test.ts @@ -44,6 +44,29 @@ function lifecycleRecorder(): { } describe('codex dispatch admission', () => { + it('classifies a marked RPC refusal as an unwritten send, with diagnostic text kept off screen', async () => { + const { CodexAppServerRequestError } = await import('./codex-app-server-connection') + const detail = 'provider_write_failed: stand-in rejected the turn.' + const error = new CodexAppServerRequestError('turn/start', -32603, detail, detail) + expect(error.providerDiagnostic).toEqual({ text: detail, audience: 'log' }) + const codex = fakeCodexAppServer({ + 'turn/start': () => { + throw error + } + }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + expect(await send(adapter, 'client-1')).toEqual({ + state: 'rejected', + reason: 'provider_write_failed', + rejection: { kind: 'writeFailed', detail: { text: detail, audience: 'log' } } + }) + const connection = codex.connections[0]! + startTurn(connection, 'turn-1') + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' }) + expect(settlements).toEqual([]) + }) + it('admits a send queued behind a running turn and settles it when Codex echoes it', async () => { // A send while a turn runs is steered into it: that turn's id back, no second // `turn/started`, and the user message echoed only once the running turn reaches it. @@ -152,13 +175,13 @@ describe('codex dispatch admission', () => { const adapter = await acquiredCodexAdapter({ codex, settlements }) const connection = codex.connections[0]! - // Codex's own words reach the sentence and the fact; Orca's prefix reaches neither. + // Protocol refusals remain in the fact for Details, outside the person-facing sentence. expect(await send(adapter, 'client-1')).toEqual({ state: 'rejected', - reason: 'The provider did not accept this message: thread not found.', + reason: "Codex didn't accept this message.", rejection: { kind: 'providerRejected', - detail: { text: 'thread not found', audience: 'person' } + detail: { text: 'thread not found', audience: 'log' } } }) diff --git a/src/main/codex/codex-structured-journal-translation-streams.test.ts b/src/main/codex/codex-structured-journal-translation-streams.test.ts index d98ccba5f35e..16cfc45f9795 100644 --- a/src/main/codex/codex-structured-journal-translation-streams.test.ts +++ b/src/main/codex/codex-structured-journal-translation-streams.test.ts @@ -658,7 +658,14 @@ describe('codex journal translation', () => { const bodies = tap.rows.map((row) => row.body) expect(bodies.filter((body) => body.kind === 'status')).toEqual([ - expect.objectContaining({ text: 'Selected model is at capacity.', tone: 'error' }) + expect.objectContaining({ + text: 'Codex ran into a problem. Check the chat before trying again.', + tone: 'error', + failure: { + kind: 'providerError', + detail: { text: 'Selected model is at capacity.', audience: 'log' } + } + }) ]) expect(bodies.at(-1)).toMatchObject({ kind: 'turn', diff --git a/src/main/codex/codex-structured-journal-translation.test.ts b/src/main/codex/codex-structured-journal-translation.test.ts index 23249a959b76..c32e7830b275 100644 --- a/src/main/codex/codex-structured-journal-translation.test.ts +++ b/src/main/codex/codex-structured-journal-translation.test.ts @@ -345,7 +345,7 @@ describe('codex journal translation', () => { expect(reduced(tap.rows).map((row) => row.body)).toContainEqual( expect.objectContaining({ kind: 'status', - text: 'Selected model is at capacity. Please try a different model.', + text: 'Codex ran into a problem. Check the chat before trying again.', tone: 'error' }) ) diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index 19fee37a5368..ef002787c552 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -14,7 +14,11 @@ import { import { CodexJournalTurnBoundaries } from './codex-structured-journal-translation-turn-boundaries' import { createCodexJournalTranslatorWriters } from './codex-structured-journal-translation-writers' import { publishCodexTurnLifecycle } from './codex-structured-journal-translation-turns' -import { codexProviderRetryRowBody, isCodexProviderRetryFrame } from './codex-provider-retry-row' +import { + codexProviderFinalErrorRowBody, + codexProviderRetryRowBody, + isCodexProviderRetryFrame +} from './codex-provider-retry-row' import { createCodexThreadItemRouter } from './codex-structured-journal-thread-item-routing' import { codexThreadStoppedRunning, readCodexTurnId } from './codex-structured-thread-facts' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' @@ -249,16 +253,20 @@ export function createCodexJournalTranslator( return publishActivity(event, routed) } } - if (isCodexProviderRetryFrame(event)) { + if (event.method === 'error') { // Always journaled, like any error frame: each attempt is evidence, and its publish is // the activity the idle sweep reads. - return publishActivity( - event, - genericFrames.appendFrameRow(event.threadId, event.params, { - body: codexProviderRetryRowBody(event.params), - classification: 'error-surface' - }) - ) + const retrying = isCodexProviderRetryFrame(event) + const admission = genericFrames.appendFrameRow(event.threadId, event.params, { + body: retrying + ? codexProviderRetryRowBody(event.params) + : codexProviderFinalErrorRowBody(event.params, deps.account?.()), + classification: 'error-surface' + }) + if (admission.accepted && !retrying) { + commands.errorShown(event.params) + } + return publishActivity(event, admission) } // A thread that stopped running settles no open turn: Codex clears `running` // on every error, and an open turn ends on its `turn/completed`. It releases @@ -280,9 +288,6 @@ export function createCodexJournalTranslator( event.threadId, { coveredByTypedTranslator: event.method === 'thread/status/changed' } ) - if (unhandled.accepted && event.method === 'error') { - commands.errorShown(event.params) - } return publishActivity(event, unhandled) }, beginCommand: (command) => commands.begin(command), diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index b2154b196cb5..bf93e32f3698 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -568,7 +568,7 @@ describe('CodexStructuredSessionAdapter.dispatch', () => { ).toEqual({ // Built without Codex's own words, so nothing is quoted and no detail is invented. state: 'rejected', - reason: 'The provider did not accept this message.', + reason: "Codex didn't accept this message.", rejection: { kind: 'providerRejected' } }) }) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index a5fbf4628a2d..6c30e35a962a 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -2,6 +2,7 @@ import * as codexRewind from './codex-structured-rewind' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import { isCodexAppServerRequestError } from './codex-app-server-connection' +import { providerDispatchRejectionFact } from '../../shared/structured-agent-session-dispatch-rejection' import type { AgentSessionAcquisition, AgentSessionDispatchOutcome, @@ -11,7 +12,7 @@ import type { } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexJournalTranslationAdmission } from './codex-structured-journal-translation' import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start' -import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' +import { providerDiagnosticOf } from '../../shared/agent-session-failure' import { supportsCodexStructuredLocation } from './codex-structured-location-support' import { CodexStructuredSessionTeardown } from './codex-structured-session-teardown' import { @@ -257,9 +258,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap // Codex's own words, when it gave any, are the one part of the error a person can use. return { state: 'rejected', - ...codexDispatchRejection( - agentSessionFailureFact('providerRejected', { detail: providerDiagnosticOf(error) }) - ) + ...codexDispatchRejection(providerDispatchRejectionFact(providerDiagnosticOf(error))) } } throw error diff --git a/src/main/codex/codex-structured-turn-end-settlement.test.ts b/src/main/codex/codex-structured-turn-end-settlement.test.ts index 776ce7263103..84cbc842bb64 100644 --- a/src/main/codex/codex-structured-turn-end-settlement.test.ts +++ b/src/main/codex/codex-structured-turn-end-settlement.test.ts @@ -105,7 +105,7 @@ describe('a Codex send its turn ended without echoing', () => { state: 'rejected', rejection: { kind: 'providerRejected', - detail: { text: 'usage limit reached', audience: 'person' } + detail: { text: 'usage limit reached', audience: 'log' } } }) ]) @@ -245,7 +245,7 @@ describe('a Codex send its turn ended without echoing', () => { state: 'rejected', rejection: { kind: 'providerRejected', - detail: { text: 'usage limit reached', audience: 'person' } + detail: { text: 'usage limit reached', audience: 'log' } } }) // Settled once, by the answer: a late echo is no longer owed anything. diff --git a/src/main/codex/codex-structured-turn-end-settlement.ts b/src/main/codex/codex-structured-turn-end-settlement.ts index cc1a62d05615..d3930f871c7a 100644 --- a/src/main/codex/codex-structured-turn-end-settlement.ts +++ b/src/main/codex/codex-structured-turn-end-settlement.ts @@ -30,6 +30,7 @@ import { readCodexTurnStatus } from './codex-structured-thread-facts' import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' +import { providerDispatchRejectionFact } from '../../shared/structured-agent-session-dispatch-rejection' import { codexAuthenticationFailure } from './codex-authentication-failure' /** A message Codex rejected, in the words that name Codex and its legacy markers. */ @@ -56,7 +57,7 @@ export type CodexTurnEndSettlement = { function errorDetail(params: unknown): ProviderDiagnostic | undefined { const message = readCodexTurnErrorMessage(params) - return message ? providerDiagnostic(message, 'person') : undefined + return message ? providerDiagnostic(message, 'log') : undefined } /** A final `error` naming a turn Codex never opened: before 0.148 that is the only end such a @@ -76,7 +77,7 @@ function unopenedTurnFailure( } const message = readCodexJournalRecord(readCodexJournalRecord(params).error).message const detail = - typeof message === 'string' && message ? providerDiagnostic(message, 'person') : undefined + typeof message === 'string' && message ? providerDiagnostic(message, 'log') : undefined const auth = codexAuthenticationFailure(params) return { status: 'failed', @@ -116,10 +117,10 @@ export function codexTurnEndRejection( } if (end.status === 'failed') { return codexDispatchRejection( - agentSessionFailureFact(end.notSignedIn ? 'notSignedIn' : 'providerRejected', { - ...(end.detail ? { detail: end.detail } : {}), - ...(account ? { account } : {}) - }) + end.notSignedIn + ? agentSessionFailureFact('notSignedIn', { detail: end.detail }) + : providerDispatchRejectionFact(end.detail), + account ) } return null diff --git a/src/main/codex/codex-structured-turn-open-wait.test.ts b/src/main/codex/codex-structured-turn-open-wait.test.ts index d669c088d080..d2b99286fffb 100644 --- a/src/main/codex/codex-structured-turn-open-wait.test.ts +++ b/src/main/codex/codex-structured-turn-open-wait.test.ts @@ -16,6 +16,7 @@ type Rig = Awaited> const ADMITTED = { state: 'admitted' } const REFUSED = { cancelled: false } +const NO_RUNNING_TURN = { cancelled: false, refusal: { turnNotRunning: true } } const MAY_OPEN = { cancelled: false, refusal: { turnMayOpen: true } } const stop = (rig: Rig) => rig.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) @@ -114,7 +115,7 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi rig.turns.end('interrupted') - expect(await settledWithin(stopping)).toEqual(REFUSED) + expect(await settledWithin(stopping)).toEqual(NO_RUNNING_TURN) expect(interruptedTurns(rig)).toEqual(['turn-1']) }) @@ -128,7 +129,7 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi return interrupt(params) } - expect(await settledWithin(stop(rig))).toEqual(REFUSED) + expect(await settledWithin(stop(rig))).toEqual(NO_RUNNING_TURN) expect(interruptedTurns(rig)).toEqual(['turn-1']) }) diff --git a/src/main/codex/codex-structured-turn-open-wait.ts b/src/main/codex/codex-structured-turn-open-wait.ts index 9dd071656f35..64a8010a8080 100644 --- a/src/main/codex/codex-structured-turn-open-wait.ts +++ b/src/main/codex/codex-structured-turn-open-wait.ts @@ -130,7 +130,7 @@ export async function interruptOpeningCodexTurn( } if (!stillOpening()) { // It ended: the Stop found no turn running. - return { cancelled: false } + return { cancelled: false, refusal: { turnNotRunning: true } } } return { cancelled: false, refusal: { turnMayOpen: true } } } diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 99aced214f60..a7e500774d18 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -1,5 +1,6 @@ import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' +import { providerDispatchRejectionFact } from '../../shared/structured-agent-session-dispatch-rejection' import type { AgentJournalMessageItem, AgentJournalTurnJoin @@ -211,9 +212,7 @@ export async function dispatchCodexTurn( // Codex's own words, when it gave any, are the one part of the error a person can use. return { state: 'rejected', - ...codexDispatchRejection( - agentSessionFailureFact('providerRejected', { detail: providerDiagnosticOf(error) }) - ) + ...codexDispatchRejection(providerDispatchRejectionFact(providerDiagnosticOf(error))) } } // A timeout or transport failure can happen after the frame was written. diff --git a/src/main/codex/codex-structured-turn-steer.test.ts b/src/main/codex/codex-structured-turn-steer.test.ts index dfde08edafd1..0ebc4ac16fa5 100644 --- a/src/main/codex/codex-structured-turn-steer.test.ts +++ b/src/main/codex/codex-structured-turn-steer.test.ts @@ -168,10 +168,10 @@ describe('a Codex send while a turn runs', () => { expect(await send('client-1')).toEqual({ state: 'rejected', - reason: 'The provider did not accept this message: thread not found.', + reason: "Codex didn't accept this message.", rejection: { kind: 'providerRejected', - detail: { text: 'thread not found', audience: 'person' } + detail: { text: 'thread not found', audience: 'log' } } }) expect(methods()).toEqual(['thread/start', 'turn/steer', 'turn/start']) diff --git a/src/main/native-chat/agent-session-journal/journal-reducer-rejection-fact.test.ts b/src/main/native-chat/agent-session-journal/journal-reducer-rejection-fact.test.ts index 3332e53b0b3e..542c704bc785 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer-rejection-fact.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer-rejection-fact.test.ts @@ -45,14 +45,14 @@ describe('a rejected dispatch', () => { clientMessageId: 'cm_1', state: 'rejected', providerItemId: null, - reason: 'The provider did not accept this message.', + reason: "The agent didn't accept this message.", rejection: { kind: 'providerRejected', detail: { text: 'Too long', audience: 'person' } }, ...base(2) } ]) expect(state.submissions.get('cm_1')).toMatchObject({ dispatchState: 'rejected', - reason: 'The provider did not accept this message.', + reason: "The agent didn't accept this message.", rejection }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index f6d7d6d4be7e..8a892580b5fe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -238,7 +238,7 @@ describe('a Codex Stop whose interrupt failed', () => { expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) expect(disposeSession).toHaveBeenCalledExactlyOnceWith(SESSION) expect((await journalRows()).statuses).toEqual([ - "Codex didn't stop: failed to interrupt turn: channel closed." + "Codex didn't stop. Check the chat before trying again." ]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts index 30810b9b9f3e..0466649479c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts @@ -312,7 +312,7 @@ function commandBlocked( body: AgentJournalMessageItem ): SubmissionRejectionFact | null { if (body.command?.name !== STRUCTURED_AGENT_SESSION_COMPACT_COMMAND) { - return agentSessionFailureFact('commandRefused') + return unsupportedCommand() } const record = ctx.record() if (!record) { @@ -320,10 +320,19 @@ function commandBlocked( } // The declaration admits it, as it does the advertised command list; a client may send it anyway. if (!ctx.agents.capabilities(record.provider)?.compact) { - return agentSessionFailureFact('commandRefused') + return unsupportedCommand() } const refusal = conversationCommandBlocked(ctx, record, ctx.childWork(), 'handover') return refusal ? agentSessionFailureFact('commandRefused', { refusal: agentSessionRefusalReference(refusal) }) : null } + +function unsupportedCommand(): SubmissionRejectionFact { + return agentSessionFailureFact('commandRefused', { + refusal: { + code: 'structured_agent_session_unsupported', + details: { reason: 'hostUnsupported' } + } + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index 3154d165ca62..4abfdf0abd54 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -396,7 +396,7 @@ describe('a Stop that names no turn', () => { await eventually(async () => expect(await submission(refused.id)).toMatchObject({ dispatchState: 'rejected', - reason: expect.stringContaining('unknown model gpt-missing'), + reason: "Codex didn't accept this message.", rejection: { kind: 'providerRejected' } }) ) @@ -615,7 +615,7 @@ describe('a Stop that names no turn', () => { expect(await statusRows()).toEqual([]) }) - it('says the agent did not stop, in its words, when it refused because its turn is not running', async () => { + it('reports a refused Stop without exposing its technical no-turn reason', async () => { const { id, result } = send('hello') await result await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) @@ -630,7 +630,7 @@ describe('a Stop that names no turn', () => { expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) expect(closeSession).not.toHaveBeenCalled() - expect(await statusRows()).toEqual(["Codex didn't stop: no active turn to interrupt."]) + expect(await statusRows()).toEqual(["Codex didn't stop. Check the chat before trying again."]) }) it('ends the child when the provider never answered the interrupt', async () => { @@ -666,7 +666,9 @@ describe('a Stop that names no turn', () => { fields: expect.objectContaining({ scope: 'stop-child', sessionId: SESSION }) }) ) - expect(await statusRows()).toEqual(["Codex didn't stop: failed to interrupt turn."]) + expect(await statusRows()).toEqual([ + "Codex didn't stop: failed to interrupt turn. Check the chat before trying again." + ]) }) it('counts as stopped when the child exit was proven and only a later cleanup step failed', async () => { @@ -700,18 +702,20 @@ describe('a Stop that names no turn', () => { expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) - expect(await statusRows()).toEqual(['Cancellation was not confirmed.']) + expect(await statusRows()).toEqual([ + "Codex hasn't confirmed that it stopped. Check the chat before trying again." + ]) }) - it('says the agent had no turn to stop when it had none', async () => { + it('reports a no-turn refusal without inferring that the agent is idle', async () => { const { id, result } = send('hello') await result await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) - cancelTurn.mockResolvedValueOnce({ cancelled: false }) + cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: { turnNotRunning: true } }) expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) - expect(await statusRows()).toEqual(['Codex had no turn running to stop.']) + expect(await statusRows()).toEqual(["Codex didn't stop. Check the chat before trying again."]) }) it('stops nothing when it reuses the id of a Stop the host already ran', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-compact.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-compact.test.ts index ee7a26af161e..fc4487d5f112 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-compact.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-compact.test.ts @@ -323,7 +323,7 @@ describe('a /compact that waits in line', () => { expect(failures).toHaveLength(1) // The reason, as the direct path says it, not a bare "try it again". expect(failures[0]!.body).toMatchObject({ - text: 'Background tasks are still running. Wait for the background tasks to finish. Run /compact again.' + text: 'Codex still has background tasks running. Wait for the background tasks to finish. Run /compact again.' }) expect(cards).toEqual([]) }) @@ -354,7 +354,7 @@ describe('a /compact that waits in line', () => { const { failures } = await failureRowsAndCards() expect(failures.map((row) => row.body)).toContainEqual( expect.objectContaining({ - text: 'Background tasks are still running. Wait for the background tasks to finish. Run /compact again.' + text: 'Codex still has background tasks running. Wait for the background tasks to finish. Run /compact again.' }) ) expect((await rig.handoff(compactId))?.handedOverAt).toBeUndefined() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index 4ceae6b6c047..aca97c52663d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -424,7 +424,7 @@ describe('a send with no live owner', () => { expect(await errorStatuses()).toEqual([cause]) }) - it("keeps Codex's own words behind a refused resume without saying the provider stopped", async () => { + it('keeps resume diagnostics in Details without claiming Codex stopped', async () => { await loseOwner() const said = `no rollout found for thread id ${THREAD}` acquire.mockRejectedValue( @@ -432,10 +432,10 @@ describe('a send with no live owner', () => { ) const id = await accept(sendParams('after the thread went away')) - // The sentence names no cause and quotes nothing; Codex's words ride in the fact for Details. + // Resume diagnostics stay in Details, outside the plain failure sentence. const rejection = { kind: 'restartFailed', - detail: { text: said, audience: 'person' }, + detail: { text: said, audience: 'log' }, refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } } expect(await settled(id)).toMatchObject({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-failure-copy.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-failure-copy.test.ts new file mode 100644 index 000000000000..51bfa21816be --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-failure-copy.test.ts @@ -0,0 +1,142 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { CodexAppServerRequestError } from '../../codex/codex-app-server-request-error' +import { codexTurnLifecycleRig } from '../../codex/codex-structured-dispatch-test-support' +import { + acquired, + fakeCodex, + identityFor +} from '../../codex/codex-structured-session-adapter-fixture' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' + +let root: string +const journals = createTrackedJournalOpener() +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-stop-copy-')) +}) +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +async function runningContext( + cancelTurn: AgentSessionTurnContext['adapter']['cancelTurn'], + turnId = 'turn-1' +) { + const journal = await journals.open({ identity: identityFor('session-1'), stateDirectory: root }) + await journal.appendItem( + { provider: 'codex', threadId: 'thread-abc', turnId, ordinal: 0 }, + { kind: 'turn', turnId, state: 'running' }, + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const ctx: AgentSessionTurnContext = { + logger: createStructuredAgentSessionLogger(), + sessionId: 'session-1', + journal, + fence: 7, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', + adapter: { + acquire: vi.fn(), + dispatch: vi.fn(), + cancelTurn, + answerPrompt: vi.fn(), + setOption: vi.fn() + }, + persistOptions: async () => undefined, + resolvedBy: 'client-1', + publish: vi.fn(), + now: () => 1, + failureTextContext: { agentName: 'Codex' } + } + return ctx +} + +describe('Stop wording follows the host verdict', () => { + it('keeps a failed interrupt distinct from no running turn when child exit is unverifiable', async () => { + const codex = fakeCodex({ + 'turn/interrupt': () => { + throw new CodexAppServerRequestError( + 'turn/interrupt', + -32603, + 'interrupt failed', + 'write EPIPE' + ) + } + }) + const adapter = await acquired(codex) + const ctx = await runningContext(adapter.cancelTurn) + const result = await performCancel(ctx, { + clientOperationId: 'stop-1', + stopChild: async () => { + throw new Error('exit unverifiable') + }, + childReleased: () => false + }) + expect(result).toMatchObject({ ok: true, value: { cancelled: false } }) + expect(ctx.journal.activeTurnId()).toBe('turn-1') + expect(ctx.journal.snapshot().items.map((row) => row.body)).toContainEqual( + expect.objectContaining({ + kind: 'status', + text: "Codex didn't stop. Check the chat before trying again.", + failure: { kind: 'stopRefused', detail: { text: 'write EPIPE', audience: 'log' } } + }) + ) + }) + + it('keeps the requested-turn verdict without claiming the whole agent is idle', async () => { + const ctx = await runningContext(async () => ({ + cancelled: false, + refusal: { turnNotRunning: true } + })) + await performCancel(ctx, { clientOperationId: 'stop-2' }) + expect(ctx.journal.snapshot().items.map((row) => row.body)).toContainEqual( + expect.objectContaining({ + kind: 'status', + text: "Codex didn't stop. Check the chat before trying again.", + failure: { kind: 'stopRefused', turnNotRunning: true } + }) + ) + }) + + it('does not claim Codex is idle when it refuses a different turn', async () => { + const rig = await codexTurnLifecycleRig() + const sending = rig.send('client-1') + await vi.waitFor(() => expect(rig.turns.turnId).toBe('turn-1')) + rig.turns.start() + await sending + const ctx = await runningContext(rig.adapter.cancelTurn, 'turn-journal') + + const result = await performCancel(ctx, { clientOperationId: 'stop-another-turn' }) + + expect(result).toMatchObject({ ok: true, value: { cancelled: false } }) + expect(rig.turns.turnId).toBe('turn-1') + expect(ctx.journal.activeTurnId()).toBe('turn-journal') + expect(ctx.journal.snapshot().items.map((row) => row.body)).toContainEqual( + expect.objectContaining({ + kind: 'status', + text: "Codex didn't stop. Check the chat before trying again.", + failure: expect.objectContaining({ kind: 'stopRefused', turnNotRunning: true }) + }) + ) + }) + + it('names the chat agent when the interrupt throws without a provider refusal', async () => { + const ctx = await runningContext(async () => { + throw new Error('request timed out') + }) + await performCancel(ctx, { clientOperationId: 'stop-3' }) + expect(ctx.journal.snapshot().items.map((row) => row.body)).toContainEqual( + expect.objectContaining({ + kind: 'status', + text: "Codex hasn't confirmed that it stopped. Check the chat before trying again." + }) + ) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index 19fac5d20b8c..f2706058f761 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -83,7 +83,10 @@ function stopRefusedNote( const detail = refusal?.detail const fact = refusal?.turnMayOpen ? agentSessionFailureFact('cancelUnconfirmed') - : agentSessionFailureFact('stopRefused', detail ? { detail } : {}) + : agentSessionFailureFact('stopRefused', { + ...(detail ? { detail } : {}), + ...(refusal?.turnNotRunning ? { turnNotRunning: true } : {}) + }) return { kind: 'status', ...agentSessionFailureWords(fact, { ...ctx.failureTextContext, surface: 'row' }) @@ -242,7 +245,10 @@ async function cancelAndNote( // The adapter's error is Orca's; the row says only that the stop is unconfirmed. note = { kind: 'status', - ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { + ...ctx.failureTextContext, + surface: 'row' + }) } } // Keyed by the turn it stopped, as the provider names it, so another Stop of that turn rewrites diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts index ffaf2104b410..5b30567a099f 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -238,7 +238,7 @@ it('holds messages sent during the command and delivers them after it, in order (item) => item.body.kind === 'status' && item.body.tone === 'error' ) expect(error?.body).toMatchObject({ - text: 'Compaction failed: Not enough messages to compact.', + text: "The agent couldn't shorten this chat's history: Not enough messages to compact.", failure: { kind: 'compactionFailed', detail: NOT_ENOUGH } }) }) @@ -298,7 +298,7 @@ it('settles a command the provider refused as a failure with its reason, and mov // The message was not sent, in the provider's words; the command's row says the compaction failed. expect(snapshot.submissions.find((entry) => entry.clientMessageId === cmid)).toMatchObject({ dispatchState: 'rejected', - reason: 'The provider did not accept this message: Not enough messages to compact.', + reason: "The agent didn't accept this message: Not enough messages to compact.", rejection: { kind: 'providerRejected', detail: NOT_ENOUGH } }) expect( @@ -307,7 +307,7 @@ it('settles a command the provider refused as a failure with its reason, and mov expect.objectContaining({ body: { kind: 'status', - text: 'Compaction failed: Not enough messages to compact.', + text: "Codex couldn't shorten this chat's history: Not enough messages to compact.", failure: { kind: 'compactionFailed', detail: NOT_ENOUGH }, tone: 'error' }, @@ -333,7 +333,7 @@ it('says only that the compaction failed when the provider refused it without wo ).toEqual([ { kind: 'status', - text: 'Compaction failed.', + text: "Codex couldn't shorten this chat's history.", failure: { kind: 'compactionFailed' }, tone: 'error' } @@ -392,10 +392,9 @@ it('refuses the command at handover when the provider opened a turn meanwhile (B } await expect(commanded).resolves.toMatchObject({ ok: true, - // The reason, said as its refusal is everywhere, not a bare "try it again". value: { state: 'completed', - error: "The agent is still working. Run /compact when it's done.", + error: "Codex is still working. Run /compact when it's done.", failure: refused } }) @@ -407,7 +406,7 @@ it('refuses the command at handover when the provider opened a turn meanwhile (B ) ).toMatchObject({ dispatchState: 'rejected', - reason: "The agent is still working. Run /compact when it's done.", + reason: "Codex is still working. Run /compact when it's done.", rejection: refused }) }) @@ -782,7 +781,8 @@ it("ignores an older build's unconfirmed compaction record, and answers its oper ok: true, value: { state: 'unknown', - error: 'Compaction completion is unconfirmed.', + error: + "The agent hasn't confirmed that this chat's history was shortened. Check the chat before running /compact again.", failure: { kind: 'compactionUnconfirmed' } } }) diff --git a/src/main/pi/rpc-capture-replay.test.ts b/src/main/pi/rpc-capture-replay.test.ts index 1e19cb4da083..83404a941459 100644 --- a/src/main/pi/rpc-capture-replay.test.ts +++ b/src/main/pi/rpc-capture-replay.test.ts @@ -25,7 +25,7 @@ function capture(name: string) { } const disposals: (() => void)[] = [] -it('separates resend advice from the captured Pi 1.0.4 missing-key detail', () => { +it('gives only the sign-in step for the captured Pi 1.0.4 missing-key detail', () => { const reply = capture('signed-out').find( ({ dir, frame }) => dir === 'out' && frame.command === 'prompt' && frame.success === false )?.frame @@ -36,8 +36,9 @@ it('separates resend advice from the captured Pi 1.0.4 missing-key detail', () = const fact = piRpcFailureFact(reply.error) expect(fact.kind).toBe('notSignedIn') expect(fact.detail?.text).toBe(reply.error) - expect(agentSessionFailureSentence(fact, 'rejection', { agentName: 'Pi' })).toContain( - `${reply.error}. Then send your message again.` + // Pi's words name a "provider" and local doc paths, so the row keeps only Pi's sign-in step. + expect(agentSessionFailureSentence(fact, 'rejection', { agentName: 'Pi' })).toBe( + 'Sign in to Pi by running `pi` and using `/login` on the computer running this chat. Then send your message again.' ) }) diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 9db9b37287ca..74f9a386b8e9 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -632,7 +632,7 @@ describe('a Stop in that window that the turn never opens for', () => { return { stopping, sent } } - it('says Codex had no turn running when that turn ends first', async () => { + it('reports the refused Stop when its requested turn ends first', async () => { const { stopping } = await waitingStop() turns.end('interrupted') @@ -640,7 +640,7 @@ describe('a Stop in that window that the turn never opens for', () => { expect(interrupts).toBe(1) expect(childCloses).toBe(0) - expect(await statusRows()).toContain('Codex had no turn running to stop.') + expect(await statusRows()).toContain("Codex didn't stop. Check the chat before trying again.") }) it('lets a chat closed behind it close within its bound and one eviction', async () => { diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.provider-retry-runs.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.provider-retry-runs.test.tsx index 06bc89d8c16c..a4be8e883aef 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.provider-retry-runs.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.provider-retry-runs.test.tsx @@ -59,13 +59,11 @@ function transcript(items: AgentJournalRenderItem[]) { } describe('a Codex retrying a dropped stream', () => { - it('draws one warning for the run, with what failed on its second line', () => { + it('draws one warning for the run without a technical transport cause', () => { render(transcript([retry(1, 1), retry(2, 2), retry(3, 3)])) const rows = screen.getAllByText(/Codex is retrying/) expect(rows).toHaveLength(1) - expect(rows[0]?.textContent).toBe( - 'Codex is retrying: Reconnecting... 3/5.\nstream disconnected before completion' - ) + expect(rows[0]?.textContent).toBe('Codex is retrying: Reconnecting... 3/5.') }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx index d412b58f18a3..79444f6bf70a 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx @@ -203,7 +203,7 @@ describe('notice rows', () => { 'rewords known %s auth facts for %s and keeps literal diagnostics', async (locale, agentName, guidance) => { await i18n.changeLanguage(locale) - const detail = 'Provider diagnostic: {{agent}} must remain literal.' + const detail = 'Sign-in note: {{agent}} must remain literal.' const words = agentSessionFailureWords( { kind: 'notSignedIn', detail: { text: detail, audience: 'person' } }, { agentName, surface: 'row' } diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.failure-copy.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.failure-copy.test.tsx new file mode 100644 index 000000000000..d4c472e45da0 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.failure-copy.test.tsx @@ -0,0 +1,41 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import type * as I18nModule from '@/i18n/i18n' +import { NativeChatQueuedMessageCard } from './NativeChatQueuedMessageCard' + +vi.mock('@/i18n/i18n', async (importOriginal) => ({ + ...(await importOriginal()), + translate: (_key: string, fallback: string, values?: Record) => + fallback.replace(/\{\{(\w+)\}\}/g, (placeholder, name: string) => values?.[name] ?? placeholder) +})) +afterEach(cleanup) + +describe('the known chat agent on returned cards', () => { + it.each(['Codex', 'Claude'])('names %s when the card has its own Send control', (agentName) => { + render( + + undefined} + onDelete={() => undefined} + onEdit={() => undefined} + /> + + ) + expect(screen.getByText(`${agentName} couldn't receive this message.`)).toBeTruthy() + expect(screen.getByRole('button', { name: 'Send' })).toBeTruthy() + expect(screen.queryByText('Send it again.')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index 9201e87ef459..a10500972a0c 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -23,6 +23,7 @@ import { translate } from '@/i18n/i18n' import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-rejection-words' import { classifyDispatchRejection } from '../../../../shared/structured-agent-session-dispatch-rejection' import { readWholeAgentSessionFailureFact } from '../../../../shared/agent-session-failure' +import { sayAgentSessionFailureTranslated } from './agent-session-failure-words-text' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' import { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../../shared/agent-session-wire' import { isMacPlatform } from './native-chat-shortcut' @@ -73,16 +74,14 @@ export function queuedMessageCardCaption( // A card's own hold; the queue's pause is the list's header. Markers localize, and an absent // or unknown one (newer host) is a plain pause, never shown raw. if (card.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED) { - // Its Send shows only while the agent is idle; the words match what is on the card. - return card.waitsForAgent - ? translate( - 'components.native-chat.queuedMessages.pausedSendFailedWaiting', - "Couldn't send — press Send to retry once the agent finishes." - ) - : translate( - 'components.native-chat.queuedMessages.pausedSendFailed', - "Couldn't send — press Send to retry." - ) + const values = { agent: agentName ?? sayAgentSessionFailureTranslated('theAgent') } + return [ + sayAgentSessionFailureTranslated('writeFailed', values), + sayAgentSessionFailureTranslated( + card.waitsForAgent ? 'queueSendRetryWhenDone' : 'queueSendRetry', + values + ) + ].join(' ') } return translate('components.native-chat.queuedMessages.paused', 'Paused') case 'behind-returned': diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index 50b26c27d3a2..c69660d27cc6 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -269,9 +269,13 @@ describe('NativeChatQueuedMessageList', () => { ]) ) expect( - screen.getByText("Couldn't send — press Send to retry once the agent finishes.") + screen.getByText( + "The agent couldn't receive this message. Use Send to try again once The agent finishes." + ) + ).toBeTruthy() + expect( + screen.getByText("The agent couldn't receive this message. Use Send to try again.") ).toBeTruthy() - expect(screen.getByText("Couldn't send — press Send to retry.")).toBeTruthy() expect(screen.getAllByRole('button', { name: 'Send' })).toHaveLength(1) }) @@ -586,7 +590,9 @@ describe('NativeChatQueuedMessageList', () => { expect(row.className.split(' ')).not.toContain('rounded-md') } // Captions still render on their own rows. - expect(rows[2]?.textContent).toContain("Couldn't send — press Send to retry.") + expect(rows[2]?.textContent).toContain( + "The agent couldn't receive this message. Use Send to try again." + ) }) it('without a pause the box holds only the list, one card or many', () => { @@ -665,7 +671,9 @@ describe('NativeChatQueuedMessageList', () => { ]) ) const rows = screen.getAllByRole('listitem') - expect(rows[0]?.textContent).toContain("Couldn't send — press Send to retry.") + expect(rows[0]?.textContent).toContain( + "The agent couldn't receive this message. Use Send to try again." + ) expect(rows[0]?.textContent).not.toContain('send_failed') expect(rows[1]?.textContent).toContain('Stopped before it was sent') // Still a normal returned card: Send and Delete stay offered. diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx index 73334d71ca3b..a21045429a81 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx @@ -140,18 +140,12 @@ it('keeps the full notice on a message rejected for a reason no start-failure ro renderPane([ rejected('stated', START_FAILED_REASON, START_FAILED), - rejected( - 'other', - 'The provider did not accept this message: Image type .bmp.', - providerRejected - ) + rejected('other', "Claude didn't accept this message: Image type .bmp.", providerRejected) ]) expect(within(await notice('stated')).getByText('Your message was not sent.')).toBeTruthy() expect( - within(await notice('other')).getByText( - 'The provider did not accept this message: Image type .bmp.' - ) + within(await notice('other')).getByText("Claude didn't accept this message: Image type .bmp.") ).toBeTruthy() }) diff --git a/src/renderer/src/components/native-chat/agent-session-command-refusal-words-text.ts b/src/renderer/src/components/native-chat/agent-session-command-refusal-words-text.ts new file mode 100644 index 000000000000..cf8b2e5b9cfa --- /dev/null +++ b/src/renderer/src/components/native-chat/agent-session-command-refusal-words-text.ts @@ -0,0 +1,106 @@ +import { translate } from '@/i18n/i18n' +import { + AGENT_SESSION_FAILURE_COPY as COPY, + type AgentSessionFailureCopyId, + type AgentSessionFailureCopyValues +} from '../../../../shared/agent-session-failure-copy' + +type CommandCopyId = Extract + +export const COMMAND_REFUSAL_PIECES: Record< + CommandCopyId, + (values: AgentSessionFailureCopyValues) => string +> = { + commandAfterAnswer: (values) => + translate( + 'components.native-chat.failureWords.commandAfterAnswer', + COPY.commandAfterAnswer, + values + ), + commandBackgroundTasksRunning: (values) => + translate( + 'components.native-chat.failureWords.commandBackgroundTasksRunning', + COPY.commandBackgroundTasksRunning, + values + ), + commandAfterSending: (values) => + translate( + 'components.native-chat.failureWords.commandAfterSending', + COPY.commandAfterSending, + values + ), + commandWaitForPendingWork: (values) => + translate( + 'components.native-chat.failureWords.commandWaitForPendingWork', + COPY.commandWaitForPendingWork, + values + ), + commandStillWorking: (values) => + translate( + 'components.native-chat.failureWords.commandStillWorking', + COPY.commandStillWorking, + values + ), + commandRefused: (values) => + translate('components.native-chat.failureWords.commandRefused', COPY.commandRefused, values), + commandUnsupported: (values) => + translate( + 'components.native-chat.failureWords.commandUnsupported', + COPY.commandUnsupported, + values + ), + commandAgentStarting: (values) => + translate( + 'components.native-chat.failureWords.commandAgentStarting', + COPY.commandAgentStarting, + values + ), + commandWaitForStart: (values) => + translate( + 'components.native-chat.failureWords.commandWaitForStart', + COPY.commandWaitForStart, + values + ), + commandTurnActive: (values) => + translate( + 'components.native-chat.failureWords.commandTurnActive', + COPY.commandTurnActive, + values + ), + commandWaitForTurn: (values) => + translate( + 'components.native-chat.failureWords.commandWaitForTurn', + COPY.commandWaitForTurn, + values + ), + commandPromptPending: (values) => + translate( + 'components.native-chat.failureWords.commandPromptPending', + COPY.commandPromptPending, + values + ), + commandAgentRefused: (values) => + translate( + 'components.native-chat.failureWords.commandAgentRefused', + COPY.commandAgentRefused, + values + ), + commandOwnerUnproven: (values) => + translate( + 'components.native-chat.failureWords.commandOwnerUnproven', + COPY.commandOwnerUnproven, + values + ), + commandGoalsUnsupported: (values) => + translate( + 'components.native-chat.failureWords.commandGoalsUnsupported', + COPY.commandGoalsUnsupported, + values + ), + commandOptionRejected: (values) => + translate( + 'components.native-chat.failureWords.commandOptionRejected', + COPY.commandOptionRejected, + values + ) +} diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts index 9e4c7ddbcafb..29f947ad8b84 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts @@ -34,16 +34,12 @@ const IDS = Object.keys(AGENT_SESSION_FAILURE_COPY).filter( (id): id is AgentSessionFailureCopyId => id in AGENT_SESSION_FAILURE_COPY ) // Said by a refusal notice too, so they keep the notice's keys. -const NOTICE_PIECES: readonly AgentSessionFailureCopyId[] = [ - 'terminalAgentHoldsChat', - 'quitTerminalAgent', - 'startNewChat', - 'backgroundTasksRunning', - 'waitForBackgroundTasks', - 'agentStarting', - 'waitForStart', - 'agentStillWorking' -] +const NOTICE_KEYS: Partial> = { + terminalAgentHoldsChat: 'terminalAgentHoldsChat', + quitTerminalAgent: 'quitTerminalAgent', + startNewChat: 'startNewChat', + sendItAgain: 'tryAgainComposerSend' +} // Kana, and kanji whose simplified Chinese form differs (続 is 续, 読 is 读, ...). const JAPANESE_ONLY = /[\u3040-\u30ff続読変済図気帰戻検択転権単圧応対発処実証覧関専]/u const VALUES = { @@ -124,23 +120,29 @@ describe('desktop words for a failure fact', () => { for (const id of IDS) { vi.mocked(translate).mockClear() sayAgentSessionFailureTranslated(id, VALUES) - const section = NOTICE_PIECES.includes(id) ? 'writeNotice' : 'failureWords' + const section = NOTICE_KEYS[id] ? 'writeNotice' : 'failureWords' expect(vi.mocked(translate).mock.calls.map(([key, fallback]) => [key, fallback])).toEqual([ - [`components.native-chat.${section}.${id}`, AGENT_SESSION_FAILURE_COPY[id]] + [ + `components.native-chat.${section}.${NOTICE_KEYS[id] ?? id}`, + AGENT_SESSION_FAILURE_COPY[id] + ] ]) } }) it('keeps the English catalog in step with the shared copy', () => { const own = Object.fromEntries( - IDS.filter((id) => !NOTICE_PIECES.includes(id)).map((id) => [ - id, - AGENT_SESSION_FAILURE_COPY[id] - ]) + IDS.filter((id) => !NOTICE_KEYS[id]).map((id) => [id, AGENT_SESSION_FAILURE_COPY[id]]) ) expect(en.components['native-chat'].failureWords).toEqual(own) - for (const id of NOTICE_PIECES) { - expect(AGENT_SESSION_WRITE_NOTICE_COPY).toHaveProperty(id, AGENT_SESSION_FAILURE_COPY[id]) + for (const id of IDS) { + const noticeKey = NOTICE_KEYS[id] + if (noticeKey) { + expect(AGENT_SESSION_WRITE_NOTICE_COPY).toHaveProperty( + noticeKey, + AGENT_SESSION_FAILURE_COPY[id] + ) + } } }) @@ -180,7 +182,7 @@ describe('desktop words for a failure fact', () => { ) // The provider's own words stay as written: placeholders, nesting and markup are not read. expect(agentSessionWriteNoticeText(rejected)).toBe( - `Le fournisseur n'a pas accepté ce message : ${detail}.` + `Codex n'a pas accepté ce message : ${detail}.` ) expect( agentSessionWriteNoticeText( @@ -222,16 +224,18 @@ describe('desktop words for a failure fact', () => { { agentName: 'Codex' }, sayAgentSessionFailureTranslated ) - expect(sentence({ kind: 'commandRefused' })).toBe( - "Cette commande n'a pas été exécutée. Réessayez." + expect(sentence({ kind: 'commandRefused' })).toBe('Codex n’a pas exécuté cette commande.') + expect(sentence({ kind: 'stopRefused' })).toBe( + 'Codex ne s’est pas arrêté. Vérifiez le chat avant de réessayer.' ) - expect(sentence({ kind: 'stopRefused' })).toBe("Codex n'avait aucun tour en cours à arrêter.") expect( sentence({ kind: 'stopRefused', detail: { text: 'no active turn to interrupt.', audience: 'person' } }) - ).toBe("Codex ne s'est pas arrêté : no active turn to interrupt.") + ).toBe( + 'Codex ne s’est pas arrêté : no active turn to interrupt. Vérifiez le chat avant de réessayer.' + ) }) it("says an image's size limit in the reader's unit", async () => { diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 9aa65486b826..9f8d0d8e6ee2 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -2,6 +2,7 @@ // shared English as its fallback, so the host's row and desktop's notice never say it differently. import { translate } from '@/i18n/i18n' +import { COMMAND_REFUSAL_PIECES } from './agent-session-command-refusal-words-text' import { AGENT_SESSION_FAILURE_COPY as COPY, type AgentSessionFailureCopyId, @@ -34,6 +35,8 @@ const PIECES: Record + translate('components.native-chat.writeNotice.tryAgainComposerSend', COPY.sendItAgain), couldNotStart: (values) => translate('components.native-chat.failureWords.couldNotStart', COPY.couldNotStart, values), couldNotRestart: (values) => @@ -150,8 +153,12 @@ const PIECES: Record - translate('components.native-chat.failureWords.providerRejected', COPY.providerRejected), + providerRejected: (values) => + translate( + 'components.native-chat.failureWords.providerRejected', + COPY.providerRejected, + values + ), providerRejectedQuoted: (values) => translate( 'components.native-chat.failureWords.providerRejectedQuoted', @@ -162,8 +169,8 @@ const PIECES: Record translate('components.native-chat.failureWords.emptyMessage', COPY.emptyMessage), queueFull: () => translate('components.native-chat.failureWords.queueFull', COPY.queueFull), - writeFailed: () => - translate('components.native-chat.failureWords.writeFailed', COPY.writeFailed), + writeFailed: (values) => + translate('components.native-chat.failureWords.writeFailed', COPY.writeFailed, values), cancelled: () => translate('components.native-chat.failureWords.cancelled', COPY.cancelled), chatClosed: () => translate('components.native-chat.failureWords.chatClosed', COPY.chatClosed), hostRestarted: () => @@ -175,56 +182,45 @@ const PIECES: Record - translate('components.native-chat.failureWords.commandRefused', COPY.commandRefused), - commandRefusedTryAgain: () => - translate( - 'components.native-chat.failureWords.commandRefusedTryAgain', - COPY.commandRefusedTryAgain - ), - backgroundTasksRunning: () => + queueSendRetryWhenDone: (values) => translate( - 'components.native-chat.writeNotice.backgroundTasksRunning', - COPY.backgroundTasksRunning + 'components.native-chat.failureWords.queueSendRetryWhenDone', + COPY.queueSendRetryWhenDone, + values ), - waitForBackgroundTasks: () => + queueSendRetry: () => + translate('components.native-chat.failureWords.queueSendRetry', COPY.queueSendRetry), + ...COMMAND_REFUSAL_PIECES, + compactionFailed: (values) => translate( - 'components.native-chat.writeNotice.waitForBackgroundTasks', - COPY.waitForBackgroundTasks + 'components.native-chat.failureWords.compactionFailed', + COPY.compactionFailed, + values ), - agentStarting: () => - translate('components.native-chat.writeNotice.agentStarting', COPY.agentStarting), - waitForStart: () => - translate('components.native-chat.writeNotice.waitForStart', COPY.waitForStart), - agentStillWorking: () => - translate('components.native-chat.writeNotice.agentStillWorking', COPY.agentStillWorking), runCommandWhenDone: (values) => translate( 'components.native-chat.failureWords.runCommandWhenDone', COPY.runCommandWhenDone, values ), - commandAfterAnswer: (values) => - translate( - 'components.native-chat.failureWords.commandAfterAnswer', - COPY.commandAfterAnswer, - values - ), - compactionFailed: () => - translate('components.native-chat.failureWords.compactionFailed', COPY.compactionFailed), compactionFailedQuoted: (values) => translate( 'components.native-chat.failureWords.compactionFailedQuoted', COPY.compactionFailedQuoted, values ), - compactionUnconfirmed: () => + compactionUnconfirmed: (values) => translate( 'components.native-chat.failureWords.compactionUnconfirmed', - COPY.compactionUnconfirmed + COPY.compactionUnconfirmed, + values + ), + cancelUnconfirmed: (values) => + translate( + 'components.native-chat.failureWords.cancelUnconfirmed', + COPY.cancelUnconfirmed, + values ), - cancelUnconfirmed: () => - translate('components.native-chat.failureWords.cancelUnconfirmed', COPY.cancelUnconfirmed), stopRefused: (values) => translate('components.native-chat.failureWords.stopRefused', COPY.stopRefused, values), stopRefusedQuoted: (values) => @@ -235,8 +231,12 @@ const PIECES: Record translate('components.native-chat.failureWords.noTurnToStop', COPY.noTurnToStop, values), - answerUnconfirmed: () => - translate('components.native-chat.failureWords.answerUnconfirmed', COPY.answerUnconfirmed), + answerUnconfirmed: (values) => + translate( + 'components.native-chat.failureWords.answerUnconfirmed', + COPY.answerUnconfirmed, + values + ), hostFault: () => translate('components.native-chat.failureWords.hostFault', COPY.hostFault), hostFaultTryAgain: () => translate('components.native-chat.failureWords.hostFaultTryAgain', COPY.hostFaultTryAgain), @@ -254,6 +254,14 @@ const PIECES: Record + translate('components.native-chat.failureWords.providerError', COPY.providerError, values), + providerErrorQuoted: (values) => + translate( + 'components.native-chat.failureWords.providerErrorQuoted', + COPY.providerErrorQuoted, + values + ), providerRetryingQuoted: (values) => translate( 'components.native-chat.failureWords.providerRetryingQuoted', diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts index 3589b1084a0b..eb383dfdade6 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts @@ -2,7 +2,10 @@ // shared English as its fallback so desktop and mobile never say it differently. import { translate } from '@/i18n/i18n' -import { agentSessionFailureSentence } from '../../../../shared/agent-session-failure-words' +import { + agentSessionFailureSentence, + type AgentSessionFailureWordsContext +} from '../../../../shared/agent-session-failure-words' import { agentSessionWriteNoticeParts } from '../../../../shared/agent-session-refusal-notice' import { AGENT_SESSION_WRITE_NOTICE_COPY as COPY, @@ -159,6 +162,10 @@ const SENTENCES: Record string> = { tryAgain: () => translate('components.native-chat.writeNotice.tryAgain', COPY.tryAgain) } +export function sayAgentSessionWriteNoticeTranslated(id: AgentSessionWriteNoticeSentence): string { + return SENTENCES[id]() +} + export function agentSessionWriteNoticeText(parts: readonly AgentSessionWriteNoticePart[]): string { return joinSentences( parts.map((part) => @@ -170,7 +177,8 @@ export function agentSessionWriteNoticeText(parts: readonly AgentSessionWriteNot part.failure, part.surface, part.context, - sayAgentSessionFailureTranslated + sayAgentSessionFailureTranslated, + sayAgentSessionWriteNoticeTranslated ) ) ) @@ -178,7 +186,8 @@ export function agentSessionWriteNoticeText(parts: readonly AgentSessionWriteNot export function agentSessionWriteFailureText( failure: AgentSessionWriteFailure, - write: AgentSessionWriteKind + write: AgentSessionWriteKind, + context: AgentSessionFailureWordsContext = {} ): string { - return agentSessionWriteNoticeText(agentSessionWriteNoticeParts(failure, write)) + return agentSessionWriteNoticeText(agentSessionWriteNoticeParts(failure, write, context)) } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index 3aacca132633..078202de9882 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -11,10 +11,8 @@ import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' import { structuredAgentSessionStartFailureRowIdentity } from '../../../../shared/structured-agent-session-start-failure-row-key' -import { - structuredAgentSessionDeliveryNotices, - structuredAgentSessionStartFailureFacts -} from './structured-agent-session-delivery-notices' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' +import { structuredAgentSessionStartFailureFacts } from '../../../../shared/structured-agent-session-start-failure-facts' import type { StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends' const SENDING = 'Sending…' @@ -136,6 +134,15 @@ describe('the line under each of the chat own messages', () => { ).toEqual([startFailed]) }) + it("skips a subagent's sign-in row, which the conversation does not show", () => { + const notSignedIn: AgentSessionFailureFact = { kind: 'notSignedIn' } + const root = statusRow('root-auth', notSignedIn) + expect( + structuredAgentSessionStartFailureFacts([{ ...root, itemId: 'child-auth', agentId: 'c1' }]) + ).toEqual([]) + expect(structuredAgentSessionStartFailureFacts([root])).toEqual([notSignedIn]) + }) + it('says only that it was not sent, and words a rejection no row states in full', () => { const facts = structuredAgentSessionStartFailureFacts([statusRow(startRowKey, startFailed)]) const recorded = (id: string) => diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts index 96b1f66efb8f..0a1d0e3d720f 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts @@ -1,14 +1,10 @@ // What each of the structured chat's own messages says under it about its delivery. Derived from // the sender's in-memory sends and the host's rows on every render, never stored. -import { - readWholeAgentSessionFailureFact, - type AgentSessionFailureFact -} from '../../../../shared/agent-session-failure' +import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' -import { agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice' -import { structuredAgentSessionRejectionParts } from '../../../../shared/structured-agent-session-rejection-words' +import { structuredAgentSessionRecordedRejectionParts } from '../../../../shared/structured-agent-session-rejection-words' import { structuredAgentSessionRejectedShownInPlace } from '../../../../shared/structured-agent-session-message-projection' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' @@ -23,26 +19,6 @@ export { agentSessionFailureStatedByRow as agentSessionFailureStatedByStartRow, sameAgentSessionFailureFact } from '../../../../shared/agent-session-visible-failures' -import { agentSessionFailureStatedByRow as agentSessionFailureStatedByStartRow } from '../../../../shared/agent-session-visible-failures' - -function hostRejectionNoticeText( - submission: AgentJournalSubmission, - agentName: string, - startFailures: readonly AgentSessionFailureFact[] -): string { - if (agentSessionFailureStatedByStartRow(submission.rejection, startFailures)) { - return agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send')) - } - return agentSessionWriteNoticeText( - structuredAgentSessionRejectionParts( - submission.reason, - 'send', - readWholeAgentSessionFailureFact(submission.rejection), - { agentName } - ) - ) -} - /** * Keyed by the message id the transcript renders each message under; `agentName` is the chat's * agent, for the words. A message on its way says so quietly, and one the host rejected is worded @@ -78,7 +54,13 @@ export function structuredAgentSessionDeliveryNotices(args: { const id = agentJournalSubmissionKey(submission.clientMessageId) if (submission.dispatchState === 'rejected' && shown.has(id)) { notices.set(id, { - text: hostRejectionNoticeText(submission, agentName, args.startFailures) + text: agentSessionWriteNoticeText( + structuredAgentSessionRecordedRejectionParts( + submission, + { agentName }, + args.startFailures + ) + ) }) } } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts index 6381611738cd..791175b5a43e 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts @@ -226,9 +226,9 @@ describe('a message the host accepted and then rejected, on the desktop', () => expect(rows(projectStructuredAgentSessionMessages(items, [], submissions))).toEqual([ { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, - { id: agentJournalSubmissionKey('again'), text: 'continue', unsent: false }, - // Listed after the delivered rows; its journal position keeps its place. - { id: agentJournalSubmissionKey('first'), text: 'continue', unsent: true } + // At its journal position, before the copy sent after it. + { id: agentJournalSubmissionKey('first'), text: 'continue', unsent: true }, + { id: agentJournalSubmissionKey('again'), text: 'continue', unsent: false } ]) }) @@ -265,8 +265,8 @@ describe('a message the host accepted and then rejected, on the desktop', () => expect(rows(messages)).toEqual([ { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, { id: agentJournalSubmissionKey('first'), text: 'again', unsent: false }, - { id: agentJournalSubmissionKey('stopped'), text: 'again', unsent: false }, - { id: agentJournalSubmissionKey('failed'), text: 'again', unsent: true } + { id: agentJournalSubmissionKey('failed'), text: 'again', unsent: true }, + { id: agentJournalSubmissionKey('stopped'), text: 'again', unsent: false } ]) expectStopRowRightAfterStopped(messages) }) @@ -380,7 +380,7 @@ describe('a rejected message the queue holds', () => { }) describe('the phone', () => { - it('still hides a message the host failed to deliver, but draws one a Stop withdrew', () => { + it('draws a message the host failed to deliver as not sent, and one a Stop withdrew', () => { const items = [ ...SEED_ROWS, userItem('lost', 3, 'fix the parser'), @@ -392,9 +392,10 @@ describe('the phone', () => { withdrawn('stopped', 'never mind', 4) ] - const messages = projectShared(items, [], submissions, { rejectedInPlace: false }) + const messages = projectShared(items, [], submissions, { rejectedInPlace: true }) expect(rows(messages)).toEqual([ { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, + { id: agentJournalSubmissionKey('lost'), text: 'fix the parser', unsent: true }, { id: agentJournalSubmissionKey('stopped'), text: 'never mind', unsent: false } ]) expectStopRowRightAfterStopped(messages) diff --git a/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts b/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts index 6686498a3ef8..fe45381c15dc 100644 --- a/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts +++ b/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts @@ -117,7 +117,7 @@ describe('the line under the composer after a conversation command failed', () = "Claude n'a pas pu démarrer. Relancez /clear." ) expect((await sent(hostResult('compact', COMPACTION_FAILED))).error).toBe( - 'La compaction a échoué : Context is too short.' + "Claude n'a pas pu raccourcir l'historique de cette conversation : Context is too short." ) expect((await sent(hostResult('compact', { kind: 'restartFailed' }))).error).toBe( "Claude n'a pas pu redémarrer. Relancez /compact." @@ -134,6 +134,24 @@ describe('the line under the composer after a conversation command failed', () = ) }) + it('translates the recorded refusal and its action alongside the named command failure', async () => { + await i18n.changeLanguage('fr') + const result = hostResult( + 'compact', + { + kind: 'commandRefused', + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'conversationCleared' } + } + }, + 'codex' + ) + expect((await sent(result, 'codex')).error).toBe( + 'Codex n’a pas exécuté cette commande. Cette conversation a été effacée. Ouvrez la conversation actuelle pour continuer.' + ) + }) + it("shows the host's sentence as written when this build cannot read all of its fact", async () => { await i18n.changeLanguage('fr') const error = "Claude couldn't start. Start a new chat to continue." @@ -272,7 +290,7 @@ describe('a command held here', () => { const { send, result } = held('clear', null, { current: true }) expect(await result).toEqual({ accepted: false, - error: "The agent is still working. Run /clear when it's done." + error: "Claude is still working. Run /clear when it's done." }) expect(send).not.toHaveBeenCalled() }) @@ -280,29 +298,29 @@ describe('a command held here', () => { it('behind a message still being sent after a Stop, says so, naming no Retry', async () => { expect(await held('clear', 'sending').result).toEqual({ accepted: false, - error: 'Your earlier message is still being sent. Run /clear once it has gone.' + error: 'Your earlier message to Claude is still being sent. Run /clear once it has gone.' }) }) it('a /clear while the agent works says so in plain words, once', async () => { expect(await held('clear', 'working').result).toEqual({ accepted: false, - error: "The agent is still working. Run /clear when it's done." + error: "Claude is still working. Run /clear when it's done." }) expect(await held('clear', 'prompt').result).toEqual({ accepted: false, - error: "Answer the agent's question or approval, then run /clear." + error: "Answer Claude's question or approval, then run /clear." }) }) it('a /compact a host without the queue refuses reads the same way', async () => { expect(await held('compact', 'working').result).toEqual({ accepted: false, - error: "The agent is still working. Run /compact when it's done." + error: "Claude is still working. Run /compact when it's done." }) expect(await held('compact', 'prompt').result).toEqual({ accepted: false, - error: "Answer the agent's question or approval, then run /compact." + error: "Answer Claude's question or approval, then run /compact." }) }) }) @@ -370,7 +388,7 @@ describe('a refusal names what it waits on only while the chat shows it', () => expect(background).toEqual({ accepted: false, error: - 'Background tasks are still running. Wait for the background tasks to finish. Run /clear again.', + 'Claude still has background tasks running. Wait for the background tasks to finish. Run /clear again.', refusedWhile: 'background' }) // Ahead of the chat, or a reason that names nothing it shows: said as any failure. diff --git a/src/renderer/src/components/native-chat/structured-conversation-command-send.ts b/src/renderer/src/components/native-chat/structured-conversation-command-send.ts index 47ae05dcbc6d..ceb4f843e70e 100644 --- a/src/renderer/src/components/native-chat/structured-conversation-command-send.ts +++ b/src/renderer/src/components/native-chat/structured-conversation-command-send.ts @@ -8,13 +8,14 @@ import { type AgentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentSessionFailureSentence } from '../../../../shared/agent-session-failure-words' -import { translate } from '@/i18n/i18n' import { sayAgentSessionFailureTranslated } from './agent-session-failure-words-text' -import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' import { - agentSessionFailureStatedByStartRow, - structuredAgentSessionStartFailureFacts -} from './structured-agent-session-delivery-notices' + agentSessionWriteNoticeText, + agentSessionWriteFailureText, + sayAgentSessionWriteNoticeTranslated +} from './agent-session-write-notice-text' +import { agentSessionFailureStatedByStartRow } from '../../../../shared/structured-agent-session-start-failure-facts' +import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-delivery-notices' import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' import type { StructuredPromptItem } from './structured-agent-session-message-projection' import type { @@ -80,26 +81,28 @@ export type StructuredConversationCommandCauses = Readonly< /** The line a command refused here gets: what the person sees and can do, as the host says it. */ function heldCommandText( command: AgentSessionConversationCommand, - hold: Exclude + hold: Exclude, + agentName: string ): string { - const clear = command === 'clear' - switch (hold) { - case 'prompt': - return agentSessionWriteNoticeText([clear ? 'clearAfterAnswer' : 'compactAfterAnswer']) - case 'working': - return agentSessionWriteNoticeText([ - 'agentStillWorking', - clear ? 'runClearWhenDone' : 'runCompactWhenDone' - ]) - case 'sending': - return agentSessionWriteNoticeText([clear ? 'clearAfterSending' : 'compactAfterSending']) - case 'background': - break + if (hold === 'sending' || hold === 'background') { + return sayAgentSessionFailureTranslated( + hold === 'sending' ? 'commandAfterSending' : 'commandWaitForPendingWork', + { agent: agentName, command } + ) } - return translate( - 'components.native-chat.conversationCommand.pendingWork', - 'Wait for pending work and messages to finish before using this command.' - ) + return agentSessionWriteNoticeText([ + { + failure: { + kind: 'commandRefused', + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: hold === 'prompt' ? 'promptPending' : 'turnActive' } + } + }, + surface: 'row', + context: { agentName, command } + } + ]) } export async function sendStructuredConversationCommand(input: { @@ -128,7 +131,7 @@ export async function sendStructuredConversationCommand(input: { }) // A command on its way is the agent's work in flight. if (input.pending.current) { - return { accepted: false, error: heldCommandText(input.command, 'working') } + return { accepted: false, error: heldCommandText(input.command, 'working', input.agentName) } } // The message ahead reads as sending, and Send is busy until the host has it; nothing is armed. if (input.hold === 'ahead') { @@ -136,13 +139,18 @@ export async function sendStructuredConversationCommand(input: { } if (input.hold !== null) { // Each hold here is named for the cause it waits on. - return refused(heldCommandText(input.command, input.hold), input.hold) + return refused(heldCommandText(input.command, input.hold, input.agentName), input.hold) } input.pending.current = true try { const outcome = await input.send(input.command) if (outcome.kind === 'not-done') { - return { accepted: false, error: outcome.notice } + return { + accepted: false, + error: agentSessionWriteFailureText(outcome.failure, input.command, { + agentName: input.agentName + }) + } } // The pane stopped waiting on this reply (closed, left the chat, or sent a newer command). if (outcome.kind === 'dropped') { @@ -259,6 +267,7 @@ function conversationCommandFailureText( fact, 'row', { agentName, command: result.command }, - sayAgentSessionFailureTranslated + sayAgentSessionFailureTranslated, + sayAgentSessionWriteNoticeTranslated ) } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx index 1326b1f16a06..331fa6cbd6d1 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx @@ -129,7 +129,7 @@ describe('a /clear refused for something the chat shows', () => { const { result, rerender } = renderSession() expect(await clear(result)).toEqual({ accepted: false, - error: "The agent is still working. Run /clear when it's done.", + error: "Codex is still working. Run /clear when it's done.", refusedWhile: 'working' }) expect(result.current.commandRefusalCauses.working).toBe(true) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts index 83f20103de11..eecfca21d231 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts @@ -4,7 +4,7 @@ import type { AgentJournalRenderItem } from '../../../../shared/agent-session-jo import { sameAgentSessionFailureFact, structuredAgentSessionStartFailureFacts -} from './structured-agent-session-delivery-notices' +} from '../../../../shared/structured-agent-session-start-failure-facts' const NO_FACTS: readonly AgentSessionFailureFact[] = [] diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx index 96a7b3c8d2a4..472110770380 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx @@ -256,7 +256,7 @@ describe('a chat write the host refused', () => { await expect(result.current.cancel('turn-1')).resolves.toBeNull() await expect(result.current.runConversationCommand('compact')).resolves.toEqual({ accepted: false, - error: "The agent is still working. Run /compact when it's done." + error: "Claude is still working. Run /compact when it's done." }) }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx index 84976d1d4659..52a2dcbdfd6a 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx @@ -575,7 +575,7 @@ describe('a /compact against a host that holds commands in line', () => { }) expect(outcome).toEqual({ accepted: false, - error: 'Your earlier message is still being sent. Run /clear once it has gone.', + error: 'Your earlier message to Claude is still being sent. Run /clear once it has gone.', refusedWhile: 'sending' }) }) @@ -590,7 +590,7 @@ describe('a /compact against a host that holds commands in line', () => { }) expect(outcome).toEqual({ accepted: false, - error: 'Your earlier message is still being sent. Run /clear once it has gone.', + error: 'Your earlier message to Claude is still being sent. Run /clear once it has gone.', refusedWhile: 'sending' }) expect(commandCalls()).toHaveLength(0) @@ -643,7 +643,7 @@ describe('a /compact against a host that holds commands in line', () => { }) expect(outcome).toEqual({ accepted: false, - error: 'Wait for pending work and messages to finish before using this command.' + error: `Wait for pending work and messages with Claude to finish before running /${command}.` }) } expect(commandCalls()).toHaveLength(0) @@ -719,7 +719,7 @@ describe('a /compact against a host that holds commands but has its queue dark', }) expect(outcome).toEqual({ accepted: false, - error: "The agent is still working. Run /compact when it's done.", + error: "Claude is still working. Run /compact when it's done.", refusedWhile: 'working' }) expect(commandCalls()).toHaveLength(0) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index ef80824aa94b..748da9419cd5 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2853,12 +2853,15 @@ "effort": "Effort", "imageUnsupported": "Image paste is not supported for this agent." }, + "conversationCommand": { + "pendingWork": "Wait for pending work and messages to finish before using this command." + }, "failureWords": { "accountSwitchInProgress": "A Claude account switch is in progress. Try again after it finishes.", "agentCommandNotRunnable": "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", "agentCommandNotSignedIn": "Sign in to {{agent}} with `{{loginCommand}}` on the computer running this chat.", "agentNotSignedIn": "Sign in to {{agent}}.", - "answerUnconfirmed": "Your answer was recorded but the agent did not confirm it.", + "answerUnconfirmed": "Your answer was saved, but {{agent}} hasn't confirmed it. Check the chat before answering again.", "argumentsMissingValue": "Saved Arguments need a value for {{option}}.", "argumentsMultipleValues": "Saved Arguments give {{option}} more than one value.", "argumentsPositionalPrompt": "Saved Arguments include a prompt.", @@ -2875,23 +2878,36 @@ "attachmentTotalTooLarge": "The images on this message are too large together, so the message was not sent.", "attachmentUnreadable": "An attachment on this message couldn't be read, so the message was not sent.", "attachmentUnsupportedType": "{{agent}} accepts only PNG, JPEG, GIF, and WebP images, so this message was not sent.", - "cancelUnconfirmed": "Cancellation was not confirmed.", + "cancelUnconfirmed": "{{agent}} hasn't confirmed that it stopped. Check the chat before trying again.", "cancelled": "This message was withdrawn before the agent started it.", "chatClosed": "The chat closed before this message was sent.", "chooseClaudeAccount": "Choose or add one in Claude Accounts settings.", "chooseClaudeAccountThenRunCommand": "Choose or add one in Claude Accounts settings, then run /{{command}} again.", "chooseClaudeAccountThenSend": "Choose or add one in Claude Accounts settings, then send your message again.", - "commandAfterAnswer": "Answer the agent's question or approval, then run /{{command}}.", "claudeManagedNotSignedIn": "This Claude account isn't signed in. Sign in again in Claude Accounts settings.", "claudeSystemNotSignedIn": "Claude isn't signed in. Run `{{loginCommand}}`, or choose an account in Claude Accounts settings.", "cliMissing": "{{agent}} wasn't found on the computer running this chat. Install it, or check its Command in Settings → Agents.", "codexManagedNotSignedIn": "This Codex account isn't signed in. Sign in again in Codex Accounts settings.", "codexSystemNotSignedIn": "Codex isn't signed in. Run `{{loginCommand}}`.", - "commandRefused": "This command didn't run.", - "commandRefusedTryAgain": "This command didn't run. Try it again.", - "compactionFailed": "Compaction failed.", - "compactionFailedQuoted": "Compaction failed: {{detail}}.", - "compactionUnconfirmed": "Compaction completion is unconfirmed.", + "commandAfterAnswer": "Answer {{agent}}'s question or approval, then run /{{command}}.", + "commandAfterSending": "Your earlier message to {{agent}} is still being sent. Run /{{command}} once it has gone.", + "commandAgentRefused": "{{agent}} turned this down.", + "commandAgentStarting": "{{agent}} is still starting.", + "commandBackgroundTasksRunning": "{{agent}} still has background tasks running.", + "commandGoalsUnsupported": "{{agent}} doesn't support goals.", + "commandOptionRejected": "{{agent}} didn't accept this setting.", + "commandOwnerUnproven": "An earlier {{agent}} session may still be running in this chat.", + "commandPromptPending": "{{agent}} is waiting for an answer to a question or approval.", + "commandRefused": "{{agent}} didn't run this command.", + "commandStillWorking": "{{agent}} is still working.", + "commandTurnActive": "{{agent}} is still responding.", + "commandUnsupported": "{{agent}} doesn't support this command in this chat.", + "commandWaitForPendingWork": "Wait for pending work and messages with {{agent}} to finish before running /{{command}}.", + "commandWaitForStart": "Wait for {{agent}} to finish starting.", + "commandWaitForTurn": "Wait for it to finish, or stop it.", + "compactionFailed": "{{agent}} couldn't shorten this chat's history.", + "compactionFailedQuoted": "{{agent}} couldn't shorten this chat's history: {{detail}}.", + "compactionUnconfirmed": "{{agent}} hasn't confirmed that this chat's history was shortened. Check the chat before running /compact again.", "couldNotRestart": "{{agent}} couldn't restart.", "couldNotStart": "{{agent}} couldn't start.", "editSavedArguments": "Edit them in Settings > Agents > Arguments.", @@ -2906,16 +2922,18 @@ "launchFolderMissing": "The folder this chat ran in no longer exists. Restore it to continue this chat.", "managedAccountEnvOverride": "This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.", "managedAccountUnsupported": "While a Claude account is added in WSL, Claude chats need a Windows Claude account.", - "noTurnToStop": "{{agent}} had no turn running to stop.", + "noTurnToStop": "{{agent}} had no response in progress to stop.", "notDelivered": "This message was not delivered.", "notDeliveredSendAgain": "This message was not delivered. Send it again to continue.", "notSignedIn": "{{agent}} is not signed in.", "previousExitUnverifiable": "Couldn't stop {{agent}} from before.", + "providerError": "{{agent}} ran into a problem. Check the chat before trying again.", + "providerErrorQuoted": "{{agent}} ran into a problem: {{detail}}. Check the chat before trying again.", "providerExitedRejection": "{{agent}} stopped before this message was sent.", "providerExitedRow": "{{agent}} stopped while this response was in progress. You can continue in this conversation.", - "providerRateLimited": "{{agent}} is rate-limited and retrying.", - "providerRejected": "The provider did not accept this message.", - "providerRejectedQuoted": "The provider did not accept this message: {{detail}}.", + "providerRateLimited": "{{agent}} reached a request limit and is retrying.", + "providerRejected": "{{agent}} didn't accept this message.", + "providerRejectedQuoted": "{{agent}} didn't accept this message: {{detail}}.", "providerRetryLastError": "Last error: {{detail}}.", "providerRetryNumber": "Retry {{attempt}}.", "providerRetryNumberOf": "Retry {{attempt}} of {{maxRetries}}.", @@ -2923,6 +2941,8 @@ "providerRetryingQuoted": "{{agent}} is retrying: {{detail}}.", "providerStartFailed": "{{agent}} stopped before it finished starting.", "queueFull": "Too many messages were waiting for the agent, so this one was not sent.", + "queueSendRetry": "Use Send to try again.", + "queueSendRetryWhenDone": "Use Send to try again once {{agent}} finishes.", "runCommandAgain": "Run /{{command}} again.", "runCommandWhenDone": "Run /{{command}} when it's done.", "sendAgainToTryOnceMore": "Send your message again to try once more.", @@ -2931,11 +2951,11 @@ "signInFirst": "Sign in first.", "signInThenRunCommand": "Sign in, then run /{{command}} again.", "signInThenSend": "Sign in, then send your message again.", - "stopRefused": "{{agent}} didn't stop.", - "stopRefusedQuoted": "{{agent}} didn't stop: {{detail}}.", + "stopRefused": "{{agent}} didn't stop. Check the chat before trying again.", + "stopRefusedQuoted": "{{agent}} didn't stop: {{detail}}. Check the chat before trying again.", "theAgent": "The agent", "thenSendAgain": "Then send your message again.", - "writeFailed": "Orca couldn't hand this message to the agent, so it was not sent." + "writeFailed": "{{agent}} couldn't receive this message." }, "notices": { "historyItemTooLarge": "This part of the chat was too large to show.", @@ -2945,6 +2965,8 @@ "other": "Other…" }, "queuedMessages": { + "pausedSendFailed": "Couldn't send — press Send to retry.", + "pausedSendFailedWaiting": "Couldn't send — press Send to retry once the agent finishes.", "sendConfirmBody_one": "You are about to send a message. Do you want to clear the {{count}} message previously queued?", "sendConfirmBody_other": "You are about to send a message. Do you want to clear the {{count}} messages previously queued?" }, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index fa1afe01cbc6..a87e0549c2dd 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18035,8 +18035,8 @@ "chooseClaudeAccountThenSend": "Choose or add one in Claude Accounts settings, then send your message again.", "providerExitedRow": "{{agent}} stopped while this response was in progress. You can continue in this conversation.", "providerExitedRejection": "{{agent}} stopped before this message was sent.", - "providerRejected": "The provider did not accept this message.", - "providerRejectedQuoted": "The provider did not accept this message: {{detail}}.", + "providerRejected": "{{agent}} didn't accept this message.", + "providerRejectedQuoted": "{{agent}} didn't accept this message: {{detail}}.", "attachmentEmpty": "An image on this message is empty, so the message was not sent.", "attachmentTooLarge": "An image on this message is too large, so the message was not sent.", "attachmentLargerThan": "An image on this message is larger than {{size}} MB, so the message was not sent.", @@ -18051,27 +18051,38 @@ "attachmentUnreadable": "An attachment on this message couldn't be read, so the message was not sent.", "emptyMessage": "This message is empty, so it was not sent.", "queueFull": "Too many messages were waiting for the agent, so this one was not sent.", - "writeFailed": "Orca couldn't hand this message to the agent, so it was not sent.", + "writeFailed": "{{agent}} couldn't receive this message.", "cancelled": "This message was withdrawn before the agent started it.", "chatClosed": "The chat closed before this message was sent.", "hostRestarted": "Orca restarted before this message was sent.", "notDelivered": "This message was not delivered.", "notDeliveredSendAgain": "This message was not delivered. Send it again to continue.", - "commandRefused": "This command didn't run.", - "commandRefusedTryAgain": "This command didn't run. Try it again.", - "compactionFailed": "Compaction failed.", - "compactionFailedQuoted": "Compaction failed: {{detail}}.", - "compactionUnconfirmed": "Compaction completion is unconfirmed.", - "cancelUnconfirmed": "Cancellation was not confirmed.", - "stopRefused": "{{agent}} didn't stop.", - "stopRefusedQuoted": "{{agent}} didn't stop: {{detail}}.", - "noTurnToStop": "{{agent}} had no turn running to stop.", - "answerUnconfirmed": "Your answer was recorded but the agent did not confirm it.", + "commandRefused": "{{agent}} didn't run this command.", + "commandUnsupported": "{{agent}} doesn't support this command in this chat.", + "commandAgentStarting": "{{agent}} is still starting.", + "commandWaitForStart": "Wait for {{agent}} to finish starting.", + "commandTurnActive": "{{agent}} is still responding.", + "commandWaitForTurn": "Wait for it to finish, or stop it.", + "commandPromptPending": "{{agent}} is waiting for an answer to a question or approval.", + "commandAgentRefused": "{{agent}} turned this down.", + "commandOwnerUnproven": "An earlier {{agent}} session may still be running in this chat.", + "commandGoalsUnsupported": "{{agent}} doesn't support goals.", + "commandOptionRejected": "{{agent}} didn't accept this setting.", + "compactionFailed": "{{agent}} couldn't shorten this chat's history.", + "compactionFailedQuoted": "{{agent}} couldn't shorten this chat's history: {{detail}}.", + "compactionUnconfirmed": "{{agent}} hasn't confirmed that this chat's history was shortened. Check the chat before running /compact again.", + "cancelUnconfirmed": "{{agent}} hasn't confirmed that it stopped. Check the chat before trying again.", + "stopRefused": "{{agent}} didn't stop. Check the chat before trying again.", + "stopRefusedQuoted": "{{agent}} didn't stop: {{detail}}. Check the chat before trying again.", + "noTurnToStop": "{{agent}} had no response in progress to stop.", + "answerUnconfirmed": "Your answer was saved, but {{agent}} hasn't confirmed it. Check the chat before answering again.", "hostFault": "Orca ran into a problem, so this didn't go through.", "hostFaultTryAgain": "Orca ran into a problem, so this didn't go through. Try again.", "hostStopped": "{{agent}} never finished starting, so Orca stopped it.", - "providerRateLimited": "{{agent}} is rate-limited and retrying.", + "providerRateLimited": "{{agent}} reached a request limit and is retrying.", "providerRetrying": "{{agent}} hit a temporary problem and is retrying.", + "providerError": "{{agent}} ran into a problem. Check the chat before trying again.", + "providerErrorQuoted": "{{agent}} ran into a problem: {{detail}}. Check the chat before trying again.", "providerRetryingQuoted": "{{agent}} is retrying: {{detail}}.", "providerRetryNumber": "Retry {{attempt}}.", "providerRetryNumberOf": "Retry {{attempt}} of {{maxRetries}}.", @@ -18088,7 +18099,13 @@ "agentNotSignedIn": "Sign in to {{agent}}.", "thenSendAgain": "Then send your message again.", "runCommandWhenDone": "Run /{{command}} when it's done.", - "commandAfterAnswer": "Answer the agent's question or approval, then run /{{command}}." + "commandAfterAnswer": "Answer {{agent}}'s question or approval, then run /{{command}}.", + "commandBackgroundTasksRunning": "{{agent}} still has background tasks running.", + "commandAfterSending": "Your earlier message to {{agent}} is still being sent. Run /{{command}} once it has gone.", + "commandWaitForPendingWork": "Wait for pending work and messages with {{agent}} to finish before running /{{command}}.", + "queueSendRetry": "Use Send to try again.", + "queueSendRetryWhenDone": "Use Send to try again once {{agent}} finishes.", + "commandStillWorking": "{{agent}} is still working." }, "writeNotice": { "notDoneReadHistory": "This chat's history couldn't be loaded.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index f1456c9c6781..2c226b4a7d73 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17813,8 +17813,8 @@ "chooseClaudeAccountThenSend": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a enviar su mensaje.", "providerExitedRow": "{{agent}} se detuvo mientras esta respuesta estaba en curso. Puede continuar en esta conversación.", "providerExitedRejection": "{{agent}} se detuvo antes de que se enviara este mensaje.", - "providerRejected": "El proveedor no aceptó este mensaje.", - "providerRejectedQuoted": "El proveedor no aceptó este mensaje: {{detail}}.", + "providerRejected": "{{agent}} no aceptó este mensaje.", + "providerRejectedQuoted": "{{agent}} no aceptó este mensaje: {{detail}}.", "attachmentEmpty": "Una imagen de este mensaje está vacía, así que el mensaje no se envió.", "attachmentTooLarge": "Una imagen de este mensaje es demasiado grande, así que el mensaje no se envió.", "attachmentLargerThan": "Una imagen de este mensaje supera los {{size}} MB, así que el mensaje no se envió.", @@ -17829,22 +17829,31 @@ "attachmentUnreadable": "No se pudo leer un archivo adjunto de este mensaje, así que el mensaje no se envió.", "emptyMessage": "Este mensaje está vacío, así que no se envió.", "queueFull": "Había demasiados mensajes esperando al agente, así que este no se envió.", - "writeFailed": "Orca no pudo entregar este mensaje al agente, así que no se envió.", + "writeFailed": "{{agent}} no pudo recibir este mensaje.", "cancelled": "Este mensaje se retiró antes de que el agente empezara a procesarlo.", "chatClosed": "El chat se cerró antes de que se enviara este mensaje.", "hostRestarted": "Orca se reinició antes de que se enviara este mensaje.", "notDelivered": "Este mensaje no se entregó.", "notDeliveredSendAgain": "Este mensaje no se entregó. Vuelva a enviarlo para continuar.", - "commandRefused": "Este comando no se ejecutó.", - "commandRefusedTryAgain": "Este comando no se ejecutó. Vuelva a intentarlo.", - "compactionFailed": "La compactación falló.", - "compactionFailedQuoted": "La compactación falló: {{detail}}.", - "compactionUnconfirmed": "No se confirmó que la compactación haya terminado.", - "cancelUnconfirmed": "No se confirmó la cancelación.", - "stopRefused": "{{agent}} no se detuvo.", - "stopRefusedQuoted": "{{agent}} no se detuvo: {{detail}}.", - "noTurnToStop": "{{agent}} no tenía ningún turno en curso que detener.", - "answerUnconfirmed": "Su respuesta se registró, pero el agente no la confirmó.", + "commandRefused": "{{agent}} no ejecutó este comando.", + "commandUnsupported": "{{agent}} no admite este comando en este chat.", + "commandAgentStarting": "{{agent}} todavía se está iniciando.", + "commandWaitForStart": "Espera a que {{agent}} termine de iniciarse.", + "commandTurnActive": "{{agent}} todavía está respondiendo.", + "commandWaitForTurn": "Espera a que termine o detenlo.", + "commandPromptPending": "{{agent}} espera una respuesta a una pregunta o solicitud de aprobación.", + "commandAgentRefused": "{{agent}} rechazó esto.", + "commandOwnerUnproven": "Puede que una sesión anterior de {{agent}} siga ejecutándose en este chat.", + "commandGoalsUnsupported": "{{agent}} no admite objetivos.", + "commandOptionRejected": "{{agent}} no aceptó esta configuración.", + "compactionFailed": "{{agent}} no pudo acortar el historial de esta conversación.", + "compactionFailedQuoted": "{{agent}} no pudo acortar el historial de esta conversación: {{detail}}.", + "compactionUnconfirmed": "{{agent}} no ha confirmado que el historial de esta conversación se haya acortado. Revisa la conversación antes de volver a ejecutar /compact.", + "cancelUnconfirmed": "{{agent}} no ha confirmado que se haya detenido. Revisa la conversación antes de intentarlo de nuevo.", + "stopRefused": "{{agent}} no se detuvo. Revisa el chat antes de volver a intentarlo.", + "stopRefusedQuoted": "{{agent}} no se detuvo: {{detail}}. Revisa el chat antes de volver a intentarlo.", + "noTurnToStop": "{{agent}} no tenía ninguna respuesta en curso que detener.", + "answerUnconfirmed": "Tu respuesta se guardó, pero {{agent}} no la ha confirmado. Revisa la conversación antes de responder de nuevo.", "argumentsMissingValue": "Los argumentos guardados necesitan un valor para {{option}}.", "argumentsMultipleValues": "Los argumentos guardados asignan más de un valor a {{option}}.", "argumentsPositionalPrompt": "Los argumentos guardados incluyen un mensaje.", @@ -17852,8 +17861,10 @@ "hostFault": "Orca tuvo un problema, así que esto no se completó.", "hostFaultTryAgain": "Orca tuvo un problema, así que esto no se completó. Vuelva a intentarlo.", "hostStopped": "{{agent}} no terminó de iniciarse, así que Orca lo detuvo.", - "providerRateLimited": "{{agent}} alcanzó un límite de solicitudes y está reintentando.", + "providerRateLimited": "{{agent}} alcanzó un límite de solicitudes y está reintentándolo.", "providerRetrying": "{{agent}} tuvo un problema temporal y está reintentando.", + "providerError": "{{agent}} tuvo un problema. Revisa el chat antes de volver a intentarlo.", + "providerErrorQuoted": "{{agent}} tuvo un problema: {{detail}}. Revisa el chat antes de volver a intentarlo.", "providerRetryingQuoted": "{{agent}} está reintentando: {{detail}}.", "providerRetryNumber": "Reintento {{attempt}}.", "providerRetryNumberOf": "Reintento {{attempt}} de {{maxRetries}}.", @@ -17865,8 +17876,13 @@ "agentNotSignedIn": "Inicia sesión en {{agent}}.", "thenSendAgain": "Después, envía tu mensaje de nuevo.", "runCommandWhenDone": "Ejecute /{{command}} cuando termine.", - "commandAfterAnswer": "Responda a la pregunta o aprobación del agente y luego ejecute /{{command}}.", - "sessionNotRestored": "{{agent}} no pudo reabrir su sesión anterior, así que este chat continúa en una nueva. {{agent}} no recuerda los mensajes anteriores." + "commandAfterAnswer": "Responde a la pregunta o aprobación de {{agent}} y luego ejecuta /{{command}}.", + "commandBackgroundTasksRunning": "{{agent}} todavía tiene tareas en segundo plano en curso.", + "commandAfterSending": "Tu mensaje anterior a {{agent}} todavía se está enviando. Ejecuta /{{command}} cuando se haya enviado.", + "commandWaitForPendingWork": "Espera a que terminen el trabajo pendiente y los mensajes con {{agent}} antes de ejecutar /{{command}}.", + "queueSendRetry": "Usa Enviar para volver a intentarlo.", + "queueSendRetryWhenDone": "Usa Enviar para volver a intentarlo cuando {{agent}} termine.", + "commandStillWorking": "{{agent}} todavía está trabajando." }, "writeNotice": { "notDoneReadHistory": "No se pudo cargar el historial de este chat.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 74954b2510cf..40512335bff6 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17914,8 +17914,8 @@ "chooseClaudeAccountThenSend": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis renvoyez votre message.", "providerExitedRow": "{{agent}} s'est arrêté pendant que cette réponse était en cours. Vous pouvez continuer dans cette conversation.", "providerExitedRejection": "{{agent}} s'est arrêté avant l'envoi de ce message.", - "providerRejected": "Le fournisseur n'a pas accepté ce message.", - "providerRejectedQuoted": "Le fournisseur n'a pas accepté ce message : {{detail}}.", + "providerRejected": "{{agent}} n'a pas accepté ce message.", + "providerRejectedQuoted": "{{agent}} n'a pas accepté ce message : {{detail}}.", "attachmentEmpty": "Une image de ce message est vide, le message n'a donc pas été envoyé.", "attachmentTooLarge": "Une image de ce message est trop volumineuse, le message n'a donc pas été envoyé.", "attachmentLargerThan": "Une image de ce message dépasse {{size}} Mo, le message n'a donc pas été envoyé.", @@ -17930,22 +17930,31 @@ "attachmentUnreadable": "Une pièce jointe de ce message n'a pas pu être lue, le message n'a donc pas été envoyé.", "emptyMessage": "Ce message est vide, il n'a donc pas été envoyé.", "queueFull": "Trop de messages attendaient l'agent, celui-ci n'a donc pas été envoyé.", - "writeFailed": "Orca n'a pas pu transmettre ce message à l'agent, il n'a donc pas été envoyé.", + "writeFailed": "{{agent}} n'a pas pu recevoir ce message.", "cancelled": "Ce message a été retiré avant que l'agent ne commence à le traiter.", "chatClosed": "Le chat s'est fermé avant l'envoi de ce message.", "hostRestarted": "Orca a redémarré avant l'envoi de ce message.", "notDelivered": "Ce message n'a pas été remis.", "notDeliveredSendAgain": "Ce message n'a pas été remis. Renvoyez-le pour continuer.", - "commandRefused": "Cette commande n'a pas été exécutée.", - "commandRefusedTryAgain": "Cette commande n'a pas été exécutée. Réessayez.", - "compactionFailed": "La compaction a échoué.", - "compactionFailedQuoted": "La compaction a échoué : {{detail}}.", - "compactionUnconfirmed": "La fin de la compaction n'est pas confirmée.", - "cancelUnconfirmed": "L'annulation n'a pas été confirmée.", - "stopRefused": "{{agent}} ne s'est pas arrêté.", - "stopRefusedQuoted": "{{agent}} ne s'est pas arrêté : {{detail}}.", - "noTurnToStop": "{{agent}} n'avait aucun tour en cours à arrêter.", - "answerUnconfirmed": "Votre réponse a été enregistrée, mais l'agent ne l'a pas confirmée.", + "commandRefused": "{{agent}} n’a pas exécuté cette commande.", + "commandUnsupported": "{{agent}} ne prend pas en charge cette commande dans ce chat.", + "commandAgentStarting": "{{agent}} est encore en cours de démarrage.", + "commandWaitForStart": "Attendez que {{agent}} ait fini de démarrer.", + "commandTurnActive": "{{agent}} répond encore.", + "commandWaitForTurn": "Attendez la fin de sa réponse ou arrêtez-le.", + "commandPromptPending": "{{agent}} attend une réponse à une question ou à une demande d’approbation.", + "commandAgentRefused": "{{agent}} a refusé cette demande.", + "commandOwnerUnproven": "Une session précédente de {{agent}} est peut-être encore en cours dans ce chat.", + "commandGoalsUnsupported": "{{agent}} ne prend pas en charge les objectifs.", + "commandOptionRejected": "{{agent}} n’a pas accepté ce réglage.", + "compactionFailed": "{{agent}} n'a pas pu raccourcir l'historique de cette conversation.", + "compactionFailedQuoted": "{{agent}} n'a pas pu raccourcir l'historique de cette conversation : {{detail}}.", + "compactionUnconfirmed": "{{agent}} n'a pas confirmé que l'historique de cette conversation a été raccourci. Vérifiez la conversation avant de relancer /compact.", + "cancelUnconfirmed": "{{agent}} n'a pas confirmé son arrêt. Vérifiez la conversation avant de réessayer.", + "stopRefused": "{{agent}} ne s’est pas arrêté. Vérifiez le chat avant de réessayer.", + "stopRefusedQuoted": "{{agent}} ne s’est pas arrêté : {{detail}}. Vérifiez le chat avant de réessayer.", + "noTurnToStop": "{{agent}} n'avait aucune réponse en cours à arrêter.", + "answerUnconfirmed": "Votre réponse a été enregistrée, mais {{agent}} ne l'a pas confirmée. Vérifiez la conversation avant de répondre à nouveau.", "argumentsMissingValue": "Les arguments enregistrés nécessitent une valeur pour {{option}}.", "argumentsMultipleValues": "Les arguments enregistrés donnent plusieurs valeurs à {{option}}.", "argumentsPositionalPrompt": "Les arguments enregistrés contiennent un message.", @@ -17953,8 +17962,10 @@ "hostFault": "Orca a rencontré un problème, l'opération n'a donc pas abouti.", "hostFaultTryAgain": "Orca a rencontré un problème, l'opération n'a donc pas abouti. Réessayez.", "hostStopped": "{{agent}} n'a jamais fini de démarrer, Orca l'a donc arrêté.", - "providerRateLimited": "{{agent}} est limité en débit et réessaie.", + "providerRateLimited": "{{agent}} a atteint une limite de requêtes et réessaie.", "providerRetrying": "{{agent}} a rencontré un problème temporaire et réessaie.", + "providerError": "{{agent}} a rencontré un problème. Vérifiez le chat avant de réessayer.", + "providerErrorQuoted": "{{agent}} a rencontré un problème : {{detail}}. Vérifiez le chat avant de réessayer.", "providerRetryingQuoted": "{{agent}} réessaie : {{detail}}.", "providerRetryNumber": "Nouvelle tentative {{attempt}}.", "providerRetryNumberOf": "Nouvelle tentative {{attempt}} sur {{maxRetries}}.", @@ -17966,8 +17977,13 @@ "agentNotSignedIn": "Connectez-vous à {{agent}}.", "thenSendAgain": "Puis envoyez à nouveau votre message.", "runCommandWhenDone": "Lancez /{{command}} quand il aura terminé.", - "commandAfterAnswer": "Répondez à la question ou à la demande d'approbation de l'agent, puis lancez /{{command}}.", - "sessionNotRestored": "{{agent}} n'a pas pu rouvrir sa session précédente, cette discussion continue donc dans une nouvelle. {{agent}} ne se souvient pas des messages précédents." + "commandAfterAnswer": "Répondez à la question ou à la demande d’approbation de {{agent}}, puis exécutez /{{command}}.", + "commandBackgroundTasksRunning": "{{agent}} a encore des tâches en cours en arrière-plan.", + "commandAfterSending": "Votre message précédent à {{agent}} est encore en cours d’envoi. Exécutez /{{command}} une fois qu’il est envoyé.", + "commandWaitForPendingWork": "Attendez la fin du travail et des messages en attente avec {{agent}} avant d’exécuter /{{command}}.", + "queueSendRetry": "Utilisez Envoyer pour réessayer.", + "queueSendRetryWhenDone": "Utilisez Envoyer pour réessayer une fois que {{agent}} a terminé.", + "commandStillWorking": "{{agent}} travaille encore." }, "writeNotice": { "notDoneReadHistory": "L'historique de ce chat n'a pas pu être chargé.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 71a8d66f1648..9e8a37a62b08 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17848,8 +17848,8 @@ "chooseClaudeAccountThenSend": "Claude アカウントの設定で選択または追加してから、メッセージをもう一度送信してください。", "providerExitedRow": "この応答の途中で {{agent}} が停止しました。この会話で続けることができます。", "providerExitedRejection": "このメッセージが送信される前に {{agent}} が停止しました。", - "providerRejected": "プロバイダーはこのメッセージを受け付けませんでした。", - "providerRejectedQuoted": "プロバイダーはこのメッセージを受け付けませんでした: {{detail}}。", + "providerRejected": "{{agent}} はこのメッセージを受け付けませんでした。", + "providerRejectedQuoted": "{{agent}} はこのメッセージを受け付けませんでした:{{detail}}。", "attachmentEmpty": "このメッセージの画像が空のため、メッセージは送信されませんでした。", "attachmentTooLarge": "このメッセージの画像が大きすぎるため、メッセージは送信されませんでした。", "attachmentLargerThan": "このメッセージの画像が {{size}} MB を超えているため、メッセージは送信されませんでした。", @@ -17864,22 +17864,31 @@ "attachmentUnreadable": "このメッセージの添付ファイルを読み取れなかったため、メッセージは送信されませんでした。", "emptyMessage": "このメッセージは空のため、送信されませんでした。", "queueFull": "Agent を待っているメッセージが多すぎたため、このメッセージは送信されませんでした。", - "writeFailed": "Orca はこのメッセージを Agent に渡せなかったため、送信されませんでした。", + "writeFailed": "{{agent}} はこのメッセージを受信できませんでした。", "cancelled": "このメッセージは Agent が処理を始める前に取り消されました。", "chatClosed": "このメッセージが送信される前にチャットが閉じられました。", "hostRestarted": "このメッセージが送信される前に Orca が再起動しました。", "notDelivered": "このメッセージは配信されませんでした。", "notDeliveredSendAgain": "このメッセージは配信されませんでした。続けるには、もう一度送信してください。", - "commandRefused": "このコマンドは実行されませんでした。", - "commandRefusedTryAgain": "このコマンドは実行されませんでした。もう一度お試しください。", - "compactionFailed": "圧縮に失敗しました。", - "compactionFailedQuoted": "圧縮に失敗しました: {{detail}}。", - "compactionUnconfirmed": "圧縮の完了は確認されていません。", - "cancelUnconfirmed": "キャンセルは確認されませんでした。", - "stopRefused": "{{agent}} は停止しませんでした。", - "stopRefusedQuoted": "{{agent}} は停止しませんでした: {{detail}}。", - "noTurnToStop": "{{agent}} には停止できる実行中のターンがありませんでした。", - "answerUnconfirmed": "回答は記録されましたが、Agent は確認しませんでした。", + "commandRefused": "{{agent}} はこのコマンドを実行しませんでした。", + "commandUnsupported": "{{agent}} はこのチャットでこのコマンドに対応していません。", + "commandAgentStarting": "{{agent}} はまだ起動中です。", + "commandWaitForStart": "{{agent}} の起動が完了するまでお待ちください。", + "commandTurnActive": "{{agent}} はまだ応答中です。", + "commandWaitForTurn": "応答が終わるまで待つか、停止してください。", + "commandPromptPending": "{{agent}} は質問への回答または承認を待っています。", + "commandAgentRefused": "{{agent}} はこの要求を拒否しました。", + "commandOwnerUnproven": "このチャットで以前の {{agent}} セッションがまだ実行されている可能性があります。", + "commandGoalsUnsupported": "{{agent}} は目標に対応していません。", + "commandOptionRejected": "{{agent}} はこの設定を受け付けませんでした。", + "compactionFailed": "{{agent}} はこのチャットの履歴を短くできませんでした。", + "compactionFailedQuoted": "{{agent}} はこのチャットの履歴を短くできませんでした:{{detail}}。", + "compactionUnconfirmed": "{{agent}} はこのチャットの履歴が短くなったことを確認していません。/compact を再実行する前にチャットを確認してください。", + "cancelUnconfirmed": "{{agent}} は停止したことを確認していません。再試行する前にチャットを確認してください。", + "stopRefused": "{{agent}} は停止しませんでした。再試行する前にチャットを確認してください。", + "stopRefusedQuoted": "{{agent}} は停止しませんでした:{{detail}}。再試行する前にチャットを確認してください。", + "noTurnToStop": "{{agent}} に停止できる応答はありませんでした。", + "answerUnconfirmed": "回答は保存されましたが、{{agent}} は受け取ったことを確認していません。もう一度回答する前にチャットを確認してください。", "argumentsMissingValue": "保存された引数の {{option}} に値が必要です。", "argumentsMultipleValues": "保存された引数の {{option}} に複数の値が指定されています。", "argumentsPositionalPrompt": "保存された引数にプロンプトが含まれています。", @@ -17887,8 +17896,10 @@ "hostFault": "Orca で問題が発生したため、この操作は完了しませんでした。", "hostFaultTryAgain": "Orca で問題が発生したため、この操作は完了しませんでした。もう一度お試しください。", "hostStopped": "{{agent}} の起動が完了しなかったため、Orca が停止しました。", - "providerRateLimited": "{{agent}} はレート制限を受けているため、再試行しています。", + "providerRateLimited": "{{agent}} はリクエストの上限に達したため、再試行しています。", "providerRetrying": "{{agent}} で一時的な問題が発生したため、再試行しています。", + "providerError": "{{agent}} で問題が発生しました。再試行する前にチャットを確認してください。", + "providerErrorQuoted": "{{agent}} で問題が発生しました:{{detail}}。再試行する前にチャットを確認してください。", "providerRetryingQuoted": "{{agent}} は再試行しています: {{detail}}。", "providerRetryNumber": "{{attempt}} 回目の再試行です。", "providerRetryNumberOf": "{{maxRetries}} 回中 {{attempt}} 回目の再試行です。", @@ -17900,8 +17911,13 @@ "agentNotSignedIn": "{{agent}} にサインインしてください。", "thenSendAgain": "その後、メッセージをもう一度送信してください。", "runCommandWhenDone": "終わったら /{{command}} を実行してください。", - "commandAfterAnswer": "Agent の質問または承認に回答してから、/{{command}} を実行してください。", - "sessionNotRestored": "{{agent}} は以前のセッションを再開できなかったため、このチャットは新しいセッションで続行します。{{agent}} は以前のメッセージを覚えていません。" + "commandAfterAnswer": "{{agent}} の質問または承認に回答してから /{{command}} を実行してください。", + "commandBackgroundTasksRunning": "{{agent}} のバックグラウンドタスクがまだ実行中です。", + "commandAfterSending": "{{agent}} への前のメッセージはまだ送信中です。送信が完了してから /{{command}} を実行してください。", + "commandWaitForPendingWork": "{{agent}} の保留中の作業とメッセージが完了してから /{{command}} を実行してください。", + "queueSendRetry": "「送信」で再試行してください。", + "queueSendRetryWhenDone": "{{agent}} が完了してから「送信」で再試行してください。", + "commandStillWorking": "{{agent}} はまだ作業中です。" }, "writeNotice": { "notDoneReadHistory": "このチャットの履歴を読み込めませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 2a4cee1663bd..ac7ee91ea4cd 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17848,8 +17848,8 @@ "chooseClaudeAccountThenSend": "Claude 계정 설정에서 선택하거나 추가한 다음 메시지를 다시 보내세요.", "providerExitedRow": "이 응답이 진행되는 동안 {{agent}}이(가) 중지되었습니다. 이 대화에서 계속할 수 있습니다.", "providerExitedRejection": "이 메시지가 전송되기 전에 {{agent}}이(가) 중지되었습니다.", - "providerRejected": "공급자가 이 메시지를 받아들이지 않았습니다.", - "providerRejectedQuoted": "공급자가 이 메시지를 받아들이지 않았습니다: {{detail}}.", + "providerRejected": "{{agent}}이(가) 이 메시지를 수락하지 않았습니다.", + "providerRejectedQuoted": "{{agent}}이(가) 이 메시지를 수락하지 않았습니다: {{detail}}.", "attachmentEmpty": "이 메시지의 이미지가 비어 있어 메시지가 전송되지 않았습니다.", "attachmentTooLarge": "이 메시지의 이미지가 너무 커서 메시지가 전송되지 않았습니다.", "attachmentLargerThan": "이 메시지의 이미지가 {{size}}MB보다 커서 메시지가 전송되지 않았습니다.", @@ -17864,22 +17864,31 @@ "attachmentUnreadable": "이 메시지의 첨부 파일을 읽을 수 없어 메시지가 전송되지 않았습니다.", "emptyMessage": "이 메시지가 비어 있어 전송되지 않았습니다.", "queueFull": "에이전트를 기다리는 메시지가 너무 많아 이 메시지는 전송되지 않았습니다.", - "writeFailed": "Orca가 이 메시지를 에이전트에 전달하지 못해 전송되지 않았습니다.", + "writeFailed": "{{agent}}이(가) 이 메시지를 받지 못했습니다.", "cancelled": "이 메시지는 에이전트가 처리를 시작하기 전에 철회되었습니다.", "chatClosed": "이 메시지가 전송되기 전에 채팅이 닫혔습니다.", "hostRestarted": "이 메시지가 전송되기 전에 Orca가 다시 시작되었습니다.", "notDelivered": "이 메시지는 전달되지 않았습니다.", "notDeliveredSendAgain": "이 메시지는 전달되지 않았습니다. 계속하려면 다시 보내세요.", - "commandRefused": "이 명령이 실행되지 않았습니다.", - "commandRefusedTryAgain": "이 명령이 실행되지 않았습니다. 다시 시도하세요.", - "compactionFailed": "압축에 실패했습니다.", - "compactionFailedQuoted": "압축에 실패했습니다: {{detail}}.", - "compactionUnconfirmed": "압축 완료가 확인되지 않았습니다.", - "cancelUnconfirmed": "취소가 확인되지 않았습니다.", - "stopRefused": "{{agent}}이(가) 중지되지 않았습니다.", - "stopRefusedQuoted": "{{agent}}이(가) 중지되지 않았습니다: {{detail}}.", - "noTurnToStop": "{{agent}}에 중지할 실행 중인 턴이 없었습니다.", - "answerUnconfirmed": "응답이 기록되었지만 에이전트가 확인하지 않았습니다.", + "commandRefused": "{{agent}}이(가) 이 명령을 실행하지 않았습니다.", + "commandUnsupported": "{{agent}}은(는) 이 채팅에서 이 명령을 지원하지 않습니다.", + "commandAgentStarting": "{{agent}}이(가) 아직 시작 중입니다.", + "commandWaitForStart": "{{agent}}이(가) 시작을 마칠 때까지 기다리세요.", + "commandTurnActive": "{{agent}}이(가) 아직 응답 중입니다.", + "commandWaitForTurn": "응답이 끝날 때까지 기다리거나 중지하세요.", + "commandPromptPending": "{{agent}}이(가) 질문 또는 승인 요청에 대한 답변을 기다리고 있습니다.", + "commandAgentRefused": "{{agent}}이(가) 이 요청을 거절했습니다.", + "commandOwnerUnproven": "이 채팅에서 이전 {{agent}} 세션이 아직 실행 중일 수 있습니다.", + "commandGoalsUnsupported": "{{agent}}은(는) 목표를 지원하지 않습니다.", + "commandOptionRejected": "{{agent}}이(가) 이 설정을 수락하지 않았습니다.", + "compactionFailed": "{{agent}}이(가) 이 채팅의 기록을 줄이지 못했습니다.", + "compactionFailedQuoted": "{{agent}}이(가) 이 채팅의 기록을 줄이지 못했습니다: {{detail}}.", + "compactionUnconfirmed": "{{agent}}이(가) 이 채팅의 기록이 줄어들었는지 확인하지 않았습니다. /compact를 다시 실행하기 전에 채팅을 확인하세요.", + "cancelUnconfirmed": "{{agent}}이(가) 중지되었는지 확인하지 않았습니다. 다시 시도하기 전에 채팅을 확인하세요.", + "stopRefused": "{{agent}}이(가) 중지되지 않았습니다. 다시 시도하기 전에 채팅을 확인하세요.", + "stopRefusedQuoted": "{{agent}}이(가) 중지되지 않았습니다: {{detail}}. 다시 시도하기 전에 채팅을 확인하세요.", + "noTurnToStop": "{{agent}}에 중지할 진행 중인 응답이 없었습니다.", + "answerUnconfirmed": "답변은 저장되었지만 {{agent}}이(가) 확인하지 않았습니다. 다시 답변하기 전에 채팅을 확인하세요.", "argumentsMissingValue": "저장된 인수의 {{option}}에 값이 필요합니다.", "argumentsMultipleValues": "저장된 인수에 {{option}}의 값이 여러 개 지정되어 있습니다.", "argumentsPositionalPrompt": "저장된 인수에 프롬프트가 포함되어 있습니다.", @@ -17887,8 +17896,10 @@ "hostFault": "Orca에 문제가 발생하여 이 작업이 완료되지 않았습니다.", "hostFaultTryAgain": "Orca에 문제가 발생하여 이 작업이 완료되지 않았습니다. 다시 시도하세요.", "hostStopped": "{{agent}}이(가) 시작을 마치지 못해 Orca가 중지했습니다.", - "providerRateLimited": "{{agent}}이(가) 속도 제한에 걸려 다시 시도하고 있습니다.", + "providerRateLimited": "{{agent}}이(가) 요청 한도에 도달하여 다시 시도하고 있습니다.", "providerRetrying": "{{agent}}에 일시적인 문제가 발생하여 다시 시도하고 있습니다.", + "providerError": "{{agent}}에서 문제가 발생했습니다. 다시 시도하기 전에 채팅을 확인하세요.", + "providerErrorQuoted": "{{agent}}에서 문제가 발생했습니다: {{detail}}. 다시 시도하기 전에 채팅을 확인하세요.", "providerRetryingQuoted": "{{agent}}이(가) 다시 시도하고 있습니다: {{detail}}.", "providerRetryNumber": "{{attempt}}번째 재시도입니다.", "providerRetryNumberOf": "{{maxRetries}}회 중 {{attempt}}번째 재시도입니다.", @@ -17900,8 +17911,13 @@ "agentNotSignedIn": "{{agent}}에 로그인하세요.", "thenSendAgain": "그런 다음 메시지를 다시 보내세요.", "runCommandWhenDone": "작업이 끝나면 /{{command}}을(를) 실행하세요.", - "commandAfterAnswer": "에이전트의 질문 또는 승인에 응답한 다음 /{{command}}을(를) 실행하세요.", - "sessionNotRestored": "{{agent}}이(가) 이전 세션을 다시 열 수 없어 이 채팅은 새 세션에서 계속됩니다. {{agent}}은(는) 이전 메시지를 기억하지 않습니다." + "commandAfterAnswer": "{{agent}}의 질문이나 승인 요청에 답한 다음 /{{command}}를 실행하세요.", + "commandBackgroundTasksRunning": "{{agent}}의 백그라운드 작업이 아직 실행 중입니다.", + "commandAfterSending": "{{agent}}에게 보낸 이전 메시지를 아직 전송 중입니다. 전송이 완료되면 /{{command}}를 실행하세요.", + "commandWaitForPendingWork": "{{agent}}의 대기 중인 작업과 메시지가 완료된 후 /{{command}}를 실행하세요.", + "queueSendRetry": "보내기로 다시 시도하세요.", + "queueSendRetryWhenDone": "{{agent}}가 끝나면 보내기로 다시 시도하세요.", + "commandStillWorking": "{{agent}}가 아직 작업 중입니다." }, "writeNotice": { "notDoneReadHistory": "이 채팅의 기록을 불러오지 못했습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b1306527066e..6e5b85be04b8 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17813,8 +17813,8 @@ "chooseClaudeAccountThenSend": "请在 Claude 账户设置中选择或添加一个,然后重新发送您的消息。", "providerExitedRow": "{{agent}} 在生成此回复时停止了。您可以在此对话中继续。", "providerExitedRejection": "{{agent}} 在发送此消息前已停止。", - "providerRejected": "提供商未接受此消息。", - "providerRejectedQuoted": "提供商未接受此消息:{{detail}}。", + "providerRejected": "{{agent}} 未接受这条消息。", + "providerRejectedQuoted": "{{agent}} 未接受这条消息:{{detail}}。", "attachmentEmpty": "此消息中的一张图片为空,因此消息未发送。", "attachmentTooLarge": "此消息中的一张图片过大,因此消息未发送。", "attachmentLargerThan": "此消息中的一张图片超过 {{size}} MB,因此消息未发送。", @@ -17829,22 +17829,31 @@ "attachmentUnreadable": "无法读取此消息中的附件,因此消息未发送。", "emptyMessage": "此消息为空,因此未发送。", "queueFull": "等待智能体处理的消息过多,因此此消息未发送。", - "writeFailed": "Orca 无法将此消息交给智能体,因此消息未发送。", + "writeFailed": "{{agent}} 未能收到这条消息。", "cancelled": "此消息在智能体开始处理前已撤回。", "chatClosed": "此消息发送前聊天已关闭。", "hostRestarted": "此消息发送前 Orca 已重新启动。", "notDelivered": "此消息未送达。", "notDeliveredSendAgain": "此消息未送达。请重新发送以继续。", - "commandRefused": "此命令未运行。", - "commandRefusedTryAgain": "此命令未运行。请重试。", - "compactionFailed": "压缩失败。", - "compactionFailedQuoted": "压缩失败:{{detail}}。", - "compactionUnconfirmed": "未确认压缩是否完成。", - "cancelUnconfirmed": "未确认取消。", - "stopRefused": "{{agent}} 未停止。", - "stopRefusedQuoted": "{{agent}} 未停止:{{detail}}。", - "noTurnToStop": "{{agent}} 没有正在运行的轮次可停止。", - "answerUnconfirmed": "您的回答已记录,但智能体未确认。", + "commandRefused": "{{agent}} 未执行此命令。", + "commandUnsupported": "{{agent}} 在此聊天中不支持此命令。", + "commandAgentStarting": "{{agent}} 仍在启动。", + "commandWaitForStart": "请等待 {{agent}} 完成启动。", + "commandTurnActive": "{{agent}} 仍在回复。", + "commandWaitForTurn": "请等待回复完成,或将其停止。", + "commandPromptPending": "{{agent}} 正在等待问题或审批请求的答复。", + "commandAgentRefused": "{{agent}} 拒绝了此请求。", + "commandOwnerUnproven": "此聊天中先前的 {{agent}} 会话可能仍在运行。", + "commandGoalsUnsupported": "{{agent}} 不支持目标。", + "commandOptionRejected": "{{agent}} 未接受此设置。", + "compactionFailed": "{{agent}} 未能缩短此聊天的历史记录。", + "compactionFailedQuoted": "{{agent}} 未能缩短此聊天的历史记录:{{detail}}。", + "compactionUnconfirmed": "{{agent}} 尚未确认此聊天的历史记录已缩短。再次运行 /compact 前,请检查聊天。", + "cancelUnconfirmed": "{{agent}} 尚未确认已停止。重试前,请检查聊天。", + "stopRefused": "{{agent}} 未停止。请先检查聊天,再重试。", + "stopRefusedQuoted": "{{agent}} 未停止:{{detail}}。请先检查聊天,再重试。", + "noTurnToStop": "{{agent}} 没有正在进行的回复可供停止。", + "answerUnconfirmed": "你的回答已保存,但 {{agent}} 尚未确认。再次回答前,请检查聊天。", "argumentsMissingValue": "已保存的参数需要为 {{option}} 指定一个值。", "argumentsMultipleValues": "已保存的参数为 {{option}} 指定了多个值。", "argumentsPositionalPrompt": "已保存的参数中包含提示词。", @@ -17852,8 +17861,10 @@ "hostFault": "Orca 遇到问题,因此此操作未完成。", "hostFaultTryAgain": "Orca 遇到问题,因此此操作未完成。请重试。", "hostStopped": "{{agent}} 始终未完成启动,因此 Orca 已将其停止。", - "providerRateLimited": "{{agent}} 已被限流,正在重试。", + "providerRateLimited": "{{agent}} 已达到请求限制,正在重试。", "providerRetrying": "{{agent}} 遇到临时问题,正在重试。", + "providerError": "{{agent}} 遇到了问题。请先检查聊天,再重试。", + "providerErrorQuoted": "{{agent}} 遇到了问题:{{detail}}。请先检查聊天,再重试。", "providerRetryingQuoted": "{{agent}} 正在重试:{{detail}}。", "providerRetryNumber": "第 {{attempt}} 次重试。", "providerRetryNumberOf": "第 {{attempt}} 次重试,共 {{maxRetries}} 次。", @@ -17865,8 +17876,13 @@ "agentNotSignedIn": "请登录 {{agent}}。", "thenSendAgain": "然后再次发送消息。", "runCommandWhenDone": "完成后再运行 /{{command}}。", - "commandAfterAnswer": "请先回答智能体的问题或审批,然后再运行 /{{command}}。", - "sessionNotRestored": "{{agent}} 无法重新打开之前的会话,因此此聊天将在新会话中继续。{{agent}} 不记得之前的消息。" + "commandAfterAnswer": "回答 {{agent}} 的问题或批准请求后再运行 /{{command}}。", + "commandBackgroundTasksRunning": "{{agent}} 仍有后台任务正在运行。", + "commandAfterSending": "之前发给 {{agent}} 的消息仍在发送中。发送完成后再运行 /{{command}}。", + "commandWaitForPendingWork": "请等待 {{agent}} 的待处理工作和消息完成后再运行 /{{command}}。", + "queueSendRetry": "使用发送重试。", + "queueSendRetryWhenDone": "{{agent}} 完成后使用发送重试。", + "commandStillWorking": "{{agent}} 仍在工作。" }, "writeNotice": { "notDoneReadHistory": "无法加载此聊天的历史记录。", diff --git a/src/shared/agent-session-attachment-failure-words.ts b/src/shared/agent-session-attachment-failure-words.ts new file mode 100644 index 000000000000..702f2147d93d --- /dev/null +++ b/src/shared/agent-session-attachment-failure-words.ts @@ -0,0 +1,47 @@ +import type { + AgentSessionAttachmentProblem, + AgentSessionAttachmentProblemReason +} from './agent-session-failure' +import type { + AgentSessionFailureCopyValues, + AgentSessionFailureSay +} from './agent-session-failure-copy' + +const BYTES_PER_MB = 1024 * 1024 + +// The number only; each language's sentence carries its own unit. +function megabytes(bytes: number): string { + return String(Math.round((bytes / BYTES_PER_MB) * 10) / 10) +} + +const ATTACHMENT_SENTENCES = { + empty: (say) => say('attachmentEmpty'), + tooLarge: (say, _, { limit }) => + limit ? say('attachmentLargerThan', { size: megabytes(limit) }) : say('attachmentTooLarge'), + tooMany: (say, agent, { limit }) => + limit ? say('attachmentAtMost', { ...agent, limit: String(limit) }) : say('attachmentTooMany'), + totalTooLarge: (say, _, { limit }) => + limit + ? say('attachmentTotalMoreThan', { size: megabytes(limit) }) + : say('attachmentTotalTooLarge'), + unsupportedType: (say, agent) => say('attachmentUnsupportedType', agent), + notAFile: (say) => say('attachmentNotAFile'), + noSource: (say) => say('attachmentNoSource') +} satisfies Record< + AgentSessionAttachmentProblemReason, + ( + say: AgentSessionFailureSay, + agent: AgentSessionFailureCopyValues, + problem: AgentSessionAttachmentProblem + ) => string +> + +export function agentSessionAttachmentFailureWords( + problem: AgentSessionAttachmentProblem | undefined, + agent: AgentSessionFailureCopyValues, + say: AgentSessionFailureSay +): string { + return problem + ? ATTACHMENT_SENTENCES[problem.reason](say, agent, problem) + : say('attachmentInvalid') +} diff --git a/src/shared/agent-session-availability-sentences.ts b/src/shared/agent-session-availability-sentences.ts index ccf0b409a0de..3c40258da03e 100644 --- a/src/shared/agent-session-availability-sentences.ts +++ b/src/shared/agent-session-availability-sentences.ts @@ -7,6 +7,7 @@ import type { } from './agent-session-failure-words' import { joinSentences } from './sentence-joining' import { agentSessionSignInFor } from './agent-session-sign-in' +import { isProviderDiagnosticPersonText } from './provider-diagnostic-person-text' const SENTENCE_END = /[.!?。!?][\p{Pe}\p{Pf}"']*\s*$/u @@ -38,7 +39,10 @@ export function notSignedInSentence( signIn?.agent !== 'codex' ? say(signIn ? 'thenSendAgain' : 'signInThenSend') : undefined - const detail = fact.detail?.audience === 'person' ? fact.detail.text : undefined + const detail = + fact.detail?.audience === 'person' && isProviderDiagnosticPersonText(fact.detail.text) + ? fact.detail.text + : undefined const detailSentence = detail && next && !SENTENCE_END.test(detail) ? `${detail.trimEnd()}.` : detail return joinSentences( diff --git a/src/shared/agent-session-command-refusal-words.test.ts b/src/shared/agent-session-command-refusal-words.test.ts new file mode 100644 index 000000000000..db9b3145ce77 --- /dev/null +++ b/src/shared/agent-session-command-refusal-words.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import { agentSessionFailureSentence } from './agent-session-failure-words' +import { readWholeAgentSessionFailureFact } from './agent-session-failure' + +describe('commands whose recorded refusal decides the next step', () => { + it.each([false, true])('keeps a cleared chat action with retryControl=%s', (retryControl) => { + expect( + agentSessionFailureSentence( + { + kind: 'commandRefused', + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'conversationCleared' } + } + }, + 'rejection', + { agentName: 'Codex', retryControl } + ) + ).toBe( + "Codex didn't run this command. This conversation has been cleared. Open the current conversation to continue." + ) + }) + + it('does not suggest retrying an unsupported command', () => { + expect( + agentSessionFailureSentence( + { + kind: 'commandRefused', + refusal: { + code: 'structured_agent_session_unsupported', + details: { reason: 'hostUnsupported' } + } + }, + 'rejection', + { agentName: 'Claude' } + ) + ).toBe("Claude doesn't support this command in this chat.") + }) + + it('keeps the prerequisite when an agent is still responding', () => { + expect( + agentSessionFailureSentence( + { + kind: 'commandRefused', + refusal: { code: 'agent_session_operation_invalid', details: { reason: 'turnActive' } } + }, + 'rejection', + { agentName: 'Codex' } + ) + ).toBe( + "Codex didn't run this command. Codex is still responding. Wait for it to finish, or stop it." + ) + }) + + it.each([ + ['turnActive', "Codex is still working. Run /compact when it's done."], + ['messagesUnsettled', "Codex is still working. Run /compact when it's done."], + ['promptPending', "Answer Codex's question or approval, then run /compact."], + [ + 'backgroundTasksRunning', + 'Codex still has background tasks running. Wait for the background tasks to finish. Run /compact again.' + ], + [ + 'handoffInFlight', + 'Codex is still starting. Wait for Codex to finish starting. Run /compact again.' + ] + ] as const)('keeps the command action and agent name for %s', (reason, expected) => { + expect( + agentSessionFailureSentence( + { + kind: 'commandRefused', + refusal: { code: 'agent_session_operation_invalid', details: { reason } } + }, + 'row', + { agentName: 'Codex', command: 'compact' } + ) + ).toBe(expected) + }) + + it('does not invent a retry action when an old host recorded no reason', () => { + expect( + agentSessionFailureSentence({ kind: 'commandRefused' }, 'rejection', { agentName: 'Codex' }) + ).toBe("Codex didn't run this command.") + }) + + it.each([ + 'providerStarting', + 'turnActive', + 'promptPending', + 'optionRejected', + 'goalsUnsupported', + 'providerRejected' + ] as const)('keeps the known agent name in every reused cause/action for %s', (reason) => { + const text = agentSessionFailureSentence( + { + kind: 'commandRefused', + refusal: { + code: 'agent_session_operation_invalid', + details: { reason } + } + }, + 'rejection', + { agentName: 'Claude' } + ) + expect(text).toContain('Claude') + expect(text).not.toMatch(/the agent/i) + }) + + it('reads a stored requested-turn refusal without claiming the agent is idle', () => { + const fact = { kind: 'stopRefused', turnNotRunning: true } as const + expect(readWholeAgentSessionFailureFact(fact)).toEqual(fact) + expect(agentSessionFailureSentence(fact, 'row', { agentName: 'Codex' })).toBe( + "Codex didn't stop. Check the chat before trying again." + ) + expect( + agentSessionFailureSentence({ kind: 'stopRefused' }, 'row', { agentName: 'Codex' }) + ).toBe("Codex didn't stop. Check the chat before trying again.") + }) +}) diff --git a/src/shared/agent-session-command-refusal-words.ts b/src/shared/agent-session-command-refusal-words.ts index ec85fe3b9947..fcb455d72886 100644 --- a/src/shared/agent-session-command-refusal-words.ts +++ b/src/shared/agent-session-command-refusal-words.ts @@ -1,49 +1,76 @@ -// A conversation command refused for a reason the person can act on: said as that refusal is -// everywhere, so a command's row names what to wait for rather than "try it again". - import type { AgentSessionFailureFact } from './agent-session-failure' -import type { AgentSessionFailureSay } from './agent-session-failure-copy' -import type { AgentSessionConversationCommand } from './agent-session-conversation-command' +import type { + AgentSessionFailureCopyId, + AgentSessionFailureSay +} from './agent-session-failure-copy' +import type { AgentSessionFailureWordsContext } from './agent-session-failure-words' +import { agentSessionRefusalReasonWords } from './agent-session-refusal-reason-words' +import type { AgentSessionWriteNoticeSentence } from './agent-session-write-notice-copy' import { joinSentences } from './sentence-joining' -type ReasonWords = ( - say: AgentSessionFailureSay, - command: AgentSessionConversationCommand | undefined -) => string | undefined - -const STILL_WORKING: ReasonWords = (say, command) => - command - ? joinSentences([say('agentStillWorking'), say('runCommandWhenDone', { command })]) - : undefined - -/** A wait the person ends: nothing runs the command after it, so they run it again. */ -const WAIT_THEN_RUN_AGAIN = - ( - cause: 'backgroundTasksRunning' | 'agentStarting', - wait: 'waitForBackgroundTasks' | 'waitForStart' - ): ReasonWords => - (say, command) => - joinSentences([ - say(cause), - say(wait), - ...(command ? [say('runCommandAgain', { command })] : []) - ]) - -const WORDS_BY_REASON: Partial> = { - backgroundTasksRunning: WAIT_THEN_RUN_AGAIN('backgroundTasksRunning', 'waitForBackgroundTasks'), - handoffInFlight: WAIT_THEN_RUN_AGAIN('agentStarting', 'waitForStart'), - turnActive: STILL_WORKING, - messagesUnsettled: STILL_WORKING, - promptPending: (say, command) => (command ? say('commandAfterAnswer', { command }) : undefined) +const NAMED_CAUSES: Partial> = { + agentStarting: 'commandAgentStarting', + waitForStart: 'commandWaitForStart', + turnActive: 'commandTurnActive', + waitForTurn: 'commandWaitForTurn', + promptPending: 'commandPromptPending', + agentRefused: 'commandAgentRefused', + ownerUnproven: 'commandOwnerUnproven', + goalsUnsupported: 'commandGoalsUnsupported', + optionRejected: 'commandOptionRejected', + backgroundTasksRunning: 'commandBackgroundTasksRunning' } -/** Undefined for a reason with no words of its own: the command's generic sentence says it. */ -export function commandRefusedByReason( +/** A refused command keeps the refusal's action; repeating it cannot clear every refusal. */ +export function agentSessionCommandRefusalWords( + fact: AgentSessionFailureFact, + context: AgentSessionFailureWordsContext, say: AgentSessionFailureSay, - { command }: { command?: AgentSessionConversationCommand }, - fact: AgentSessionFailureFact -): string | undefined { - const details = fact.refusal?.details - const reason = details && 'reason' in details ? details.reason : undefined - return reason === undefined ? undefined : WORDS_BY_REASON[reason]?.(say, command) + sayNotice: (id: AgentSessionWriteNoticeSentence) => string +): string { + const agent = { agent: context.agentName ?? say('theAgent') } + const notice = (id: AgentSessionWriteNoticeSentence): string => { + const named = NAMED_CAUSES[id] + return named ? say(named, agent) : sayNotice(id) + } + if (fact.refusal?.code === 'structured_agent_session_unsupported') { + return say('commandUnsupported', agent) + } + const lead = say('commandRefused', agent) + const reason = fact.refusal?.details?.reason + if (context.command) { + const values = { ...agent, command: context.command } + if (reason === 'turnActive' || reason === 'messagesUnsettled') { + return joinSentences([say('commandStillWorking', agent), say('runCommandWhenDone', values)]) + } + if (reason === 'promptPending') { + if (!context.agentName) { + return sayNotice(context.command === 'clear' ? 'clearAfterAnswer' : 'compactAfterAnswer') + } + return say('commandAfterAnswer', values) + } + if (reason === 'backgroundTasksRunning' || reason === 'handoffInFlight') { + return joinSentences([ + notice(reason === 'handoffInFlight' ? 'agentStarting' : 'backgroundTasksRunning'), + notice(reason === 'handoffInFlight' ? 'waitForStart' : 'waitForBackgroundTasks'), + say('runCommandAgain', values) + ]) + } + } + const words = fact.refusal + ? agentSessionRefusalReasonWords({ kind: 'refused', ...fact.refusal }) + : undefined + if (!words || 'fact' in words) { + return lead + } + const retry = words.action === 'retry' && !context.retryControl + return joinSentences([ + lead, + ...('cause' in words ? [notice(words.cause)] : []), + ...('step' in words && words.step && (words.action !== 'retry' || retry) + ? [notice(words.step)] + : retry + ? [sayNotice('tryAgain')] + : []) + ]) } diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index 8f17594db219..d8df61021852 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -20,6 +20,7 @@ export const AGENT_SESSION_FAILURE_COPY = { runCommandAgain: 'Run /{{command}} again.', sendToTryAgain: 'Send your message to try again.', sendAgainToTryOnceMore: 'Send your message again to try once more.', + sendItAgain: 'Send it again.', couldNotStart: "{{agent}} couldn't start.", couldNotRestart: "{{agent}} couldn't restart.", argumentsUnsupportedOption: 'Saved Arguments contain an unsupported option ({{option}}).', @@ -69,8 +70,8 @@ export const AGENT_SESSION_FAILURE_COPY = { providerExitedRow: '{{agent}} stopped while this response was in progress. You can continue in this conversation.', providerExitedRejection: '{{agent}} stopped before this message was sent.', - providerRejected: 'The provider did not accept this message.', - providerRejectedQuoted: 'The provider did not accept this message: {{detail}}.', + providerRejected: "{{agent}} didn't accept this message.", + providerRejectedQuoted: "{{agent}} didn't accept this message: {{detail}}.", attachmentEmpty: 'An image on this message is empty, so the message was not sent.', attachmentTooLarge: 'An image on this message is too large, so the message was not sent.', attachmentLargerThan: @@ -91,35 +92,52 @@ export const AGENT_SESSION_FAILURE_COPY = { "An attachment on this message couldn't be read, so the message was not sent.", emptyMessage: 'This message is empty, so it was not sent.', queueFull: 'Too many messages were waiting for the agent, so this one was not sent.', - writeFailed: "Orca couldn't hand this message to the agent, so it was not sent.", + writeFailed: "{{agent}} couldn't receive this message.", cancelled: 'This message was withdrawn before the agent started it.', chatClosed: 'The chat closed before this message was sent.', hostRestarted: 'Orca restarted before this message was sent.', notDelivered: 'This message was not delivered.', notDeliveredSendAgain: 'This message was not delivered. Send it again to continue.', - commandRefused: "This command didn't run.", - commandRefusedTryAgain: "This command didn't run. Try it again.", - // What kept a command from running, in the words its refusal has everywhere. - backgroundTasksRunning: BACKGROUND_TASKS_RUNNING, - waitForBackgroundTasks: WAIT_FOR_BACKGROUND_TASKS, - agentStarting: AGENT_STARTING, - waitForStart: WAIT_FOR_START, - agentStillWorking: AGENT_STILL_WORKING, + commandRefused: "{{agent}} didn't run this command.", + commandUnsupported: "{{agent}} doesn't support this command in this chat.", + commandAgentStarting: '{{agent}} is still starting.', + commandWaitForStart: 'Wait for {{agent}} to finish starting.', + commandTurnActive: '{{agent}} is still responding.', + commandStillWorking: '{{agent}} is still working.', + commandWaitForTurn: 'Wait for it to finish, or stop it.', + commandPromptPending: '{{agent}} is waiting for an answer to a question or approval.', + commandAgentRefused: '{{agent}} turned this down.', + commandOwnerUnproven: 'An earlier {{agent}} session may still be running in this chat.', + commandGoalsUnsupported: "{{agent}} doesn't support goals.", + commandOptionRejected: "{{agent}} didn't accept this setting.", + compactionFailed: "{{agent}} couldn't shorten this chat's history.", + compactionFailedQuoted: "{{agent}} couldn't shorten this chat's history: {{detail}}.", + compactionUnconfirmed: + "{{agent}} hasn't confirmed that this chat's history was shortened. Check the chat before running /compact again.", + cancelUnconfirmed: + "{{agent}} hasn't confirmed that it stopped. Check the chat before trying again.", + stopRefused: "{{agent}} didn't stop. Check the chat before trying again.", + stopRefusedQuoted: "{{agent}} didn't stop: {{detail}}. Check the chat before trying again.", + noTurnToStop: '{{agent}} had no response in progress to stop.', + answerUnconfirmed: + "Your answer was saved, but {{agent}} hasn't confirmed it. Check the chat before answering again.", + commandBackgroundTasksRunning: '{{agent}} still has background tasks running.', + commandAfterSending: + 'Your earlier message to {{agent}} is still being sent. Run /{{command}} once it has gone.', + commandWaitForPendingWork: + 'Wait for pending work and messages with {{agent}} to finish before running /{{command}}.', + queueSendRetry: 'Use Send to try again.', + queueSendRetryWhenDone: 'Use Send to try again once {{agent}} finishes.', runCommandWhenDone: "Run /{{command}} when it's done.", - commandAfterAnswer: "Answer the agent's question or approval, then run /{{command}}.", - compactionFailed: 'Compaction failed.', - compactionFailedQuoted: 'Compaction failed: {{detail}}.', - compactionUnconfirmed: 'Compaction completion is unconfirmed.', - cancelUnconfirmed: 'Cancellation was not confirmed.', - stopRefused: "{{agent}} didn't stop.", - stopRefusedQuoted: "{{agent}} didn't stop: {{detail}}.", - noTurnToStop: '{{agent}} had no turn running to stop.', - answerUnconfirmed: 'Your answer was recorded but the agent did not confirm it.', + commandAfterAnswer: "Answer {{agent}}'s question or approval, then run /{{command}}.", hostFault: "Orca ran into a problem, so this didn't go through.", hostFaultTryAgain: "Orca ran into a problem, so this didn't go through. Try again.", hostStopped: '{{agent}} never finished starting, so Orca stopped it.', - providerRateLimited: '{{agent}} is rate-limited and retrying.', + providerRateLimited: '{{agent}} reached a request limit and is retrying.', providerRetrying: '{{agent}} hit a temporary problem and is retrying.', + providerError: '{{agent}} ran into a problem. Check the chat before trying again.', + providerErrorQuoted: + '{{agent}} ran into a problem: {{detail}}. Check the chat before trying again.', providerRetryingQuoted: '{{agent}} is retrying: {{detail}}.', providerRetryNumber: 'Retry {{attempt}}.', providerRetryNumberOf: 'Retry {{attempt}} of {{maxRetries}}.', diff --git a/src/shared/agent-session-failure-words.test.ts b/src/shared/agent-session-failure-words.test.ts index bfdc644b8bee..65eb84c77946 100644 --- a/src/shared/agent-session-failure-words.test.ts +++ b/src/shared/agent-session-failure-words.test.ts @@ -61,6 +61,79 @@ function factsFor(kind: AgentSessionFailureKind): AgentSessionFailureFact[] { } describe('the words written beside a failure fact', () => { + it('names the agent and offers another send only for a provably undelivered write', () => { + expect( + agentSessionFailureSentence({ kind: 'writeFailed' }, 'rejection', { + agentName: 'Codex' + }) + ).toBe("Codex couldn't receive this message. Send it again.") + expect( + agentSessionFailureSentence({ kind: 'writeFailed' }, 'rejection', { + agentName: 'Claude', + retryControl: true + }) + ).toBe("Claude couldn't receive this message.") + expect( + agentSessionFailureSentence({ kind: 'providerRejected' }, 'rejection', { + agentName: 'Claude' + }) + ).toBe("Claude didn't accept this message.") + }) + + it.each([ + 'provider_write_failed: stand-in rejected the turn.', + 'The provider did not accept this message: provider_write_failed: stand-in rejected the turn.', + '{"type":"error","message":"failed"}', + 'API Error: Request was aborted.', + 'stream disconnected before completion: error sending request for url (http://127.0.0.1:9/v1/responses)', + 'Error: failed\n at send (/app/send.ts:1:2)' + ])('never quotes technical detail labelled person: %s', (text) => { + for (const kind of AGENT_SESSION_FAILURE_KINDS) { + for (const surface of SURFACES) { + expect( + agentSessionFailureSentence( + { + kind, + detail: { text, audience: 'person' }, + retry: { cause: text } + }, + surface, + { agentName: 'Codex' } + ) + ).not.toContain(text) + } + } + }) + + it.each([ + 'Claude does not support the image type .bmp', + 'Claude does not support the image my_photo.bmp. Please use a PNG image' + ])('keeps a readable content explanation without an unconditional resend: %s', (text) => { + expect( + agentSessionFailureSentence( + { + kind: 'providerRejected', + detail: { text, audience: 'person' } + }, + 'rejection', + { agentName: 'Claude' } + ) + ).toBe(`Claude didn't accept this message: ${text}.`) + }) + + it('withholds a quoted explanation that talks about a provider', () => { + expect( + agentSessionFailureSentence( + { + kind: 'providerRejected', + detail: { text: 'The provider did not accept this message.', audience: 'person' } + }, + 'rejection', + { agentName: 'Codex' } + ) + ).toBe("Codex didn't accept this message.") + }) + describe.each(SURFACES)('on the %s surface', (surface) => { it.each(AGENT_SESSION_FAILURE_KINDS)('for %s are one sentence a person can read', (kind) => { for (const fact of factsFor(kind)) { @@ -210,14 +283,16 @@ describe('the words written beside a failure fact', () => { { kind: 'stopRefused', ...(detail ? { detail } : {}) }, { surface: 'row', agentName } ).text - expect(refused(undefined, 'Codex')).toBe('Codex had no turn running to stop.') - expect(refused()).toBe('The agent had no turn running to stop.') + expect(refused(undefined, 'Codex')).toBe( + "Codex didn't stop. Check the chat before trying again." + ) + expect(refused()).toBe("The agent didn't stop. Check the chat before trying again.") expect(refused({ text: 'no active turn to interrupt.', audience: 'person' }, 'Codex')).toBe( - "Codex didn't stop: no active turn to interrupt." + "Codex didn't stop: no active turn to interrupt. Check the chat before trying again." ) // Words Codex wrote for the log are never quoted to a person. expect(refused({ text: 'rpc -32600', audience: 'log' }, 'Codex')).toBe( - 'Codex had no turn running to stop.' + "Codex didn't stop. Check the chat before trying again." ) }) @@ -249,7 +324,7 @@ describe('the words written beside a failure fact', () => { detail: { text: '{"type":"system","subtype":"api_retry"}', audience: 'log' }, retry: { error: 'rate_limit', status: 429 } }) - ).toBe('Codex is rate-limited and retrying.\nLast error: HTTP 429 rate limit.') + ).toBe('Codex reached a request limit and is retrying.') expect(retrying({})).toBe('Codex hit a temporary problem and is retrying.') }) @@ -263,26 +338,26 @@ describe('the words written beside a failure fact', () => { detail: { text: 'Reconnecting... 2/5', audience: 'person' }, retry: { cause: 'stream disconnected before completion' } }) - ).toBe('Codex is retrying: Reconnecting... 2/5.\nstream disconnected before completion') + ).toBe('Codex is retrying: Reconnecting... 2/5.') expect(retrying({ retry: { status: 429, cause: 'Too many requests' } })).toBe( - 'Codex is rate-limited and retrying.\nToo many requests' + 'Codex reached a request limit and is retrying.\nToo many requests' ) }) - it("says which retry it is, and the provider's codes when it wrote no account", () => { + it('says which retry it is without displaying protocol codes', () => { const retrying = (retry: AgentSessionFailureFact['retry']) => agentSessionFailureSentence({ kind: 'providerRetrying', retry }, 'row', { agentName: 'Claude' }) expect(retrying({ error: 'server_error', status: 502, attempt: 3, maxRetries: 10 })).toBe( - 'Claude hit a temporary problem and is retrying. Retry 3 of 10.\nLast error: HTTP 502 server error.' + 'Claude hit a temporary problem and is retrying. Retry 3 of 10.' ) expect(retrying({ status: 502, attempt: 3 })).toBe( - 'Claude hit a temporary problem and is retrying. Retry 3.\nLast error: HTTP 502.' + 'Claude hit a temporary problem and is retrying. Retry 3.' ) // The provider's own account outranks its codes. expect(retrying({ status: 429, cause: 'Too many requests', attempt: 2, maxRetries: 5 })).toBe( - 'Claude is rate-limited and retrying. Retry 2 of 5.\nToo many requests' + 'Claude reached a request limit and is retrying. Retry 2 of 5.\nToo many requests' ) // A provider that words its own progress is quoted alone, never counted twice. expect( diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 79133b2389b3..9669c1d02b75 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -9,8 +9,6 @@ import { isSubmissionRejectionFact, - type AgentSessionAttachmentProblem, - type AgentSessionAttachmentProblemReason, type AgentSessionFailureFact, type AgentSessionFailureKind, type ProviderDiagnostic, @@ -27,8 +25,14 @@ import { } from './agent-session-failure-copy' import type { AgentSessionWireRefusalCode } from './agent-session-wire-refusals' import { providerRetryWords, withRetryCause } from './agent-session-provider-retry-words' -import { commandRefusedByReason } from './agent-session-command-refusal-words' import { joinSentences } from './sentence-joining' +import { agentSessionAttachmentFailureWords } from './agent-session-attachment-failure-words' +import { isProviderDiagnosticPersonText } from './provider-diagnostic-person-text' +import { agentSessionCommandRefusalWords } from './agent-session-command-refusal-words' +import { + AGENT_SESSION_WRITE_NOTICE_COPY, + type AgentSessionWriteNoticeSentence +} from './agent-session-write-notice-copy' import { DISPATCH_REJECTED_CANCELLED, DISPATCH_REJECTED_CODEX_QUEUE_FULL, @@ -97,13 +101,13 @@ export const START_REFUSAL_RESUMABLE: Record string ) => string function agent(say: AgentSessionFailureSay, { agentName }: AgentSessionFailureWordsContext) { @@ -118,7 +122,7 @@ function quotingPersonDetail( values: AgentSessionFailureCopyValues = {} ): string { const quoted = - detail?.audience === 'person' + detail?.audience === 'person' && isProviderDiagnosticPersonText(detail.text) ? detail.text .slice(0, MAX_QUOTED_DETAIL_CHARS) .trim() @@ -180,35 +184,6 @@ function couldNot(verb: 'couldNotStart' | 'couldNotRestart'): Sentence { } } -// The number only; each language's sentence carries its own unit. -function megabytes(bytes: number): string { - return String(Math.round((bytes / BYTES_PER_MB) * 10) / 10) -} - -const ATTACHMENT_SENTENCES = { - empty: (say) => say('attachmentEmpty'), - tooLarge: (say, _, { limit }) => - limit ? say('attachmentLargerThan', { size: megabytes(limit) }) : say('attachmentTooLarge'), - tooMany: (say, context, { limit }) => - limit - ? say('attachmentAtMost', { ...agent(say, context), limit: String(limit) }) - : say('attachmentTooMany'), - totalTooLarge: (say, _, { limit }) => - limit - ? say('attachmentTotalMoreThan', { size: megabytes(limit) }) - : say('attachmentTotalTooLarge'), - unsupportedType: (say, context) => say('attachmentUnsupportedType', agent(say, context)), - notAFile: (say) => say('attachmentNotAFile'), - noSource: (say) => say('attachmentNoSource') -} satisfies Record< - AgentSessionAttachmentProblemReason, - ( - say: AgentSessionFailureSay, - context: AgentSessionFailureWordsContext, - problem: AgentSessionAttachmentProblem - ) => string -> - const FAILURE_SENTENCES = { providerStartFailed: (context, _fact, _surface, say) => joinSentences([say('providerStartFailed', agent(say, context)), ...startRetry(say, context)]), @@ -235,46 +210,70 @@ const FAILURE_SENTENCES = { providerExited: (context, _fact, surface, say) => say(surface === 'row' ? 'providerExitedRow' : 'providerExitedRejection', agent(say, context)), restartFailed: couldNot('couldNotRestart'), - providerRejected: (_context, fact, _surface, say) => - quotingPersonDetail(say, 'providerRejected', 'providerRejectedQuoted', fact.detail), + providerRejected: (context, fact, _surface, say) => + quotingPersonDetail( + say, + 'providerRejected', + 'providerRejectedQuoted', + fact.detail, + agent(say, context) + ), attachmentInvalid: (context, fact, _surface, say) => - fact.attachment - ? ATTACHMENT_SENTENCES[fact.attachment.reason](say, context, fact.attachment) - : say('attachmentInvalid'), + agentSessionAttachmentFailureWords(fact.attachment, agent(say, context), say), attachmentUnreadable: (_context, _fact, _surface, say) => say('attachmentUnreadable'), emptyMessage: (_context, _fact, _surface, say) => say('emptyMessage'), queueFull: (_context, _fact, _surface, say) => say('queueFull'), - writeFailed: (_context, _fact, _surface, say) => say('writeFailed'), + writeFailed: (context, _fact, _surface, say) => + joinSentences([ + say('writeFailed', agent(say, context)), + ...(!context.retryControl ? [say('sendItAgain')] : []) + ]), cancelled: (_context, _fact, _surface, say) => say('cancelled'), chatClosed: (_context, _fact, _surface, say) => say('chatClosed'), hostRestarted: (_context, _fact, _surface, say) => say('hostRestarted'), notDelivered: ({ retryControl }, _fact, _surface, say) => say(retryControl ? 'notDelivered' : 'notDeliveredSendAgain'), - commandRefused: (context, fact, _surface, say) => - commandRefusedByReason(say, context, fact) ?? - say(context.retryControl ? 'commandRefused' : 'commandRefusedTryAgain'), - compactionFailed: (_context, fact, _surface, say) => - quotingPersonDetail(say, 'compactionFailed', 'compactionFailedQuoted', fact.detail), - compactionUnconfirmed: (_context, _fact, _surface, say) => say('compactionUnconfirmed'), - cancelUnconfirmed: (_context, _fact, _surface, say) => say('cancelUnconfirmed'), - // The agent was reached and declined, so the sentence says that, not that the Stop was lost. + commandRefused: (context, fact, _surface, say, sayNotice) => + agentSessionCommandRefusalWords(fact, context, say, sayNotice), + compactionFailed: (context, fact, _surface, say) => + quotingPersonDetail( + say, + 'compactionFailed', + 'compactionFailedQuoted', + fact.detail, + agent(say, context) + ), + compactionUnconfirmed: (context, _fact, _surface, say) => + say('compactionUnconfirmed', agent(say, context)), + cancelUnconfirmed: (context, _fact, _surface, say) => + say('cancelUnconfirmed', agent(say, context)), + // Refusing the named response does not prove the agent has nothing else running. stopRefused: (context, fact, _surface, say) => - fact.detail?.audience === 'person' - ? quotingPersonDetail( + fact.turnNotRunning + ? say('stopRefused', agent(say, context)) + : quotingPersonDetail( say, 'stopRefused', 'stopRefusedQuoted', fact.detail, agent(say, context) - ) - : say('noTurnToStop', agent(say, context)), - answerUnconfirmed: (_context, _fact, _surface, say) => say('answerUnconfirmed'), + ), + answerUnconfirmed: (context, _fact, _surface, say) => + say('answerUnconfirmed', agent(say, context)), hostFault: ({ retryControl }, _fact, _surface, say) => say(retryControl ? 'hostFault' : 'hostFaultTryAgain'), hostStopped: (context, _fact, _surface, say) => say('hostStopped', agent(say, context)), + providerError: (context, fact, _surface, say) => + quotingPersonDetail( + say, + 'providerError', + 'providerErrorQuoted', + fact.detail, + agent(say, context) + ), // A provider that says how its retry is going, for a person, is quoted: that is the progress. providerRetrying: (context, { retry, detail }, _surface, say) => - detail?.audience === 'person' + detail?.audience === 'person' && isProviderDiagnosticPersonText(detail.text) ? withRetryCause( quotingPersonDetail( say, @@ -298,10 +297,12 @@ export function agentSessionFailureSentence( surface: AgentSessionFailureSurface, context: AgentSessionFailureWordsContext = {}, /** Desktop passes its translations; the host and the phone keep English. */ - say: AgentSessionFailureSay = sayAgentSessionFailureEnglish + say: AgentSessionFailureSay = sayAgentSessionFailureEnglish, + sayNotice: (id: AgentSessionWriteNoticeSentence) => string = (id) => + AGENT_SESSION_WRITE_NOTICE_COPY[id] ): string { const sentence: Sentence = FAILURE_SENTENCES[fact.kind] - return sentence(context, fact, surface, say) + return sentence(context, fact, surface, say, sayNotice) } /** The markers released clients hide, for the rejections that had one before rows carried a fact. diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index 9f43c47e9bfe..d71c194eb645 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -52,7 +52,7 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'compactionFailed', 'compactionUnconfirmed', 'cancelUnconfirmed', - /** A Stop naming no turn reached the agent, which ended nothing while the chat read working. */ + /** The agent declined a Stop; this alone says nothing about whether a turn was running. */ 'stopRefused', 'answerUnconfirmed', 'hostFault', @@ -60,6 +60,8 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'hostStopped', /** The provider is retrying a request its API refused; not a failure yet. */ 'providerRetrying', + /** A final provider error notification; it does not prove whether a message was delivered. */ + 'providerError', /** A child a Stop could not prove gone: its exit is unverifiable. Kept for rows hosts wrote. */ 'previousExitUnverifiable', /** The agent could not reopen the chat's saved session, so a fresh one without its memory continues it. */ @@ -79,6 +81,7 @@ const STATUS_ROW_ONLY_FAILURE_KINDS = [ 'stopRefused', 'answerUnconfirmed', 'providerRetrying', + 'providerError', 'previousExitUnverifiable', 'sessionNotRestored' ] as const satisfies readonly AgentSessionFailureKind[] @@ -156,6 +159,8 @@ export type AgentSessionFailureFact = { retry?: AgentSessionProviderRetry /** A safe option name from Orca's saved Arguments parser, never an error message. */ argumentProblem?: AgentSessionArgumentProblem + /** On `stopRefused`: the agent positively reported no running turn, never inferred from detail. */ + turnNotRunning?: true } /** A fact as a row stores it: its kind may be one a newer host added, so only @@ -189,6 +194,7 @@ export function agentSessionFailureFact( attachment?: AgentSessionAttachmentProblem retry?: AgentSessionProviderRetry argumentProblem?: AgentSessionArgumentProblem + turnNotRunning?: true } = {} ): AgentSessionFailureFact & { kind: TKind } { // Re-bounded here, so no writer can store more than the cap however it built the detail. @@ -202,7 +208,8 @@ export function agentSessionFailureFact( ...(extra.refusal ? { refusal: extra.refusal } : {}), ...(extra.attachment ? { attachment: extra.attachment } : {}), ...(extra.retry ? { retry: extra.retry } : {}), - ...(extra.argumentProblem ? { argumentProblem: extra.argumentProblem } : {}) + ...(extra.argumentProblem ? { argumentProblem: extra.argumentProblem } : {}), + ...(extra.turnNotRunning ? { turnNotRunning: true } : {}) } } @@ -279,7 +286,8 @@ export function readAgentSessionFailureFact(value: unknown): AgentSessionFailure ...(refusal ? { refusal } : {}), ...(attachment ? { attachment } : {}), ...(retry ? { retry } : {}), - ...(argumentProblem ? { argumentProblem } : {}) + ...(argumentProblem ? { argumentProblem } : {}), + ...(value.turnNotRunning === true ? { turnNotRunning: true } : {}) }) } diff --git a/src/shared/agent-session-provider-retry-words.ts b/src/shared/agent-session-provider-retry-words.ts index ece476ca096f..5a302b01b897 100644 --- a/src/shared/agent-session-provider-retry-words.ts +++ b/src/shared/agent-session-provider-retry-words.ts @@ -7,10 +7,11 @@ import type { AgentSessionFailureSay } from './agent-session-failure-copy' import { joinSentences } from './sentence-joining' +import { isProviderDiagnosticPersonText } from './provider-diagnostic-person-text' /** The provider's account of what failed goes on the line under the sentence, as it wrote it. */ export function withRetryCause(sentence: string, cause: string | undefined): string { - return cause ? `${sentence}\n${cause}` : sentence + return cause && isProviderDiagnosticPersonText(cause) ? `${sentence}\n${cause}` : sentence } export function providerRetryWords( @@ -28,7 +29,7 @@ export function providerRetryWords( ), ...retryNumber(say, retry) ]), - retry?.cause ?? retryLastError(say, retry) + retry?.cause ) } @@ -46,15 +47,3 @@ function retryNumber( : say('providerRetryNumber', { attempt }) ] } - -/** The provider's own codes for what failed, when it wrote no account of it for a person. */ -function retryLastError( - say: AgentSessionFailureSay, - retry: AgentSessionProviderRetry | undefined -): string | undefined { - // `server_error` reads as words once its separators are spaces. - const codes = [retry?.status ? `HTTP ${retry.status}` : '', retry?.error?.replace(/_+/g, ' ')] - .filter(Boolean) - .join(' ') - return codes ? say('providerRetryLastError', { detail: codes }) : undefined -} diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index aadf9e26fa49..3fae934894ad 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -160,8 +160,7 @@ describe('the notice for every failure and write', () => { } }) - // The control that sent the write is how to try again; only the phone's composer has none. - it('says how to try again only to update Orca, or on the phone where a resend can work', () => { + it('says how to try again only where that can help', () => { for (const { failure, write, parts, english, cell } of cells) { const phoneResend = write === 'composer-send' && RESEND_CAN_WORK.has(codeOf(failure)) expect(parts.includes('tryAgainComposerSend'), cell).toBe(phoneResend) @@ -171,10 +170,15 @@ describe('the notice for every failure and write', () => { codeOf(failure) === 'agent_session_operation_capacity' ) } - for (const reason of [null, DISPATCH_REJECTED_WRITE_FAILED, DISPATCH_REJECTED_QUEUE_FULL]) { - expect( - agentSessionWriteNoticeEnglish(structuredAgentSessionRejectionParts(reason, 'send')) - ).not.toMatch(/again/i) + for (const [reason, canSendAgain] of [ + [null, false], + [DISPATCH_REJECTED_WRITE_FAILED, true], + [DISPATCH_REJECTED_QUEUE_FULL, false] + ] as const) { + const english = agentSessionWriteNoticeEnglish( + structuredAgentSessionRejectionParts(reason, 'send') + ) + expect(/again/i.test(english)).toBe(canSendAgain) } }) @@ -405,7 +409,13 @@ describe('the notice for every reason a host names', () => { if (words && 'cause' in words) { const retried = words.action === 'retry' ? words.step : undefined expect(agentSessionWriteNoticeParts(failure, write, beside), cell).toEqual( - parts.filter((part) => part !== retried) + parts + .filter((part) => part !== retried) + .map((part) => + typeof part === 'string' || 'text' in part + ? part + : { ...part, context: { ...part.context, ...beside } } + ) ) } } @@ -458,14 +468,13 @@ describe('the notice for every reason a host names', () => { const saysNotDone = write === 'read-history' && failure.code === 'agent_session_journal_unreadable' // "Run /clear when it's done" already says the command has yet to happen. - const clearWhileWorking = ( - [ - 'runClearWhenDone', - 'clearAfterAnswer', - 'runCompactWhenDone', - 'compactAfterAnswer' - ] as const - ).some((sentence) => parts.includes(sentence)) + const clearWhileWorking = parts.some( + (part) => + typeof part !== 'string' && + 'failure' in part && + part.failure.kind === 'commandRefused' && + part.context.command !== undefined + ) expect(notDone, cell).toEqual( answeredAway || unsupported || saysNotDone || clearWhileWorking ? [] : [NOT_DONE[write]] ) @@ -634,6 +643,21 @@ describe('a /clear or /compact refused while the agent works', () => { const refused = (reason: 'turnActive' | 'messagesUnsettled' | 'promptPending') => ({ kind: 'refused', code: 'agent_session_operation_invalid', details: { reason } }) as const + it.each(['clear', 'compact'] as const)('names the chat agent for /%s', (command) => { + for (const reason of ['turnActive', 'messagesUnsettled'] as const) { + expect( + agentSessionWriteNoticeEnglish( + agentSessionWriteNoticeParts(refused(reason), command, { agentName: 'Codex' }) + ) + ).toBe(`Codex is still working. Run /${command} when it's done.`) + } + expect( + agentSessionWriteNoticeEnglish( + agentSessionWriteNoticeParts(refused('promptPending'), command, { agentName: 'Codex' }) + ) + ).toBe(`Answer Codex's question or approval, then run /${command}.`) + }) + it('says one plain sentence of what the person sees and can do, whichever reason', () => { for (const reason of ['turnActive', 'messagesUnsettled'] as const) { expect( diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index f81e1b51a901..c0cf9a54e880 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -51,23 +51,6 @@ const NOT_DONE: Record = goal: 'notDoneGoal' } -/** A /clear or /compact refused because the agent is working: one plain sentence for every - * reason that is, saying only what the person sees and can do. */ -const COMMAND_WHILE_WORKING: Partial< - Record>> -> = { - clear: { - turnActive: ['agentStillWorking', 'runClearWhenDone'], - messagesUnsettled: ['agentStillWorking', 'runClearWhenDone'], - promptPending: ['clearAfterAnswer'] - }, - compact: { - turnActive: ['agentStillWorking', 'runCompactWhenDone'], - messagesUnsettled: ['agentStillWorking', 'runCompactWhenDone'], - promptPending: ['compactAfterAnswer'] - } -} - /** That the write did not happen, for one that a second attempt can carry out. Only the phone says * how: its message goes back to the composer and it has no Retry control. Everywhere else the * control that sent the write is the way to try again. */ @@ -113,12 +96,23 @@ function reasonParts( } ] } - const commandWhileWorking = - failure.code === 'agent_session_operation_invalid' - ? COMMAND_WHILE_WORKING[write]?.[failure.details?.reason ?? ''] - : undefined - if (commandWhileWorking) { - return commandWhileWorking + if ( + failure.code === 'agent_session_operation_invalid' && + (write === 'clear' || write === 'compact') && + (failure.details?.reason === 'turnActive' || + failure.details?.reason === 'messagesUnsettled' || + failure.details?.reason === 'promptPending') + ) { + return [ + { + failure: { + kind: 'commandRefused', + refusal: { code: failure.code, details: failure.details } + }, + surface: 'row', + context: { ...context, command: write } + } + ] } const words = agentSessionRefusalReasonWords(failure) if (!words || 'words' in words) { @@ -252,10 +246,11 @@ export function agentSessionWriteNoticeEnglish( * message is not read. */ export function agentSessionRefusalNotice( refusal: Pick, - write: AgentSessionWriteKind + write: AgentSessionWriteKind, + context: AgentSessionFailureWordsContext = {} ): string { return agentSessionWriteNoticeEnglish( - agentSessionWriteNoticeParts(agentSessionRefusalFailure(refusal), write) + agentSessionWriteNoticeParts(agentSessionRefusalFailure(refusal), write, context) ) } diff --git a/src/shared/agent-session-sign-in.test.ts b/src/shared/agent-session-sign-in.test.ts index ecc5612e73ff..80796c9e15b4 100644 --- a/src/shared/agent-session-sign-in.test.ts +++ b/src/shared/agent-session-sign-in.test.ts @@ -50,7 +50,7 @@ describe('agent-specific sign-in guidance', () => { (agentName, command) => { const fact = { kind: 'notSignedIn', - detail: { text: 'The configured provider has no API key.', audience: 'person' } + detail: { text: 'No API key is configured.', audience: 'person' } } as const const row = agentSessionFailureSentence(fact, 'row', { agentName }) const rejection = agentSessionFailureSentence(fact, 'rejection', { agentName }) @@ -65,6 +65,20 @@ describe('agent-specific sign-in guidance', () => { } } ) + it.each([ + 'The configured provider has no API key.', + 'provider_auth_failed: missing credentials', + 'HTTP 401 Unauthorized' + ])('withholds internal or technical detail %j but keeps the sign-in step', (text) => { + const fact = { kind: 'notSignedIn', detail: { text, audience: 'person' } } as const + for (const surface of ['row', 'rejection'] as const) { + const sentence = agentSessionFailureSentence(fact, surface, { agentName: 'Codex' }) + expect(sentence).toContain('codex login') + expect(sentence).not.toContain(text) + expect(sentence).not.toMatch(/provider/i) + } + }) + it('keeps diagnostic text literal and keeps log-only detail off the visible sentence', () => { const detail = 'No API key found for {{agent}} $t(fake.key)' expect( diff --git a/src/shared/agent-session-visible-failures.ts b/src/shared/agent-session-visible-failures.ts index 32a9cbce25f6..1ab1c59241d3 100644 --- a/src/shared/agent-session-visible-failures.ts +++ b/src/shared/agent-session-visible-failures.ts @@ -2,16 +2,18 @@ import { readWholeAgentSessionFailureFact, type AgentSessionFailureFact } from './agent-session-failure' +import { isRootAgentJournalItem } from './agent-session-journal-producer' import type { AgentJournalRenderItem } from './agent-session-journal-types' import { isStructuredAgentSessionStartFailureRow } from './structured-agent-session-start-failure-row-key' -/** Loaded startup causes and authentication explanations already visible in the transcript. */ +/** Loaded startup causes and authentication explanations already visible in the transcript. + * Only the session's own rows count: a subagent's rows are folded away or collapsed. */ export function agentSessionVisibleFailureFacts( items: readonly AgentJournalRenderItem[] ): AgentSessionFailureFact[] { const facts: AgentSessionFailureFact[] = [] for (const item of items) { - if (item.body.kind === 'status') { + if (item.body.kind === 'status' && isRootAgentJournalItem(item)) { const fact = readWholeAgentSessionFailureFact(item.body.failure) if ( fact && diff --git a/src/shared/provider-diagnostic-person-text.test.ts b/src/shared/provider-diagnostic-person-text.test.ts new file mode 100644 index 000000000000..c32e14c51968 --- /dev/null +++ b/src/shared/provider-diagnostic-person-text.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { isProviderDiagnosticPersonText } from './provider-diagnostic-person-text' + +describe('provider explanations a person can read', () => { + it.each([ + 'Claude does not support the image type .bmp', + 'Claude does not support the image my_photo.bmp. Please use a PNG image.', + 'Not enough messages to compact.', + 'Too many requests', + 'Reconnecting... 2/5', + 'Set ANTHROPIC_API_KEY, then try again.', + 'GPT4 is not available on this plan.', + 'Set AWS_S3_BUCKET, then try again.', + 'Export R2_ACCESS_KEY first.', + 'Model GPT_4O is not available.', + 'Uses {{agent}} $t(key) &' + ])('keeps readable words: %s', (text) => { + expect(isProviderDiagnosticPersonText(text)).toBe(true) + }) + + it.each([ + 'provider_write_failed: stand-in rejected the turn.', + 'The provider did not accept this message: provider_write_failed: stand-in rejected the turn.', + 'The send failed. new_transport_marker: a future failure', + 'The send failed:\nnew_transport_marker: a future failure', + 'new_transport_marker: a future failure', + '{"jsonrpc":"2.0","error":{"code":-32603,"message":"failed"}}', + 'The request failed: {"code":-32603}', + 'API Error: Request was aborted.', + 'TypeError: Cannot read properties of undefined', + 'write EPIPE', + 'Internal error: CAPTURE_PROVIDER_400', + 'The request failed with ERR_42.', + 'The configured provider has no API key.', + 'The Provider did not accept this message.', + 'The providers did not accept this message.', + 'HTTP 502 Bad Gateway', + 'RPC -32603', + 'stream disconnected before completion: error sending request for url (http://127.0.0.1:9/v1/responses)', + 'stream disconnected before completion', + 'Request failed at https://example.test/v1/responses', + 'Failure\n at send (/app/dispatch.ts:10:2)', + 'Traceback (most recent call last):', + 'data: {"type":"error"}', + '\u001b[31mFailed\u001b[0m', + '' + ])('withholds technical text: %s', (text) => { + expect(isProviderDiagnosticPersonText(text)).toBe(false) + }) +}) diff --git a/src/shared/provider-diagnostic-person-text.ts b/src/shared/provider-diagnostic-person-text.ts new file mode 100644 index 000000000000..7889fad1daae --- /dev/null +++ b/src/shared/provider-diagnostic-person-text.ts @@ -0,0 +1,39 @@ +// An audience label cannot make protocol records, error codes or stack traces readable copy. +const TECHNICAL_TEXT = [ + /(?:^|:\s+|[.!?]\s+|\n)\s*[a-zA-Z][a-zA-Z0-9]*_[a-zA-Z0-9_]+\s*:/, + /^\s*[a-zA-Z][a-zA-Z0-9]*_[a-zA-Z0-9_]+\s*$/, + /\b(?:[A-Za-z]*Error|Exception)\s*:/, + /\bE[A-Z][A-Z0-9]{2,}\b/, + // Upper-case snake codes ending in a number (ERR_42); setting names and model ids (AWS_S3_BUCKET, GPT_4O) stay. + /\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*_\d+\b/, + /\b(?:HTTP\/\d|(?:HTTP|RPC|JSON-RPC)\s+(?:[-+]?\d+|error|response))/i, + /(?:^|\n)\s*(?:at\s+\S+|Caused by:|Traceback\s*\()/, + /[[{]\s*"[^"\n]+"\s*:/, + /^\s*[[{]/, + /(?:^|\n)\s*(?:data|event):/, + /\b[a-z][a-z0-9+.-]*:\/\/\S+/i, + /(?:^|\n)\s*(?:stream|connection|request|transport)\s+(?:disconnected|reset|failed|aborted|timed out)\b/i, + /\b\w+\.(?:[cm]?[jt]s|py|rs|go):\d+(?::\d+)?\b/ +] + +// Users know agents, not "providers": an explanation using that word is withheld. +const INTERNAL_WORD = /\bproviders?\b/i + +function hasControlCharacters(text: string): boolean { + for (const character of text) { + const code = character.charCodeAt(0) + if ((code < 32 && ![9, 10, 13].includes(code)) || code === 127) { + return true + } + } + return false +} + +export function isProviderDiagnosticPersonText(text: string): boolean { + return ( + text.trim().length > 0 && + !hasControlCharacters(text) && + !INTERNAL_WORD.test(text) && + !TECHNICAL_TEXT.some((pattern) => pattern.test(text)) + ) +} diff --git a/src/shared/structured-agent-session-dispatch-rejection.test.ts b/src/shared/structured-agent-session-dispatch-rejection.test.ts index 95f522a765d1..8eea1fb0b25a 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.test.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.test.ts @@ -12,9 +12,33 @@ import { DISPATCH_REJECTED_HOST_RESTARTED, DISPATCH_REJECTED_PROVIDER_CLOSED, DISPATCH_REJECTED_QUEUE_FULL, - isWriteFailureSubmission + isWriteFailureSubmission, + providerDispatchRejectionFact } from './structured-agent-session-dispatch-rejection' +describe('provider dispatch refusal facts', () => { + it('records an explicit write-failure marker as transport, keeping its detail for the log', () => { + const text = 'provider_write_failed: stand-in rejected the turn.' + const fact = providerDispatchRejectionFact({ text, audience: 'person' }) + expect(fact).toEqual({ kind: 'writeFailed', detail: { text, audience: 'log' } }) + expect(classifyDispatchRejection({ reason: null, rejection: fact })).toEqual({ + category: 'transport', + verdict: 'failure', + kind: 'writeFailed' + }) + }) + + it('preserves content explanations and never guesses transport from an unfamiliar code', () => { + for (const text of ['Claude does not support .bmp images', 'future_failure: refused']) { + expect(providerDispatchRejectionFact({ text, audience: 'person' })).toEqual({ + kind: 'providerRejected', + detail: { text, audience: 'person' } + }) + } + expect(providerDispatchRejectionFact(undefined)).toEqual({ kind: 'providerRejected' }) + }) +}) + describe('classifyDispatchRejection', () => { it.each([ [DISPATCH_REJECTED_CANCELLED, 'withdrawn', null, 'cancelled'], diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 1b8bf6990233..58526d65c403 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -10,8 +10,11 @@ // a retry rotates the client message id, which is a new message and cannot duplicate. import { + agentSessionFailureFact, isSubmissionRejectionKind, readAgentSessionFailureFact, + type ProviderDiagnostic, + type SubmissionRejectionFact, type SubmissionRejectionKind } from './agent-session-failure' import type { AgentJournalSubmission } from './agent-session-journal-types' @@ -50,6 +53,17 @@ function dispatchRejectionWasTransportWriteFailure(reason: string | null | undef ) } +/** A provider's explicit refusal can report a known unwritten send instead of a content problem. */ +export function providerDispatchRejectionFact( + detail: ProviderDiagnostic | undefined +): SubmissionRejectionFact { + return dispatchRejectionWasTransportWriteFailure(detail?.text) + ? agentSessionFailureFact('writeFailed', { + detail: detail ? { ...detail, audience: 'log' } : undefined + }) + : agentSessionFailureFact('providerRejected', { detail }) +} + /** * What a rejection means for the chat, whatever wrote it: * - `withdrawn`: the user's Stop took it back; nothing failed. diff --git a/src/shared/structured-agent-session-message-projection.test.ts b/src/shared/structured-agent-session-message-projection.test.ts index ee292e14fdc1..5a19d8d083df 100644 --- a/src/shared/structured-agent-session-message-projection.test.ts +++ b/src/shared/structured-agent-session-message-projection.test.ts @@ -6,6 +6,8 @@ import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-ses import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import { structuredAgentSessionSendBody } from './structured-agent-session-send-mutation' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' +import { DISPATCH_REJECTED_CANCELLED } from './structured-agent-session-dispatch-rejection' +import { projectStructuredItemsToNativeChat } from './structured-agent-session-projection' const KEPT_ID = 'client-kept' // The desktop draws a rejected send in place, as not sent, unless the host kept it as a card. @@ -78,3 +80,96 @@ describe('a send kept as a card', () => { expect(shown[0]).not.toHaveProperty('unsent') }) }) + +// The phone draws this list as it comes, so the projection itself must place a not-sent row. +describe('a send the host recorded and then rejected', () => { + it('stays where the host placed it, above what came after, and above a held send', () => { + const answer: AgentJournalRenderItem = { + itemId: 'answer-3', + revision: 1, + sequence: 3, + observedAt: 3, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Yes' }] } + } + const later: AgentJournalSubmission = { + ...rejected({ clientMessageId: 'client-later', payloadFingerprint: 'fingerprint-later' }), + dispatchState: 'accepted', + providerItemId: 'provider-later', + reason: null + } + delete later.rejection + const held: AgentJournalSubmission = { + ...later, + clientMessageId: 'client-held', + payloadFingerprint: 'fingerprint-held', + dispatchState: 'pending', + providerItemId: null, + handoverRecorded: true + } + const shown = projectStructuredAgentSessionMessages( + [ + userItem(KEPT_ID, 'hello', 1), + userItem('client-later', 'are you there?', 2), + answer, + userItem('client-held', 'next', 4) + ], + [], + [rejected(), later, held], + DESKTOP + ) + expect(shown.map((message) => [message.id, message.unsent ?? message.queued ?? false])).toEqual( + [ + [agentJournalSubmissionKey(KEPT_ID), true], + [agentJournalSubmissionKey('client-later'), false], + ['answer-3', false], + [agentJournalSubmissionKey('client-held'), true] + ] + ) + }) +}) + +// A not-sent row joins the conversation after the stop rows are placed, so the rows a run of +// stopped sends ends with stay as they were without it. +describe('a not-sent row between two stopped sends', () => { + const items = [ + userItem('client-s1', 'a', 3), + userItem(KEPT_ID, 'b', 4), + userItem('client-s2', 'c', 5) + ] + const stopped = (clientMessageId: string): AgentJournalSubmission => ({ + ...rejected({ clientMessageId, payloadFingerprint: `fingerprint-${clientMessageId}` }), + reason: DISPATCH_REJECTED_CANCELLED, + rejection: { kind: 'cancelled' } + }) + const submissions = [stopped('client-s1'), rejected(), stopped('client-s2')] + const drawnOrder = (messages: readonly { id: string; unsent?: true }[]) => + messages.map((message) => `${message.id}${message.unsent ? ' (not sent)' : ''}`) + + it('sits at its journal place without splitting the run: one stop row, after the last', () => { + expect( + drawnOrder(projectStructuredAgentSessionMessages(items, [], submissions, DESKTOP)) + ).toEqual([ + agentJournalSubmissionKey('client-s1'), + `${agentJournalSubmissionKey(KEPT_ID)} (not sent)`, + agentJournalSubmissionKey('client-s2'), + `stopped-before-start:${agentJournalSubmissionKey('client-s2')}` + ]) + }) + + it('keeps a stop row whose send has no journal place right after that send', () => { + const unplaced = (input: readonly AgentJournalRenderItem[]) => + projectStructuredItemsToNativeChat(input).map((message) => + message.id === agentJournalSubmissionKey(KEPT_ID) + ? message + : { ...message, journalPosition: undefined } + ) + expect( + drawnOrder(projectStructuredAgentSessionMessages(items, [], submissions, DESKTOP, unplaced)) + ).toEqual([ + agentJournalSubmissionKey('client-s1'), + agentJournalSubmissionKey('client-s2'), + `stopped-before-start:${agentJournalSubmissionKey('client-s2')}`, + `${agentJournalSubmissionKey(KEPT_ID)} (not sent)` + ]) + }) +}) diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 43c3cf1ffee5..09b797377f93 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -31,7 +31,7 @@ export type StructuredAgentSessionOptimisticMessage = { export type StructuredAgentSessionMessageProjectionOptions = { /** Draw a message the host accepted and then rejected where the host recorded it, as not sent. - * Off for a client that hands such a message back to its composer instead. */ + * Off only where such a message is no conversation row, as for the outline's ticks. */ rejectedInPlace: boolean } @@ -178,17 +178,23 @@ export function projectStructuredAgentSessionMessages( if (shownStopped.size > 0) { moved = keepStoppedSendsInSendOrder(delivered, stoppedBeforeStart, shownStopped) || moved } - const conversation = moved + const ordered = moved ? Array.from(collapseProviderRetryRuns(delivered)).sort(compareNativeChatTranscriptMessages) : collapseProviderRetryRuns(delivered) + const conversation = + shownStopped.size > 0 ? withStopRowsAfterStoppedSends(ordered, shownStopped) : ordered return [ - // After the held sends leave: they are drawn after the conversation, never inside a run. - ...(shownStopped.size > 0 - ? withStopRowsAfterStoppedSends(conversation, shownStopped) + // In no turn, like the outbox's not-sent rows, and at their journal places, so a reader that + // draws this list as it comes (the phone) puts them where the host recorded them. Placed after + // the stop rows, so one never splits a run of stopped sends. + ...(unsentItems.length > 0 + ? withRowsAtJournalPlaces( + conversation, + projectItems(unsentItems).map((message) => ({ ...message, unsent: true as const })) + ) : conversation), + // After the held sends leave: they are drawn after the conversation, never inside a run. ...held, - // In no turn; the journal position keeps their place. - ...projectItems(unsentItems).map((message) => ({ ...message, unsent: true as const })), // The host's row draws a message once it has one, under its own id or the provider's. ...optimistic .filter( @@ -206,3 +212,29 @@ export function projectStructuredAgentSessionMessages( })) ] } + +/** `rows`, in journal order, with `placed` merged in where the journal puts them: a few rows, so no + * re-sort of the whole list. A row with no journal place keeps its neighbour; `placed` it outran go + * last. */ +function withRowsAtJournalPlaces( + rows: readonly NativeChatMessage[], + placed: NativeChatMessage[] +): NativeChatMessage[] { + placed.sort(compareNativeChatTranscriptMessages) + const merged: NativeChatMessage[] = [] + let next = 0 + for (const row of rows) { + for ( + let early = placed[next]; + early && + row.journalPosition !== undefined && + compareNativeChatTranscriptMessages(early, row) < 0; + early = placed[next] + ) { + merged.push(early) + next += 1 + } + merged.push(row) + } + return next < placed.length ? merged.concat(placed.slice(next)) : merged +} diff --git a/src/shared/structured-agent-session-rejection-words.test.ts b/src/shared/structured-agent-session-rejection-words.test.ts index b984008f42f9..9976a59f4a96 100644 --- a/src/shared/structured-agent-session-rejection-words.test.ts +++ b/src/shared/structured-agent-session-rejection-words.test.ts @@ -1,10 +1,16 @@ // What a rejection puts on the reader's screen: the desktop row, the phone and a returned card. import { describe, expect, it } from 'vitest' +import { agentSessionFailureWords } from './agent-session-failure-words' +import { agentJournalItemKey } from './agent-session-journal-item-key' +import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' +import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-start-failure-facts' +import { structuredAgentSessionStartFailureRowIdentity } from './structured-agent-session-start-failure-row-key' import type { AgentSessionFailureFact } from './agent-session-failure' import { agentSessionWriteNoticeEnglish } from './agent-session-refusal-notice' import { DISPATCH_REJECTED_QUEUE_FULL } from './structured-agent-session-dispatch-rejection' import { + structuredAgentSessionRecordedRejectionParts, structuredAgentSessionRejectionNotice, structuredAgentSessionRejectionParts } from './structured-agent-session-rejection-words' @@ -18,7 +24,7 @@ describe('what a rejection shows the reader', () => { const shown = notice('provider_write_failed: broken pipe') expect(shown).not.toContain('provider_write_failed') expect(shown).not.toContain('broken pipe') - expect(shown).toBe("Orca couldn't reach the agent. Your message was not sent.") + expect(shown).toBe("The agent couldn't receive this message. Send it again.") }) it("shows a content rejection in the provider's own words", () => { @@ -27,10 +33,20 @@ describe('what a rejection shows the reader', () => { ) }) - it('shows the sentence a host wrote for the person reading it', () => { - const reason = 'The provider stopped before it finished starting.' - expect(notice(reason)).toBe(reason) - }) + it.each([ + 'The provider stopped before it finished starting.', + 'The provider did not accept this message.', + 'The provider did not accept this message: provider_write_failed: stand-in rejected the turn.' + ])( + 'uses the known agent for a legacy host sentence without exposing its internals: %s', + (reason) => { + expect( + agentSessionWriteNoticeEnglish( + structuredAgentSessionRejectionParts(reason, 'send', undefined, { agentName: 'Codex' }) + ) + ).toBe("Codex didn't accept this message.") + } + ) it('never puts a legacy or local-capacity marker on screen', () => { expect(notice('not_delivered')).toBe('Your message was not sent.') @@ -43,8 +59,8 @@ describe('what a rejection shows the reader', () => { it('tells the phone how to send it again after a transport failure', () => { const phone = structuredAgentSessionRejectionNotice('provider_write_failed', 'composer-send') - expect(phone.startsWith("Orca couldn't reach the agent. Your message was not sent.")).toBe(true) - expect(phone.length).toBeGreaterThan(notice('provider_write_failed').length) + expect(phone.startsWith("The agent couldn't receive this message. Send it again.")).toBe(true) + expect(phone).toBe(notice('provider_write_failed')) }) }) @@ -53,7 +69,7 @@ describe('what a rejection with a typed fact shows the reader', () => { it('says Orca could not hand the message over, whatever the reason holds', () => { for (const reason of ['provider_write_failed', 'Something unrelated.']) { expect(notice(reason, { kind: 'writeFailed' })).toBe( - "Orca couldn't reach the agent. Your message was not sent." + "The agent couldn't receive this message. Send it again." ) } }) @@ -79,3 +95,83 @@ describe('what a rejection with a typed fact shows the reader', () => { ) }) }) + +function rejected(patch: Partial = {}): AgentJournalSubmission { + return { + clientMessageId: 'sent', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + reason: 'provider_write_failed: broken pipe', + submittedAt: 1, + resolvedAt: 1, + ...patch + } +} + +function startRow(fact: AgentSessionFailureFact): AgentJournalRenderItem { + return { + itemId: agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('gen')), + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'status', + tone: 'error', + ...agentSessionFailureWords(fact, { agentName: 'Claude', surface: 'row' }) + } + } +} + +function line( + submission: AgentJournalSubmission, + items: readonly AgentJournalRenderItem[] = [] +): string { + return agentSessionWriteNoticeEnglish( + structuredAgentSessionRecordedRejectionParts( + submission, + { agentName: 'Claude' }, + structuredAgentSessionStartFailureFacts(items) + ) + ) +} + +describe('the line under a message the host recorded and then rejected', () => { + it("says why, in the host's words", () => { + expect(line(rejected())).toBe("Claude couldn't receive this message. Send it again.") + expect(line(rejected({ reason: 'Claude does not support .bmp' }))).toBe( + 'Claude does not support .bmp' + ) + }) + + it("words the host's typed fact when no loaded row states it", () => { + const startFailed: AgentSessionFailureFact = { kind: 'startFailed' } + expect(line(rejected({ reason: 'Written by the host.', rejection: startFailed }))).toBe( + "Claude couldn't start. Send your message to try again." + ) + }) + + it('says only that it was not sent when a loaded start-failure row already says why', () => { + const startFailed: AgentSessionFailureFact = { kind: 'startFailed' } + expect( + line(rejected({ reason: 'Written by the host.', rejection: startFailed }), [ + startRow(startFailed) + ]) + ).toBe('Your message was not sent.') + }) + + it('keeps its own words when the loaded start row states a different failure', () => { + const conflict: AgentSessionFailureFact = { + kind: 'startFailed', + refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } } + } + expect( + line(rejected({ reason: 'Written by the host.', rejection: conflict }), [ + startRow({ kind: 'startFailed' }) + ]) + ).toBe( + "Claude couldn't start. This chat is still open in a terminal agent. Quit that agent to continue the chat here." + ) + }) +}) diff --git a/src/shared/structured-agent-session-rejection-words.ts b/src/shared/structured-agent-session-rejection-words.ts index fcc780a19b18..9ef641c236c5 100644 --- a/src/shared/structured-agent-session-rejection-words.ts +++ b/src/shared/structured-agent-session-rejection-words.ts @@ -6,9 +6,15 @@ import { agentSessionWriteNotDoneParts } from './agent-session-refusal-notice' import type { AgentSessionWriteNoticePart } from './agent-session-write-notice-copy' -import type { AgentSessionFailureFact } from './agent-session-failure' +import { + readWholeAgentSessionFailureFact, + type AgentSessionFailureFact +} from './agent-session-failure' +import type { AgentJournalSubmission } from './agent-session-journal-types' +import { agentSessionFailureStatedByStartRow } from './structured-agent-session-start-failure-facts' import type { AgentSessionFailureWordsContext } from './agent-session-failure-words' import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' +import { isProviderDiagnosticPersonText } from './provider-diagnostic-person-text' /** * What to put on screen for a rejection. @@ -49,11 +55,19 @@ export function structuredAgentSessionRejectionParts( } const rejection = classifyDispatchRejection({ reason }) if (rejection.kind === 'writeFailed') { - return ['unreachable', ...agentSessionWriteNotDoneParts(write)] + return rejectionFactParts(write, { kind: 'writeFailed' }, context) + } + if (rejection.kind) { + return agentSessionWriteNotDoneParts(write) } - // A legacy marker is an internal cause with no user-facing meaning; any other reason is a - // sentence written to be read — the provider's, or the host's own. - return rejection.kind ? agentSessionWriteNotDoneParts(write) : [{ text: reason }] + // A reason-only row cannot identify its author or recover its cause; use a named neutral lead. + const parts = rejectionFactParts(write, { kind: 'providerRejected' }, context) + if (!isProviderDiagnosticPersonText(reason)) { + return parts + } + const alreadyNamed = + !context.agentName || reason.toLowerCase().includes(context.agentName.toLowerCase()) + return alreadyNamed ? [{ text: reason }] : [...parts, { text: reason }] } function rejectionFactParts( @@ -62,9 +76,6 @@ function rejectionFactParts( context: AgentSessionFailureWordsContext ): AgentSessionWriteNoticePart[] { const { kind } = classifyDispatchRejection({ reason: null, rejection: fact }) - if (kind === 'writeFailed') { - return ['unreachable', ...agentSessionWriteNotDoneParts(write)] - } // A fact this build cannot place proves only that the message did not happen. return kind ? [{ failure: { ...fact, kind }, surface: 'rejection', context }] @@ -80,3 +91,21 @@ export function structuredAgentSessionAttemptFailureParts( ): AgentSessionWriteNoticePart[] { return structuredAgentSessionRejectionParts(failure.reason, 'send', recorded, context) } + +/** The line under a message the host recorded and then rejected, in the host's terms. A failed + * start's loaded row already says why, so then it says only that the message was not sent. */ +export function structuredAgentSessionRecordedRejectionParts( + submission: AgentJournalSubmission, + context: AgentSessionFailureWordsContext, + startFailures: readonly AgentSessionFailureFact[] +): AgentSessionWriteNoticePart[] { + if (agentSessionFailureStatedByStartRow(submission.rejection, startFailures)) { + return agentSessionWriteNotDoneParts('send') + } + return structuredAgentSessionRejectionParts( + submission.reason, + 'send', + readWholeAgentSessionFailureFact(submission.rejection), + context + ) +} diff --git a/src/shared/structured-agent-session-start-failure-facts.ts b/src/shared/structured-agent-session-start-failure-facts.ts new file mode 100644 index 000000000000..6fdd3dab9304 --- /dev/null +++ b/src/shared/structured-agent-session-start-failure-facts.ts @@ -0,0 +1,6 @@ +// Both clients compare a rejected send with the startup and sign-in facts already shown. +export { + agentSessionVisibleFailureFacts as structuredAgentSessionStartFailureFacts, + agentSessionFailureStatedByRow as agentSessionFailureStatedByStartRow, + sameAgentSessionFailureFact +} from './agent-session-visible-failures'