diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index 135469d89171..074473e2c7dc 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -77,6 +77,7 @@ describe('MobileNativeChatMessage', () => { workedSeconds: number | null } | null onToggleTurn?: () => void + unsentNotice?: string } = {} ): ReactTestRenderer { act(() => { @@ -146,6 +147,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 83f93266d6ff..2553cb12ca05 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -17,6 +17,7 @@ import { ToolRun } from './MobileNativeChatToolRun' 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 { AGENT_SESSION_WRITE_NOTICE_COPY } from '../../../src/shared/agent-session-write-notice-copy' function Prose({ block, @@ -118,7 +119,8 @@ function MobileNativeChatMessageImpl({ turnKey, onToggleTurn, activeTurnIsWorking, - structuredActivityUi = false + structuredActivityUi = false, + unsentNotice }: { message: NativeChatMessage toolsExpanded?: boolean @@ -139,6 +141,8 @@ function MobileNativeChatMessageImpl({ activeTurnIsWorking?: boolean /** Structured lane only: live tool progress plus the turn-status disclosure. */ structuredActivityUi?: boolean + /** Why the host did not deliver this message, when it is shown as not sent. */ + unsentNotice?: string }): React.JSX.Element { const isUser = message.role === 'user' const isReasoning = message.role === 'reasoning' @@ -207,6 +211,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 = @@ -349,21 +356,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/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts index 3ae08af66881..7565fa162d5b 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts @@ -18,20 +18,37 @@ 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 } -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 && normalizedNativeChatUserMessageText(message) === text) { count++ } } @@ -63,6 +80,8 @@ export type PendingImagePreviewEcho = { images?: string[] expectedOccurrence: number baselineTailMessageId: string | null + /** See `sendBaselineUnsentMessageIds`. */ + baselineUnsentMessageIds?: readonly string[] } export type LandedImagePreviewEcho = { @@ -177,12 +196,13 @@ 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) { - const text = normalizedUserText(message) + const text = normalizedNativeChatUserMessageText(message) if (text === null) { return false } @@ -257,15 +277,17 @@ 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') { continue } - const key = isImageSourceUserTurn(message) ? '' : (normalizedUserText(message) ?? '') + const key = isImageSourceUserTurn(message) + ? '' + : (normalizedNativeChatUserMessageText(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 +300,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-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-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts index 225d664e65e4..8be84fd6de2c 100644 --- a/mobile/src/session/mobile-native-chat-message-styles.ts +++ b/mobile/src/session/mobile-native-chat-message-styles.ts @@ -37,6 +37,11 @@ export const styles = StyleSheet.create({ reasoning: { opacity: 0.7 }, + unsentLabel: { + marginTop: spacing.xs, + color: colors.textMuted, + fontSize: typography.metaSize + }, toolRun: { marginTop: spacing.xs }, 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..00483ef8e566 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-not-sent-resend.test.ts @@ -0,0 +1,275 @@ +// 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 not-sent original stays where it was. 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 { 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, + queuedMessageIds: [] + }) +} + +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', () => { + it('ends as the not-sent original plus the delivered resend, 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([true, 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( + [] + ) + }) + + it('keeps a not-sent row where the host placed it, above what came after', () => { + const items = [ + user('m1', 11, 'hello'), + user('m3', 20, 'are you there?'), + answer('a3', 21, 'Yes') + ] + const messages = phone(items, [ + { ...REJECTED, payloadFingerprint: 'body:hello' }, + submission('m3', 'are you there?', 20) + ]) + expect(messages.map((message) => [message.id, message.unsent ?? false])).toEqual([ + [agentJournalSubmissionKey('m1'), true], + [agentJournalSubmissionKey('m3'), false], + ['a3', false] + ]) + }) +}) + +// 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 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) + }) + + 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 136043447899..1d0d0ef57988 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 a queue-capable host only. */ 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..0eb74f6d81d0 100644 --- a/mobile/src/session/mobile-native-chat-pending-retirement.ts +++ b/mobile/src/session/mobile-native-chat-pending-retirement.ts @@ -1,10 +1,10 @@ import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { countImageSourceTurnsAfter, - normalizeReconcileText, - normalizedUserText + normalizeReconcileText } from './mobile-native-chat-draft-reconcile' import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo' +import { normalizedNativeChatUserMessageText } from './mobile-native-chat-image-transcript-markers' const SPACE = ' ' const NO_PENDING_IDS: ReadonlySet = new Set() @@ -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 : normalizedNativeChatUserMessageText(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) { + const text = normalizedNativeChatUserMessageText(message) + 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 @@ -173,7 +188,7 @@ export function retireLandedMobileNativeChatPending( item.text.trim() === '' ? countImageSourceTurnsAfter(messages, item.baselineTailMessageId) >= item.expectedOccurrence - : (landedCounts.get(normalizeReconcileText(item.text)) ?? 0) >= 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/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index 2a3bc5a60b2a..22d2874b4ee1 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -29,8 +29,9 @@ type MobileNativeChatSendArgs = { /** 'unknown' = the RPC failed without proof the request never reached the * 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. */ + * 'queued' = structured lane only: the host holds the message, as a queued + * draft's card above the composer or as a recorded row in the transcript, so + * the client draws no echo and hands no text back. */ export type MobileNativeChatSendOutcome = 'accepted' | 'rejected' | 'unknown' | 'queued' /** What a terminal write can answer: the PTY lane has no draft queue. */ 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..485159a856b1 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-unsent-notices.test.ts @@ -0,0 +1,110 @@ +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 { structuredAgentSessionCommandResultRowIdentity } from '../../../src/shared/structured-agent-session-command-entry' +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.' + ) + }) + + // Its turn's result row says how a refused command ended; a blocked one has only its own line. + it("says only that a command was not sent when its result row says why, else the command's words", () => { + const providerRejected: AgentSessionFailureFact = { + kind: 'providerRejected', + detail: { text: 'busy', audience: 'person' } + } + const refused = rejected({ clientMessageId: 'compact-1', rejection: providerRejected }) + const resultRow: AgentJournalRenderItem = { + itemId: agentJournalItemKey(structuredAgentSessionCommandResultRowIdentity('compact-1')), + revision: 1, + sequence: 2, + observedAt: 2, + body: { + kind: 'status', + tone: 'error', + ...agentSessionFailureWords( + { kind: 'compactionFailed', detail: { text: 'busy', audience: 'person' } }, + { agentName: 'Codex', surface: 'row' } + ) + } + } + const blocked = rejected({ + clientMessageId: 'compact-2', + rejection: { kind: 'commandRefused' } + }) + const notices = mobileNativeChatUnsentNotices( + { items: [resultRow], submissions: [refused, blocked] }, + 'codex' + ) + expect(notices.get(agentJournalSubmissionKey('compact-1'))).toBe('Your message was not sent.') + expect(notices.get(agentJournalSubmissionKey('compact-2'))).toBe( + "This command didn't run. Try it again." + ) + }) + + 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..b8f76a876a44 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-unsent-notices.ts @@ -0,0 +1,44 @@ +// 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 its banner. + +import { formatAgentTypeLabel } from '../../../src/shared/agent-type-label' +import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key' +import { structuredAgentSessionCommandResultRows } from '../../../src/shared/structured-agent-session-command-entry' +import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice' +import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership' +import { + structuredAgentSessionRecordedRejectionParts, + structuredAgentSessionStartFailureFacts +} from '../../../src/shared/structured-agent-session-recorded-rejection-words' + +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 commandResults = structuredAgentSessionCommandResultRows(journal.items) + const context = { + retryControl: false, + ...(agent ? { agentName: formatAgentTypeLabel(agent) } : {}) + } + return new Map( + rejected.map((submission) => [ + agentJournalSubmissionKey(submission.clientMessageId), + agentSessionWriteNoticeEnglish( + structuredAgentSessionRecordedRejectionParts( + submission, + context, + startFailures, + commandResults + ) + ) + ]) + ) +} 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 c9da99a3cbaf..8631e61809d1 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 @@ -31,8 +31,33 @@ function queuedAnswer(clientMessageId: string, state: 'waiting' | 'withdrawn'): }) } -/** Each `agentSession.send` answered in turn: a lost answer, or a queued draft in that state. */ -function hostAnswering(answers: readonly ('lost' | 'waiting' | 'withdrawn')[]) { +function submissionAnswer(clientMessageId: string, dispatchState: 'accepted' | 'rejected') { + return ok({ + ok: true, + replayed: false, + fence: 3, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value: { + clientMessageId, + submission: { + clientMessageId, + fence: 3, + payloadFingerprint: 'fingerprint', + dispatchState, + providerItemId: null, + reason: dispatchState === 'rejected' ? 'provider_write_failed: broken pipe' : null, + submittedAt: 1, + resolvedAt: 1 + } + } + }) +} + +/** Each `agentSession.send` answered in turn: a lost answer, a queued draft in that state, or a + * recorded submission in that state. */ +function hostAnswering( + answers: readonly ('lost' | 'waiting' | 'withdrawn' | 'accepted' | 'rejected')[] +) { const ids: string[] = [] const deliveries: unknown[] = [] const sendRequest = vi.fn(async (_method, params) => { @@ -44,7 +69,9 @@ function hostAnswering(answers: readonly ('lost' | 'waiting' | 'withdrawn')[]) { if (answer === 'lost' || answer === undefined) { throw markRpcDeliveryUnknown(new Error('Connection closed')) } - return queuedAnswer(id, answer) + return answer === 'accepted' || answer === 'rejected' + ? submissionAnswer(id, answer) + : queuedAnswer(id, answer) }) const client: RpcClient = { sendRequest, @@ -154,4 +181,27 @@ 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', 'rejected', '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', 'rejected']) + 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() + }) }) diff --git a/mobile/src/session/mobile-structured-agent-session-send.ts b/mobile/src/session/mobile-structured-agent-session-send.ts index ce7f60a8b929..8ad04187d636 100644 --- a/mobile/src/session/mobile-structured-agent-session-send.ts +++ b/mobile/src/session/mobile-structured-agent-session-send.ts @@ -11,10 +11,12 @@ import type { RpcClient } from '../transport/rpc-client' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { requestStructuredAgentSessionMutation, - timeoutForDeadline + timeoutForDeadline, + type StructuredAgentSessionMutationCallResult } from './mobile-structured-agent-session-rpc' import { structuredSessionOperationId } from './structured-session-operation-id' import { mobileStructuredSendDelivery } from './mobile-structured-send-delivery' +import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection' import { bypassedMobileStructuredSendOperationId, clearMobileStructuredSendOperation, @@ -36,13 +38,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 { @@ -165,22 +167,22 @@ export async function sendMobileStructuredAgentSessionMessage(input: { result.status === 'accepted' && 'queued' in result.value && result.value.queued?.state === 'withdrawn' - if (withdrawnReplay && operation.retained && !input.resendingAfterWithdrawal) { - // The retained id's draft 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 - // block the send. - const resent = await sendMobileStructuredAgentSessionMessage({ + if ( + operation.retained && + !input.resendingAfterSettledReplay && + // A recorded rejection's row already says it was not sent, so a replay would do nothing. + (withdrawnReplay || recordedRejection(result)) + ) { + // The retained id's message never reached the agent (its draft 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. + 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) { // Not resent: no card and no bubble holds the text, so it goes back to the @@ -191,6 +193,13 @@ export async function sendMobileStructuredAgentSessionMessage(input: { 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 = + outcome.outcome === 'accepted' || (outcome.outcome === 'queued' && !recordedRejection(result)) + if (input.bypassRetainedRecord && wentOut) { + input.onError("Sent, but this phone couldn't update its record of sent messages.") + } return outcome.outcome } @@ -240,3 +249,14 @@ async function adoptBypassedOperation(input: { return bypassOperation(input.operationKey, input.payloadFingerprint, input.attachmentPaths) } } + +/** An answer that the host recorded the message and then rejected it, not by a Stop. */ +function recordedRejection( + result: StructuredAgentSessionMutationCallResult +): boolean { + const submission = + result.status === 'accepted' && 'submission' in result.value + ? result.value.submission + : undefined + return submission?.dispatchState === 'rejected' && !dispatchWasWithdrawn(submission) +} diff --git a/mobile/src/session/mobile-structured-composer-command.test.ts b/mobile/src/session/mobile-structured-composer-command.test.ts index 8a54140a7dc3..af5c06cbbe3d 100644 --- a/mobile/src/session/mobile-structured-composer-command.test.ts +++ b/mobile/src/session/mobile-structured-composer-command.test.ts @@ -33,6 +33,7 @@ function setup() { conversationCommands: ['clear', 'compact'] }, canRun: () => true, + shownNotSent: () => false, onError: vi.fn(), timeoutMs: 15000 } @@ -119,6 +120,31 @@ describe('mobile structured conversation commands', () => { expect(fields.command).toBe('clear') expect(asyncStorage.setItem).not.toHaveBeenCalled() }) + // Its row in the chat says it was not sent and why, for every viewer, so no banner repeats it. + it('banners a refused /compact only when the journal does not show the host recorded it', async () => { + const refused = { + ok: true, + result: { + ok: true, + value: { command: 'compact', state: 'completed', error: "This command didn't run." } + } + } + const { input, sendRequest } = setup() + sendRequest.mockResolvedValue(refused) + const sentId = () => { + const envelope = requestFields(sendRequest.mock.calls.at(-1)).envelope + return typeof envelope === 'object' && envelope !== null && 'clientOperationId' in envelope + ? envelope.clientOperationId + : undefined + } + input.shownNotSent = (clientMessageId) => clientMessageId === sentId() + expect(await dispatchMobileStructuredCommand(input)).toBe('accepted') + expect(input.onError).not.toHaveBeenCalled() + + input.shownNotSent = () => false + expect(await dispatchMobileStructuredCommand(input)).toBe('rejected') + expect(input.onError).toHaveBeenCalledWith("This command didn't run.") + }) it('keeps ordinary messages on the existing send path', async () => { const { input, sendRequest } = setup() expect(await dispatchMobileStructuredCommand({ ...input, text: 'hello' })).toBeNull() diff --git a/mobile/src/session/mobile-structured-composer-command.ts b/mobile/src/session/mobile-structured-composer-command.ts index ebe30fe88031..fd764668ac78 100644 --- a/mobile/src/session/mobile-structured-composer-command.ts +++ b/mobile/src/session/mobile-structured-composer-command.ts @@ -7,6 +7,7 @@ import { import type { RpcClient } from '../transport/rpc-client' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { requestStructuredAgentSessionMutation } from './mobile-structured-agent-session-rpc' +import { structuredSessionOperationId } from './structured-session-operation-id' export async function dispatchMobileStructuredCommand(input: { text: string @@ -17,6 +18,8 @@ export async function dispatchMobileStructuredCommand(input: { pending: { current: boolean } controller: StructuredAgentSessionComposerOptions canRun: () => boolean + /** Whether the loaded journal already shows the message recorded under this id as not sent. */ + shownNotSent: (clientMessageId: string) => boolean onError: (message: string) => void timeoutMs: number }): Promise { @@ -41,6 +44,7 @@ export async function dispatchMobileStructuredCommand(input: { } } input.pending.current = true + const clientOperationId = structuredSessionOperationId() try { const result = await requestStructuredAgentSessionMutation({ @@ -50,6 +54,7 @@ export async function dispatchMobileStructuredCommand(input: { method: 'agentSession.conversationCommand', fingerprintMethod: 'agentSession.conversationCommand', fields: { command }, + clientOperationId, timeoutMs: Math.max(input.timeoutMs, 195_000) }) if ( @@ -62,9 +67,15 @@ export async function dispatchMobileStructuredCommand(input: { error: 'Conversation operation was not confirmed.' } } - return result.status === 'accepted' - ? { accepted: !result.value.error, error: result.value.error ?? null } - : { accepted: false, error: result.message } + if (result.status !== 'accepted') { + return { accepted: false, error: result.message } + } + // The command's own row already says it was not sent, and why: no banner repeats it, and + // the text stays the row's, not the composer's. + if (result.value.state === 'completed' && input.shownNotSent(clientOperationId)) { + return { accepted: true, error: null } + } + return { accepted: !result.value.error, error: result.value.error ?? null } } finally { input.pending.current = false } diff --git a/mobile/src/session/mobile-structured-send-delivery.test.ts b/mobile/src/session/mobile-structured-send-delivery.test.ts index 8431e32a7aa9..d6b0a85a93ad 100644 --- a/mobile/src/session/mobile-structured-send-delivery.test.ts +++ b/mobile/src/session/mobile-structured-send-delivery.test.ts @@ -112,26 +112,26 @@ 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 the + // transcript holds it and shows it as not sent, with why: 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 + }) + } }) - it('shows a provider content rejection verbatim', () => { + it('says a send a Stop withdrew was not sent: no row in the chat says it', () => { expect( - mobileStructuredSendDelivery(accepted('rejected', 'Claude does not support .bmp')) + mobileStructuredSendDelivery(accepted('rejected', 'provider_cancelled_before_start')) ).toEqual({ outcome: 'rejected', operationIdSpent: true, - error: 'Claude does not support .bmp' + error: 'Your message was not sent. Send it again.' }) }) diff --git a/mobile/src/session/mobile-structured-send-delivery.ts b/mobile/src/session/mobile-structured-send-delivery.ts index f20dc0971887..62dbf0df4ef5 100644 --- a/mobile/src/session/mobile-structured-send-delivery.ts +++ b/mobile/src/session/mobile-structured-send-delivery.ts @@ -13,7 +13,8 @@ // // accepted/pending — the send happened. The id is spent; a later identical // message is a new message and must carry a new id. -// rejected — a terminal refusal or rejected submission spends a fresh id. A +// rejected — a terminal refusal or rejected submission spends a fresh id (a +// recorded one reports `queued`: the host holds its text, not the composer). A // pending-admission refusal, or any refusal after earlier transport doubt, // keeps it because neither proves a retained delivery did not happen. Two // exceptions spend it anyway, because the host can never accept the replay @@ -30,6 +31,7 @@ import type { AgentSessionSendResult } from '../../../src/shared/agent-session-w 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 { 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' @@ -96,13 +98,19 @@ export function mobileStructuredSendDelivery( if (!submission || submission.dispatchState === 'unknown') { return { outcome: 'unknown', operationIdSpent: false, error: null } } - if (submission.dispatchState === 'rejected') { + if (submission.dispatchState === 'rejected' && dispatchWasWithdrawn(submission)) { + // A Stop withdrew it, so no row holds it: its text goes back to the composer, with why. return { outcome: 'rejected', operationIdSpent: true, error: structuredAgentSessionRejectionNotice(submission.reason, 'composer-send') } } + if (submission.dispatchState === 'rejected') { + // The host recorded it: its row in the chat holds the text and says it was not sent and why, + // so nothing is handed back. + return { outcome: 'queued', operationIdSpent: true, error: null } + } if (retained) { // A payload match cannot distinguish retrying the ambiguous action from a // later identical intent. Wait for the stream to settle and release it. 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-launch-draft-seed.ts b/mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts index b75dbf127678..b76343c33df0 100644 --- a/mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts +++ b/mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { normalizedUserText } from './mobile-native-chat-draft-reconcile' +import { normalizedNativeChatUserMessageText } from './mobile-native-chat-image-transcript-markers' export type MobileNativeChatLaunchDraftSeed = { text: string @@ -63,7 +63,7 @@ export function useMobileNativeChatLaunchDraftSeed(args: { } // A user turn already in the transcript means the TUI prefill was submitted // or deliberately cleared; decline instead of resurrecting it. - if (messages.some((message) => normalizedUserText(message) !== null)) { + if (messages.some((message) => normalizedNativeChatUserMessageText(message) !== null)) { seededLaunchDraftByKeyRef.current.set(draftKey, null) return } @@ -98,7 +98,9 @@ export function useMobileNativeChatLaunchDraftSeed(args: { if (!seeded) { return } - const hasUserTurn = messages.some((message) => normalizedUserText(message) !== null) + const hasUserTurn = messages.some( + (message) => normalizedNativeChatUserMessageText(message) !== null + ) if (!hasUserTurn && launchDraft?.trim()) { return } 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. 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 086482537ef2..b8e63fb3ee0f 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 @@ -5,6 +5,7 @@ import type { AgentJournalDispatchState } from '../../../src/shared/agent-sessio 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' @@ -21,13 +22,13 @@ function ok(result: unknown) { return { ok: true, result, _meta: { runtimeId: 'runtime-1' } } } -function sendResult(dispatchState: AgentJournalDispatchState) { +function sendResult(dispatchState: AgentJournalDispatchState, reason: string | null = null) { return ok({ ok: true, replayed: false, fence: 3, cursor: { epoch: 'epoch-1', sequence: 1 }, - value: structuredSendResultFixture(dispatchState) + value: structuredSendResultFixture(dispatchState, reason) }) } @@ -257,6 +258,67 @@ describe('mobile structured send retries', () => { expect(calls().every(([, params]) => !('retryUnknown' in (params as object)))).toBe(true) }) + // 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() + 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('hands back only a send a Stop withdrew, never one the host recorded and rejected', async () => { + let reason = 'provider_write_failed: broken pipe' + sendRequest.mockImplementation(async (method) => + method === 'agentSession.send' + ? sendResult('rejected', reason) + : 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() + + reason = 'provider_cancelled_before_start' + await act(async () => { + expect(await hook!.sendWithOutcome('withdrawn by Stop')).toBe('rejected') + }) + expect(onSendError).toHaveBeenCalledWith('Your message was not sent. Send it again.') + }) + it('reuses the original uploaded attachment identity after acknowledgement loss', 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 7bec053c9d67..eb261efd499d 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -160,6 +160,10 @@ export function useMobileStructuredAgentSession(args: { onSendError }) + const queuedMessageIds = useMemo( + () => (queuedMessages ?? []).map((message) => message.messageId), + [queuedMessages] + ) // What the transcript reads, as desktop does: the journal plus the one notice a cut turn with no // row gets. const transcriptItems = useMemo( @@ -170,12 +174,13 @@ 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, + queuedMessageIds }), - [transcriptItems, state.submissions] + [transcriptItems, state.submissions, queuedMessageIds] ) const turnId = activeStructuredAgentSessionTurnId(state.items) const turnTiming = useMobileStructuredAgentTurnTiming( 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..f8b9d7f64b22 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 @@ -77,8 +77,8 @@ export function useMobileStructuredNativeChatSendBridge(args: { return 'accepted' } if (outcome === 'queued') { - // The host holds the draft and publishes it as a card above the - // composer — never an optimistic transcript bubble. + // The host holds the text, as a card above the composer or a recorded + // row in the transcript — never an optimistic bubble or a hand-back. return 'queued' } if (outcome === '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 613dd14fe084..cfb21d10fc77 100644 --- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts +++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts @@ -11,6 +11,7 @@ import { type StructuredAgentSessionAttachment } from '../../../src/shared/structured-agent-session-outbox' import type { StructuredAgentSessionComposerOptions } from '../../../src/shared/structured-agent-session-composer' +import { structuredAgentSessionJournalShowsRejection } from '../../../src/shared/structured-agent-session-message-projection' 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' @@ -99,6 +100,11 @@ export function useMobileStructuredSendWithOutcome(args: { !stateRef.current.items.some( (item) => pendingStructuredApproval(item) || pendingStructuredQuestion(item) ), + shownNotSent: (clientMessageId) => + structuredAgentSessionJournalShowsRejection( + stateRef.current.submissions, + clientMessageId + ), onError: onSendError, timeoutMs }) 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 e51b8e672f96..7daf618fa991 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 @@ -31,6 +31,7 @@ import type { AgentSessionConversationCommand } from '../../../shared/agent-sess import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' +import { structuredAgentSessionCommandResultRowIdentity } from '../../../shared/structured-agent-session-command-entry' import { agentJournalTurnBody, readAgentJournalTurn @@ -103,7 +104,7 @@ export function structuredAgentSessionCommandTurn(clientMessageId: string): { identity, itemId: agentJournalItemKey(identity), turnId: `compact:${clientMessageId}`, - resultIdentity: { provider: 'orca', clientMessageId: `command-result:${clientMessageId}` } + resultIdentity: structuredAgentSessionCommandResultRowIdentity(clientMessageId) } } diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.rejected-command.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.rejected-command.test.tsx new file mode 100644 index 000000000000..36b6169e8e96 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.rejected-command.test.tsx @@ -0,0 +1,210 @@ +// @vitest-environment happy-dom + +// A /compact the host recorded and then rejected stays in the chat as not sent, for every viewer, +// and its failure is said once: by its row's line, or, where a host row already says why, by that +// row alone. The host moves a rejected message to its rejection, in no turn, while a command turn +// that opened keeps naming it as its opener. + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render } from '@testing-library/react' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../../shared/agent-session-journal-item-key' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import { + structuredAgentSessionCommandResultRowIdentity, + structuredAgentSessionCommandResultRows +} from '../../../../shared/structured-agent-session-command-entry' +import { structuredAgentSessionStartFailureFacts } from '../../../../shared/structured-agent-session-recorded-rejection-words' +import { structuredAgentSessionStartFailureRowIdentity } from '../../../../shared/structured-agent-session-start-failure-row-key' +import { NativeChatMessageList } from './NativeChatMessageList' +import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' + +let restoreViewport = (): void => {} +beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() +}) +afterAll(() => restoreViewport()) +afterEach(cleanup) + +const SEED = agentJournalSubmissionKey('seed') +const COMPACT = agentJournalSubmissionKey('compact') +const TURN = agentJournalItemKey({ provider: 'orca', clientMessageId: 'command-turn:compact' }) +const BUSY = { text: 'thread busy', audience: 'person' as const } +const START_FAILED: AgentSessionFailureFact = { kind: 'providerStartFailed' } + +function row( + itemId: string, + sequence: number, + body: AgentJournalRenderItem['body'], + turnScope: AgentJournalRenderItem['turnScope'] = { kind: 'thread' } +): AgentJournalRenderItem { + return { itemId, revision: 1, sequence, observedAt: 1000 + sequence, body, turnScope } +} + +function hostRow(itemId: string, sequence: number, fact: AgentSessionFailureFact, turn = false) { + return row( + itemId, + sequence, + { + kind: 'status', + tone: 'error', + ...agentSessionFailureWords(fact, { agentName: 'Codex', surface: 'row' }) + }, + turn ? { kind: 'turn', turnItemId: TURN } : { kind: 'thread' } + ) +} + +function submission( + id: string, + dispatchState: AgentJournalSubmission['dispatchState'], + rejection?: AgentSessionFailureFact +): AgentJournalSubmission { + return { + clientMessageId: id, + fence: 1, + payloadFingerprint: id, + dispatchState, + providerItemId: null, + reason: null, + ...(rejection ? { rejection } : {}), + submittedAt: 1, + resolvedAt: 1 + } +} + +type Case = 'blocked' | 'refused' | 'startFailed' + +function journal(kind: Case): { + items: AgentJournalRenderItem[] + submissions: AgentJournalSubmission[] +} { + const rejection: AgentSessionFailureFact = + kind === 'blocked' + ? { kind: 'commandRefused' } + : kind === 'refused' + ? { kind: 'providerRejected', detail: BUSY } + : START_FAILED + const compact = row(COMPACT, 5, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + }) + const items: AgentJournalRenderItem[] = [ + row(SEED, 1, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'SEED' }] }), + row( + 'seed-answer', + 2, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'SEED OK' }] }, + { kind: 'thread' } + ) + ] + if (kind === 'refused') { + // The turn the command opened, its rejection (which moved the message there) and its result. + items.push( + row(TURN, 3, { + kind: 'turn', + turnId: 'compact:compact', + state: 'completed', + outcome: 'failure', + userItemId: COMPACT + }), + compact, + hostRow( + agentJournalItemKey(structuredAgentSessionCommandResultRowIdentity('compact')), + 6, + { kind: 'compactionFailed', detail: BUSY }, + true + ) + ) + } else if (kind === 'startFailed') { + items.push( + compact, + hostRow( + agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('g')), + 6, + START_FAILED + ) + ) + } else { + items.push(compact) + } + return { + items, + submissions: [submission('seed', 'accepted'), submission('compact', 'rejected', rejection)] + } +} + +function list(kind: Case) { + const { items, submissions } = journal(kind) + return ( + + ) +} + +function count(container: HTMLElement, text: string): number { + return (container.textContent ?? '').split(text).length - 1 +} + +describe('a /compact the host recorded and then rejected, on the desktop', () => { + it('shows a blocked one with its own line, the only place it is said', () => { + const { container } = render(list('blocked')) + expect(container).toHaveTextContent('/compact') + expect(count(container, "This command didn't run.")).toBe(1) + expect(container.querySelector('button[aria-label="Retry"]')).toBeNull() + }) + + it("leaves why to a refused one's result row: its own line says only that it was not sent", () => { + const { container } = render(list('refused')) + const text = container.textContent ?? '' + expect(text.indexOf('/compact')).toBeLessThan(text.indexOf('Your message was not sent.')) + expect(text.indexOf('Your message was not sent.')).toBeLessThan(text.indexOf('thread busy')) + expect(count(container, 'Your message was not sent.')).toBe(1) + expect(count(container, 'thread busy')).toBe(1) + }) + + it("leaves why to a failed start's row", () => { + const { container } = render(list('startFailed')) + expect(count(container, 'Your message was not sent.')).toBe(1) + expect(count(container, 'stopped before it finished starting')).toBe(1) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx index f309593d898f..b4b522697914 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx @@ -160,6 +160,20 @@ describe('what a user message says about its delivery', () => { expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull() }) + // A plain "not sent" is a label, not an error; a doubt to check keeps the error color. + it('reads muted when it says only that the message was not sent', () => { + renderUser({ text: 'Your message was not sent.', notSent: true }) + const notSent = screen.getByText('Your message was not sent.').parentElement + expect(notSent).toHaveClass('text-muted-foreground') + expect(notSent).not.toHaveClass('text-destructive/80') + cleanup() + + renderUser({ text: 'Message delivery is unconfirmed.' }) + expect(screen.getByText('Message delivery is unconfirmed.').parentElement).toHaveClass( + 'text-destructive/80' + ) + }) + it('says nothing when it went through', () => { renderUser() expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull() diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index 529b0c024c8f..f5896707fc68 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -31,8 +31,15 @@ import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' * place of its time, or that it did not go through, with its own Retry when the surface can send * it again. */ export type NativeChatDeliveryNotice = - | { sending: true; text?: never; onRetry?: never; onDismiss?: never } - | { sending?: never; text: string; onRetry?: () => void; onDismiss?: () => void } + | { sending: true; text?: never; notSent?: never; onRetry?: never; onDismiss?: never } + | { + sending?: never + text: string + /** Says only that the message did not go out, so it reads muted, not as an error. */ + notSent?: true + onRetry?: () => void + onDismiss?: () => void + } const USER_META_REVEAL = 'transition-opacity can-hover:pointer-events-none can-hover:opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 [.group:has(:focus-visible)_&]:pointer-events-auto [.group:has(:focus-visible)_&]:opacity-100' @@ -215,7 +222,12 @@ export const MessageRow = memo(function MessageRow({ sending={deliveryNotice?.sending === true} /> {deliveryNotice?.text !== undefined ? ( - + {deliveryNotice.text} {deliveryNotice.onDismiss ? ( diff --git a/src/renderer/src/components/native-chat/native-chat-active-rail-item.test.ts b/src/renderer/src/components/native-chat/native-chat-active-rail-item.test.ts index 72e4898ce04c..e28e77b7de20 100644 --- a/src/renderer/src/components/native-chat/native-chat-active-rail-item.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-active-rail-item.test.ts @@ -123,7 +123,91 @@ describe('active rail item', () => { ).toBe('u2') }) - // A rejected send the journal recorded keeps its place but opens no turn. + // A send shown as not sent keeps its place but has no tick, as the rail's items agree. + it('keeps the tick before a not-sent row lit, never the row itself', () => { + const slots: NativeChatRailSlot[] = [ + ...TURNS, + { turnKey: undefined, message: { id: 'not-sent', role: 'user', unsent: true } }, + { turnKey: undefined, message: { id: 'not-sent-2', role: 'user', unsent: true } } + ] + expect( + findActiveNativeChatRailItem({ + slots, + virtualItems: rows(12), + scrollTop: 900, + clientHeight: VIEWPORT, + scrollHeight: 1200, + previousActiveId: null + }) + ).toBe('u2') + expect( + findActiveNativeChatRailItem({ + slots, + virtualItems: rows(12), + scrollTop: 1010, + ...MID_SCROLL, + scrollHeight: 2000 + }) + ).toBe('u2') + expect( + findActiveNativeChatRailItem({ + slots: [ + { turnKey: undefined, message: { id: 'first', role: 'user', unsent: true } }, + ...TURNS + ], + virtualItems: rows(11), + scrollTop: 50, + ...MID_SCROLL, + scrollHeight: 1100 + }) + ).toBeNull() + }) + + // A failed start: three delivered prompts, the not-sent message, then the start's row below it. + it('keeps the tick before a not-sent message lit with its failed-start row below it', () => { + const notSent: NativeChatRailSlot = { + turnKey: undefined, + message: { id: 'not-sent', role: 'user', unsent: true } + } + const THREE: NativeChatRailSlot[] = [ + { turnKey: 'u1' }, + { turnKey: 'u1' }, + { turnKey: 'u2' }, + { turnKey: 'u2' }, + { turnKey: 'u3' }, + { turnKey: 'u3' } + ] + // The start's row in no turn, or in the turn the not-sent message names. + for (const startRow of [{ turnKey: undefined }, { turnKey: 'not-sent' }]) { + const slots = [...THREE, notSent, startRow] + const read = (scrollTop: number, scrollHeight: number): string | null => + findActiveNativeChatRailItem({ + slots, + virtualItems: rows(8), + scrollTop, + clientHeight: VIEWPORT, + scrollHeight, + previousActiveId: null + }) + expect(read(500, 800)).toBe('u3') + expect(read(600, 2000)).toBe('u3') + expect(read(700, 2000)).toBe('u3') + } + }) + + it('walks back past a row in no turn below the last prompt', () => { + expect( + findActiveNativeChatRailItem({ + slots: [...TURNS, { turnKey: undefined }], + virtualItems: rows(11), + scrollTop: 800, + clientHeight: VIEWPORT, + scrollHeight: 1100, + previousActiveId: null + }) + ).toBe('u2') + }) + it('lights a prompt in no turn by its own id', () => { const slots: NativeChatRailSlot[] = [ ...TURNS, diff --git a/src/renderer/src/components/native-chat/native-chat-active-rail-item.ts b/src/renderer/src/components/native-chat/native-chat-active-rail-item.ts index dc780334df44..cd6af72d5cfd 100644 --- a/src/renderer/src/components/native-chat/native-chat-active-rail-item.ts +++ b/src/renderer/src/components/native-chat/native-chat-active-rail-item.ts @@ -26,12 +26,28 @@ export type NativeChatRailVirtualItem = { /** Only the fields the rail reads, so a test needs no slot builder. */ export type NativeChatRailSlot = { turnKey: string | undefined - /** A user row in no turn (one shown as not sent) lights its own tick. */ - message?: { id: string; role: string } + /** A user row in no turn lights its own tick, unless it is shown as not sent. */ + message?: { id: string; role: string; unsent?: true } } -function railTickOf(slot: NativeChatRailSlot | undefined): string | null { - return slot?.turnKey ?? (slot?.message?.role === 'user' ? slot.message.id : null) +/** The tick of the nearest row at or above `index` that has one. A row shown as not sent has no + * tick, nor does a row in no turn or one whose turn a not-sent message opened (a failed start's + * row, a refused command's result), so the tick before them stays lit. */ +function railTickAt(slots: readonly NativeChatRailSlot[], index: number): string | null { + const notSent = new Set() + for (const slot of slots) { + if (slot.message?.unsent === true) { + notSent.add(slot.message.id) + } + } + for (let at = index; at >= 0; at -= 1) { + const slot = slots[at] + const tick = slot?.turnKey ?? (slot?.message?.role === 'user' ? slot.message.id : null) + if (tick !== null && !notSent.has(tick)) { + return tick + } + } + return null } export function findActiveNativeChatRailItem({ @@ -58,7 +74,7 @@ export function findActiveNativeChatRailItem({ const atBottom = scrollHeight - clientHeight - scrollTop <= NATIVE_CHAT_BOTTOM_THRESHOLD_PX if (atBottom) { const last = virtualItems.at(-1) - return last === undefined ? previousActiveId : railTickOf(slots[last.index]) + return last === undefined ? previousActiveId : railTickAt(slots, last.index) } let fold: NativeChatRailVirtualItem | undefined @@ -70,12 +86,12 @@ export function findActiveNativeChatRailItem({ // Scrolled above everything the window holds: the first windowed row is the // nearest thing to the fold. if (fold === undefined) { - return railTickOf(slots[virtualItems[0]?.index ?? -1]) + return railTickAt(slots, virtualItems[0]?.index ?? -1) } // The window lags the scroll by a commit, so a fold past every row it holds is // a stale read, not an answer. Holding the previous tick beats blanking one. if (fold.end <= scrollTop) { return previousActiveId } - return railTickOf(slots[fold.index]) + return railTickAt(slots, fold.index) } diff --git a/src/renderer/src/components/native-chat/native-chat-message-rail-items.ts b/src/renderer/src/components/native-chat/native-chat-message-rail-items.ts index 755d34123169..a5a6352b2bcc 100644 --- a/src/renderer/src/components/native-chat/native-chat-message-rail-items.ts +++ b/src/renderer/src/components/native-chat/native-chat-message-rail-items.ts @@ -36,7 +36,8 @@ export function buildNativeChatRailItems( ): readonly NativeChatRailItem[] { const items: NativeChatRailItem[] = [] for (const [slotIndex, slot] of slots.entries()) { - if (slot.kind !== 'message' || slot.message.role !== 'user') { + // A message shown as not sent is no turn of the conversation, as the host's outline agrees. + if (slot.kind !== 'message' || slot.message.role !== 'user' || slot.message.unsent === true) { continue } const preview = nativeChatUserMessagePreview(slot.message.blocks) diff --git a/src/renderer/src/components/native-chat/native-chat-rail-not-sent-start.test.ts b/src/renderer/src/components/native-chat/native-chat-rail-not-sent-start.test.ts new file mode 100644 index 000000000000..ce051ca10e84 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-rail-not-sent-start.test.ts @@ -0,0 +1,207 @@ +// The rail's lit tick for a chat whose last message was recorded, then not sent because the agent +// failed to start: three delivered prompts with replies, the not-sent message, then the start's +// row at the bottom. Built through the renderer's own path from journal items to rail slots. + +import { describe, expect, it } from 'vitest' +import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../../shared/agent-session-journal-item-key' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import { nativeChatTurnMembership } from '../../../../shared/native-chat-turn-membership' +import { nativeChatRowsInDrawOrder } from '../../../../shared/native-chat-turn-grouping' +import { structuredAgentSessionStartFailureRowIdentity } from '../../../../shared/structured-agent-session-start-failure-row-key' +import { findActiveNativeChatRailItem } from './native-chat-active-rail-item' +import { buildNativeChatRailItems } from './native-chat-message-rail-items' +import { createNativeChatMessageListProjection } from './native-chat-message-list-projection' +import type { NativeChatResolvedPrompt } from './native-chat-resolution-receipt' +import { projectNativeChatTaskListFrames } from './native-chat-task-list-frames' +import { omitNativeChatThreadGoalRows } from './native-chat-thread-goal-rows' +import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots' +import type { NativeChatTurnDiff } from './native-chat-turn-diffs' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' + +const START_FAILED: AgentSessionFailureFact = { kind: 'providerStartFailed' } +const NOT_SENT = agentJournalSubmissionKey('fourth') +const ROW_PX = 100 + +type Scope = AgentJournalRenderItem['turnScope'] + +function row( + itemId: string, + sequence: number, + body: AgentJournalRenderItem['body'], + turnScope: Scope +): AgentJournalRenderItem { + return { + itemId, + revision: 1, + sequence, + observedAt: 1000 + sequence, + body, + ...(turnScope ? { turnScope } : {}) + } +} + +function submission( + id: string, + dispatchState: AgentJournalSubmission['dispatchState'] +): AgentJournalSubmission { + return { + clientMessageId: id, + fence: 1, + payloadFingerprint: id, + dispatchState, + providerItemId: null, + reason: null, + ...(dispatchState === 'rejected' ? { rejection: START_FAILED } : {}), + submittedAt: 1, + resolvedAt: 1 + } +} + +/** With `scoped`, the host states each row's turn and writes a record per turn; without, an older + * host's journal is grouped by order. */ +function journal(scoped: boolean): { + items: AgentJournalRenderItem[] + submissions: AgentJournalSubmission[] +} { + const items: AgentJournalRenderItem[] = [] + const submissions: AgentJournalSubmission[] = [] + let sequence = 0 + for (const n of [1, 2, 3]) { + const user = agentJournalSubmissionKey(`m${n}`) + const turn = `turn-${n}` + const inTurn: Scope = scoped ? { kind: 'turn', turnItemId: turn } : undefined + if (scoped) { + items.push( + row( + turn, + (sequence += 1), + { + kind: 'turn', + turnId: turn, + state: 'completed', + outcome: 'success', + userItemId: user + }, + { kind: 'thread' } + ) + ) + } + items.push( + row( + user, + (sequence += 1), + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: `message ${n}` }] }, + inTurn + ), + row( + `reply-${n}`, + (sequence += 1), + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `reply ${n}` }] }, + inTurn + ) + ) + submissions.push(submission(`m${n}`, 'accepted')) + } + const thread: Scope = scoped ? { kind: 'thread' } : undefined + items.push( + row( + NOT_SENT, + (sequence += 1), + { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'fourth message after exit' }] + }, + thread + ), + row( + agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('start-1')), + (sequence += 1), + { + kind: 'status', + tone: 'error', + ...agentSessionFailureWords(START_FAILED, { agentName: 'Claude', surface: 'row' }) + }, + thread + ) + ) + submissions.push(submission('fourth', 'rejected')) + return { items, submissions } +} + +/** The renderer's path from the journal to the transcript's slots, as the message list runs it. */ +function slotsOf(scoped: boolean) { + const { items, submissions } = journal(scoped) + const projected = createNativeChatMessageListProjection()( + projectStructuredAgentSessionMessages(items, [], submissions, []) + ).conversation + const messages = omitNativeChatThreadGoalRows(projectNativeChatTaskListFrames(projected)) + const membership = nativeChatTurnMembership(messages, { items, submissions }) + return buildNativeChatTranscriptSlots({ + messages: nativeChatRowsInDrawOrder(messages, membership.drawOrder), + turnKeys: nativeChatRowsInDrawOrder(membership.turnKeys, membership.drawOrder), + liveTurnKey: membership.liveTurnKey, + receipts: new Map(), + turnStatuses: { active: null, completedByTurn: {} }, + turnDiffs: new Map(), + expandedTurnKeys: new Set(), + isWorking: false, + lifecycleWorking: false + }) +} + +describe("the rail below a message not sent because the agent's start failed", () => { + for (const scoped of [true, false]) { + describe(scoped ? 'host-stated turns' : 'turns by journal order', () => { + const slots = slotsOf(scoped) + const third = agentJournalSubmissionKey('m3') + const virtualItems = slots.map((_slot, index) => ({ + index, + start: index * ROW_PX, + end: (index + 1) * ROW_PX + })) + const read = (scrollTop: number, clientHeight: number, scrollHeight: number) => + findActiveNativeChatRailItem({ + slots, + virtualItems, + scrollTop, + clientHeight, + scrollHeight, + previousActiveId: null + }) + const indexOf = (find: (slot: (typeof slots)[number]) => boolean): number => + slots.findIndex(find) + + it('draws the not-sent message then the start row last, with three ticks', () => { + const notSent = indexOf((slot) => slot.kind === 'message' && slot.message.id === NOT_SENT) + expect(notSent).toBe(slots.length - 2) + const last = slots.at(-1) + expect(last?.kind === 'message' && last.message.role).not.toBe('user') + expect(buildNativeChatRailItems(slots).map((item) => item.id)).toEqual([ + agentJournalSubmissionKey('m1'), + agentJournalSubmissionKey('m2'), + third + ]) + }) + + it('lights the third message when the whole chat fits, pinned to the bottom', () => { + const height = slots.length * ROW_PX + expect(read(0, height + 400, height)).toBe(third) + }) + + it('lights the third message with the not-sent row or the start row at the fold', () => { + const scrollHeight = slots.length * ROW_PX + 2000 + expect(read((slots.length - 2) * ROW_PX, 300, scrollHeight)).toBe(third) + expect(read((slots.length - 1) * ROW_PX, 300, scrollHeight)).toBe(third) + }) + }) + } +}) diff --git a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts index 16c9a30a3972..f40c9fb2ccb3 100644 --- a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts @@ -95,14 +95,11 @@ function loadedRailItems(items: AgentJournalRenderItem[], submissions: AgentJour } describe('conversation outline parity with the loaded rail', () => { - // Except a rejected message: the desktop draws it in place and ticks it once loaded, while the - // host's outline, which older clients read too, leaves it out. + // A rejected message is drawn in place as not sent but is no prompt the agent saw, so it takes no + // tick, as the host's outline, which older clients read too, leaves it out. it('lists exactly the user messages the transcript gives a rail tick, with the same ids and previews', () => { const outline = projectAgentSessionConversationOutline(JOURNAL, [REJECTED]) - const rejectedId = agentJournalSubmissionKey(REJECTED.clientMessageId) - const loadedWithRejected = loadedRailItems(JOURNAL, [REJECTED]) - expect(loadedWithRejected.filter((item) => item.id === rejectedId)).toHaveLength(1) - const loaded = loadedWithRejected.filter((item) => item.id !== rejectedId) + const loaded = loadedRailItems(JOURNAL, [REJECTED]) expect(outline.map((entry) => entry.itemId)).toEqual(loaded.map((item) => item.id)) expect( diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices-tone.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices-tone.test.ts new file mode 100644 index 000000000000..3ec5a31740a0 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices-tone.test.ts @@ -0,0 +1,102 @@ +// Which delivery lines read muted: a plain "not sent" does, a line still in doubt keeps the error +// color, so nothing turns from red to muted and no doubt reads as settled. + +import { describe, expect, it, vi } from 'vitest' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import { + createStructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' + +function entry( + clientMessageId: string, + patch: Partial = {} +): StructuredAgentSessionOutboxEntry { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId, + sessionId: 'session-1', + text: clientMessageId, + attachments: [], + queuedAt: 1 + }), + ...patch + } +} + +const NOT_FAILED_HERE: ReadonlySet = new Set() + +describe('the tone of a delivery line', () => { + // Only a plain "not sent" reads muted, the host's copy until its row loads included, so nothing + // turns from red to muted; a doubt still reads as one to check. + it('marks as not sent only words that say the message did not go out', () => { + const notices = structuredAgentSessionDeliveryNotices( + [ + entry('doubt', { state: 'unconfirmed', retryAfterUnknownSubmittedAt: -1 }), + entry('expired', { + lastAttemptAt: 1, + lastFailure: { kind: 'refused', code: 'agent_session_operation_expired' } + }), + entry('hostCopy', { state: 'rejected', lastFailure: { kind: 'rejected', reason: null } }), + entry('held', { + lastFailure: { kind: 'refused', code: 'agent_session_owner_restart_failed' } + }) + ], + 'Claude', + vi.fn(), + [ + { + clientMessageId: 'elsewhere', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + reason: 'not_delivered', + submittedAt: 1, + resolvedAt: 1 + } + ], + [], + NOT_FAILED_HERE + ) + expect( + Object.fromEntries([...notices].map(([id, notice]) => [id, notice.notSent === true])) + ).toEqual({ + [agentJournalSubmissionKey('doubt')]: false, + [agentJournalSubmissionKey('expired')]: false, + [agentJournalSubmissionKey('hostCopy')]: true, + [agentJournalSubmissionKey('held')]: true, + [agentJournalSubmissionKey('elsewhere')]: true + }) + }) + + // A resend of an id still running: the host can't say what became of it, so it may have landed. + it("keeps a send the host couldn't confirm in the doubt color, seen here or not", () => { + const unknown = entry('unknown', { + lastAttemptAt: 1, + lastFailure: { kind: 'refused', code: 'agent_session_operation_unknown' } + }) + for (const failedHere of [new Set(['unknown']), NOT_FAILED_HERE]) { + const notice = structuredAgentSessionDeliveryNotices( + [unknown], + 'Claude', + vi.fn(), + [], + [], + failedHere + ).get(agentJournalSubmissionKey('unknown')) + expect(notice?.notSent).toBeUndefined() + } + expect( + structuredAgentSessionDeliveryNotices( + [unknown], + 'Claude', + vi.fn(), + [], + [], + new Set(['unknown']) + ).get(agentJournalSubmissionKey('unknown'))?.text + ).toBe("Orca couldn't confirm what happened. Check the chat.") + }) +}) 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 9054c35b5335..e59010442d40 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-recorded-rejection-words' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' function entry( clientMessageId: string, @@ -115,7 +113,7 @@ describe('the notice on each message that did not go through', () => { [], NOT_FAILED_HERE ).get(agentJournalSubmissionKey('earlier')) - expect(notice).toEqual({ text: 'Claude messages support at most 20 images' }) + expect(notice).toEqual({ text: 'Claude messages support at most 20 images', notSent: true }) expect(retry).not.toHaveBeenCalled() }) @@ -239,7 +237,8 @@ describe('the notice on each message that did not go through', () => { NOT_FAILED_HERE ) expect(notices.get(agentJournalSubmissionKey('rejected'))).toEqual({ - text: 'Message was not sent.' + text: 'Message was not sent.', + notSent: true }) } }) @@ -601,7 +600,9 @@ describe('the notice on each message that did not go through', () => { new Set(), [loadedRow] ) - expect([...loaded]).toEqual([[agentJournalSubmissionKey('q'), { text: 'provider said no' }]]) + expect([...loaded]).toEqual([ + [agentJournalSubmissionKey('q'), { text: 'provider said no', notSent: true }] + ]) }) }) 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 52f3832dffaf..09e2d77c7a04 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 @@ -11,23 +11,20 @@ // words and gets its Retry once the queue moves. // // A message the host recorded and then rejected is drawn from the host's history, worded from the -// journal's own fact, with no Retry: sending it again is a new message. A rejection that is a -// failed start's, the fact its loaded row states, says only that it was not sent: the row already -// says why. Until its row loads, the outbox draws it from its smaller copy, which leaves on the batch +// journal's own fact, with no Retry: sending it again is a new message. A rejection a loaded host +// row already states (a failed start's, or a command's result row) says only that it was not +// sent. 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 { agentSessionRefusalOperationState } from '../../../../shared/agent-session-refusal-retry' +import { structuredAgentSessionRecordedRejectionParts } from '../../../../shared/structured-agent-session-recorded-rejection-words' import { structuredAgentSessionEntryIdExpired, structuredAgentSessionEntryRejectedByHost, @@ -40,10 +37,7 @@ 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 { structuredAgentSessionRejectedShownInPlace } from '../../../../shared/structured-agent-session-message-projection' import { translate } from '@/i18n/i18n' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' @@ -51,52 +45,25 @@ import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' /** One shared value, so a rebuilt map re-renders no row still sending. */ const STRUCTURED_AGENT_SESSION_DELIVERY_SENDING: NativeChatDeliveryNotice = { sending: true } -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 - ) +function deliveryIsInDoubt(entry: StructuredAgentSessionOutboxEntry): boolean { + // A send attempted before a Stop and then interrupted may already be with the host. + const attemptedAcrossStop = + entry.outlivedStop === true && entry.lastAttemptAt !== null && !entry.lastFailure + return entry.state === 'unconfirmed' || attemptedAcrossStop } -/** 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) +/** Whether the entry is known not to have gone out, so its line reads muted, not as a doubt. */ +function deliveryNoticeSaysNotSent(entry: StructuredAgentSessionOutboxEntry): boolean { return ( - fact !== undefined && startFailures.some((stated) => sameAgentSessionFailureFact(stated, fact)) + !deliveryIsInDoubt(entry) && + !(entry.lastFailure && structuredAgentSessionEntryIdExpired(entry)) && + // The host could not say what became of it: it may have landed. + !( + entry.lastFailure?.kind === 'refused' && + agentSessionRefusalOperationState(entry.lastFailure.code) === 'unknown' + ) ) } @@ -105,10 +72,7 @@ function deliveryNoticeText( context: AgentSessionFailureWordsContext, failedHere: ReadonlySet ): string { - // A send attempted before a Stop and then interrupted may already be with the host. - const attemptedAcrossStop = - entry.outlivedStop === true && entry.lastAttemptAt !== null && !entry.lastFailure - if (entry.state === 'unconfirmed' || attemptedAcrossStop) { + if (deliveryIsInDoubt(entry)) { return translate( 'auto.components.native.chat.NativeChatStructuredSession.1f772bb5d0', 'Message delivery is unconfirmed.' @@ -133,24 +97,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( @@ -166,8 +112,9 @@ export function structuredAgentSessionDeliveryNotices( failedHere: ReadonlySet, /** The queue's live cards, which the transcript leaves a rejected message to. */ queuedMessageIds: readonly string[] = [], - /** The loaded commands, from `structuredAgentSessionCommandItemIds`: they report their own. */ - commandItemIds: ReadonlySet = NO_COMMANDS, + /** Commands whose loaded result row says how they ended, from + * `structuredAgentSessionCommandResultRows`. */ + commandResults?: ReadonlySet, /** The loaded rows: a rejected message's outbox copy leaves once its row is here. */ journalItems: readonly AgentJournalRenderItem[] = NO_ITEMS ): ReadonlyMap { @@ -196,10 +143,11 @@ export function structuredAgentSessionDeliveryNotices( (stalledFrom === -1 || index <= stalledFrom) && !structuredAgentSessionEntryRejectedByHost(entry) const text = deliveryNoticeText(entry, { agentName, retryControl }, failedHere) - notices.set( - agentJournalSubmissionKey(entry.clientMessageId), - retryControl ? { text, onRetry: () => retry(entry.clientMessageId) } : { text } - ) + notices.set(agentJournalSubmissionKey(entry.clientMessageId), { + text, + ...(deliveryNoticeSaysNotSent(entry) ? { notSent: true as const } : {}), + ...(retryControl ? { onRetry: () => retry(entry.clientMessageId) } : {}) + }) } else if ( !submissions.some( (submission) => @@ -214,15 +162,21 @@ export function structuredAgentSessionDeliveryNotices( } } // After the outbox's: in the host's words, whether its row or the outbox's copy draws it. - const shown = structuredAgentSessionRejectedShownInPlace( - submissions, - queuedMessageIds, - commandItemIds - ) + const shown = structuredAgentSessionRejectedShownInPlace(submissions, queuedMessageIds) 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, { + notSent: true, + text: agentSessionWriteNoticeText( + structuredAgentSessionRecordedRejectionParts( + submission, + { agentName }, + startFailures, + commandResults + ) + ) + }) } } return notices diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.older-host.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.older-host.test.ts index 7cc13a0a4eb6..7b8014a233a0 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.older-host.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.older-host.test.ts @@ -138,7 +138,7 @@ it("keeps the outbox copy of a rejected message whose row is outside the window, [], new Set() ).get(MESSAGE_ID) - expect(notice).toEqual({ text: 'Orca restarted before this message was sent.' }) + expect(notice).toEqual({ text: 'Orca restarted before this message was sent.', notSent: true }) // Paging back loads the row: the outbox lets go, and the host's row is the one drawn. state = reduceStructuredAgentSession(state, { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.recorded-copy.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.recorded-copy.test.ts index 84e10ca18b7b..336d29d3843c 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.recorded-copy.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.recorded-copy.test.ts @@ -156,7 +156,7 @@ it('leaves on the live batch that rejects it on a host that places the row at th expect(shown(state, [sent])).toEqual({ kept: [], rows: [{ id: MESSAGE_ID, unsent: true }], - notice: { text: WORDS } + notice: { text: WORDS, notSent: true } }) }) @@ -166,7 +166,7 @@ it('leaves on the page that opens the chat when that page holds the row', () => expect(shown(state, [RECORDED_COPY])).toEqual({ kept: [], rows: [{ id: MESSAGE_ID, unsent: true }], - notice: { text: WORDS } + notice: { text: WORDS, notSent: true } }) }) @@ -188,7 +188,7 @@ it('draws once, with no control, while its row is outside the window, and leaves const before = shown(state, [copy({ state: 'dispatching', lastAttemptAt: 9 })]) expect(before.kept).toMatchObject([{ clientMessageId: 'm', state: 'rejected' }]) expect(before.rows).toEqual([{ id: MESSAGE_ID, unsent: true }]) - expect(before.notice).toEqual({ text: WORDS }) + expect(before.notice).toEqual({ text: WORDS, notSent: true }) // Scrolling back loads the page; no control on the copy is involved. state = reduceStructuredAgentSession(state, { @@ -202,7 +202,7 @@ it('draws once, with no control, while its row is outside the window, and leaves expect(shown(state, before.kept)).toEqual({ kept: [], rows: [{ id: MESSAGE_ID, unsent: true }], - notice: { text: WORDS } + notice: { text: WORDS, notSent: true } }) }) @@ -212,7 +212,7 @@ it('keeps a stored copy whose record is not held drawn, with no control, and nev expect(after.kept).toEqual([RECORDED_COPY]) expect(after.rows).toEqual([{ id: MESSAGE_ID, unsent: true }]) - expect(after.notice).toEqual({ text: WORDS }) + expect(after.notice).toEqual({ text: WORDS, notSent: true }) // The drain passes over it: nothing it holds is ever on its way. expect(admitStructuredAgentSessionOutboxEntry(after.kept)).toEqual({ state: 'idle', entry: null }) }) 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 4e7577052256..d346be93a3fa 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 @@ -11,10 +11,7 @@ import { DISPATCH_REJECTED_CANCELLED, DISPATCH_REJECTED_HOST_RESTARTED } from '../../../../shared/structured-agent-session-dispatch-rejection' -import { - projectStructuredAgentSessionMessages as projectShared, - structuredAgentSessionCommandItemIds -} from '../../../../shared/structured-agent-session-message-projection' +import { projectStructuredAgentSessionMessages as projectShared } from '../../../../shared/structured-agent-session-message-projection' import { createStructuredAgentSessionOutboxEntry, type StructuredAgentSessionOutboxEntry @@ -199,11 +196,13 @@ describe('a message the host accepted and then rejected, on the desktop', () => ) expect(notices.get(agentJournalSubmissionKey('first'))).toEqual({ - text: 'Your message was not sent.' + text: 'Your message was not sent.', + notSent: true }) }) - it('is hidden by a copy of the same body sent once its rejection was known', () => { + // A resend of its text is a new message: the original stays the record that it failed. + it('stays at its place when the same text is sent again after it was rejected', () => { // An earlier build's Retry resent it under a new id, and that copy was delivered. const items = [...SEED_ROWS, userItem('old', 3, 'retry me'), userItem('resent', 4, 'retry me')] const submissions = [ @@ -214,8 +213,26 @@ describe('a message the host accepted and then rejected, on the desktop', () => expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([ { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, + { id: agentJournalSubmissionKey('old'), text: 'retry me', unsent: true }, { id: agentJournalSubmissionKey('resent'), text: 'retry me', unsent: false } ]) + // Only the original says it was not sent; the delivered resend says nothing. + expect([ + ...structuredAgentSessionDeliveryNotices( + [], + 'Claude', + () => {}, + submissions, + [], + new Set(), + NO_CARDS + ) + ]).toEqual([ + [ + agentJournalSubmissionKey('old'), + { text: 'Orca restarted before this message was sent.', notSent: true } + ] + ]) }) it('stays when the same text was sent again before it was rejected', () => { @@ -229,14 +246,14 @@ describe('a message the host accepted and then rejected, on the desktop', () => expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).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 place, so a reader that draws the list as it comes needs no sort. + { id: agentJournalSubmissionKey('first'), text: 'continue', unsent: true }, + { id: agentJournalSubmissionKey('again'), text: 'continue', unsent: false } ]) }) - // The host re-delivers its own message under new ids; however close their times, one row stays. - it('keeps the last of several copies rejected in the same instant', () => { + // Each rejected submission is its own record, however close their times or texts. + it('keeps every copy rejected in the same instant, each at its place', () => { const items = [...SEED_ROWS, userItem('a', 3, 'pointer'), userItem('b', 4, 'pointer')] const submissions = [ SEED, @@ -246,6 +263,7 @@ describe('a message the host accepted and then rejected, on the desktop', () => expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([ { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, + { id: agentJournalSubmissionKey('a'), text: 'pointer', unsent: true }, { id: agentJournalSubmissionKey('b'), text: 'pointer', unsent: true } ]) }) @@ -271,8 +289,8 @@ describe('a message the host accepted and then rejected, on the desktop', () => ]) }) - // Its own reply reports the rejection, in the composer, as a command's. - it('is not drawn when it was a command such as /compact', () => { + // Like any recorded message: its row is where every viewer learns it did not run. + it('is drawn as not sent when it was a command such as /compact', () => { const compact = { ...userItem('compact', 3, '/compact'), body: { ...body('/compact'), command: { name: 'compact' } } @@ -283,20 +301,22 @@ describe('a message the host accepted and then rejected, on the desktop', () => rows( projectStructuredAgentSessionMessages([...SEED_ROWS, compact], [], submissions, NO_CARDS) ) - ).toEqual([{ id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }]) + ).toEqual([ + { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, + { id: agentJournalSubmissionKey('compact'), text: '/compact', unsent: true } + ]) // One rule decides for the rows and the notices. - expect( - structuredAgentSessionDeliveryNotices( + expect([ + ...structuredAgentSessionDeliveryNotices( [], 'Claude', () => {}, submissions, [], new Set(), - NO_CARDS, - structuredAgentSessionCommandItemIds([...SEED_ROWS, compact]) - ).size - ).toBe(0) + NO_CARDS + ).keys() + ]).toEqual([agentJournalSubmissionKey('compact')]) }) it('keeps a message a Stop withdrew hidden: it went back to its sender', () => { @@ -368,12 +388,12 @@ describe("one row per rejected message, the host's once it records the rejection ) // In the host's words, with no control. expect([...notices]).toEqual([ - [hostRow.id, { text: 'Orca restarted before this message was sent.' }] + [hostRow.id, { text: 'Orca restarted before this message was sent.', notSent: true }] ]) } }) - it("keeps the old row beside an earlier build's resend until the host records the resend", () => { + it("keeps the old row beside an earlier build's resend, before and after the host records it", () => { const hostItems = [...SEED_ROWS, userItem('held', 3, 'outbox copy')] const resent = restartRejected('held', 'outbox copy', 3) const resend = outboxEntry('resend', 'outbox copy') @@ -401,6 +421,7 @@ describe("one row per rejected message, the host's once it records the rejection ) ).toEqual([ seedRow, + { id: agentJournalSubmissionKey('held'), text: 'outbox copy', unsent: true }, { id: agentJournalSubmissionKey('resend'), text: 'outbox copy', unsent: false } ]) }) 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..6e46e9caa7b5 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 @@ -53,7 +53,8 @@ function hostResult( async function sent( result: AgentSessionConversationCommandResult, - provider: 'claude' | 'codex' = 'claude' + provider: 'claude' | 'codex' = 'claude', + recordedIds: readonly string[] = [] ) { return sendStructuredConversationCommand({ command: result.command, @@ -61,7 +62,8 @@ async function sent( pending: { current: false }, blocked: false, startFailures: () => [], - send: async () => ({ kind: 'done', value: result }) + shownNotSent: (clientMessageId) => recordedIds.includes(clientMessageId), + send: async () => ({ kind: 'done', value: result, operationId: 'op-command' }) }) } @@ -98,6 +100,30 @@ describe('the line under the composer after a conversation command failed', () = ) }) + // Its row in the chat says it was not sent and why, for every viewer, so the reply stays quiet. + it('says nothing for a command the host recorded, and leaves its text to its row', async () => { + for (const result of [ + hostResult('compact', { kind: 'commandRefused' }), + hostResult('compact', { + kind: 'providerRejected', + detail: { text: 'busy', audience: 'person' } + }), + hostResult('compact', START_FAILED) + ]) { + expect(await sent(result, 'codex', ['op-command'])).toEqual({ accepted: true, error: null }) + } + // Not answered yet: nothing on its row says how it went. + const notStarted: AgentSessionConversationCommandResult = { + command: 'compact', + state: 'unknown', + error: 'The command has not started yet.' + } + expect(await sent(notStarted, 'codex', ['op-command'])).toEqual({ + accepted: false, + error: 'The command has not started yet.' + }) + }) + it("says it in the reader's language", async () => { await i18n.changeLanguage('fr') expect((await sent(hostResult('clear', START_FAILED))).error).toBe( @@ -156,7 +182,8 @@ describe('the line under the composer after a conversation command failed', () = pending: { current: false }, blocked: false, startFailures: () => [START_FAILED], - send: async () => ({ kind: 'done', value: result }) + shownNotSent: () => false, + send: async () => ({ kind: 'done', value: result, operationId: 'op-command' }) }) ).toEqual({ accepted: false, error }) }) 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..9c0597becf45 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-recorded-rejection-words' import type { StructuredAgentSessionWriteOutcome } from './use-structured-agent-session-mutate' export async function sendStructuredConversationCommand(input: { @@ -21,6 +21,8 @@ export async function sendStructuredConversationCommand(input: { blocked: boolean /** What the chat's loaded start-failure rows state, read when the reply lands. */ startFailures: () => readonly AgentSessionFailureFact[] + /** Whether the loaded journal already shows the message recorded under this id as not sent. */ + shownNotSent: (clientMessageId: string) => boolean send: ( command: AgentSessionConversationCommand ) => Promise> @@ -45,6 +47,11 @@ export async function sendStructuredConversationCommand(input: { return { accepted: false, error: null } } const { value } = outcome + // The command's own row already says it was not sent, and why: the reply says nothing more, and + // the text is the row's, not the composer's. + if (value.state === 'completed' && input.shownNotSent(outcome.operationId)) { + return { accepted: true, error: null } + } // The chat's own start failed and its loaded row already says why, as for a message that start // rejected. A /clear's failed start is its new chat's, whose row this pane never shows, and a // command this build doesn't know may be either, so its host's words are shown. diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-command-result-rows.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-command-result-rows.ts new file mode 100644 index 000000000000..a526c2c227a3 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-command-result-rows.ts @@ -0,0 +1,18 @@ +import { useMemo } from 'react' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { structuredAgentSessionCommandResultRows } from '../../../../shared/structured-agent-session-command-entry' + +const NO_ROWS: ReadonlySet = new Set() + +/** The commands whose result row is loaded, read only while `enabled`. Held while unchanged, so a + * streaming turn does not rebuild every row's delivery notice. */ +export function useStructuredAgentSessionCommandResultRows( + items: readonly AgentJournalRenderItem[], + enabled: boolean +): ReadonlySet { + const key = useMemo( + () => (enabled ? [...structuredAgentSessionCommandResultRows(items)].sort().join('\0') : ''), + [enabled, items] + ) + return useMemo(() => (key === '' ? NO_ROWS : new Set(key.split('\0'))), [key]) +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx index 698d83e61065..4e77f5156de9 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx @@ -5,7 +5,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' import { agentJournalItemKey } from '../../../../shared/agent-session-journal-item-key' -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' import { structuredAgentSessionStartFailureRowIdentity } from '../../../../shared/structured-agent-session-start-failure-row-key' const mocks = vi.hoisted(() => ({ @@ -15,6 +18,7 @@ const mocks = vi.hoisted(() => ({ })) let fence = 3 let items: AgentJournalRenderItem[] = [] +let submissions: AgentJournalSubmission[] = [] vi.mock('sonner', () => ({ toast: { error: mocks.toastError, message: vi.fn() } })) @@ -28,7 +32,7 @@ vi.mock('./use-structured-agent-session-read', () => ({ state: { fence, items, - submissions: [], + submissions, status: 'ready', error: null, hasOlder: false, @@ -150,6 +154,7 @@ beforeEach(() => { vi.clearAllMocks() fence = 3 items = [] + submissions = [] }) afterEach(async () => { @@ -204,6 +209,49 @@ describe('a conversation command whose reply lands after the fence moved', () => ).toEqual({ accepted: false, error: null }) }) + // The host recorded the /compact under the operation id, so its row in the chat says it. + const compactRow = (patch: Partial): AgentJournalSubmission => ({ + clientMessageId: 'operation-1', + fence: 3, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + reason: 'busy', + rejection: { kind: 'commandRefused' }, + submittedAt: 1, + resolvedAt: 1, + ...patch + }) + + it('says nothing for a /compact the host recorded, and clears its text: its row holds it', async () => { + submissions = [compactRow({})] + expect( + await commandAcrossFenceMove('compact', commandReply('compact', { kind: 'commandRefused' })) + ).toEqual({ accepted: true, error: null }) + }) + + // The reply can beat the stream: a row still pending may yet be withdrawn by a Stop and hidden. + it('says why when the reply lands before the row shows the rejection', async () => { + for (const row of [ + compactRow({ + dispatchState: 'pending', + reason: null, + rejection: undefined, + resolvedAt: null + }), + compactRow({ reason: 'provider_cancelled_before_start', rejection: { kind: 'cancelled' } }) + ]) { + fence = 3 + submissions = [row] + const outcome = await commandAcrossFenceMove( + 'compact', + commandReply('compact', { kind: 'commandRefused' }) + ) + expect(outcome.accepted).toBe(false) + expect(outcome.error).toMatch(/didn't run/) + } + }) + it("says why a /compact's start failed while its row is not loaded", async () => { expect( await commandAcrossFenceMove('compact', commandReply('compact', RESTART_FAILED)) @@ -284,7 +332,7 @@ describe('a conversation command whose reply lands after the fence moved', () => await Promise.all([older, newer]) }) expect(await older).toEqual({ kind: 'dropped' }) - expect(await newer).toEqual({ kind: 'done', value: commandReply('clear').value }) + expect(await newer).toMatchObject({ kind: 'done', value: commandReply('clear').value }) }) it('drops a reply once the pane has moved to another chat or closed', async () => { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts index 2c39d306e82a..fb6d5f48bd06 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts @@ -4,10 +4,10 @@ import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection' -import { structuredAgentSessionCommandItemIds } from '../../../../shared/structured-agent-session-message-projection' import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts' +import { useStructuredAgentSessionCommandResultRows } from './use-structured-agent-session-command-result-rows' import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' const NO_SUBMISSIONS: readonly AgentJournalSubmission[] = [] @@ -44,7 +44,7 @@ export function useStructuredAgentSessionDeliveryNotices(args: { // no notice otherwise. const loadedItems = hasRejected && outbox.length > 0 ? args.journalItems : NO_ITEMS const startFailures = useStructuredAgentSessionStartFailureFacts(args.journalItems, hasRejected) - const commandItemIds = useCommandItemIds(args.journalItems, hasRejected) + const commandResults = useStructuredAgentSessionCommandResultRows(args.journalItems, hasRejected) const notices = useMemo( () => structuredAgentSessionDeliveryNotices( @@ -55,7 +55,7 @@ export function useStructuredAgentSessionDeliveryNotices(args: { startFailures, failedHere, queuedMessageIds, - commandItemIds, + commandResults, loadedItems ), [ @@ -66,7 +66,7 @@ export function useStructuredAgentSessionDeliveryNotices(args: { startFailures, failedHere, queuedMessageIds, - commandItemIds, + commandResults, loadedItems ] ) @@ -79,28 +79,6 @@ export function useStructuredAgentSessionDeliveryNotices(args: { return stable } -const NO_COMMANDS: ReadonlySet = new Set() - -/** The loaded commands, read only while `enabled`, and held while unchanged so a streaming turn - * rebuilds no notice. */ -function useCommandItemIds( - items: readonly AgentJournalRenderItem[], - enabled: boolean -): ReadonlySet { - const ids = useMemo( - () => (enabled ? structuredAgentSessionCommandItemIds(items) : NO_COMMANDS), - [enabled, items] - ) - const previousRef = useRef(ids) - const previous = previousRef.current - const stable = - previous.size === ids.size && [...ids].every((id) => previous.has(id)) ? previous : ids - useEffect(() => { - previousRef.current = stable - }, [stable]) - return stable -} - /** `next`, reusing each notice `previous` words the same way, and `previous` itself when all are. */ function sameNoticesKept( previous: ReadonlyMap, @@ -116,7 +94,9 @@ function sameNoticesKept( // Each Retry calls the stable `retry` with its own id, so one under the same key is the same. const same = before !== undefined && + before.sending === notice.sending && before.text === notice.text && + before.notSent === notice.notSent && (before.onRetry === undefined) === (notice.onRetry === undefined) && (before.onDismiss === undefined) === (notice.onDismiss === undefined) allKept &&= same diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts index 1f5116718a50..9aaf350f6ce5 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts @@ -27,7 +27,8 @@ import { structuredSessionOperationId } from './use-structured-agent-session-out import { agentSessionWriteFailureText } from './agent-session-write-notice-text' export type StructuredAgentSessionWriteOutcome = - | { kind: 'done'; value: T } + /** `operationId`: the id the host recorded this write under. */ + | { kind: 'done'; value: T; operationId: string } /** Refused or failed, with what to tell the person. */ | { kind: 'not-done'; notice: string } /** Settled for an owner or session this pane no longer shows; there is nothing to say. */ @@ -105,12 +106,13 @@ export function useStructuredAgentSessionMutate(args: { } return enabledRef.current && (stateRef.current.fence === targetFence || waitedOn) } + const clientOperationId = structuredSessionOperationId() let result: AgentSessionMutationResult try { result = await callStructuredAgentSession>(target, method, { envelope: { sessionId, - clientOperationId: structuredSessionOperationId(), + clientOperationId, expectedRuntimeFence: targetFence, payloadFingerprint: structuredAgentSessionPayloadFingerprint({ method: fingerprintMethod, @@ -148,7 +150,7 @@ export function useStructuredAgentSessionMutate(args: { if (!settlesHere()) { return { kind: 'dropped' } } - return { kind: 'done', value: result.value } + return { kind: 'done', value: result.value, operationId: clientOperationId } }, [enabled, sessionId, stateRef, target] ) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx index cad9c99ac002..ced329d13673 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx @@ -346,7 +346,7 @@ describe('a send the host rejected because the agent never started', () => { const onSecond = notice(second.result.current.outbox, second.result.current.failedHere) for (const shown of [onFirst, onSecond]) { - expect(shown).toEqual({ text: REASON }) + expect(shown).toEqual({ text: REASON, notSent: true }) } await advanceProbeClock(1500) // Only the first mount's question about the send it left in doubt; nothing resends it. 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..595d68bffa46 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-recorded-rejection-words' 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 463e178cd202..be9189d3fc34 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 @@ -36,7 +36,8 @@ 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-recorded-rejection-words' +import { structuredAgentSessionJournalShowsRejection } from '../../../../shared/structured-agent-session-message-projection' import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' import { withNativeChatCutTurnNotices } from '../../../../shared/native-chat-cut-turn-notice' @@ -206,6 +207,11 @@ export function useStructuredAgentSession(args: { outbox.length ), startFailures: () => structuredAgentSessionStartFailureFacts(stateRef.current.items), + shownNotSent: (clientMessageId) => + structuredAgentSessionJournalShowsRejection( + stateRef.current.submissions, + clientMessageId + ), send: (command) => write( 'agentSession.conversationCommand', diff --git a/src/shared/agent-session-conversation-outline.ts b/src/shared/agent-session-conversation-outline.ts index 4d6b814d64d3..b038cfead73c 100644 --- a/src/shared/agent-session-conversation-outline.ts +++ b/src/shared/agent-session-conversation-outline.ts @@ -89,7 +89,7 @@ export function projectAgentSessionConversationOutline( } const entries: AgentSessionConversationOutlineEntry[] = [] const transcript = projectNativeChatTranscriptMessages( - // Unchanged on the wire: a desktop's rejected rows tick once their page is loaded. + // A rejected message is no prompt the agent saw: it takes no tick, here or on the desktop rail. projectStructuredAgentSessionMessages(items, [], submissions, { rejectedInPlace: false }) ) for (const message of transcript) { diff --git a/src/shared/structured-agent-session-command-entry.ts b/src/shared/structured-agent-session-command-entry.ts index fd76b45d43a6..d0eb38b4ba0e 100644 --- a/src/shared/structured-agent-session-command-entry.ts +++ b/src/shared/structured-agent-session-command-entry.ts @@ -2,13 +2,17 @@ // request of the user's to the agent: the sidebar's prompt, preview and verdict, the completion // feed and restart resume all read past them to the last real request. +import { parseAgentJournalItemKey } from './agent-session-journal-item-key' import type { AgentJournalItemBody, + AgentJournalItemIdentity, AgentJournalRenderItem, AgentJournalTurnLifecycle } from './agent-session-journal-types' import { readAgentJournalTurn } from './agent-session-turn-record' +const COMMAND_RESULT_ROW = 'command-result:' + export function isStructuredAgentSessionCommandEntry( body: AgentJournalItemBody | null | undefined ): boolean { @@ -52,3 +56,28 @@ export function isStructuredAgentSessionCommandRow( (scope?.kind === 'turn' && commandTurnItemIds.has(scope.turnItemId)) ) } + +/** The one row a command's turn reports how it ended on, keyed by the command's message id. */ +export function structuredAgentSessionCommandResultRowIdentity( + clientMessageId: string +): Extract { + return { provider: 'orca', clientMessageId: `${COMMAND_RESULT_ROW}${clientMessageId}` } +} + +/** The message ids of the commands whose result row is in `items`. */ +export function structuredAgentSessionCommandResultRows( + items: readonly AgentJournalRenderItem[] +): Set { + const ids = new Set() + for (const item of items) { + // Every result row is a status row; parsing only those keeps a long chat's scan cheap. + if (item.body.kind !== 'status') { + continue + } + const identity = parseAgentJournalItemKey(item.itemId) + if (identity?.provider === 'orca' && identity.clientMessageId.startsWith(COMMAND_RESULT_ROW)) { + ids.add(identity.clientMessageId.slice(COMMAND_RESULT_ROW.length)) + } + } + return ids +} diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 57a2f6fe15d6..149a71742a60 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -1,5 +1,6 @@ import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' import { agentJournalSubmissionKey } from './agent-session-journal-item-key' +import { compareAgentJournalPositions } from './agent-session-journal-position' import { isQueuedAgentJournalSubmission } from './agent-session-queued-submission' import { collapseProviderRetryRuns } from './native-chat-provider-retry-runs' import type { NativeChatMessage } from './native-chat-types' @@ -17,59 +18,24 @@ export type StructuredAgentSessionMessageProjectionOptions = { queuedMessageIds?: readonly string[] } -/** The loaded items that are a conversation command such as `/compact`, by item id. */ -export function structuredAgentSessionCommandItemIds( - items: readonly AgentJournalRenderItem[] -): Set { - return new Set( - items - .filter((item) => item.body.kind === 'message' && item.body.command) - .map((item) => item.itemId) - ) -} - /** - * The rejected submissions the host's history shows in place as not sent, by item id; `submissions` - * in submission order, as the client keeps them. A withdrawn one went back to its sender, one the - * queue holds (a draft's hand-off, or a card under its id) is drawn as its card, and a command such - * as `/compact` has its rejection reported as its own reply. + * The rejected submissions the host's history shows in place as not sent, by item id. A withdrawn + * one went back to its sender, and one the queue holds (a draft's hand-off, or a card under its id) + * is drawn as its card. Every other one stays, a command such as `/compact` included, whatever was + * sent after it: a resend of its text is a new message, and the row is the record of the failure. */ export function structuredAgentSessionRejectedShownInPlace( submissions: readonly AgentJournalSubmission[], - queuedMessageIds: readonly string[], - commandItemIds: ReadonlySet + queuedMessageIds: readonly string[] ): Set { const cards = new Set(queuedMessageIds) - // Each body's copies, as positions in submission order. A withdrawn one is hidden too, so it - // supersedes nothing. - const copies = new Map() - for (const [index, submission] of submissions.entries()) { - if (!dispatchWasWithdrawn(submission)) { - const copy = { index, submittedAt: submission.submittedAt } - const same = copies.get(submission.payloadFingerprint) - if (same) { - same.push(copy) - } else { - copies.set(submission.payloadFingerprint, [copy]) - } - } - } const shown = new Set() - for (const [index, submission] of submissions.entries()) { - const { resolvedAt } = submission + for (const submission of submissions) { if ( submission.dispatchState !== 'rejected' || dispatchWasWithdrawn(submission) || submission.queuedMessageId !== undefined || - cards.has(submission.clientMessageId) || - commandItemIds.has(agentJournalSubmissionKey(submission.clientMessageId)) || - // Collapses resends of a rejected message: past Retries resent it under a new id, and the - // host re-delivers its own messages under new ids. Only a later copy sent once the rejection - // was known counts, so a repeat sent before it is kept. - (resolvedAt !== null && - (copies.get(submission.payloadFingerprint) ?? []).some( - (copy) => copy.index > index && copy.submittedAt >= resolvedAt - )) + cards.has(submission.clientMessageId) ) { continue } @@ -78,6 +44,21 @@ export function structuredAgentSessionRejectedShownInPlace( return shown } +/** Whether the loaded journal already draws the send recorded under `clientMessageId` as not sent, + * so its row, not a reply, says it failed. One still pending may yet be withdrawn and hidden. */ +export function structuredAgentSessionJournalShowsRejection( + submissions: readonly AgentJournalSubmission[], + clientMessageId: string +): boolean { + const submission = submissions.find((entry) => entry.clientMessageId === clientMessageId) + return ( + submission?.dispatchState === 'rejected' && + structuredAgentSessionRejectedShownInPlace(submissions, []).has( + agentJournalSubmissionKey(clientMessageId) + ) + ) +} + export function projectStructuredAgentSessionMessages( items: readonly AgentJournalRenderItem[], outbox: readonly StructuredAgentSessionOutboxEntry[], @@ -93,11 +74,7 @@ export function projectStructuredAgentSessionMessages( .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) ) const inPlace = options.rejectedInPlace - ? structuredAgentSessionRejectedShownInPlace( - submissions, - options.queuedMessageIds ?? [], - structuredAgentSessionCommandItemIds(items) - ) + ? structuredAgentSessionRejectedShownInPlace(submissions, options.queuedMessageIds ?? []) : new Set() const visibleItems: AgentJournalRenderItem[] = [] const unsentItems: AgentJournalRenderItem[] = [] @@ -126,11 +103,14 @@ export function projectStructuredAgentSessionMessages( } } return [ - // After the held sends leave: they are drawn after the conversation, never inside a run. - ...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. + ...inJournalOrder( + // After the held sends leave: they are drawn after the conversation, never inside a run. + collapseProviderRetryRuns(delivered), + projectItems(unsentItems).map((message) => ({ ...message, unsent: true as const })) + ), ...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 => ({ @@ -145,3 +125,36 @@ export function projectStructuredAgentSessionMessages( })) ] } + +/** `rows`, already in journal order, with `placed` merged in at their journal positions. A placed + * row the host moved to its rejection can sit anywhere in `items`, so it is ordered first. */ +function inJournalOrder( + rows: readonly NativeChatMessage[], + unordered: readonly NativeChatMessage[] +): readonly NativeChatMessage[] { + if (unordered.length === 0) { + return rows + } + // Not `toSorted`: mobile's Hermes lacks it, and src/shared must stay loadable there. + const placed = Array.from(unordered).sort((a, b) => + journalPlaceBefore(a, b) ? -1 : journalPlaceBefore(b, a) ? 1 : 0 + ) + const merged: NativeChatMessage[] = [] + let next = 0 + for (const row of rows) { + for (let early = placed[next]; early && journalPlaceBefore(early, row); early = placed[next]) { + merged.push(early) + next += 1 + } + merged.push(row) + } + return merged.concat(placed.slice(next)) +} + +function journalPlaceBefore(a: NativeChatMessage, b: NativeChatMessage): boolean { + return ( + a.journalPosition !== undefined && + b.journalPosition !== undefined && + compareAgentJournalPositions(a.journalPosition, b.journalPosition) < 0 + ) +} diff --git a/src/shared/structured-agent-session-recorded-rejection-words.ts b/src/shared/structured-agent-session-recorded-rejection-words.ts new file mode 100644 index 000000000000..9e5995b583f4 --- /dev/null +++ b/src/shared/structured-agent-session-recorded-rejection-words.ts @@ -0,0 +1,85 @@ +// What a send the host recorded and then did not deliver says on its own row, on every client. +// A rejection a loaded host row already states says only that it was not sent: a failed start's, +// the fact its row states, or a command's, whose turn's result row says how it ended. + +import { + readAgentSessionFailureFact, + readWholeAgentSessionFailureFact, + type AgentSessionFailureFact +} from './agent-session-failure' +import type { AgentSessionFailureWordsContext } from './agent-session-failure-words' +import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' +import { agentSessionWriteNotDoneParts } from './agent-session-refusal-notice' +import type { AgentSessionWriteNoticePart } from './agent-session-write-notice-copy' +import { structuredAgentSessionRejectionParts } from './structured-agent-session-send-disposition' +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)) + ) +} + +const NO_COMMAND_RESULTS: ReadonlySet = new Set() + +/** The words on the row of a send the host recorded and then rejected, in the host's terms. */ +export function structuredAgentSessionRecordedRejectionParts( + submission: AgentJournalSubmission, + context: AgentSessionFailureWordsContext, + startFailures: readonly AgentSessionFailureFact[], + /** Commands whose result row is loaded, from `structuredAgentSessionCommandResultRows`. */ + commandResults: ReadonlySet = NO_COMMAND_RESULTS +): AgentSessionWriteNoticePart[] { + if ( + commandResults.has(submission.clientMessageId) || + 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.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts index 9605764b0179..34dc853c5b57 100644 --- a/src/shared/structured-agent-session-send-disposition.test.ts +++ b/src/shared/structured-agent-session-send-disposition.test.ts @@ -415,6 +415,33 @@ describe('ambiguous operation refusals', () => { expect(disposition.entries).toMatchObject([{ clientMessageId: 'fresh-id', state: 'rejected' }]) }) + it("parks an older host's restart answer that carries the reason but not the recovered marker", () => { + const result = rejectedWith(null) + if (!result.ok || !('submission' in result.value)) { + throw new Error('expected a send result') + } + result.value.submission = { + ...result.value.submission, + dispatchState: 'unknown', + reason: 'host_restarted_before_acknowledgement' + } + + const disposition = disposeStructuredAgentSessionSendResult({ + entries: [entry], + entry, + result, + createOperationId: () => 'unused' + }) + + expect(disposition.entries).toMatchObject([ + { + clientMessageId: entry.clientMessageId, + state: 'unconfirmed', + retryAfterUnknownSubmittedAt: -1 + } + ]) + }) + it('parks a recovered missing submission without polling forever', () => { const result = rejectedWith(null) if (!result.ok || !('submission' in result.value)) { diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index 77e1030d4b21..b5e4a8148644 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -22,6 +22,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 { isRecoveredStructuredAgentSessionSubmission } from './structured-agent-session-unanswered-dispatch' import { classifyStructuredAgentSessionSendFailure, requeueStructuredAgentSessionSendRefusal, @@ -286,7 +287,7 @@ export function disposeStructuredAgentSessionSendResult( error: null } } - if (submission.dispatchState === 'unknown' && submission.recovered) { + if (isRecoveredStructuredAgentSessionSubmission(submission)) { return { entries: input.entries.map((candidate) => candidate.clientMessageId === input.entry.clientMessageId diff --git a/src/shared/structured-agent-session-unanswered-dispatch.test.ts b/src/shared/structured-agent-session-unanswered-dispatch.test.ts new file mode 100644 index 000000000000..39a67f880e71 --- /dev/null +++ b/src/shared/structured-agent-session-unanswered-dispatch.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalSubmission } from './agent-session-journal-types' +import { + isRecoveredStructuredAgentSessionSubmission, + isUnansweredStructuredAgentSessionDispatch +} from './structured-agent-session-unanswered-dispatch' + +function submission(patch: Partial): AgentJournalSubmission { + return { + clientMessageId: 'client-1', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'unknown', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: 1, + ...patch + } +} + +describe('a send whose outcome the host lost for good', () => { + it('is one the host marked recovered', () => { + const row = submission({ recovered: true, reason: 'provider_exited_before_acknowledgement' }) + expect(isRecoveredStructuredAgentSessionSubmission(row)).toBe(true) + expect(isUnansweredStructuredAgentSessionDispatch(row)).toBe(false) + }) + + it("is an older host's restart row, which carries the reason without the marker", () => { + const row = submission({ reason: 'host_restarted_before_acknowledgement' }) + expect(isRecoveredStructuredAgentSessionSubmission(row)).toBe(true) + expect(isUnansweredStructuredAgentSessionDispatch(row)).toBe(false) + }) + + it('is never a live unknown, which something still running may answer', () => { + const row = submission({ reason: 'provider_ack_ambiguous' }) + expect(isRecoveredStructuredAgentSessionSubmission(row)).toBe(false) + expect(isUnansweredStructuredAgentSessionDispatch(row)).toBe(true) + }) + + it('is never a settled send, whatever its reason says', () => { + expect( + isRecoveredStructuredAgentSessionSubmission( + submission({ dispatchState: 'rejected', reason: 'host_restarted_before_acknowledgement' }) + ) + ).toBe(false) + }) +}) diff --git a/src/shared/structured-agent-session-unanswered-dispatch.ts b/src/shared/structured-agent-session-unanswered-dispatch.ts index 412b3e981968..2113525564d3 100644 --- a/src/shared/structured-agent-session-unanswered-dispatch.ts +++ b/src/shared/structured-agent-session-unanswered-dispatch.ts @@ -1,6 +1,19 @@ import type { AgentJournalSubmission } from './agent-session-journal-types' import { isQueuedAgentJournalSubmission } from './agent-session-queued-submission' +/** A send whose outcome the host lost for good when the process that sent it went away (a restart, + * a provider exit, an idle release): nothing still running can answer it. */ +export function isRecoveredStructuredAgentSessionSubmission( + submission: Pick +): boolean { + return ( + submission.dispatchState === 'unknown' && + (submission.recovered === true || + // Older hosts publish the recovery reason but omit the optional marker. + submission.reason === 'host_restarted_before_acknowledgement') + ) +} + /** One send the provider has neither opened a turn for nor refused; the rule is explained on * `hasUnansweredStructuredAgentSessionDispatch`, which asks it of every send. */ export function isUnansweredStructuredAgentSessionDispatch( @@ -15,9 +28,7 @@ export function isUnansweredStructuredAgentSessionDispatch( (currentFence == null || submission.fence >= currentFence) && (submission.dispatchState === 'pending' || (submission.dispatchState === 'unknown' && - submission.recovered !== true && - // Older hosts publish the recovery reason but omit the optional marker. - submission.reason !== 'host_restarted_before_acknowledgement')) + !isRecoveredStructuredAgentSessionSubmission(submission))) ) }