From 311b5baa85edb7d8dc24645feae80084b7becc09 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:29:09 -0700 Subject: [PATCH 01/27] refactor(native-chat): share the words for a recorded, rejected message Move the rejection words and the start-failure fact match from the desktop notices and the outbox's send disposition into src/shared, so the phone can word a not-sent row exactly as the desktop does. The desktop's output is unchanged. --- .../mobile-structured-send-delivery.ts | 2 +- .../agent-session-failure-words-text.test.ts | 2 +- ...red-agent-session-delivery-notices.test.ts | 6 +- ...ructured-agent-session-delivery-notices.ts | 83 ++-------------- .../structured-conversation-command-send.ts | 2 +- ...tured-agent-session-start-failure-facts.ts | 2 +- .../use-structured-agent-session.ts | 2 +- .../agent-session-refusal-notice.test.ts | 2 +- ...ured-agent-session-rejection-words.test.ts | 89 ++++++++++++++++++ ...tructured-agent-session-rejection-words.ts | 94 +++++++++++++++++++ ...ructured-agent-session-send-disposition.ts | 68 +------------- ...tured-agent-session-start-failure-facts.ts | 52 ++++++++++ 12 files changed, 253 insertions(+), 151 deletions(-) create mode 100644 src/shared/structured-agent-session-rejection-words.test.ts create mode 100644 src/shared/structured-agent-session-rejection-words.ts create mode 100644 src/shared/structured-agent-session-start-failure-facts.ts diff --git a/mobile/src/session/mobile-structured-send-delivery.ts b/mobile/src/session/mobile-structured-send-delivery.ts index 2d48d1d2a9e0..32dbc6f99e93 100644 --- a/mobile/src/session/mobile-structured-send-delivery.ts +++ b/mobile/src/session/mobile-structured-send-delivery.ts @@ -35,7 +35,7 @@ import type { AgentJournalSubmission } from '../../../src/shared/agent-session-j import type { AgentSessionSendResult } from '../../../src/shared/agent-session-wire' import { agentSessionRefusalOperationState } from '../../../src/shared/agent-session-refusal-retry' import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice' -import { structuredAgentSessionRejectionNotice } from '../../../src/shared/structured-agent-session-send-disposition' +import { structuredAgentSessionRejectionNotice } from '../../../src/shared/structured-agent-session-rejection-words' import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import type { StructuredAgentSessionMutationCallResult } from './mobile-structured-agent-session-rpc' 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 143a0dd4e8b1..40d67de0c335 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 @@ -21,7 +21,7 @@ import { import { AGENT_SESSION_WRITE_NOTICE_COPY } from '../../../../shared/agent-session-write-notice-copy' import { agentSessionWriteNoticeParts } from '../../../../shared/agent-session-refusal-notice' import { agentSessionRefusalFailure } from '../../../../shared/agent-session-write-failure' -import { structuredAgentSessionRejectionParts } from '../../../../shared/structured-agent-session-send-disposition' +import { structuredAgentSessionRejectionParts } from '../../../../shared/structured-agent-session-rejection-words' import { sayAgentSessionFailureTranslated } from './agent-session-failure-words-text' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' 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 3ed26650ed42..3cfcf64541aa 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 @@ -15,10 +15,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 { structuredAgentSessionStartFailureFacts } from '../../../../shared/structured-agent-session-start-failure-facts' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' function entry( clientMessageId: 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 ef91f8c1920f..20c22d160368 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 @@ -16,18 +16,13 @@ // says why. Until its row loads, the outbox draws it from its smaller copy, which leaves on the batch // or page that loads the row. -import { - readAgentSessionFailureFact, - 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 { AgentJournalRenderItem, AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice' -import { isStructuredAgentSessionStartFailureRow } from '../../../../shared/structured-agent-session-start-failure-row-key' import { structuredAgentSessionEntryIdExpired, structuredAgentSessionEntryRejectedByHost, @@ -40,10 +35,8 @@ import { import { reconcileStructuredAgentSessionOutboxWithQueue } from '../../../../shared/structured-agent-session-draft-hand-off' import { structuredAgentSessionEntryResendsUnconfirmed } from '../../../../shared/structured-agent-session-outbox-unconfirmed-resend' import type { AgentSessionFailureWordsContext } from '../../../../shared/agent-session-failure-words' -import { - structuredAgentSessionAttemptFailureParts, - structuredAgentSessionRejectionParts -} from '../../../../shared/structured-agent-session-send-disposition' +import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition' +import { structuredAgentSessionRecordedRejectionParts } from '../../../../shared/structured-agent-session-rejection-words' import { structuredAgentSessionRejectedShownInPlace } from '../../../../shared/structured-agent-session-message-projection' import { translate } from '@/i18n/i18n' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' @@ -54,52 +47,6 @@ const STRUCTURED_AGENT_SESSION_DELIVERY_SENDING: NativeChatDeliveryNotice = { se const NO_COMMANDS: ReadonlySet = new Set() const NO_ITEMS: readonly AgentJournalRenderItem[] = [] -/** The facts the chat's loaded start-failure rows state. */ -export function structuredAgentSessionStartFailureFacts( - items: readonly AgentJournalRenderItem[] -): AgentSessionFailureFact[] { - const facts: AgentSessionFailureFact[] = [] - for (const item of items) { - if (item.body.kind === 'status' && isStructuredAgentSessionStartFailureRow(item.itemId)) { - const fact = readAgentSessionFailureFact(item.body.failure) - if (fact) { - facts.push(fact) - } - } - } - return facts -} - -/** Whether two facts are one failure: a start's row and the messages it rejected share one. */ -export function sameAgentSessionFailureFact( - a: AgentSessionFailureFact, - b: AgentSessionFailureFact -): boolean { - return ( - a.kind === b.kind && - a.detail?.text === b.detail?.text && - a.detail?.audience === b.detail?.audience && - a.refusal?.code === b.refusal?.code && - a.refusal?.details?.reason === b.refusal?.details?.reason && - a.attachment?.reason === b.attachment?.reason && - a.attachment?.limit === b.attachment?.limit && - a.retry?.error === b.retry?.error && - a.retry?.status === b.retry?.status - ) -} - -/** Whether a loaded start-failure row already states this failure. Matching is identity, not - * wording: what this build can read is enough. */ -export function agentSessionFailureStatedByStartRow( - failure: unknown, - startFailures: readonly AgentSessionFailureFact[] -): boolean { - const fact = readAgentSessionFailureFact(failure) - return ( - fact !== undefined && startFailures.some((stated) => sameAgentSessionFailureFact(stated, fact)) - ) -} - function deliveryNoticeText( entry: StructuredAgentSessionOutboxEntry, context: AgentSessionFailureWordsContext, @@ -133,24 +80,6 @@ function deliveryNoticeText( ) } -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 entry under; `agentName` is the chat's * agent, for the words. */ export function structuredAgentSessionDeliveryNotices( @@ -216,7 +145,11 @@ export function structuredAgentSessionDeliveryNotices( for (const submission of rejected.values()) { const id = agentJournalSubmissionKey(submission.clientMessageId) if (shown.has(id)) { - notices.set(id, { text: hostRejectionNoticeText(submission, agentName, startFailures) }) + notices.set(id, { + text: agentSessionWriteNoticeText( + structuredAgentSessionRecordedRejectionParts(submission, { agentName }, startFailures) + ) + }) } } return notices 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 0a351db917f1..35c9bf71aeb5 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 @@ -10,7 +10,7 @@ import { import { agentSessionFailureSentence } from '../../../../shared/agent-session-failure-words' import { translate } from '@/i18n/i18n' import { sayAgentSessionFailureTranslated } from './agent-session-failure-words-text' -import { agentSessionFailureStatedByStartRow } from './structured-agent-session-delivery-notices' +import { agentSessionFailureStatedByStartRow } from '../../../../shared/structured-agent-session-start-failure-facts' import type { StructuredAgentSessionWriteOutcome } from './use-structured-agent-session-mutate' export async function sendStructuredConversationCommand(input: { 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.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 2f1d8d0fd8ed..adaa749ff24b 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -34,7 +34,7 @@ import { useStructuredAgentSessionContextUsage } from './use-structured-agent-se import { useStructuredAgentSessionRailOutline } from './use-structured-agent-session-rail-outline' import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages' import { outboxOutsideQueuedCards } from './structured-agent-session-queued-cards' -import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-delivery-notices' +import { structuredAgentSessionStartFailureFacts } from '../../../../shared/structured-agent-session-start-failure-facts' import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' import { structuredAgentSessionNewSendsQueue } from '../../../../shared/structured-agent-session-outbox-delivery' import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index 1501453361fe..43ed3c6706a5 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -32,7 +32,7 @@ import { DISPATCH_REJECTED_QUEUE_FULL, DISPATCH_REJECTED_WRITE_FAILED } from './structured-agent-session-dispatch-rejection' -import { structuredAgentSessionRejectionParts } from './structured-agent-session-send-disposition' +import { structuredAgentSessionRejectionParts } from './structured-agent-session-rejection-words' const WRITES: AgentSessionWriteKind[] = [ 'read-history', diff --git a/src/shared/structured-agent-session-rejection-words.test.ts b/src/shared/structured-agent-session-rejection-words.test.ts new file mode 100644 index 000000000000..8417129a10d1 --- /dev/null +++ b/src/shared/structured-agent-session-rejection-words.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionFailureFact } from './agent-session-failure' +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 { agentSessionWriteNoticeEnglish } from './agent-session-refusal-notice' +import { structuredAgentSessionRecordedRejectionParts } from './structured-agent-session-rejection-words' +import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-start-failure-facts' +import { structuredAgentSessionStartFailureRowIdentity } from './structured-agent-session-start-failure-row-key' + +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("Orca couldn't reach the agent. Your message was not sent.") + 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 new file mode 100644 index 000000000000..8e2bb25ed416 --- /dev/null +++ b/src/shared/structured-agent-session-rejection-words.ts @@ -0,0 +1,94 @@ +// The words for a message the host rejected, shared by the desktop and the phone. + +import { + readWholeAgentSessionFailureFact, + type AgentSessionFailureFact +} from './agent-session-failure' +import type { AgentSessionFailureWordsContext } from './agent-session-failure-words' +import type { AgentJournalSubmission } from './agent-session-journal-types' +import { + agentSessionWriteNoticeEnglish, + agentSessionWriteNotDoneParts +} from './agent-session-refusal-notice' +import type { AgentSessionWriteNoticePart } from './agent-session-write-notice-copy' +import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' +import { agentSessionFailureStatedByStartRow } from './structured-agent-session-start-failure-facts' + +/** + * What to put on screen for a rejection. + * + * A content rejection's reason is the provider explaining itself, so it is shown + * verbatim — "Claude does not support the image type .bmp" is the whole answer and + * a generic string would throw it away. A transport rejection's reason is an + * internal marker; printing it put `provider_write_failed: broken pipe` in front of + * users, which names nothing they can act on. That case gets copy that says what + * happened, and on the phone that the message can be sent again — which it can, + * because the frame provably never left, so a resend cannot duplicate. + * + * The null default claims no cause and no next step, because at that point we know + * neither: all it asserts is the one thing every rejection shares. + * + * Exported because a client without an outbox needs the same copy: the rule about + * which reasons a person may read is a property of the reason, not of the queue. + */ +export function structuredAgentSessionRejectionNotice( + reason: string | null, + write: 'send' | 'composer-send' +): string { + return agentSessionWriteNoticeEnglish(structuredAgentSessionRejectionParts(reason, write)) +} + +export function structuredAgentSessionRejectionParts( + reason: string | null, + write: 'send' | 'composer-send', + /** The host's typed fact, which decides when the row carried one. */ + fact?: AgentSessionFailureFact, + context: AgentSessionFailureWordsContext = {} +): AgentSessionWriteNoticePart[] { + if (fact) { + return rejectionFactParts(write, fact, context) + } + if (reason === null) { + return ['notDoneSend'] + } + const rejection = classifyDispatchRejection({ reason }) + if (rejection.kind === 'writeFailed') { + return ['unreachable', ...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 }] +} + +function rejectionFactParts( + write: 'send' | 'composer-send', + fact: AgentSessionFailureFact, + 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 }] + : agentSessionWriteNotDoneParts(write) +} + +/** 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-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index 3dad3dc4dc0b..612e42c70cbd 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -9,11 +9,7 @@ import type { AgentJournalSubmission } from './agent-session-journal-types' import type { AgentSessionMutationResult, AgentSessionSendResult } from './agent-session-wire' -import { - agentSessionWriteNoticeEnglish, - agentSessionWriteNoticeParts, - agentSessionWriteNotDoneParts -} from './agent-session-refusal-notice' +import { agentSessionWriteNoticeParts } from './agent-session-refusal-notice' import type { AgentSessionWriteNoticePart } from './agent-session-write-notice-copy' import { agentSessionRefusalFailure, @@ -22,6 +18,7 @@ import { import type { AgentSessionFailureFact } from './agent-session-failure' import type { AgentSessionFailureWordsContext } from './agent-session-failure-words' import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' +import { structuredAgentSessionRejectionParts } from './structured-agent-session-rejection-words' import { classifyStructuredAgentSessionSendFailure, requeueStructuredAgentSessionSendRefusal, @@ -111,67 +108,6 @@ export function journalAnswersInFlightSend( ) } -/** - * What to put on screen for a rejection. - * - * A content rejection's reason is the provider explaining itself, so it is shown - * verbatim — "Claude does not support the image type .bmp" is the whole answer and - * a generic string would throw it away. A transport rejection's reason is an - * internal marker; printing it put `provider_write_failed: broken pipe` in front of - * users, which names nothing they can act on. That case gets copy that says what - * happened, and on the phone that the message can be sent again — which it can, - * because the frame provably never left, so a resend cannot duplicate. - * - * The null default claims no cause and no next step, because at that point we know - * neither: all it asserts is the one thing every rejection shares. - * - * Exported because a client without an outbox needs the same copy: the rule about - * which reasons a person may read is a property of the reason, not of the queue. - */ -export function structuredAgentSessionRejectionNotice( - reason: string | null, - write: 'send' | 'composer-send' -): string { - return agentSessionWriteNoticeEnglish(structuredAgentSessionRejectionParts(reason, write)) -} - -export function structuredAgentSessionRejectionParts( - reason: string | null, - write: 'send' | 'composer-send', - /** The host's typed fact, which decides when the row carried one. */ - fact?: AgentSessionFailureFact, - context: AgentSessionFailureWordsContext = {} -): AgentSessionWriteNoticePart[] { - if (fact) { - return rejectionFactParts(write, fact, context) - } - if (reason === null) { - return ['notDoneSend'] - } - const rejection = classifyDispatchRejection({ reason }) - if (rejection.kind === 'writeFailed') { - return ['unreachable', ...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 }] -} - -function rejectionFactParts( - write: 'send' | 'composer-send', - fact: AgentSessionFailureFact, - 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 }] - : agentSessionWriteNotDoneParts(write) -} - /** Kinds whose words need what the message's copy drops: the provider's detail, or the refusal. */ const WORDED_FROM_WHOLE_FACT: ReadonlySet = new Set< AgentSessionFailureFact['kind'] 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..905f19e6d077 --- /dev/null +++ b/src/shared/structured-agent-session-start-failure-facts.ts @@ -0,0 +1,52 @@ +// The failures a chat's loaded start-failure rows state, so a message that start rejected need not +// say why again. Shared so the desktop and the phone match a rejection to its row the same way. + +import { readAgentSessionFailureFact, type AgentSessionFailureFact } from './agent-session-failure' +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import { isStructuredAgentSessionStartFailureRow } from './structured-agent-session-start-failure-row-key' + +/** The facts the chat's loaded start-failure rows state. */ +export function structuredAgentSessionStartFailureFacts( + items: readonly AgentJournalRenderItem[] +): AgentSessionFailureFact[] { + const facts: AgentSessionFailureFact[] = [] + for (const item of items) { + if (item.body.kind === 'status' && isStructuredAgentSessionStartFailureRow(item.itemId)) { + const fact = readAgentSessionFailureFact(item.body.failure) + if (fact) { + facts.push(fact) + } + } + } + return facts +} + +/** Whether two facts are one failure: a start's row and the messages it rejected share one. */ +export function sameAgentSessionFailureFact( + a: AgentSessionFailureFact, + b: AgentSessionFailureFact +): boolean { + return ( + a.kind === b.kind && + a.detail?.text === b.detail?.text && + a.detail?.audience === b.detail?.audience && + a.refusal?.code === b.refusal?.code && + a.refusal?.details?.reason === b.refusal?.details?.reason && + a.attachment?.reason === b.attachment?.reason && + a.attachment?.limit === b.attachment?.limit && + a.retry?.error === b.retry?.error && + a.retry?.status === b.retry?.status + ) +} + +/** Whether a loaded start-failure row already states this failure. Matching is identity, not + * wording: what this build can read is enough. */ +export function agentSessionFailureStatedByStartRow( + failure: unknown, + startFailures: readonly AgentSessionFailureFact[] +): boolean { + const fact = readAgentSessionFailureFact(failure) + return ( + fact !== undefined && startFailures.some((stated) => sameAgentSessionFailureFact(stated, fact)) + ) +} From 9bd025484f38651bc3eb75f70cdcc379d729fff7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:31:44 -0700 Subject: [PATCH 02/27] fix(native-chat): place a not-sent row at its journal position in the projection The shared projection listed rows the host recorded and then rejected after the whole conversation; only the desktop re-sorted them. Merge them into the conversation through the existing journal-order sort, so a reader that draws the list as it comes puts them where the host recorded them. --- ...ssage-projection.rejected-in-place.test.ts | 10 ++-- ...d-agent-session-message-projection.test.ts | 47 +++++++++++++++++++ ...ctured-agent-session-message-projection.ts | 14 ++++-- 3 files changed, 61 insertions(+), 10 deletions(-) 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 b12229143019..3d649aba1f00 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 @@ -233,9 +233,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 } ]) }) @@ -272,8 +272,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) }) diff --git a/src/shared/structured-agent-session-message-projection.test.ts b/src/shared/structured-agent-session-message-projection.test.ts index c0829aa02b63..2358bfa4762e 100644 --- a/src/shared/structured-agent-session-message-projection.test.ts +++ b/src/shared/structured-agent-session-message-projection.test.ts @@ -83,3 +83,50 @@ 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] + ] + ) + }) +}) diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 7591a4332c74..d6233d7a7618 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -159,17 +159,21 @@ export function projectStructuredAgentSessionMessages( if (shownStopped.size > 0) { moved = keepStoppedSendsInSendOrder(delivered, stoppedBeforeStart, shownStopped) || moved } - const conversation = moved - ? Array.from(collapseProviderRetryRuns(delivered)).sort(compareNativeChatTranscriptMessages) - : collapseProviderRetryRuns(delivered) + // 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. + const unsent = projectItems(unsentItems).map((message) => ({ ...message, unsent: true as const })) + const conversation = + moved || unsent.length > 0 + ? collapseProviderRetryRuns(delivered) + .concat(unsent) + .sort(compareNativeChatTranscriptMessages) + : collapseProviderRetryRuns(delivered) return [ // After the held sends leave: they are drawn after the conversation, never inside a run. ...(shownStopped.size > 0 ? withStopRowsAfterStoppedSends(conversation, shownStopped) : conversation), ...held, - // In no turn, like the outbox's not-sent rows; the journal position keeps their place. - ...projectItems(unsentItems).map((message) => ({ ...message, unsent: true as const })), ...optimistic .filter((entry) => !journalled.has(agentJournalSubmissionKey(entry.clientMessageId))) .map((entry): NativeChatMessage => ({ From e951642ba6b690743832f147c4a4672423baa86c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:39:58 -0700 Subject: [PATCH 03/27] fix(mobile): match a send's echo past rows the host shows as not sent Record at send time which rows are already shown as not sent. Only those can never be the send's echo: a not-sent row that appears later is the send's own, so it settles the send and retires its bubble. The send's boundary is the newest row past any shown as not sent, and they never count as earlier copies of its text, so a same-text resend is not left as a phantom bubble or a 'Delivery unconfirmed' banner. --- .../mobile-native-chat-draft-reconcile.ts | 47 ++- ...mobile-native-chat-not-sent-resend.test.ts | 327 ++++++++++++++++++ .../mobile-native-chat-pending-baseline.ts | 7 +- .../mobile-native-chat-pending-echo.ts | 7 + .../mobile-native-chat-pending-retirement.ts | 28 +- ...ile-native-chat-drafts-unsent-hold.test.ts | 110 ++++++ .../session/use-mobile-native-chat-drafts.ts | 9 +- ...se-mobile-native-chat-unconfirmed-sends.ts | 3 + 8 files changed, 523 insertions(+), 15 deletions(-) create mode 100644 mobile/src/session/mobile-native-chat-not-sent-resend.test.ts create mode 100644 mobile/src/session/use-mobile-native-chat-drafts-unsent-hold.test.ts diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts index 3ae08af66881..7c67c83913ff 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts @@ -18,6 +18,8 @@ export type UnconfirmedSend = { baselineTailMessageId: string | null /** Queued-draft cards on screen at send time; see `findQueuedUnconfirmedSends`. */ baselineQueuedMessageIds?: readonly string[] + /** Rows shown as not sent at send time; see `sendBaselineUnsentMessageIds`. */ + baselineUnsentMessageIds?: readonly string[] deadline: ReturnType | null } @@ -25,13 +27,32 @@ export function normalizedUserText(message: NativeChatMessage): string | null { return normalizedNativeChatUserMessageText(message) } +/** Rows shown as not sent when a send went out. Only these can't be its echo: one that appears + * later is the send's own row, settled as not sent. The count, image and unconfirmed matchers + * all apply this. */ +export function sendBaselineUnsentMessageIds(messages: readonly NativeChatMessage[]): string[] { + return messages.filter((message) => message.unsent === true).map((message) => message.id) +} + +/** The row a send's echo must land after: the newest one, past any shown as not sent, which are in + * the send's baseline and so never its echo. */ +export function sendBaselineTailMessageId(messages: readonly NativeChatMessage[]): string | null { + for (let index = messages.length - 1; index >= 0; index -= 1) { + if (messages[index]?.unsent !== true) { + return messages[index]?.id ?? null + } + } + return null +} + +/** At send time: every row shown as not sent is then in the send's baseline, so none is counted. */ export function countUserTextOccurrences( messages: readonly NativeChatMessage[], text: string ): number { let count = 0 for (const message of messages) { - if (normalizedUserText(message) === text) { + if (message.unsent !== true && normalizedUserText(message) === text) { count++ } } @@ -44,13 +65,16 @@ export function countUserTextOccurrences( * only image echoes keeps an unrelated text send's echo from clearing it. */ export function countImageSourceTurnsAfter( messages: readonly NativeChatMessage[], - tailId: string | null + tailId: string | null, + /** See `sendBaselineUnsentMessageIds`: one can sit past the tail, never the send's own. */ + baselineUnsentMessageIds: readonly string[] = [] ): number { const tailIndex = tailId ? messages.findIndex((message) => message.id === tailId) : -1 + const baselineUnsent = new Set(baselineUnsentMessageIds) let count = 0 for (let i = tailIndex + 1; i < messages.length; i++) { const message = messages[i] - if (message && isImageSourceUserTurn(message)) { + if (message && isImageSourceUserTurn(message) && !baselineUnsent.has(message.id)) { count++ } } @@ -63,6 +87,8 @@ export type PendingImagePreviewEcho = { images?: string[] expectedOccurrence: number baselineTailMessageId: string | null + /** See `sendBaselineUnsentMessageIds`. */ + baselineUnsentMessageIds?: readonly string[] } export type LandedImagePreviewEcho = { @@ -177,8 +203,9 @@ export function findLandedImagePreviewEchoes( continue } const targetText = normalizeNativeChatUserText(entry.text) + const baselineUnsent = new Set(entry.baselineUnsentMessageIds) const candidates = normalized.filter((message) => { - if (message.role !== 'user') { + if (message.role !== 'user' || (message.unsent === true && baselineUnsent.has(message.id))) { return false } if (targetText) { @@ -257,7 +284,7 @@ export function findLandedUnconfirmedSends( // (`[Image: source: …]` or no text) keys under '' so an empty-text send can // claim it. const messageIndexById = new Map() - const userMessagesByText = new Map>() + const userMessagesByText = new Map() for (const [index, message] of messages.entries()) { messageIndexById.set(message.id, index) if (message.role !== 'user') { @@ -265,7 +292,7 @@ export function findLandedUnconfirmedSends( } const key = isImageSourceUserTurn(message) ? '' : (normalizedUserText(message) ?? '') const current = userMessagesByText.get(key) ?? [] - current.push({ id: message.id, index }) + current.push({ id: message.id, index, unsent: message.unsent === true }) userMessagesByText.set(key, current) } @@ -278,9 +305,15 @@ export function findLandedUnconfirmedSends( if (tailIndex === undefined) { continue } + const baselineUnsent = new Set(entry.baselineUnsentMessageIds) const echo = userMessagesByText .get(entry.normalizedText) - ?.find((message) => message.index > tailIndex && !claimedMessageIds.has(message.id)) + ?.find( + (message) => + message.index > tailIndex && + !claimedMessageIds.has(message.id) && + !(message.unsent && baselineUnsent.has(message.id)) + ) if (echo) { claimedMessageIds.add(echo.id) landed.push(entry) diff --git a/mobile/src/session/mobile-native-chat-not-sent-resend.test.ts b/mobile/src/session/mobile-native-chat-not-sent-resend.test.ts new file mode 100644 index 000000000000..71503e4e50d4 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-not-sent-resend.test.ts @@ -0,0 +1,327 @@ +// The phone draws a message the host recorded and then rejected in place, as not sent. The user's +// way on is to send its text again, as a new message. The phone's own echo of that resend must land +// on the new copy alone, never on the original. + +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, + normalizeReconcileText, + sendBaselineTailMessageId, + sendBaselineUnsentMessageIds +} from './mobile-native-chat-draft-reconcile' +import { rebaseMobileNativeChatPendingBaselines } from './mobile-native-chat-pending-baseline' +import { appendMobileNativeChatPending } from './mobile-native-chat-pending-echo' +import { retireLandedMobileNativeChatPending } from './mobile-native-chat-pending-retirement' + +const TEXT = 'fix the test' + +function user(id: string, sequence: number, text: string): AgentJournalRenderItem { + return { + itemId: agentJournalSubmissionKey(id), + revision: 1, + sequence, + observedAt: sequence, + turnScope: { kind: 'thread' }, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } + } +} + +function answer(id: string, sequence: number, text: string): AgentJournalRenderItem { + return { + itemId: id, + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } + } +} + +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 phone( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[] +): NativeChatMessage[] { + return projectStructuredAgentSessionMessages(items, [], submissions, { rejectedInPlace: true }) +} + +const REJECTED = submission('m1', TEXT, 10, { + dispatchState: 'rejected', + providerItemId: null, + reason: 'provider_write_failed: broken pipe', + resolvedAt: 11 +}) +const BEFORE_ITEMS = [answer('hi', 5, 'Hi'), user('m1', 11, TEXT)] +const AFTER_ITEMS = [...BEFORE_ITEMS, user('m2', 20, TEXT), answer('done', 21, 'Done.')] +const AFTER_SUBMISSIONS = [REJECTED, submission('m2', TEXT, 20)] + +describe('resending a not-sent message from the phone', () => { + // The original leaves once the resend is recorded: the projection collapses a copy sent after its + // rejection was known. The echo must not wait for a second copy that is never drawn. + it('ends as the delivered resend alone, with no echo left', () => { + const before = phone(BEFORE_ITEMS, [REJECTED]) + const normalizedText = normalizeReconcileText(TEXT) + const origin = { + draftKey: 'draft', + draftEditGeneration: 0, + pendingKey: 'pending', + normalizedText, + baselineOccurrences: countUserTextOccurrences(before, normalizedText), + baselineTailMessageId: sendBaselineTailMessageId(before), + baselineResolved: true, + baselineUnsentMessageIds: sendBaselineUnsentMessageIds(before) + } + const pending = appendMobileNativeChatPending({}, 'pending', 'pending-1', origin, TEXT).pending + + const after = phone(AFTER_ITEMS, AFTER_SUBMISSIONS) + expect( + after.filter((message) => message.role === 'user').map((message) => message.unsent ?? false) + ).toEqual([false]) + expect(retireLandedMobileNativeChatPending(after, pending ?? [], new Set())).toEqual([]) + }) + + it('sees an ack-lost resend that landed as landed, so no doubt is raised', () => { + const before = phone(BEFORE_ITEMS, [REJECTED]) + const landed = findLandedUnconfirmedSends(phone(AFTER_ITEMS, AFTER_SUBMISSIONS), [ + { + draftKey: 'draft', + pendingKey: 'pending', + text: TEXT, + normalizedText: normalizeReconcileText(TEXT), + baselineTailMessageId: sendBaselineTailMessageId(before), + baselineUnsentMessageIds: sendBaselineUnsentMessageIds(before), + deadline: null + } + ]) + expect(landed).toHaveLength(1) + }) + + it('lets the older not-sent copy settle nothing while the resend is on its way', () => { + const before = phone(BEFORE_ITEMS, [REJECTED]) + const normalizedText = normalizeReconcileText(TEXT) + const origin = { + draftKey: 'draft', + draftEditGeneration: 0, + pendingKey: 'pending', + normalizedText, + baselineOccurrences: countUserTextOccurrences(before, normalizedText), + baselineTailMessageId: sendBaselineTailMessageId(before), + baselineResolved: true, + baselineUnsentMessageIds: sendBaselineUnsentMessageIds(before) + } + const pending = appendMobileNativeChatPending({}, 'pending', 'pending-1', origin, TEXT).pending + expect(retireLandedMobileNativeChatPending(before, pending ?? [], new Set())).toHaveLength(1) + expect(findLandedUnconfirmedSends(before, [{ ...origin, text: TEXT, deadline: null }])).toEqual( + [] + ) + }) +}) + +// The phone's first sight of its own row can already be the rejected one: a catch-up after the +// stream dropped, or a send made before the first read settled. That row settles the send. +describe('a phone send whose own row first appears as not sent', () => { + const OWN_REJECTED = submission('m2', 'later', 20, { + dispatchState: 'rejected', + providerItemId: null, + reason: 'provider_write_failed: broken pipe', + resolvedAt: 21 + }) + const BEFORE = phone(BEFORE_ITEMS, [REJECTED]) + const AFTER = phone([...BEFORE_ITEMS, user('m2', 21, 'later')], [REJECTED, OWN_REJECTED]) + + it('settles an ack-lost send, so no "Delivery unconfirmed" banner follows', () => { + expect( + findLandedUnconfirmedSends(AFTER, [ + { + draftKey: 'draft', + pendingKey: 'pending', + text: 'later', + normalizedText: normalizeReconcileText('later'), + baselineTailMessageId: sendBaselineTailMessageId(BEFORE), + baselineUnsentMessageIds: sendBaselineUnsentMessageIds(BEFORE), + deadline: null + } + ]) + ).toHaveLength(1) + }) + + it("retires an accepted send's echo, so no bubble stays beside its row", () => { + const normalizedText = normalizeReconcileText('later') + const pending = appendMobileNativeChatPending( + {}, + 'pending', + 'pending-1', + { + draftKey: 'draft', + draftEditGeneration: 0, + pendingKey: 'pending', + normalizedText, + baselineOccurrences: countUserTextOccurrences(BEFORE, normalizedText), + baselineTailMessageId: sendBaselineTailMessageId(BEFORE), + baselineResolved: true, + baselineUnsentMessageIds: sendBaselineUnsentMessageIds(BEFORE) + }, + 'later' + ).pending + expect(retireLandedMobileNativeChatPending(AFTER, pending ?? [], new Set())).toEqual([]) + }) +}) + +// A send made before the chat's first read settled takes its boundary from that read, which can +// already hold the send's own row, shown as not sent: the boundary is the row before it. +describe('a phone send made before the first read, whose own row it already shows as not sent', () => { + it('takes the row before it as its boundary, so its own row settles it', () => { + const pending = + appendMobileNativeChatPending( + {}, + 'pending', + 'pending-1', + { + draftKey: 'draft', + draftEditGeneration: 0, + pendingKey: 'pending', + normalizedText: normalizeReconcileText('later'), + baselineOccurrences: 0, + baselineTailMessageId: null, + baselineResolved: false + }, + 'later' + ).pending ?? [] + const read = phone( + [...BEFORE_ITEMS, user('m2', 21, 'later')], + [ + REJECTED, + submission('m2', 'later', 20, { + dispatchState: 'rejected', + providerItemId: null, + reason: 'provider_write_failed: broken pipe', + resolvedAt: 21 + }) + ] + ) + const rebased = rebaseMobileNativeChatPendingBaselines(read, pending) + expect(rebased[0]?.baselineTailMessageId).toBe('hi') + expect(retireLandedMobileNativeChatPending(read, rebased, new Set())).toEqual([]) + }) +}) + +// A captioned image send's placeholder retires only once its local preview binds to its row, so the +// image matcher must apply the same rule: its own not-sent row binds it, an older one doesn't. +describe('a captioned image send whose own row turns not sent', () => { + const CAPTION = 'look at this' + function imageRow(id: string, unsent: boolean): NativeChatMessage { + return { + id, + role: 'user', + source: 'transcript', + timestamp: null, + blocks: [ + { type: 'text', text: CAPTION }, + { type: 'image-ref', path: '/tmp/x.png' } + ], + ...(unsent ? { unsent: true as const } : {}) + } + } + const HELLO: NativeChatMessage = { + id: 'a1', + role: 'assistant', + source: 'transcript', + timestamp: null, + blocks: [{ type: 'text', text: 'Hello' }] + } + + function placeholderLeft(before: NativeChatMessage[], after: NativeChatMessage[]): number { + const normalizedText = normalizeReconcileText(CAPTION) + const pending = + appendMobileNativeChatPending( + {}, + 'pending', + 'pending-1', + { + draftKey: 'draft', + draftEditGeneration: 0, + pendingKey: 'pending', + normalizedText, + baselineOccurrences: countUserTextOccurrences(before, normalizedText), + baselineTailMessageId: sendBaselineTailMessageId(before), + baselineResolved: true, + baselineUnsentMessageIds: sendBaselineUnsentMessageIds(before) + }, + CAPTION, + ['file:///local.png'] + ).pending ?? [] + const landed = findLandedImagePreviewEchoes(after, pending) + return retireLandedMobileNativeChatPending( + after, + pending, + new Set(landed.map((preview) => preview.pendingId)) + ).length + } + + it('retires its placeholder on its own not-sent row', () => { + expect(placeholderLeft([HELLO], [HELLO, imageRow('m2', true)])).toBe(0) + }) + + // An image-only send whose preview is gone is counted by its image turns after the send's tail; + // an older not-sent one can sit past that tail, at its journal place. + it('never counts an older not-sent image turn for a textless send', () => { + const imageOnly = (id: string, unsent: boolean): NativeChatMessage => ({ + ...imageRow(id, unsent), + blocks: [{ type: 'text', text: '[Image: source: /tmp/x.png]' }] + }) + const before = [HELLO, imageOnly('m1', true)] + const pending = + appendMobileNativeChatPending( + {}, + 'pending', + 'pending-1', + { + draftKey: 'draft', + draftEditGeneration: 0, + pendingKey: 'pending', + normalizedText: '', + baselineOccurrences: 0, + baselineTailMessageId: sendBaselineTailMessageId(before), + baselineResolved: true, + baselineUnsentMessageIds: sendBaselineUnsentMessageIds(before) + }, + '' + ).pending ?? [] + expect(retireLandedMobileNativeChatPending(before, pending, new Set())).toHaveLength(1) + expect( + retireLandedMobileNativeChatPending([...before, imageOnly('m2', true)], pending, new Set()) + ).toEqual([]) + }) + + it('keeps it while only a not-sent copy from before the send is there', () => { + const before = [HELLO, imageRow('m1', true)] + expect(placeholderLeft(before, before)).toBe(1) + expect(placeholderLeft(before, [...before, imageRow('m2', true)])).toBe(0) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-pending-baseline.ts b/mobile/src/session/mobile-native-chat-pending-baseline.ts index 903bf9d8f324..be13032fdd90 100644 --- a/mobile/src/session/mobile-native-chat-pending-baseline.ts +++ b/mobile/src/session/mobile-native-chat-pending-baseline.ts @@ -1,5 +1,8 @@ import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { normalizeReconcileText } from './mobile-native-chat-draft-reconcile' +import { + normalizeReconcileText, + sendBaselineTailMessageId +} from './mobile-native-chat-draft-reconcile' import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo' /** @@ -39,7 +42,7 @@ export function rebaseMobileNativeChatPendingBaselines( if (current.every((item) => item.baselineResolved)) { return current } - const baselineTailMessageId = messages.at(-1)?.id ?? null + const baselineTailMessageId = sendBaselineTailMessageId(messages) return current.map((item) => { if (item.baselineResolved) { return item diff --git a/mobile/src/session/mobile-native-chat-pending-echo.ts b/mobile/src/session/mobile-native-chat-pending-echo.ts index 01b8c7d4bc23..9792d8e8a270 100644 --- a/mobile/src/session/mobile-native-chat-pending-echo.ts +++ b/mobile/src/session/mobile-native-chat-pending-echo.ts @@ -12,6 +12,8 @@ export type MobileNativeChatPendingMessage = { * and rebased onto the first authoritative read instead of reconciling * against rows that may belong to another tab. */ baselineResolved: boolean + /** Rows shown as not sent when it was sent: they can't retire it, its own row can. */ + baselineUnsentMessageIds?: readonly string[] } export type MobileNativeChatSendOrigin = { @@ -25,6 +27,8 @@ export type MobileNativeChatSendOrigin = { /** Queued-draft cards already on screen at send time, so an earlier identical * card cannot confirm this send. Structured lane, on any host that publishes cards. */ baselineQueuedMessageIds?: readonly string[] + /** Rows shown as not sent at send time: an older not-sent copy can't settle this send. */ + baselineUnsentMessageIds?: readonly string[] } type PendingByKey = Record @@ -73,6 +77,9 @@ export function appendMobileNativeChatPending( : origin.baselineOccurrences + earlierOutstanding + 1, baselineTailMessageId: origin.baselineTailMessageId, baselineResolved: origin.baselineResolved, + ...(origin.baselineUnsentMessageIds + ? { baselineUnsentMessageIds: origin.baselineUnsentMessageIds } + : {}), ...(images?.length ? { images } : {}) } ] diff --git a/mobile/src/session/mobile-native-chat-pending-retirement.ts b/mobile/src/session/mobile-native-chat-pending-retirement.ts index 7f720302eccc..e763641ede09 100644 --- a/mobile/src/session/mobile-native-chat-pending-retirement.ts +++ b/mobile/src/session/mobile-native-chat-pending-retirement.ts @@ -36,7 +36,8 @@ export function selectGluedPendingIds( const turns: UserTurn[] = [] for (const [index, message] of messages.entries()) { messageIndexById.set(message.id, index) - const text = normalizedUserText(message) + // A not-sent row is one refused structured send, never several glued on an input line. + const text = message.unsent === true ? null : normalizedUserText(message) if (text) { turns.push({ index, text }) } @@ -147,12 +148,26 @@ export function retireLandedMobileNativeChatPending( landedImagePendingIds: ReadonlySet ): MobileNativeChatPendingMessage[] { const landedCounts = new Map() + const unsentByText = new Map() for (const message of messages) { const text = normalizedUserText(message) - if (text) { + if (!text) { + continue + } + if (message.unsent === true) { + unsentByText.set(text, [...(unsentByText.get(text) ?? []), message.id]) + } else { landedCounts.set(text, (landedCounts.get(text) ?? 0) + 1) } } + // A not-sent row settles a send only when it appeared after the send: then it is the send's own. + const landedFor = (item: MobileNativeChatPendingMessage, text: string): number => { + const baselineUnsent = new Set(item.baselineUnsentMessageIds) + return ( + (landedCounts.get(text) ?? 0) + + (unsentByText.get(text) ?? []).filter((id) => !baselineUnsent.has(id)).length + ) + } const landedPendingIds = new Set() // Why a separate set: a barrier preserves adjacency after a landing consumed a whole // row. An image landing can share its row with the send glued after it, so treating it @@ -171,9 +186,12 @@ export function retireLandedMobileNativeChatPending( } const landed = item.text.trim() === '' - ? countImageSourceTurnsAfter(messages, item.baselineTailMessageId) >= - item.expectedOccurrence - : (landedCounts.get(normalizeReconcileText(item.text)) ?? 0) >= item.expectedOccurrence + ? countImageSourceTurnsAfter( + messages, + item.baselineTailMessageId, + item.baselineUnsentMessageIds + ) >= item.expectedOccurrence + : landedFor(item, normalizeReconcileText(item.text)) >= item.expectedOccurrence if (landed) { landedPendingIds.add(item.id) exactLandedIds.add(item.id) diff --git a/mobile/src/session/use-mobile-native-chat-drafts-unsent-hold.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-unsent-hold.test.ts new file mode 100644 index 000000000000..f6676057b640 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-drafts-unsent-hold.test.ts @@ -0,0 +1,110 @@ +// An ack-lost send is settled by its own row, even one the host already shows as not sent, and never +// by a not-sent row of the same text that was on screen when it went out. + +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' + +function user(id: string, text: string, unsent = false): NativeChatMessage { + return { + id, + role: 'user', + source: 'transcript', + timestamp: null, + blocks: [{ type: 'text', text }], + ...(unsent ? { unsent: true as const } : {}) + } +} + +const ANSWER: NativeChatMessage = { + id: 'answer', + role: 'assistant', + source: 'transcript', + timestamp: null, + blocks: [{ type: 'text', text: 'Hi' }] +} + +describe('useMobileNativeChatDrafts unconfirmed hold beside not-sent rows', () => { + 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({ messages }: { messages: NativeChatMessage[] }): null { + state = useMobileNativeChatDrafts({ + hostId: 'host', + worktreeId: 'worktree', + tabId: 'a', + sessionId: 'session-a', + messages, + launchDraft: null, + transcriptLoading: false, + transcriptSettled: true, + queuedCards: [] + }) + return null + } + + async function render(messages: NativeChatMessage[]): Promise { + await act(async () => { + if (renderer) { + renderer.update(createElement(Harness, { messages })) + } else { + renderer = create(createElement(Harness, { messages })) + } + }) + } + + /** Holds a lost send; the returned call runs out its deadline and hands back the warning spy. */ + function holdLostSend(text: string): () => ReturnType { + const onUnconfirmed = vi.fn() + const origin = state?.captureSendOrigin(text) + if (!origin) { + throw new Error('no send origin') + } + act(() => state?.holdUnconfirmedSend(origin, text, onUnconfirmed)) + return () => { + act(() => { + vi.advanceTimersByTime(30_000) + }) + return onUnconfirmed + } + } + + const BEFORE = [user('m1', 'fix the test', true), ANSWER] + + it('stays quiet when its own row arrives already shown as not sent', async () => { + await render(BEFORE) + const settle = holdLostSend('fix the test') + await render([...BEFORE, user('m2', 'fix the test', true)]) + expect(settle()).not.toHaveBeenCalled() + }) + + // Once the resend is recorded, the projection drops the not-sent original it copies. + it('stays quiet when its resend replaces the not-sent original it was sent after', async () => { + const before = [ANSWER, user('m1', 'fix the test', true)] + await render(before) + const settle = holdLostSend('fix the test') + await render([ANSWER, user('m2', 'fix the test')]) + expect(settle()).not.toHaveBeenCalled() + }) + + it('still warns when only the older not-sent copy of its text is there', async () => { + const before = [ANSWER, user('m1', 'fix the test', true)] + await render(before) + const settle = holdLostSend('fix the test') + await render([...before]) + expect(settle()).toHaveBeenCalledTimes(1) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index 027e6a9a1d89..d0559d47ad11 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -11,6 +11,8 @@ import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { appendReturnedDraftText } from '../../../src/shared/returned-draft-text' import { countUserTextOccurrences, + sendBaselineTailMessageId, + sendBaselineUnsentMessageIds, findLandedImagePreviewEchoes, mergeLandedImagePreviewEchoes, migrateImagePreviewMessageIds, @@ -175,7 +177,8 @@ export function useMobileNativeChatDrafts(args: { pendingKey, normalizedText, baselineOccurrences: countUserTextOccurrences(messagesRef.current, normalizedText), - baselineTailMessageId: messagesRef.current.at(-1)?.id ?? null, + baselineTailMessageId: sendBaselineTailMessageId(messagesRef.current), + ...unsentBaseline(sendBaselineUnsentMessageIds(messagesRef.current)), // Only a settled read makes this a boundary. Anything else — hydrating, // or a read that failed — hands back an empty list that reads as "the // conversation was empty", which lets any row claim this send later. @@ -319,3 +322,7 @@ export function useMobileNativeChatDrafts(args: { holdUnconfirmedSend } } + +function unsentBaseline(ids: string[]): { baselineUnsentMessageIds?: readonly string[] } { + return ids.length > 0 ? { baselineUnsentMessageIds: ids } : {} +} 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..6cb769666399 100644 --- a/mobile/src/session/use-mobile-native-chat-unconfirmed-sends.ts +++ b/mobile/src/session/use-mobile-native-chat-unconfirmed-sends.ts @@ -79,6 +79,9 @@ export function useMobileNativeChatUnconfirmedSends(args: { ...(origin.baselineQueuedMessageIds ? { baselineQueuedMessageIds: origin.baselineQueuedMessageIds } : {}), + ...(origin.baselineUnsentMessageIds + ? { baselineUnsentMessageIds: origin.baselineUnsentMessageIds } + : {}), deadline: null } // Why: the transcript event (or the card) can beat the lost RPC acknowledgement. From 1032f99ed7f0b3fdc43dc64e6fefb43c3e338bdf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:47:36 -0700 Subject: [PATCH 04/27] feat(mobile): draw a recorded, rejected message in place, as the desktop does The phone hid a message the host recorded and then did not deliver, and handed its text back to the composer with a banner. The text then lived only on the phone that sent it: other viewers saw nothing, and the sender's text was gone once the composer changed. The phone now projects these rows in place, with the host's reason in a muted line under the message (the same words the desktop uses). Its own send answers such a rejection like a held send: no banner and no hand-back, because the row holds the text. A retained id that replays its own recorded rejection is sent again once under a fresh id, as a replay a Stop took back already is. A send a Stop withdrew and one kept as a card behave as before. --- .../session/MobileNativeChatMessage.test.ts | 31 +++++ .../src/session/MobileNativeChatMessage.tsx | 11 +- mobile/src/session/MobileNativeChatView.tsx | 25 ++-- ...MobileNativeChatView.unsent-notice.test.ts | 117 ++++++++++++++++++ .../mobile-native-chat-list-header.tsx | 24 ++++ ...native-chat-merged-snapshot-parity.test.ts | 4 +- .../mobile-native-chat-message-styles.ts | 5 + ...-native-chat-stopped-before-start.test.tsx | 2 +- .../mobile-native-chat-unsent-notices.test.ts | 74 +++++++++++ .../mobile-native-chat-unsent-notices.ts | 32 +++++ ...ructured-agent-session-send-bypass.test.ts | 64 ++++++++-- .../mobile-structured-agent-session-send.ts | 48 +++---- .../mobile-structured-send-delivery.test.ts | 36 +++--- .../mobile-structured-send-delivery.ts | 26 ++++ ...chat-turn-disclosure-opening-send.test.tsx | 4 +- ...ile-structured-agent-session-send.test.tsx | 58 +++++++++ .../use-mobile-structured-agent-session.ts | 4 +- ...ssage-projection.rejected-in-place.test.ts | 5 +- 18 files changed, 496 insertions(+), 74 deletions(-) create mode 100644 mobile/src/session/MobileNativeChatView.unsent-notice.test.ts create mode 100644 mobile/src/session/mobile-native-chat-list-header.tsx create mode 100644 mobile/src/session/mobile-native-chat-unsent-notices.test.ts create mode 100644 mobile/src/session/mobile-native-chat-unsent-notices.ts diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index 97796a8a0e7f..1ebb55d296a5 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(() => { @@ -177,6 +178,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: "Orca couldn't reach the agent. Your message was not sent." } + ) + expect(textIn(tree.root)).toEqual([ + 'hello', + "Orca couldn't reach the agent. Your message was not sent." + ]) + }) + + 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 1d61a8117ed4..252561e9e2d9 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -21,6 +21,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' function Prose({ block, @@ -106,7 +107,8 @@ function MobileNativeChatMessageImpl({ structuredActivityUi = false, reasoningIsLive = false, reasoningExpanded, - onToggleReasoning + onToggleReasoning, + unsentNotice }: { message: NativeChatMessage toolsExpanded?: boolean @@ -132,6 +134,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 }): React.JSX.Element { // Another agent's message is set apart from the person's bubble, left-aligned and named. const attribution = agentMessageAttribution('Message from', message.from) @@ -243,6 +247,11 @@ function MobileNativeChatMessageImpl({ /> ) : null} + {isUser && message.unsent === true ? ( + + {unsentNotice ?? AGENT_SESSION_WRITE_NOTICE_COPY.notDoneSend} + + ) : null} {actionsOpen ? ( 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..814147104703 --- /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("Orca couldn't reach the agent. Your message was not sent.") + expect(notice(1)).toBeUndefined() +}) 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.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 a18034fa505d..63d2d785f05a 100644 --- a/mobile/src/session/mobile-native-chat-message-styles.ts +++ b/mobile/src/session/mobile-native-chat-message-styles.ts @@ -70,6 +70,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-stopped-before-start.test.tsx b/mobile/src/session/mobile-native-chat-stopped-before-start.test.tsx index fda3b94f18f4..76ebed07036d 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..5f4b503e06de --- /dev/null +++ b/mobile/src/session/mobile-native-chat-unsent-notices.test.ts @@ -0,0 +1,74 @@ +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("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( + "Orca couldn't reach the agent. Your message was not sent." + ) + }) + + 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('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-send-bypass.test.ts b/mobile/src/session/mobile-structured-agent-session-send-bypass.test.ts index 2652c041b525..e6ac2f915bed 100644 --- a/mobile/src/session/mobile-structured-agent-session-send-bypass.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-send-bypass.test.ts @@ -22,8 +22,12 @@ function ok(result: unknown): RpcResponse { return { id: 'request-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } } } -/** Its own submission, which a Stop took back before the agent started it. */ -function stoppedAnswer(clientMessageId: string): RpcResponse { +/** Its own submission, in that state: delivered, recorded and then rejected, or taken back by a + * Stop before the agent started it. */ +function submissionAnswer( + clientMessageId: string, + answer: 'accepted' | 'recorded' | 'stopped' +): RpcResponse { return ok({ ok: true, replayed: false, @@ -35,9 +39,14 @@ function stoppedAnswer(clientMessageId: string): RpcResponse { clientMessageId, fence: 3, payloadFingerprint: 'fingerprint', - dispatchState: 'rejected', + dispatchState: answer === 'accepted' ? 'accepted' : 'rejected', providerItemId: null, - reason: DISPATCH_REJECTED_CANCELLED, + reason: + answer === 'stopped' + ? DISPATCH_REJECTED_CANCELLED + : answer === 'recorded' + ? 'provider_write_failed: broken pipe' + : null, submittedAt: 10, resolvedAt: 10 } @@ -56,8 +65,10 @@ function queuedAnswer(clientMessageId: string, state: 'waiting' | 'withdrawn'): } /** Each `agentSession.send` answered in turn: a lost answer, a queued draft in that state, or its - * submission stopped. */ -function hostAnswering(answers: readonly ('lost' | 'waiting' | 'withdrawn' | 'stopped')[]) { + * submission in that state. */ +function hostAnswering( + answers: readonly ('lost' | 'waiting' | 'withdrawn' | 'accepted' | 'recorded' | 'stopped')[] +) { const ids: string[] = [] const deliveries: unknown[] = [] const sendRequest = vi.fn(async (_method, params) => { @@ -69,7 +80,9 @@ function hostAnswering(answers: readonly ('lost' | 'waiting' | 'withdrawn' | 'st if (answer === 'lost' || answer === undefined) { throw markRpcDeliveryUnknown(new Error('Connection closed')) } - return answer === 'stopped' ? stoppedAnswer(id) : queuedAnswer(id, answer) + return answer === 'waiting' || answer === 'withdrawn' + ? queuedAnswer(id, answer) + : submissionAnswer(id, answer) }) const client: RpcClient = { sendRequest, @@ -212,4 +225,41 @@ describe('a resend past a saved record storage would not clear', () => { ["Sent, but this phone couldn't update its record of sent messages."] ]) }) + + // Its row says it was not sent, so replaying the kept id would answer that again and do nothing. + it('sends the same text as a new message when its kept id answers as recorded and rejected', async () => { + const { client, ids } = hostAnswering(['lost', 'recorded', 'accepted']) + const onError = vi.fn() + expect(await sendAgain(client, onError, false)).toBe('unknown') + expect(await sendAgain(client, onError, false)).toBe('accepted') + expect(ids).toHaveLength(3) + expect(ids[1]).toBe(ids[0]) + expect(ids[2]).not.toBe(ids[0]) + expect(onError).not.toHaveBeenCalled() + }) + + it('never says a resend went out when the host recorded and rejected it', async () => { + const { client, ids } = hostAnswering(['lost', 'withdrawn', 'recorded']) + const onError = vi.fn() + expect(await sendAgain(client, onError)).toBe('unknown') + asyncStorage.setItem.mockRejectedValue(new Error('disk full')) + asyncStorage.removeItem.mockRejectedValue(new Error('disk full')) + expect(await sendAgain(client, onError)).toBe('queued') + expect(ids).toHaveLength(3) + expect(onError).not.toHaveBeenCalled() + }) + + it('leaves a replay of its own recorded rejection to the row once it was resent', async () => { + const { client, ids } = hostAnswering(['lost', 'recorded', 'lost', 'recorded', 'recorded']) + const onError = vi.fn() + expect(await sendAgain(client, onError, false)).toBe('unknown') + asyncStorage.setItem.mockRejectedValue(new Error('disk full')) + asyncStorage.removeItem.mockRejectedValue(new Error('disk full')) + expect(await sendAgain(client, onError, false)).toBe('unknown') + // The resend's own id replays a recorded rejection: its row holds the text, nothing comes back. + expect(await sendAgain(client, onError, false)).toBe('queued') + expect(ids).toHaveLength(5) + expect(ids[4]).toBe(ids[2]) + expect(onError).not.toHaveBeenCalled() + }) }) diff --git a/mobile/src/session/mobile-structured-agent-session-send.ts b/mobile/src/session/mobile-structured-agent-session-send.ts index c7a2aa356128..38fc6dc03034 100644 --- a/mobile/src/session/mobile-structured-agent-session-send.ts +++ b/mobile/src/session/mobile-structured-agent-session-send.ts @@ -16,6 +16,7 @@ import { import { structuredSessionOperationId } from './structured-session-operation-id' import { mobileStructuredSendDelivery, + mobileStructuredSendRecordedNotSent, mobileStructuredSendWithdrawnBeforeStart } from './mobile-structured-send-delivery' import { @@ -39,13 +40,13 @@ export async function sendMobileStructuredAgentSessionMessage(input: { delivery?: 'queue-if-active' deadline?: number onError: (message: string) => void - /** Internal: the one fresh-id resend after a withdrawn replay. */ - resendingAfterWithdrawal?: true - /** Internal: that resend when the withdrawn id's record could not be cleared. It bypasses the + /** Internal: the one fresh-id resend after a replay that settled without delivering. */ + resendingAfterSettledReplay?: true + /** Internal: that resend when the settled id's record could not be cleared. It bypasses the * record, so failing storage never blocks the send; its id is kept in memory for this app run, * so a retry after a lost answer replays it rather than sending again. */ bypassRetainedRecord?: true - /** Internal: on that resend, the key the withdrawn record matched. The remembered id is keyed + /** Internal: on that resend, the key the settled record matched. The remembered id is keyed * by it, so a capability change since the lost answer never mints another id. */ resendOperationKey?: string }): Promise { @@ -169,37 +170,42 @@ export async function sendMobileStructuredAgentSessionMessage(input: { 'queued' in result.value && result.value.queued?.state === 'withdrawn') || (operation.retained && mobileStructuredSendWithdrawnBeforeStart(result)) - if (withdrawnReplay && operation.retained && !input.resendingAfterWithdrawal) { - // The retained id's draft or submission was withdrawn, so it never reached the agent: - // this identical message is a new one, not a replay to swallow. A record - // storage would not clear is bookkeeping: it is reported, never allowed to + const recordedReplay = operation.retained && mobileStructuredSendRecordedNotSent(result) + if ( + (withdrawnReplay || recordedReplay) && + operation.retained && + !input.resendingAfterSettledReplay + ) { + // The retained id's message never reached the agent (its draft or submission was withdrawn, + // or the host recorded and rejected it): this identical message is a new one, not a replay to + // swallow. A record storage would not clear is bookkeeping: it is reported, never allowed to // block the send. - const resent = await sendMobileStructuredAgentSessionMessage({ + return sendMobileStructuredAgentSessionMessage({ ...input, - resendingAfterWithdrawal: true, + resendingAfterSettledReplay: true, resendOperationKey: operationKey, ...(released ? {} : { bypassRetainedRecord: true as const }) }) - // Only when the resend is known to have gone out; an unconfirmed one may not have. - if (!released && (resent === 'accepted' || resent === 'queued')) { - input.onError("Sent, but this phone couldn't update its record of sent messages.") - } - return resent - } - if (withdrawnReplay && mobileStructuredSendWithdrawnBeforeStart(result)) { - // Not resent, but the chat draws it with its stop row: handing it back too would show it twice. - return 'accepted' } - if (withdrawnReplay) { + if (withdrawnReplay && !mobileStructuredSendWithdrawnBeforeStart(result)) { // Not resent: no card and no bubble holds the text, so it goes back to the // composer rather than vanishing. input.onError('Message not sent') return 'rejected' } + // Not resent, but the chat draws it with its stop row or as not sent: no hand-back as well. + const answer = withdrawnReplay ? 'accepted' : recordedReplay ? 'queued' : outcome.outcome if (outcome.error !== null) { input.onError(outcome.error) } - return outcome.outcome + // Only once the resend is known to have gone out: an unconfirmed one may not have, and one the + // host recorded and rejected shows as not sent. + const wentOut = + answer === 'accepted' || (answer === 'queued' && !mobileStructuredSendRecordedNotSent(result)) + if (input.bypassRetainedRecord && wentOut) { + input.onError("Sent, but this phone couldn't update its record of sent messages.") + } + return answer } /** The id a resend past an uncleared record goes out under: the one this app run already used diff --git a/mobile/src/session/mobile-structured-send-delivery.test.ts b/mobile/src/session/mobile-structured-send-delivery.test.ts index b7e457c7569d..fcd9a74387d8 100644 --- a/mobile/src/session/mobile-structured-send-delivery.test.ts +++ b/mobile/src/session/mobile-structured-send-delivery.test.ts @@ -115,17 +115,23 @@ describe('mobileStructuredSendDelivery', () => { } }) - it('spends the id of a rejection and withholds its internal reason', () => { + it('spends the id of a recorded rejection and leaves the message to its row in the chat', () => { // Provably undelivered and terminal, so the id can only replay it: spending the - // id makes the retry a first delivery. The marker itself names nothing a person - // can act on, so it must not reach the screen. - expect( - mobileStructuredSendDelivery(accepted('rejected', 'provider_write_failed: broken pipe')) - ).toEqual({ - outcome: 'rejected', - operationIdSpent: true, - error: "Orca couldn't reach the agent. Your message was not sent. Send it again." - }) + // id makes the retry a first delivery. The host recorded the message, so its row + // holds it and says why it was not sent: no banner, no hand-back. + for (const reason of ['provider_write_failed: broken pipe', 'Claude does not support .bmp']) { + expect(mobileStructuredSendDelivery(accepted('rejected', reason))).toEqual({ + outcome: 'queued', + operationIdSpent: true, + error: null + }) + // A retained replay cannot be told from a new send of the same text: the caller resends it. + expect(mobileStructuredSendDelivery(accepted('rejected', reason), true)).toEqual({ + outcome: 'rejected', + operationIdSpent: true, + error: null + }) + } }) it('answers a send the host kept as a card like a queued one, first send or replay', () => { @@ -178,16 +184,6 @@ describe('mobileStructuredSendDelivery', () => { } ) - it('shows a provider content rejection verbatim', () => { - expect( - mobileStructuredSendDelivery(accepted('rejected', 'Claude does not support .bmp')) - ).toEqual({ - outcome: 'rejected', - operationIdSpent: true, - error: 'Claude does not support .bmp' - }) - }) - it('spends only refusals that prove the operation is settled', () => { expect( mobileStructuredSendDelivery({ diff --git a/mobile/src/session/mobile-structured-send-delivery.ts b/mobile/src/session/mobile-structured-send-delivery.ts index 32dbc6f99e93..fb173bab59cf 100644 --- a/mobile/src/session/mobile-structured-send-delivery.ts +++ b/mobile/src/session/mobile-structured-send-delivery.ts @@ -18,6 +18,10 @@ // draft. Answering a first send, it is that message: sent, then stopped. As a // retained replay it cannot be told from a new send of the same text, and it // provably never ran, so the caller sends it again under a fresh id. +// recorded, then rejected — the chat draws it as not sent and its row holds the +// text, so it spends the id and answers `queued`: no banner, nothing handed +// back. As a retained replay it cannot be told from a new send of the same +// text, so the caller sends it again under a fresh id. // rejected — a terminal refusal or rejected submission spends a fresh id. A // pending-admission refusal, or any refusal after earlier transport doubt, // keeps it because neither proves a retained delivery did not happen. Two @@ -63,6 +67,23 @@ export function mobileStructuredSendWithdrawnBeforeStart( ) } +/** Whether a send answer is its own submission, which the host recorded and then rejected: the + * chat draws it in place as not sent. One a Stop withdrew or the queue holds is drawn otherwise. */ +export function mobileStructuredSendRecordedNotSent( + result: StructuredAgentSessionMutationCallResult +): boolean { + if (result.status !== 'accepted' || !('submission' in result.value)) { + return false + } + const { submission } = result.value + return ( + submission.dispatchState === 'rejected' && + submission.queuedMessageId === undefined && + submission.keptAsQueuedMessageId === undefined && + !dispatchWasWithdrawn(submission) + ) +} + export function mobileStructuredSendDelivery( result: StructuredAgentSessionMutationCallResult, retained = false @@ -128,6 +149,11 @@ export function mobileStructuredSendDelivery( // retained replay is resent by the caller. return { outcome: retained ? 'rejected' : 'accepted', operationIdSpent: true, error: null } } + if (mobileStructuredSendRecordedNotSent(result)) { + // Its row in the chat holds the text and says why it was not sent, so nothing is handed back + // and no banner repeats it. A retained replay is resent by the caller. + return { outcome: retained ? 'rejected' : 'queued', operationIdSpent: true, error: null } + } if (submission.dispatchState === 'rejected') { return { outcome: 'rejected', 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-structured-agent-session-send.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx index 4fa1d5bd13c0..0e023056655e 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 @@ -6,6 +6,7 @@ import { DISPATCH_REJECTED_CANCELLED } from '../../../src/shared/structured-agen import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key' import { resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal' import { structuredSendResultFixture } from './structured-agent-send-result.test-fixture' import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' @@ -204,6 +205,63 @@ describe('mobile structured send retries', () => { expect(ids[1]).not.toBe(ids[0]) }) + // 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 held: 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')).toBe('queued') + }) + expect(onSendError).not.toHaveBeenCalled() + }) + it('releases an ack-lost id after the journal accepts it for a later identical intent', async () => { let attempts = 0 sendRequest.mockImplementation(async (method) => { diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 6bcc502aaf2b..cb5309d4fd25 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -174,10 +174,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] ) 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 3d649aba1f00..1bbf6a26ee11 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 @@ -465,7 +465,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'), @@ -477,9 +477,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) From cbbab97aa67f77e6a2888c7b9f89ae8c50401206 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:19:03 -0700 Subject: [PATCH 05/27] fix(mobile): settle a structured send by its own row, and name a recorded rejection The phone matched a structured send's bubble, photo preview and unconfirmed hold to the transcript by text and by the row that was newest when it was sent. Once rows the host recorded and rejected stay in the chat, that broke: the host moves a rejected row to where it was rejected, so a later image send's bubble never retired (R1P-1), and another send's not-sent row of the same text cleared this send's bubble early (R1P-3). The host records a send, and draws its row, under the id the phone sent it with. The structured send now returns that id with its outcome, and the bubble, the preview and the hold are settled by that row alone, delivered or not sent, wherever it sits; a hold is also settled by its own queued card. The text guards added for not-sent rows are gone, and the terminal lane's text matching is main's again. A recorded, rejected send now answers its own outcome, 'recorded-unsent', instead of reusing 'queued' (a card above the composer). The bridge echoes it under its id, so a not-sent photo keeps its local preview until its row arrives. --- .../__mocks__/mobile-prompt-controller.ts | 2 +- .../mobile-native-chat-draft-reconcile.ts | 75 ++-- ...mobile-native-chat-not-sent-resend.test.ts | 327 ------------------ .../mobile-native-chat-own-row-echo.test.ts | 259 ++++++++++++++ .../mobile-native-chat-pending-baseline.ts | 7 +- .../mobile-native-chat-pending-echo.ts | 16 +- .../mobile-native-chat-pending-retirement.ts | 41 +-- mobile/src/session/mobile-native-chat-send.ts | 19 +- ...ructured-agent-session-send-bypass.test.ts | 43 ++- .../mobile-structured-agent-session-send.ts | 34 +- .../mobile-structured-send-delivery.test.ts | 2 +- .../mobile-structured-send-delivery.ts | 10 +- .../use-mobile-native-chat-controller.test.ts | 2 +- ...-mobile-native-chat-drafts-own-row.test.ts | 136 ++++++++ ...ile-native-chat-drafts-unsent-hold.test.ts | 110 ------ .../session/use-mobile-native-chat-drafts.ts | 41 ++- ...se-mobile-native-chat-unconfirmed-sends.ts | 15 +- ...e-structured-agent-session-queued.test.tsx | 42 +-- ...ile-structured-agent-session-send.test.tsx | 92 ++--- ...e-mobile-structured-agent-session.test.tsx | 12 +- .../use-mobile-structured-agent-session.ts | 4 +- ...structured-native-chat-send-bridge.test.ts | 30 +- ...bile-structured-native-chat-send-bridge.ts | 32 +- ...use-mobile-structured-send-with-outcome.ts | 21 +- 24 files changed, 700 insertions(+), 672 deletions(-) delete mode 100644 mobile/src/session/mobile-native-chat-not-sent-resend.test.ts create mode 100644 mobile/src/session/mobile-native-chat-own-row-echo.test.ts create mode 100644 mobile/src/session/use-mobile-native-chat-drafts-own-row.test.ts delete mode 100644 mobile/src/session/use-mobile-native-chat-drafts-unsent-hold.test.ts diff --git a/mobile/src/session/__mocks__/mobile-prompt-controller.ts b/mobile/src/session/__mocks__/mobile-prompt-controller.ts index 397c4483ef29..bf09ea192b36 100644 --- a/mobile/src/session/__mocks__/mobile-prompt-controller.ts +++ b/mobile/src/session/__mocks__/mobile-prompt-controller.ts @@ -96,7 +96,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 7c67c83913ff..e7ac73e94d40 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, @@ -18,8 +19,8 @@ export type UnconfirmedSend = { baselineTailMessageId: string | null /** Queued-draft cards on screen at send time; see `findQueuedUnconfirmedSends`. */ baselineQueuedMessageIds?: readonly string[] - /** Rows shown as not sent at send time; see `sendBaselineUnsentMessageIds`. */ - baselineUnsentMessageIds?: readonly string[] + /** Structured lane: settled by its own row or card, never by text; see the pending echo's. */ + clientMessageId?: string deadline: ReturnType | null } @@ -27,32 +28,13 @@ export function normalizedUserText(message: NativeChatMessage): string | null { return normalizedNativeChatUserMessageText(message) } -/** Rows shown as not sent when a send went out. Only these can't be its echo: one that appears - * later is the send's own row, settled as not sent. The count, image and unconfirmed matchers - * all apply this. */ -export function sendBaselineUnsentMessageIds(messages: readonly NativeChatMessage[]): string[] { - return messages.filter((message) => message.unsent === true).map((message) => message.id) -} - -/** The row a send's echo must land after: the newest one, past any shown as not sent, which are in - * the send's baseline and so never its echo. */ -export function sendBaselineTailMessageId(messages: readonly NativeChatMessage[]): string | null { - for (let index = messages.length - 1; index >= 0; index -= 1) { - if (messages[index]?.unsent !== true) { - return messages[index]?.id ?? null - } - } - return null -} - -/** At send time: every row shown as not sent is then in the send's baseline, so none is counted. */ export function countUserTextOccurrences( messages: readonly NativeChatMessage[], text: string ): number { let count = 0 for (const message of messages) { - if (message.unsent !== true && normalizedUserText(message) === text) { + if (normalizedUserText(message) === text) { count++ } } @@ -65,16 +47,13 @@ export function countUserTextOccurrences( * only image echoes keeps an unrelated text send's echo from clearing it. */ export function countImageSourceTurnsAfter( messages: readonly NativeChatMessage[], - tailId: string | null, - /** See `sendBaselineUnsentMessageIds`: one can sit past the tail, never the send's own. */ - baselineUnsentMessageIds: readonly string[] = [] + tailId: string | null ): number { const tailIndex = tailId ? messages.findIndex((message) => message.id === tailId) : -1 - const baselineUnsent = new Set(baselineUnsentMessageIds) let count = 0 for (let i = tailIndex + 1; i < messages.length; i++) { const message = messages[i] - if (message && isImageSourceUserTurn(message) && !baselineUnsent.has(message.id)) { + if (message && isImageSourceUserTurn(message)) { count++ } } @@ -87,8 +66,7 @@ export type PendingImagePreviewEcho = { images?: string[] expectedOccurrence: number baselineTailMessageId: string | null - /** See `sendBaselineUnsentMessageIds`. */ - baselineUnsentMessageIds?: readonly string[] + clientMessageId?: string } export type LandedImagePreviewEcho = { @@ -202,10 +180,16 @@ 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 baselineUnsent = new Set(entry.baselineUnsentMessageIds) const candidates = normalized.filter((message) => { - if (message.role !== 'user' || (message.unsent === true && baselineUnsent.has(message.id))) { + if (message.role !== 'user') { return false } if (targetText) { @@ -261,11 +245,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) @@ -284,7 +269,7 @@ export function findLandedUnconfirmedSends( // (`[Image: source: …]` or no text) keys under '' so an empty-text send can // claim it. const messageIndexById = new Map() - const userMessagesByText = new Map() + const userMessagesByText = new Map>() for (const [index, message] of messages.entries()) { messageIndexById.set(message.id, index) if (message.role !== 'user') { @@ -292,28 +277,28 @@ export function findLandedUnconfirmedSends( } const key = isImageSourceUserTurn(message) ? '' : (normalizedUserText(message) ?? '') const current = userMessagesByText.get(key) ?? [] - current.push({ id: message.id, index, unsent: message.unsent === true }) + current.push({ id: message.id, index }) userMessagesByText.set(key, current) } const claimedMessageIds = new Set() const landed: UnconfirmedSend[] = [] for (const entry of entries) { + if (entry.clientMessageId !== undefined) { + if (messageIndexById.has(agentJournalSubmissionKey(entry.clientMessageId))) { + landed.push(entry) + } + continue + } const tailIndex = entry.baselineTailMessageId ? messageIndexById.get(entry.baselineTailMessageId) : -1 if (tailIndex === undefined) { continue } - const baselineUnsent = new Set(entry.baselineUnsentMessageIds) const echo = userMessagesByText .get(entry.normalizedText) - ?.find( - (message) => - message.index > tailIndex && - !claimedMessageIds.has(message.id) && - !(message.unsent && baselineUnsent.has(message.id)) - ) + ?.find((message) => message.index > tailIndex && !claimedMessageIds.has(message.id)) if (echo) { claimedMessageIds.add(echo.id) landed.push(entry) diff --git a/mobile/src/session/mobile-native-chat-not-sent-resend.test.ts b/mobile/src/session/mobile-native-chat-not-sent-resend.test.ts deleted file mode 100644 index 71503e4e50d4..000000000000 --- a/mobile/src/session/mobile-native-chat-not-sent-resend.test.ts +++ /dev/null @@ -1,327 +0,0 @@ -// The phone draws a message the host recorded and then rejected in place, as not sent. The user's -// way on is to send its text again, as a new message. The phone's own echo of that resend must land -// on the new copy alone, never on the original. - -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, - normalizeReconcileText, - sendBaselineTailMessageId, - sendBaselineUnsentMessageIds -} from './mobile-native-chat-draft-reconcile' -import { rebaseMobileNativeChatPendingBaselines } from './mobile-native-chat-pending-baseline' -import { appendMobileNativeChatPending } from './mobile-native-chat-pending-echo' -import { retireLandedMobileNativeChatPending } from './mobile-native-chat-pending-retirement' - -const TEXT = 'fix the test' - -function user(id: string, sequence: number, text: string): AgentJournalRenderItem { - return { - itemId: agentJournalSubmissionKey(id), - revision: 1, - sequence, - observedAt: sequence, - turnScope: { kind: 'thread' }, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } - } -} - -function answer(id: string, sequence: number, text: string): AgentJournalRenderItem { - return { - itemId: id, - revision: 1, - sequence, - observedAt: sequence, - body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } - } -} - -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 phone( - items: AgentJournalRenderItem[], - submissions: AgentJournalSubmission[] -): NativeChatMessage[] { - return projectStructuredAgentSessionMessages(items, [], submissions, { rejectedInPlace: true }) -} - -const REJECTED = submission('m1', TEXT, 10, { - dispatchState: 'rejected', - providerItemId: null, - reason: 'provider_write_failed: broken pipe', - resolvedAt: 11 -}) -const BEFORE_ITEMS = [answer('hi', 5, 'Hi'), user('m1', 11, TEXT)] -const AFTER_ITEMS = [...BEFORE_ITEMS, user('m2', 20, TEXT), answer('done', 21, 'Done.')] -const AFTER_SUBMISSIONS = [REJECTED, submission('m2', TEXT, 20)] - -describe('resending a not-sent message from the phone', () => { - // The original leaves once the resend is recorded: the projection collapses a copy sent after its - // rejection was known. The echo must not wait for a second copy that is never drawn. - it('ends as the delivered resend alone, with no echo left', () => { - const before = phone(BEFORE_ITEMS, [REJECTED]) - const normalizedText = normalizeReconcileText(TEXT) - const origin = { - draftKey: 'draft', - draftEditGeneration: 0, - pendingKey: 'pending', - normalizedText, - baselineOccurrences: countUserTextOccurrences(before, normalizedText), - baselineTailMessageId: sendBaselineTailMessageId(before), - baselineResolved: true, - baselineUnsentMessageIds: sendBaselineUnsentMessageIds(before) - } - const pending = appendMobileNativeChatPending({}, 'pending', 'pending-1', origin, TEXT).pending - - const after = phone(AFTER_ITEMS, AFTER_SUBMISSIONS) - expect( - after.filter((message) => message.role === 'user').map((message) => message.unsent ?? false) - ).toEqual([false]) - expect(retireLandedMobileNativeChatPending(after, pending ?? [], new Set())).toEqual([]) - }) - - it('sees an ack-lost resend that landed as landed, so no doubt is raised', () => { - const before = phone(BEFORE_ITEMS, [REJECTED]) - const landed = findLandedUnconfirmedSends(phone(AFTER_ITEMS, AFTER_SUBMISSIONS), [ - { - draftKey: 'draft', - pendingKey: 'pending', - text: TEXT, - normalizedText: normalizeReconcileText(TEXT), - baselineTailMessageId: sendBaselineTailMessageId(before), - baselineUnsentMessageIds: sendBaselineUnsentMessageIds(before), - deadline: null - } - ]) - expect(landed).toHaveLength(1) - }) - - it('lets the older not-sent copy settle nothing while the resend is on its way', () => { - const before = phone(BEFORE_ITEMS, [REJECTED]) - const normalizedText = normalizeReconcileText(TEXT) - const origin = { - draftKey: 'draft', - draftEditGeneration: 0, - pendingKey: 'pending', - normalizedText, - baselineOccurrences: countUserTextOccurrences(before, normalizedText), - baselineTailMessageId: sendBaselineTailMessageId(before), - baselineResolved: true, - baselineUnsentMessageIds: sendBaselineUnsentMessageIds(before) - } - const pending = appendMobileNativeChatPending({}, 'pending', 'pending-1', origin, TEXT).pending - expect(retireLandedMobileNativeChatPending(before, pending ?? [], new Set())).toHaveLength(1) - expect(findLandedUnconfirmedSends(before, [{ ...origin, text: TEXT, deadline: null }])).toEqual( - [] - ) - }) -}) - -// The phone's first sight of its own row can already be the rejected one: a catch-up after the -// stream dropped, or a send made before the first read settled. That row settles the send. -describe('a phone send whose own row first appears as not sent', () => { - const OWN_REJECTED = submission('m2', 'later', 20, { - dispatchState: 'rejected', - providerItemId: null, - reason: 'provider_write_failed: broken pipe', - resolvedAt: 21 - }) - const BEFORE = phone(BEFORE_ITEMS, [REJECTED]) - const AFTER = phone([...BEFORE_ITEMS, user('m2', 21, 'later')], [REJECTED, OWN_REJECTED]) - - it('settles an ack-lost send, so no "Delivery unconfirmed" banner follows', () => { - expect( - findLandedUnconfirmedSends(AFTER, [ - { - draftKey: 'draft', - pendingKey: 'pending', - text: 'later', - normalizedText: normalizeReconcileText('later'), - baselineTailMessageId: sendBaselineTailMessageId(BEFORE), - baselineUnsentMessageIds: sendBaselineUnsentMessageIds(BEFORE), - deadline: null - } - ]) - ).toHaveLength(1) - }) - - it("retires an accepted send's echo, so no bubble stays beside its row", () => { - const normalizedText = normalizeReconcileText('later') - const pending = appendMobileNativeChatPending( - {}, - 'pending', - 'pending-1', - { - draftKey: 'draft', - draftEditGeneration: 0, - pendingKey: 'pending', - normalizedText, - baselineOccurrences: countUserTextOccurrences(BEFORE, normalizedText), - baselineTailMessageId: sendBaselineTailMessageId(BEFORE), - baselineResolved: true, - baselineUnsentMessageIds: sendBaselineUnsentMessageIds(BEFORE) - }, - 'later' - ).pending - expect(retireLandedMobileNativeChatPending(AFTER, pending ?? [], new Set())).toEqual([]) - }) -}) - -// A send made before the chat's first read settled takes its boundary from that read, which can -// already hold the send's own row, shown as not sent: the boundary is the row before it. -describe('a phone send made before the first read, whose own row it already shows as not sent', () => { - it('takes the row before it as its boundary, so its own row settles it', () => { - const pending = - appendMobileNativeChatPending( - {}, - 'pending', - 'pending-1', - { - draftKey: 'draft', - draftEditGeneration: 0, - pendingKey: 'pending', - normalizedText: normalizeReconcileText('later'), - baselineOccurrences: 0, - baselineTailMessageId: null, - baselineResolved: false - }, - 'later' - ).pending ?? [] - const read = phone( - [...BEFORE_ITEMS, user('m2', 21, 'later')], - [ - REJECTED, - submission('m2', 'later', 20, { - dispatchState: 'rejected', - providerItemId: null, - reason: 'provider_write_failed: broken pipe', - resolvedAt: 21 - }) - ] - ) - const rebased = rebaseMobileNativeChatPendingBaselines(read, pending) - expect(rebased[0]?.baselineTailMessageId).toBe('hi') - expect(retireLandedMobileNativeChatPending(read, rebased, new Set())).toEqual([]) - }) -}) - -// A captioned image send's placeholder retires only once its local preview binds to its row, so the -// image matcher must apply the same rule: its own not-sent row binds it, an older one doesn't. -describe('a captioned image send whose own row turns not sent', () => { - const CAPTION = 'look at this' - function imageRow(id: string, unsent: boolean): NativeChatMessage { - return { - id, - role: 'user', - source: 'transcript', - timestamp: null, - blocks: [ - { type: 'text', text: CAPTION }, - { type: 'image-ref', path: '/tmp/x.png' } - ], - ...(unsent ? { unsent: true as const } : {}) - } - } - const HELLO: NativeChatMessage = { - id: 'a1', - role: 'assistant', - source: 'transcript', - timestamp: null, - blocks: [{ type: 'text', text: 'Hello' }] - } - - function placeholderLeft(before: NativeChatMessage[], after: NativeChatMessage[]): number { - const normalizedText = normalizeReconcileText(CAPTION) - const pending = - appendMobileNativeChatPending( - {}, - 'pending', - 'pending-1', - { - draftKey: 'draft', - draftEditGeneration: 0, - pendingKey: 'pending', - normalizedText, - baselineOccurrences: countUserTextOccurrences(before, normalizedText), - baselineTailMessageId: sendBaselineTailMessageId(before), - baselineResolved: true, - baselineUnsentMessageIds: sendBaselineUnsentMessageIds(before) - }, - CAPTION, - ['file:///local.png'] - ).pending ?? [] - const landed = findLandedImagePreviewEchoes(after, pending) - return retireLandedMobileNativeChatPending( - after, - pending, - new Set(landed.map((preview) => preview.pendingId)) - ).length - } - - it('retires its placeholder on its own not-sent row', () => { - expect(placeholderLeft([HELLO], [HELLO, imageRow('m2', true)])).toBe(0) - }) - - // An image-only send whose preview is gone is counted by its image turns after the send's tail; - // an older not-sent one can sit past that tail, at its journal place. - it('never counts an older not-sent image turn for a textless send', () => { - const imageOnly = (id: string, unsent: boolean): NativeChatMessage => ({ - ...imageRow(id, unsent), - blocks: [{ type: 'text', text: '[Image: source: /tmp/x.png]' }] - }) - const before = [HELLO, imageOnly('m1', true)] - const pending = - appendMobileNativeChatPending( - {}, - 'pending', - 'pending-1', - { - draftKey: 'draft', - draftEditGeneration: 0, - pendingKey: 'pending', - normalizedText: '', - baselineOccurrences: 0, - baselineTailMessageId: sendBaselineTailMessageId(before), - baselineResolved: true, - baselineUnsentMessageIds: sendBaselineUnsentMessageIds(before) - }, - '' - ).pending ?? [] - expect(retireLandedMobileNativeChatPending(before, pending, new Set())).toHaveLength(1) - expect( - retireLandedMobileNativeChatPending([...before, imageOnly('m2', true)], pending, new Set()) - ).toEqual([]) - }) - - it('keeps it while only a not-sent copy from before the send is there', () => { - const before = [HELLO, imageRow('m1', true)] - expect(placeholderLeft(before, before)).toBe(1) - expect(placeholderLeft(before, [...before, imageRow('m2', true)])).toBe(0) - }) -}) 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..103fba309bcf --- /dev/null +++ b/mobile/src/session/mobile-native-chat-own-row-echo.test.ts @@ -0,0 +1,259 @@ +// A structured send is recorded under the id it went out under, and its row carries that id. The +// phone settles the send's bubble, its photo preview and any unconfirmed hold by that row alone, +// whatever its text, wherever the host places it, delivered or not sent. + +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, + normalizeReconcileText +} from './mobile-native-chat-draft-reconcile' +import { + appendMobileNativeChatPending, + type MobileNativeChatPendingMessage +} from './mobile-native-chat-pending-echo' +import { retireLandedMobileNativeChatPending } from './mobile-native-chat-pending-retirement' + +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 + }) +} + +/** What the phone draws: the journal, with a recorded, rejected send in place as not sent. */ +function phone( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[] +): NativeChatMessage[] { + return projectStructuredAgentSessionMessages(items, [], submissions, { rejectedInPlace: true }) +} + +/** One bubble for a structured send, captured against `before`. */ +function bubble( + before: readonly NativeChatMessage[], + 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, normalizedText), + baselineTailMessageId: before.at(-1)?.id ?? null, + baselineResolved: true + }, + text, + images, + clientMessageId + ).pending ?? [] + ) +} + +/** The bubbles still drawn once `after` is the transcript. */ +function left(after: readonly NativeChatMessage[], pending: MobileNativeChatPendingMessage[]) { + const bound = new Set(findLandedImagePreviewEchoes(after, pending).map((echo) => echo.pendingId)) + return retireLandedMobileNativeChatPending(after, pending, bound).map((item) => item.id) +} + +function held( + before: readonly NativeChatMessage[], + after: readonly NativeChatMessage[], + clientMessageId: string, + text: string +): boolean { + return ( + findLandedUnconfirmedSends(after, [ + { + draftKey: 'draft', + pendingKey: 'pending', + text, + normalizedText: normalizeReconcileText(text), + baselineTailMessageId: before.at(-1)?.id ?? null, + clientMessageId, + deadline: null + } + ]).length === 0 + ) +} + +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('keeps its bubble until its own row arrives, never settled by the original', () => { + const pending = bubble(BEFORE, 'm2', TEXT) + expect(left(BEFORE, pending)).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('retires its bubble and settles its hold on its own row, 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(after.some((message) => message.id === agentJournalSubmissionKey('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.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, 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, 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 is recorded and rejected before the second's row +// arrives. Its row is the first send's own, never the second's (R1P-3). +describe('two quick sends of the same text', () => { + it('retires each bubble on its own row only', () => { + const before = phone([answer('a0', 5)], []) + const pending = [...bubble(before, 'm1', TEXT), ...bubble(before, 'm2', TEXT)] + const firstOnly = phone([answer('a0', 5), said('m1', 11, TEXT)], [rejected('m1', TEXT, 10)]) + const bound = new Set() + const afterFirst = retireLandedMobileNativeChatPending(firstOnly, pending, bound) + expect(afterFirst.map((item) => item.id)).toEqual(['pending-m2']) + // The resend went out once the first was known not sent, so the first's row leaves. + const both = phone( + [answer('a0', 5), said('m1', 11, TEXT), said('m2', 12, TEXT)], + [rejected('m1', TEXT, 10), submission('m2', TEXT, 12)] + ) + expect(retireLandedMobileNativeChatPending(both, afterFirst, bound)).toEqual([]) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-pending-baseline.ts b/mobile/src/session/mobile-native-chat-pending-baseline.ts index be13032fdd90..903bf9d8f324 100644 --- a/mobile/src/session/mobile-native-chat-pending-baseline.ts +++ b/mobile/src/session/mobile-native-chat-pending-baseline.ts @@ -1,8 +1,5 @@ import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { - normalizeReconcileText, - sendBaselineTailMessageId -} from './mobile-native-chat-draft-reconcile' +import { normalizeReconcileText } from './mobile-native-chat-draft-reconcile' import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo' /** @@ -42,7 +39,7 @@ export function rebaseMobileNativeChatPendingBaselines( if (current.every((item) => item.baselineResolved)) { return current } - const baselineTailMessageId = sendBaselineTailMessageId(messages) + const baselineTailMessageId = messages.at(-1)?.id ?? null return current.map((item) => { if (item.baselineResolved) { return item diff --git a/mobile/src/session/mobile-native-chat-pending-echo.ts b/mobile/src/session/mobile-native-chat-pending-echo.ts index 9792d8e8a270..aa9039186b8b 100644 --- a/mobile/src/session/mobile-native-chat-pending-echo.ts +++ b/mobile/src/session/mobile-native-chat-pending-echo.ts @@ -12,8 +12,9 @@ export type MobileNativeChatPendingMessage = { * and rebased onto the first authoritative read instead of reconciling * against rows that may belong to another tab. */ baselineResolved: boolean - /** Rows shown as not sent when it was sent: they can't retire it, its own row can. */ - baselineUnsentMessageIds?: readonly string[] + /** Structured lane: the id the host records the send under. Its row settles it, in any state, + * wherever the host places it; text and position are not consulted. */ + clientMessageId?: string } export type MobileNativeChatSendOrigin = { @@ -27,8 +28,6 @@ export type MobileNativeChatSendOrigin = { /** Queued-draft cards already on screen at send time, so an earlier identical * card cannot confirm this send. Structured lane, on any host that publishes cards. */ baselineQueuedMessageIds?: readonly string[] - /** Rows shown as not sent at send time: an older not-sent copy can't settle this send. */ - baselineUnsentMessageIds?: readonly string[] } type PendingByKey = Record @@ -50,7 +49,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. @@ -77,10 +77,8 @@ export function appendMobileNativeChatPending( : origin.baselineOccurrences + earlierOutstanding + 1, baselineTailMessageId: origin.baselineTailMessageId, baselineResolved: origin.baselineResolved, - ...(origin.baselineUnsentMessageIds - ? { baselineUnsentMessageIds: origin.baselineUnsentMessageIds } - : {}), - ...(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 e763641ede09..d493e33f87cd 100644 --- a/mobile/src/session/mobile-native-chat-pending-retirement.ts +++ b/mobile/src/session/mobile-native-chat-pending-retirement.ts @@ -1,3 +1,4 @@ +import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { countImageSourceTurnsAfter, @@ -36,8 +37,7 @@ export function selectGluedPendingIds( const turns: UserTurn[] = [] for (const [index, message] of messages.entries()) { messageIndexById.set(message.id, index) - // A not-sent row is one refused structured send, never several glued on an input line. - const text = message.unsent === true ? null : normalizedUserText(message) + const text = normalizedUserText(message) if (text) { turns.push({ index, text }) } @@ -53,7 +53,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 === '' || @@ -148,26 +150,14 @@ export function retireLandedMobileNativeChatPending( landedImagePendingIds: ReadonlySet ): MobileNativeChatPendingMessage[] { const landedCounts = new Map() - const unsentByText = new Map() + const messageIds = new Set() for (const message of messages) { + messageIds.add(message.id) const text = normalizedUserText(message) - if (!text) { - continue - } - if (message.unsent === true) { - unsentByText.set(text, [...(unsentByText.get(text) ?? []), message.id]) - } else { + if (text) { landedCounts.set(text, (landedCounts.get(text) ?? 0) + 1) } } - // A not-sent row settles a send only when it appeared after the send: then it is the send's own. - const landedFor = (item: MobileNativeChatPendingMessage, text: string): number => { - const baselineUnsent = new Set(item.baselineUnsentMessageIds) - return ( - (landedCounts.get(text) ?? 0) + - (unsentByText.get(text) ?? []).filter((id) => !baselineUnsent.has(id)).length - ) - } const landedPendingIds = new Set() // Why a separate set: a barrier preserves adjacency after a landing consumed a whole // row. An image landing can share its row with the send glued after it, so treating it @@ -178,6 +168,14 @@ export function retireLandedMobileNativeChatPending( landedPendingIds.add(item.id) continue } + if (item.clientMessageId !== undefined) { + // An image echo waits for its preview to bind to that row, above. + if (!item.images?.length && messageIds.has(agentJournalSubmissionKey(item.clientMessageId))) { + 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. @@ -186,12 +184,9 @@ export function retireLandedMobileNativeChatPending( } const landed = item.text.trim() === '' - ? countImageSourceTurnsAfter( - messages, - item.baselineTailMessageId, - item.baselineUnsentMessageIds - ) >= item.expectedOccurrence - : landedFor(item, normalizeReconcileText(item.text)) >= item.expectedOccurrence + ? countImageSourceTurnsAfter(messages, item.baselineTailMessageId) >= + item.expectedOccurrence + : (landedCounts.get(normalizeReconcileText(item.text)) ?? 0) >= item.expectedOccurrence if (landed) { landedPendingIds.add(item.id) exactLandedIds.add(item.id) 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-structured-agent-session-send-bypass.test.ts b/mobile/src/session/mobile-structured-agent-session-send-bypass.test.ts index e6ac2f915bed..3ea43770ad08 100644 --- a/mobile/src/session/mobile-structured-agent-session-send-bypass.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-send-bypass.test.ts @@ -137,10 +137,10 @@ describe('a resend past a saved record storage would not clear', () => { 'waiting' ]) const onError = vi.fn() - expect(await sendAgain(client, onError)).toBe('unknown') + expect((await sendAgain(client, onError)).outcome).toBe('unknown') asyncStorage.setItem.mockRejectedValue(new Error('disk full')) asyncStorage.removeItem.mockRejectedValue(new Error('disk full')) - expect(await sendAgain(client, onError)).toBe('unknown') + expect((await sendAgain(client, onError)).outcome).toBe('unknown') // Storage recovers: the lost send's record now clears, and the queue capability is gone. asyncStorage.setItem.mockImplementation(async (key: string, value: string) => { stored.set(key, value) @@ -148,7 +148,7 @@ describe('a resend past a saved record storage would not clear', () => { asyncStorage.removeItem.mockImplementation(async (key: string) => { stored.delete(key) }) - expect(await sendAgain(client, onError, false)).toBe('queued') + expect((await sendAgain(client, onError, false)).outcome).toBe('queued') expect(ids).toHaveLength(5) // The resend's id is replayed, as first sent, never replaced by a fresh one. expect(ids[4]).toBe(ids[2]) @@ -172,16 +172,16 @@ describe('a resend past a saved record storage would not clear', () => { delivery: 'queue-if-active', onError }) - expect(await send()).toBe('unknown') + expect((await send()).outcome).toBe('unknown') // From here the lost send's record can never be cleared. asyncStorage.setItem.mockRejectedValue(new Error('disk full')) asyncStorage.removeItem.mockRejectedValue(new Error('disk full')) - expect(await send()).toBe('unknown') + expect((await send()).outcome).toBe('unknown') // An unconfirmed resend may not have gone out, so nothing says it was sent. expect(onError).not.toHaveBeenCalled() - expect(await send()).toBe('queued') + expect((await send()).outcome).toBe('queued') expect(ids).toHaveLength(5) expect(ids[1]).toBe(ids[0]) expect(ids[3]).toBe(ids[0]) @@ -198,12 +198,12 @@ describe('a resend past a saved record storage would not clear', () => { it('sends a withdrawn replay once more at most, then hands a queued draft back', async () => { const { client, ids } = hostAnswering(['lost', 'withdrawn', 'lost', 'withdrawn', 'withdrawn']) const onError = vi.fn() - expect(await sendAgain(client, onError)).toBe('unknown') + expect((await sendAgain(client, onError)).outcome).toBe('unknown') asyncStorage.setItem.mockRejectedValue(new Error('disk full')) asyncStorage.removeItem.mockRejectedValue(new Error('disk full')) - expect(await sendAgain(client, onError)).toBe('unknown') + expect((await sendAgain(client, onError)).outcome).toBe('unknown') - expect(await sendAgain(client, onError)).toBe('rejected') + expect((await sendAgain(client, onError)).outcome).toBe('rejected') expect(ids).toHaveLength(5) expect(ids[4]).toBe(ids[2]) expect(onError).toHaveBeenCalledWith('Message not sent') @@ -212,12 +212,12 @@ describe('a resend past a saved record storage would not clear', () => { it('reads a resend a Stop took back again as sent, since the chat draws it, not as not sent', async () => { const { client, ids } = hostAnswering(['lost', 'stopped', 'lost', 'stopped', 'stopped']) const onError = vi.fn() - expect(await sendAgain(client, onError, false)).toBe('unknown') + expect((await sendAgain(client, onError, false)).outcome).toBe('unknown') asyncStorage.setItem.mockRejectedValue(new Error('disk full')) asyncStorage.removeItem.mockRejectedValue(new Error('disk full')) - expect(await sendAgain(client, onError, false)).toBe('unknown') + expect((await sendAgain(client, onError, false)).outcome).toBe('unknown') - expect(await sendAgain(client, onError, false)).toBe('accepted') + expect((await sendAgain(client, onError, false)).outcome).toBe('accepted') expect(ids).toHaveLength(5) expect(ids[4]).toBe(ids[2]) // Only the storage failure is reported; nothing says the message was not sent. @@ -230,9 +230,14 @@ describe('a resend past a saved record storage would not clear', () => { it('sends the same text as a new message when its kept id answers as recorded and rejected', async () => { const { client, ids } = hostAnswering(['lost', 'recorded', 'accepted']) const onError = vi.fn() - expect(await sendAgain(client, onError, false)).toBe('unknown') - expect(await sendAgain(client, onError, false)).toBe('accepted') + expect(await sendAgain(client, onError, false)).toEqual({ + outcome: 'unknown', + clientMessageId: ids[0] + }) + // The answer names the fresh id, so the phone waits for that row, not the not-sent one. + const resent = await sendAgain(client, onError, false) expect(ids).toHaveLength(3) + expect(resent).toEqual({ outcome: 'accepted', clientMessageId: ids[2] }) expect(ids[1]).toBe(ids[0]) expect(ids[2]).not.toBe(ids[0]) expect(onError).not.toHaveBeenCalled() @@ -241,10 +246,10 @@ describe('a resend past a saved record storage would not clear', () => { it('never says a resend went out when the host recorded and rejected it', async () => { const { client, ids } = hostAnswering(['lost', 'withdrawn', 'recorded']) const onError = vi.fn() - expect(await sendAgain(client, onError)).toBe('unknown') + expect((await sendAgain(client, onError)).outcome).toBe('unknown') asyncStorage.setItem.mockRejectedValue(new Error('disk full')) asyncStorage.removeItem.mockRejectedValue(new Error('disk full')) - expect(await sendAgain(client, onError)).toBe('queued') + expect((await sendAgain(client, onError)).outcome).toBe('recorded-unsent') expect(ids).toHaveLength(3) expect(onError).not.toHaveBeenCalled() }) @@ -252,12 +257,12 @@ describe('a resend past a saved record storage would not clear', () => { it('leaves a replay of its own recorded rejection to the row once it was resent', async () => { const { client, ids } = hostAnswering(['lost', 'recorded', 'lost', 'recorded', 'recorded']) const onError = vi.fn() - expect(await sendAgain(client, onError, false)).toBe('unknown') + expect((await sendAgain(client, onError, false)).outcome).toBe('unknown') asyncStorage.setItem.mockRejectedValue(new Error('disk full')) asyncStorage.removeItem.mockRejectedValue(new Error('disk full')) - expect(await sendAgain(client, onError, false)).toBe('unknown') + expect((await sendAgain(client, onError, false)).outcome).toBe('unknown') // The resend's own id replays a recorded rejection: its row holds the text, nothing comes back. - expect(await sendAgain(client, onError, false)).toBe('queued') + expect((await sendAgain(client, onError, false)).outcome).toBe('recorded-unsent') expect(ids).toHaveLength(5) expect(ids[4]).toBe(ids[2]) expect(onError).not.toHaveBeenCalled() diff --git a/mobile/src/session/mobile-structured-agent-session-send.ts b/mobile/src/session/mobile-structured-agent-session-send.ts index 38fc6dc03034..74d126b87340 100644 --- a/mobile/src/session/mobile-structured-agent-session-send.ts +++ b/mobile/src/session/mobile-structured-agent-session-send.ts @@ -28,6 +28,19 @@ import { rememberBypassedMobileStructuredSendOperation } from './mobile-structured-send-operation-journal' +/** A structured send's answer, with the id it went out under: the host records the send, and + * draws its row, under that id. Null when nothing went out. */ +export type MobileStructuredSendResult = { + outcome: MobileNativeChatSendOutcome + clientMessageId: string | null +} + +/** Refused before anything went out. */ +export const MOBILE_STRUCTURED_SEND_NOT_SENT: MobileStructuredSendResult = { + outcome: 'rejected', + clientMessageId: null +} + export async function sendMobileStructuredAgentSessionMessage(input: { client: RpcClient sessionId: string @@ -49,11 +62,11 @@ export async function sendMobileStructuredAgentSessionMessage(input: { /** Internal: on that resend, the key the settled record matched. The remembered id is keyed * by it, so a capability change since the lost answer never mints another id. */ resendOperationKey?: string -}): Promise { +}): Promise { const timeoutMs = timeoutForDeadline(input.deadline) if (timeoutMs === null) { input.onError('Message not sent') - return 'rejected' + return MOBILE_STRUCTURED_SEND_NOT_SENT } const requestedBody = structuredAgentSessionSendBody(input.text, input.attachments) const requestedPayloadFingerprint = structuredAgentSessionPayloadFingerprint({ @@ -119,7 +132,7 @@ export async function sendMobileStructuredAgentSessionMessage(input: { } } catch { input.onError('Message not sent') - return 'rejected' + return MOBILE_STRUCTURED_SEND_NOT_SENT } const operationKey = operation.operationKey const delivery = operationKey === queuedOperationKey ? 'queue-if-active' : undefined @@ -134,7 +147,7 @@ export async function sendMobileStructuredAgentSessionMessage(input: { }) if (payloadFingerprint !== operation.payloadFingerprint) { input.onError('Message not sent') - return 'rejected' + return MOBILE_STRUCTURED_SEND_NOT_SENT } const result = await requestStructuredAgentSessionMutation({ client: input.client, @@ -191,21 +204,18 @@ export async function sendMobileStructuredAgentSessionMessage(input: { // Not resent: no card and no bubble holds the text, so it goes back to the // composer rather than vanishing. input.onError('Message not sent') - return 'rejected' + return MOBILE_STRUCTURED_SEND_NOT_SENT } // Not resent, but the chat draws it with its stop row or as not sent: no hand-back as well. - const answer = withdrawnReplay ? 'accepted' : recordedReplay ? 'queued' : outcome.outcome + const answer = withdrawnReplay ? 'accepted' : recordedReplay ? 'recorded-unsent' : outcome.outcome if (outcome.error !== null) { input.onError(outcome.error) } - // Only once the resend is known to have gone out: an unconfirmed one may not have, and one the - // host recorded and rejected shows as not sent. - const wentOut = - answer === 'accepted' || (answer === 'queued' && !mobileStructuredSendRecordedNotSent(result)) - if (input.bypassRetainedRecord && wentOut) { + // Only once the resend is known to have gone out: an unconfirmed one may not have. + if (input.bypassRetainedRecord && (answer === 'accepted' || answer === 'queued')) { input.onError("Sent, but this phone couldn't update its record of sent messages.") } - return answer + return { outcome: answer, clientMessageId: operation.operationId } } /** The id a resend past an uncleared record goes out under: the one this app run already used diff --git a/mobile/src/session/mobile-structured-send-delivery.test.ts b/mobile/src/session/mobile-structured-send-delivery.test.ts index fcd9a74387d8..3696d905af1b 100644 --- a/mobile/src/session/mobile-structured-send-delivery.test.ts +++ b/mobile/src/session/mobile-structured-send-delivery.test.ts @@ -121,7 +121,7 @@ describe('mobileStructuredSendDelivery', () => { // holds it and says why it was not sent: no banner, no hand-back. for (const reason of ['provider_write_failed: broken pipe', 'Claude does not support .bmp']) { expect(mobileStructuredSendDelivery(accepted('rejected', reason))).toEqual({ - outcome: 'queued', + outcome: 'recorded-unsent', operationIdSpent: true, error: null }) diff --git a/mobile/src/session/mobile-structured-send-delivery.ts b/mobile/src/session/mobile-structured-send-delivery.ts index fb173bab59cf..94e1e2cd6da8 100644 --- a/mobile/src/session/mobile-structured-send-delivery.ts +++ b/mobile/src/session/mobile-structured-send-delivery.ts @@ -19,8 +19,8 @@ // retained replay it cannot be told from a new send of the same text, and it // provably never ran, so the caller sends it again under a fresh id. // recorded, then rejected — the chat draws it as not sent and its row holds the -// text, so it spends the id and answers `queued`: no banner, nothing handed -// back. As a retained replay it cannot be told from a new send of the same +// text, so it spends the id and answers `recorded-unsent`: no banner, nothing +// handed back. As a retained replay it cannot be told from a new send of the same // text, so the caller sends it again under a fresh id. // rejected — a terminal refusal or rejected submission spends a fresh id. A // pending-admission refusal, or any refusal after earlier transport doubt, @@ -152,7 +152,11 @@ export function mobileStructuredSendDelivery( if (mobileStructuredSendRecordedNotSent(result)) { // Its row in the chat holds the text and says why it was not sent, so nothing is handed back // and no banner repeats it. A retained replay is resent by the caller. - return { outcome: retained ? 'rejected' : 'queued', operationIdSpent: true, error: null } + return { + outcome: retained ? 'rejected' : 'recorded-unsent', + operationIdSpent: true, + error: null + } } if (submission.dispatchState === 'rejected') { return { 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..64aa4aa37c15 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -221,7 +221,7 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { }) resetMobileNativeChatStaleInputForTests() captureSendOrigin.mockReturnValue(ORIGIN) - structuredSendWithOutcome.mockResolvedValue('accepted') + structuredSendWithOutcome.mockResolvedValue({ outcome: 'accepted', clientMessageId: 'op-1' }) act(() => { renderer = create(createElement(Harness)) }) 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..2551502bbb7c --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-drafts-own-row.test.ts @@ -0,0 +1,136 @@ +// A structured send's bubble and its unconfirmed hold are settled by the row the host records under +// the send's own id, delivered or not sent, and never by another row with the same 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 { 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 } : {}) + } +} + +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[] }): null { + state = useMobileNativeChatDrafts({ + hostId: 'host', + worktreeId: 'worktree', + tabId: 'a', + sessionId: 'session-a', + messages: props.messages, + launchDraft: null, + transcriptLoading: false, + transcriptSettled: true, + queuedCards: props.queuedCards + }) + return null + } + + async function render(messages: NativeChatMessage[], queuedCards: QueuedCard[] = []) { + await act(async () => { + const element = createElement(Harness, { messages, queuedCards }) + 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)] + + 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')]) + 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)]) + expect(settle()).not.toHaveBeenCalled() + }) + + 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')]) + 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() + }) + + 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-unsent-hold.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-unsent-hold.test.ts deleted file mode 100644 index f6676057b640..000000000000 --- a/mobile/src/session/use-mobile-native-chat-drafts-unsent-hold.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -// An ack-lost send is settled by its own row, even one the host already shows as not sent, and never -// by a not-sent row of the same text that was on screen when it went out. - -import { createElement } from 'react' -import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' - -function user(id: string, text: string, unsent = false): NativeChatMessage { - return { - id, - role: 'user', - source: 'transcript', - timestamp: null, - blocks: [{ type: 'text', text }], - ...(unsent ? { unsent: true as const } : {}) - } -} - -const ANSWER: NativeChatMessage = { - id: 'answer', - role: 'assistant', - source: 'transcript', - timestamp: null, - blocks: [{ type: 'text', text: 'Hi' }] -} - -describe('useMobileNativeChatDrafts unconfirmed hold beside not-sent rows', () => { - 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({ messages }: { messages: NativeChatMessage[] }): null { - state = useMobileNativeChatDrafts({ - hostId: 'host', - worktreeId: 'worktree', - tabId: 'a', - sessionId: 'session-a', - messages, - launchDraft: null, - transcriptLoading: false, - transcriptSettled: true, - queuedCards: [] - }) - return null - } - - async function render(messages: NativeChatMessage[]): Promise { - await act(async () => { - if (renderer) { - renderer.update(createElement(Harness, { messages })) - } else { - renderer = create(createElement(Harness, { messages })) - } - }) - } - - /** Holds a lost send; the returned call runs out its deadline and hands back the warning spy. */ - function holdLostSend(text: string): () => ReturnType { - const onUnconfirmed = vi.fn() - const origin = state?.captureSendOrigin(text) - if (!origin) { - throw new Error('no send origin') - } - act(() => state?.holdUnconfirmedSend(origin, text, onUnconfirmed)) - return () => { - act(() => { - vi.advanceTimersByTime(30_000) - }) - return onUnconfirmed - } - } - - const BEFORE = [user('m1', 'fix the test', true), ANSWER] - - it('stays quiet when its own row arrives already shown as not sent', async () => { - await render(BEFORE) - const settle = holdLostSend('fix the test') - await render([...BEFORE, user('m2', 'fix the test', true)]) - expect(settle()).not.toHaveBeenCalled() - }) - - // Once the resend is recorded, the projection drops the not-sent original it copies. - it('stays quiet when its resend replaces the not-sent original it was sent after', async () => { - const before = [ANSWER, user('m1', 'fix the test', true)] - await render(before) - const settle = holdLostSend('fix the test') - await render([ANSWER, user('m2', 'fix the test')]) - expect(settle()).not.toHaveBeenCalled() - }) - - it('still warns when only the older not-sent copy of its text is there', async () => { - const before = [ANSWER, user('m1', 'fix the test', true)] - await render(before) - const settle = holdLostSend('fix the test') - await render([...before]) - expect(settle()).toHaveBeenCalledTimes(1) - }) -}) diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index d0559d47ad11..b752f7b84add 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -11,8 +11,6 @@ import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { appendReturnedDraftText } from '../../../src/shared/returned-draft-text' import { countUserTextOccurrences, - sendBaselineTailMessageId, - sendBaselineUnsentMessageIds, findLandedImagePreviewEchoes, mergeLandedImagePreviewEchoes, migrateImagePreviewMessageIds, @@ -82,11 +80,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, so its row alone 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 { @@ -177,8 +182,7 @@ export function useMobileNativeChatDrafts(args: { pendingKey, normalizedText, baselineOccurrences: countUserTextOccurrences(messagesRef.current, normalizedText), - baselineTailMessageId: sendBaselineTailMessageId(messagesRef.current), - ...unsentBaseline(sendBaselineUnsentMessageIds(messagesRef.current)), + baselineTailMessageId: messagesRef.current.at(-1)?.id ?? null, // Only a settled read makes this a boundary. Anything else — hydrating, // or a read that failed — hands back an empty list that reads as "the // conversation was empty", which lets any row claim this send later. @@ -213,7 +217,12 @@ 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 } @@ -222,11 +231,19 @@ export function useMobileNativeChatDrafts(args: { const key = origin.pendingKey if (key) { setPendingBySession((previous) => - appendMobileNativeChatPending(previous, key, id, origin, text, images) + appendMobileNativeChatPending(previous, key, id, origin, text, images, clientMessageId) ) } else { setPendingWaitingForSession((previous) => - appendMobileNativeChatPending(previous, origin.draftKey, id, origin, text, images) + appendMobileNativeChatPending( + previous, + origin.draftKey, + id, + origin, + text, + images, + clientMessageId + ) ) } }, @@ -276,7 +293,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) { @@ -322,7 +339,3 @@ export function useMobileNativeChatDrafts(args: { holdUnconfirmedSend } } - -function unsentBaseline(ids: string[]): { baselineUnsentMessageIds?: readonly string[] } { - return ids.length > 0 ? { baselineUnsentMessageIds: ids } : {} -} 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 6cb769666399..4a8d64e9a960 100644 --- a/mobile/src/session/use-mobile-native-chat-unconfirmed-sends.ts +++ b/mobile/src/session/use-mobile-native-chat-unconfirmed-sends.ts @@ -45,7 +45,9 @@ export function useMobileNativeChatUnconfirmedSends(args: { holdUnconfirmedSend: ( origin: MobileNativeChatSendOrigin, text: string, - onUnconfirmed: () => void + onUnconfirmed: () => void, + /** Structured lane: the id the send went out under; its row or card settles the hold. */ + clientMessageId?: string ) => void } { const { draftKey, pendingKey, messages, queuedCards = NO_QUEUED_CARDS } = args @@ -63,7 +65,12 @@ export function useMobileNativeChatUnconfirmedSends(args: { 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,9 +86,7 @@ export function useMobileNativeChatUnconfirmedSends(args: { ...(origin.baselineQueuedMessageIds ? { baselineQueuedMessageIds: origin.baselineQueuedMessageIds } : {}), - ...(origin.baselineUnsentMessageIds - ? { baselineUnsentMessageIds: origin.baselineUnsentMessageIds } - : {}), + ...(clientMessageId !== undefined ? { clientMessageId } : {}), deadline: null } // Why: the transcript event (or the card) can beat the lost RPC acknowledgement. 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 c14e823ab401..aa8cebc72ec9 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 @@ -156,7 +156,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') @@ -194,7 +194,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) @@ -218,7 +218,7 @@ describe('mobile structured queued messages', () => { }) await mountSession(CAPABLE) await act(async () => { - expect(await hook!.sendWithOutcome('retry me')).toBe('unknown') + expect((await hook!.sendWithOutcome('retry me')).outcome).toBe('unknown') }) unmountSession() // The capability probe has not answered after the reload, but the recorded @@ -226,7 +226,7 @@ describe('mobile structured queued messages', () => { // delivery field — or the host would refuse it as a fingerprint conflict. await mountSession(LEGACY) await act(async () => { - expect(await hook!.sendWithOutcome('retry me')).toBe('unknown') + expect((await hook!.sendWithOutcome('retry me')).outcome).toBe('unknown') }) expect(attempts).toBe(2) const first = requestOf('agentSession.send', 0) @@ -273,13 +273,13 @@ describe('mobile structured queued messages', () => { }) await mountSession(CAPABLE) await act(async () => { - expect(await hook!.sendWithOutcome('downgraded')).toBe('unknown') + expect((await hook!.sendWithOutcome('downgraded')).outcome).toBe('unknown') }) unmountSession() await mountSession(LEGACY) // A lost answer is still doubt: the replay keeps the id and its delivery. await act(async () => { - expect(await hook!.sendWithOutcome('downgraded')).toBe('unknown') + expect((await hook!.sendWithOutcome('downgraded')).outcome).toBe('unknown') }) const first = requestOf('agentSession.send', 0) expect(requestOf('agentSession.send', 1).envelope.clientOperationId).toBe( @@ -288,14 +288,14 @@ describe('mobile structured queued messages', () => { expect(stored.get(journalKey)).toContain(String(first.envelope.clientOperationId)) // The host answering that it cannot take the request retires the entry, once. await act(async () => { - expect(await hook!.sendWithOutcome('downgraded')).toBe('rejected') + expect((await hook!.sendWithOutcome('downgraded')).outcome).toBe('rejected') }) const refused = requestOf('agentSession.send', 2) expect(refused.params.delivery).toBe('queue-if-active') expect(refused.envelope.clientOperationId).toBe(first.envelope.clientOperationId) expect(onSendError).toHaveBeenCalledTimes(1) await act(async () => { - expect(await hook!.sendWithOutcome('downgraded')).toBe('accepted') + expect((await hook!.sendWithOutcome('downgraded')).outcome).toBe('accepted') }) const plain = requestOf('agentSession.send', 3) expect('delivery' in plain.params).toBe(false) @@ -322,10 +322,10 @@ describe('mobile structured queued messages', () => { }) await mountSession(CAPABLE) await act(async () => { - expect(await hook!.sendWithOutcome('in doubt')).toBe('unknown') + expect((await hook!.sendWithOutcome('in doubt')).outcome).toBe('unknown') }) await act(async () => { - expect(await hook!.sendWithOutcome('in doubt')).toBe('rejected') + expect((await hook!.sendWithOutcome('in doubt')).outcome).toBe('rejected') }) await act(async () => { await hook!.sendWithOutcome('in doubt') @@ -355,7 +355,7 @@ 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 operationId = String(requestOf('agentSession.send').envelope.clientOperationId) expect(stored.get(journalKey)).toContain(operationId) @@ -369,7 +369,7 @@ describe('mobile structured queued messages', () => { // Deleted elsewhere: no submission will ever settle it, and nothing has to. act(() => listener?.(batchEvent(null))) await act(async () => { - expect(await hook!.sendWithOutcome('held')).toBe('queued') + expect((await hook!.sendWithOutcome('held')).outcome).toBe('queued') }) expect(requestOf('agentSession.send', 1).envelope.clientOperationId).not.toBe(operationId) }) @@ -397,12 +397,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') }) // A Delete spent the ack-lost draft before it reached the agent; typing the // same words again is a new message, not a replay to swallow. await act(async () => { - expect(await hook!.sendWithOutcome('again')).toBe('queued') + expect((await hook!.sendWithOutcome('again')).outcome).toBe('queued') }) expect(attempts).toBe(3) const ids = [0, 1, 2].map( @@ -430,13 +430,13 @@ 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') }) // The retained record cannot be cleared; that must not keep this text from being sent. asyncStorage.setItem.mockRejectedValue(new Error('disk full')) asyncStorage.removeItem.mockRejectedValue(new Error('disk full')) await act(async () => { - expect(await hook!.sendWithOutcome('again')).toBe('queued') + expect((await hook!.sendWithOutcome('again')).outcome).toBe('queued') }) expect(attempts).toBe(3) const ids = [0, 1, 2].map( @@ -484,7 +484,7 @@ describe('mobile structured queued messages', () => { it('spends the record once the stream carries the hand-off naming it; a direct send does not', async () => { await mountSession(CAPABLE) await act(async () => { - expect(await hook!.sendWithOutcome('again')).toBe('unknown') + expect((await hook!.sendWithOutcome('again')).outcome).toBe('unknown') }) expect(stored.get(journalKey)).toContain(lostId) // A direct send's submission names no draft, so it settles nothing here. @@ -495,7 +495,7 @@ describe('mobile structured queued messages', () => { act(() => listener?.(batchEvent(undefined, [acceptedSubmission('fresh-hand-off', lostId)]))) await vi.waitFor(() => expect(stored.has(journalKey)).toBe(false)) await act(async () => { - expect(await hook!.sendWithOutcome('again')).toBe('queued') + expect((await hook!.sendWithOutcome('again')).outcome).toBe('queued') }) expect(attempts).toBe(2) expect(requestOf('agentSession.send', 1).envelope.clientOperationId).not.toBe(lostId) @@ -504,17 +504,17 @@ describe('mobile structured queued messages', () => { it('spends the record from a replay answered by the hand-off, which no page carries', async () => { await mountSession(CAPABLE) await act(async () => { - expect(await hook!.sendWithOutcome('again')).toBe('unknown') + expect((await hook!.sendWithOutcome('again')).outcome).toBe('unknown') }) // The host holds that message now; the phone paints no bubble for it. The stream never // carries the hand-off, so the answer's link is what spends the record. await act(async () => { - expect(await hook!.sendWithOutcome('again')).toBe('unknown') + expect((await hook!.sendWithOutcome('again')).outcome).toBe('unknown') }) expect(requestOf('agentSession.send', 1).envelope.clientOperationId).toBe(lostId) await vi.waitFor(() => expect(stored.has(journalKey)).toBe(false)) await act(async () => { - expect(await hook!.sendWithOutcome('again')).toBe('queued') + expect((await hook!.sendWithOutcome('again')).outcome).toBe('queued') }) expect(requestOf('agentSession.send', 2).envelope.clientOperationId).not.toBe(lostId) }) 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 0e023056655e..5fddf8fc0ac8 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 @@ -143,9 +143,9 @@ describe('mobile structured send retries', () => { await mountSession() await act(async () => { - expect(await hook!.sendWithOutcome('retry me')).toBe('unknown') - expect(await hook!.sendWithOutcome('retry me')).toBe('unknown') - expect(await hook!.sendWithOutcome('retry me')).toBe('unknown') + expect((await hook!.sendWithOutcome('retry me')).outcome).toBe('unknown') + expect((await hook!.sendWithOutcome('retry me')).outcome).toBe('unknown') + expect((await hook!.sendWithOutcome('retry me')).outcome).toBe('unknown') }) expect(calls()).toHaveLength(3) @@ -172,8 +172,8 @@ describe('mobile structured send retries', () => { await mountSession() await act(async () => { - expect(await hook!.sendWithOutcome('stopped one')).toBe('unknown') - expect(await hook!.sendWithOutcome('stopped one')).toBe('accepted') + expect((await hook!.sendWithOutcome('stopped one')).outcome).toBe('unknown') + expect((await hook!.sendWithOutcome('stopped one')).outcome).toBe('accepted') }) expect(onSendError).not.toHaveBeenCalled() @@ -195,8 +195,8 @@ describe('mobile structured send retries', () => { await mountSession() await act(async () => { - expect(await hook!.sendWithOutcome('stopped first')).toBe('accepted') - expect(await hook!.sendWithOutcome('stopped first')).toBe('accepted') + expect((await hook!.sendWithOutcome('stopped first')).outcome).toBe('accepted') + expect((await hook!.sendWithOutcome('stopped first')).outcome).toBe('accepted') }) expect(onSendError).not.toHaveBeenCalled() @@ -246,7 +246,7 @@ describe('mobile structured send retries', () => { }) // The transcript owns a message the host recorded, so the composer never gets it back. - it('answers a recorded, rejected send as held: no banner and no hand-back', async () => { + 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') @@ -257,7 +257,9 @@ describe('mobile structured send retries', () => { await mountSession() await act(async () => { - expect(await hook!.sendWithOutcome('recorded, then rejected')).toBe('queued') + expect((await hook!.sendWithOutcome('recorded, then rejected')).outcome).toBe( + 'recorded-unsent' + ) }) expect(onSendError).not.toHaveBeenCalled() }) @@ -275,7 +277,7 @@ describe('mobile structured send retries', () => { }) await mountSession() await act(async () => { - expect(await hook!.sendWithOutcome('same text, later intent')).toBe('unknown') + expect((await hook!.sendWithOutcome('same text, later intent')).outcome).toBe('unknown') }) const firstRequest = calls()[0]![1] as { envelope: { clientOperationId: string; payloadFingerprint: string } @@ -299,7 +301,7 @@ describe('mobile structured send retries', () => { await vi.waitFor(() => expect(storedOperations.size).toBe(0)) await act(async () => { - expect(await hook!.sendWithOutcome('same text, later intent')).toBe('accepted') + expect((await hook!.sendWithOutcome('same text, later intent')).outcome).toBe('accepted') }) expect(sentIds()).toHaveLength(2) @@ -319,7 +321,7 @@ describe('mobile structured send retries', () => { }) 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 @@ -328,7 +330,7 @@ describe('mobile structured send retries', () => { 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(1) @@ -359,8 +361,8 @@ describe('mobile structured send retries', () => { await mountSession() await act(async () => { - expect(await hook!.sendWithOutcome('possibly delivered')).toBe('unknown') - expect(await hook!.sendWithOutcome('possibly delivered')).toBe('unknown') + expect((await hook!.sendWithOutcome('possibly delivered')).outcome).toBe('unknown') + expect((await hook!.sendWithOutcome('possibly delivered')).outcome).toBe('unknown') }) expect(calls()).toHaveLength(2) @@ -384,22 +386,26 @@ describe('mobile structured send retries', () => { await act(async () => { expect( - await hook!.sendWithOutcome('describe', undefined, undefined, [ - { - path: '/tmp/original.png', - previewUri: 'file:///photo.jpg', - contentFingerprint - } - ]) + ( + await hook!.sendWithOutcome('describe', undefined, undefined, [ + { + path: '/tmp/original.png', + previewUri: 'file:///photo.jpg', + contentFingerprint + } + ]) + ).outcome ).toBe('unknown') expect( - await hook!.sendWithOutcome('describe', undefined, undefined, [ - { - path: '/tmp/reuploaded.png', - previewUri: 'file:///photo.jpg', - contentFingerprint - } - ]) + ( + await hook!.sendWithOutcome('describe', undefined, undefined, [ + { + path: '/tmp/reuploaded.png', + previewUri: 'file:///photo.jpg', + contentFingerprint + } + ]) + ).outcome ).toBe('unknown') }) @@ -431,8 +437,8 @@ describe('mobile structured send retries', () => { await mountSession() await act(async () => { - expect(await hook!.sendWithOutcome('never reached the handler')).toBe('rejected') - expect(await hook!.sendWithOutcome('never reached the handler')).toBe('accepted') + expect((await hook!.sendWithOutcome('never reached the handler')).outcome).toBe('rejected') + expect((await hook!.sendWithOutcome('never reached the handler')).outcome).toBe('accepted') }) expect(sentIds()).toHaveLength(2) @@ -461,8 +467,8 @@ describe('mobile structured send retries', () => { await mountSession() await act(async () => { - expect(await hook!.sendWithOutcome('retry at the current fence')).toBe('rejected') - expect(await hook!.sendWithOutcome('retry at the current fence')).toBe('unknown') + expect((await hook!.sendWithOutcome('retry at the current fence')).outcome).toBe('rejected') + expect((await hook!.sendWithOutcome('retry at the current fence')).outcome).toBe('unknown') }) expect(sentIds()).toHaveLength(2) @@ -492,9 +498,9 @@ describe('mobile structured send retries', () => { await mountSession() await act(async () => { - expect(await hook!.sendWithOutcome('old host replay')).toBe('unknown') - expect(await hook!.sendWithOutcome('old host replay')).toBe('unknown') - expect(await hook!.sendWithOutcome('old host replay')).toBe('unknown') + expect((await hook!.sendWithOutcome('old host replay')).outcome).toBe('unknown') + expect((await hook!.sendWithOutcome('old host replay')).outcome).toBe('unknown') + expect((await hook!.sendWithOutcome('old host replay')).outcome).toBe('unknown') }) expect(new Set(sentIds()).size).toBe(1) @@ -525,9 +531,9 @@ describe('mobile structured send retries', () => { await mountSession() await act(async () => { - expect(await hook!.sendWithOutcome('old ambiguity')).toBe('unknown') - expect(await hook!.sendWithOutcome('old ambiguity')).toBe('rejected') - expect(await hook!.sendWithOutcome('old ambiguity')).toBe('accepted') + expect((await hook!.sendWithOutcome('old ambiguity')).outcome).toBe('unknown') + expect((await hook!.sendWithOutcome('old ambiguity')).outcome).toBe('rejected') + expect((await hook!.sendWithOutcome('old ambiguity')).outcome).toBe('accepted') }) expect(calls()).toHaveLength(3) @@ -543,7 +549,9 @@ describe('mobile structured send retries', () => { 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) @@ -561,7 +569,9 @@ describe('mobile structured send retries', () => { await mountSession() await act(async () => { - expect(await hook!.sendWithOutcome('the journal will not take this')).toBe('rejected') + expect((await hook!.sendWithOutcome('the journal will not take this')).outcome).toBe( + 'rejected' + ) }) expect(onSendError).toHaveBeenCalledWith('Message not sent') 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 04b8e87304a2..e87cc1676e76 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session.test.tsx @@ -368,7 +368,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') @@ -499,9 +499,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') @@ -537,7 +539,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 cb5309d4fd25..1dde5f43d9cb 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -1,6 +1,6 @@ import { useCallback, useMemo, useRef } from 'react' import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' -import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import type { MobileStructuredSendResult } from './mobile-structured-agent-session-send' import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' import { withNativeChatCutTurnNotices } from '../../../src/shared/native-chat-cut-turn-notice' import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names' @@ -58,7 +58,7 @@ type StructuredMobileSession = ReturnType Promise + ) => Promise cancel: () => void permission: MobileChatPermission | null question: MobileChatQuestion | null 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..7229eec699c5 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 @@ -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')).resolves.toBe('recorded-unsent') + + expect(acceptSend).toHaveBeenCalledWith(ORIGIN, 'look', undefined, '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 5abe42b39e60..fb2a40540ac4 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 = { @@ -18,14 +19,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 @@ -62,7 +69,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 @@ -70,11 +77,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 @@ -86,8 +97,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-with-outcome.ts b/mobile/src/session/use-mobile-structured-send-with-outcome.ts index e1759ac4fca8..0023f6b81ee0 100644 --- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts +++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts @@ -13,9 +13,12 @@ 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 { sendMobileStructuredAgentSessionMessage } from './mobile-structured-agent-session-send' +import { + MOBILE_STRUCTURED_SEND_NOT_SENT, + sendMobileStructuredAgentSessionMessage, + type MobileStructuredSendResult +} from './mobile-structured-agent-session-send' import { timeoutForDeadline } from './mobile-structured-agent-session-rpc' import { pendingStructuredApproval, @@ -56,7 +59,7 @@ export function useMobileStructuredSendWithOutcome(args: { images?: string[], deadline?: number, attachments?: readonly StructuredMobileSendAttachment[] -) => Promise { +) => Promise { const { agent, callerIdentity, @@ -76,20 +79,20 @@ 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({ @@ -112,11 +115,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, From 85d990943d432fc6f232a1dbeefd3387f2849e48 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:19:27 -0700 Subject: [PATCH 06/27] fix(mobile): say another agent's not-sent message was not sent An attributed user message (an orchestration task, for one) is laid out as the agent's, and the not-sent line was drawn only for the person's own bubble, so a task the host recorded and rejected read as delivered on the phone. Draw the line for any user-role row shown as not sent, as the desktop does. --- .../session/MobileNativeChatMessage.test.ts | 22 +++++++++++++++++++ .../src/session/MobileNativeChatMessage.tsx | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index 1ebb55d296a5..7ff56921c69f 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -145,6 +145,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( diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index 252561e9e2d9..3dd899e374f3 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -247,7 +247,7 @@ function MobileNativeChatMessageImpl({ /> ) : null} - {isUser && message.unsent === true ? ( + {message.role === 'user' && message.unsent === true ? ( {unsentNotice ?? AGENT_SESSION_WRITE_NOTICE_COPY.notDoneSend} From 057fe4874342239906eccd4cc2db4adc67602654 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:21:35 -0700 Subject: [PATCH 07/27] fix(native-chat): place not-sent rows after the stop rows, without a re-sort Merging not-sent rows into the conversation before the stop rows were placed let one split a run of stopped sends, which gave the desktop a second 'stopped' row. Place the stop rows first, then merge the few not-sent rows in at their journal places in one pass; a row with no journal place keeps its neighbour. The desktop's order is base's again (0 differences in 20,000 random journals), and a chat with a not-sent row no longer sorts the whole list on every update. --- ...d-agent-session-message-projection.test.ts | 46 +++++++++++++++++ ...ctured-agent-session-message-projection.ts | 50 +++++++++++++++---- 2 files changed, 85 insertions(+), 11 deletions(-) diff --git a/src/shared/structured-agent-session-message-projection.test.ts b/src/shared/structured-agent-session-message-projection.test.ts index 2358bfa4762e..3d3ada4ff7ab 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 { createStructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' 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. @@ -130,3 +132,47 @@ describe('a send the host recorded and then rejected', () => { ) }) }) + +// 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 shape = (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(shape(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( + shape(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 d6233d7a7618..7ebd54bdf5d0 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -159,20 +159,22 @@ export function projectStructuredAgentSessionMessages( if (shownStopped.size > 0) { moved = keepStoppedSendsInSendOrder(delivered, stoppedBeforeStart, shownStopped) || moved } - // 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. - const unsent = projectItems(unsentItems).map((message) => ({ ...message, unsent: true as const })) + const ordered = moved + ? Array.from(collapseProviderRetryRuns(delivered)).sort(compareNativeChatTranscriptMessages) + : collapseProviderRetryRuns(delivered) const conversation = - moved || unsent.length > 0 - ? collapseProviderRetryRuns(delivered) - .concat(unsent) - .sort(compareNativeChatTranscriptMessages) - : collapseProviderRetryRuns(delivered) + 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, ...optimistic .filter((entry) => !journalled.has(agentJournalSubmissionKey(entry.clientMessageId))) @@ -191,3 +193,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 +} From 84b74decdd7087bd98ca934338a3b0c1e29bb8af Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:21:35 -0700 Subject: [PATCH 08/27] docs(native-chat): say when the projection leaves rejected rows out Both chat clients now draw a recorded, rejected message in place; only the outline's ticks leave it out. --- src/shared/structured-agent-session-message-projection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 7ebd54bdf5d0..1ab1e5635747 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -18,7 +18,7 @@ import { projectStructuredItemsToNativeChat } from './structured-agent-session-p 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 } From d46439d53c4395ce74086806a0357ea1edf91521 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:46:34 -0700 Subject: [PATCH 09/27] fix(mobile): settle a structured send from the journal's record of it Round 1 settled a structured send's bubble and its held send only when a row with the send's id was drawn. Two cases never draw one: - a send the host took as a queued card that then went out under a fresh id; - a send whose own row the projection hides, because a same-text copy was sent after it was rejected, or because it was kept as a card. In the first the phone said "Delivery unconfirmed" for a message in the chat; in the second its bubble stayed for good. A send made under id I is now settled when the loaded journal holds a submission recorded under I, in any state, or one whose queuedMessageId is I (a held send also settles on its own card, as before). This is the rule the phone already uses to release an ack-lost id, now one shared index beside it (mobileStructuredSendsInJournal); the release keeps its narrower settled-only, same-body reading through its parameters. A photo binds to the send's row when that row is drawn, and otherwise retires unbound. The drafts hook takes the structured journal's submissions from the controller, as it takes the queued cards. --- .../__mocks__/mobile-prompt-controller.ts | 1 + .../mobile-native-chat-draft-reconcile.ts | 13 +- ...chat-merged-snapshot-parity-hooks.test.tsx | 3 +- .../mobile-native-chat-own-row-echo.test.ts | 199 +++++++++++++----- .../mobile-native-chat-pending-echo.ts | 5 +- .../mobile-native-chat-pending-retirement.ts | 14 +- ...-structured-send-operation-journal.test.ts | 12 ++ ...obile-structured-send-operation-journal.ts | 66 ++++-- .../use-mobile-native-chat-controller.ts | 3 +- ...e-native-chat-drafts-glued-pending.test.ts | 1 + ...le-native-chat-drafts-launch-draft.test.ts | 1 + ...-mobile-native-chat-drafts-own-row.test.ts | 73 ++++++- ...ile-native-chat-drafts-queued-hold.test.ts | 1 + .../use-mobile-native-chat-drafts.test.ts | 1 + .../session/use-mobile-native-chat-drafts.ts | 51 +++-- ...se-mobile-native-chat-unconfirmed-sends.ts | 35 ++- .../use-mobile-structured-agent-session.ts | 4 + ...structured-native-chat-send-bridge.test.ts | 6 +- ...use-mobile-structured-send-reached-host.ts | 18 ++ 19 files changed, 388 insertions(+), 119 deletions(-) create mode 100644 mobile/src/session/use-mobile-structured-send-reached-host.ts diff --git a/mobile/src/session/__mocks__/mobile-prompt-controller.ts b/mobile/src/session/__mocks__/mobile-prompt-controller.ts index bf09ea192b36..ad9ec90a6929 100644 --- a/mobile/src/session/__mocks__/mobile-prompt-controller.ts +++ b/mobile/src/session/__mocks__/mobile-prompt-controller.ts @@ -87,6 +87,7 @@ const structured = { isWorking: false, turnId: null, turnIndicator: null, + submissions: [], workingStartedAt: null, settledTurns: null, turnJournal: null, diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts index e7ac73e94d40..6ed38f33197c 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts @@ -9,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 = { @@ -19,7 +25,7 @@ export type UnconfirmedSend = { baselineTailMessageId: string | null /** Queued-draft cards on screen at send time; see `findQueuedUnconfirmedSends`. */ baselineQueuedMessageIds?: readonly string[] - /** Structured lane: settled by its own row or card, never by text; see the pending echo's. */ + /** Structured lane: settled by the journal's record of it or by its own card, never by text. */ clientMessageId?: string deadline: ReturnType | null } @@ -262,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 @@ -285,7 +292,7 @@ export function findLandedUnconfirmedSends( const landed: UnconfirmedSend[] = [] for (const entry of entries) { if (entry.clientMessageId !== undefined) { - if (messageIndexById.has(agentJournalSubmissionKey(entry.clientMessageId))) { + if (reachedHost(entry.clientMessageId)) { landed.push(entry) } continue 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 065df61ea190..5af3a6ce2a1d 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 @@ -189,7 +189,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-own-row-echo.test.ts b/mobile/src/session/mobile-native-chat-own-row-echo.test.ts index 103fba309bcf..b262a693e47c 100644 --- a/mobile/src/session/mobile-native-chat-own-row-echo.test.ts +++ b/mobile/src/session/mobile-native-chat-own-row-echo.test.ts @@ -1,6 +1,6 @@ -// A structured send is recorded under the id it went out under, and its row carries that id. The -// phone settles the send's bubble, its photo preview and any unconfirmed hold by that row alone, -// whatever its text, wherever the host places it, delivered or not sent. +// 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' @@ -14,13 +14,19 @@ import { countUserTextOccurrences, findLandedImagePreviewEchoes, findLandedUnconfirmedSends, - normalizeReconcileText + 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 { + mobileStructuredSendInJournal, + mobileStructuredSendsInJournal +} from './mobile-structured-send-operation-journal' const TEXT = 'fix the test' @@ -81,17 +87,23 @@ function rejected(id: string, text: string, at: number): AgentJournalSubmission }) } -/** What the phone draws: the journal, with a recorded, rejected send in place as not sent. */ -function phone( - items: AgentJournalRenderItem[], - submissions: AgentJournalSubmission[] -): NativeChatMessage[] { - return projectStructuredAgentSessionMessages(items, [], submissions, { rejectedInPlace: true }) +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 = mobileStructuredSendsInJournal(submissions, false) + return { + messages: projectStructuredAgentSessionMessages(items, [], submissions, { + rejectedInPlace: true + }), + reachedHost: (clientMessageId) => mobileStructuredSendInJournal(sends, clientMessageId) + } } /** One bubble for a structured send, captured against `before`. */ function bubble( - before: readonly NativeChatMessage[], + before: Chat, clientMessageId: string, text: string, images?: string[] @@ -107,8 +119,8 @@ function bubble( draftEditGeneration: 0, pendingKey: 'pending', normalizedText, - baselineOccurrences: countUserTextOccurrences(before, normalizedText), - baselineTailMessageId: before.at(-1)?.id ?? null, + baselineOccurrences: countUserTextOccurrences(before.messages, normalizedText), + baselineTailMessageId: before.messages.at(-1)?.id ?? null, baselineResolved: true }, text, @@ -118,50 +130,58 @@ function bubble( ) } -/** The bubbles still drawn once `after` is the transcript. */ -function left(after: readonly NativeChatMessage[], pending: MobileNativeChatPendingMessage[]) { - const bound = new Set(findLandedImagePreviewEchoes(after, pending).map((echo) => echo.pendingId)) - return retireLandedMobileNativeChatPending(after, pending, bound).map((item) => item.id) +/** 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: readonly NativeChatMessage[], - after: readonly NativeChatMessage[], + before: Chat, + after: Chat, clientMessageId: string, - text: 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, [ - { - draftKey: 'draft', - pendingKey: 'pending', - text, - normalizedText: normalizeReconcileText(text), - baselineTailMessageId: before.at(-1)?.id ?? null, - clientMessageId, - deadline: null - } - ]).length === 0 + 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('keeps its bubble until its own row arrives, never settled by the original', () => { - const pending = bubble(BEFORE, 'm2', TEXT) - expect(left(BEFORE, pending)).toEqual(['pending-m2']) + 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('retires its bubble and settles its hold on its own row, with the original gone', () => { + 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(after.some((message) => message.id === agentJournalSubmissionKey('m1'))).toBe(false) + expect(drawn(after, 'm1')).toBe(false) expect(left(after, bubble(BEFORE, 'm2', TEXT))).toEqual([]) expect(held(BEFORE, after, 'm2', TEXT)).toBe(false) }) @@ -174,7 +194,10 @@ describe('a send whose own row arrives already not sent', () => { ) it('settles an ack-lost send, so no "Delivery unconfirmed" banner follows', () => { - expect(after.at(-1)).toMatchObject({ id: agentJournalSubmissionKey('m2'), unsent: true }) + expect(after.messages.at(-1)).toMatchObject({ + id: agentJournalSubmissionKey('m2'), + unsent: true + }) expect(held(BEFORE, after, 'm2', 'later')).toBe(false) }) @@ -198,7 +221,7 @@ describe('a send whose boundary row the host moves past it', () => { it('binds its photo to its own row and retires its bubble', () => { const pending = bubble(before, 'm2', '', ['file:///a.jpg']) - expect(findLandedImagePreviewEchoes(after, pending)).toEqual([ + expect(findLandedImagePreviewEchoes(after.messages, pending)).toEqual([ { pendingId: 'pending-m2', messageId: agentJournalSubmissionKey('m2'), @@ -228,7 +251,7 @@ describe('a captioned photo send', () => { [photo('m1', 10), photo('m3', 12), photo('m2', 21)], [rejected('m1', 'look', 10), rejected('m2', 'look', 20)] ) - expect(findLandedImagePreviewEchoes(after, pending)).toEqual([ + expect(findLandedImagePreviewEchoes(after.messages, pending)).toEqual([ { pendingId: 'pending-m2', messageId: agentJournalSubmissionKey('m2'), @@ -239,21 +262,99 @@ describe('a captioned photo send', () => { }) }) -// Two quick sends of the same text: the first is recorded and rejected before the second's row -// arrives. Its row is the first send's own, never the second's (R1P-3). +// 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', () => { - it('retires each bubble on its own row only', () => { - const before = phone([answer('a0', 5)], []) - const pending = [...bubble(before, 'm1', TEXT), ...bubble(before, 'm2', 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)]) - const bound = new Set() - const afterFirst = retireLandedMobileNativeChatPending(firstOnly, pending, bound) - expect(afterFirst.map((item) => item.id)).toEqual(['pending-m2']) - // The resend went out once the first was known not sent, so the first's row leaves. + 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(retireLandedMobileNativeChatPending(both, afterFirst, bound)).toEqual([]) + 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 aa9039186b8b..5fe7005cfa7d 100644 --- a/mobile/src/session/mobile-native-chat-pending-echo.ts +++ b/mobile/src/session/mobile-native-chat-pending-echo.ts @@ -12,8 +12,9 @@ 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. Its row settles it, in any state, - * wherever the host places it; text and position are not consulted. */ + /** 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 } diff --git a/mobile/src/session/mobile-native-chat-pending-retirement.ts b/mobile/src/session/mobile-native-chat-pending-retirement.ts index d493e33f87cd..55c1d69ba1d1 100644 --- a/mobile/src/session/mobile-native-chat-pending-retirement.ts +++ b/mobile/src/session/mobile-native-chat-pending-retirement.ts @@ -1,9 +1,10 @@ -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' @@ -147,12 +148,11 @@ 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) @@ -169,8 +169,8 @@ export function retireLandedMobileNativeChatPending( continue } if (item.clientMessageId !== undefined) { - // An image echo waits for its preview to bind to that row, above. - if (!item.images?.length && messageIds.has(agentJournalSubmissionKey(item.clientMessageId))) { + // A photo has bound to its row above when that row is drawn; one the chat hides retires unbound. + if (reachedHost(item.clientMessageId)) { landedPendingIds.add(item.id) exactLandedIds.add(item.id) } diff --git a/mobile/src/session/mobile-structured-send-operation-journal.test.ts b/mobile/src/session/mobile-structured-send-operation-journal.test.ts index 3c76e1d1c3fc..26e7d4672ccf 100644 --- a/mobile/src/session/mobile-structured-send-operation-journal.test.ts +++ b/mobile/src/session/mobile-structured-send-operation-journal.test.ts @@ -180,6 +180,18 @@ describe('mobile structured send operation journal', () => { await clearMobileStructuredSettledSendOperations({ submissions: [submission] }) expect(values.size).toBe(1) + // Settled under the id but with another body: not this record's send. + await clearMobileStructuredSettledSendOperations({ + submissions: [ + { + ...submission, + dispatchState: 'accepted', + reason: null, + payloadFingerprint: 'e'.repeat(64) + } + ] + }) + expect(values.size).toBe(1) await clearMobileStructuredSettledSendOperations({ submissions: [{ ...submission, dispatchState: 'accepted', reason: null }] diff --git a/mobile/src/session/mobile-structured-send-operation-journal.ts b/mobile/src/session/mobile-structured-send-operation-journal.ts index 98937ad1bbbb..76ffdd03addc 100644 --- a/mobile/src/session/mobile-structured-send-operation-journal.ts +++ b/mobile/src/session/mobile-structured-send-operation-journal.ts @@ -191,33 +191,67 @@ export async function clearMobileStructuredSendOperation(input: { }) } +/** The structured sends the journal shows reached the host, by the operation id each went out + * under: one recorded under the id (with the bodies recorded), or one whose queued draft went out + * as a submission naming the id as its `queuedMessageId`, in whatever state. */ +export type MobileStructuredSendsInJournal = { + recorded: ReadonlyMap> + handedOff: ReadonlySet +} + +/** `settledOnly`: a recorded send counts only once accepted or rejected, for reconciling an id; + * otherwise in any state, as for the phone's own echo of the send. */ +export function mobileStructuredSendsInJournal( + submissions: readonly AgentJournalSubmission[], + settledOnly: boolean +): MobileStructuredSendsInJournal { + const recorded = new Map>() + const handedOff = new Set() + for (const submission of submissions) { + if (submission.queuedMessageId !== undefined) { + handedOff.add(submission.queuedMessageId) + } + if ( + !settledOnly || + submission.dispatchState === 'accepted' || + submission.dispatchState === 'rejected' + ) { + const bodies = recorded.get(submission.clientMessageId) ?? new Set() + bodies.add(submission.payloadFingerprint) + recorded.set(submission.clientMessageId, bodies) + } + } + return { recorded, handedOff } +} + +/** Whether the send under `operationId` reached the host; with `payloadFingerprint`, only a + * recording of that body counts. */ +export function mobileStructuredSendInJournal( + sends: MobileStructuredSendsInJournal, + operationId: string, + payloadFingerprint?: string +): boolean { + const bodies = sends.recorded.get(operationId) + return ( + sends.handedOff.has(operationId) || + (bodies !== undefined && (payloadFingerprint === undefined || bodies.has(payloadFingerprint))) + ) +} + /** Reconcile an ack-lost operation once the authoritative journal settles it, or hands it off: * a submission naming the operation's id as its `queuedMessageId` is that send's queued draft * going out, in whatever state, so the send reached the host and its id is spent. */ export async function clearMobileStructuredSettledSendOperations(input: { submissions: readonly AgentJournalSubmission[] }): Promise { - const settled = new Set( - input.submissions.flatMap((submission) => - submission.dispatchState === 'accepted' || submission.dispatchState === 'rejected' - ? [`${submission.payloadFingerprint}\u0000${submission.clientMessageId}`] - : [] - ) - ) - const handedOff = new Set( - input.submissions.flatMap((submission) => - submission.queuedMessageId !== undefined ? [submission.queuedMessageId] : [] - ) - ) - if (settled.size === 0 && handedOff.size === 0) { + const sends = mobileStructuredSendsInJournal(input.submissions, true) + if (sends.recorded.size === 0 && sends.handedOff.size === 0) { return } return serialize(async () => { const journal = parseJournal(await AsyncStorage.getItem(STORAGE_KEY)) const entries = journal.entries.filter( - (entry) => - !settled.has(`${entry.payloadFingerprint}\u0000${entry.operationId}`) && - !handedOff.has(entry.operationId) + (entry) => !mobileStructuredSendInJournal(sends, entry.operationId, entry.payloadFingerprint) ) if (entries.length !== journal.entries.length) { await writeEntries(entries) diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index c44001a45737..a658a340a5b0 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 index 2551502bbb7c..0e28c5d74bc5 100644 --- 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 @@ -1,10 +1,11 @@ -// A structured send's bubble and its unconfirmed hold are settled by the row the host records under -// the send's own id, delivered or not sent, and never by another row with the same text. +// 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' @@ -21,6 +22,23 @@ function user(clientMessageId: string, text: string, unsent = false): NativeChat } } +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', @@ -44,7 +62,11 @@ describe('useMobileNativeChatDrafts with a structured send id', () => { vi.useRealTimers() }) - function Harness(props: { messages: NativeChatMessage[]; queuedCards: QueuedCard[] }): null { + function Harness(props: { + messages: NativeChatMessage[] + queuedCards: QueuedCard[] + submissions: AgentJournalSubmission[] + }): null { state = useMobileNativeChatDrafts({ hostId: 'host', worktreeId: 'worktree', @@ -54,14 +76,19 @@ describe('useMobileNativeChatDrafts with a structured send id', () => { launchDraft: null, transcriptLoading: false, transcriptSettled: true, - queuedCards: props.queuedCards + queuedCards: props.queuedCards, + submissions: props.submissions }) return null } - async function render(messages: NativeChatMessage[], queuedCards: QueuedCard[] = []) { + async function render( + messages: NativeChatMessage[], + queuedCards: QueuedCard[] = [], + submissions: AgentJournalSubmission[] = SUBMITTED + ) { await act(async () => { - const element = createElement(Harness, { messages, queuedCards }) + const element = createElement(Harness, { messages, queuedCards, submissions }) if (renderer) { renderer.update(element) } else { @@ -92,6 +119,7 @@ describe('useMobileNativeChatDrafts with a structured send id', () => { } 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) @@ -99,21 +127,36 @@ describe('useMobileNativeChatDrafts with a structured send id', () => { 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')]) + 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)]) + 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')]) + await render([...BEFORE, user('m3', 'fix the test')], [], [...SUBMITTED, recorded('m3')]) expect(settle()).toHaveBeenCalledTimes(1) }) @@ -127,6 +170,18 @@ describe('useMobileNativeChatDrafts with a structured send id', () => { 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') 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 b752f7b84add..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,7 +85,7 @@ 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 - /** `clientMessageId`: the structured send's own id, so its row alone retires the echo. */ + /** `clientMessageId`: the structured send's own id; the journal's record of it retires the echo. */ acceptSend: ( origin: MobileNativeChatSendOrigin, text: string, @@ -105,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 @@ -228,33 +234,33 @@ export function useMobileNativeChatDrafts(args: { } pendingCounterRef.current += 1 const id = `pending-${pendingCounterRef.current}` - const key = origin.pendingKey - if (key) { - setPendingBySession((previous) => - appendMobileNativeChatPending(previous, key, id, origin, text, images, clientMessageId) + // 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, - clientMessageId - ) - ) + setPendingWaitingForSession(append) } }, [] ) + const reachedHost = useMobileStructuredSendReachedHost(submissions) const { holdUnconfirmedSend } = useMobileNativeChatUnconfirmedSends({ draftKey, pendingKey, messages, - ...(queuedCards ? { queuedCards } : {}) + ...(queuedCards ? { queuedCards } : {}), + reachedHost }) const waitingForSession = draftKey @@ -308,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 } @@ -319,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-unconfirmed-sends.ts b/mobile/src/session/use-mobile-native-chat-unconfirmed-sends.ts index 4a8d64e9a960..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,27 +44,38 @@ 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, - /** Structured lane: the id the send went out under; its row or card settles the hold. */ + /** 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( @@ -92,7 +106,12 @@ export function useMobileNativeChatUnconfirmedSends(args: { // 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 } @@ -114,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 } @@ -123,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.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 1dde5f43d9cb..cb90c2a74f05 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -17,6 +17,7 @@ import { projectStructuredPermission, projectStructuredQuestion } from './mobile-structured-agent-prompts' +import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' import type { RpcClient } from '../transport/rpc-client' import type { MobileChatPermission } from './mobile-native-chat-permission' import type { MobileChatQuestion } from './mobile-native-chat-question' @@ -53,6 +54,8 @@ type StructuredMobileSession = ReturnType { 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 7229eec699c5..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() @@ -110,9 +110,9 @@ describe('useMobileStructuredNativeChatSendBridge', () => { sendStructured.mockResolvedValue({ outcome: 'recorded-unsent', clientMessageId: 'op-2' }) mount('claude') - await expect(sendWithOutcome('look')).resolves.toBe('recorded-unsent') + await expect(sendWithOutcome('look', ['file:///photo.jpg'])).resolves.toBe('recorded-unsent') - expect(acceptSend).toHaveBeenCalledWith(ORIGIN, 'look', undefined, 'op-2') + 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-send-reached-host.ts b/mobile/src/session/use-mobile-structured-send-reached-host.ts new file mode 100644 index 000000000000..0003020e39b6 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-send-reached-host.ts @@ -0,0 +1,18 @@ +import { useMemo } from 'react' +import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' +import type { MobileStructuredSendReachedHost } from './mobile-native-chat-draft-reconcile' +import { + mobileStructuredSendInJournal, + mobileStructuredSendsInJournal +} from './mobile-structured-send-operation-journal' + +/** 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 = mobileStructuredSendsInJournal(submissions, false) + return (clientMessageId) => mobileStructuredSendInJournal(sends, clientMessageId) + }, [submissions]) +} From 3bad603caf25e139d9707f788437cde56442770c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 01:02:21 -0700 Subject: [PATCH 10/27] fix(mobile): keep a photo bubble until it binds to its own drawn row A photo send's answer can land after the frame carrying its record renders but before that frame's effects run. With another bubble pending, the retirement then saw the record, not yet the bound preview, and retired the photo unbound, so its delivered row showed the host path instead of the thumbnail. A photo bubble whose own row is drawn now waits to bind to it; only one whose row the chat hides retires unbound on the journal's record. --- .../mobile-native-chat-pending-retirement.ts | 10 +- ...bile-native-chat-drafts-photo-race.test.ts | 124 ++++++++++++++++++ 2 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 mobile/src/session/use-mobile-native-chat-drafts-photo-race.test.ts diff --git a/mobile/src/session/mobile-native-chat-pending-retirement.ts b/mobile/src/session/mobile-native-chat-pending-retirement.ts index 55c1d69ba1d1..96b8d64cf428 100644 --- a/mobile/src/session/mobile-native-chat-pending-retirement.ts +++ b/mobile/src/session/mobile-native-chat-pending-retirement.ts @@ -1,3 +1,4 @@ +import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { countImageSourceTurnsAfter, @@ -152,7 +153,9 @@ export function retireLandedMobileNativeChatPending( 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) @@ -169,8 +172,11 @@ export function retireLandedMobileNativeChatPending( continue } if (item.clientMessageId !== undefined) { - // A photo has bound to its row above when that row is drawn; one the chat hides retires unbound. - if (reachedHost(item.clientMessageId)) { + // 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) } 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..7dd6aea85694 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-drafts-photo-race.test.ts @@ -0,0 +1,124 @@ +// 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, useRef } 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 }) => { + let state: Drafts | null = null + 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 { + const latest = useRef(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(latest.current) + } + }) + state = useMobileNativeChatDrafts({ + hostId: 'host', + worktreeId: 'worktree', + tabId: 'tab', + sessionId: 'session', + messages: props.messages, + launchDraft: null, + transcriptLoading: false, + transcriptSettled: true, + queuedCards: [], + submissions: props.submissions + }) + latest.current = state + 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 = state?.captureSendOrigin('first') + await act(async () => { + if (first) { + state?.acceptSend(first, 'first', undefined, 'P') + } + }) + } + origin = state?.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(state)) + } + await render([ROW], [RECORD]) + + expect(state?.pending.map((item) => item.text)).toEqual(earlierBubble ? ['first'] : []) + expect(state?.imagePreviewsByMessageId[OWN_ROW]).toEqual([PHOTO]) + } + ) +}) From 58bf24479093b6e802eb569777850bdfbd932f97 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 01:02:21 -0700 Subject: [PATCH 11/27] test(mobile): check the controller hands the journal's submissions to the drafts The type pinned only that some array is passed; the structured-session mock now returns submissions and the drafts arguments must carry that array. --- mobile/src/session/use-mobile-native-chat-controller.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 64aa4aa37c15..0c42ba7fed94 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -57,7 +57,7 @@ const structuredQuestion = { allowOther: true, optionTokens: ['choice-a', 'choice-b'] } -const structuredActivity = { isWorking: false, turnId: null as string | null } +const structuredActivity = { isWorking: false, turnId: null as string | null, submissions: [{}] } const structuredSessionState = { messages: [] as unknown[], status: 'ready', @@ -584,7 +584,8 @@ describe('useMobileNativeChatController launch-draft wiring', () => { launchDraft: 'https://github.com/o/r/issues/12', launchDraftCreatedAt: 7, chatActive: true, - transcriptLoading: false + transcriptLoading: false, + submissions: structuredActivity.submissions }) }) From 538c201fb15f252784ca2916249e79d6d464b2e9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 01:04:10 -0700 Subject: [PATCH 12/27] test(mobile): type-check the photo-race test's hook reads A local the harness assigned was narrowed to null at the reads, so the test file failed the mobile tests typecheck; read the hook through an object. --- ...bile-native-chat-drafts-photo-race.test.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) 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 index 7dd6aea85694..8d4edf0d6780 100644 --- 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 @@ -54,7 +54,8 @@ describe('a photo send answered around the frame that carries its record', () => ])( 'binds its photo to its own row (another bubble pending: $earlierBubble, answered before the effects: $answerBeforeEffects)', async ({ earlierBubble, answerBeforeEffects }) => { - let state: Drafts | null = null + // Read through an object: a local the harness assigns would be narrowed to null here. + const current: { drafts?: Drafts } = {} let origin: Origin | null = null let answered = false const accept = (drafts: Drafts | null): void => { @@ -74,7 +75,7 @@ describe('a photo send answered around the frame that carries its record', () => accept(latest.current) } }) - state = useMobileNativeChatDrafts({ + current.drafts = useMobileNativeChatDrafts({ hostId: 'host', worktreeId: 'worktree', tabId: 'tab', @@ -86,7 +87,7 @@ describe('a photo send answered around the frame that carries its record', () => queuedCards: [], submissions: props.submissions }) - latest.current = state + latest.current = current.drafts return null } const render = async (messages: NativeChatMessage[], submissions: AgentJournalSubmission[]) => @@ -102,23 +103,25 @@ describe('a photo send answered around the frame that carries its record', () => await render([], []) if (earlierBubble) { // An earlier send still waiting for its record keeps the retirement effect running. - const first = state?.captureSendOrigin('first') + const first = current.drafts?.captureSendOrigin('first') await act(async () => { if (first) { - state?.acceptSend(first, 'first', undefined, 'P') + current.drafts?.acceptSend(first, 'first', undefined, 'P') } }) } - origin = state?.captureSendOrigin('look') ?? null + 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(state)) + await act(async () => accept(current.drafts ?? null)) } await render([ROW], [RECORD]) - expect(state?.pending.map((item) => item.text)).toEqual(earlierBubble ? ['first'] : []) - expect(state?.imagePreviewsByMessageId[OWN_ROW]).toEqual([PHOTO]) + expect(current.drafts?.pending.map((item) => item.text)).toEqual( + earlierBubble ? ['first'] : [] + ) + expect(current.drafts?.imagePreviewsByMessageId[OWN_ROW]).toEqual([PHOTO]) } ) }) From 8869cfc1f9493f9c80856d290822a953912f96f2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 01:11:46 -0700 Subject: [PATCH 13/27] test(mobile): keep the round-3 tests inside the quality gates The controller check no longer rewrites the mock's existing cast line (the gate counts it as new), and the photo-race harness reads the hook through a plain object instead of writing a ref during render. --- .../session/use-mobile-native-chat-controller.test.ts | 8 ++++---- .../use-mobile-native-chat-drafts-photo-race.test.ts | 9 ++++----- 2 files changed, 8 insertions(+), 9 deletions(-) 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 0c42ba7fed94..e8938377ec20 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -57,7 +57,7 @@ const structuredQuestion = { allowOther: true, optionTokens: ['choice-a', 'choice-b'] } -const structuredActivity = { isWorking: false, turnId: null as string | null, submissions: [{}] } +const structuredActivity = { isWorking: false, turnId: null as string | null } const structuredSessionState = { messages: [] as unknown[], status: 'ready', @@ -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, @@ -584,8 +585,7 @@ describe('useMobileNativeChatController launch-draft wiring', () => { launchDraft: 'https://github.com/o/r/issues/12', launchDraftCreatedAt: 7, chatActive: true, - transcriptLoading: false, - submissions: structuredActivity.submissions + transcriptLoading: false }) }) @@ -607,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-drafts-photo-race.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-photo-race.test.ts index 8d4edf0d6780..dd1935ed749f 100644 --- 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 @@ -2,7 +2,7 @@ // 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, useRef } from 'react' +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' @@ -54,7 +54,8 @@ describe('a photo send answered around the frame that carries its record', () => ])( 'binds its photo to its own row (another bubble pending: $earlierBubble, answered before the effects: $answerBeforeEffects)', async ({ earlierBubble, answerBeforeEffects }) => { - // Read through an object: a local the harness assigns would be narrowed to null here. + // 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 @@ -67,12 +68,11 @@ describe('a photo send answered around the frame that carries its record', () => messages: NativeChatMessage[] submissions: AgentJournalSubmission[] }): null { - const latest = useRef(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(latest.current) + accept(current.drafts ?? null) } }) current.drafts = useMobileNativeChatDrafts({ @@ -87,7 +87,6 @@ describe('a photo send answered around the frame that carries its record', () => queuedCards: [], submissions: props.submissions }) - latest.current = current.drafts return null } const render = async (messages: NativeChatMessage[], submissions: AgentJournalSubmission[]) => From b0185e29de9cdfb1423b3682a1aa850bf4175815 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 01:32:18 -0700 Subject: [PATCH 14/27] test(native-chat): name the projection test's row helper for what it reads The anti-slop naming rule rejects "shape"; the helper lists the rows in the order they are drawn. --- .../structured-agent-session-message-projection.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/shared/structured-agent-session-message-projection.test.ts b/src/shared/structured-agent-session-message-projection.test.ts index 3d3ada4ff7ab..22213396410f 100644 --- a/src/shared/structured-agent-session-message-projection.test.ts +++ b/src/shared/structured-agent-session-message-projection.test.ts @@ -147,11 +147,13 @@ describe('a not-sent row between two stopped sends', () => { rejection: { kind: 'cancelled' } }) const submissions = [stopped('client-s1'), rejected(), stopped('client-s2')] - const shape = (messages: readonly { id: string; unsent?: true }[]) => + 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(shape(projectStructuredAgentSessionMessages(items, [], submissions, DESKTOP))).toEqual([ + expect( + drawnOrder(projectStructuredAgentSessionMessages(items, [], submissions, DESKTOP)) + ).toEqual([ agentJournalSubmissionKey('client-s1'), `${agentJournalSubmissionKey(KEPT_ID)} (not sent)`, agentJournalSubmissionKey('client-s2'), @@ -167,7 +169,7 @@ describe('a not-sent row between two stopped sends', () => { : { ...message, journalPosition: undefined } ) expect( - shape(projectStructuredAgentSessionMessages(items, [], submissions, DESKTOP, unplaced)) + drawnOrder(projectStructuredAgentSessionMessages(items, [], submissions, DESKTOP, unplaced)) ).toEqual([ agentJournalSubmissionKey('client-s1'), agentJournalSubmissionKey('client-s2'), From 8ed888640ef563508878bce943eacf42be90d18e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:07:05 -0700 Subject: [PATCH 15/27] Clarify unsent chat messages and withhold technical diagnostics --- .../MobileNativeChatQueuedMessages.test.tsx | 2 +- .../mobile-native-chat-unsent-notices.test.ts | 43 +++++++++- src/main/claude/claude-api-retry-row.test.ts | 18 ++--- src/main/claude/claude-command-turn.test.ts | 8 +- .../codex/codex-app-server-request-error.ts | 8 +- .../codex/codex-command-turn-claim.test.ts | 6 +- ...odex-structured-compaction-refusal.test.ts | 2 +- ...odex-structured-dispatch-admission.test.ts | 25 +++++- .../codex-structured-session-adapter.test.ts | 2 +- .../codex/codex-structured-session-adapter.ts | 7 +- .../codex-structured-turn-end-settlement.ts | 5 +- src/main/codex/codex-structured-turn-start.ts | 5 +- .../codex/codex-structured-turn-steer.test.ts | 2 +- .../journal-reducer-rejection-fact.test.ts | 4 +- ...ed-agent-session-conversation-stop.test.ts | 6 +- ...structured-conversation-compaction.test.ts | 11 +-- ...turedSession.start-failure-notice.test.tsx | 10 +-- .../agent-session-failure-words-text.test.ts | 39 +++++---- .../agent-session-failure-words-text.ts | 43 +++++++--- .../src/i18n/en-runtime-required.json | 20 ++--- src/renderer/src/i18n/locales/en.json | 20 ++--- src/renderer/src/i18n/locales/es.json | 20 ++--- src/renderer/src/i18n/locales/fr.json | 20 ++--- src/renderer/src/i18n/locales/ja.json | 20 ++--- src/renderer/src/i18n/locales/ko.json | 20 ++--- src/renderer/src/i18n/locales/zh.json | 20 ++--- .../agent-session-attachment-failure-words.ts | 47 +++++++++++ src/shared/agent-session-failure-copy.ts | 24 +++--- .../agent-session-failure-words.test.ts | 73 ++++++++++++++--- src/shared/agent-session-failure-words.ts | 79 ++++++++----------- .../agent-session-provider-retry-words.ts | 17 +--- .../provider-diagnostic-person-text.test.ts | 33 ++++++++ src/shared/provider-diagnostic-person-text.ts | 30 +++++++ ...d-agent-session-dispatch-rejection.test.ts | 26 +++++- ...ctured-agent-session-dispatch-rejection.ts | 14 ++++ ...ured-agent-session-rejection-words.test.ts | 10 +-- ...tructured-agent-session-rejection-words.ts | 10 +-- 37 files changed, 511 insertions(+), 238 deletions(-) create mode 100644 src/shared/agent-session-attachment-failure-words.ts create mode 100644 src/shared/provider-diagnostic-person-text.test.ts create mode 100644 src/shared/provider-diagnostic-person-text.ts diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx index 5eb53046128b..0fc61ebb87ee 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx @@ -116,7 +116,7 @@ describe('MobileNativeChatQueuedMessages', () => { 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/mobile-native-chat-unsent-notices.test.ts b/mobile/src/session/mobile-native-chat-unsent-notices.test.ts index 5f4b503e06de..11629abc466f 100644 --- a/mobile/src/session/mobile-native-chat-unsent-notices.test.ts +++ b/mobile/src/session/mobile-native-chat-unsent-notices.test.ts @@ -27,13 +27,54 @@ function rejected(patch: Partial = {}): AgentJournalSubm } describe('the line under a message the host recorded and did not deliver', () => { + it('names the agent with a resend step after the host classifies an unwritten send', () => { + const notices = mobileNativeChatUnsentNotices( + { + items: [], + submissions: [ + rejected({ + rejection: { + kind: 'writeFailed', + detail: { + text: 'provider_write_failed: stand-in rejected the turn.', + audience: 'log' + } + } + }) + ] + }, + '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 notices = mobileNativeChatUnsentNotices( + { + items: [], + submissions: [ + rejected({ + rejection: { kind: 'providerRejected', detail: { text, audience: 'person' } } + }) + ] + }, + '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( - "Orca couldn't reach the agent. Your message was not sent." + "Claude couldn't receive this message. Send it again." ) }) 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-request-error.ts b/src/main/codex/codex-app-server-request-error.ts index 0e7cc719017e..d1115cb1f37b 100644 --- a/src/main/codex/codex-app-server-request-error.ts +++ b/src/main/codex/codex-app-server-request-error.ts @@ -1,4 +1,5 @@ import { providerDiagnostic, type ProviderDiagnostic } from '../../shared/agent-session-failure' +import { isProviderDiagnosticPersonText } from '../../shared/provider-diagnostic-person-text' /** Codex answered the call and refused it, rather than timing out or exiting. */ export class CodexAppServerRequestError extends Error { @@ -14,7 +15,12 @@ export class CodexAppServerRequestError extends Error { super(message) this.name = 'CodexAppServerRequestError' const diagnostic = - providerMessage === undefined ? undefined : providerDiagnostic(providerMessage, 'person') + providerMessage === undefined + ? undefined + : providerDiagnostic( + providerMessage, + 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..1fc6644608c3 100644 --- a/src/main/codex/codex-command-turn-claim.test.ts +++ b/src/main/codex/codex-command-turn-claim.test.ts @@ -194,7 +194,7 @@ 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: Unavailable.", failure: { kind: 'compactionFailed', detail: { text: 'Unavailable', audience: 'person' } @@ -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' } diff --git a/src/main/codex/codex-structured-compaction-refusal.test.ts b/src/main/codex/codex-structured-compaction-refusal.test.ts index fa8800f9605f..c1c8e262e3c3 100644 --- a/src/main/codex/codex-structured-compaction-refusal.test.ts +++ b/src/main/codex/codex-structured-compaction-refusal.test.ts @@ -32,7 +32,7 @@ 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: thread has nothing to compact.", rejection: { kind: 'providerRejected', detail: { text: 'thread has nothing to compact', audience: 'person' } diff --git a/src/main/codex/codex-structured-dispatch-admission.test.ts b/src/main/codex/codex-structured-dispatch-admission.test.ts index 80bf0cee097d..4b28768926d8 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. @@ -155,7 +178,7 @@ describe('codex dispatch admission', () => { // Codex's own words reach the sentence and the fact; Orca's prefix reaches neither. 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: thread not found.", rejection: { kind: 'providerRejected', detail: { text: 'thread not found', audience: 'person' } 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.ts b/src/main/codex/codex-structured-turn-end-settlement.ts index 44416ed9b84d..524c13156b6f 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' /** A message Codex rejected, in the words that name Codex and its legacy markers. */ export function codexDispatchRejection( @@ -97,9 +98,7 @@ export function codexTurnEndRejection(end: CodexTurnEnd): AgentJournalDispatchRe return agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) } if (end.status === 'failed') { - return codexDispatchRejection( - agentSessionFailureFact('providerRejected', end.detail ? { detail: end.detail } : {}) - ) + return codexDispatchRejection(providerDispatchRejectionFact(end.detail)) } return null } diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 85a4007d2f7b..08f400b52d0f 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -1,4 +1,5 @@ import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' +import { providerDispatchRejectionFact } from '../../shared/structured-agent-session-dispatch-rejection' import type { AgentJournalMessageItem, AgentJournalTurnJoin @@ -209,9 +210,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..e6c654c00775 100644 --- a/src/main/codex/codex-structured-turn-steer.test.ts +++ b/src/main/codex/codex-structured-turn-steer.test.ts @@ -168,7 +168,7 @@ 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: thread not found.", rejection: { kind: 'providerRejected', detail: { text: 'thread not found', audience: 'person' } 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-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index 3154d165ca62..81ccdcc31593 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 @@ -700,7 +700,9 @@ 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([ + "The agent 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 () => { @@ -711,7 +713,7 @@ describe('a Stop that names no turn', () => { 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 had no response in progress to stop.']) }) 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-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts index a5db53e5555c..8e6382b8d0d3 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' } @@ -777,7 +777,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/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-failure-words-text.test.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts index 40d67de0c335..87b89901de7d 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,11 +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' -] +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 = { @@ -96,23 +97,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] + ) + } } }) @@ -152,7 +159,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( @@ -197,7 +204,9 @@ describe('desktop words for a failure fact', () => { expect(sentence({ kind: 'commandRefused' })).toBe( "Cette commande n'a pas été exécutée. Réessayez." ) - expect(sentence({ kind: 'stopRefused' })).toBe("Codex n'avait aucun tour en cours à arrêter.") + expect(sentence({ kind: 'stopRefused' })).toBe( + "Codex n'avait aucune réponse en cours à arrêter." + ) expect( sentence({ kind: 'stopRefused', 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 51f3f4f4c94c..e6a849f88cf7 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 @@ -33,6 +33,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) => @@ -158,8 +160,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', @@ -170,8 +176,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: () => @@ -190,21 +196,30 @@ const PIECES: Record - translate('components.native-chat.failureWords.compactionFailed', COPY.compactionFailed), + compactionFailed: (values) => + translate( + 'components.native-chat.failureWords.compactionFailed', + COPY.compactionFailed, + values + ), 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) => @@ -215,8 +230,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), diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 57c9143f1eb0..cc5a4991e4cb 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2820,7 +2820,7 @@ "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.", - "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.", @@ -2837,7 +2837,7 @@ "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.", @@ -2847,9 +2847,9 @@ "claudeAccountSetupFailed": "The selected Claude account could not be set up. Try again or choose another account.", "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.", + "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.", @@ -2863,16 +2863,16 @@ "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 for the selected account.", "previousExitUnverifiable": "Couldn't stop {{agent}} from before.", "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}}.", @@ -2890,7 +2890,7 @@ "stopRefused": "{{agent}} didn't stop.", "stopRefusedQuoted": "{{agent}} didn't stop: {{detail}}.", "theAgent": "The agent", - "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.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9cc7c3d90488..2d5c799655f9 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17996,8 +17996,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.", @@ -18012,7 +18012,7 @@ "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.", @@ -18020,18 +18020,18 @@ "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.", + "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.", "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.", + "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.", "providerRetryingQuoted": "{{agent}} is retrying: {{detail}}.", "providerRetryNumber": "Retry {{attempt}}.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ca78deda08b2..38d4259b8249 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17768,8 +17768,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ó.", @@ -17784,7 +17784,7 @@ "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.", @@ -17792,14 +17792,14 @@ "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.", + "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.", "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ó.", + "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.", @@ -17807,7 +17807,7 @@ "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.", "providerRetryingQuoted": "{{agent}} está reintentando: {{detail}}.", "providerRetryNumber": "Reintento {{attempt}}.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index a18e5d599a4b..6e9db8dc40ae 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17869,8 +17869,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é.", @@ -17885,7 +17885,7 @@ "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.", @@ -17893,14 +17893,14 @@ "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.", + "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é.", "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.", + "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.", @@ -17908,7 +17908,7 @@ "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.", "providerRetryingQuoted": "{{agent}} réessaie : {{detail}}.", "providerRetryNumber": "Nouvelle tentative {{attempt}}.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 72efe22af4f2..95c6a8172241 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17803,8 +17803,8 @@ "chooseClaudeAccountThenSend": "Claude アカウントの設定で選択または追加してから、メッセージをもう一度送信してください。", "providerExitedRow": "この応答の途中で {{agent}} が停止しました。この会話で続けることができます。", "providerExitedRejection": "このメッセージが送信される前に {{agent}} が停止しました。", - "providerRejected": "プロバイダーはこのメッセージを受け付けませんでした。", - "providerRejectedQuoted": "プロバイダーはこのメッセージを受け付けませんでした: {{detail}}。", + "providerRejected": "{{agent}} はこのメッセージを受け付けませんでした。", + "providerRejectedQuoted": "{{agent}} はこのメッセージを受け付けませんでした:{{detail}}。", "attachmentEmpty": "このメッセージの画像が空のため、メッセージは送信されませんでした。", "attachmentTooLarge": "このメッセージの画像が大きすぎるため、メッセージは送信されませんでした。", "attachmentLargerThan": "このメッセージの画像が {{size}} MB を超えているため、メッセージは送信されませんでした。", @@ -17819,7 +17819,7 @@ "attachmentUnreadable": "このメッセージの添付ファイルを読み取れなかったため、メッセージは送信されませんでした。", "emptyMessage": "このメッセージは空のため、送信されませんでした。", "queueFull": "Agent を待っているメッセージが多すぎたため、このメッセージは送信されませんでした。", - "writeFailed": "Orca はこのメッセージを Agent に渡せなかったため、送信されませんでした。", + "writeFailed": "{{agent}} はこのメッセージを受信できませんでした。", "cancelled": "このメッセージは Agent が処理を始める前に取り消されました。", "chatClosed": "このメッセージが送信される前にチャットが閉じられました。", "hostRestarted": "このメッセージが送信される前に Orca が再起動しました。", @@ -17827,14 +17827,14 @@ "notDeliveredSendAgain": "このメッセージは配信されませんでした。続けるには、もう一度送信してください。", "commandRefused": "このコマンドは実行されませんでした。", "commandRefusedTryAgain": "このコマンドは実行されませんでした。もう一度お試しください。", - "compactionFailed": "圧縮に失敗しました。", - "compactionFailedQuoted": "圧縮に失敗しました: {{detail}}。", - "compactionUnconfirmed": "圧縮の完了は確認されていません。", - "cancelUnconfirmed": "キャンセルは確認されませんでした。", + "compactionFailed": "{{agent}} はこのチャットの履歴を短くできませんでした。", + "compactionFailedQuoted": "{{agent}} はこのチャットの履歴を短くできませんでした:{{detail}}。", + "compactionUnconfirmed": "{{agent}} はこのチャットの履歴が短くなったことを確認していません。/compact を再実行する前にチャットを確認してください。", + "cancelUnconfirmed": "{{agent}} は停止したことを確認していません。再試行する前にチャットを確認してください。", "stopRefused": "{{agent}} は停止しませんでした。", "stopRefusedQuoted": "{{agent}} は停止しませんでした: {{detail}}。", - "noTurnToStop": "{{agent}} には停止できる実行中のターンがありませんでした。", - "answerUnconfirmed": "回答は記録されましたが、Agent は確認しませんでした。", + "noTurnToStop": "{{agent}} に停止できる応答はありませんでした。", + "answerUnconfirmed": "回答は保存されましたが、{{agent}} は受け取ったことを確認していません。もう一度回答する前にチャットを確認してください。", "argumentsMissingValue": "保存された引数の {{option}} に値が必要です。", "argumentsMultipleValues": "保存された引数の {{option}} に複数の値が指定されています。", "argumentsPositionalPrompt": "保存された引数にプロンプトが含まれています。", @@ -17842,7 +17842,7 @@ "hostFault": "Orca で問題が発生したため、この操作は完了しませんでした。", "hostFaultTryAgain": "Orca で問題が発生したため、この操作は完了しませんでした。もう一度お試しください。", "hostStopped": "{{agent}} の起動が完了しなかったため、Orca が停止しました。", - "providerRateLimited": "{{agent}} はレート制限を受けているため、再試行しています。", + "providerRateLimited": "{{agent}} はリクエストの上限に達したため、再試行しています。", "providerRetrying": "{{agent}} で一時的な問題が発生したため、再試行しています。", "providerRetryingQuoted": "{{agent}} は再試行しています: {{detail}}。", "providerRetryNumber": "{{attempt}} 回目の再試行です。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 1a66b828be6a..d9f7c43ee6a8 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17803,8 +17803,8 @@ "chooseClaudeAccountThenSend": "Claude 계정 설정에서 선택하거나 추가한 다음 메시지를 다시 보내세요.", "providerExitedRow": "이 응답이 진행되는 동안 {{agent}}이(가) 중지되었습니다. 이 대화에서 계속할 수 있습니다.", "providerExitedRejection": "이 메시지가 전송되기 전에 {{agent}}이(가) 중지되었습니다.", - "providerRejected": "공급자가 이 메시지를 받아들이지 않았습니다.", - "providerRejectedQuoted": "공급자가 이 메시지를 받아들이지 않았습니다: {{detail}}.", + "providerRejected": "{{agent}}이(가) 이 메시지를 수락하지 않았습니다.", + "providerRejectedQuoted": "{{agent}}이(가) 이 메시지를 수락하지 않았습니다: {{detail}}.", "attachmentEmpty": "이 메시지의 이미지가 비어 있어 메시지가 전송되지 않았습니다.", "attachmentTooLarge": "이 메시지의 이미지가 너무 커서 메시지가 전송되지 않았습니다.", "attachmentLargerThan": "이 메시지의 이미지가 {{size}}MB보다 커서 메시지가 전송되지 않았습니다.", @@ -17819,7 +17819,7 @@ "attachmentUnreadable": "이 메시지의 첨부 파일을 읽을 수 없어 메시지가 전송되지 않았습니다.", "emptyMessage": "이 메시지가 비어 있어 전송되지 않았습니다.", "queueFull": "에이전트를 기다리는 메시지가 너무 많아 이 메시지는 전송되지 않았습니다.", - "writeFailed": "Orca가 이 메시지를 에이전트에 전달하지 못해 전송되지 않았습니다.", + "writeFailed": "{{agent}}이(가) 이 메시지를 받지 못했습니다.", "cancelled": "이 메시지는 에이전트가 처리를 시작하기 전에 철회되었습니다.", "chatClosed": "이 메시지가 전송되기 전에 채팅이 닫혔습니다.", "hostRestarted": "이 메시지가 전송되기 전에 Orca가 다시 시작되었습니다.", @@ -17827,14 +17827,14 @@ "notDeliveredSendAgain": "이 메시지는 전달되지 않았습니다. 계속하려면 다시 보내세요.", "commandRefused": "이 명령이 실행되지 않았습니다.", "commandRefusedTryAgain": "이 명령이 실행되지 않았습니다. 다시 시도하세요.", - "compactionFailed": "압축에 실패했습니다.", - "compactionFailedQuoted": "압축에 실패했습니다: {{detail}}.", - "compactionUnconfirmed": "압축 완료가 확인되지 않았습니다.", - "cancelUnconfirmed": "취소가 확인되지 않았습니다.", + "compactionFailed": "{{agent}}이(가) 이 채팅의 기록을 줄이지 못했습니다.", + "compactionFailedQuoted": "{{agent}}이(가) 이 채팅의 기록을 줄이지 못했습니다: {{detail}}.", + "compactionUnconfirmed": "{{agent}}이(가) 이 채팅의 기록이 줄어들었는지 확인하지 않았습니다. /compact를 다시 실행하기 전에 채팅을 확인하세요.", + "cancelUnconfirmed": "{{agent}}이(가) 중지되었는지 확인하지 않았습니다. 다시 시도하기 전에 채팅을 확인하세요.", "stopRefused": "{{agent}}이(가) 중지되지 않았습니다.", "stopRefusedQuoted": "{{agent}}이(가) 중지되지 않았습니다: {{detail}}.", - "noTurnToStop": "{{agent}}에 중지할 실행 중인 턴이 없었습니다.", - "answerUnconfirmed": "응답이 기록되었지만 에이전트가 확인하지 않았습니다.", + "noTurnToStop": "{{agent}}에 중지할 진행 중인 응답이 없었습니다.", + "answerUnconfirmed": "답변은 저장되었지만 {{agent}}이(가) 확인하지 않았습니다. 다시 답변하기 전에 채팅을 확인하세요.", "argumentsMissingValue": "저장된 인수의 {{option}}에 값이 필요합니다.", "argumentsMultipleValues": "저장된 인수에 {{option}}의 값이 여러 개 지정되어 있습니다.", "argumentsPositionalPrompt": "저장된 인수에 프롬프트가 포함되어 있습니다.", @@ -17842,7 +17842,7 @@ "hostFault": "Orca에 문제가 발생하여 이 작업이 완료되지 않았습니다.", "hostFaultTryAgain": "Orca에 문제가 발생하여 이 작업이 완료되지 않았습니다. 다시 시도하세요.", "hostStopped": "{{agent}}이(가) 시작을 마치지 못해 Orca가 중지했습니다.", - "providerRateLimited": "{{agent}}이(가) 속도 제한에 걸려 다시 시도하고 있습니다.", + "providerRateLimited": "{{agent}}이(가) 요청 한도에 도달하여 다시 시도하고 있습니다.", "providerRetrying": "{{agent}}에 일시적인 문제가 발생하여 다시 시도하고 있습니다.", "providerRetryingQuoted": "{{agent}}이(가) 다시 시도하고 있습니다: {{detail}}.", "providerRetryNumber": "{{attempt}}번째 재시도입니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index cccf45c2ed35..34770bb78120 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17768,8 +17768,8 @@ "chooseClaudeAccountThenSend": "请在 Claude 账户设置中选择或添加一个,然后重新发送您的消息。", "providerExitedRow": "{{agent}} 在生成此回复时停止了。您可以在此对话中继续。", "providerExitedRejection": "{{agent}} 在发送此消息前已停止。", - "providerRejected": "提供商未接受此消息。", - "providerRejectedQuoted": "提供商未接受此消息:{{detail}}。", + "providerRejected": "{{agent}} 未接受这条消息。", + "providerRejectedQuoted": "{{agent}} 未接受这条消息:{{detail}}。", "attachmentEmpty": "此消息中的一张图片为空,因此消息未发送。", "attachmentTooLarge": "此消息中的一张图片过大,因此消息未发送。", "attachmentLargerThan": "此消息中的一张图片超过 {{size}} MB,因此消息未发送。", @@ -17784,7 +17784,7 @@ "attachmentUnreadable": "无法读取此消息中的附件,因此消息未发送。", "emptyMessage": "此消息为空,因此未发送。", "queueFull": "等待智能体处理的消息过多,因此此消息未发送。", - "writeFailed": "Orca 无法将此消息交给智能体,因此消息未发送。", + "writeFailed": "{{agent}} 未能收到这条消息。", "cancelled": "此消息在智能体开始处理前已撤回。", "chatClosed": "此消息发送前聊天已关闭。", "hostRestarted": "此消息发送前 Orca 已重新启动。", @@ -17792,14 +17792,14 @@ "notDeliveredSendAgain": "此消息未送达。请重新发送以继续。", "commandRefused": "此命令未运行。", "commandRefusedTryAgain": "此命令未运行。请重试。", - "compactionFailed": "压缩失败。", - "compactionFailedQuoted": "压缩失败:{{detail}}。", - "compactionUnconfirmed": "未确认压缩是否完成。", - "cancelUnconfirmed": "未确认取消。", + "compactionFailed": "{{agent}} 未能缩短此聊天的历史记录。", + "compactionFailedQuoted": "{{agent}} 未能缩短此聊天的历史记录:{{detail}}。", + "compactionUnconfirmed": "{{agent}} 尚未确认此聊天的历史记录已缩短。再次运行 /compact 前,请检查聊天。", + "cancelUnconfirmed": "{{agent}} 尚未确认已停止。重试前,请检查聊天。", "stopRefused": "{{agent}} 未停止。", "stopRefusedQuoted": "{{agent}} 未停止:{{detail}}。", - "noTurnToStop": "{{agent}} 没有正在运行的轮次可停止。", - "answerUnconfirmed": "您的回答已记录,但智能体未确认。", + "noTurnToStop": "{{agent}} 没有正在进行的回复可供停止。", + "answerUnconfirmed": "你的回答已保存,但 {{agent}} 尚未确认。再次回答前,请检查聊天。", "argumentsMissingValue": "已保存的参数需要为 {{option}} 指定一个值。", "argumentsMultipleValues": "已保存的参数为 {{option}} 指定了多个值。", "argumentsPositionalPrompt": "已保存的参数中包含提示词。", @@ -17807,7 +17807,7 @@ "hostFault": "Orca 遇到问题,因此此操作未完成。", "hostFaultTryAgain": "Orca 遇到问题,因此此操作未完成。请重试。", "hostStopped": "{{agent}} 始终未完成启动,因此 Orca 已将其停止。", - "providerRateLimited": "{{agent}} 已被限流,正在重试。", + "providerRateLimited": "{{agent}} 已达到请求限制,正在重试。", "providerRetrying": "{{agent}} 遇到临时问题,正在重试。", "providerRetryingQuoted": "{{agent}} 正在重试:{{detail}}。", "providerRetryNumber": "第 {{attempt}} 次重试。", 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-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index 10da640f9541..5a6361f1dad9 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}}).', @@ -56,8 +57,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: @@ -78,7 +79,7 @@ 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.', @@ -86,18 +87,21 @@ export const AGENT_SESSION_FAILURE_COPY = { 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.', + 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.", 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.', + 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.', providerRetryingQuoted: '{{agent}} is retrying: {{detail}}.', providerRetryNumber: 'Retry {{attempt}}.', diff --git a/src/shared/agent-session-failure-words.test.ts b/src/shared/agent-session-failure-words.test.ts index a909a1e2b90c..bb28ffcd283a 100644 --- a/src/shared/agent-session-failure-words.test.ts +++ b/src/shared/agent-session-failure-words.test.ts @@ -61,6 +61,61 @@ 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.', + '{"type":"error","message":"failed"}', + 'API Error: Request was aborted.', + '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('keeps a readable content explanation and does not suggest repeating an unsupported image', () => { + expect( + agentSessionFailureSentence( + { + kind: 'providerRejected', + detail: { text: 'Claude does not support the image type .bmp', audience: 'person' } + }, + 'rejection', + { agentName: 'Claude' } + ) + ).toBe("Claude didn't accept this message: Claude does not support the image type .bmp.") + }) + 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)) { @@ -202,14 +257,14 @@ 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 had no response in progress to stop.') + expect(refused()).toBe('The agent had no response in progress to stop.') expect(refused({ text: 'no active turn to interrupt.', audience: 'person' }, 'Codex')).toBe( "Codex didn't stop: no active turn to interrupt." ) // 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 had no response in progress to stop.' ) }) @@ -241,7 +296,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.') }) @@ -257,24 +312,24 @@ describe('the words written beside a failure fact', () => { }) ).toBe('Codex is retrying: Reconnecting... 2/5.\nstream disconnected before completion') 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 946e775639c9..249c7719e299 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,6 +25,8 @@ import { import type { AgentSessionWireRefusalCode } from './agent-session-wire-refusals' import { providerRetryWords, withRetryCause } from './agent-session-provider-retry-words' import { joinSentences } from './sentence-joining' +import { agentSessionAttachmentFailureWords } from './agent-session-attachment-failure-words' +import { isProviderDiagnosticPersonText } from './provider-diagnostic-person-text' import { DISPATCH_REJECTED_CANCELLED, DISPATCH_REJECTED_CODEX_QUEUE_FULL, @@ -93,7 +93,6 @@ export const START_REFUSAL_RESUMABLE: Record 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)]), @@ -237,16 +207,24 @@ 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'), @@ -254,13 +232,21 @@ const FAILURE_SENTENCES = { say(retryControl ? 'notDelivered' : 'notDeliveredSendAgain'), commandRefused: ({ retryControl }, _fact, _surface, say) => say(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'), + 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)), // The agent was reached and declined, so the sentence says that, not that the Stop was lost. stopRefused: (context, fact, _surface, say) => - fact.detail?.audience === 'person' + fact.detail?.audience === 'person' && isProviderDiagnosticPersonText(fact.detail.text) ? quotingPersonDetail( say, 'stopRefused', @@ -269,13 +255,14 @@ const FAILURE_SENTENCES = { 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)), // 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, 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/provider-diagnostic-person-text.test.ts b/src/shared/provider-diagnostic-person-text.test.ts new file mode 100644 index 000000000000..417bbf1413cf --- /dev/null +++ b/src/shared/provider-diagnostic-person-text.test.ts @@ -0,0 +1,33 @@ +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', + 'Not enough messages to compact.', + 'Too many requests', + 'Reconnecting... 2/5', + 'Uses {{agent}} $t(key) &' + ])('keeps readable words: %s', (text) => { + expect(isProviderDiagnosticPersonText(text)).toBe(true) + }) + + it.each([ + 'provider_write_failed: stand-in rejected the turn.', + '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', + 'HTTP 502 Bad Gateway', + 'RPC -32603', + '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..5cd42f23db0d --- /dev/null +++ b/src/shared/provider-diagnostic-person-text.ts @@ -0,0 +1,30 @@ +// An audience label cannot make protocol records, error codes or stack traces readable copy. +const TECHNICAL_TEXT = [ + /\b[a-zA-Z][a-zA-Z0-9]*_[a-zA-Z0-9_]+\b/, + /\b(?:[A-Za-z]*Error|Exception)\s*:/, + /\bE[A-Z][A-Z0-9]{2,}\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\w+\.(?:[cm]?[jt]s|py|rs|go):\d+(?::\d+)?\b/ +] + +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) && + !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 4aa598339d13..4170515d82ef 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-rejection-words.test.ts b/src/shared/structured-agent-session-rejection-words.test.ts index 7177b60d2f78..51d085dabeda 100644 --- a/src/shared/structured-agent-session-rejection-words.test.ts +++ b/src/shared/structured-agent-session-rejection-words.test.ts @@ -24,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", () => { @@ -49,8 +49,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')) }) }) @@ -59,7 +59,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." ) } }) @@ -129,7 +129,7 @@ function line( describe('the line under a message the host recorded and then rejected', () => { it("says why, in the host's words", () => { - expect(line(rejected())).toBe("Orca couldn't reach the agent. Your message was not sent.") + 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' ) diff --git a/src/shared/structured-agent-session-rejection-words.ts b/src/shared/structured-agent-session-rejection-words.ts index 4b814360f4fd..d0bc66be5ed4 100644 --- a/src/shared/structured-agent-session-rejection-words.ts +++ b/src/shared/structured-agent-session-rejection-words.ts @@ -14,6 +14,7 @@ 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. @@ -54,11 +55,13 @@ export function structuredAgentSessionRejectionParts( } const rejection = classifyDispatchRejection({ reason }) if (rejection.kind === 'writeFailed') { - return ['unreachable', ...agentSessionWriteNotDoneParts(write)] + return rejectionFactParts(write, { kind: 'writeFailed' }, context) } // 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 }] + return rejection.kind || !isProviderDiagnosticPersonText(reason) + ? agentSessionWriteNotDoneParts(write) + : [{ text: reason }] } function rejectionFactParts( @@ -67,9 +70,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 }] From 4ef5afa462216f909de28aa085734961f092bb9b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 02:04:51 -0700 Subject: [PATCH 16/27] Keep technical diagnostics out of chat failure instructions --- .../session/MobileNativeChatMessage.test.ts | 4 +- ...MobileNativeChatView.unsent-notice.test.ts | 2 +- .../mobile-native-chat-unsent-notices.test.ts | 26 ++-- ...le-structured-queued-message-cards.test.ts | 12 ++ .../mobile-structured-queued-message-cards.ts | 9 +- .../use-mobile-structured-agent-session.ts | 2 + ...bile-structured-queued-message-controls.ts | 5 +- .../codex/codex-app-server-connection.test.ts | 4 +- .../codex/codex-app-server-request-error.ts | 7 +- .../codex/codex-command-turn-claim.test.ts | 4 +- src/main/codex/codex-journal-command-turn.ts | 2 +- .../codex-provider-diagnostic-copy.test.ts | 36 ++++++ .../codex/codex-provider-retry-row.test.ts | 26 ++-- src/main/codex/codex-provider-retry-row.ts | 13 +- ...odex-structured-compaction-refusal.test.ts | 4 +- ...odex-structured-dispatch-admission.test.ts | 6 +- ...dex-structured-turn-end-settlement.test.ts | 4 +- .../codex-structured-turn-end-settlement.ts | 4 +- .../codex/codex-structured-turn-open-wait.ts | 2 +- .../codex/codex-structured-turn-steer.test.ts | 4 +- .../structured-agent-session-command-turn.ts | 13 +- ...ed-agent-session-conversation-stop.test.ts | 12 +- ...ed-agent-session-stop-failure-copy.test.ts | 116 ++++++++++++++++++ .../structured-agent-session-turns-cancel.ts | 10 +- ...structured-conversation-compaction.test.ts | 10 +- ...-session-codex-turn-end-settlement.test.ts | 2 +- ...hatQueuedMessageCard.failure-copy.test.tsx | 38 ++++++ .../NativeChatQueuedMessageCard.tsx | 11 +- .../NativeChatQueuedMessageList.tsx | 3 + .../NativeChatStructuredSession.tsx | 1 + ...gent-session-command-refusal-words-text.ts | 76 ++++++++++++ .../agent-session-failure-words-text.test.ts | 10 +- .../agent-session-failure-words-text.ts | 9 +- .../agent-session-write-notice-text.ts | 7 +- ...ructured-conversation-command-send.test.ts | 20 ++- .../structured-conversation-command-send.ts | 4 +- .../src/i18n/en-runtime-required.json | 17 ++- src/renderer/src/i18n/locales/en.json | 17 ++- src/renderer/src/i18n/locales/es.json | 17 ++- src/renderer/src/i18n/locales/fr.json | 17 ++- src/renderer/src/i18n/locales/ja.json | 17 ++- src/renderer/src/i18n/locales/ko.json | 17 ++- src/renderer/src/i18n/locales/zh.json | 17 ++- ...gent-session-command-refusal-words.test.ts | 94 ++++++++++++++ .../agent-session-command-refusal-words.ts | 55 +++++++++ src/shared/agent-session-failure-copy.ts | 17 ++- .../agent-session-failure-words.test.ts | 22 ++-- src/shared/agent-session-failure-words.ts | 26 ++-- src/shared/agent-session-failure.ts | 11 +- .../provider-diagnostic-person-text.test.ts | 4 + src/shared/provider-diagnostic-person-text.ts | 4 +- 51 files changed, 722 insertions(+), 148 deletions(-) create mode 100644 src/main/codex/codex-provider-diagnostic-copy.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-failure-copy.test.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.failure-copy.test.tsx create mode 100644 src/renderer/src/components/native-chat/agent-session-command-refusal-words-text.ts create mode 100644 src/shared/agent-session-command-refusal-words.test.ts create mode 100644 src/shared/agent-session-command-refusal-words.ts diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index 7ff56921c69f..0f334b0315c8 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -203,11 +203,11 @@ describe('MobileNativeChatMessage', () => { 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: "Orca couldn't reach the agent. Your message was not sent." } + { unsentNotice: "Claude couldn't receive this message. Send it again." } ) expect(textIn(tree.root)).toEqual([ 'hello', - "Orca couldn't reach the agent. Your message was not sent." + "Claude couldn't receive this message. Send it again." ]) }) diff --git a/mobile/src/session/MobileNativeChatView.unsent-notice.test.ts b/mobile/src/session/MobileNativeChatView.unsent-notice.test.ts index 814147104703..9dbe2dada9aa 100644 --- a/mobile/src/session/MobileNativeChatView.unsent-notice.test.ts +++ b/mobile/src/session/MobileNativeChatView.unsent-notice.test.ts @@ -112,6 +112,6 @@ it('puts the host words under a not-sent row, and nothing under a delivered one' ? element.props.unsentNotice : undefined } - expect(notice(0)).toBe("Orca couldn't reach the agent. Your message was not sent.") + expect(notice(0)).toBe("Claude couldn't receive this message. Send it again.") expect(notice(1)).toBeUndefined() }) diff --git a/mobile/src/session/mobile-native-chat-unsent-notices.test.ts b/mobile/src/session/mobile-native-chat-unsent-notices.test.ts index 11629abc466f..fc71c022a2e6 100644 --- a/mobile/src/session/mobile-native-chat-unsent-notices.test.ts +++ b/mobile/src/session/mobile-native-chat-unsent-notices.test.ts @@ -28,20 +28,14 @@ function rejected(patch: Partial = {}): AgentJournalSubm describe('the line under a message the host recorded and did not deliver', () => { 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: { - kind: 'writeFailed', - detail: { - text: 'provider_write_failed: stand-in rejected the turn.', - audience: 'log' - } - } - }) - ] + submissions: [rejected({ rejection })] }, 'codex' ) @@ -52,14 +46,14 @@ describe('the line under a message the host recorded and did not deliver', () => 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: { kind: 'providerRejected', detail: { text, audience: 'person' } } - }) - ] + submissions: [rejected({ rejection })] }, 'codex' ) 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 8ead70b20275..ec4b81ae9640 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,18 @@ function draft(overrides: Partial & { messageId: stri } describe('mobileQueuedMessageCards', () => { + 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('renders nothing without a published list', () => { expect(mobileQueuedMessageCards(null, [], { pendingPrompt: false })).toEqual([]) expect(mobileQueuedMessageCards([], [], { pendingPrompt: false })).toEqual([]) diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index d65a73388c3d..2871c21dfd4f 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -34,7 +34,8 @@ function queuedMessageBodyText(body: AgentSessionQueuedMessage['body']): string } function returnedCaption( - draft: Pick + draft: Pick, + agentName?: string ): string { const reason = draft.returnedReason ?? null const rejection = draft.returnedRejection @@ -46,7 +47,7 @@ function returnedCaption( return agentSessionWriteNoticeEnglish( structuredAgentSessionAttemptFailureParts( { kind: 'rejected', reason }, - { retryControl: true }, + { retryControl: true, agentName }, readWholeAgentSessionFailureFact(rejection) ) ) @@ -93,7 +94,7 @@ export function mobileQueuePauseLabel(pause: Pick[], - facts: { pendingPrompt: boolean; queuePaused?: boolean } + facts: { pendingPrompt: boolean; queuePaused?: boolean; agentName?: string } ): MobileQueuedMessageCard[] { if (!queuedMessages || queuedMessages.length === 0) { return [] @@ -114,7 +115,7 @@ export function mobileQueuedMessageCards( const paused = draft.paused === true const caption = draft.state === 'returned' - ? returnedCaption(draft) + ? returnedCaption(draft, facts.agentName) : paused ? pausedCaption(draft.pausedReason) : behindReturned diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 90b96b97b2c4..880394d06c6b 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -1,4 +1,5 @@ import { useCallback, useMemo, useRef } from 'react' +import { formatAgentTypeLabel } from '../../../src/shared/agent-type-label' import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' import type { MobileStructuredSendResult } from './mobile-structured-agent-session-send' import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' @@ -250,6 +251,7 @@ export function useMobileStructuredAgentSession(args: { ) const queued = useMobileStructuredQueuedMessageControls({ sessionKey, + ...(agent ? { agentName: formatAgentTypeLabel(agent) } : {}), queuedMessages, queuePause, submissions: state.submissions, diff --git a/mobile/src/session/use-mobile-structured-queued-message-controls.ts b/mobile/src/session/use-mobile-structured-queued-message-controls.ts index a786eeacbe09..804a3fd1a1f6 100644 --- a/mobile/src/session/use-mobile-structured-queued-message-controls.ts +++ b/mobile/src/session/use-mobile-structured-queued-message-controls.ts @@ -50,6 +50,7 @@ export function useMobileStructuredQueuedMessageControls(args: { queuePause: MobileQueuePause submissions: readonly AgentJournalSubmission[] pendingPrompt: boolean + agentName?: string mutate: MobileStructuredAgentMutate /** The active pane's live composer, Edit's copy target; absent = Edit refuses. False when * nothing was copied. */ @@ -60,6 +61,7 @@ export function useMobileStructuredQueuedMessageControls(args: { }): MobileStructuredQueuedMessageControls { const { appendComposerText, + agentName, mutate, onActionResolved, onSendError, @@ -73,9 +75,10 @@ export function useMobileStructuredQueuedMessageControls(args: { () => mobileQueuedMessageCards(queuedMessages, submissions, { pendingPrompt, + agentName, queuePaused: queuePause !== null }), - [pendingPrompt, queuePause, queuedMessages, submissions] + [agentName, pendingPrompt, queuePause, queuedMessages, submissions] ) const resolved = useCallback( (accepted: boolean): boolean => { 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 d1115cb1f37b..4e3cd8926d60 100644 --- a/src/main/codex/codex-app-server-request-error.ts +++ b/src/main/codex/codex-app-server-request-error.ts @@ -1,6 +1,8 @@ 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 { /** Codex's own `error.message`, kept apart from the Orca text around it. */ @@ -19,7 +21,10 @@ export class CodexAppServerRequestError extends Error { ? undefined : providerDiagnostic( providerMessage, - isProviderDiagnosticPersonText(providerMessage) ? 'person' : 'log' + !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 1fc6644608c3..2aeea78bd135 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: "Codex couldn't shorten this chat's history: 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' } diff --git a/src/main/codex/codex-journal-command-turn.ts b/src/main/codex/codex-journal-command-turn.ts index 90e2f556b845..cd82cea51431 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..8f2c9da65735 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,8 +222,8 @@ 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: 'warning', text: 'Codex hit a temporary problem and is retrying.' }, + { tone: 'warning', text: 'Codex hit a temporary problem and is retrying.' }, { tone: 'error', text: cause } ]) expect(reduced(rows).map((row) => row.body)).toContainEqual( diff --git a/src/main/codex/codex-provider-retry-row.ts b/src/main/codex/codex-provider-retry-row.ts index 48b0c8eef40b..35fc38fe5beb 100644 --- a/src/main/codex/codex-provider-retry-row.ts +++ b/src/main/codex/codex-provider-retry-row.ts @@ -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 @@ -33,15 +32,9 @@ export function isCodexProviderRetryFrame(event: CodexStructuredSessionEvent): b export function codexProviderRetryRowBody(payload: unknown): 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 detail = message ? providerDiagnostic(message, 'log') : undefined + // Transport details remain in providerFrame; neither error field is a person-facing explanation. + const retry = readProviderRetry(readCodexErrorInfo(payload)) const words = agentSessionFailureWords( agentSessionFailureFact('providerRetrying', { ...(detail ? { detail } : {}), diff --git a/src/main/codex/codex-structured-compaction-refusal.test.ts b/src/main/codex/codex-structured-compaction-refusal.test.ts index c1c8e262e3c3..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: "Codex didn't 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-dispatch-admission.test.ts b/src/main/codex/codex-structured-dispatch-admission.test.ts index 4b28768926d8..1be3d1b02bc9 100644 --- a/src/main/codex/codex-structured-dispatch-admission.test.ts +++ b/src/main/codex/codex-structured-dispatch-admission.test.ts @@ -175,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: "Codex didn't 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-turn-end-settlement.test.ts b/src/main/codex/codex-structured-turn-end-settlement.test.ts index 2b6542a43336..e04dc5f4c717 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 524c13156b6f..4e632de25328 100644 --- a/src/main/codex/codex-structured-turn-end-settlement.ts +++ b/src/main/codex/codex-structured-turn-end-settlement.ts @@ -52,7 +52,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 @@ -72,7 +72,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 return { status: 'failed', ...(detail ? { detail } : {}) } } 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-steer.test.ts b/src/main/codex/codex-structured-turn-steer.test.ts index e6c654c00775..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: "Codex didn't 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-wire/structured-agent-session-command-turn.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts index e0130423d5e2..001a688be16d 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 @@ -313,7 +313,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) { @@ -321,10 +321,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 81ccdcc31593..54c0658c6e48 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' } }) ) @@ -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 had no response in progress to stop.']) }) 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 () => { @@ -701,7 +703,7 @@ describe('a Stop that names no turn', () => { expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) expect(await statusRows()).toEqual([ - "The agent hasn't confirmed that it stopped. Check the chat before trying again." + "Codex hasn't confirmed that it stopped. Check the chat before trying again." ]) }) @@ -709,7 +711,7 @@ describe('a Stop that names no turn', () => { 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 } }) 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..8a84a32a5508 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-failure-copy.test.ts @@ -0,0 +1,116 @@ +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 { + 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']) { + const journal = await journals.open({ identity: identityFor('session-1'), stateDirectory: root }) + await journal.appendItem( + { provider: 'codex', threadId: 'thread-abc', turnId: 'turn-1', ordinal: 0 }, + { kind: 'turn', turnId: 'turn-1', 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('records the positive no-running-turn distinction', 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 had no response in progress to stop.', + failure: { 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 8e6382b8d0d3..85c0e48463da 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 @@ -392,7 +392,12 @@ it('refuses the command at handover when the provider opened a turn meanwhile (B } await expect(commanded).resolves.toMatchObject({ ok: true, - value: { state: 'completed', error: "This command didn't run. Try it again.", failure: refused } + value: { + state: 'completed', + error: + "Codex didn't run this command. Codex is still responding. Wait for it to finish, or stop it.", + failure: refused + } }) expect(compact).not.toHaveBeenCalled() expect(await commandTurn(params.envelope.clientOperationId)).toBeUndefined() @@ -402,7 +407,8 @@ it('refuses the command at handover when the provider opened a turn meanwhile (B ) ).toMatchObject({ dispatchState: 'rejected', - reason: "This command didn't run. Try it again.", + reason: + "Codex didn't run this command. Codex is still responding. Wait for it to finish, or stop it.", rejection: refused }) }) 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..35ab9ca44414 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 @@ -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 had no response in progress to stop.') }) it('lets a chat closed behind it close within its bound and one eviction', async () => { 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..5c52061ac41c --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.failure-copy.test.tsx @@ -0,0 +1,38 @@ +// @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 { NativeChatQueuedMessageCard } from './NativeChatQueuedMessageCard' + +vi.mock('@/i18n/i18n', () => ({ + 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 8c09a70a2d1c..9b76ffc27d81 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -30,7 +30,10 @@ import { import { queuedCardSenderLine } from './native-chat-agent-message-sender-label' /** The visible caption under the text; the default waiting hold needs none. */ -export function queuedMessageCardCaption(card: QueuedMessageCard): string | null { +export function queuedMessageCardCaption( + card: QueuedMessageCard, + agentName?: string +): string | null { switch (card.hold) { case 'returned': { // Read exactly as a rejected submission: the typed fact decides, the reason is the fallback. @@ -50,7 +53,7 @@ export function queuedMessageCardCaption(card: QueuedMessageCard): string | null structuredAgentSessionAttemptFailureParts( { kind: 'rejected', reason }, // The card's own Send is the retry, so the words leave out sending again. - { retryControl: true }, + { retryControl: true, agentName }, readWholeAgentSessionFailureFact(card.returnedRejection) ) ) @@ -108,6 +111,7 @@ export function queuedMessageCardSendNow(card: QueuedMessageCard): { export function NativeChatQueuedMessageCard({ card, + agentName, showsSteerShortcut, steerHeld = false, onSteer, @@ -116,6 +120,7 @@ export function NativeChatQueuedMessageCard({ onTurnOffQueueing }: { card: QueuedMessageCard + agentName?: string /** Only the newest card answers Cmd/Ctrl+Enter; only it may show the chord. */ showsSteerShortcut: boolean /** The chat reads Stopping: the card waits for the stop (`NativeChatQueuedMessageList`). */ @@ -126,7 +131,7 @@ export function NativeChatQueuedMessageCard({ /** Absent when the host does not queue sends, so there is nothing to turn off. */ onTurnOffQueueing?: () => void }): React.JSX.Element { - const caption = queuedMessageCardCaption(card) + const caption = queuedMessageCardCaption(card, agentName) const returned = card.state === 'returned' const sendNow = queuedMessageCardSendNow(card) const isMac = isMacPlatform() diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index ea5b454f9974..c811fe6354d2 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -13,10 +13,12 @@ import type { StructuredAgentSessionQueuedMessagesController } from './use-struc */ export function NativeChatQueuedMessageList({ controller, + agentName, steerHeld = false, focusComposer }: { controller: StructuredAgentSessionQueuedMessagesController + agentName?: string /** The chat reads Stopping: no card steers into the turn a Stop is ending. */ steerHeld?: boolean /** Where focus goes once Steer, Edit or Delete takes the focused card away. */ @@ -64,6 +66,7 @@ export function NativeChatQueuedMessageList({ refocusAfter(controller.steer(card.messageId))} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 5d34f797df60..de8251137480 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -288,6 +288,7 @@ export function NativeChatStructuredSession( {/* Host-held drafts, never transcript rows. Above the status area, so running shells and agents sit next to the composer. */} 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..f93176f0bd43 --- /dev/null +++ b/src/renderer/src/components/native-chat/agent-session-command-refusal-words-text.ts @@ -0,0 +1,76 @@ +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 +> = { + 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 87b89901de7d..b9c1b233e45b 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 @@ -201,18 +201,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 n'avait aucune réponse en cours à arrêter." + 'Codex ne s’est pas arrêté. Vérifiez le chat avant de réessayer.' ) 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 e6a849f88cf7..a2e777aa68fe 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, @@ -189,13 +190,7 @@ const PIECES: Record - translate('components.native-chat.failureWords.commandRefused', COPY.commandRefused), - commandRefusedTryAgain: () => - translate( - 'components.native-chat.failureWords.commandRefusedTryAgain', - COPY.commandRefusedTryAgain - ), + ...COMMAND_REFUSAL_PIECES, compactionFailed: (values) => translate( 'components.native-chat.failureWords.compactionFailed', 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 67029a9c0c7f..519f53a1d9b5 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 @@ -138,6 +138,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) => @@ -149,7 +153,8 @@ export function agentSessionWriteNoticeText(parts: readonly AgentSessionWriteNot part.failure, part.surface, part.context, - sayAgentSessionFailureTranslated + sayAgentSessionFailureTranslated, + sayAgentSessionWriteNoticeTranslated ) ) ) 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 498e9d4fc011..77e3eb2aaf11 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 @@ -104,7 +104,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." @@ -121,6 +121,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." 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 35c9bf71aeb5..4da3bc76486e 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 @@ -10,6 +10,7 @@ import { import { agentSessionFailureSentence } from '../../../../shared/agent-session-failure-words' import { translate } from '@/i18n/i18n' import { sayAgentSessionFailureTranslated } from './agent-session-failure-words-text' +import { sayAgentSessionWriteNoticeTranslated } from './agent-session-write-notice-text' import { agentSessionFailureStatedByStartRow } from '../../../../shared/structured-agent-session-start-failure-facts' import type { StructuredAgentSessionWriteOutcome } from './use-structured-agent-session-mutate' @@ -79,6 +80,7 @@ function conversationCommandFailureText( fact, 'row', { agentName, command: result.command }, - sayAgentSessionFailureTranslated + sayAgentSessionFailureTranslated, + sayAgentSessionWriteNoticeTranslated ) } diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index cc5a4991e4cb..8cdc3ef6f91e 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2845,8 +2845,17 @@ "chooseClaudeAccountThenSend": "Choose or add one in Claude Accounts settings, then send your message again.", "claudeAccountFolderMissing": "The selected Claude account's folder is missing. Sign in to it again or choose another account.", "claudeAccountSetupFailed": "The selected Claude account could not be set up. Try again or choose another account.", - "commandRefused": "This command didn't run.", - "commandRefusedTryAgain": "This command didn't run. Try it again.", + "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.", @@ -2887,8 +2896,8 @@ "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", "writeFailed": "{{agent}} couldn't receive this message." }, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2d5c799655f9..a71b1bd8d719 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18018,14 +18018,23 @@ "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.", + "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.", - "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.", "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.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 38d4259b8249..c0a42e9ab9fb 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17790,14 +17790,23 @@ "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.", + "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.", - "stopRefusedQuoted": "{{agent}} no se detuvo: {{detail}}.", + "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}}.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 6e9db8dc40ae..6a3388e8aee5 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17891,14 +17891,23 @@ "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.", + "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é.", - "stopRefusedQuoted": "{{agent}} ne s'est pas arrêté : {{detail}}.", + "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}}.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 95c6a8172241..63f4edaa7677 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17825,14 +17825,23 @@ "hostRestarted": "このメッセージが送信される前に Orca が再起動しました。", "notDelivered": "このメッセージは配信されませんでした。", "notDeliveredSendAgain": "このメッセージは配信されませんでした。続けるには、もう一度送信してください。", - "commandRefused": "このコマンドは実行されませんでした。", - "commandRefusedTryAgain": "このコマンドは実行されませんでした。もう一度お試しください。", + "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}}。", + "stopRefused": "{{agent}} は停止しませんでした。再試行する前にチャットを確認してください。", + "stopRefusedQuoted": "{{agent}} は停止しませんでした:{{detail}}。再試行する前にチャットを確認してください。", "noTurnToStop": "{{agent}} に停止できる応答はありませんでした。", "answerUnconfirmed": "回答は保存されましたが、{{agent}} は受け取ったことを確認していません。もう一度回答する前にチャットを確認してください。", "argumentsMissingValue": "保存された引数の {{option}} に値が必要です。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d9f7c43ee6a8..642bfa4b95c1 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17825,14 +17825,23 @@ "hostRestarted": "이 메시지가 전송되기 전에 Orca가 다시 시작되었습니다.", "notDelivered": "이 메시지는 전달되지 않았습니다.", "notDeliveredSendAgain": "이 메시지는 전달되지 않았습니다. 계속하려면 다시 보내세요.", - "commandRefused": "이 명령이 실행되지 않았습니다.", - "commandRefusedTryAgain": "이 명령이 실행되지 않았습니다. 다시 시도하세요.", + "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}}.", + "stopRefused": "{{agent}}이(가) 중지되지 않았습니다. 다시 시도하기 전에 채팅을 확인하세요.", + "stopRefusedQuoted": "{{agent}}이(가) 중지되지 않았습니다: {{detail}}. 다시 시도하기 전에 채팅을 확인하세요.", "noTurnToStop": "{{agent}}에 중지할 진행 중인 응답이 없었습니다.", "answerUnconfirmed": "답변은 저장되었지만 {{agent}}이(가) 확인하지 않았습니다. 다시 답변하기 전에 채팅을 확인하세요.", "argumentsMissingValue": "저장된 인수의 {{option}}에 값이 필요합니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 34770bb78120..f66e5a6b175b 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17790,14 +17790,23 @@ "hostRestarted": "此消息发送前 Orca 已重新启动。", "notDelivered": "此消息未送达。", "notDeliveredSendAgain": "此消息未送达。请重新发送以继续。", - "commandRefused": "此命令未运行。", - "commandRefusedTryAgain": "此命令未运行。请重试。", + "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}}。", + "stopRefused": "{{agent}} 未停止。请先检查聊天,再重试。", + "stopRefusedQuoted": "{{agent}} 未停止:{{detail}}。请先检查聊天,再重试。", "noTurnToStop": "{{agent}} 没有正在进行的回复可供停止。", "answerUnconfirmed": "你的回答已保存,但 {{agent}} 尚未确认。再次回答前,请检查聊天。", "argumentsMissingValue": "已保存的参数需要为 {{option}} 指定一个值。", 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..50ee061cd99f --- /dev/null +++ b/src/shared/agent-session-command-refusal-words.test.ts @@ -0,0 +1,94 @@ +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('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('retains a positive no-running-turn verdict when reading a stored Stop fact', () => { + const fact = { kind: 'stopRefused', turnNotRunning: true } as const + expect(readWholeAgentSessionFailureFact(fact)).toEqual(fact) + expect(agentSessionFailureSentence(fact, 'row', { agentName: 'Codex' })).toBe( + 'Codex had no response in progress to stop.' + ) + 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 new file mode 100644 index 000000000000..b920542ad9a2 --- /dev/null +++ b/src/shared/agent-session-command-refusal-words.ts @@ -0,0 +1,55 @@ +import type { AgentSessionFailureFact } from './agent-session-failure' +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' + +const NAMED_CAUSES: Partial> = { + agentStarting: 'commandAgentStarting', + waitForStart: 'commandWaitForStart', + turnActive: 'commandTurnActive', + waitForTurn: 'commandWaitForTurn', + promptPending: 'commandPromptPending', + agentRefused: 'commandAgentRefused', + ownerUnproven: 'commandOwnerUnproven', + goalsUnsupported: 'commandGoalsUnsupported', + optionRejected: 'commandOptionRejected' +} + +/** A refused command keeps the refusal's action; repeating it cannot clear every refusal. */ +export function agentSessionCommandRefusalWords( + fact: AgentSessionFailureFact, + context: AgentSessionFailureWordsContext, + say: AgentSessionFailureSay, + 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 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 5a6361f1dad9..e7f0c546cf18 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -85,16 +85,25 @@ export const AGENT_SESSION_FAILURE_COPY = { 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.", + 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.", - 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.", 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.", diff --git a/src/shared/agent-session-failure-words.test.ts b/src/shared/agent-session-failure-words.test.ts index bb28ffcd283a..e0047160ae7b 100644 --- a/src/shared/agent-session-failure-words.test.ts +++ b/src/shared/agent-session-failure-words.test.ts @@ -84,6 +84,7 @@ describe('the words written beside a failure fact', () => { '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) { @@ -103,17 +104,20 @@ describe('the words written beside a failure fact', () => { } }) - it('keeps a readable content explanation and does not suggest repeating an unsupported image', () => { + 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: 'Claude does not support the image type .bmp', audience: 'person' } + detail: { text, audience: 'person' } }, 'rejection', { agentName: 'Claude' } ) - ).toBe("Claude didn't accept this message: Claude does not support the image type .bmp.") + ).toBe(`Claude didn't accept this message: ${text}.`) }) describe.each(SURFACES)('on the %s surface', (surface) => { @@ -257,14 +261,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 response in progress to stop.') - expect(refused()).toBe('The agent had no response in progress 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 response in progress to stop.' + "Codex didn't stop. Check the chat before trying again." ) }) @@ -310,7 +316,7 @@ 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 reached a request limit and is retrying.\nToo many requests' ) diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 249c7719e299..d6ec7d801186 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -27,6 +27,11 @@ import { providerRetryWords, withRetryCause } from './agent-session-provider-ret 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, @@ -98,7 +103,8 @@ type Sentence = ( context: AgentSessionFailureWordsContext, fact: AgentSessionFailureFact, surface: AgentSessionFailureSurface, - say: AgentSessionFailureSay + say: AgentSessionFailureSay, + sayNotice: (id: AgentSessionWriteNoticeSentence) => string ) => string function agent(say: AgentSessionFailureSay, { agentName }: AgentSessionFailureWordsContext) { @@ -230,8 +236,8 @@ const FAILURE_SENTENCES = { hostRestarted: (_context, _fact, _surface, say) => say('hostRestarted'), notDelivered: ({ retryControl }, _fact, _surface, say) => say(retryControl ? 'notDelivered' : 'notDeliveredSendAgain'), - commandRefused: ({ retryControl }, _fact, _surface, say) => - say(retryControl ? 'commandRefused' : 'commandRefusedTryAgain'), + commandRefused: (context, fact, _surface, say, sayNotice) => + agentSessionCommandRefusalWords(fact, context, say, sayNotice), compactionFailed: (context, fact, _surface, say) => quotingPersonDetail( say, @@ -246,15 +252,15 @@ const FAILURE_SENTENCES = { say('cancelUnconfirmed', agent(say, context)), // The agent was reached and declined, so the sentence says that, not that the Stop was lost. stopRefused: (context, fact, _surface, say) => - fact.detail?.audience === 'person' && isProviderDiagnosticPersonText(fact.detail.text) - ? quotingPersonDetail( + fact.turnNotRunning + ? say('noTurnToStop', agent(say, context)) + : quotingPersonDetail( say, 'stopRefused', 'stopRefusedQuoted', fact.detail, agent(say, context) - ) - : say('noTurnToStop', agent(say, context)), + ), answerUnconfirmed: (context, _fact, _surface, say) => say('answerUnconfirmed', agent(say, context)), hostFault: ({ retryControl }, _fact, _surface, say) => @@ -286,10 +292,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 cca1b1c9b27a..44fc80ffde13 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -51,7 +51,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', @@ -154,6 +154,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 @@ -186,6 +188,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. @@ -198,7 +201,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 } : {}) } } @@ -272,7 +276,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/provider-diagnostic-person-text.test.ts b/src/shared/provider-diagnostic-person-text.test.ts index 417bbf1413cf..7ef6914c9090 100644 --- a/src/shared/provider-diagnostic-person-text.test.ts +++ b/src/shared/provider-diagnostic-person-text.test.ts @@ -4,6 +4,7 @@ import { isProviderDiagnosticPersonText } from './provider-diagnostic-person-tex 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', @@ -22,6 +23,9 @@ describe('provider explanations a person can read', () => { 'write EPIPE', '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"}', diff --git a/src/shared/provider-diagnostic-person-text.ts b/src/shared/provider-diagnostic-person-text.ts index 5cd42f23db0d..606637a80b23 100644 --- a/src/shared/provider-diagnostic-person-text.ts +++ b/src/shared/provider-diagnostic-person-text.ts @@ -1,6 +1,6 @@ // An audience label cannot make protocol records, error codes or stack traces readable copy. const TECHNICAL_TEXT = [ - /\b[a-zA-Z][a-zA-Z0-9]*_[a-zA-Z0-9_]+\b/, + /^\s*[a-zA-Z][a-zA-Z0-9]*_[a-zA-Z0-9_]+(?:\s*:|\s*$)/, /\b(?:[A-Za-z]*Error|Exception)\s*:/, /\bE[A-Z][A-Z0-9]{2,}\b/, /\b(?:HTTP\/\d|(?:HTTP|RPC|JSON-RPC)\s+(?:[-+]?\d+|error|response))/i, @@ -8,6 +8,8 @@ const TECHNICAL_TEXT = [ /[[{]\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/ ] From 5822e86f55709e070cdea2597d329a21cef1c80a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 02:30:03 -0700 Subject: [PATCH 17/27] Keep final agent errors and older rejection copy readable --- .../mobile-native-chat-unsent-notices.test.ts | 13 +++++++++ ...le-structured-queued-message-cards.test.ts | 12 ++++++++ .../codex/codex-command-turn-claim.test.ts | 7 ++++- .../codex/codex-provider-retry-row.test.ts | 12 +++++++- src/main/codex/codex-provider-retry-row.ts | 27 ++++++++++++----- ...ctured-journal-translation-streams.test.ts | 9 +++++- ...dex-structured-journal-translation.test.ts | 2 +- .../codex-structured-journal-translation.ts | 29 +++++++++++-------- .../agent-session-failure-words-text.ts | 8 +++++ .../src/i18n/en-runtime-required.json | 2 ++ src/renderer/src/i18n/locales/en.json | 2 ++ src/renderer/src/i18n/locales/es.json | 2 ++ src/renderer/src/i18n/locales/fr.json | 2 ++ src/renderer/src/i18n/locales/ja.json | 2 ++ src/renderer/src/i18n/locales/ko.json | 2 ++ src/renderer/src/i18n/locales/zh.json | 2 ++ src/shared/agent-session-failure-copy.ts | 3 ++ .../agent-session-failure-words.test.ts | 1 + src/shared/agent-session-failure-words.ts | 8 +++++ src/shared/agent-session-failure.ts | 3 ++ .../provider-diagnostic-person-text.test.ts | 3 ++ src/shared/provider-diagnostic-person-text.ts | 3 +- ...ured-agent-session-rejection-words.test.ts | 18 +++++++++--- ...tructured-agent-session-rejection-words.ts | 16 ++++++---- 24 files changed, 154 insertions(+), 34 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-unsent-notices.test.ts b/mobile/src/session/mobile-native-chat-unsent-notices.test.ts index fc71c022a2e6..936bf8055fc0 100644 --- a/mobile/src/session/mobile-native-chat-unsent-notices.test.ts +++ b/mobile/src/session/mobile-native-chat-unsent-notices.test.ts @@ -27,6 +27,19 @@ function rejected(patch: Partial = {}): AgentJournalSubm } 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', 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 ec4b81ae9640..7f8eabacc32b 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,18 @@ 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) => { diff --git a/src/main/codex/codex-command-turn-claim.test.ts b/src/main/codex/codex-command-turn-claim.test.ts index 2aeea78bd135..a72b3a9bb831 100644 --- a/src/main/codex/codex-command-turn-claim.test.ts +++ b/src/main/codex/codex-command-turn-claim.test.ts @@ -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-provider-retry-row.test.ts b/src/main/codex/codex-provider-retry-row.test.ts index 8f2c9da65735..35b5173eaa7c 100644 --- a/src/main/codex/codex-provider-retry-row.test.ts +++ b/src/main/codex/codex-provider-retry-row.test.ts @@ -224,8 +224,18 @@ describe('a Codex stream error it is about to retry', () => { expect(errorFrameRows).toEqual([ { 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: cause } + { + 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 35fc38fe5beb..ff794bfe8aad 100644 --- a/src/main/codex/codex-provider-retry-row.ts +++ b/src/main/codex/codex-provider-retry-row.ts @@ -1,6 +1,4 @@ -// 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, @@ -31,12 +29,25 @@ export function isCodexProviderRetryFrame(event: CodexStructuredSessionEvent): b } export function codexProviderRetryRowBody(payload: unknown): AgentJournalStatusItem { + return codexProviderErrorRowBody(payload, 'providerRetrying') +} + +export function codexProviderFinalErrorRowBody(payload: unknown): AgentJournalStatusItem { + return codexProviderErrorRowBody(payload, 'providerError') +} + +function codexProviderErrorRowBody( + payload: unknown, + kind: 'providerRetrying' | 'providerError' +): AgentJournalStatusItem { const message = readCodexErrorMessage(payload) - const detail = message ? providerDiagnostic(message, 'log') : undefined - // Transport details remain in providerFrame; neither error field is a person-facing explanation. - const retry = readProviderRetry(readCodexErrorInfo(payload)) + 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', { + agentSessionFailureFact(kind, { ...(detail ? { detail } : {}), ...(retry ? { retry } : {}) }), @@ -44,7 +55,7 @@ export function codexProviderRetryRowBody(payload: unknown): AgentJournalStatusI ) return { kind: 'status', - tone: 'warning', + tone: kind === 'providerRetrying' ? 'warning' : 'error', ...words, providerFrame: { provider: 'codex', 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..aad45d34c768 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), + 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/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 a2e777aa68fe..a6390dbc2d5e 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 @@ -248,6 +248,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/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 8cdc3ef6f91e..e67398b54cb5 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2886,6 +2886,8 @@ "providerRetryNumber": "Retry {{attempt}}.", "providerRetryNumberOf": "Retry {{attempt}} of {{maxRetries}}.", "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}}.", "providerStartFailed": "{{agent}} stopped before it finished starting.", "queueFull": "Too many messages were waiting for the agent, so this one was not sent.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a71b1bd8d719..0948f3e2552a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18042,6 +18042,8 @@ "hostStopped": "{{agent}} never finished starting, so Orca stopped it.", "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/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index c0a42e9ab9fb..19600a192bde 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17818,6 +17818,8 @@ "hostStopped": "{{agent}} no terminó de iniciarse, así que Orca lo detuvo.", "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}}.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 6a3388e8aee5..1eda8162ec8f 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17919,6 +17919,8 @@ "hostStopped": "{{agent}} n'a jamais fini de démarrer, Orca l'a donc arrêté.", "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}}.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 63f4edaa7677..ea6a4bcfc9b9 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17853,6 +17853,8 @@ "hostStopped": "{{agent}} の起動が完了しなかったため、Orca が停止しました。", "providerRateLimited": "{{agent}} はリクエストの上限に達したため、再試行しています。", "providerRetrying": "{{agent}} で一時的な問題が発生したため、再試行しています。", + "providerError": "{{agent}} で問題が発生しました。再試行する前にチャットを確認してください。", + "providerErrorQuoted": "{{agent}} で問題が発生しました:{{detail}}。再試行する前にチャットを確認してください。", "providerRetryingQuoted": "{{agent}} は再試行しています: {{detail}}。", "providerRetryNumber": "{{attempt}} 回目の再試行です。", "providerRetryNumberOf": "{{maxRetries}} 回中 {{attempt}} 回目の再試行です。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 642bfa4b95c1..7154492aaac8 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17853,6 +17853,8 @@ "hostStopped": "{{agent}}이(가) 시작을 마치지 못해 Orca가 중지했습니다.", "providerRateLimited": "{{agent}}이(가) 요청 한도에 도달하여 다시 시도하고 있습니다.", "providerRetrying": "{{agent}}에 일시적인 문제가 발생하여 다시 시도하고 있습니다.", + "providerError": "{{agent}}에서 문제가 발생했습니다. 다시 시도하기 전에 채팅을 확인하세요.", + "providerErrorQuoted": "{{agent}}에서 문제가 발생했습니다: {{detail}}. 다시 시도하기 전에 채팅을 확인하세요.", "providerRetryingQuoted": "{{agent}}이(가) 다시 시도하고 있습니다: {{detail}}.", "providerRetryNumber": "{{attempt}}번째 재시도입니다.", "providerRetryNumberOf": "{{maxRetries}}회 중 {{attempt}}번째 재시도입니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index f66e5a6b175b..66f6cefa8649 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17818,6 +17818,8 @@ "hostStopped": "{{agent}} 始终未完成启动,因此 Orca 已将其停止。", "providerRateLimited": "{{agent}} 已达到请求限制,正在重试。", "providerRetrying": "{{agent}} 遇到临时问题,正在重试。", + "providerError": "{{agent}} 遇到了问题。请先检查聊天,再重试。", + "providerErrorQuoted": "{{agent}} 遇到了问题:{{detail}}。请先检查聊天,再重试。", "providerRetryingQuoted": "{{agent}} 正在重试:{{detail}}。", "providerRetryNumber": "第 {{attempt}} 次重试。", "providerRetryNumberOf": "第 {{attempt}} 次重试,共 {{maxRetries}} 次。", diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index e7f0c546cf18..1364bb25aee7 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -112,6 +112,9 @@ export const AGENT_SESSION_FAILURE_COPY = { hostStopped: '{{agent}} never finished starting, so Orca stopped it.', 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 e0047160ae7b..98dc9f5aa4ae 100644 --- a/src/shared/agent-session-failure-words.test.ts +++ b/src/shared/agent-session-failure-words.test.ts @@ -82,6 +82,7 @@ describe('the words written beside a failure fact', () => { 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)', diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index d6ec7d801186..6887442fa9bb 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -266,6 +266,14 @@ const FAILURE_SENTENCES = { 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' && isProviderDiagnosticPersonText(detail.text) diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index 44fc80ffde13..16e3702a67cb 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -59,6 +59,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. */ @@ -78,6 +80,7 @@ const STATUS_ROW_ONLY_FAILURE_KINDS = [ 'stopRefused', 'answerUnconfirmed', 'providerRetrying', + 'providerError', 'previousExitUnverifiable', 'sessionNotRestored' ] as const satisfies readonly AgentSessionFailureKind[] diff --git a/src/shared/provider-diagnostic-person-text.test.ts b/src/shared/provider-diagnostic-person-text.test.ts index 7ef6914c9090..e05bd071620f 100644 --- a/src/shared/provider-diagnostic-person-text.test.ts +++ b/src/shared/provider-diagnostic-person-text.test.ts @@ -15,6 +15,9 @@ describe('provider explanations a person can read', () => { 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}', diff --git a/src/shared/provider-diagnostic-person-text.ts b/src/shared/provider-diagnostic-person-text.ts index 606637a80b23..490f14d933ad 100644 --- a/src/shared/provider-diagnostic-person-text.ts +++ b/src/shared/provider-diagnostic-person-text.ts @@ -1,6 +1,7 @@ // An audience label cannot make protocol records, error codes or stack traces readable copy. const TECHNICAL_TEXT = [ - /^\s*[a-zA-Z][a-zA-Z0-9]*_[a-zA-Z0-9_]+(?:\s*:|\s*$)/, + /(?:^|:\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/, /\b(?:HTTP\/\d|(?:HTTP|RPC|JSON-RPC)\s+(?:[-+]?\d+|error|response))/i, diff --git a/src/shared/structured-agent-session-rejection-words.test.ts b/src/shared/structured-agent-session-rejection-words.test.ts index 51d085dabeda..9976a59f4a96 100644 --- a/src/shared/structured-agent-session-rejection-words.test.ts +++ b/src/shared/structured-agent-session-rejection-words.test.ts @@ -33,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.') diff --git a/src/shared/structured-agent-session-rejection-words.ts b/src/shared/structured-agent-session-rejection-words.ts index d0bc66be5ed4..4740d1c32ac0 100644 --- a/src/shared/structured-agent-session-rejection-words.ts +++ b/src/shared/structured-agent-session-rejection-words.ts @@ -57,11 +57,17 @@ export function structuredAgentSessionRejectionParts( if (rejection.kind === 'writeFailed') { return rejectionFactParts(write, { kind: 'writeFailed' }, context) } - // 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 || !isProviderDiagnosticPersonText(reason) - ? agentSessionWriteNotDoneParts(write) - : [{ text: reason }] + if (rejection.kind) { + return agentSessionWriteNotDoneParts(write) + } + // 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) || /\bprovider\b/i.test(reason)) { + return parts + } + const alreadyNamed = + !context.agentName || reason.toLowerCase().includes(context.agentName.toLowerCase()) + return alreadyNamed ? [{ text: reason }] : [...parts, { text: reason }] } function rejectionFactParts( From 37920c03705e9ddac35a5f61c8751162c85e0c0d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 03:12:19 -0700 Subject: [PATCH 18/27] Expect safe transport retry copy on phone --- .../use-mobile-structured-agent-session-retry-rows.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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.' }) ] ]) From 91751ae7c9e87e63080bdca5030a1194bfb62a3b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 03:29:12 -0700 Subject: [PATCH 19/27] Expect safe transport retry copy on desktop --- .../NativeChatMessageList.provider-retry-runs.test.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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.') }) }) From 3c06915737cbb63209288f0d8ea9a8024a285145 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 03:48:04 -0700 Subject: [PATCH 20/27] Align failure assertions and SQLite test routing --- config/scripts/vitest-sqlite-runtime-files.mjs | 1 + .../codex-structured-conversation-stop.test.ts | 2 +- .../codex-structured-turn-open-wait.test.ts | 5 +++-- ...ructured-agent-session-codex-stop-row.test.ts | 2 +- ...ctured-agent-session-send-preparation.test.ts | 6 +++--- src/shared/agent-session-refusal-notice.test.ts | 16 ++++++++++------ 6 files changed, 19 insertions(+), 13 deletions(-) diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 1655071b95ad..70e89ce5ab11 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -136,6 +136,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/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-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/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-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index f1bbd673cd62..50d3b6de41f3 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 @@ -426,7 +426,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( @@ -434,10 +434,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/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index 4ca0ebf2f4f5..f6965020f6a6 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -156,8 +156,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) @@ -165,10 +164,15 @@ describe('the notice for every failure and write', () => { phoneResend || codeOf(failure) === 'structured_agent_session_unsupported' ) } - 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) } }) From 99ecd04607eaa2c278dcadc8afc2bd0ca8f18cdb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 04:38:32 -0700 Subject: [PATCH 21/27] Avoid inferring idle from a refused response Stop --- ...ed-agent-session-conversation-stop.test.ts | 8 ++--- ...ed-agent-session-stop-failure-copy.test.ts | 36 ++++++++++++++++--- ...gent-session-command-refusal-words.test.ts | 4 +-- src/shared/agent-session-failure-words.ts | 4 +-- 4 files changed, 39 insertions(+), 13 deletions(-) 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 54c0658c6e48..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 @@ -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 had no response in progress to stop.']) + 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 () => { @@ -707,7 +707,7 @@ describe('a Stop that names no turn', () => { ]) }) - 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()) @@ -715,7 +715,7 @@ describe('a Stop that names no turn', () => { expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) - expect(await statusRows()).toEqual(['Codex had no response in progress 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-stop-failure-copy.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-failure-copy.test.ts index 8a84a32a5508..51bfa21816be 100644 --- 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 @@ -4,6 +4,7 @@ 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, @@ -24,11 +25,14 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -async function runningContext(cancelTurn: AgentSessionTurnContext['adapter']['cancelTurn']) { +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: 'turn-1', ordinal: 0 }, - { kind: 'turn', turnId: 'turn-1', state: 'running' }, + { provider: 'codex', threadId: 'thread-abc', turnId, ordinal: 0 }, + { kind: 'turn', turnId, state: 'running' }, { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const ctx: AgentSessionTurnContext = { @@ -86,7 +90,7 @@ describe('Stop wording follows the host verdict', () => { ) }) - it('records the positive no-running-turn distinction', async () => { + it('keeps the requested-turn verdict without claiming the whole agent is idle', async () => { const ctx = await runningContext(async () => ({ cancelled: false, refusal: { turnNotRunning: true } @@ -95,12 +99,34 @@ describe('Stop wording follows the host verdict', () => { expect(ctx.journal.snapshot().items.map((row) => row.body)).toContainEqual( expect.objectContaining({ kind: 'status', - text: 'Codex had no response in progress to stop.', + 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') diff --git a/src/shared/agent-session-command-refusal-words.test.ts b/src/shared/agent-session-command-refusal-words.test.ts index 50ee061cd99f..5e91ac639af6 100644 --- a/src/shared/agent-session-command-refusal-words.test.ts +++ b/src/shared/agent-session-command-refusal-words.test.ts @@ -81,11 +81,11 @@ describe('commands whose recorded refusal decides the next step', () => { expect(text).not.toMatch(/the agent/i) }) - it('retains a positive no-running-turn verdict when reading a stored Stop fact', () => { + 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 had no response in progress to stop.' + "Codex didn't stop. Check the chat before trying again." ) expect( agentSessionFailureSentence({ kind: 'stopRefused' }, 'row', { agentName: 'Codex' }) diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 70eb79f33d0a..9fd583f59e37 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -243,10 +243,10 @@ const FAILURE_SENTENCES = { say('compactionUnconfirmed', agent(say, context)), cancelUnconfirmed: (context, _fact, _surface, say) => say('cancelUnconfirmed', agent(say, context)), - // The agent was reached and declined, so the sentence says that, not that the Stop was lost. + // Refusing the named response does not prove the agent has nothing else running. stopRefused: (context, fact, _surface, say) => fact.turnNotRunning - ? say('noTurnToStop', agent(say, context)) + ? say('stopRefused', agent(say, context)) : quotingPersonDetail( say, 'stopRefused', From c15c79bf109669676541ca63779630b0da167edf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 05:42:20 -0700 Subject: [PATCH 22/27] Align runtime Stop assertion with the scoped refusal copy --- ...structured-agent-session-codex-turn-end-settlement.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 35ab9ca44414..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 response in progress 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 () => { From 1c3ed1f2b03a4eac0639f55f3abdf23ee8454643 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:50:09 -0700 Subject: [PATCH 23/27] Keep failure lines plain after the main sync - A subagent's sign-in row no longer hides the chat's own sign-in steps: only rows the conversation renders count as already explained (phone and desktop). - A failed ACP turn (Grok, OpenCode, OMP) names the agent and quotes its reason only when a person can read it; raw error text stays on the failure fact. - The readable-text check itself withholds explanations that say "provider", so quoted refusals, retry causes and request errors follow the same rule. - Main's sign-in tests use safe example detail and assert technical detail is withheld. --- .../mobile-native-chat-unsent-notices.test.ts | 35 +++++++++++++++++++ src/main/acp/acp-structured-sign-in.test.ts | 8 ++--- .../acp/acp-timeline-fixture.test-support.ts | 6 +++- src/main/acp/acp-timeline-generic.test.ts | 28 ++++++++++++--- .../acp/acp-timeline-turn-failures.test.ts | 19 ++++++---- src/main/acp/acp-turn-failures.ts | 15 ++++---- src/main/pi/rpc-capture-replay.test.ts | 7 ++-- .../native-chat/NativeChatNoticeRow.test.tsx | 2 +- ...red-agent-session-delivery-notices.test.ts | 9 +++++ .../agent-session-availability-sentences.ts | 4 +-- .../agent-session-failure-words.test.ts | 13 +++++++ src/shared/agent-session-sign-in.test.ts | 16 ++++++++- src/shared/agent-session-visible-failures.ts | 6 ++-- .../provider-diagnostic-person-text.test.ts | 2 ++ src/shared/provider-diagnostic-person-text.ts | 4 +++ ...tructured-agent-session-rejection-words.ts | 2 +- 16 files changed, 142 insertions(+), 34 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-unsent-notices.test.ts b/mobile/src/session/mobile-native-chat-unsent-notices.test.ts index 936bf8055fc0..4500427bcfec 100644 --- a/mobile/src/session/mobile-native-chat-unsent-notices.test.ts +++ b/mobile/src/session/mobile-native-chat-unsent-notices.test.ts @@ -110,6 +110,41 @@ describe('the line under a message the host recorded and did not deliver', () => ) }) + 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( 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..8a70ac8098f0 100644 --- a/src/main/acp/acp-timeline-fixture.test-support.ts +++ b/src/main/acp/acp-timeline-fixture.test-support.ts @@ -71,7 +71,11 @@ 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' + }) 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..52da4a2d3783 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,32 @@ 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') 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' } + } + }, + { 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' } } + } ]) expect((await rig.turns()).map((turn) => turn.outcome)).toEqual([ 'success', 'failure', + 'failure', 'failure' ]) } diff --git a/src/main/acp/acp-timeline-turn-failures.test.ts b/src/main/acp/acp-timeline-turn-failures.test.ts index 801b85fd9fb8..56ed62682aae 100644 --- a/src/main/acp/acp-timeline-turn-failures.test.ts +++ b/src/main/acp/acp-timeline-turn-failures.test.ts @@ -40,6 +40,13 @@ const promptComplete = (promptId: string, stopReason: string, agentResult: strin agentResult }) const rpcError = new AcpAgentError(-32603, 'Internal error', { message: REASON }) +// The reason is an API record, so the row names Grok and keeps the reason on its fact. +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' } } +} function statusRows(rows: AgentJournalRenderItem[]) { return rows.flatMap((row) => (row.body.kind === 'status' ? [{ row, body: row.body }] : [])) @@ -55,7 +62,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 +89,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 +120,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 +156,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 +169,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 () => { diff --git a/src/main/acp/acp-turn-failures.ts b/src/main/acp/acp-turn-failures.ts index f5f5f5dd463a..63bbb2c7d794 100644 --- a/src/main/acp/acp-turn-failures.ts +++ b/src/main/acp/acp-turn-failures.ts @@ -43,10 +43,10 @@ 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. */ +/** 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. 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. */ export class AcpTurnFailures { /** The reason each failed turn's row holds; '' for none yet. */ private readonly rows = new BoundedMap({ @@ -102,14 +102,15 @@ export class AcpTurnFailures { body: { kind: 'status', tone: 'error', - ...(authenticationRequired + ...(authenticationRequired || diagnostic ? agentSessionFailureWords( - agentSessionFailureFact('notSignedIn', { detail: diagnostic }), + agentSessionFailureFact(authenticationRequired ? 'notSignedIn' : 'providerError', { + detail: diagnostic + }), { agentName: this.agentName, surface: 'row' } ) : { text: - diagnostic?.text ?? this.dialect.failedTurnText?.(stopReason) ?? `${this.agentName ?? 'The agent'} ended this turn with an error.` }) 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/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/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index 0e490590a7b0..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 @@ -134,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/shared/agent-session-availability-sentences.ts b/src/shared/agent-session-availability-sentences.ts index 2059a48febcb..3c40258da03e 100644 --- a/src/shared/agent-session-availability-sentences.ts +++ b/src/shared/agent-session-availability-sentences.ts @@ -40,9 +40,7 @@ export function notSignedInSentence( ? say(signIn ? 'thenSendAgain' : 'signInThenSend') : undefined const detail = - fact.detail?.audience === 'person' && - isProviderDiagnosticPersonText(fact.detail.text) && - !/\bprovider\b/i.test(fact.detail.text) + fact.detail?.audience === 'person' && isProviderDiagnosticPersonText(fact.detail.text) ? fact.detail.text : undefined const detailSentence = diff --git a/src/shared/agent-session-failure-words.test.ts b/src/shared/agent-session-failure-words.test.ts index 954b5e4ba336..65eb84c77946 100644 --- a/src/shared/agent-session-failure-words.test.ts +++ b/src/shared/agent-session-failure-words.test.ts @@ -121,6 +121,19 @@ describe('the words written beside a failure fact', () => { ).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)) { 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 index e05bd071620f..03efa7c8f1bb 100644 --- a/src/shared/provider-diagnostic-person-text.test.ts +++ b/src/shared/provider-diagnostic-person-text.test.ts @@ -24,6 +24,8 @@ describe('provider explanations a person can read', () => { 'API Error: Request was aborted.', 'TypeError: Cannot read properties of undefined', 'write EPIPE', + 'The configured provider has no API key.', + 'The Provider 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)', diff --git a/src/shared/provider-diagnostic-person-text.ts b/src/shared/provider-diagnostic-person-text.ts index 490f14d933ad..1035346a66a6 100644 --- a/src/shared/provider-diagnostic-person-text.ts +++ b/src/shared/provider-diagnostic-person-text.ts @@ -14,6 +14,9 @@ const TECHNICAL_TEXT = [ /\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 = /\bprovider\b/i + function hasControlCharacters(text: string): boolean { for (const character of text) { const code = character.charCodeAt(0) @@ -28,6 +31,7 @@ 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-rejection-words.ts b/src/shared/structured-agent-session-rejection-words.ts index 4740d1c32ac0..9ef641c236c5 100644 --- a/src/shared/structured-agent-session-rejection-words.ts +++ b/src/shared/structured-agent-session-rejection-words.ts @@ -62,7 +62,7 @@ export function structuredAgentSessionRejectionParts( } // 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) || /\bprovider\b/i.test(reason)) { + if (!isProviderDiagnosticPersonText(reason)) { return parts } const alreadyNamed = From e416d27c87fddf076b8fd24cf8912efbebf56441 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:37:56 -0700 Subject: [PATCH 24/27] Expect hidden technical detail in the startup sign-in row test The startup row test from main still expected 'Provider diagnostic' text on the row. This PR withholds that kind of detail, so the test now checks plain detail is kept literally, technical detail is withheld, and the agent's sign-in step and resend advice stay. --- ...-session-start-failure-row-render.test.mjs | 87 +++++++++++-------- 1 file changed, 50 insertions(+), 37 deletions(-) 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() +} From e940ff7dc854902ed242cc0021f14acf927355b6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:45:01 -0700 Subject: [PATCH 25/27] Keep a failed agent turn's reason in Details and its usage-limit lead An ACP turn failure hid the agent's raw reason from the line but attached it nowhere, so 'Check the chat' pointed at nothing, and a later copy of a rate-limit reason replaced 'Grok usage limit reached.' with a generic line. The row now carries the reason as its Details frame and logs it once, and a usage-limit end keeps its lead whichever copy arrives first. The readable-text check also withholds 'providers'. --- src/main/acp/acp-structured-lane.ts | 3 +- .../acp/acp-timeline-fixture.test-support.ts | 3 +- src/main/acp/acp-timeline-generic.test.ts | 12 +- src/main/acp/acp-timeline-translator.ts | 9 +- .../acp/acp-timeline-turn-failures.test.ts | 128 +++++++++++++++++- src/main/acp/acp-turn-failures.ts | 85 ++++++++---- .../provider-diagnostic-person-text.test.ts | 1 + src/shared/provider-diagnostic-person-text.ts | 2 +- 8 files changed, 209 insertions(+), 34 deletions(-) diff --git a/src/main/acp/acp-structured-lane.ts b/src/main/acp/acp-structured-lane.ts index 958d20dc47ef..686cc7d453f6 100644 --- a/src/main/acp/acp-structured-lane.ts +++ b/src/main/acp/acp-structured-lane.ts @@ -45,7 +45,8 @@ export class AcpStructuredLane { this.translator = new AcpTimelineTranslator({ sessionId: deps.providerSessionId, dialect: deps.dialect, - agentName: deps.agentName + agentName: deps.agentName, + agent: deps.agent }) this.requestIdentity = createLegacyProviderTimelineIdentityScheme({ agent: deps.agent, diff --git a/src/main/acp/acp-timeline-fixture.test-support.ts b/src/main/acp/acp-timeline-fixture.test-support.ts index 8a70ac8098f0..158581a3bf5c 100644 --- a/src/main/acp/acp-timeline-fixture.test-support.ts +++ b/src/main/acp/acp-timeline-fixture.test-support.ts @@ -74,7 +74,8 @@ export async function openAcpFixtureRig() { new AcpTimelineTranslator({ sessionId: providerSessionId, dialect: GROK_ACP_DIALECT, - agentName: 'Grok' + agentName: 'Grok', + agent: 'grok' }) let lane = translator() const requests = new Map< diff --git a/src/main/acp/acp-timeline-generic.test.ts b/src/main/acp/acp-timeline-generic.test.ts index 52da4a2d3783..c1c6fcc65f21 100644 --- a/src/main/acp/acp-timeline-generic.test.ts +++ b/src/main/acp/acp-timeline-generic.test.ts @@ -185,6 +185,11 @@ describe('generic ACP translation', () => { 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', @@ -193,14 +198,17 @@ describe('generic ACP translation', () => { 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' } } + 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([ diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index 5125c7f68de7..0b41b7eedf69 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -35,6 +35,8 @@ export type AcpTimelineTranslatorOptions = { dialect?: AcpDialect /** The agent's display name, for a failed turn the provider gave no words for. */ agentName?: string + /** The agent's id, naming whose words a failed turn's Details hold. */ + agent?: string } /** Consumes each frame once; the host retries the returned grammar events. Lives exactly as long @@ -53,7 +55,12 @@ 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( + options.sessionId, + this.dialect, + options.agentName, + options.agent + ) 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 56ed62682aae..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,12 +41,27 @@ const promptComplete = (promptId: string, stopReason: string, agentResult: strin agentResult }) const rpcError = new AcpAgentError(-32603, 'Internal error', { message: REASON }) -// The reason is an API record, so the row names Grok and keeps the reason on its fact. +// 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' } } + 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[]) { @@ -194,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 63bbb2c7d794..dcffc79fd84d 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' @@ -45,18 +50,21 @@ export function acpSignInRequiredRefusal( /** 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. 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. */ + * 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 agentName: string | undefined, + private readonly agent = 'acp' ) {} has(turn: string): boolean { @@ -81,20 +89,29 @@ 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 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.agentName ?? this.agent} turn failed (${stopReason}):`, + detail.text + ) + } return [ { type: 'item.update', @@ -102,21 +119,39 @@ export class AcpTurnFailures { body: { kind: 'status', tone: 'error', - ...(authenticationRequired || diagnostic - ? agentSessionFailureWords( - agentSessionFailureFact(authenticationRequired ? 'notSignedIn' : 'providerError', { - detail: diagnostic - }), - { agentName: this.agentName, surface: 'row' } - ) - : { - text: - this.dialect.failedTurnText?.(stopReason) ?? - `${this.agentName ?? 'The agent'} ended this turn with an error.` - }) + ...this.words(state, diagnostic), + ...(diagnostic + ? { + providerFrame: { + provider: this.agent, + kind: 'turn:failed', + payload: boundPayload(diagnostic.text, DEFAULT_JOURNAL_PAYLOAD_LIMITS) + } + } + : {}) }, join: { thread: this.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.agentName ?? 'The agent'} ended this turn with an error.` + } + } + return agentSessionFailureWords( + agentSessionFailureFact(notSignedIn ? 'notSignedIn' : 'providerError', { + detail: diagnostic + }), + { agentName: this.agentName, surface: 'row' } + ) + } } diff --git a/src/shared/provider-diagnostic-person-text.test.ts b/src/shared/provider-diagnostic-person-text.test.ts index 03efa7c8f1bb..9a3944b256c2 100644 --- a/src/shared/provider-diagnostic-person-text.test.ts +++ b/src/shared/provider-diagnostic-person-text.test.ts @@ -26,6 +26,7 @@ describe('provider explanations a person can read', () => { 'write EPIPE', '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)', diff --git a/src/shared/provider-diagnostic-person-text.ts b/src/shared/provider-diagnostic-person-text.ts index 1035346a66a6..1ba57c3f0087 100644 --- a/src/shared/provider-diagnostic-person-text.ts +++ b/src/shared/provider-diagnostic-person-text.ts @@ -15,7 +15,7 @@ const TECHNICAL_TEXT = [ ] // Users know agents, not "providers": an explanation using that word is withheld. -const INTERNAL_WORD = /\bprovider\b/i +const INTERNAL_WORD = /\bproviders?\b/i function hasControlCharacters(text: string): boolean { for (const character of text) { From ff35ce35b9e980153a1e56da0b1054369defdf78 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:47:56 -0700 Subject: [PATCH 26/27] Keep the ACP agent id option clear of main's neighbouring lane changes --- src/main/acp/acp-structured-lane.ts | 4 ++-- src/main/acp/acp-timeline-translator.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/acp/acp-structured-lane.ts b/src/main/acp/acp-structured-lane.ts index 686cc7d453f6..739136a09575 100644 --- a/src/main/acp/acp-structured-lane.ts +++ b/src/main/acp/acp-structured-lane.ts @@ -44,9 +44,9 @@ export class AcpStructuredLane { constructor(private readonly deps: AcpStructuredLaneDeps) { this.translator = new AcpTimelineTranslator({ sessionId: deps.providerSessionId, + agent: deps.agent, dialect: deps.dialect, - agentName: deps.agentName, - agent: deps.agent + agentName: deps.agentName }) this.requestIdentity = createLegacyProviderTimelineIdentityScheme({ agent: deps.agent, diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index 0b41b7eedf69..555ee9b25c04 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -32,11 +32,11 @@ const SUBSTANTIVE_UPDATES = [ export type AcpTimelineTranslatorOptions = { sessionId: string + /** The agent's id, naming whose words a failed turn's Details hold. */ + agent?: string dialect?: AcpDialect /** The agent's display name, for a failed turn the provider gave no words for. */ agentName?: string - /** The agent's id, naming whose words a failed turn's Details hold. */ - agent?: string } /** Consumes each frame once; the host retries the returned grammar events. Lives exactly as long From a7a110f2b001882883a80a932cce03aaff89da6e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:59:53 -0700 Subject: [PATCH 27/27] Keep setting names and model ids with digits readable in failure lines Only upper-case snake codes ending in a number (CAPTURE_PROVIDER_400, ERR_42) are withheld, so next steps such as AWS_S3_BUCKET, R2_ACCESS_KEY and GPT_4O stay visible. --- src/shared/provider-diagnostic-person-text.test.ts | 3 +++ src/shared/provider-diagnostic-person-text.ts | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/shared/provider-diagnostic-person-text.test.ts b/src/shared/provider-diagnostic-person-text.test.ts index d6d347493d5d..c32e14c51968 100644 --- a/src/shared/provider-diagnostic-person-text.test.ts +++ b/src/shared/provider-diagnostic-person-text.test.ts @@ -10,6 +10,9 @@ describe('provider explanations a person can read', () => { '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) diff --git a/src/shared/provider-diagnostic-person-text.ts b/src/shared/provider-diagnostic-person-text.ts index 23b071b5fd6e..7889fad1daae 100644 --- a/src/shared/provider-diagnostic-person-text.ts +++ b/src/shared/provider-diagnostic-person-text.ts @@ -4,8 +4,8 @@ const TECHNICAL_TEXT = [ /^\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 with a digit (ERR_42); digit-free setting names (ANTHROPIC_API_KEY) stay. - /\b(?=[A-Z0-9_]*\d)[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\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*:/,