From 3cbb62ba47a8d9314b1c9ab6c202ef5cf0fe60be Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:56:03 -0700 Subject: [PATCH 1/8] fix(native-chat): list not-sent rows at their journal places The shared projection appended rows the host recorded but never delivered after every other row, leaving the reader to sort them. A reader that draws the list as given showed an old not-sent message under newer turns. Merge them in at their journal places, after the stop rows are built, so the list is already in order. The desktop sorts by the same order, so what it draws is unchanged. --- ...ssage-projection.rejected-in-place.test.ts | 9 ++- ...d-agent-session-message-projection.test.ts | 65 +++++++++++++++++++ ...ctured-agent-session-message-projection.ts | 38 +++++++++-- 3 files changed, 101 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts index 6381611738cd..8b58c0b8b4be 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts @@ -226,9 +226,8 @@ describe('a message the host accepted and then rejected, on the desktop', () => expect(rows(projectStructuredAgentSessionMessages(items, [], submissions))).toEqual([ { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, - { id: agentJournalSubmissionKey('again'), text: 'continue', unsent: false }, - // Listed after the delivered rows; its journal position keeps its place. - { id: agentJournalSubmissionKey('first'), text: 'continue', unsent: true } + { id: agentJournalSubmissionKey('first'), text: 'continue', unsent: true }, + { id: agentJournalSubmissionKey('again'), text: 'continue', unsent: false } ]) }) @@ -265,8 +264,8 @@ describe('a message the host accepted and then rejected, on the desktop', () => expect(rows(messages)).toEqual([ { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, { id: agentJournalSubmissionKey('first'), text: 'again', unsent: false }, - { id: agentJournalSubmissionKey('stopped'), text: 'again', unsent: false }, - { id: agentJournalSubmissionKey('failed'), text: 'again', unsent: true } + { id: agentJournalSubmissionKey('failed'), text: 'again', unsent: true }, + { id: agentJournalSubmissionKey('stopped'), text: 'again', unsent: false } ]) expectStopRowRightAfterStopped(messages) }) diff --git a/src/shared/structured-agent-session-message-projection.test.ts b/src/shared/structured-agent-session-message-projection.test.ts index ee292e14fdc1..9eb1e1713cfb 100644 --- a/src/shared/structured-agent-session-message-projection.test.ts +++ b/src/shared/structured-agent-session-message-projection.test.ts @@ -6,6 +6,8 @@ import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-ses import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import { structuredAgentSessionSendBody } from './structured-agent-session-send-mutation' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' +import { projectNativeChatTranscriptMessages } from './native-chat-transcript-projection' +import { DISPATCH_REJECTED_CANCELLED } from './structured-agent-session-dispatch-rejection' const KEPT_ID = 'client-kept' // The desktop draws a rejected send in place, as not sent, unless the host kept it as a card. @@ -78,3 +80,66 @@ describe('a send kept as a card', () => { expect(shown[0]).not.toHaveProperty('unsent') }) }) + +describe('a not-sent row', () => { + const said = (id: string, text: string, sequence: number): AgentJournalRenderItem => ({ + itemId: id, + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } + }) + const delivered = (id: string, submittedAt: number): AgentJournalSubmission => ({ + ...rejected({ clientMessageId: id, payloadFingerprint: id, submittedAt }), + dispatchState: 'accepted', + reason: null, + rejection: undefined + }) + const items = [ + userItem('first', 'first', 1), + said('reply-1', 'one', 2), + userItem(KEPT_ID, 'not sent', 3), + userItem('later', 'later', 4), + said('reply-2', 'two', 5) + ] + const submissions = [delivered('first', 1), rejected(), delivered('later', 3)] + + // The phone draws the list as given, so the projection itself puts the row in place. + it('is listed where the host recorded it, not after later turns', () => { + const shown = projectStructuredAgentSessionMessages(items, [], submissions, DESKTOP) + expect(shown.map((message) => [message.id, message.unsent ?? false])).toEqual([ + [agentJournalSubmissionKey('first'), false], + ['reply-1', false], + [agentJournalSubmissionKey(KEPT_ID), true], + [agentJournalSubmissionKey('later'), false], + ['reply-2', false] + ]) + // The desktop sorts by the same order, so what it draws is unchanged. + expect(projectNativeChatTranscriptMessages(shown).map((message) => message.id)).toEqual( + shown.map((message) => message.id) + ) + }) + + it('follows the stop row of a send taken back before it', () => { + const stoppedId = 'stopped' + const shown = projectStructuredAgentSessionMessages( + [userItem(stoppedId, 'taken back', 1), userItem(KEPT_ID, 'not sent', 2)], + [], + [ + rejected({ + clientMessageId: stoppedId, + payloadFingerprint: stoppedId, + reason: DISPATCH_REJECTED_CANCELLED, + rejection: undefined + }), + rejected({ submittedAt: 3 }) + ], + DESKTOP + ) + expect(shown.map((message) => message.id)).toEqual([ + agentJournalSubmissionKey(stoppedId), + `stopped-before-start:${agentJournalSubmissionKey(stoppedId)}`, + agentJournalSubmissionKey(KEPT_ID) + ]) + }) +}) diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 43c3cf1ffee5..24a3912c4d55 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -178,17 +178,21 @@ export function projectStructuredAgentSessionMessages( if (shownStopped.size > 0) { moved = keepStoppedSendsInSendOrder(delivered, stoppedBeforeStart, shownStopped) || moved } - const conversation = moved + const ordered = moved ? Array.from(collapseProviderRetryRuns(delivered)).sort(compareNativeChatTranscriptMessages) : collapseProviderRetryRuns(delivered) + const conversation = + shownStopped.size > 0 ? withStopRowsAfterStoppedSends(ordered, shownStopped) : ordered return [ - // After the held sends leave: they are drawn after the conversation, never inside a run. - ...(shownStopped.size > 0 - ? withStopRowsAfterStoppedSends(conversation, shownStopped) + // In no turn; merged at their journal places, after the stop rows so none splits a run. + ...(unsentItems.length > 0 + ? withRowsAtJournalPlaces( + conversation, + projectItems(unsentItems).map((message) => ({ ...message, unsent: true as const })) + ) : conversation), + // After the held sends leave: they are drawn after the conversation, never inside a run. ...held, - // In no turn; the journal position keeps their place. - ...projectItems(unsentItems).map((message) => ({ ...message, unsent: true as const })), // The host's row draws a message once it has one, under its own id or the provider's. ...optimistic .filter( @@ -206,3 +210,25 @@ export function projectStructuredAgentSessionMessages( })) ] } + +/** `rows` with the few `placed` rows merged in at their journal places, so a reader that draws the + * list as given needs no sort. A row with no journal place keeps the row before it. */ +function withRowsAtJournalPlaces( + rows: readonly NativeChatMessage[], + placed: NativeChatMessage[] +): NativeChatMessage[] { + placed.sort(compareNativeChatTranscriptMessages) + const merged: NativeChatMessage[] = [] + let next = 0 + for (const row of rows) { + while ( + next < placed.length && + row.journalPosition !== undefined && + compareNativeChatTranscriptMessages(placed[next], row) < 0 + ) { + merged.push(placed[next++]) + } + merged.push(row) + } + return merged.concat(placed.slice(next)) +} From a9d9000bec00c00b3dd454e874a375b363e0a0f4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:58:13 -0700 Subject: [PATCH 2/8] refactor(native-chat): move the recorded-rejection line into shared words The desktop's words for a message the host recorded and then rejected now live beside the other rejection words, returning notice parts each client renders its own way, so the phone can say the same thing. The desktop calls it with the same inputs; its words are unchanged. --- ...ructured-agent-session-delivery-notices.ts | 35 ++++------------ ...ured-agent-session-rejection-words.test.ts | 42 +++++++++++++++++++ ...tructured-agent-session-rejection-words.ts | 25 ++++++++++- 3 files changed, 75 insertions(+), 27 deletions(-) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts index 96b1f66efb8f..00392a0c4a35 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts @@ -1,14 +1,10 @@ // What each of the structured chat's own messages says under it about its delivery. Derived from // the sender's in-memory sends and the host's rows on every render, never stored. -import { - readWholeAgentSessionFailureFact, - type AgentSessionFailureFact -} from '../../../../shared/agent-session-failure' +import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' -import { agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice' -import { structuredAgentSessionRejectionParts } from '../../../../shared/structured-agent-session-rejection-words' +import { structuredAgentSessionRecordedRejectionParts } from '../../../../shared/structured-agent-session-rejection-words' import { structuredAgentSessionRejectedShownInPlace } from '../../../../shared/structured-agent-session-message-projection' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' @@ -23,25 +19,6 @@ export { agentSessionFailureStatedByRow as agentSessionFailureStatedByStartRow, sameAgentSessionFailureFact } from '../../../../shared/agent-session-visible-failures' -import { agentSessionFailureStatedByRow as agentSessionFailureStatedByStartRow } from '../../../../shared/agent-session-visible-failures' - -function hostRejectionNoticeText( - submission: AgentJournalSubmission, - agentName: string, - startFailures: readonly AgentSessionFailureFact[] -): string { - if (agentSessionFailureStatedByStartRow(submission.rejection, startFailures)) { - return agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send')) - } - return agentSessionWriteNoticeText( - structuredAgentSessionRejectionParts( - submission.reason, - 'send', - readWholeAgentSessionFailureFact(submission.rejection), - { agentName } - ) - ) -} /** * Keyed by the message id the transcript renders each message under; `agentName` is the chat's @@ -78,7 +55,13 @@ export function structuredAgentSessionDeliveryNotices(args: { const id = agentJournalSubmissionKey(submission.clientMessageId) if (submission.dispatchState === 'rejected' && shown.has(id)) { notices.set(id, { - text: hostRejectionNoticeText(submission, agentName, args.startFailures) + text: agentSessionWriteNoticeText( + structuredAgentSessionRecordedRejectionParts( + submission, + { agentName }, + args.startFailures + ) + ) }) } } diff --git a/src/shared/structured-agent-session-rejection-words.test.ts b/src/shared/structured-agent-session-rejection-words.test.ts index b984008f42f9..33388f8326f8 100644 --- a/src/shared/structured-agent-session-rejection-words.test.ts +++ b/src/shared/structured-agent-session-rejection-words.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest' import type { AgentSessionFailureFact } from './agent-session-failure' +import type { AgentJournalSubmission } from './agent-session-journal-types' import { agentSessionWriteNoticeEnglish } from './agent-session-refusal-notice' import { DISPATCH_REJECTED_QUEUE_FULL } from './structured-agent-session-dispatch-rejection' import { + structuredAgentSessionRecordedRejectionParts, structuredAgentSessionRejectionNotice, structuredAgentSessionRejectionParts } from './structured-agent-session-rejection-words' @@ -79,3 +81,43 @@ describe('what a rejection with a typed fact shows the reader', () => { ) }) }) + +// The line under a message the host recorded and then rejected, on the desktop and the phone. +describe('the line under a recorded rejection', () => { + const signedOut: AgentSessionFailureFact = { kind: 'notSignedIn' } + const recorded = (reason: string | null, rejection?: AgentSessionFailureFact) => + structuredAgentSessionRecordedRejectionParts( + { + clientMessageId: 'sent', + fence: 1, + payloadFingerprint: 'sent', + dispatchState: 'rejected', + providerItemId: null, + reason, + submittedAt: 1, + resolvedAt: 2, + ...(rejection ? { rejection } : {}) + } satisfies AgentJournalSubmission, + { agentName: 'Claude' }, + [signedOut] + ) + const line = (...args: Parameters) => + agentSessionWriteNoticeEnglish(recorded(...args)) + + it('says only that it was not sent when a loaded row already states the reason', () => { + expect(line('Written by the host.', signedOut)).toBe('Your message was not sent.') + }) + + it('words a fact no row states from the fact itself', () => { + expect(line('Written by the host.', { kind: 'writeFailed' })).toBe( + "Orca couldn't reach the agent. Your message was not sent." + ) + }) + + it("shows an older host's reason sentence, and never a legacy marker", () => { + expect(line('Claude messages support at most 20 images')).toBe( + 'Claude messages support at most 20 images' + ) + expect(line('not_delivered')).toBe('Your message was not sent.') + }) +}) diff --git a/src/shared/structured-agent-session-rejection-words.ts b/src/shared/structured-agent-session-rejection-words.ts index fcc780a19b18..9a9eca67345c 100644 --- a/src/shared/structured-agent-session-rejection-words.ts +++ b/src/shared/structured-agent-session-rejection-words.ts @@ -6,8 +6,13 @@ import { agentSessionWriteNotDoneParts } from './agent-session-refusal-notice' import type { AgentSessionWriteNoticePart } from './agent-session-write-notice-copy' -import type { AgentSessionFailureFact } from './agent-session-failure' +import { + readWholeAgentSessionFailureFact, + type AgentSessionFailureFact +} from './agent-session-failure' import type { AgentSessionFailureWordsContext } from './agent-session-failure-words' +import type { AgentJournalSubmission } from './agent-session-journal-types' +import { agentSessionFailureStatedByRow } from './agent-session-visible-failures' import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' /** @@ -56,6 +61,24 @@ export function structuredAgentSessionRejectionParts( return rejection.kind ? agentSessionWriteNotDoneParts(write) : [{ text: reason }] } +/** The line under a message the host recorded and then rejected. `startFailures`: what the loaded + * rows already state (`agentSessionVisibleFailureFacts`), so the reason is said once. */ +export function structuredAgentSessionRecordedRejectionParts( + submission: AgentJournalSubmission, + context: AgentSessionFailureWordsContext, + startFailures: readonly AgentSessionFailureFact[] +): AgentSessionWriteNoticePart[] { + if (agentSessionFailureStatedByRow(submission.rejection, startFailures)) { + return agentSessionWriteNotDoneParts('send') + } + return structuredAgentSessionRejectionParts( + submission.reason, + 'send', + readWholeAgentSessionFailureFact(submission.rejection), + context + ) +} + function rejectionFactParts( write: 'send' | 'composer-send', fact: AgentSessionFailureFact, From 4432c63cba58adc3ecee92ee7b10d9bbb1a98b77 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:10:35 -0700 Subject: [PATCH 3/8] refactor(native-chat): one projection behaviour for rejected rows Every reader now draws a message the host recorded and then rejected where it was sent, marked not sent, so the rejectedInPlace option goes. The phone was its last off caller apart from the host's conversation outline, which now leaves not-sent rows out itself; a test proves the outline is the same as if the rejected message had never been sent. --- ...native-chat-merged-snapshot-parity.test.ts | 4 +-- ...-native-chat-stopped-before-start.test.tsx | 4 +-- ...chat-turn-disclosure-opening-send.test.tsx | 3 +- .../use-mobile-structured-agent-session.ts | 7 ++-- .../codex-collab-call-delegation.test.ts | 4 +-- ...ab-call-rows-on-positional-clients.test.ts | 4 +-- .../codex-structured-question-order.test.ts | 7 ++-- ...journal-rejected-message-placement.test.ts | 6 ++-- .../journal-turn-scope.test.ts | 4 +-- ...t-session-claude-opening-send-hold.test.ts | 2 +- .../structured-agent-session-create.test.ts | 4 +-- ...structured-agent-session-host-test-data.ts | 4 +-- ...red-agent-session-unsent-send-hold.test.ts | 7 ++-- ...nt-session-codex-opening-send-hold.test.ts | 4 +-- ...t-session-codex-stopped-send-order.test.ts | 6 ++-- ...ativeChatMessageList.opening-send.test.tsx | 8 ++--- ...NativeChatMessageList.rewind-rows.test.tsx | 4 +-- ...hatMessageList.stream-render.perf.test.tsx | 4 +-- ...veChatMessageList.turn-membership.test.tsx | 8 ++--- .../native-chat-rail-outline-parity.test.ts | 12 +++++++ .../native-chat-work-run-pairing.test.ts | 4 +-- ...ssage-projection.rejected-in-place.test.ts | 8 +++-- ...ctured-agent-session-message-projection.ts | 8 +---- .../agent-session-conversation-outline.ts | 6 ++-- .../native-chat-cut-turn-notice.test.ts | 8 ++--- .../native-chat-provider-retry-runs.test.ts | 8 ++--- .../native-chat-stopped-send-turn.test.ts | 2 +- ...ge-projection-stopped-before-start.test.ts | 33 ++++++++----------- ...d-agent-session-message-projection.test.ts | 20 ++++------- ...ctured-agent-session-message-projection.ts | 19 +++-------- 30 files changed, 84 insertions(+), 138 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts b/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts index 9d75bb02d7d6..ae934923c72c 100644 --- a/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts +++ b/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts @@ -60,9 +60,7 @@ function phoneTranscript(state: StructuredAgentSessionState) { }) return { items, - messages: projectStructuredAgentSessionMessages(items, [], state.submissions, { - rejectedInPlace: false - }) + messages: projectStructuredAgentSessionMessages(items, [], state.submissions) } } diff --git a/mobile/src/session/mobile-native-chat-stopped-before-start.test.tsx b/mobile/src/session/mobile-native-chat-stopped-before-start.test.tsx index 8d7d2cc56591..3b640a8317a0 100644 --- a/mobile/src/session/mobile-native-chat-stopped-before-start.test.tsx +++ b/mobile/src/session/mobile-native-chat-stopped-before-start.test.tsx @@ -150,9 +150,7 @@ describe('a send a Stop took back before the agent started it, on the phone', () }) it('stays where it was sent, with the stop row after it and no turn status of its own', () => { - const messages = projectStructuredAgentSessionMessages(items, [], submissions, { - rejectedInPlace: false - }) + const messages = projectStructuredAgentSessionMessages(items, [], submissions) expect(messages.map((message) => message.id)).toEqual([ agentJournalSubmissionKey('warm-up'), 't1-answer', diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure-opening-send.test.tsx b/mobile/src/session/use-mobile-native-chat-turn-disclosure-opening-send.test.tsx index 1c8e285179f1..49f8146c0849 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure-opening-send.test.tsx +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure-opening-send.test.tsx @@ -92,8 +92,7 @@ function Harness(props: { const messages: NativeChatMessage[] = projectStructuredAgentSessionMessages( props.items, [], - props.submissions, - { rejectedInPlace: false } + props.submissions ) const { data } = buildMobileNativeChatTransientData({ messages, diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index ceb172306486..e3fda0d19596 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -142,11 +142,8 @@ 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. - () => - projectStructuredAgentSessionMessages(transcriptItems, [], state.submissions, { - rejectedInPlace: false - }), + // A message the host recorded and then rejected stays where it was sent, as not sent. + () => projectStructuredAgentSessionMessages(transcriptItems, [], state.submissions), [transcriptItems, state.submissions] ) const turnId = runningStructuredAgentSessionTurnId(state) diff --git a/src/main/codex/codex-collab-call-delegation.test.ts b/src/main/codex/codex-collab-call-delegation.test.ts index dbc116802e41..96131248ddc6 100644 --- a/src/main/codex/codex-collab-call-delegation.test.ts +++ b/src/main/codex/codex-collab-call-delegation.test.ts @@ -28,9 +28,7 @@ import { THREAD_ID } from './codex-structured-session-adapter-fixture' /** The delegation the parent's newest tool run reads as, the row a running chat's frontier judges. */ async function newestRunDelegation(frames: Frame[]): Promise { const { conversation } = projectNativeChatTranscript( - projectStructuredAgentSessionMessages(await publishedRows(frames), [], [], { - rejectedInPlace: true - }) + projectStructuredAgentSessionMessages(await publishedRows(frames), [], []) ) const runs = conversation.filter((message: NativeChatMessage) => message.blocks.some((block) => block.type === 'tool-call') diff --git a/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts b/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts index ae7770b5bebe..795716ed553c 100644 --- a/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts +++ b/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts @@ -47,9 +47,7 @@ function positionalRuns(rows: AgentJournalRenderItem[]): { }) }) const transcript = projectNativeChatTranscript( - projectStructuredAgentSessionMessages(rows, [], [], { rejectedInPlace: true }).map( - withoutCallIds - ) + projectStructuredAgentSessionMessages(rows, [], []).map(withoutCallIds) ) // The conversation's runs, then each helper's section's. const runs = [ diff --git a/src/main/codex/codex-structured-question-order.test.ts b/src/main/codex/codex-structured-question-order.test.ts index f942b5f83a88..4a85cbffa7f1 100644 --- a/src/main/codex/codex-structured-question-order.test.ts +++ b/src/main/codex/codex-structured-question-order.test.ts @@ -209,7 +209,6 @@ function drawnPromptRows(): string[][] { client.items, [], client.submissions, - { rejectedInPlace: true }, projectStructuredQuestionMessages ) ) @@ -247,9 +246,9 @@ describe('a Codex ask with several questions', () => { ]) // Mobile draws the shared projection in journal order, one row per question. expect( - projectStructuredAgentSessionMessages(client.items, [], client.submissions, { - rejectedInPlace: false - }).map(({ blocks }) => (blocks[0]?.type === 'text' ? blocks[0].text.split('\n')[0] : null)) + projectStructuredAgentSessionMessages(client.items, [], client.submissions).map( + ({ blocks }) => (blocks[0]?.type === 'text' ? blocks[0].text.split('\n')[0] : null) + ) ).toEqual(ASKED.map(({ question }) => question)) }) diff --git a/src/main/native-chat/agent-session-journal/journal-rejected-message-placement.test.ts b/src/main/native-chat/agent-session-journal/journal-rejected-message-placement.test.ts index ce5b407d3e84..6a124226cacc 100644 --- a/src/main/native-chat/agent-session-journal/journal-rejected-message-placement.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-rejected-message-placement.test.ts @@ -149,9 +149,9 @@ describe('a message in doubt', () => { expect(placed(state, 'steer')).toEqual({ sequence: 12, observedAt: 1_012, scope: IN_TURN }) const { items, submissions } = renderJournalState(state) - const drawn = projectStructuredAgentSessionMessages(items, [], submissions, { - rejectedInPlace: true - }).find((message) => message.id === agentJournalSubmissionKey('steer')) + const drawn = projectStructuredAgentSessionMessages(items, [], submissions).find( + (message) => message.id === agentJournalSubmissionKey('steer') + ) expect(drawn).toBeDefined() expect(drawn?.unsent).toBeUndefined() }) diff --git a/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts b/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts index 4674f6de9296..2b8e4b80a761 100644 --- a/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts @@ -163,9 +163,7 @@ describe('stated turn scope', () => { const expected = [agentJournalItemKey(row('result')), agentJournalSubmissionKey('held')] const onPhone = () => { const { items, submissions } = renderJournalState(state) - return projectStructuredAgentSessionMessages(items, [], submissions, { - rejectedInPlace: false - }) + return projectStructuredAgentSessionMessages(items, [], submissions) } // Still waiting: drawn after everything the agent did, the command's result included. expect(drawn(onPhone())).toEqual(expected) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts index 18040c241ded..256db2f8de37 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts @@ -149,7 +149,7 @@ async function snapshot() { async function drawn(): Promise<{ text: string; turn: string | undefined }[]> { const { items, submissions } = await snapshot() const rows = projectNativeChatTranscriptMessages( - projectStructuredAgentSessionMessages(items, [], submissions, { rejectedInPlace: true }) + projectStructuredAgentSessionMessages(items, [], submissions) ) const { drawOrder, turnKeys } = nativeChatTurnMembership(rows, { items, submissions }) const keyed = rows.map((row, index) => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts index c3f2a4855986..2bee381140a5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts @@ -346,9 +346,7 @@ it('admits Stop with the create-returned fence after acquisition advances it and } ]) expect( - projectStructuredAgentSessionMessages(snapshot.items, [], snapshot.submissions, { - rejectedInPlace: true - }) + projectStructuredAgentSessionMessages(snapshot.items, [], snapshot.submissions) ).toMatchObject([ { role: 'user', blocks: first.body.blocks }, { blocks: [{ type: 'text', text: NATIVE_CHAT_STOPPED_BEFORE_START_TEXT }] } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts index ce3a50938882..4f7d87fb47cc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts @@ -77,8 +77,6 @@ export function hostTestDrawnRowIds( queuedAt: HOST_TEST_NOW })) return projectNativeChatTranscriptMessages( - projectStructuredAgentSessionMessages(snapshot.items, optimistic, snapshot.submissions, { - rejectedInPlace: true - }) + projectStructuredAgentSessionMessages(snapshot.items, optimistic, snapshot.submissions) ).map(({ id }) => id) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts index 872d08c1da40..e3b0257711e6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts @@ -263,9 +263,7 @@ describe('the next accepted turn releases a kept card', () => { const shown = projectStructuredAgentSessionMessages( page.page.items, [lingering], - page.page.submissions, - // The desktop's transcript, which draws a rejected send in place unless it was kept. - { rejectedInPlace: true } + page.page.submissions ).map((message) => ({ text: message.blocks.map((block) => ('text' in block ? block.text : '')).join(''), unsent: message.unsent ?? false @@ -482,8 +480,7 @@ describe('a message accepted while the agent starts, then the chat closes', () = const texts = projectStructuredAgentSessionMessages( page.page.items, [], - page.page.submissions, - { rejectedInPlace: true } + page.page.submissions ) .flatMap((shown) => shown.blocks.map((block) => ('text' in block ? block.text : ''))) .join('|') diff --git a/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts b/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts index 6e3e94a3fa14..06fc96b51bfc 100644 --- a/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts +++ b/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts @@ -259,7 +259,7 @@ async function drawn(): Promise<{ text: string; turn: string | undefined }[]> { await host.flushStreamedEvents(SESSION) const { items, submissions } = await host.journalSnapshot(SESSION) const rows = projectNativeChatTranscriptMessages( - projectStructuredAgentSessionMessages(items, [], submissions, { rejectedInPlace: true }) + projectStructuredAgentSessionMessages(items, [], submissions) ) const { drawOrder, turnKeys } = nativeChatTurnMembership(rows, { items, submissions }) const keyed = rows.map((row, index) => ({ @@ -274,7 +274,7 @@ async function liveTurn(): Promise { await host.flushStreamedEvents(SESSION) const { items, submissions } = await host.journalSnapshot(SESSION) const rows = projectNativeChatTranscriptMessages( - projectStructuredAgentSessionMessages(items, [], submissions, { rejectedInPlace: true }) + projectStructuredAgentSessionMessages(items, [], submissions) ) return nativeChatTurnMembership(rows, { items, submissions }).liveTurnKey } diff --git a/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts index 02c7ae282f27..fe54ee7c2bad 100644 --- a/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts @@ -113,7 +113,7 @@ async function drawn(): Promise { const { items, submissions } = await host.journalSnapshot(SESSION) const journal = { items, submissions } const { conversation } = projectNativeChatTranscript( - projectStructuredAgentSessionMessages(items, [], submissions, { rejectedInPlace: true }), + projectStructuredAgentSessionMessages(items, [], submissions), undefined, journal ) @@ -349,9 +349,7 @@ describe('a send a Stop took back before Codex echoed it', () => { // Every client draws the send where it was sent, then the one row saying it never started. await host.flushStreamedEvents(SESSION) const { items, submissions: settled } = await host.journalSnapshot(SESSION) - const messages = projectStructuredAgentSessionMessages(items, [], settled, { - rejectedInPlace: true - }) + const messages = projectStructuredAgentSessionMessages(items, [], settled) expect(messages.slice(-2).map((message) => [message.role, message.blocks])).toEqual([ ['user', [{ type: 'text', text: 'look around' }]], ['system', [expect.objectContaining({ text: NATIVE_CHAT_STOPPED_BEFORE_START_TEXT })]] diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.opening-send.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.opening-send.test.tsx index f95b9f8ae709..8fef8febbe0d 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.opening-send.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.opening-send.test.tsx @@ -125,9 +125,7 @@ describe('a message sent while the turn ahead is still opening', () => { return ( { return ( { } const view = (items: AgentJournalRenderItem[]) => ( { ]) }) + // Served to clients of every version: a rejected row the transcript draws in place, as not sent, + // changes nothing in it, neither its own tick nor any other entry or reply. + it('is the same as if a rejected message had never been sent', () => { + const rejectedId = agentJournalSubmissionKey(REJECTED.clientMessageId) + expect(projectAgentSessionConversationOutline(JOURNAL, [REJECTED])).toEqual( + projectAgentSessionConversationOutline( + JOURNAL.filter((item) => item.itemId !== rejectedId), + [] + ) + ) + }) + // The host serves this outline to clients of every version, so it lists what it always listed. it('leaves out a send a Stop took back, which the transcript still draws', () => { const stopped: AgentJournalSubmission = { diff --git a/src/renderer/src/components/native-chat/native-chat-work-run-pairing.test.ts b/src/renderer/src/components/native-chat/native-chat-work-run-pairing.test.ts index ad9696941f67..5f1d1d194c16 100644 --- a/src/renderer/src/components/native-chat/native-chat-work-run-pairing.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-work-run-pairing.test.ts @@ -53,9 +53,7 @@ const diff = (id: string, sequence: number, file: string) => /** As the transcript list gets them: projected, then adjacent tool rows folded. */ function messagesOf(items: AgentJournalRenderItem[]): NativeChatMessage[] { - return foldToolMessages( - projectStructuredAgentSessionMessages(items, [], [], { rejectedInPlace: false }) - ) + return foldToolMessages(projectStructuredAgentSessionMessages(items, [], [])) } /** Each edit card's call input and the files it draws. */ 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 8b58c0b8b4be..4f9ffe8abbea 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 @@ -379,7 +379,7 @@ describe('a rejected message the queue holds', () => { }) describe('the phone', () => { - it('still hides a message the host failed to deliver, but draws one a Stop withdrew', () => { + it('draws the same rows as the desktop: not sent in place, and a stopped send with its stop row', () => { const items = [ ...SEED_ROWS, userItem('lost', 3, 'fix the parser'), @@ -391,11 +391,15 @@ describe('the phone', () => { withdrawn('stopped', 'never mind', 4) ] - const messages = projectShared(items, [], submissions, { rejectedInPlace: false }) + const messages = projectShared(items, [], submissions) expect(rows(messages)).toEqual([ { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, + { id: agentJournalSubmissionKey('lost'), text: 'fix the parser', unsent: true }, { id: agentJournalSubmissionKey('stopped'), text: 'never mind', unsent: false } ]) + expect(rows(messages)).toEqual( + rows(projectStructuredAgentSessionMessages(items, [], submissions)) + ) expectStopRowRightAfterStopped(messages) }) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts index 25ff332ceb87..fa3216d760ca 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts @@ -14,13 +14,7 @@ export function projectStructuredAgentSessionMessages( optimistic: readonly StructuredAgentSessionOptimisticMessage[], submissions: readonly AgentJournalSubmission[] ) { - return projectMessages( - items, - optimistic, - submissions, - { rejectedInPlace: true }, - projectStructuredQuestionMessages - ) + return projectMessages(items, optimistic, submissions, projectStructuredQuestionMessages) } export type StructuredPromptItem = AgentJournalRenderItem & { diff --git a/src/shared/agent-session-conversation-outline.ts b/src/shared/agent-session-conversation-outline.ts index 89063e5fbfab..2b6d540af316 100644 --- a/src/shared/agent-session-conversation-outline.ts +++ b/src/shared/agent-session-conversation-outline.ts @@ -178,8 +178,10 @@ export function projectAgentSessionConversationOutline( ) const entries: AgentSessionConversationOutlineEntry[] = [] const transcript = projectNativeChatTranscriptMessages( - // Unchanged on the wire: a desktop's rejected rows tick once their page is loaded. - projectStructuredAgentSessionMessages(items, [], submissions, { rejectedInPlace: false }) + // Unchanged on the wire: a not-sent row ticks only once a desktop loads its page. + projectStructuredAgentSessionMessages(items, [], submissions).filter( + (message) => !message.unsent + ) ) const { turnKeys } = nativeChatTurnMembership(transcript, { items, submissions }) const replies = nativeChatTurnReplyPreviews(transcript, turnKeys) diff --git a/src/shared/native-chat-cut-turn-notice.test.ts b/src/shared/native-chat-cut-turn-notice.test.ts index 09fc37ba5773..0b1057c4374d 100644 --- a/src/shared/native-chat-cut-turn-notice.test.ts +++ b/src/shared/native-chat-cut-turn-notice.test.ts @@ -339,9 +339,7 @@ describe('withNativeChatCutTurnNotices', () => { it('places the notice in its turn, for desktop and phone alike', () => { const items = [user('u1'), turn('t1', 'u1', CUT), reply('a1', inTurn('t1')), user('u2')] const derived = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }) - const messages = projectStructuredAgentSessionMessages(derived, [], [], { - rejectedInPlace: true - }) + const messages = projectStructuredAgentSessionMessages(derived, [], []) const { turnKeys } = nativeChatTurnMembership(messages, { items: derived, submissions: [] }) const notice = messages.findIndex((message) => message.id.includes('cut-turn-notice')) @@ -376,9 +374,7 @@ describe('withNativeChatCutTurnNotices', () => { } ] const derived = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }) - const messages = projectStructuredAgentSessionMessages(derived, [], submissions, { - rejectedInPlace: true - }) + const messages = projectStructuredAgentSessionMessages(derived, [], submissions) const { turnKeys } = nativeChatTurnMembership(messages, { items: derived, submissions }) const notice = messages.findIndex((message) => message.id.includes('cut-turn-notice')) diff --git a/src/shared/native-chat-provider-retry-runs.test.ts b/src/shared/native-chat-provider-retry-runs.test.ts index 3bb0c2d02271..0dff50c5f1db 100644 --- a/src/shared/native-chat-provider-retry-runs.test.ts +++ b/src/shared/native-chat-provider-retry-runs.test.ts @@ -49,7 +49,7 @@ function texts(messages: readonly NativeChatMessage[]): string[] { } function drawn(items: AgentJournalRenderItem[]): string[] { - return texts(projectStructuredAgentSessionMessages(items, [], [], { rejectedInPlace: true })) + return texts(projectStructuredAgentSessionMessages(items, [], [])) } describe('a run of provider retry rows', () => { @@ -101,8 +101,7 @@ describe('a run of provider retry rows', () => { retry(2, 'subagent-1') ], [], - [], - { rejectedInPlace: true } + [] ) ) expect(texts(conversation)).toEqual([ @@ -137,8 +136,7 @@ describe('a run of provider retry rows', () => { resolvedAt: null, handoverRecorded: true } - ], - { rejectedInPlace: true } + ] ) expect(messages.map((message) => [message.id, message.queued ?? false])).toEqual([ [expect.stringMatching(/^item-/), false], diff --git a/src/shared/native-chat-stopped-send-turn.test.ts b/src/shared/native-chat-stopped-send-turn.test.ts index c5004cae3cc2..39934a8eff93 100644 --- a/src/shared/native-chat-stopped-send-turn.test.ts +++ b/src/shared/native-chat-stopped-send-turn.test.ts @@ -80,7 +80,7 @@ const stopped = (id: string, overrides: Partial = {}) => function drawn(items: AgentJournalRenderItem[], submissions: AgentJournalSubmission[]) { const journal = { items, submissions } const { conversation } = projectNativeChatTranscript( - projectStructuredAgentSessionMessages(items, [], submissions, { rejectedInPlace: false }), + projectStructuredAgentSessionMessages(items, [], submissions), undefined, journal ) diff --git a/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts index 73e93915dfd5..d1cc577eca9a 100644 --- a/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts +++ b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts @@ -37,9 +37,6 @@ function entry( return { itemId, revision: 0, sequence, observedAt: sequence, body, turnScope } } -// A client that draws no other rejected send in place; a send a Stop took back is drawn either way. -const NO_IN_PLACE = { rejectedInPlace: false } - const said = (itemId: string, text: string, turnItemId: string) => entry( itemId, @@ -77,7 +74,7 @@ const stopped = (id: string, overrides: Partial = {}) => /** Each row as id and role, in the order the transcript draws them. */ function rows(items: AgentJournalRenderItem[], submissions: AgentJournalSubmission[]) { return projectNativeChatTranscriptMessages( - projectStructuredAgentSessionMessages(items, [], submissions, NO_IN_PLACE) + projectStructuredAgentSessionMessages(items, [], submissions) ).map((message) => ({ id: message.id, role: message.role })) } @@ -132,12 +129,7 @@ describe('a send a Stop took back before the agent started it', () => { user('never-ran'), stopRow('never-ran') ]) - const [, , row] = projectStructuredAgentSessionMessages( - items, - [], - [stopped('never-ran')], - NO_IN_PLACE - ) + const [, , row] = projectStructuredAgentSessionMessages(items, [], [stopped('never-ran')]) expect(row?.blocks).toEqual([ { type: 'text', @@ -146,8 +138,7 @@ describe('a send a Stop took back before the agent started it', () => { } ]) expect(row?.journalPosition).toEqual( - projectStructuredAgentSessionMessages(items, [], [stopped('never-ran')], NO_IN_PLACE)[1] - ?.journalPosition + projectStructuredAgentSessionMessages(items, [], [stopped('never-ran')])[1]?.journalPosition ) }) @@ -396,7 +387,7 @@ describe('a send a Stop took back before the agent started it', () => { ] const journal = { items, submissions } const { conversation } = projectNativeChatTranscript( - projectStructuredAgentSessionMessages(items, [], submissions, NO_IN_PLACE), + projectStructuredAgentSessionMessages(items, [], submissions), undefined, journal ) @@ -738,13 +729,15 @@ describe('a send a Stop took back before the agent started it', () => { expect(rows(items, [stopped('card-send', { queuedMessageId: 'card-1' })])).toEqual([]) }) - it('keeps any other refused send out of the conversation', () => { - const items = [sent('refused', 'hello')] + it('draws any other refused send as not sent, with no stop row', () => { + const shown = projectStructuredAgentSessionMessages( + [sent('refused', 'hello')], + [], + [submission('refused', { dispatchState: 'rejected', reason: DISPATCH_REJECTED_WRITE_FAILED })] + ) - expect( - rows(items, [ - submission('refused', { dispatchState: 'rejected', reason: DISPATCH_REJECTED_WRITE_FAILED }) - ]) - ).toEqual([]) + expect(shown.map(({ id, unsent }) => ({ id, unsent }))).toEqual([ + { id: agentJournalSubmissionKey('refused'), unsent: true } + ]) }) }) diff --git a/src/shared/structured-agent-session-message-projection.test.ts b/src/shared/structured-agent-session-message-projection.test.ts index 9eb1e1713cfb..fae3a79b4f03 100644 --- a/src/shared/structured-agent-session-message-projection.test.ts +++ b/src/shared/structured-agent-session-message-projection.test.ts @@ -10,8 +10,6 @@ import { projectNativeChatTranscriptMessages } from './native-chat-transcript-pr import { DISPATCH_REJECTED_CANCELLED } from './structured-agent-session-dispatch-rejection' const KEPT_ID = 'client-kept' -// The desktop draws a rejected send in place, as not sent, unless the host kept it as a card. -const DESKTOP = { rejectedInPlace: true } function rejected(fields: Partial = {}): AgentJournalSubmission { return { @@ -49,13 +47,11 @@ describe('a send kept as a card', () => { it('shows nothing of the original send, even beside its own local copy', () => { const kept = rejected({ keptAsQueuedMessageId: KEPT_ID }) const items = [userItem(KEPT_ID, 'the kept words', 1)] - expect(projectStructuredAgentSessionMessages(items, [lingeringCopy], [kept], DESKTOP)).toEqual( - [] - ) + expect(projectStructuredAgentSessionMessages(items, [lingeringCopy], [kept])).toEqual([]) // A rejection with no card is drawn from the host's row, marked not sent. - expect( - projectStructuredAgentSessionMessages(items, [lingeringCopy], [rejected()], DESKTOP) - ).toEqual([expect.objectContaining({ id: agentJournalSubmissionKey(KEPT_ID), unsent: true })]) + expect(projectStructuredAgentSessionMessages(items, [lingeringCopy], [rejected()])).toEqual([ + expect.objectContaining({ id: agentJournalSubmissionKey(KEPT_ID), unsent: true }) + ]) }) // Edit is the card's text in the composer, then the card's Delete, then a new send. @@ -72,8 +68,7 @@ describe('a send kept as a card', () => { const shown = projectStructuredAgentSessionMessages( [userItem(KEPT_ID, 'the kept words', 1), userItem('client-edited', 'the edited words', 2)], [lingeringCopy], - [kept, edited], - DESKTOP + [kept, edited] ) expect(shown).toHaveLength(1) expect(shown[0]).toMatchObject({ blocks: [{ text: 'the edited words' }] }) @@ -106,7 +101,7 @@ describe('a not-sent row', () => { // The phone draws the list as given, so the projection itself puts the row in place. it('is listed where the host recorded it, not after later turns', () => { - const shown = projectStructuredAgentSessionMessages(items, [], submissions, DESKTOP) + const shown = projectStructuredAgentSessionMessages(items, [], submissions) expect(shown.map((message) => [message.id, message.unsent ?? false])).toEqual([ [agentJournalSubmissionKey('first'), false], ['reply-1', false], @@ -133,8 +128,7 @@ describe('a not-sent row', () => { rejection: undefined }), rejected({ submittedAt: 3 }) - ], - DESKTOP + ] ) expect(shown.map((message) => message.id)).toEqual([ agentJournalSubmissionKey(stoppedId), diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 24a3912c4d55..f68172160cdd 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -29,12 +29,6 @@ export type StructuredAgentSessionOptimisticMessage = { sentWhileStopping?: true } -export type StructuredAgentSessionMessageProjectionOptions = { - /** Draw a message the host accepted and then rejected where the host recorded it, as not sent. - * Off for a client that hands such a message back to its composer instead. */ - rejectedInPlace: boolean -} - /** The loaded items that are a conversation command such as `/compact`, by item id. */ export function structuredAgentSessionCommandItemIds( items: readonly AgentJournalRenderItem[] @@ -105,7 +99,6 @@ export function projectStructuredAgentSessionMessages( items: readonly AgentJournalRenderItem[], optimistic: readonly StructuredAgentSessionOptimisticMessage[], submissions: readonly AgentJournalSubmission[], - options: StructuredAgentSessionMessageProjectionOptions, projectItems = projectStructuredItemsToNativeChat ): NativeChatMessage[] { // A send a Stop took back before the agent started it stays where it was sent, as the @@ -128,13 +121,11 @@ export function projectStructuredAgentSessionMessages( // Runs on every streaming commit, so a chat with no such send pays nothing for placing one. const placement = stoppedBeforeStart.size > 0 ? placeStoppedSends(items, submissions, stoppedBeforeStart) : null - const inPlace = options.rejectedInPlace - ? structuredAgentSessionRejectedShownInPlace( - submissions, - structuredAgentSessionCommandItemIds(items), - items.filter((item) => isAgentSessionContextClear(item.body)).map((item) => item.sequence) - ) - : new Set() + const inPlace = structuredAgentSessionRejectedShownInPlace( + submissions, + structuredAgentSessionCommandItemIds(items), + items.filter((item) => isAgentSessionContextClear(item.body)).map((item) => item.sequence) + ) const visibleItems: AgentJournalRenderItem[] = [] const unsentItems: AgentJournalRenderItem[] = [] for (const item of items) { From 49b7d7eb979129d6a31d8e108de0875601caa3d2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:15:01 -0700 Subject: [PATCH 4/8] fix(mobile): show a not-sent message in place with the reason under it The phone now draws a message the host recorded and then rejected where it was sent, as the desktop does, with one small line under the bubble in the desktop's words. The line is built from the journal the view already receives; when a start-failure or sign-in row already gives the reason, it says only that the message was not sent. The load-earlier header moves to its own file to keep the view under its line limit. --- .../session/MobileNativeChatMessage.test.ts | 35 ++++++++ .../src/session/MobileNativeChatMessage.tsx | 9 +++ mobile/src/session/MobileNativeChatView.tsx | 25 +++--- .../mobile-native-chat-list-header.tsx | 24 ++++++ .../mobile-native-chat-message-styles.ts | 5 ++ .../mobile-native-chat-unsent-notices.test.ts | 81 +++++++++++++++++++ .../mobile-native-chat-unsent-notices.ts | 34 ++++++++ 7 files changed, 197 insertions(+), 16 deletions(-) create mode 100644 mobile/src/session/mobile-native-chat-list-header.tsx create mode 100644 mobile/src/session/mobile-native-chat-unsent-notices.test.ts create mode 100644 mobile/src/session/mobile-native-chat-unsent-notices.ts diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index b5a8e8b395c6..9176b18c8eae 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -84,6 +84,7 @@ describe('MobileNativeChatMessage', () => { } | null onToggleTurn?: () => void reasoningIsLive?: boolean + unsentNotice?: string } = {} ): ReactTestRenderer { act(() => { @@ -168,6 +169,40 @@ describe('MobileNativeChatMessage', () => { expect(rows.some((row) => row.props.style.includes(styles.rowUser))).toBe(false) }) + describe('a message the host recorded and did not deliver', () => { + const notSent = { ...userMessage([{ type: 'text', text: 'fix it' }]), unsent: true as const } + const lineIn = (tree: ReactTestRenderer) => + tree.root.findAll( + (node) => String(node.type) === 'Text' && node.props.style === styles.unsentNotice + ) + + it('says why under the bubble, and that it was not sent when no reason is known', () => { + expect(textIn(render(notSent, { unsentNotice: 'Claude is not signed in.' }).root)).toEqual([ + 'fix it', + 'Claude is not signed in.' + ]) + expect(textIn(render(notSent).root).at(-1)).toBe('Your message was not sent.') + }) + + it("draws the line under another agent's message too, and never under a delivered one", () => { + const fromAgent: NativeChatMessage = { + ...notSent, + from: { + kind: 'agent', + senders: [ + { + party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null }, + name: 'Coder' + } + ], + orchestration: null + } + } + expect(lineIn(render(fromAgent))).toHaveLength(1) + expect(lineIn(render(userMessage([{ type: 'text', text: 'fix it' }])))).toHaveLength(0) + }) + }) + it('preserves an ordinary assistant answer without interpreting its text as a host notice', () => { const tree = render(toolMessage([{ type: 'text', text: 'provider fallback' }])) expect(tree.root.find((node) => String(node.type) === 'MobileMarkdown').props.content).toBe( diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index 315b6aa7168b..c72a18dcbb45 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -31,6 +31,7 @@ import type { NativeChatTurnStatus } from './use-mobile-native-chat-turn-status' import { isRenderableImageUri } from './mobile-native-chat-image-preview' import { styles, TEXT_SIZE } from './mobile-native-chat-message-styles' import { agentMessageAttribution } from './mobile-agent-message-attribution' +import { AGENT_SESSION_WRITE_NOTICE_COPY } from '../../../src/shared/agent-session-write-notice-copy' import { withoutPendingNativeChatVisualDirectiveTail } from '../../../src/shared/native-chat-visual-directive' import { MobileNativeChatVisualContext, @@ -146,6 +147,7 @@ function MobileNativeChatMessageImpl({ reasoningIsLive = false, reasoningExpanded, onToggleReasoning, + unsentNotice, subagentGroupsOpen, onToggleSubagentGroup }: { @@ -175,6 +177,8 @@ function MobileNativeChatMessageImpl({ /** The transcript-held disclosure of a reasoning row; one stable handler takes its key. */ reasoningExpanded?: boolean onToggleReasoning?: (key: string) => void + /** Why the host did not deliver this message, when it is drawn as not sent. */ + unsentNotice?: string /** The roster groups the reader opened, held by the transcript; only a roster row gets it. */ subagentGroupsOpen?: ReadonlySet onToggleSubagentGroup?: (groupId: string) => void @@ -308,6 +312,11 @@ function MobileNativeChatMessageImpl({ /> ) : null} + {message.role === 'user' && message.unsent ? ( + + {unsentNotice ?? AGENT_SESSION_WRITE_NOTICE_COPY.notDoneSend} + + ) : null} {actionsOpen ? ( mobileNativeChatUnsentNotices(turnJournal, agent), + [turnJournal, agent] + ) const renderItem = useCallback( ({ item, index }: { item: NativeChatMessage; index: number }) => ( ), - [toolsExpanded, fontScale, onOpenFile, structuredActivityUi, turns] + [toolsExpanded, fontScale, onOpenFile, structuredActivityUi, turns, unsentNotices] ) const liveStatus = turns.liveLine ? ( @@ -368,21 +375,7 @@ export function MobileNativeChatView({ scrollEventThrottle={32} onContentSizeChange={pinToTailAfterContentResize} onLayout={pinToTail} - ListHeaderComponent={ - hasMore ? ( - - {loadingEarlier ? ( - - ) : ( - Load earlier messages - )} - - ) : null - } + ListHeaderComponent={mobileNativeChatListHeader(hasMore, loadingEarlier, loadEarlier)} ListFooterComponent={mobileNativeChatListFooter( liveStatus, turns.waitingRows, diff --git a/mobile/src/session/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 ad2325ffbbeb..45e448c823b5 100644 --- a/mobile/src/session/mobile-native-chat-message-styles.ts +++ b/mobile/src/session/mobile-native-chat-message-styles.ts @@ -75,6 +75,11 @@ export const styles = StyleSheet.create({ color: colors.textMuted, fontSize: typography.metaSize }, + unsentNotice: { + marginTop: spacing.xs, + color: colors.textMuted, + fontSize: typography.metaSize + }, toolRun: { marginTop: spacing.xs }, 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..c59062059681 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-unsent-notices.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../src/shared/agent-session-journal-item-key' +import type { AgentSessionFailureFact } from '../../../src/shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../src/shared/agent-session-journal-types' +import { structuredAgentSessionStartFailureRowIdentity } from '../../../src/shared/structured-agent-session-start-failure-row-key' +import { mobileNativeChatUnsentNotices } from './mobile-native-chat-unsent-notices' + +function submission( + clientMessageId: string, + patch: Partial = {} +): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: 'rejected', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: 2, + ...patch + } +} + +const startFailed: AgentSessionFailureFact = { + kind: 'startFailed', + refusal: { code: 'agent_session_identity_required', details: { reason: 'recordMissing' } } +} + +const startRow: AgentJournalRenderItem = { + itemId: agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('gen')), + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'status', + tone: 'error', + ...agentSessionFailureWords(startFailed, { agentName: 'Claude', surface: 'row' }) + } +} + +describe("the phone's line under a not-sent message", () => { + it("words each rejected message as the desktop does, naming the chat's agent", () => { + const notices = mobileNativeChatUnsentNotices( + { + items: [], + submissions: [ + submission('lost', { rejection: startFailed }), + submission('sent', { dispatchState: 'accepted' }) + ] + }, + 'claude' + ) + expect(Object.fromEntries(notices)).toEqual({ + [agentJournalSubmissionKey('lost')]: "Claude couldn't start. Start a new chat to continue." + }) + }) + + it('says only that it was not sent when a loaded row already says why', () => { + const notices = mobileNativeChatUnsentNotices( + { items: [startRow], submissions: [submission('lost', { rejection: startFailed })] }, + 'claude' + ) + expect(notices.get(agentJournalSubmissionKey('lost'))).toBe('Your message was not sent.') + }) + + it('gives one shared empty map when nothing was rejected', () => { + const accepted = { items: [], submissions: [submission('sent', { dispatchState: 'accepted' })] } + expect(mobileNativeChatUnsentNotices(accepted, 'claude').size).toBe(0) + expect(mobileNativeChatUnsentNotices(accepted, 'claude')).toBe( + mobileNativeChatUnsentNotices(null, 'claude') + ) + }) +}) 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..245065663c15 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-unsent-notices.ts @@ -0,0 +1,34 @@ +// The line under each message the host recorded and then rejected, in the desktop's words. + +import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key' +import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice' +import { agentSessionVisibleFailureFacts } from '../../../src/shared/agent-session-visible-failures' +import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership' +import { structuredAgentSessionRecordedRejectionParts } from '../../../src/shared/structured-agent-session-rejection-words' +import { tuiAgentDisplayName } from '../../../src/shared/tui-agent-display-names' + +const NO_NOTICES: ReadonlyMap = new Map() + +/** By the row id the transcript draws each message under; shared and empty when none was rejected, + * so a streaming turn pays nothing. */ +export function mobileNativeChatUnsentNotices( + journal: NativeChatTurnJournal | null, + agent: string | null | undefined +): ReadonlyMap { + const rejected = (journal?.submissions ?? []).filter( + (submission) => submission.dispatchState === 'rejected' + ) + if (!journal || rejected.length === 0) { + return NO_NOTICES + } + const startFailures = agentSessionVisibleFailureFacts(journal.items) + const agentName = agent ? (tuiAgentDisplayName(agent) ?? agent) : undefined + return new Map( + rejected.map((submission) => [ + agentJournalSubmissionKey(submission.clientMessageId), + agentSessionWriteNoticeEnglish( + structuredAgentSessionRecordedRejectionParts(submission, { agentName }, startFailures) + ) + ]) + ) +} From 5d32ec60a9fa0b61bed718941be160844f9f2a47 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:19:49 -0700 Subject: [PATCH 5/8] fix(mobile): a send the host recorded is never handed back When the send's own reply said the host recorded the message and then rejected it, the phone put the text back in the composer with a banner, though the host and the desktop keep it in the chat. A recorded send now settles like any other: its row draws it in place as not sent (or its kept card holds it), and the composer stays empty. The banner's failure-fact plumbing, fed only by that branch, goes with it; the transcript states the sign-in guidance once. --- .../mobile-structured-agent-session-send.ts | 6 +- .../mobile-structured-send-delivery.test.ts | 45 +++------ .../mobile-structured-send-delivery.ts | 25 +---- .../use-mobile-native-chat-send-error.test.ts | 95 +------------------ .../use-mobile-native-chat-send-error.ts | 33 +------ ...se-mobile-session-native-chat-dictation.ts | 21 +--- ...ile-structured-agent-session-send.test.tsx | 47 ++++----- 7 files changed, 48 insertions(+), 224 deletions(-) diff --git a/mobile/src/session/mobile-structured-agent-session-send.ts b/mobile/src/session/mobile-structured-agent-session-send.ts index fcba60c16485..5363dce73aee 100644 --- a/mobile/src/session/mobile-structured-agent-session-send.ts +++ b/mobile/src/session/mobile-structured-agent-session-send.ts @@ -43,11 +43,7 @@ export async function sendMobileStructuredAgentSessionMessage(input: { }) const outcome = mobileStructuredSendDelivery(result) if (outcome.error !== null) { - if (outcome.failure) { - input.onError(outcome.error, { failure: outcome.failure }) - } else { - input.onError(outcome.error) - } + input.onError(outcome.error) } return outcome.outcome } diff --git a/mobile/src/session/mobile-structured-send-delivery.test.ts b/mobile/src/session/mobile-structured-send-delivery.test.ts index 57b18015f571..3da64b8d9699 100644 --- a/mobile/src/session/mobile-structured-send-delivery.test.ts +++ b/mobile/src/session/mobile-structured-send-delivery.test.ts @@ -14,26 +14,22 @@ function accepted( } describe('mobileStructuredSendDelivery', () => { - it('keeps the typed auth fact beside guidance until the transcript can explain it', () => { - const result = structuredSendResultFixture('rejected', 'Sign in to Grok with `grok login`.') - if (!('submission' in result)) { - throw new Error('expected submission') - } - const fact = { - kind: 'notSignedIn' as const, - detail: { text: 'Key expired.', audience: 'person' as const } + // The host's row draws it in place, as not sent, with the reason under it; the composer stays empty. + it.each([ + ['a sign-in rejection', 'Sign in to Grok with `grok login`.', { kind: 'notSignedIn' as const }], + ['a content rejection', 'Claude does not support .bmp', undefined], + ['a write failure', 'provider_write_failed: broken pipe', undefined] + ])('leaves %s to its recorded row', (_name, reason, rejection) => { + const value = structuredSendResultFixture('rejected', reason) + if (rejection && 'submission' in value) { + value.submission.rejection = rejection } - result.submission.rejection = fact - expect(mobileStructuredSendDelivery({ status: 'accepted', value: result })).toEqual({ - outcome: 'rejected', - error: 'Sign in to Grok with `grok login`.', - failure: fact + expect(mobileStructuredSendDelivery({ status: 'accepted', value })).toEqual({ + outcome: 'accepted', + error: null }) - result.submission.rejection = { kind: 'providerRejected' } - expect(mobileStructuredSendDelivery({ status: 'accepted', value: result }).error).toContain( - 'Sign in' - ) }) + it('reports transport and host uncertainty on this send', () => { expect(mobileStructuredSendDelivery({ status: 'unknown' })).toEqual({ outcome: 'unknown', @@ -106,15 +102,6 @@ describe('mobileStructuredSendDelivery', () => { }) }) - it('withholds internal provider write reasons', () => { - expect( - mobileStructuredSendDelivery(accepted('rejected', 'provider_write_failed: broken pipe')) - ).toEqual({ - outcome: 'rejected', - error: "Orca couldn't reach the agent. Your message was not sent. Send it again." - }) - }) - it('lets a kept queued card hold the text after a provider rejection', () => { const value = structuredSendResultFixture('rejected', 'Orca restarted before this was sent.') if (!('submission' in value)) { @@ -141,12 +128,6 @@ describe('mobileStructuredSendDelivery', () => { }) }) - it('shows an actionable content rejection', () => { - expect( - mobileStructuredSendDelivery(accepted('rejected', 'Claude does not support .bmp')) - ).toEqual({ outcome: 'rejected', error: 'Claude does not support .bmp' }) - }) - it.each([ ['agent_session_operation_invalid', 'Invalid operation'], ['agent_session_checkpoint_stale', 'Fence moved'], diff --git a/mobile/src/session/mobile-structured-send-delivery.ts b/mobile/src/session/mobile-structured-send-delivery.ts index c4fd5e450f38..1dcaebf893b8 100644 --- a/mobile/src/session/mobile-structured-send-delivery.ts +++ b/mobile/src/session/mobile-structured-send-delivery.ts @@ -1,18 +1,11 @@ import type { AgentSessionSendResult } from '../../../src/shared/agent-session-wire' import { agentSessionRefusalOperationState } from '../../../src/shared/agent-session-refusal-retry' -import { structuredAgentSessionRejectionNotice } from '../../../src/shared/structured-agent-session-rejection-words' -import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection' -import { - readWholeAgentSessionFailureFact, - type AgentSessionFailureFact -} from '../../../src/shared/agent-session-failure' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import type { StructuredAgentSessionMutationCallResult } from './mobile-structured-agent-session-rpc' export type MobileStructuredSendDelivery = { outcome: MobileNativeChatSendOutcome error: string | null - failure?: AgentSessionFailureFact } export function mobileStructuredSendDelivery( @@ -41,20 +34,10 @@ export function mobileStructuredSendDelivery( if (submission.queuedMessageId === result.value.clientMessageId) { return { outcome: 'unknown', error: null } } - if (submission.dispatchState === 'rejected') { - if (submission.keptAsQueuedMessageId !== undefined) { - return { outcome: 'queued', error: null } - } - if (submission.queuedMessageId === undefined && dispatchWasWithdrawn(submission)) { - // The host transcript already owns this send's stopped row. - return { outcome: 'accepted', error: null } - } - const failure = readWholeAgentSessionFailureFact(submission.rejection) - return { - outcome: 'rejected', - error: structuredAgentSessionRejectionNotice(submission.reason, 'composer-send'), - ...(failure?.kind === 'notSignedIn' ? { failure } : {}) - } + // Recorded, so the host's row holds it, as on desktop: a rejection draws in place, never back in + // the composer. One kept as a card is that card. + if (submission.dispatchState === 'rejected' && submission.keptAsQueuedMessageId !== undefined) { + return { outcome: 'queued', error: null } } return { outcome: 'accepted', error: null } } diff --git a/mobile/src/session/use-mobile-native-chat-send-error.test.ts b/mobile/src/session/use-mobile-native-chat-send-error.test.ts index 5faafea45698..df4d25280d2f 100644 --- a/mobile/src/session/use-mobile-native-chat-send-error.test.ts +++ b/mobile/src/session/use-mobile-native-chat-send-error.test.ts @@ -3,11 +3,8 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useMobileNativeChatSendError, - mobileNativeChatSendErrorMessage, type MobileNativeChatCommandRefusalCauses } from './use-mobile-native-chat-send-error' -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import * as MobileNativeChatRenderData from './mobile-native-chat-render-data' type HookApi = ReturnType @@ -116,94 +113,6 @@ describe('useMobileNativeChatSendError', () => { expect(api().message).toBeNull() }) - it('holds the fact with its message, and clears it on another failure or expiry', async () => { - const fact = { kind: 'notSignedIn', account: 'managed' } as const - await render() - await act(async () => api().show('Sign in', { failure: fact })) - expect(api().failure).toEqual(fact) - await act(async () => api().show('Stop failed')) - expect(api().failure).toBeUndefined() - await act(async () => api().show('Sign in', { failure: fact })) - await act(async () => { - vi.advanceTimersByTime(4000) - }) - expect(api().failure).toBeUndefined() - expect(api().message).toBeNull() - }) - - it('dedupes only the same failure and leaves unrelated banners free of transcript scans', () => { - const failure = { - kind: 'notSignedIn', - account: 'managed', - detail: { text: 'Key expired.', audience: 'person' } - } as const - const messages: NativeChatMessage[] = [ - { - id: 'auth', - role: 'system', - timestamp: 1, - source: 'transcript', - blocks: [{ type: 'text', text: 'Host guidance', failure }] - } - ] - const scan = vi.spyOn(MobileNativeChatRenderData, 'foldMobileNativeChatMessages') - expect(mobileNativeChatSendErrorMessage({ message: 'Stop failed' }, messages)).toBe( - 'Stop failed' - ) - expect(scan).not.toHaveBeenCalled() - expect(mobileNativeChatSendErrorMessage({ message: 'Sign in', failure }, messages)).toBe( - 'Your message was not sent.' - ) - expect( - mobileNativeChatSendErrorMessage( - { message: 'Sign in', failure: { ...failure, account: 'system' } }, - messages - ) - ).toBe('Sign in') - expect( - mobileNativeChatSendErrorMessage( - { - message: 'Sign in', - failure: { ...failure, detail: { ...failure.detail, text: 'Other key.' } } - }, - messages - ) - ).toBe('Sign in') - scan.mockRestore() - }) - - it('keeps sign-in guidance when only a hidden child states the failure', () => { - const failure = { kind: 'notSignedIn' } as const - const child: NativeChatMessage = { - id: 'child-auth', - agentId: 'codex-child', - role: 'system', - timestamp: 1, - source: 'transcript', - blocks: [{ type: 'text', text: 'Sign in to Codex', failure }] - } - expect( - mobileNativeChatSendErrorMessage({ message: 'Sign in to Codex', failure }, [child]) - ).toBe('Sign in to Codex') - }) - - it('shortens guidance only while a matching row is visible', () => { - const failure = { kind: 'notSignedIn' } as const - const visible: NativeChatMessage = { - id: 'parent-auth', - role: 'system', - timestamp: 1, - source: 'transcript', - blocks: [{ type: 'text', text: 'Sign in to Codex', failure }] - } - const child = { ...visible, id: 'child-auth', agentId: 'codex-child' } - const error = { message: 'Sign in to Codex', failure } - expect(mobileNativeChatSendErrorMessage(error, [child, visible])).toBe( - 'Your message was not sent.' - ) - expect(mobileNativeChatSendErrorMessage(error, [child])).toBe(error.message) - }) - it('restarts the hold when a second failure lands mid-hold', async () => { await render() await act(async () => api().show('a')) @@ -240,19 +149,17 @@ describe('useMobileNativeChatSendError', () => { it('drops a held failure when the scope changes', async () => { await render('terminal-1') - await act(async () => api().show('a', { failure: { kind: 'notSignedIn' } })) + await act(async () => api().show('a')) expect(api().message).toBe('a') await act(async () => { renderer?.update(createElement(Harness, { scopeKey: 'terminal-2' })) }) expect(api().message).toBeNull() - expect(api().failure).toBeUndefined() await act(async () => { renderer?.update(createElement(Harness, { scopeKey: 'terminal-1' })) }) expect(api().message).toBeNull() - expect(api().failure).toBeUndefined() }) it('falls back to the toast when the banner is not mounted', async () => { diff --git a/mobile/src/session/use-mobile-native-chat-send-error.ts b/mobile/src/session/use-mobile-native-chat-send-error.ts index 02df95dc16ff..bb3ea4aa912a 100644 --- a/mobile/src/session/use-mobile-native-chat-send-error.ts +++ b/mobile/src/session/use-mobile-native-chat-send-error.ts @@ -1,20 +1,10 @@ import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react' -import { - readWholeAgentSessionFailureFact, - type AgentSessionFailureFact -} from '../../../src/shared/agent-session-failure' -import { sameAgentSessionFailureFact } from '../../../src/shared/agent-session-visible-failures' -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { structuredAgentSessionRejectionNotice } from '../../../src/shared/structured-agent-session-rejection-words' -import { foldMobileNativeChatMessages } from './mobile-native-chat-render-data' import type { StructuredAgentSessionCommandRefusalCause } from '../../../src/shared/structured-agent-session-composer' /** What a send error says beyond its text. `refusedWhile`: a refused command's cause, whose line is - * said only while the chat shows it. `failure`: a rejected send's fact, whose guidance steps aside - * once the transcript states the same failure. */ + * said only while the chat shows it. */ export type MobileNativeChatSendErrorDetails = { refusedWhile?: StructuredAgentSessionCommandRefusalCause - failure?: AgentSessionFailureFact } export type MobileNativeChatSendErrorReporter = ( @@ -30,25 +20,6 @@ export type MobileNativeChatCommandRefusalCauses = Readonly< const NATIVE_CHAT_SEND_ERROR_HOLD_MS = 4000 const NATIVE_CHAT_SEND_ERROR_TOAST_MS = 1600 -/** A held rejection keeps its guidance until the current transcript states the same failure. */ -export function mobileNativeChatSendErrorMessage( - error: { message: string | null; failure?: AgentSessionFailureFact }, - messages: readonly NativeChatMessage[] -): string | null { - const { failure, message } = error - if (!message || !failure) { - return message - } - const stated = foldMobileNativeChatMessages(messages).some((row) => - row.blocks.some((block) => { - const fact = - block.type === 'text' ? readWholeAgentSessionFailureFact(block.failure) : undefined - return fact !== undefined && sameAgentSessionFailureFact(failure, fact) - }) - ) - return stated ? structuredAgentSessionRejectionNotice(null, 'composer-send') : message -} - /** Holds the newest native-chat send failure for the composer's inline banner. * Why a banner and not the bottom toast: chat failures happen with the keyboard * up, which covers the toast — the surface the user is looking at is the composer. @@ -59,7 +30,6 @@ export function useMobileNativeChatSendError(args: { showToast: (message: string, durationMs?: number) => void }): { message: string | null - failure?: AgentSessionFailureFact show: MobileNativeChatSendErrorReporter clear: () => void /** Called each render with what the chat shows: a refusal whose cause has ended is dropped. */ @@ -139,7 +109,6 @@ export function useMobileNativeChatSendError(args: { } return { message: visibleError?.message ?? null, - ...(visibleError?.failure ? { failure: visibleError.failure } : {}), show, clear, keepWhile, diff --git a/mobile/src/session/use-mobile-session-native-chat-dictation.ts b/mobile/src/session/use-mobile-session-native-chat-dictation.ts index 024ab78a86ab..075ee17c19ef 100644 --- a/mobile/src/session/use-mobile-session-native-chat-dictation.ts +++ b/mobile/src/session/use-mobile-session-native-chat-dictation.ts @@ -1,4 +1,4 @@ -import { useEffect, useCallback, useMemo } from 'react' +import { useEffect, useCallback } from 'react' import { useFocusEffect } from 'expo-router' import { useMobileDictation } from '../hooks/use-mobile-dictation' import { triggerError } from '../platform/haptics' @@ -13,10 +13,7 @@ import { import { useMobileNativeChatController } from './use-mobile-native-chat-controller' import { useMobileNativeChatReadability } from './use-mobile-native-chat-readability' import { useMobileNativeChatInputLease } from './use-mobile-native-chat-input-lease' -import { - useMobileNativeChatSendError, - mobileNativeChatSendErrorMessage -} from './use-mobile-native-chat-send-error' +import { useMobileNativeChatSendError } from './use-mobile-native-chat-send-error' import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' import { useMobileSendCompletionGeneration } from './use-mobile-send-completion-generation' import type { MobileSessionFeedbackCapabilitiesModel } from './use-mobile-session-feedback-capabilities' @@ -81,18 +78,6 @@ export function useMobileSessionNativeChatDictation( onSendResolved: nativeChatSendError.clear }) const { toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController - const sendErrorMessage = useMemo( - () => - mobileNativeChatSendErrorMessage( - { message: nativeChatSendError.message, failure: nativeChatSendError.failure }, - nativeChatController.nativeChatSession.messages - ), - [ - nativeChatSendError.message, - nativeChatSendError.failure, - nativeChatController.nativeChatSession.messages - ] - ) nativeChatSendError.bannerMountedRef.current = showNativeChat nativeChatSendError.keepWhile(nativeChatController.nativeChatCommandRefusalCauses) const nativeChatOverlayInputLockReason = @@ -243,7 +228,7 @@ export function useMobileSessionNativeChatDictation( }, [diffComments]) return { nativeChatScopeKey, - nativeChatSendError: { ...nativeChatSendError, message: sendErrorMessage }, + nativeChatSendError, nativeChatTranscriptIsLocalReadable, nativeChatInputLeaseReady, nativeChatInputLeaseReadyRef, 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 5b53fd9bcedb..f5e746fca399 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 @@ -10,9 +10,9 @@ import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { structuredSendResultFixture } from './structured-agent-send-result.test-fixture' import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' +import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key' import { useMobileNativeChatSendError, - mobileNativeChatSendErrorMessage, type MobileNativeChatSendErrorDetails } from './use-mobile-native-chat-send-error' @@ -97,11 +97,7 @@ describe('mobile structured send actions', () => { hostSupport: null, onSendError }) - return createElement( - 'span', - null, - mobileNativeChatSendErrorMessage(banner, hook.session.messages) - ) + return createElement('span', null, banner.message) } async function mountSession(): Promise { @@ -212,15 +208,19 @@ describe('mobile structured send actions', () => { expect(calls()).toHaveLength(1) }) + // The recorded row is the message: in place, as not sent, with the sign-in row saying why once. it.each(['reply-before-row', 'row-before-commit'] as const)( - 'states delivery once and leaves auth guidance in the transcript for %s', + 'leaves a recorded rejection to its row, with no banner and nothing handed back, for %s', async (order) => { - const fact = { - kind: 'notSignedIn', - detail: { text: 'The provider key expired.', audience: 'person' } - } as const + const fact = { kind: 'notSignedIn' } as const const words = agentSessionFailureWords(fact, { agentName: 'Codex', surface: 'row' }) + const value = structuredSendResultFixture('rejected', words.text) + if (!('submission' in value)) { + throw new Error('expected submission') + } + value.submission.rejection = fact const event = snapshotEvent() + const sentRow = agentJournalSubmissionKey(value.submission.clientMessageId) event.page.items = [ { itemId: 'auth-row', @@ -228,13 +228,16 @@ describe('mobile structured send actions', () => { revision: 1, observedAt: 1, body: { kind: 'status', tone: 'error', ...words } + }, + { + itemId: sentRow, + sequence: 2, + revision: 1, + observedAt: 2, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'my message' }] } } ] - const value = structuredSendResultFixture('rejected', words.text) - if (!('submission' in value)) { - throw new Error('expected submission') - } - value.submission.rejection = fact + event.page.submissions = [value.submission] sendRequest.mockImplementation(async (method) => { if (method !== 'agentSession.send') { return ok({ models: [], current: {} }) @@ -252,16 +255,16 @@ describe('mobile structured send actions', () => { }) await mountSession() await act(async () => { - expect(await hook!.sendWithOutcome('my message')).toBe('rejected') + expect(await hook!.sendWithOutcome('my message')).toBe('accepted') }) - expect(onSendError).toHaveBeenCalledExactlyOnceWith(words.text, { failure: fact }) if (order === 'reply-before-row') { - expect(renderer!.root.findByType('span').children.join('')).toBe(words.text) act(() => listener?.(event)) } - expect(renderer!.root.findByType('span').children.join('')).toBe('Your message was not sent.') - expect(JSON.stringify(hook!.session.messages)).toContain(words.text) - expect(onSendError).toHaveBeenCalledTimes(1) + expect(onSendError).not.toHaveBeenCalled() + expect(renderer!.root.findByType('span').children).toEqual([]) + expect(hook!.session.messages.find((message) => message.id === sentRow)).toMatchObject({ + unsent: true + }) } ) From 9fc6779f08083e1ca81783ab2ba67c857efb2b24 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:27:42 -0700 Subject: [PATCH 6/8] fix(mobile): keep each structured send on the shared optimistic list Once a not-sent row stays in the chat, matching a pending bubble by its text breaks: resending the same words after a not-sent message never reached the count the bubble waited for, so a stray copy stayed, and an acknowledgement lost on that resend showed a false "Delivery unconfirmed". A structured send is now one record, kept by the structured session under the id it is sent with. Three parts match the desktop's sender: the id, the drawing (the shared projection draws it at the tail until the host's row under that id lands, in the same pass) and the settle rule (the host's own record of it, a submission, a card, or a card's hand-off, drops it by the one rule the desktop now shares). The rest stays the phone's own: the bubble shows once the host accepts the send, not from the tap; a send whose answer was lost is held undrawn and says "Delivery unconfirmed" only if its deadline passes first, instead of going back to the composer; records are kept whether or not a chat view is open; and one send in flight is the composer's rule, not the sender's. Its photo is kept under the row id it shares with the host's row, so it shows on the bubble, the landed row and a not-sent row. The drafts' text matching, with its older queued-card matching, now serves only terminal-backed chats, whose transcripts carry no ids. --- .../mobile-native-chat-draft-reconcile.ts | 41 +- .../mobile-native-chat-pending-echo.ts | 3 - ...obile-structured-agent-session-contract.ts | 2 + .../mobile-structured-agent-session-send.ts | 5 +- .../mobile-structured-send-new-action.test.ts | 9 +- .../use-mobile-native-chat-controller.test.ts | 6 +- .../use-mobile-native-chat-controller.ts | 9 +- ...le-native-chat-drafts-launch-draft.test.ts | 23 ++ ...ile-native-chat-drafts-queued-hold.test.ts | 77 +--- .../session/use-mobile-native-chat-drafts.ts | 32 +- .../use-mobile-native-chat-message-send.ts | 5 +- ...chat-turn-disclosure-opening-send.test.tsx | 108 ++++-- .../use-mobile-native-chat-turn-status.ts | 4 +- ...se-mobile-native-chat-unconfirmed-sends.ts | 50 +-- ...ile-structured-agent-session-send.test.tsx | 5 +- .../use-mobile-structured-agent-session.ts | 40 +- ...structured-native-chat-send-bridge.test.ts | 56 +-- ...bile-structured-native-chat-send-bridge.ts | 46 +-- ...use-mobile-structured-send-with-outcome.ts | 32 +- .../use-mobile-structured-sends.test.tsx | 357 ++++++++++++++++++ .../session/use-mobile-structured-sends.ts | 143 +++++++ ...structured-agent-session-message-sender.ts | 16 +- ...ured-agent-session-send-settlement.test.ts | 29 ++ ...tructured-agent-session-send-settlement.ts | 20 + 24 files changed, 806 insertions(+), 312 deletions(-) create mode 100644 mobile/src/session/use-mobile-structured-sends.test.tsx create mode 100644 mobile/src/session/use-mobile-structured-sends.ts create mode 100644 src/shared/structured-agent-session-send-settlement.test.ts create mode 100644 src/shared/structured-agent-session-send-settlement.ts diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts index 3ae08af66881..04d53f61f7c6 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts @@ -16,8 +16,6 @@ export type UnconfirmedSend = { text: string normalizedText: string baselineTailMessageId: string | null - /** Queued-draft cards on screen at send time; see `findQueuedUnconfirmedSends`. */ - baselineQueuedMessageIds?: readonly string[] deadline: ReturnType | null } @@ -65,22 +63,24 @@ export type PendingImagePreviewEcho = { baselineTailMessageId: string | null } -export type LandedImagePreviewEcho = { - pendingId: string +/** This phone's photos for the row they were sent in. */ +export type SentImagePreview = { messageId: string images: string[] } +export type LandedImagePreviewEcho = SentImagePreview & { pendingId: string } + const SENT_IMAGE_PREVIEW_LIMIT = 32 const SENT_IMAGE_PREVIEW_SESSION_LIMIT = 8 -export function mergeLandedImagePreviewEchoes( +export function mergeSentImagePreviews( previous: Record>, sessionKey: string, - landed: readonly LandedImagePreviewEcho[] + previews: readonly SentImagePreview[] ): Record> { const entries = Object.entries(previous[sessionKey] ?? {}) - for (const preview of landed) { + for (const preview of previews) { const existingIndex = entries.findIndex(([messageId]) => messageId === preview.messageId) if (existingIndex !== -1) { entries.splice(existingIndex, 1) @@ -221,33 +221,6 @@ export function findLandedImagePreviewEchoes( return landed } -/** Holds the host already took as queued-draft cards: a card not on screen at - * send time with the send's text. It will reach the transcript only when it - * drains, which can be long past the unconfirmed deadline. */ -export function findQueuedUnconfirmedSends( - cards: readonly { messageId: string; text: string }[], - entries: readonly UnconfirmedSend[] -): UnconfirmedSend[] { - if (cards.length === 0) { - return [] - } - const claimed = new Set() - const held: UnconfirmedSend[] = [] - for (const entry of entries) { - const card = cards.find( - (candidate) => - !claimed.has(candidate.messageId) && - !(entry.baselineQueuedMessageIds ?? []).includes(candidate.messageId) && - normalizeNativeChatUserText(candidate.text) === entry.normalizedText - ) - if (card) { - claimed.add(card.messageId) - held.push(entry) - } - } - return held -} - export function findLandedUnconfirmedSends( messages: readonly NativeChatMessage[], entries: readonly UnconfirmedSend[] diff --git a/mobile/src/session/mobile-native-chat-pending-echo.ts b/mobile/src/session/mobile-native-chat-pending-echo.ts index 01b8c7d4bc23..91bf778ba7c3 100644 --- a/mobile/src/session/mobile-native-chat-pending-echo.ts +++ b/mobile/src/session/mobile-native-chat-pending-echo.ts @@ -22,9 +22,6 @@ export type MobileNativeChatSendOrigin = { baselineOccurrences: number baselineTailMessageId: string | null baselineResolved: boolean - /** Queued-draft cards already on screen at send time, so an earlier identical - * card cannot confirm this send. Structured lane, on any host that publishes cards. */ - baselineQueuedMessageIds?: readonly string[] } type PendingByKey = Record diff --git a/mobile/src/session/mobile-structured-agent-session-contract.ts b/mobile/src/session/mobile-structured-agent-session-contract.ts index 5c6ccec5ab12..7880255b77a1 100644 --- a/mobile/src/session/mobile-structured-agent-session-contract.ts +++ b/mobile/src/session/mobile-structured-agent-session-contract.ts @@ -37,4 +37,6 @@ export type StructuredMobileSession = ReturnType } diff --git a/mobile/src/session/mobile-structured-agent-session-send.ts b/mobile/src/session/mobile-structured-agent-session-send.ts index 5363dce73aee..e2e1ebccbe03 100644 --- a/mobile/src/session/mobile-structured-agent-session-send.ts +++ b/mobile/src/session/mobile-structured-agent-session-send.ts @@ -21,6 +21,8 @@ export async function sendMobileStructuredAgentSessionMessage(input: { /** Sent only when the host advertises `agent-session.queued-messages.v1`. */ delivery?: 'queue-if-active' deadline?: number + /** The id the host records the message under. */ + clientMessageId: string onError: MobileNativeChatSendErrorReporter }): Promise { const timeoutMs = timeoutForDeadline(input.deadline) @@ -28,7 +30,7 @@ export async function sendMobileStructuredAgentSessionMessage(input: { input.onError('Message not sent') return 'rejected' } - // Each Send is a new action; the shared mutation sender mints its operation id once. + // Each Send is a new action, under one operation id. const result = await requestStructuredAgentSessionMutation({ client: input.client, method: 'agentSession.send', @@ -39,6 +41,7 @@ export async function sendMobileStructuredAgentSessionMessage(input: { body: structuredAgentSessionSendBody(input.text, input.attachments), ...(input.delivery ? { delivery: input.delivery } : {}) }, + clientOperationId: input.clientMessageId, timeoutMs }) const outcome = mobileStructuredSendDelivery(result) diff --git a/mobile/src/session/mobile-structured-send-new-action.test.ts b/mobile/src/session/mobile-structured-send-new-action.test.ts index 71827af8640c..6b5efd544058 100644 --- a/mobile/src/session/mobile-structured-send-new-action.test.ts +++ b/mobile/src/session/mobile-structured-send-new-action.test.ts @@ -3,6 +3,7 @@ import type { RpcClient } from '../transport/rpc-client' import { fieldsOf } from './use-mobile-structured-agent-session-queued.test-fixture' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-agent-session-send' +import { structuredSessionOperationId } from './structured-session-operation-id' const storage = vi.hoisted(() => ({ getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() })) vi.mock('@react-native-async-storage/async-storage', () => ({ default: storage })) @@ -80,7 +81,13 @@ describe('a new phone send after an acknowledgement was lost', () => { attachments: [], onError } - const send = () => sendMobileStructuredAgentSessionMessage({ ...message, client }) + // Each Send makes its own id, as the session's send seam does. + const send = () => + sendMobileStructuredAgentSessionMessage({ + ...message, + client, + clientMessageId: structuredSessionOperationId() + }) expect(await send()).toBe('unknown') if (relaunch) { diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index 53ebfeb1d7ca..20ebc232e60d 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -353,7 +353,7 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { expect(clientStub.sendRequest).not.toHaveBeenCalled() }) - it("echoes a command the chat's own agent runs, though Codex's host claims it", async () => { + it("leaves a command the chat's own agent runs to its session, though Codex's host claims it", async () => { await act(async () => { renderer?.update(createElement(Harness, structuredTab('opencode'))) }) @@ -361,7 +361,9 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { await controller!.handleNativeChatSend('/review') }) expect(structuredSendWithOutcome).toHaveBeenCalledWith('/review') - expect(acceptSend).toHaveBeenCalledWith(ORIGIN, '/review', undefined) + // The structured session draws it; the drafts' text-matched echo is the terminal lane's. + expect(acceptSend).not.toHaveBeenCalled() + expect(restoreRejectedDraft).not.toHaveBeenCalled() // Its options report decides attach, not the built-in answer. expect(controller!.nativeChatAcceptsImages).toBe(false) }) diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 968bfb519f4f..c97abb27a515 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -132,8 +132,7 @@ export function useMobileNativeChatController(args: { // terminal view would permanently decline the prefill. chatActive: showNativeChat, transcriptLoading: nativeChatSession.transcriptLoading, - transcriptSettled: nativeChatSession.status === 'ready', - queuedCards: structuredNativeChat.queued.cards + transcriptSettled: nativeChatSession.status === 'ready' }) // Deliberately not gated on the chat view being visible: the streaming gate @@ -230,8 +229,6 @@ export function useMobileNativeChatController(args: { sendStructured: structuredNativeChat.sendWithOutcome, captureSendOrigin, clearDraftForSend, - acceptSend, - holdUnconfirmedSend, restoreRejectedDraft, onSendError }) @@ -291,7 +288,9 @@ export function useMobileNativeChatController(args: { setChatComposerText, getChatComposerEditGeneration, chatPending, - chatImagePreviewsByMessageId, + chatImagePreviewsByMessageId: activeChatStructured + ? structuredNativeChat.imagePreviewsByMessageId + : chatImagePreviewsByMessageId, nativeChatSession, /** Structured lane: drives the per-turn status row and live tool progress. */ nativeChatStructured: activeChatStructured, diff --git a/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts index 4d3a58ce25dd..175ad5cf370b 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts @@ -2,6 +2,7 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' import { resetMobileNativeChatDraftStoreForTests } from './mobile-native-chat-draft-store' @@ -142,6 +143,28 @@ describe('useMobileNativeChatDrafts launch draft', () => { expect(state?.composerText).toBe('') }) + // A structured chat draws this phone's accepted send before its row lands; that is a user turn. + it("declines a launch draft once the phone's own accepted send is drawn", async () => { + await mount('a') + const drawn = projectStructuredAgentSessionMessages( + [], + [ + { + clientMessageId: 'sent', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + queuedAt: 1 + } + ], + [] + ) + await act(async () => + renderer?.update( + createElement(Harness, { tabId: 'a', messages: drawn, launchDraft: 'issue link' }) + ) + ) + expect(state?.composerText).toBe('') + }) + it('clears an untouched prefill once a user turn lands, keeping user edits', async () => { await mount('a') await act(async () => diff --git a/mobile/src/session/use-mobile-native-chat-drafts-queued-hold.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-queued-hold.test.ts index 1eac27e27847..37aad43a2422 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts-queued-hold.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts-queued-hold.test.ts @@ -1,32 +1,23 @@ -// An ack-lost send the host took as a queued draft shows as a card, not in the -// transcript, until the turn ends — so the unconfirmed hold must count the card -// as delivery, or the user is told to "check chat" about a message on screen. +// A queued card's Edit copies its text into the composer through the drafts hook. import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' import { resetMobileNativeChatDraftStoreForTests } from './mobile-native-chat-draft-store' -type QueuedCard = { messageId: string; text: string } - -describe('useMobileNativeChatDrafts unconfirmed hold with queued cards', () => { +describe('useMobileNativeChatDrafts with queued cards', () => { let renderer: ReactTestRenderer | null = null let state: ReturnType | null = null - beforeEach(() => { - vi.useFakeTimers() - }) - afterEach(() => { act(() => renderer?.unmount()) resetMobileNativeChatDraftStoreForTests() renderer = null state = null - vi.useRealTimers() }) - function Harness({ queuedCards }: { queuedCards: QueuedCard[] }): null { + function Harness(): null { state = useMobileNativeChatDrafts({ hostId: 'host', worktreeId: 'worktree', @@ -35,40 +26,19 @@ describe('useMobileNativeChatDrafts unconfirmed hold with queued cards', () => { messages: [], launchDraft: null, transcriptLoading: false, - transcriptSettled: true, - queuedCards + transcriptSettled: true }) return null } - async function render(queuedCards: QueuedCard[]): Promise { + async function render(): Promise { await act(async () => { - if (renderer) { - renderer.update(createElement(Harness, { queuedCards })) - } else { - renderer = create(createElement(Harness, { queuedCards })) - } + renderer = create(createElement(Harness)) }) } - /** Holds a lost send; the returned call runs out its deadline and hands back the warning spy. */ - function holdLostSend(text: string): () => ReturnType { - const onUnconfirmed = vi.fn() - const origin = state?.captureSendOrigin(text) - if (!origin) { - throw new Error('no send origin') - } - act(() => state?.holdUnconfirmedSend(origin, text, onUnconfirmed)) - return () => { - act(() => { - vi.advanceTimersByTime(30_000) - }) - return onUnconfirmed - } - } - it("reports whether a queued card's Edit copy landed, so Edit never deletes text it did not keep", async () => { - await render([]) + await render() let copied: boolean | undefined act(() => { copied = state?.appendComposerText('') @@ -80,35 +50,4 @@ describe('useMobileNativeChatDrafts unconfirmed hold with queued cards', () => { expect(copied).toBe(true) expect(state?.composerText).toBe('edited text') }) - - it('stays quiet when the lost send appears as a queued card', async () => { - await render([]) - const settle = holdLostSend('after this turn') - await render([{ messageId: 'draft-1', text: 'after this turn' }]) - expect(settle()).not.toHaveBeenCalled() - }) - - it('stays quiet when the card was published before the lost answer returned', async () => { - await render([]) - const origin = state?.captureSendOrigin('raced') - await render([{ messageId: 'draft-1', text: 'raced' }]) - const onUnconfirmed = vi.fn() - act(() => { - if (origin) { - state?.holdUnconfirmedSend(origin, 'raced', onUnconfirmed) - } - }) - expect(vi.getTimerCount()).toBe(0) - expect(onUnconfirmed).not.toHaveBeenCalled() - }) - - it('does not confirm against an identical card already on screen at send time', async () => { - await render([{ messageId: 'earlier', text: 'same words' }]) - const settle = holdLostSend('same words') - await render([ - { messageId: 'earlier', text: 'same words' }, - { messageId: 'x', text: 'other' } - ]) - expect(settle()).toHaveBeenCalledTimes(1) - }) }) diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index cfe4f6c8f9c9..2fd2c1a20342 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -1,18 +1,10 @@ -import { - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState, - type Dispatch, - type SetStateAction -} from 'react' +import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { appendReturnedDraftText } from '../../../src/shared/returned-draft-text' import { countUserTextOccurrences, findLandedImagePreviewEchoes, - mergeLandedImagePreviewEchoes, + mergeSentImagePreviews, migrateImagePreviewMessageIds, normalizeReconcileText } from './mobile-native-chat-draft-reconcile' @@ -63,8 +55,6 @@ export function useMobileNativeChatDrafts(args: { * is an empty conversation, not a read that failed or never ran. Only then * does a send's captured tail describe a real boundary. */ transcriptSettled: boolean - /** The active pane's host-held queued-draft cards (structured lane only). */ - queuedCards?: readonly { messageId: string; text: string }[] }): { composerText: string setComposerText: Dispatch> @@ -103,8 +93,7 @@ export function useMobileNativeChatDrafts(args: { launchDraftCreatedAt, chatActive = true, transcriptLoading, - transcriptSettled, - queuedCards + transcriptSettled } = args const draftKey = mobileNativeChatScopeKey(hostId, worktreeId, tabId) const pendingKey = draftKey && sessionId ? `${draftKey}\0${sessionId}` : null @@ -123,11 +112,6 @@ export function useMobileNativeChatDrafts(args: { const getComposerEditGeneration = useCallback(() => composerEditGenerationRef.current, []) const messagesRef = useRef(messages) messagesRef.current = messages - const queuedCardsRef = useRef(queuedCards) - // Read only by captureSendOrigin, which runs from a send after this commit. - useLayoutEffect(() => { - queuedCardsRef.current = queuedCards - }, [queuedCards]) const { readSeededLaunchDraft, readSeededLaunchDraftSeed } = useMobileNativeChatLaunchDraftSeed({ draftKey, @@ -180,10 +164,7 @@ export function useMobileNativeChatDrafts(args: { // 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. - baselineResolved: transcriptSettled, - ...(queuedCardsRef.current?.length - ? { baselineQueuedMessageIds: queuedCardsRef.current.map((card) => card.messageId) } - : {}) + baselineResolved: transcriptSettled } }, [draftKey, pendingKey, transcriptSettled] @@ -227,8 +208,7 @@ export function useMobileNativeChatDrafts(args: { const { holdUnconfirmedSend } = useMobileNativeChatUnconfirmedSends({ draftKey, pendingKey, - messages, - ...(queuedCards ? { queuedCards } : {}) + messages }) const waitingForSession = draftKey @@ -272,7 +252,7 @@ export function useMobileNativeChatDrafts(args: { const landedImagePendingIds = new Set(landedImagePreviews.map((preview) => preview.pendingId)) if (landedImagePreviews.length > 0) { setImagePreviewsBySession((previous) => - mergeLandedImagePreviewEchoes(previous, pendingKey, landedImagePreviews) + mergeSentImagePreviews(previous, pendingKey, landedImagePreviews) ) } setPendingBySession((previous) => { diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts index 2d8615f5dd38..6945fc8fcec4 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -16,6 +16,7 @@ import { releaseMobileNativeChatTerminalWrite } from './mobile-native-chat-terminal-write-lock' import type { MobileNativeChatSendOrigin } from './use-mobile-native-chat-drafts' +import { UNCONFIRMED_SEND_MESSAGE } from './use-mobile-native-chat-unconfirmed-sends' import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' import { AGENT_TUI_CLEAR_INPUT_LINE, @@ -198,9 +199,7 @@ export function useMobileNativeChatMessageSend(args: { if (classification === 'chat') { // Why: an ack-lost send usually WAS delivered (issue seen on cellular // relay) — verify via the transcript echo instead of a false "not sent". - holdUnconfirmedSend(origin, text, () => - onSendError('Delivery unconfirmed — check chat before retrying') - ) + holdUnconfirmedSend(origin, text, () => onSendError(UNCONFIRMED_SEND_MESSAGE)) } return 'unknown' } diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure-opening-send.test.tsx b/mobile/src/session/use-mobile-native-chat-turn-disclosure-opening-send.test.tsx index 49f8146c0849..60019bf2c3a5 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure-opening-send.test.tsx +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure-opening-send.test.tsx @@ -1,7 +1,8 @@ // On the phone, as on the desktop: a message sent while the turn ahead is still opening waits after // that turn's live status, and the live status stays on the turn ahead, never on the waiting one. // The phone keeps no outbox: its rows are the host's recorded ones, plus its own echo of a send the -// host accepted, until that send's row arrives. The list is built as the phone's view builds it. +// host accepted, until that send's row arrives, drawn by the shared projection as the desktop draws +// its own. The list is built as the phone's view builds it. import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' @@ -15,11 +16,14 @@ import type { 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 { nativeChatTurnMembership } from '../../../src/shared/native-chat-turn-membership' +import { + projectStructuredAgentSessionMessages, + type StructuredAgentSessionOptimisticMessage +} from '../../../src/shared/structured-agent-session-message-projection' import { buildMobileNativeChatTransientData, - foldMobileNativeChatMessages, - type MobileNativeChatPendingItem + foldMobileNativeChatMessages } from './mobile-native-chat-render-data' import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure' @@ -81,24 +85,36 @@ const firstTurnOpened = item('turn-first', 6, { type Disclosure = ReturnType +/** The phone's own record of a send the host accepted, as its send seam makes it. */ +function echo( + clientMessageId: string, + sentWhileStopping = false +): StructuredAgentSessionOptimisticMessage { + return { + clientMessageId, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }, + queuedAt: NOW, + ...(sentWhileStopping ? { sentWhileStopping: true as const } : {}) + } +} + function Harness(props: { items: AgentJournalRenderItem[] submissions: AgentJournalSubmission[] stopping: boolean - pending: MobileNativeChatPendingItem[] + optimistic: StructuredAgentSessionOptimisticMessage[] seen: (disclosure: Disclosure) => void }): null { - // As the phone projects: no other rejected send drawn in place. const messages: NativeChatMessage[] = projectStructuredAgentSessionMessages( props.items, - [], + props.optimistic, props.submissions ) const { data } = buildMobileNativeChatTransientData({ messages, folded: foldMobileNativeChatMessages(messages), streaming: null, - pending: props.pending + pending: [] }) const disclosure = useMobileNativeChatTurnDisclosure({ messages: data, @@ -125,7 +141,7 @@ describe('a message sent while the turn ahead is still opening, on the phone', ( items: AgentJournalRenderItem[], submissions: AgentJournalSubmission[], stopping = false, - pending: MobileNativeChatPendingItem[] = [] + optimistic: StructuredAgentSessionOptimisticMessage[] = [] ): { listed: string[]; waiting: string[]; liveOn: string | undefined } { let seen: Disclosure | undefined act(() => { @@ -133,7 +149,7 @@ describe('a message sent while the turn ahead is still opening, on the phone', ( items, submissions, stopping, - pending, + optimistic, seen: (disclosure) => { seen = disclosure } @@ -175,17 +191,14 @@ describe('a message sent while the turn ahead is still opening, on the phone', ( // Its echo of a send the host accepted, before that send's row reaches the phone. it('keeps its own echo of an accepted send waiting after the live status, Stopping or not', () => { - const echo = { - id: 'pending-1', - text: 'second', - baselineTailMessageId: agentJournalSubmissionKey('first') - } for (const stopping of [false, true]) { - expect(frame(openingItems, openingSubmissions, stopping, [echo])).toEqual({ - listed: [agentJournalSubmissionKey('first')], - waiting: ['pending-1'], - liveOn: agentJournalSubmissionKey('first') - }) + expect(frame(openingItems, openingSubmissions, stopping, [echo('second', stopping)])).toEqual( + { + listed: [agentJournalSubmissionKey('first')], + waiting: [agentJournalSubmissionKey('second')], + liveOn: agentJournalSubmissionKey('first') + } + ) } // While Stopping, sent after a queued B the Stop holds. expect( @@ -193,9 +206,9 @@ describe('a message sent while the turn ahead is still opening, on the phone', ( [...openingItems, userMessage('b', 4)], [...openingSubmissions, submission('b', { acceptedSequence: 4 })], true, - [{ ...echo, baselineTailMessageId: agentJournalSubmissionKey('b') }] + [echo('second', true)] ).waiting - ).toEqual([agentJournalSubmissionKey('b'), 'pending-1']) + ).toEqual([agentJournalSubmissionKey('b'), agentJournalSubmissionKey('second')]) }) // Taken back, B stays where it was sent with its stop row: listed after the live turn, no longer @@ -233,6 +246,57 @@ describe('a message sent while the turn ahead is still opening, on the phone', ( providerItemId: FIRST_TURN_PROVIDER_KEY, resolvedAt: NOW + 20 }) + // Once the turn ahead runs it takes a send within moments, so the echo stays put; one made while + // Stopping waits for the turn being stopped, as on the desktop. + it('draws its echo after a running turn, and waits behind it only when sent while Stopping', () => { + const [first] = openingSubmissions + if (!first) { + throw new Error('expected the first send') + } + const running = [...openingItems, firstTurnOpened] + const after = { + listed: [agentJournalSubmissionKey('first'), agentJournalSubmissionKey('second')], + waiting: [], + liveOn: agentJournalSubmissionKey('first') + } + expect(frame(running, [echoed(first)], false, [echo('second')])).toEqual(after) + // Sent before the Stop: it stays where it is. + expect(frame(running, [echoed(first)], true, [echo('second')])).toEqual(after) + expect(frame(running, [echoed(first)], true, [echo('second', true)])).toEqual({ + listed: [agentJournalSubmissionKey('first')], + waiting: [agentJournalSubmissionKey('second')], + liveOn: agentJournalSubmissionKey('first') + }) + }) + + it('opens a turn of its own with its echo once the turn ahead has ended', () => { + const [first] = openingSubmissions + if (!first) { + throw new Error('expected the first send') + } + const ended = [ + ...openingItems, + item('turn-first', 6, { + kind: 'turn', + turnId: 'turn-first', + state: 'completed', + userItemId: FIRST_TURN_PROVIDER_KEY, + startedAt: NOW + }) + ] + const messages = projectStructuredAgentSessionMessages(ended, [echo('second')], [echoed(first)]) + const { turnKeys } = nativeChatTurnMembership(messages, { + items: ended, + submissions: [echoed(first)] + }) + expect(messages.map((message) => message.id)).toEqual([ + agentJournalSubmissionKey('first'), + agentJournalSubmissionKey('second') + ]) + expect(turnKeys[1]).toBe(agentJournalSubmissionKey('second')) + expect(turnKeys[0]).not.toBe(turnKeys[1]) + }) + // Handed over as a steer once the turn opened (row 8). const steered = [ ...openingItems, diff --git a/mobile/src/session/use-mobile-native-chat-turn-status.ts b/mobile/src/session/use-mobile-native-chat-turn-status.ts index f7d440dd7383..857c27bf4499 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-status.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-status.ts @@ -58,8 +58,8 @@ export function useMobileNativeChatTurnStatus({ enabled && scopedTiming.scopeKey === scopeKey ? scopedTiming.timingByTurn : EMPTY_TURN_TIMING_BY_TURN - // An accepted send renders as `pending-N` until the transcript echo lands under - // its real id. That is one turn under two keys, so the clock must survive the swap. + // A running turn can move to a new key (unanchored until its row is drawn), so the + // clock must survive the swap. const previousActiveTurn = useRef<{ scopeKey: string; turnKey: string } | null>(null) useEffect(() => { 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..989e36b1a197 100644 --- a/mobile/src/session/use-mobile-native-chat-unconfirmed-sends.ts +++ b/mobile/src/session/use-mobile-native-chat-unconfirmed-sends.ts @@ -1,46 +1,25 @@ // Ack-lost sends: a relay drop mid-send usually loses only the acknowledgement — // the desktop already delivered the message. Hold the send instead of claiming -// failure (which baits a duplicate): stay quiet when the transcript echo lands -// or the host shows it as a queued-draft card, and surface the uncertainty only -// if the deadline passes without either. The composer was already cleared at -// send time, so this never touches drafts. +// failure (which baits a duplicate): stay quiet when the transcript echo lands, +// and surface the uncertainty only if the deadline passes without it. The +// composer was already cleared at send time, so this never touches drafts. +// A structured chat's sends are held by `useMobileStructuredSends` instead. import { useCallback, useEffect, useLayoutEffect, useRef } from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { findLandedUnconfirmedSends, - findQueuedUnconfirmedSends, type UnconfirmedSend } from './mobile-native-chat-draft-reconcile' import type { MobileNativeChatSendOrigin } from './mobile-native-chat-pending-echo' -const UNCONFIRMED_SEND_DEADLINE_MS = 20_000 -const NO_QUEUED_CARDS: readonly QueuedCardText[] = [] - -type QueuedCardText = { messageId: string; text: string } - -function settledUnconfirmedSends( - messages: readonly NativeChatMessage[], - queuedCards: readonly QueuedCardText[], - entries: readonly UnconfirmedSend[] -): UnconfirmedSend[] { - const landed = findLandedUnconfirmedSends(messages, entries) - const landedSet = new Set(landed) - return [ - ...landed, - ...findQueuedUnconfirmedSends( - queuedCards, - entries.filter((entry) => !landedSet.has(entry)) - ) - ] -} +export const UNCONFIRMED_SEND_DEADLINE_MS = 20_000 +export const UNCONFIRMED_SEND_MESSAGE = 'Delivery unconfirmed — check chat before retrying' export function useMobileNativeChatUnconfirmedSends(args: { draftKey: string | null pendingKey: string | null messages: readonly NativeChatMessage[] - /** The active pane's queued-draft cards; a send the host holds as one is delivered. */ - queuedCards?: readonly QueuedCardText[] }): { holdUnconfirmedSend: ( origin: MobileNativeChatSendOrigin, @@ -48,18 +27,16 @@ export function useMobileNativeChatUnconfirmedSends(args: { onUnconfirmed: () => void ) => void } { - const { draftKey, pendingKey, messages, queuedCards = NO_QUEUED_CARDS } = args + const { draftKey, pendingKey, messages } = args const messagesRef = useRef(messages) - const queuedCardsRef = useRef(queuedCards) const activeDraftKeyRef = useRef(draftKey) const activePendingKeyRef = useRef(pendingKey) // Read only by the post-send hold, which runs after the commit that set them. useLayoutEffect(() => { messagesRef.current = messages - queuedCardsRef.current = queuedCards activeDraftKeyRef.current = draftKey activePendingKeyRef.current = pendingKey - }, [draftKey, messages, pendingKey, queuedCards]) + }, [draftKey, messages, pendingKey]) const mountedRef = useRef(false) const unconfirmedRef = useRef([]) const holdUnconfirmedSend = useCallback( @@ -76,15 +53,12 @@ export function useMobileNativeChatUnconfirmedSends(args: { text, normalizedText: origin.normalizedText, baselineTailMessageId: origin.baselineTailMessageId, - ...(origin.baselineQueuedMessageIds - ? { baselineQueuedMessageIds: origin.baselineQueuedMessageIds } - : {}), deadline: null } - // Why: the transcript event (or the card) can beat the lost RPC acknowledgement. + // Why: the transcript event can beat the lost RPC acknowledgement. if ( isActiveTranscript && - settledUnconfirmedSends(messagesRef.current, queuedCardsRef.current, [entry]).length > 0 + findLandedUnconfirmedSends(messagesRef.current, [entry]).length > 0 ) { return } @@ -106,7 +80,7 @@ export function useMobileNativeChatUnconfirmedSends(args: { entry.draftKey === draftKey && (entry.pendingKey === null || entry.pendingKey === pendingKey) ) - const landed = settledUnconfirmedSends(messages, queuedCards, relevant) + const landed = findLandedUnconfirmedSends(messages, relevant) if (landed.length === 0) { return } @@ -115,7 +89,7 @@ export function useMobileNativeChatUnconfirmedSends(args: { for (const entry of landed) { clearTimeout(entry.deadline ?? undefined) } - }, [messages, queuedCards, draftKey, pendingKey]) + }, [messages, draftKey, pendingKey]) useEffect(() => { mountedRef.current = true 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 f5e746fca399..e079adea77b1 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 @@ -203,7 +203,10 @@ describe('mobile structured send actions', () => { await act(async () => { expect(await hook!.sendWithOutcome('same text')).toBe('accepted') }) - expect(hook!.session.messages).toEqual(messages) + // The new send is drawn under its own id until its row lands. + const sent = agentJournalSubmissionKey(sentIds()[0] ?? '') + expect(hook!.session.messages.map((message) => message.id)).toEqual([messages[0]?.id, sent]) + expect(hook!.session.messages[0]).toEqual(messages[0]) expect(event.page.submissions[0]?.dispatchState).toBe('unknown') expect(calls()).toHaveLength(1) }) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index e3fda0d19596..166759db1743 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -39,6 +39,7 @@ import { import type { MobileNativeChatSendErrorReporter } from './use-mobile-native-chat-send-error' import { useMobileStructuredQueuedMessageControls } from './use-mobile-structured-queued-message-controls' import { useMobileStructuredBackgroundTasks } from './use-mobile-structured-background-tasks' +import { useMobileStructuredSends } from './use-mobile-structured-sends' export function useMobileStructuredAgentSession(args: { client: RpcClient | null @@ -112,18 +113,6 @@ export function useMobileStructuredAgentSession(args: { [conversationCommands, invokeStructuredOption, optionSnapshot, setStructuredOption] ) - const sendWithOutcome = useMobileStructuredSendWithOutcome({ - agent, - client, - sessionId, - enabled, - queueCapable, - commandsWait, - stateRef, - commandPending: commandPendingRef, - controller: sendController, - onSendError - }) const { groupedDraft, respondPermission, respondQuestion } = useMobileStructuredPromptResponses({ stateRef, sessionKey, @@ -141,10 +130,17 @@ export function useMobileStructuredAgentSession(args: { }), [agent, state.items] ) + const sends = useMobileStructuredSends({ + sessionKey, + submissions: state.submissions, + queuedMessages, + onSendError + }) const messages = useMemo( // A message the host recorded and then rejected stays where it was sent, as not sent. - () => projectStructuredAgentSessionMessages(transcriptItems, [], state.submissions), - [transcriptItems, state.submissions] + () => + projectStructuredAgentSessionMessages(transcriptItems, sends.optimistic, state.submissions), + [transcriptItems, sends.optimistic, state.submissions] ) const turnId = runningStructuredAgentSessionTurnId(state) const turnTiming = useMobileStructuredAgentTurnTiming( @@ -176,6 +172,21 @@ export function useMobileStructuredAgentSession(args: { stopPressed: stopPress.pressed }) === 'stopping' const stopRequestInFlight = isWorking && stopPress.pressed + const sendWithOutcome = useMobileStructuredSendWithOutcome({ + agent, + client, + sessionId, + enabled, + queueCapable, + commandsWait, + stateRef, + stopping, + accept: sends.accept, + hold: sends.hold, + commandPending: commandPendingRef, + controller: sendController, + onSendError + }) const turnIndicator = useMemo( () => ({ thinking, @@ -256,6 +267,7 @@ export function useMobileStructuredAgentSession(args: { return { ...options, visualSource, + imagePreviewsByMessageId: sends.imagePreviewsByMessageId, session: { messages, status, diff --git a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts index f1d1640e32ce..405e1b92fa2e 100644 --- a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts +++ b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts @@ -19,10 +19,8 @@ const ORIGIN: MobileNativeChatSendOrigin = { describe('useMobileStructuredNativeChatSendBridge', () => { let renderer: ReactTestRenderer | null = null let sendWithOutcome: (text: string) => Promise - const acceptSend = vi.fn() const captureSendOrigin = vi.fn(() => ORIGIN) const clearDraftForSend = vi.fn() - const holdUnconfirmedSend = vi.fn() const onSendError = vi.fn() const restoreRejectedDraft = vi.fn() const sendStructured = vi.fn() @@ -30,10 +28,8 @@ describe('useMobileStructuredNativeChatSendBridge', () => { function Harness({ agent }: { agent: StructuredAgentId }): null { sendWithOutcome = useMobileStructuredNativeChatSendBridge({ agent, - acceptSend, captureSendOrigin, clearDraftForSend, - holdUnconfirmedSend, onSendError, restoreRejectedDraft, sendStructured @@ -56,38 +52,19 @@ describe('useMobileStructuredNativeChatSendBridge', () => { renderer = null }) - it('optimistically echoes accepted commands owned by the active agent', async () => { - sendStructured.mockResolvedValue('accepted') - mount('claude') - - await expect(sendWithOutcome('/init')).resolves.toBe('accepted') - - expect(acceptSend).toHaveBeenCalledWith(ORIGIN, '/init', undefined) - expect(restoreRejectedDraft).not.toHaveBeenCalled() - }) - - it('holds unknown delivery for commands owned by the active agent', async () => { - sendStructured.mockResolvedValue('unknown') - mount('claude') - - await expect(sendWithOutcome('/review')).resolves.toBe('unknown') - - expect(holdUnconfirmedSend).toHaveBeenCalledWith(ORIGIN, '/review', expect.any(Function)) - expect(restoreRejectedDraft).not.toHaveBeenCalled() - }) - - it('never echoes a queued send as an optimistic transcript bubble', async () => { - // The host holds the draft and publishes it as a card above the composer; - // a `pending-N` bubble here would show the message twice and retire never. - sendStructured.mockResolvedValue('queued') - mount('claude') + // The structured session draws, or holds, what the host took; the composer stays cleared. + it.each(['accepted', 'unknown', 'queued'] as const)( + 'leaves a %s send of a command the active agent owns to the structured session', + async (outcome) => { + sendStructured.mockResolvedValue(outcome) + mount('claude') - await expect(sendWithOutcome('queue me')).resolves.toBe('queued') + await expect(sendWithOutcome('/review')).resolves.toBe(outcome) - expect(acceptSend).not.toHaveBeenCalled() - expect(holdUnconfirmedSend).not.toHaveBeenCalled() - expect(restoreRejectedDraft).not.toHaveBeenCalled() - }) + expect(sendStructured).toHaveBeenCalledWith('/review') + expect(restoreRejectedDraft).not.toHaveBeenCalled() + } + ) it('keeps host-command reconciliation for Codex', async () => { sendStructured.mockResolvedValue('unknown') @@ -96,7 +73,6 @@ describe('useMobileStructuredNativeChatSendBridge', () => { await expect(sendWithOutcome('/review')).resolves.toBe('unknown') expect(restoreRejectedDraft).toHaveBeenCalledWith(ORIGIN, '/review') - expect(holdUnconfirmedSend).not.toHaveBeenCalled() }) it("reads another agent's sends by that agent's commands, never Codex's", async () => { @@ -105,7 +81,15 @@ describe('useMobileStructuredNativeChatSendBridge', () => { await expect(sendWithOutcome('/review')).resolves.toBe('unknown') - expect(holdUnconfirmedSend).toHaveBeenCalledWith(ORIGIN, '/review', expect.any(Function)) expect(restoreRejectedDraft).not.toHaveBeenCalled() }) + + it('puts a refused send back in the composer', async () => { + sendStructured.mockResolvedValue('rejected') + mount('claude') + + await expect(sendWithOutcome('hello')).resolves.toBe('rejected') + + expect(restoreRejectedDraft).toHaveBeenCalledWith(ORIGIN, 'hello') + }) }) 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 2d30fb321b1e..959c27f75fa2 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 @@ -20,12 +20,6 @@ export function useMobileStructuredNativeChatSendBridge(args: { ) => Promise captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void - acceptSend: (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => void - holdUnconfirmedSend: ( - origin: MobileNativeChatSendOrigin, - text: string, - onUnconfirmed: () => void - ) => void restoreRejectedDraft: (origin: MobileNativeChatSendOrigin, text: string) => void onSendError: (message: string) => void }): { @@ -38,11 +32,9 @@ export function useMobileStructuredNativeChatSendBridge(args: { ) => Promise } { const { - acceptSend, agent, captureSendOrigin, clearDraftForSend, - holdUnconfirmedSend, onSendError, restoreRejectedDraft, sendStructured @@ -69,40 +61,14 @@ export function useMobileStructuredNativeChatSendBridge(args: { : images !== undefined ? await sendStructured(text, images) : await sendStructured(text) - if (outcome === 'accepted') { - if (!isHostCommand) { - acceptSend(origin, text.trimEnd(), images) - } - return 'accepted' - } - if (outcome === 'queued') { - // The host holds the draft and publishes it as a card above the - // composer — never an optimistic transcript bubble. - return 'queued' - } - if (outcome === 'unknown') { - if (isHostCommand) { - restoreRejectedDraft(origin, text) - return 'unknown' - } - holdUnconfirmedSend(origin, text.trimEnd(), () => - onSendError('Delivery unconfirmed — check chat before retrying') - ) - return 'unknown' + // The structured session draws or holds a message the host took; a command's lost answer + // comes back instead. + if (outcome === 'rejected' || (outcome === 'unknown' && isHostCommand)) { + restoreRejectedDraft(origin, text) } - restoreRejectedDraft(origin, text) - return 'rejected' + return outcome }, - [ - acceptSend, - agent, - captureSendOrigin, - clearDraftForSend, - holdUnconfirmedSend, - onSendError, - restoreRejectedDraft, - sendStructured - ] + [agent, captureSendOrigin, clearDraftForSend, onSendError, restoreRejectedDraft, sendStructured] ) const send = useCallback( async ( 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 c8fd8a74b884..a7b3c1c32b8b 100644 --- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts +++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts @@ -17,6 +17,8 @@ import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { dispatchMobileStructuredCommand } from './mobile-structured-composer-command' import type { MobileNativeChatSendErrorReporter } from './use-mobile-native-chat-send-error' import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-agent-session-send' +import type { MobileStructuredSends } from './use-mobile-structured-sends' +import { structuredSessionOperationId } from './structured-session-operation-id' import { timeoutForDeadline } from './mobile-structured-agent-session-rpc' import { pendingStructuredApproval, @@ -45,6 +47,10 @@ export function useMobileStructuredSendWithOutcome(args: { /** The host holds a /compact sent while the agent works as a card. */ commandsWait: boolean stateRef: { readonly current: StructuredAgentSessionState } + /** The chat reads Stopping: a send made now is drawn after the turn being stopped. */ + stopping: boolean + accept: MobileStructuredSends['accept'] + hold: MobileStructuredSends['hold'] commandPending: { current: boolean } controller: Pick< StructuredAgentSessionComposerOptions, @@ -58,16 +64,19 @@ export function useMobileStructuredSendWithOutcome(args: { attachments?: readonly StructuredMobileSendAttachment[] ) => Promise { const { + accept, agent, client, commandPending, commandsWait, controller, enabled, + hold, onSendError, queueCapable, sessionId, - stateRef + stateRef, + stopping } = args return useCallback( async ( @@ -122,7 +131,14 @@ export function useMobileStructuredSendWithOutcome(args: { if (body.blocks.length === 0) { return 'rejected' } - return sendMobileStructuredAgentSessionMessage({ + // The host records it under this id, which is what settles it. + const send = { + clientMessageId: structuredSessionOperationId(), + body, + queuedAt: Date.now(), + ...(stopping ? { sentWhileStopping: true as const } : {}) + } + const outcome = await sendMobileStructuredAgentSessionMessage({ client, sessionId, expectedRuntimeFence: currentFence, @@ -132,20 +148,30 @@ export function useMobileStructuredSendWithOutcome(args: { ? { delivery: 'queue-if-active' as const } : {}), deadline, + clientMessageId: send.clientMessageId, onError: onSendError }) + if (outcome === 'accepted') { + accept(send, images) + } else if (outcome === 'unknown') { + hold(send) + } + return outcome }, [ + accept, agent, client, commandPending, commandsWait, controller, enabled, + hold, onSendError, queueCapable, sessionId, - stateRef + stateRef, + stopping ] ) } diff --git a/mobile/src/session/use-mobile-structured-sends.test.tsx b/mobile/src/session/use-mobile-structured-sends.test.tsx new file mode 100644 index 000000000000..09385e83a905 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-sends.test.tsx @@ -0,0 +1,357 @@ +// A structured send is one record under the id the host records it under, drawn by the shared +// projection until its row lands and settled by the host's own record of it, never by its text. + +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key' +import type { + AgentJournalDispatchState, + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../src/shared/agent-session-journal-types' +import type { AgentSessionQueuedMessage } from '../../../src/shared/agent-session-wire' +import { DISPATCH_REJECTED_CANCELLED } from '../../../src/shared/structured-agent-session-dispatch-rejection' +import type { RpcClient } from '../transport/rpc-client' +import { + acceptedSubmission, + fieldsOf, + ok, + queuedDraft, + snapshotEvent +} from './use-mobile-structured-agent-session-queued.test-fixture' +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' +import { useMobileStructuredSends } from './use-mobile-structured-sends' + +const asyncStorage = vi.hoisted(() => ({ getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() })) +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) + +const UNCONFIRMED = 'Delivery unconfirmed — check chat before retrying' + +function recorded( + clientMessageId: string, + dispatchState: AgentJournalDispatchState = 'accepted', + overrides: Partial = {} +): AgentJournalSubmission { + return { + ...acceptedSubmission(clientMessageId), + providerItemId: null, + dispatchState, + ...overrides + } +} + +function userRow(clientMessageId: string, text: string, sequence: number): AgentJournalRenderItem { + return { + itemId: agentJournalSubmissionKey(clientMessageId), + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } + } +} + +function journal(items: AgentJournalRenderItem[], submissions: AgentJournalSubmission[]) { + const event = snapshotEvent({ submissions }) + if (event.type === 'snapshot') { + event.page.items = items + } + return event +} + +describe('a structured send in the open chat', () => { + let renderer: ReactTestRenderer | null = null + let hook: ReturnType | null = null + let listener: ((value: unknown) => void) | null = null + let answer: (id: string) => unknown = () => null + const onSendError = vi.fn() + const sendRequest = vi.fn() + const client: RpcClient = { + sendRequest, + subscribe: vi.fn((_method, _params, onData) => { + listener = onData + return vi.fn() + }), + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } + + function Harness(): null { + hook = useMobileStructuredAgentSession({ + client, + sessionId: 'session-1', + sourceIdentity: 'host-a\0workspace-a', + enabled: true, + connected: true, + agent: 'codex', + hostSupport: null, + onSendError + }) + return null + } + + async function mount(): Promise { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(snapshotEvent())) + } + + /** Sends, answered as a real host answers: the submission is recorded under the sent id. */ + async function send( + text: string, + attachments?: { path: string; previewUri: string }[] + ): Promise { + let id = '' + sendRequest.mockImplementation(async (method, params) => { + if (method !== 'agentSession.send') { + return ok({ models: [], current: {} }) + } + id = String(fieldsOf(fieldsOf(params).envelope).clientOperationId) + return ok({ + ok: true, + replayed: false, + fence: 3, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value: answer(id) + }) + }) + await act(async () => { + await hook!.sendWithOutcome( + text, + attachments?.map((attachment) => attachment.previewUri), + undefined, + attachments + ) + }) + return id + } + + const shown = () => + hook!.session.messages.map((message) => [ + message.id, + message.blocks.find((block) => block.type === 'text')?.text, + ...(message.unsent ? ['unsent'] : []) + ]) + + beforeEach(() => { + vi.clearAllMocks() + answer = (id) => ({ clientMessageId: id, submission: recorded(id) }) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + hook = null + listener = null + }) + + it('draws one bubble under its own id until its row lands, then only the row', async () => { + await mount() + const id = await send('hello') + const row = agentJournalSubmissionKey(id) + expect(shown()).toEqual([[row, 'hello']]) + + // A reload that has not recorded it yet keeps the bubble. + act(() => listener?.(journal([], []))) + expect(shown()).toEqual([[row, 'hello']]) + + act(() => listener?.(journal([userRow(id, 'hello', 1)], [recorded(id)]))) + expect(shown()).toEqual([[row, 'hello']]) + expect(hook!.session.messages[0]?.timestamp).toBe(1) + expect(onSendError).not.toHaveBeenCalled() + }) + + // Same words as a not-sent row: once recorded, the resend is one bubble and hides the earlier copy. + it('resends the words of a not-sent row as one bubble, with no stray copy', async () => { + const failed = recorded('failed', 'rejected', { resolvedAt: 5, submittedAt: 4 }) + await mount() + act(() => listener?.(journal([userRow('failed', 'hi', 1)], [failed]))) + expect(shown()).toEqual([[agentJournalSubmissionKey('failed'), 'hi', 'unsent']]) + + const id = await send('hi') + const resent = { ...recorded(id), submittedAt: 20 } + act(() => + listener?.(journal([userRow('failed', 'hi', 1), userRow(id, 'hi', 2)], [failed, resent])) + ) + expect(shown()).toEqual([[agentJournalSubmissionKey(id), 'hi']]) + expect(onSendError).not.toHaveBeenCalled() + }) + + it('draws nothing for a send whose answer was lost', async () => { + await mount() + answer = (id) => ({ clientMessageId: id, submission: recorded(id, 'unknown') }) + await send('maybe') + expect(shown()).toEqual([]) + }) + + it('draws nothing of its own for a send the host keeps as a card', async () => { + await mount() + answer = (id) => ({ + clientMessageId: id, + queued: { messageId: id, state: 'waiting', position: 1 } + }) + await send('later') + expect(shown()).toEqual([]) + }) + + it('leaves a send a Stop took back to its row and stop row', async () => { + await mount() + const withdrawn = (id: string) => + recorded(id, 'rejected', { reason: DISPATCH_REJECTED_CANCELLED, resolvedAt: 11 }) + answer = (id) => ({ clientMessageId: id, submission: withdrawn(id) }) + const id = await send('stop me') + act(() => listener?.(journal([userRow(id, 'stop me', 1)], [withdrawn(id)]))) + expect(hook!.session.messages.map((message) => message.id)).toEqual([ + agentJournalSubmissionKey(id), + `stopped-before-start:${agentJournalSubmissionKey(id)}` + ]) + }) + + it("shows this phone's photo on the bubble, the landed row and a not-sent row", async () => { + await mount() + const id = await send('look', [{ path: '/host/a.jpg', previewUri: 'file:///a.jpg' }]) + const row = agentJournalSubmissionKey(id) + expect(hook!.imagePreviewsByMessageId).toEqual({ [row]: ['file:///a.jpg'] }) + expect(hook!.session.messages[0]?.blocks).toContainEqual({ + type: 'image-ref', + path: '/host/a.jpg' + }) + + act(() => listener?.(journal([userRow(id, 'look', 1)], [recorded(id, 'rejected')]))) + expect(shown()).toEqual([[row, 'look', 'unsent']]) + expect(hook!.imagePreviewsByMessageId).toEqual({ [row]: ['file:///a.jpg'] }) + }) +}) + +describe('a structured send whose answer was lost', () => { + let renderer: ReactTestRenderer | null = null + let sends: ReturnType | null = null + const onSendError = vi.fn() + + function Harness(props: { + sessionKey: string + submissions?: AgentJournalSubmission[] + queuedMessages?: AgentSessionQueuedMessage[] + }): null { + sends = useMobileStructuredSends({ + sessionKey: props.sessionKey, + submissions: props.submissions ?? [], + queuedMessages: props.queuedMessages ?? null, + onSendError + }) + return null + } + + function render(props: Parameters[0]): void { + act(() => { + if (renderer) { + renderer.update(createElement(Harness, props)) + } else { + renderer = create(createElement(Harness, props)) + } + }) + } + + const message = (clientMessageId: string) => ({ + clientMessageId, + body: { kind: 'message' as const, role: 'user' as const, blocks: [] }, + queuedAt: 1 + }) + + function hold(clientMessageId: string): void { + act(() => sends!.hold(message(clientMessageId))) + } + + const drawn = () => sends!.optimistic.map((send) => send.clientMessageId) + + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + sends = null + vi.useRealTimers() + }) + + it('is never drawn, and says so once if the host never shows it', () => { + render({ sessionKey: 'a' }) + hold('lost') + expect(drawn()).toEqual([]) + act(() => sends!.accept(message('next'))) + render({ sessionKey: 'a', submissions: [recorded('other')] }) + expect(drawn()).toEqual(['next']) + act(() => { + vi.advanceTimersByTime(30_000) + }) + expect(drawn()).toEqual(['next']) + expect(onSendError).toHaveBeenCalledTimes(1) + expect(onSendError).toHaveBeenCalledWith(UNCONFIRMED) + }) + + it.each([ + ['recorded', { submissions: [recorded('lost')] }], + ['held as a card', { queuedMessages: [queuedDraft({ messageId: 'lost' })] }], + ['handed off from its card', { submissions: [acceptedSubmission('next', 'lost')] }] + ])('stays quiet once the host shows it %s', (_case, shownBy) => { + render({ sessionKey: 'a' }) + hold('lost') + render({ sessionKey: 'a', ...shownBy }) + expect(vi.getTimerCount()).toBe(0) + expect(drawn()).toEqual([]) + act(() => { + vi.advanceTimersByTime(30_000) + }) + expect(onSendError).not.toHaveBeenCalled() + }) + + // Already recorded before the lost answer came back. + it('stays quiet when the record beat the answer', () => { + render({ sessionKey: 'a', submissions: [recorded('lost')] }) + hold('lost') + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps its hold while another chat is open, and settles it back in its own', () => { + render({ sessionKey: 'a' }) + hold('lost') + render({ sessionKey: 'b', submissions: [recorded('other')] }) + act(() => { + vi.advanceTimersByTime(5_000) + }) + render({ sessionKey: 'a', submissions: [recorded('lost')] }) + act(() => { + vi.advanceTimersByTime(30_000) + }) + expect(onSendError).not.toHaveBeenCalled() + }) + + it('stops its deadline when the chat screen goes away', () => { + render({ sessionKey: 'a' }) + hold('lost') + act(() => renderer?.unmount()) + renderer = null + expect(vi.getTimerCount()).toBe(0) + act(() => { + vi.advanceTimersByTime(30_000) + }) + expect(onSendError).not.toHaveBeenCalled() + }) + + it('ignores an answer lost after the chat screen went away', () => { + render({ sessionKey: 'a' }) + act(() => renderer?.unmount()) + renderer = null + hold('lost') + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/mobile/src/session/use-mobile-structured-sends.ts b/mobile/src/session/use-mobile-structured-sends.ts new file mode 100644 index 000000000000..ef049e54bbb2 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-sends.ts @@ -0,0 +1,143 @@ +// This phone's structured sends the host has not drawn yet, kept as the desktop's sender keeps them: +// one per send, under the id the host records it under. The shared projection draws each until the +// host's row lands, and the host's own record of it settles it. One whose answer was lost is drawn +// nowhere: it says so only if its deadline passes before the host shows it. + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key' +import type { StructuredAgentSessionOptimisticMessage } from '../../../src/shared/structured-agent-session-message-projection' +import { structuredAgentSessionSettledSends } from '../../../src/shared/structured-agent-session-send-settlement' +import { mergeSentImagePreviews } from './mobile-native-chat-draft-reconcile' +import type { MobileQueuedMessageFeed } from './mobile-structured-queued-message-feed' +import type { MobileNativeChatSendErrorReporter } from './use-mobile-native-chat-send-error' +import { + UNCONFIRMED_SEND_DEADLINE_MS, + UNCONFIRMED_SEND_MESSAGE +} from './use-mobile-native-chat-unconfirmed-sends' + +type MobileStructuredSend = StructuredAgentSessionOptimisticMessage & { unconfirmed?: true } + +const NO_SENDS: readonly MobileStructuredSend[] = [] +const NO_IMAGE_PREVIEWS: Record = {} + +export type MobileStructuredSends = { + optimistic: readonly StructuredAgentSessionOptimisticMessage[] + /** This phone's photos, by the row id their send is drawn under on every device. */ + imagePreviewsByMessageId: Record + accept: (send: StructuredAgentSessionOptimisticMessage, images?: string[]) => void + hold: (send: StructuredAgentSessionOptimisticMessage) => void +} + +export function useMobileStructuredSends(args: { + sessionKey: string + submissions: readonly AgentJournalSubmission[] + queuedMessages: MobileQueuedMessageFeed + onSendError: MobileNativeChatSendErrorReporter +}): MobileStructuredSends { + const { onSendError, queuedMessages, sessionKey, submissions } = args + const [sendsBySession, setSendsBySession] = useState< + Record + >({}) + const [imagePreviewsBySession, setImagePreviewsBySession] = useState< + Record> + >({}) + // Timer handles only, for clearing; whether a send is held lives on its record. + const deadlinesRef = useRef(new Map>()) + const mountedRef = useRef(false) + + // Bound to the chat a send was made from: a send captures these before its answer comes back. + const keep = useCallback( + (send: MobileStructuredSend) => + setSendsBySession((previous) => ({ + ...previous, + [sessionKey]: [...(previous[sessionKey] ?? []), send] + })), + [sessionKey] + ) + const forget = useCallback( + (ids: ReadonlySet) => + setSendsBySession((previous) => { + const kept = (previous[sessionKey] ?? []).filter((send) => !ids.has(send.clientMessageId)) + const next = { ...previous, [sessionKey]: kept } + if (kept.length === 0) { + delete next[sessionKey] + } + return next + }), + [sessionKey] + ) + const accept = useCallback( + (send: StructuredAgentSessionOptimisticMessage, images?: string[]) => { + keep(send) + if (images?.length) { + // The host's row has this id too, so the photo shows on it, landed or not sent. + const messageId = agentJournalSubmissionKey(send.clientMessageId) + setImagePreviewsBySession((previous) => + mergeSentImagePreviews(previous, sessionKey, [{ messageId, images }]) + ) + } + }, + [keep, sessionKey] + ) + const hold = useCallback( + (send: StructuredAgentSessionOptimisticMessage) => { + if (!mountedRef.current) { + return + } + const id = send.clientMessageId + deadlinesRef.current.set( + id, + setTimeout(() => { + deadlinesRef.current.delete(id) + forget(new Set([id])) + onSendError(UNCONFIRMED_SEND_MESSAGE) + }, UNCONFIRMED_SEND_DEADLINE_MS) + ) + keep({ ...send, unconfirmed: true }) + }, + [forget, keep, onSendError] + ) + + const sends = sendsBySession[sessionKey] ?? NO_SENDS + useEffect(() => { + if (sends.length === 0) { + return + } + const { cards, recorded } = structuredAgentSessionSettledSends( + submissions, + (queuedMessages ?? []).map((message) => message.messageId) + ) + const settled = new Set( + sends.map((send) => send.clientMessageId).filter((id) => cards.has(id) || recorded.has(id)) + ) + if (settled.size === 0) { + return + } + for (const id of settled) { + clearTimeout(deadlinesRef.current.get(id)) + deadlinesRef.current.delete(id) + } + forget(settled) + }, [forget, queuedMessages, sends, submissions]) + + useEffect(() => { + mountedRef.current = true + const deadlines = deadlinesRef.current + return () => { + mountedRef.current = false + for (const deadline of deadlines.values()) { + clearTimeout(deadline) + } + deadlines.clear() + } + }, []) + + const optimistic = useMemo(() => sends.filter((send) => !send.unconfirmed), [sends]) + return { + optimistic, + imagePreviewsByMessageId: imagePreviewsBySession[sessionKey] ?? NO_IMAGE_PREVIEWS, + accept, + hold + } +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts index b554778544ee..83db80b6fff8 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts @@ -15,6 +15,7 @@ import { agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice' import type { AgentSessionWriteFailure } from '../../../../shared/agent-session-write-failure' +import { structuredAgentSessionSettledSends } from '../../../../shared/structured-agent-session-send-settlement' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { attemptStructuredAgentSessionSend, @@ -219,10 +220,7 @@ export function reserveStructuredAgentSessionSend( } } -/** - * Settles sends from the host's published state: a row under the message's id, a hand-off of a card - * under it, or a card under it. Whichever of this and the send's own reply comes first decides. - */ +/** Settles sends from the host's published state; see `structuredAgentSessionSettledSends`. */ export function settleStructuredAgentSessionSendsFromJournal( sessionId: string, submissions: readonly AgentJournalSubmission[], @@ -232,15 +230,9 @@ export function settleStructuredAgentSessionSendsFromJournal( if (entries.length === 0) { return } - const cards = new Set(queuedMessageIds) - for (const submission of submissions) { - if (submission.queuedMessageId !== undefined) { - cards.add(submission.queuedMessageId) - } - } - const rows = new Map(submissions.map((submission) => [submission.clientMessageId, submission])) + const { cards, recorded } = structuredAgentSessionSettledSends(submissions, queuedMessageIds) for (const entry of entries) { - const submission = rows.get(entry.clientMessageId) + const submission = recorded.get(entry.clientMessageId) if (cards.has(entry.clientMessageId)) { finish(entry, 'recorded') } else if (submission) { diff --git a/src/shared/structured-agent-session-send-settlement.test.ts b/src/shared/structured-agent-session-send-settlement.test.ts new file mode 100644 index 000000000000..7607120ae54a --- /dev/null +++ b/src/shared/structured-agent-session-send-settlement.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalSubmission } from './agent-session-journal-types' +import { structuredAgentSessionSettledSends } from './structured-agent-session-send-settlement' + +function submission(clientMessageId: string, queuedMessageId?: string): AgentJournalSubmission { + return { + clientMessageId, + ...(queuedMessageId ? { queuedMessageId } : {}), + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: 1 + } +} + +describe('structuredAgentSessionSettledSends', () => { + it('reads a send as a card while queued or once handed off, else as recorded under its id', () => { + const handOff = submission('hand-off', 'was-queued') + const sent = submission('sent') + const { cards, recorded } = structuredAgentSessionSettledSends([handOff, sent], ['on-queue']) + expect([...cards]).toEqual(['on-queue', 'was-queued']) + expect(recorded.get('sent')).toBe(sent) + expect(recorded.get('hand-off')).toBe(handOff) + expect(recorded.has('on-queue')).toBe(false) + }) +}) diff --git a/src/shared/structured-agent-session-send-settlement.ts b/src/shared/structured-agent-session-send-settlement.ts new file mode 100644 index 000000000000..2eb2acab1df5 --- /dev/null +++ b/src/shared/structured-agent-session-send-settlement.ts @@ -0,0 +1,20 @@ +import type { AgentJournalSubmission } from './agent-session-journal-types' +import { handedOffQueuedMessageIds } from './structured-agent-session-draft-hand-off' + +/** + * Where the host's published state holds a client's sends, by the id each was sent under: as a + * card (on the queue, or handed off from it), or recorded as a submission. Either settles the send, + * whichever of that and the send's own answer comes first; a card wins. + */ +export function structuredAgentSessionSettledSends( + submissions: readonly AgentJournalSubmission[], + queuedMessageIds: readonly string[] +): { + cards: ReadonlySet + recorded: ReadonlyMap +} { + return { + cards: new Set([...queuedMessageIds, ...handedOffQueuedMessageIds(submissions)]), + recorded: new Map(submissions.map((submission) => [submission.clientMessageId, submission])) + } +} From 4b92fbb0904d2b18383151537ee789963bde225f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:26:27 -0700 Subject: [PATCH 7/8] fix(native-chat): merge not-sent rows by the transcript order alone The merge kept a rule for rows with no journal place that no caller reaches and that the desktop's sort does not have; drop it, and say what merging after the stop rows actually keeps. A test pins that the list the phone draws as given is already in the desktop's sorted order, with a moved stop row, a retry run, a cut-turn notice, a held card and two not-sent rows. --- ...d-agent-session-message-projection.test.ts | 130 +++++++++++++++++- ...ctured-agent-session-message-projection.ts | 10 +- 2 files changed, 131 insertions(+), 9 deletions(-) diff --git a/src/shared/structured-agent-session-message-projection.test.ts b/src/shared/structured-agent-session-message-projection.test.ts index fae3a79b4f03..3a32bb5c0595 100644 --- a/src/shared/structured-agent-session-message-projection.test.ts +++ b/src/shared/structured-agent-session-message-projection.test.ts @@ -2,12 +2,22 @@ // own fact, never on the card still being there. import { describe, expect, it } from 'vitest' -import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalSubmission +} from './agent-session-journal-types' import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import { structuredAgentSessionSendBody } from './structured-agent-session-send-mutation' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' import { projectNativeChatTranscriptMessages } from './native-chat-transcript-projection' -import { DISPATCH_REJECTED_CANCELLED } from './structured-agent-session-dispatch-rejection' +import { + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_WRITE_FAILED +} from './structured-agent-session-dispatch-rejection' +import { withNativeChatCutTurnNotices } from './native-chat-cut-turn-notice' +import { agentSessionFailureFact } from './agent-session-failure' +import { agentSessionFailureWords } from './agent-session-failure-words' const KEPT_ID = 'client-kept' @@ -136,4 +146,120 @@ describe('a not-sent row', () => { agentJournalSubmissionKey(KEPT_ID) ]) }) + + // The merge relies on the rest of the list already being in journal order; this pins it. + it('lists everything in the order the desktop sorts it into', () => { + const row = ( + itemId: string, + sequence: number, + body: AgentJournalItemBody, + turnItemId?: string + ): AgentJournalRenderItem => ({ + itemId, + revision: 1, + sequence, + observedAt: sequence, + body, + turnScope: turnItemId ? { kind: 'turn', turnItemId } : { kind: 'thread' } + }) + const user = (id: string, sequence: number): AgentJournalRenderItem => + row(agentJournalSubmissionKey(id), sequence, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: id }] + }) + const reply = (id: string, sequence: number, turnItemId: string): AgentJournalRenderItem => + row( + id, + sequence, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: id }] }, + turnItemId + ) + const retry = (id: string, sequence: number): AgentJournalRenderItem => + row( + id, + sequence, + { + kind: 'status', + tone: 'warning', + ...agentSessionFailureWords(agentSessionFailureFact('providerRetrying'), { + surface: 'row' + }) + }, + 'turn-a' + ) + const turn = ( + turnId: string, + sequence: number, + opener: string, + state: 'completed' | 'interrupted' + ): AgentJournalRenderItem => + row(turnId, sequence, { + kind: 'turn', + turnId, + state, + userItemId: agentJournalSubmissionKey(opener) + }) + const send = ( + id: string, + sequence: number, + fields: Partial = {} + ): AgentJournalSubmission => ({ + ...rejected({ clientMessageId: id, payloadFingerprint: id, submittedAt: sequence }), + dispatchState: 'accepted', + reason: null, + rejection: undefined, + submittedSequence: sequence, + ...fields + }) + const items = withNativeChatCutTurnNotices([ + user('a', 1), + turn('turn-a', 2, 'a', 'completed'), + user('stopped', 3), + reply('reply-a', 4, 'turn-a'), + retry('retry-1', 5), + user('unsent-1', 6), + retry('retry-2', 7), + user('b', 8), + turn('turn-b', 9, 'b', 'interrupted'), + reply('reply-b', 10, 'turn-b'), + user('unsent-2', 11), + user('held', 12) + ]) + const notSent = { dispatchState: 'rejected' as const, reason: DISPATCH_REJECTED_WRITE_FAILED } + const shown = projectStructuredAgentSessionMessages( + items, + [], + [ + send('a', 1), + send('stopped', 3, { dispatchState: 'rejected', reason: DISPATCH_REJECTED_CANCELLED }), + send('unsent-1', 6, notSent), + send('b', 8), + send('unsent-2', 11, notSent), + send('held', 12, { dispatchState: 'pending', handoverRecorded: true, resolvedAt: null }) + ] + ) + const ids = shown.map((message) => message.id) + expect(ids).toEqual([ + agentJournalSubmissionKey('a'), + 'reply-a', + agentJournalSubmissionKey('unsent-1'), + // The run's earlier retry row is collapsed into this one. + 'retry-2', + // Moved past the end of the turn it was sent into. + agentJournalSubmissionKey('stopped'), + `stopped-before-start:${agentJournalSubmissionKey('stopped')}`, + agentJournalSubmissionKey('b'), + 'reply-b', + agentJournalSubmissionKey('cut-turn-notice:turn-b'), + agentJournalSubmissionKey('unsent-2'), + agentJournalSubmissionKey('held') + ]) + // The desktop sorts held cards after the conversation, and the rest by journal place. + const conversation = shown.filter((message) => !message.queued) + expect(projectNativeChatTranscriptMessages(conversation).map((message) => message.id)).toEqual( + conversation.map((message) => message.id) + ) + expect(shown.at(-1)).toMatchObject({ queued: true }) + }) }) diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index f68172160cdd..c22b4f882f04 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -175,7 +175,7 @@ export function projectStructuredAgentSessionMessages( const conversation = shownStopped.size > 0 ? withStopRowsAfterStoppedSends(ordered, shownStopped) : ordered return [ - // In no turn; merged at their journal places, after the stop rows so none splits a run. + // In no turn; merged at their journal places after the stop rows, so a run keeps one stop row. ...(unsentItems.length > 0 ? withRowsAtJournalPlaces( conversation, @@ -203,7 +203,7 @@ export function projectStructuredAgentSessionMessages( } /** `rows` with the few `placed` rows merged in at their journal places, so a reader that draws the - * list as given needs no sort. A row with no journal place keeps the row before it. */ + * list as given needs no sort. */ function withRowsAtJournalPlaces( rows: readonly NativeChatMessage[], placed: NativeChatMessage[] @@ -212,11 +212,7 @@ function withRowsAtJournalPlaces( const merged: NativeChatMessage[] = [] let next = 0 for (const row of rows) { - while ( - next < placed.length && - row.journalPosition !== undefined && - compareNativeChatTranscriptMessages(placed[next], row) < 0 - ) { + while (next < placed.length && compareNativeChatTranscriptMessages(placed[next], row) < 0) { merged.push(placed[next++]) } merged.push(row) From 8db7841aa6ff5edce99ae31607828e4b5f33332f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:30:02 -0700 Subject: [PATCH 8/8] fix(mobile): draw the not-sent line as the desktop does Red and capped to the bubble's width, like the desktop's line and the phone's own returned-card caption, and only when there is a notice: the row had a second, unreachable source of words. A send a Stop took back is no failure, so it no longer rebuilds the notices on every streaming frame. --- mobile/src/session/MobileNativeChatMessage.test.ts | 11 ++++++----- mobile/src/session/MobileNativeChatMessage.tsx | 7 +------ .../src/session/mobile-native-chat-message-styles.ts | 3 ++- .../session/mobile-native-chat-unsent-notices.test.ts | 11 +++++++++-- .../src/session/mobile-native-chat-unsent-notices.ts | 7 ++++--- 5 files changed, 22 insertions(+), 17 deletions(-) diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index 9176b18c8eae..1911a5162fa3 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -176,15 +176,15 @@ describe('MobileNativeChatMessage', () => { (node) => String(node.type) === 'Text' && node.props.style === styles.unsentNotice ) - it('says why under the bubble, and that it was not sent when no reason is known', () => { + it('says why under the bubble, and draws no line without a reason, as the desktop', () => { expect(textIn(render(notSent, { unsentNotice: 'Claude is not signed in.' }).root)).toEqual([ 'fix it', 'Claude is not signed in.' ]) - expect(textIn(render(notSent).root).at(-1)).toBe('Your message was not sent.') + expect(lineIn(render(notSent))).toHaveLength(0) }) - it("draws the line under another agent's message too, and never under a delivered one", () => { + it("draws the line under another agent's message too", () => { const fromAgent: NativeChatMessage = { ...notSent, from: { @@ -198,8 +198,9 @@ describe('MobileNativeChatMessage', () => { orchestration: null } } - expect(lineIn(render(fromAgent))).toHaveLength(1) - expect(lineIn(render(userMessage([{ type: 'text', text: 'fix it' }])))).toHaveLength(0) + expect( + lineIn(render(fromAgent, { unsentNotice: 'Your message was not sent.' })) + ).toHaveLength(1) }) }) diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index c72a18dcbb45..753627a92e9f 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -31,7 +31,6 @@ import type { NativeChatTurnStatus } from './use-mobile-native-chat-turn-status' import { isRenderableImageUri } from './mobile-native-chat-image-preview' import { styles, TEXT_SIZE } from './mobile-native-chat-message-styles' import { agentMessageAttribution } from './mobile-agent-message-attribution' -import { AGENT_SESSION_WRITE_NOTICE_COPY } from '../../../src/shared/agent-session-write-notice-copy' import { withoutPendingNativeChatVisualDirectiveTail } from '../../../src/shared/native-chat-visual-directive' import { MobileNativeChatVisualContext, @@ -312,11 +311,7 @@ function MobileNativeChatMessageImpl({ /> ) : null} - {message.role === 'user' && message.unsent ? ( - - {unsentNotice ?? AGENT_SESSION_WRITE_NOTICE_COPY.notDoneSend} - - ) : null} + {unsentNotice ? {unsentNotice} : null} {actionsOpen ? ( { expect(notices.get(agentJournalSubmissionKey('lost'))).toBe('Your message was not sent.') }) - it('gives one shared empty map when nothing was rejected', () => { - const accepted = { items: [], submissions: [submission('sent', { dispatchState: 'accepted' })] } + it('gives one shared empty map when no send failed, a Stop taking one back included', () => { + const accepted = { + items: [], + submissions: [ + submission('sent', { dispatchState: 'accepted' }), + submission('stopped', { reason: DISPATCH_REJECTED_CANCELLED }) + ] + } expect(mobileNativeChatUnsentNotices(accepted, 'claude').size).toBe(0) expect(mobileNativeChatUnsentNotices(accepted, 'claude')).toBe( mobileNativeChatUnsentNotices(null, 'claude') diff --git a/mobile/src/session/mobile-native-chat-unsent-notices.ts b/mobile/src/session/mobile-native-chat-unsent-notices.ts index 245065663c15..2c3f9e4a0f72 100644 --- a/mobile/src/session/mobile-native-chat-unsent-notices.ts +++ b/mobile/src/session/mobile-native-chat-unsent-notices.ts @@ -4,19 +4,20 @@ import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-jou import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice' import { agentSessionVisibleFailureFacts } from '../../../src/shared/agent-session-visible-failures' import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership' +import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection' import { structuredAgentSessionRecordedRejectionParts } from '../../../src/shared/structured-agent-session-rejection-words' import { tuiAgentDisplayName } from '../../../src/shared/tui-agent-display-names' const NO_NOTICES: ReadonlyMap = new Map() -/** By the row id the transcript draws each message under; shared and empty when none was rejected, - * so a streaming turn pays nothing. */ +/** By the row id the transcript draws each message under; shared and empty when no send failed (a + * Stop's withdrawal is no failure), so a streaming turn then pays nothing. */ export function mobileNativeChatUnsentNotices( journal: NativeChatTurnJournal | null, agent: string | null | undefined ): ReadonlyMap { const rejected = (journal?.submissions ?? []).filter( - (submission) => submission.dispatchState === 'rejected' + (submission) => submission.dispatchState === 'rejected' && !dispatchWasWithdrawn(submission) ) if (!journal || rejected.length === 0) { return NO_NOTICES