From 6289065cda5a3a1e17ea2225c1ec711ba2ed572e Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 17:06:31 -0700
Subject: [PATCH 01/34] fix(native-chat): a message the host recorded stays in
the chat when it is not delivered
A send the host recorded and then settled as not delivered (its agent failed to
start, Orca restarted before handing it over) was hidden by the transcript
projection unless the sender's outbox entry still carried it. Another window,
the phone, or the sender after its entry was dropped saw the message vanish.
The projection now shows every recorded, non-withdrawn rejection from the
journal as a not-sent row at its journal position, and the delivery notices
word it from the host's fact (no Retry where this client holds nothing to
send). The phone row says "Message not sent". Not-sent rows take no rail tick,
so the host's outline is unchanged. One predicate now reads a recovered unknown,
including an older host's restart row without the marker.
---
.../session/MobileNativeChatMessage.test.ts | 10 ++
.../src/session/MobileNativeChatMessage.tsx | 4 +
.../mobile-native-chat-message-styles.ts | 5 +
.../native-chat-message-rail-items.ts | 3 +-
...red-agent-session-delivery-notices.test.ts | 101 ++++++++++++++++++
...ructured-agent-session-delivery-notices.ts | 35 +++++-
...d-agent-session-message-projection.test.ts | 70 +++++++++---
.../agent-session-conversation-outline.ts | 2 +
...ctured-agent-session-message-projection.ts | 29 +++--
...red-agent-session-send-disposition.test.ts | 27 +++++
...ructured-agent-session-send-disposition.ts | 3 +-
...-agent-session-unanswered-dispatch.test.ts | 48 +++++++++
...tured-agent-session-unanswered-dispatch.ts | 17 ++-
13 files changed, 327 insertions(+), 27 deletions(-)
create mode 100644 src/shared/structured-agent-session-unanswered-dispatch.test.ts
diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts
index 67e901328667..e7e9ae80a6f1 100644
--- a/mobile/src/session/MobileNativeChatMessage.test.ts
+++ b/mobile/src/session/MobileNativeChatMessage.test.ts
@@ -107,6 +107,16 @@ describe('MobileNativeChatMessage', () => {
expect(texts.some((text) => text.includes('/tmp/host.png'))).toBe(true)
})
+ it('says under a message the host recorded but never delivered that it was not sent', () => {
+ const tree = render({ ...userMessage([{ type: 'text', text: 'hello' }]), unsent: true })
+ expect(textIn(tree.root)).toEqual(['hello', 'Message not sent'])
+ })
+
+ it('says nothing more under a delivered message', () => {
+ const tree = render(userMessage([{ type: 'text', text: 'hello' }]))
+ expect(textIn(tree.root)).toEqual(['hello'])
+ })
+
it('makes user message text selectable', () => {
const tree = render(userMessage([{ type: 'text', text: 'Prompt I typed' }]))
const text = tree.root
diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx
index 9c894e3cd7fc..01b9dd465c5a 100644
--- a/mobile/src/session/MobileNativeChatMessage.tsx
+++ b/mobile/src/session/MobileNativeChatMessage.tsx
@@ -158,6 +158,10 @@ function MobileNativeChatMessageImpl({
/>
) : null}
+ {/* The host recorded it and never delivered it; the desktop row words the same fact. */}
+ {isUser && message.unsent === true ? (
+ Message not sent
+ ) : null}
{turnStatusAbove ? null : statusRow}
>
diff --git a/mobile/src/session/mobile-native-chat-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts
index 51ff9ab1f36c..b3455c898301 100644
--- a/mobile/src/session/mobile-native-chat-message-styles.ts
+++ b/mobile/src/session/mobile-native-chat-message-styles.ts
@@ -32,6 +32,11 @@ export const styles = StyleSheet.create({
reasoning: {
opacity: 0.7
},
+ unsentLabel: {
+ marginTop: spacing.xs,
+ color: colors.statusRed,
+ fontSize: typography.metaSize
+ },
toolRun: {
marginTop: spacing.xs
},
diff --git a/src/renderer/src/components/native-chat/native-chat-message-rail-items.ts b/src/renderer/src/components/native-chat/native-chat-message-rail-items.ts
index 1ad2ba71a397..1c0e371c9cb4 100644
--- a/src/renderer/src/components/native-chat/native-chat-message-rail-items.ts
+++ b/src/renderer/src/components/native-chat/native-chat-message-rail-items.ts
@@ -39,7 +39,8 @@ export function buildNativeChatRailItems(
): readonly NativeChatRailItem[] {
const items: NativeChatRailItem[] = []
for (const [slotIndex, slot] of slots.entries()) {
- if (slot.kind !== 'message' || slot.message.role !== 'user') {
+ // A message shown as not sent is no turn of the conversation, as the host's outline agrees.
+ if (slot.kind !== 'message' || slot.message.role !== 'user' || slot.message.unsent === true) {
continue
}
const preview = nativeChatUserMessagePreview(slot.message.blocks)
diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts
index 5dde72621adb..b2464e13e37f 100644
--- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts
+++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts
@@ -547,3 +547,104 @@ describe('the notice on each message that did not go through', () => {
})
})
})
+
+describe('a recorded rejection no outbox entry here carries', () => {
+ const recorded = (patch: Partial = {}): AgentJournalSubmission => ({
+ clientMessageId: 'elsewhere',
+ fence: 1,
+ payloadFingerprint: 'fingerprint',
+ dispatchState: 'rejected',
+ providerItemId: null,
+ reason: 'not_delivered',
+ submittedAt: 1,
+ resolvedAt: 1,
+ ...patch
+ })
+
+ it('says it was not sent on its own row, with no Retry, in the words the sender would read', () => {
+ const notices = structuredAgentSessionDeliveryNotices(
+ [],
+ 'Claude',
+ () => {},
+ [recorded()],
+ [],
+ NOT_FAILED_HERE
+ )
+ const senderNotices = structuredAgentSessionDeliveryNotices(
+ [
+ entry('elsewhere', {
+ state: 'rejected',
+ lastFailure: structuredAgentSessionRejectedFailure(recorded())
+ })
+ ],
+ 'Claude',
+ () => {},
+ [recorded()],
+ [],
+ new Set(['elsewhere'])
+ )
+ const id = agentJournalSubmissionKey('elsewhere')
+ expect(notices.get(id)?.onRetry).toBeUndefined()
+ expect(senderNotices.get(id)?.onRetry).toBeDefined()
+ expect(notices.get(id)?.text).toBe('Your message was not sent.')
+ expect(senderNotices.get(id)?.text).toBe(notices.get(id)?.text)
+ })
+
+ it("keeps the sender's Retry while its outbox entry exists", () => {
+ const retry = vi.fn()
+ const notices = structuredAgentSessionDeliveryNotices(
+ [
+ entry('mine', {
+ state: 'rejected',
+ lastFailure: structuredAgentSessionRejectedFailure(recorded({ clientMessageId: 'mine' }))
+ })
+ ],
+ 'Claude',
+ retry,
+ [recorded({ clientMessageId: 'mine' })],
+ [],
+ NOT_FAILED_HERE
+ )
+ notices.get(agentJournalSubmissionKey('mine'))?.onRetry?.()
+ expect(retry).toHaveBeenCalledWith('mine')
+ expect(notices.size).toBe(1)
+ })
+
+ it('says nothing for a send a Stop withdrew', () => {
+ const notices = structuredAgentSessionDeliveryNotices(
+ [],
+ 'Claude',
+ () => {},
+ [recorded({ reason: 'provider_cancelled_before_start' })],
+ [],
+ NOT_FAILED_HERE
+ )
+ expect(notices.size).toBe(0)
+ })
+
+ it('says only that it was not sent when the start-failure row already says why', () => {
+ const startFailed: AgentSessionFailureFact = { kind: 'startFailed' }
+ const row: AgentJournalRenderItem = {
+ itemId: agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('gen')),
+ revision: 1,
+ sequence: 1,
+ observedAt: 1,
+ body: {
+ kind: 'status',
+ tone: 'error',
+ ...agentSessionFailureWords(startFailed, { agentName: 'Claude', surface: 'row' })
+ }
+ }
+ const notices = structuredAgentSessionDeliveryNotices(
+ [],
+ 'Claude',
+ () => {},
+ [recorded({ reason: 'Written by the host.', rejection: startFailed })],
+ structuredAgentSessionStartFailureFacts([row]),
+ NOT_FAILED_HERE
+ )
+ expect(notices.get(agentJournalSubmissionKey('elsewhere'))?.text).toBe(
+ 'Your message was not sent.'
+ )
+ })
+})
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 6ad655e8f80c..912372f1a70e 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
@@ -10,7 +10,8 @@
// A message the host recorded and then rejected is worded from the journal's own fact, found by id;
// the message keeps only a smaller copy, read when its submission is not loaded. A rejection that
// is a failed start's, the fact its loaded row states, says only that it was not sent: the row
-// already says why.
+// already says why. One no outbox entry here carries (another client's send, or one whose entry is
+// gone) is worded from the journal alone, with no Retry: this client holds nothing to send.
import {
readAgentSessionFailureFact,
@@ -26,8 +27,10 @@ import { agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-
import { isStructuredAgentSessionStartFailureRow } from '../../../../shared/structured-agent-session-start-failure-row-key'
import {
structuredAgentSessionEntryIdExpired,
+ structuredAgentSessionRejectedFailure,
type StructuredAgentSessionOutboxEntry
} from '../../../../shared/structured-agent-session-outbox'
+import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection'
import {
admitStructuredAgentSessionOutboxEntry,
structuredAgentSessionEntryHeldForRetry
@@ -129,6 +132,24 @@ function deliveryNoticeText(
)
}
+/** What a recorded rejection's own row says when no outbox entry here carries it. */
+function recordedRejectionNoticeText(
+ submission: AgentJournalSubmission,
+ context: AgentSessionFailureWordsContext,
+ startFailures: readonly AgentSessionFailureFact[]
+): string {
+ if (agentSessionFailureStatedByStartRow(submission.rejection, startFailures)) {
+ return agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send'))
+ }
+ return agentSessionWriteNoticeText(
+ structuredAgentSessionAttemptFailureParts(
+ structuredAgentSessionRejectedFailure(submission),
+ context,
+ readWholeAgentSessionFailureFact(submission.rejection)
+ )
+ )
+}
+
/** Keyed by the message id the transcript renders each entry under; `agentName` is the chat's
* agent, for the words. */
export function structuredAgentSessionDeliveryNotices(
@@ -172,5 +193,17 @@ export function structuredAgentSessionDeliveryNotices(
)
}
}
+ for (const submission of rejected.values()) {
+ const id = agentJournalSubmissionKey(submission.clientMessageId)
+ if (!notices.has(id) && !dispatchWasWithdrawn(submission)) {
+ notices.set(id, {
+ text: recordedRejectionNoticeText(
+ submission,
+ { agentName, retryControl: false },
+ startFailures
+ )
+ })
+ }
+ }
return notices
}
diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts
index 3a3562458cb6..2b9ba38f897c 100644
--- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts
+++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts
@@ -31,28 +31,72 @@ function item(index: number): AgentJournalRenderItem {
}
describe('structured agent session message projection', () => {
- it('does not render a rejected host submission as a sent user message', () => {
+ it('shows a recorded send the host did not deliver as not sent, never as sent', () => {
const rejected = { ...submission(0), dispatchState: 'rejected' as const, providerItemId: null }
const refusedItem = { ...item(0), itemId: agentJournalSubmissionKey(rejected.clientMessageId) }
const acceptedItem = item(1)
expect(
projectStructuredAgentSessionMessages([refusedItem, acceptedItem], [], [rejected])
- ).toMatchObject([{ id: acceptedItem.itemId, role: 'user' }])
+ ).toMatchObject([
+ { id: refusedItem.itemId, role: 'user', unsent: true },
+ { id: acceptedItem.itemId, role: 'user' }
+ ])
+ })
+
+ it('keeps a recorded send in the chat after its outbox entry is gone and the host settles it undelivered', () => {
+ // Orca restarted mid-send: the entry left this client, then the next agent start proved the
+ // provider never took the message.
+ const notDelivered = {
+ ...submission(0),
+ dispatchState: 'rejected' as const,
+ providerItemId: null,
+ reason: 'not_delivered'
+ }
+ const recordedItem = {
+ ...item(0),
+ itemId: agentJournalSubmissionKey(notDelivered.clientMessageId)
+ }
+ const messages = projectStructuredAgentSessionMessages([recordedItem], [], [notDelivered])
+ expect(messages).toMatchObject([
+ {
+ id: recordedItem.itemId,
+ unsent: true,
+ blocks: [{ type: 'text', text: 'send 0' }],
+ journalPosition: { sequence: 0 }
+ }
+ ])
+ })
+
+ it('leaves out a send a Stop withdrew: its text went back to its sender', () => {
+ const withdrawn = {
+ ...submission(0),
+ dispatchState: 'rejected' as const,
+ providerItemId: null,
+ reason: 'provider_cancelled_before_start'
+ }
+ const withdrawnItem = {
+ ...item(0),
+ itemId: agentJournalSubmissionKey(withdrawn.clientMessageId)
+ }
+ expect(projectStructuredAgentSessionMessages([withdrawnItem], [], [withdrawn])).toEqual([])
})
- it('keeps a refused local draft available through its outbox', () => {
+ it("shows the sender's recorded rejection once, from the journal, while its outbox entry holds the Retry", () => {
const rejected = { ...submission(0), dispatchState: 'rejected' as const, providerItemId: null }
const refusedItem = { ...item(0), itemId: agentJournalSubmissionKey(rejected.clientMessageId) }
- const draft = createStructuredAgentSessionOutboxEntry({
- clientMessageId: rejected.clientMessageId,
- sessionId: 'session-1',
- text: 'An unsent draft',
- attachments: [],
- queuedAt: 1
- })
- expect(projectStructuredAgentSessionMessages([refusedItem], [draft], [rejected])).toMatchObject(
- [{ id: refusedItem.itemId, blocks: [{ text: 'An unsent draft' }] }]
- )
+ const draft = {
+ ...createStructuredAgentSessionOutboxEntry({
+ clientMessageId: rejected.clientMessageId,
+ sessionId: 'session-1',
+ text: 'send 0',
+ attachments: [],
+ queuedAt: 1
+ }),
+ state: 'rejected' as const
+ }
+ expect(projectStructuredAgentSessionMessages([refusedItem], [draft], [rejected])).toEqual([
+ expect.objectContaining({ id: refusedItem.itemId, unsent: true })
+ ])
})
it.each([5, 10])('renders %i rapid accepted desktop sends exactly once', (sendCount) => {
diff --git a/src/shared/agent-session-conversation-outline.ts b/src/shared/agent-session-conversation-outline.ts
index 2b6bb846adb8..c8aeecff9e9d 100644
--- a/src/shared/agent-session-conversation-outline.ts
+++ b/src/shared/agent-session-conversation-outline.ts
@@ -96,6 +96,8 @@ export function projectAgentSessionConversationOutline(
if (
sequence === undefined ||
message.role !== 'user' ||
+ // Shown as not sent: never part of the conversation the agent saw, so no rail tick.
+ message.unsent === true ||
!nativeChatRowRendersContent(message.blocks)
) {
continue
diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts
index 5515b6478a22..5223b4c84856 100644
--- a/src/shared/structured-agent-session-message-projection.ts
+++ b/src/shared/structured-agent-session-message-projection.ts
@@ -7,6 +7,7 @@ import type { NativeChatMessage } from './native-chat-types'
import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox'
import { structuredAgentSessionEntryHeldForRetry } from './structured-agent-session-outbox-admission'
import { reconcileStructuredAgentSessionOutboxWithQueue } from './structured-agent-session-draft-hand-off'
+import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection'
import { projectStructuredItemsToNativeChat } from './structured-agent-session-projection'
export function projectStructuredAgentSessionMessages(
@@ -16,16 +17,26 @@ export function projectStructuredAgentSessionMessages(
projectItems = projectStructuredItemsToNativeChat
): NativeChatMessage[] {
const optimistic = reconcileStructuredAgentSessionOutboxWithQueue(outbox, submissions)
- // Refused sends are ledger evidence, not conversation history; local drafts remain in the outbox.
- const rejected = new Set(
- submissions
- .filter((submission) => submission.dispatchState === 'rejected')
- .map((submission) => agentJournalSubmissionKey(submission.clientMessageId))
- )
+ // A send the host recorded and then did not deliver stays in the conversation from the host's
+ // record, shown as not sent, for every viewer: dropping the sender's outbox entry can never make
+ // it vanish. Only a Stop's withdrawal leaves it, since its text went back to its sender.
+ const notSent = new Set()
+ const withdrawn = new Set()
+ for (const submission of submissions) {
+ if (submission.dispatchState !== 'rejected') {
+ continue
+ }
+ const key = agentJournalSubmissionKey(submission.clientMessageId)
+ if (dispatchWasWithdrawn(submission)) {
+ withdrawn.add(key)
+ } else {
+ notSent.add(key)
+ }
+ }
const visibleItems: AgentJournalRenderItem[] = []
const refused = new Map()
for (const item of items) {
- if (rejected.has(item.itemId)) {
+ if (withdrawn.has(item.itemId)) {
refused.set(item.itemId, item)
} else {
visibleItems.push(item)
@@ -42,7 +53,9 @@ export function projectStructuredAgentSessionMessages(
const delivered: NativeChatMessage[] = []
const held: NativeChatMessage[] = []
for (const message of projectItems(visibleItems)) {
- if (queued.has(message.id)) {
+ if (notSent.has(message.id)) {
+ delivered.push({ ...message, unsent: true })
+ } else if (queued.has(message.id)) {
held.push({ ...message, queued: true })
} else {
delivered.push(message)
diff --git a/src/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts
index 98ea2ff8b676..5514d422015f 100644
--- a/src/shared/structured-agent-session-send-disposition.test.ts
+++ b/src/shared/structured-agent-session-send-disposition.test.ts
@@ -411,6 +411,33 @@ describe('ambiguous operation refusals', () => {
expect(disposition.entries).toMatchObject([{ clientMessageId: 'fresh-id', state: 'rejected' }])
})
+ it("parks an older host's restart answer that carries the reason but not the recovered marker", () => {
+ const result = rejectedWith(null)
+ if (!result.ok || !('submission' in result.value)) {
+ throw new Error('expected a send result')
+ }
+ result.value.submission = {
+ ...result.value.submission,
+ dispatchState: 'unknown',
+ reason: 'host_restarted_before_acknowledgement'
+ }
+
+ const disposition = disposeStructuredAgentSessionSendResult({
+ entries: [entry],
+ entry,
+ result,
+ createOperationId: () => 'unused'
+ })
+
+ expect(disposition.entries).toMatchObject([
+ {
+ clientMessageId: entry.clientMessageId,
+ state: 'unconfirmed',
+ retryAfterUnknownSubmittedAt: -1
+ }
+ ])
+ })
+
it('parks a recovered missing submission without polling forever', () => {
const result = rejectedWith(null)
if (!result.ok || !('submission' in result.value)) {
diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts
index f717e54f2d43..a8f68057fd59 100644
--- a/src/shared/structured-agent-session-send-disposition.ts
+++ b/src/shared/structured-agent-session-send-disposition.ts
@@ -22,6 +22,7 @@ import {
import type { AgentSessionFailureFact } from './agent-session-failure'
import type { AgentSessionFailureWordsContext } from './agent-session-failure-words'
import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection'
+import { isRecoveredStructuredAgentSessionSubmission } from './structured-agent-session-unanswered-dispatch'
import {
classifyStructuredAgentSessionSendFailure,
requeueStructuredAgentSessionSendRefusal,
@@ -277,7 +278,7 @@ export function disposeStructuredAgentSessionSendResult(
error: null
}
}
- if (submission.dispatchState === 'unknown' && submission.recovered) {
+ if (isRecoveredStructuredAgentSessionSubmission(submission)) {
return {
entries: input.entries.map((candidate) =>
candidate.clientMessageId === input.entry.clientMessageId
diff --git a/src/shared/structured-agent-session-unanswered-dispatch.test.ts b/src/shared/structured-agent-session-unanswered-dispatch.test.ts
new file mode 100644
index 000000000000..39a67f880e71
--- /dev/null
+++ b/src/shared/structured-agent-session-unanswered-dispatch.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest'
+import type { AgentJournalSubmission } from './agent-session-journal-types'
+import {
+ isRecoveredStructuredAgentSessionSubmission,
+ isUnansweredStructuredAgentSessionDispatch
+} from './structured-agent-session-unanswered-dispatch'
+
+function submission(patch: Partial): AgentJournalSubmission {
+ return {
+ clientMessageId: 'client-1',
+ fence: 1,
+ payloadFingerprint: 'fingerprint',
+ dispatchState: 'unknown',
+ providerItemId: null,
+ reason: null,
+ submittedAt: 1,
+ resolvedAt: 1,
+ ...patch
+ }
+}
+
+describe('a send whose outcome the host lost for good', () => {
+ it('is one the host marked recovered', () => {
+ const row = submission({ recovered: true, reason: 'provider_exited_before_acknowledgement' })
+ expect(isRecoveredStructuredAgentSessionSubmission(row)).toBe(true)
+ expect(isUnansweredStructuredAgentSessionDispatch(row)).toBe(false)
+ })
+
+ it("is an older host's restart row, which carries the reason without the marker", () => {
+ const row = submission({ reason: 'host_restarted_before_acknowledgement' })
+ expect(isRecoveredStructuredAgentSessionSubmission(row)).toBe(true)
+ expect(isUnansweredStructuredAgentSessionDispatch(row)).toBe(false)
+ })
+
+ it('is never a live unknown, which something still running may answer', () => {
+ const row = submission({ reason: 'provider_ack_ambiguous' })
+ expect(isRecoveredStructuredAgentSessionSubmission(row)).toBe(false)
+ expect(isUnansweredStructuredAgentSessionDispatch(row)).toBe(true)
+ })
+
+ it('is never a settled send, whatever its reason says', () => {
+ expect(
+ isRecoveredStructuredAgentSessionSubmission(
+ submission({ dispatchState: 'rejected', reason: 'host_restarted_before_acknowledgement' })
+ )
+ ).toBe(false)
+ })
+})
diff --git a/src/shared/structured-agent-session-unanswered-dispatch.ts b/src/shared/structured-agent-session-unanswered-dispatch.ts
index 412b3e981968..2113525564d3 100644
--- a/src/shared/structured-agent-session-unanswered-dispatch.ts
+++ b/src/shared/structured-agent-session-unanswered-dispatch.ts
@@ -1,6 +1,19 @@
import type { AgentJournalSubmission } from './agent-session-journal-types'
import { isQueuedAgentJournalSubmission } from './agent-session-queued-submission'
+/** A send whose outcome the host lost for good when the process that sent it went away (a restart,
+ * a provider exit, an idle release): nothing still running can answer it. */
+export function isRecoveredStructuredAgentSessionSubmission(
+ submission: Pick
+): boolean {
+ return (
+ submission.dispatchState === 'unknown' &&
+ (submission.recovered === true ||
+ // Older hosts publish the recovery reason but omit the optional marker.
+ submission.reason === 'host_restarted_before_acknowledgement')
+ )
+}
+
/** One send the provider has neither opened a turn for nor refused; the rule is explained on
* `hasUnansweredStructuredAgentSessionDispatch`, which asks it of every send. */
export function isUnansweredStructuredAgentSessionDispatch(
@@ -15,9 +28,7 @@ export function isUnansweredStructuredAgentSessionDispatch(
(currentFence == null || submission.fence >= currentFence) &&
(submission.dispatchState === 'pending' ||
(submission.dispatchState === 'unknown' &&
- submission.recovered !== true &&
- // Older hosts publish the recovery reason but omit the optional marker.
- submission.reason !== 'host_restarted_before_acknowledgement'))
+ !isRecoveredStructuredAgentSessionSubmission(submission)))
)
}
From 1b0f2a7b5820c846f9250fe87850fb144a6f54e9 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 17:37:46 -0700
Subject: [PATCH 02/34] fix(native-chat): a not-delivered message is said once,
on its row, on the phone too
The phone's banner repeated what the recorded message's row now says. A recorded
rejection's words move to one shared function: the desktop row translates them,
the phone row shows them in English, and a start-failure row that already says
why leaves only "not sent". The phone's banner keeps only a Stop's withdrawal,
which has no row.
---
.../session/MobileNativeChatMessage.test.ts | 16 +++-
.../src/session/MobileNativeChatMessage.tsx | 11 ++-
mobile/src/session/MobileNativeChatView.tsx | 8 +-
.../mobile-native-chat-unsent-notices.test.ts | 74 ++++++++++++++++
.../mobile-native-chat-unsent-notices.ts | 37 ++++++++
.../mobile-structured-send-delivery.test.ts | 26 +++---
.../mobile-structured-send-delivery.ts | 7 +-
...red-agent-session-delivery-notices.test.ts | 6 +-
...ructured-agent-session-delivery-notices.ts | 86 +++----------------
.../structured-conversation-command-send.ts | 2 +-
...tured-agent-session-start-failure-facts.ts | 2 +-
.../use-structured-agent-session.ts | 2 +-
src/shared/native-chat-types.ts | 4 +-
...-agent-session-recorded-rejection-words.ts | 78 +++++++++++++++++
14 files changed, 255 insertions(+), 104 deletions(-)
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
create mode 100644 src/shared/structured-agent-session-recorded-rejection-words.ts
diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts
index e7e9ae80a6f1..d96d26c3100e 100644
--- a/mobile/src/session/MobileNativeChatMessage.test.ts
+++ b/mobile/src/session/MobileNativeChatMessage.test.ts
@@ -70,6 +70,7 @@ describe('MobileNativeChatMessage', () => {
workedSeconds: number | null
} | null
onToggleTurn?: () => void
+ unsentNotice?: string
} = {}
): ReactTestRenderer {
act(() => {
@@ -107,9 +108,20 @@ describe('MobileNativeChatMessage', () => {
expect(texts.some((text) => text.includes('/tmp/host.png'))).toBe(true)
})
- it('says under a message the host recorded but never delivered that it was not sent', () => {
+ it('says under a message the host recorded but never delivered why it was not sent', () => {
+ const tree = render(
+ { ...userMessage([{ type: 'text', text: 'hello' }]), unsent: true },
+ { unsentNotice: "Orca couldn't reach the agent. Your message was not sent." }
+ )
+ expect(textIn(tree.root)).toEqual([
+ 'hello',
+ "Orca couldn't reach the agent. Your message was not sent."
+ ])
+ })
+
+ it('still says it was not sent when no words for it are loaded', () => {
const tree = render({ ...userMessage([{ type: 'text', text: 'hello' }]), unsent: true })
- expect(textIn(tree.root)).toEqual(['hello', 'Message not sent'])
+ expect(textIn(tree.root)).toEqual(['hello', 'Your message was not sent.'])
})
it('says nothing more under a delivered message', () => {
diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx
index 01b9dd465c5a..4e7ebd76abe7 100644
--- a/mobile/src/session/MobileNativeChatMessage.tsx
+++ b/mobile/src/session/MobileNativeChatMessage.tsx
@@ -11,6 +11,7 @@ import { ToolRun } from './MobileNativeChatToolRun'
import type { NativeChatTurnStatus } from './use-mobile-native-chat-turn-status'
import { isRenderableImageUri } from './mobile-native-chat-image-preview'
import { styles, TEXT_SIZE } from './mobile-native-chat-message-styles'
+import { AGENT_SESSION_WRITE_NOTICE_COPY } from '../../../src/shared/agent-session-write-notice-copy'
function Prose({
block,
@@ -76,7 +77,8 @@ function MobileNativeChatMessageImpl({
turnKey,
onToggleTurn,
activeTurnIsWorking,
- structuredActivityUi = false
+ structuredActivityUi = false,
+ unsentNotice
}: {
message: NativeChatMessage
toolsExpanded?: boolean
@@ -97,6 +99,8 @@ function MobileNativeChatMessageImpl({
activeTurnIsWorking?: boolean
/** Structured lane only: live tool progress plus the turn-status disclosure. */
structuredActivityUi?: boolean
+ /** Why the host did not deliver this message, when it is shown as not sent. */
+ unsentNotice?: string
}): React.JSX.Element {
const isUser = message.role === 'user'
const isReasoning = message.role === 'reasoning'
@@ -158,9 +162,10 @@ function MobileNativeChatMessageImpl({
/>
) : null}
- {/* The host recorded it and never delivered it; the desktop row words the same fact. */}
{isUser && message.unsent === true ? (
- Message not sent
+
+ {unsentNotice ?? AGENT_SESSION_WRITE_NOTICE_COPY.notDoneSend}
+
) : null}
{turnStatusAbove ? null : statusRow}
diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx
index 469abba53880..e8c45dad398f 100644
--- a/mobile/src/session/MobileNativeChatView.tsx
+++ b/mobile/src/session/MobileNativeChatView.tsx
@@ -40,6 +40,7 @@ import type { MobileChatPermission } from './mobile-native-chat-permission'
import type { MobileChatQuestion } from './mobile-native-chat-question'
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
+import { mobileNativeChatUnsentNotices } from './mobile-native-chat-unsent-notices'
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
/** Why the composer input is locked: the transport is disconnected, or the
@@ -280,6 +281,10 @@ export function MobileNativeChatView({
const hasPendingStructuredInteraction =
structuredActivityUi && (ask != null || permission != null || question != null)
+ const unsentNotices = useMemo(
+ () => mobileNativeChatUnsentNotices(turnJournal, agent ?? null),
+ [turnJournal, agent]
+ )
const renderItem = useCallback(
({ item, index }: { item: NativeChatMessage; index: number }) => (
),
- [toolsExpanded, fontScale, onOpenFile, structuredActivityUi, turns]
+ [toolsExpanded, fontScale, onOpenFile, structuredActivityUi, turns, unsentNotices]
)
const liveStatus =
diff --git a/mobile/src/session/mobile-native-chat-unsent-notices.test.ts b/mobile/src/session/mobile-native-chat-unsent-notices.test.ts
new file mode 100644
index 000000000000..5f4b503e06de
--- /dev/null
+++ b/mobile/src/session/mobile-native-chat-unsent-notices.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from 'vitest'
+import type { AgentSessionFailureFact } from '../../../src/shared/agent-session-failure'
+import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words'
+import {
+ agentJournalItemKey,
+ agentJournalSubmissionKey
+} from '../../../src/shared/agent-session-journal-item-key'
+import type {
+ AgentJournalRenderItem,
+ AgentJournalSubmission
+} from '../../../src/shared/agent-session-journal-types'
+import { structuredAgentSessionStartFailureRowIdentity } from '../../../src/shared/structured-agent-session-start-failure-row-key'
+import { mobileNativeChatUnsentNotices } from './mobile-native-chat-unsent-notices'
+
+function rejected(patch: Partial = {}): AgentJournalSubmission {
+ return {
+ clientMessageId: 'sent-elsewhere',
+ fence: 1,
+ payloadFingerprint: 'fingerprint',
+ dispatchState: 'rejected',
+ providerItemId: null,
+ reason: 'provider_write_failed: broken pipe',
+ submittedAt: 1,
+ resolvedAt: 1,
+ ...patch
+ }
+}
+
+describe('the line under a message the host recorded and did not deliver', () => {
+ it("says why, in the host's words, keyed by the message's row", () => {
+ const notices = mobileNativeChatUnsentNotices(
+ { items: [], submissions: [rejected()] },
+ 'claude'
+ )
+ expect(notices.get(agentJournalSubmissionKey('sent-elsewhere'))).toBe(
+ "Orca couldn't reach the agent. Your message was not sent."
+ )
+ })
+
+ it('says only that it was not sent when the start-failure row already says why', () => {
+ const startFailed: AgentSessionFailureFact = { kind: 'startFailed' }
+ const startRow: AgentJournalRenderItem = {
+ itemId: agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('gen')),
+ revision: 1,
+ sequence: 1,
+ observedAt: 1,
+ body: {
+ kind: 'status',
+ tone: 'error',
+ ...agentSessionFailureWords(startFailed, { agentName: 'Claude', surface: 'row' })
+ }
+ }
+ const notices = mobileNativeChatUnsentNotices(
+ {
+ items: [startRow],
+ submissions: [rejected({ reason: 'Written by the host.', rejection: startFailed })]
+ },
+ 'claude'
+ )
+ expect(notices.get(agentJournalSubmissionKey('sent-elsewhere'))).toBe(
+ 'Your message was not sent.'
+ )
+ })
+
+ it('has nothing to say when no send was rejected', () => {
+ expect(
+ mobileNativeChatUnsentNotices(
+ { items: [], submissions: [rejected({ dispatchState: 'accepted' })] },
+ 'claude'
+ ).size
+ ).toBe(0)
+ expect(mobileNativeChatUnsentNotices(null, 'claude').size).toBe(0)
+ })
+})
diff --git a/mobile/src/session/mobile-native-chat-unsent-notices.ts b/mobile/src/session/mobile-native-chat-unsent-notices.ts
new file mode 100644
index 000000000000..d04cc894012e
--- /dev/null
+++ b/mobile/src/session/mobile-native-chat-unsent-notices.ts
@@ -0,0 +1,37 @@
+// The line under each message the host recorded and then did not deliver: the desktop row's words,
+// in English. Only the transcript says it, so the phone's own send never repeats it in its banner.
+
+import { formatAgentTypeLabel } from '../../../src/shared/agent-type-label'
+import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key'
+import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice'
+import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership'
+import {
+ structuredAgentSessionRecordedRejectionParts,
+ structuredAgentSessionStartFailureFacts
+} from '../../../src/shared/structured-agent-session-recorded-rejection-words'
+
+const NO_NOTICES: ReadonlyMap = new Map()
+
+/** Keyed by the message id the transcript renders each recorded send under. */
+export function mobileNativeChatUnsentNotices(
+ journal: NativeChatTurnJournal | null,
+ agent: string | null
+): ReadonlyMap {
+ const rejected = journal?.submissions.filter((row) => row.dispatchState === 'rejected') ?? []
+ if (!journal || rejected.length === 0) {
+ return NO_NOTICES
+ }
+ const startFailures = structuredAgentSessionStartFailureFacts(journal.items)
+ const context = {
+ retryControl: false,
+ ...(agent ? { agentName: formatAgentTypeLabel(agent) } : {})
+ }
+ return new Map(
+ rejected.map((submission) => [
+ agentJournalSubmissionKey(submission.clientMessageId),
+ agentSessionWriteNoticeEnglish(
+ structuredAgentSessionRecordedRejectionParts(submission, context, startFailures)
+ )
+ ])
+ )
+}
diff --git a/mobile/src/session/mobile-structured-send-delivery.test.ts b/mobile/src/session/mobile-structured-send-delivery.test.ts
index 6305bf75df57..bad17106e162 100644
--- a/mobile/src/session/mobile-structured-send-delivery.test.ts
+++ b/mobile/src/session/mobile-structured-send-delivery.test.ts
@@ -112,26 +112,26 @@ describe('mobileStructuredSendDelivery', () => {
}
})
- it('spends the id of a rejection and withholds its internal reason', () => {
+ it('spends the id of a recorded rejection and leaves saying it to its row in the chat', () => {
// Provably undelivered and terminal, so the id can only replay it: spending the
- // id makes the retry a first delivery. The marker itself names nothing a person
- // can act on, so it must not reach the screen.
- expect(
- mobileStructuredSendDelivery(accepted('rejected', 'provider_write_failed: broken pipe'))
- ).toEqual({
- outcome: 'rejected',
- operationIdSpent: true,
- error: "Orca couldn't reach the agent. Your message was not sent. Send it again."
- })
+ // id makes the retry a first delivery. The host recorded the message, so the
+ // transcript shows it as not sent, with why; a banner would say it twice.
+ for (const reason of ['provider_write_failed: broken pipe', 'Claude does not support .bmp']) {
+ expect(mobileStructuredSendDelivery(accepted('rejected', reason))).toEqual({
+ outcome: 'rejected',
+ operationIdSpent: true,
+ error: null
+ })
+ }
})
- it('shows a provider content rejection verbatim', () => {
+ it('says a send a Stop withdrew was not sent: no row in the chat says it', () => {
expect(
- mobileStructuredSendDelivery(accepted('rejected', 'Claude does not support .bmp'))
+ mobileStructuredSendDelivery(accepted('rejected', 'provider_cancelled_before_start'))
).toEqual({
outcome: 'rejected',
operationIdSpent: true,
- error: 'Claude does not support .bmp'
+ error: 'Your message was not sent. Send it again.'
})
})
diff --git a/mobile/src/session/mobile-structured-send-delivery.ts b/mobile/src/session/mobile-structured-send-delivery.ts
index 466a0d29ff8a..55961aead50b 100644
--- a/mobile/src/session/mobile-structured-send-delivery.ts
+++ b/mobile/src/session/mobile-structured-send-delivery.ts
@@ -29,6 +29,7 @@ import type { AgentJournalSubmission } from '../../../src/shared/agent-session-j
import type { AgentSessionSendResult } from '../../../src/shared/agent-session-wire'
import { agentSessionRefusalOperationState } from '../../../src/shared/agent-session-refusal-retry'
import { structuredAgentSessionRejectionNotice } from '../../../src/shared/structured-agent-session-send-disposition'
+import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import type { StructuredAgentSessionMutationCallResult } from './mobile-structured-agent-session-rpc'
@@ -90,7 +91,11 @@ export function mobileStructuredSendDelivery(
return {
outcome: 'rejected',
operationIdSpent: true,
- error: structuredAgentSessionRejectionNotice(submission.reason, 'composer-send')
+ // The host recorded it, so its row in the chat says it was not sent and why; only a Stop's
+ // withdrawal leaves no row to say it.
+ error: dispatchWasWithdrawn(submission)
+ ? structuredAgentSessionRejectionNotice(submission.reason, 'composer-send')
+ : null
}
}
if (retained) {
diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts
index b2464e13e37f..14f64df1ccee 100644
--- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts
+++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts
@@ -15,10 +15,8 @@ import {
agentJournalSubmissionKey
} from '../../../../shared/agent-session-journal-item-key'
import { structuredAgentSessionStartFailureRowIdentity } from '../../../../shared/structured-agent-session-start-failure-row-key'
-import {
- structuredAgentSessionDeliveryNotices,
- structuredAgentSessionStartFailureFacts
-} from './structured-agent-session-delivery-notices'
+import { structuredAgentSessionStartFailureFacts } from '../../../../shared/structured-agent-session-recorded-rejection-words'
+import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices'
function entry(
clientMessageId: string,
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 912372f1a70e..8ec1de072594 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
@@ -14,20 +14,14 @@
// gone) is worded from the journal alone, with no Retry: this client holds nothing to send.
import {
- readAgentSessionFailureFact,
readWholeAgentSessionFailureFact,
type AgentSessionFailureFact
} from '../../../../shared/agent-session-failure'
import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key'
-import type {
- AgentJournalRenderItem,
- AgentJournalSubmission
-} from '../../../../shared/agent-session-journal-types'
+import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
import { agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice'
-import { isStructuredAgentSessionStartFailureRow } from '../../../../shared/structured-agent-session-start-failure-row-key'
import {
structuredAgentSessionEntryIdExpired,
- structuredAgentSessionRejectedFailure,
type StructuredAgentSessionOutboxEntry
} from '../../../../shared/structured-agent-session-outbox'
import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection'
@@ -37,56 +31,14 @@ import {
} from '../../../../shared/structured-agent-session-outbox-admission'
import type { AgentSessionFailureWordsContext } from '../../../../shared/agent-session-failure-words'
import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition'
+import {
+ agentSessionFailureStatedByStartRow,
+ structuredAgentSessionRecordedRejectionParts
+} from '../../../../shared/structured-agent-session-recorded-rejection-words'
import { translate } from '@/i18n/i18n'
import { agentSessionWriteNoticeText } from './agent-session-write-notice-text'
import type { NativeChatDeliveryNotice } from './NativeChatMessageRow'
-/** The facts the chat's loaded start-failure rows state. */
-export function structuredAgentSessionStartFailureFacts(
- items: readonly AgentJournalRenderItem[]
-): AgentSessionFailureFact[] {
- const facts: AgentSessionFailureFact[] = []
- for (const item of items) {
- if (item.body.kind === 'status' && isStructuredAgentSessionStartFailureRow(item.itemId)) {
- const fact = readAgentSessionFailureFact(item.body.failure)
- if (fact) {
- facts.push(fact)
- }
- }
- }
- return facts
-}
-
-/** Whether two facts are one failure: a start's row and the messages it rejected share one. */
-export function sameAgentSessionFailureFact(
- a: AgentSessionFailureFact,
- b: AgentSessionFailureFact
-): boolean {
- return (
- a.kind === b.kind &&
- a.detail?.text === b.detail?.text &&
- a.detail?.audience === b.detail?.audience &&
- a.refusal?.code === b.refusal?.code &&
- a.refusal?.details?.reason === b.refusal?.details?.reason &&
- a.attachment?.reason === b.attachment?.reason &&
- a.attachment?.limit === b.attachment?.limit &&
- a.retry?.error === b.retry?.error &&
- a.retry?.status === b.retry?.status
- )
-}
-
-/** Whether a loaded start-failure row already states this failure. Matching is identity, not
- * wording: what this build can read is enough. */
-export function agentSessionFailureStatedByStartRow(
- failure: unknown,
- startFailures: readonly AgentSessionFailureFact[]
-): boolean {
- const fact = readAgentSessionFailureFact(failure)
- return (
- fact !== undefined && startFailures.some((stated) => sameAgentSessionFailureFact(stated, fact))
- )
-}
-
function deliveryNoticeText(
entry: StructuredAgentSessionOutboxEntry,
context: AgentSessionFailureWordsContext,
@@ -132,24 +84,6 @@ function deliveryNoticeText(
)
}
-/** What a recorded rejection's own row says when no outbox entry here carries it. */
-function recordedRejectionNoticeText(
- submission: AgentJournalSubmission,
- context: AgentSessionFailureWordsContext,
- startFailures: readonly AgentSessionFailureFact[]
-): string {
- if (agentSessionFailureStatedByStartRow(submission.rejection, startFailures)) {
- return agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send'))
- }
- return agentSessionWriteNoticeText(
- structuredAgentSessionAttemptFailureParts(
- structuredAgentSessionRejectedFailure(submission),
- context,
- readWholeAgentSessionFailureFact(submission.rejection)
- )
- )
-}
-
/** Keyed by the message id the transcript renders each entry under; `agentName` is the chat's
* agent, for the words. */
export function structuredAgentSessionDeliveryNotices(
@@ -197,10 +131,12 @@ export function structuredAgentSessionDeliveryNotices(
const id = agentJournalSubmissionKey(submission.clientMessageId)
if (!notices.has(id) && !dispatchWasWithdrawn(submission)) {
notices.set(id, {
- text: recordedRejectionNoticeText(
- submission,
- { agentName, retryControl: false },
- startFailures
+ text: agentSessionWriteNoticeText(
+ structuredAgentSessionRecordedRejectionParts(
+ submission,
+ { agentName, retryControl: false },
+ startFailures
+ )
)
})
}
diff --git a/src/renderer/src/components/native-chat/structured-conversation-command-send.ts b/src/renderer/src/components/native-chat/structured-conversation-command-send.ts
index 0a351db917f1..666715d265b9 100644
--- a/src/renderer/src/components/native-chat/structured-conversation-command-send.ts
+++ b/src/renderer/src/components/native-chat/structured-conversation-command-send.ts
@@ -10,7 +10,7 @@ import {
import { agentSessionFailureSentence } from '../../../../shared/agent-session-failure-words'
import { translate } from '@/i18n/i18n'
import { sayAgentSessionFailureTranslated } from './agent-session-failure-words-text'
-import { agentSessionFailureStatedByStartRow } from './structured-agent-session-delivery-notices'
+import { agentSessionFailureStatedByStartRow } from '../../../../shared/structured-agent-session-recorded-rejection-words'
import type { StructuredAgentSessionWriteOutcome } from './use-structured-agent-session-mutate'
export async function sendStructuredConversationCommand(input: {
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts
index 83f20103de11..595d68bffa46 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts
@@ -4,7 +4,7 @@ import type { AgentJournalRenderItem } from '../../../../shared/agent-session-jo
import {
sameAgentSessionFailureFact,
structuredAgentSessionStartFailureFacts
-} from './structured-agent-session-delivery-notices'
+} from '../../../../shared/structured-agent-session-recorded-rejection-words'
const NO_FACTS: readonly AgentSessionFailureFact[] = []
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts
index 96ad805560d9..0f10c1ded2bf 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts
@@ -36,7 +36,7 @@ import { useStructuredAgentSessionContextUsage } from './use-structured-agent-se
import { useStructuredAgentSessionRailOutline } from './use-structured-agent-session-rail-outline'
import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages'
import { outboxOutsideQueuedCards } from './structured-agent-session-queued-cards'
-import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-delivery-notices'
+import { structuredAgentSessionStartFailureFacts } from '../../../../shared/structured-agent-session-recorded-rejection-words'
import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership'
export type { StructuredPromptItem } from './structured-agent-session-message-projection'
diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts
index db7b7024cdc1..6f0b68243c00 100644
--- a/src/shared/native-chat-types.ts
+++ b/src/shared/native-chat-types.ts
@@ -212,8 +212,8 @@ export type NativeChatMessage = AgentJournalProducerLinkage & {
sentAs?: AgentJournalMessageSendMode
/** Accepted but not yet handed to the agent: drawn after everything the agent has done. */
queued?: true
- /** Shown as not sent, waiting for the user's Retry: in no turn, so a newer turn's bar and clock
- * never land on it, and drawn after the conversation. */
+ /** Shown as not sent, as the host recorded it or as this client's outbox holds it: in no turn, so
+ * a newer turn's bar and clock never land on it. One the journal never placed is drawn last. */
unsent?: true
/** Set only by the structured projection, on rows the journal holds, and ranks
* them ahead of time. Terminal-backed messages never carry it, and worker reads strip it. */
diff --git a/src/shared/structured-agent-session-recorded-rejection-words.ts b/src/shared/structured-agent-session-recorded-rejection-words.ts
new file mode 100644
index 000000000000..dd3dfe724b6f
--- /dev/null
+++ b/src/shared/structured-agent-session-recorded-rejection-words.ts
@@ -0,0 +1,78 @@
+// What a send the host recorded and then did not deliver says on its own row, on every client.
+// A rejection that is a failed start's, the fact its loaded row states, says only that it was not
+// sent: the row already says why.
+
+import {
+ readAgentSessionFailureFact,
+ readWholeAgentSessionFailureFact,
+ type AgentSessionFailureFact
+} from './agent-session-failure'
+import type { AgentSessionFailureWordsContext } from './agent-session-failure-words'
+import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types'
+import { agentSessionWriteNotDoneParts } from './agent-session-refusal-notice'
+import type { AgentSessionWriteNoticePart } from './agent-session-write-notice-copy'
+import { structuredAgentSessionRejectedFailure } from './structured-agent-session-outbox'
+import { structuredAgentSessionAttemptFailureParts } from './structured-agent-session-send-disposition'
+import { isStructuredAgentSessionStartFailureRow } from './structured-agent-session-start-failure-row-key'
+
+/** The facts the chat's loaded start-failure rows state. */
+export function structuredAgentSessionStartFailureFacts(
+ items: readonly AgentJournalRenderItem[]
+): AgentSessionFailureFact[] {
+ const facts: AgentSessionFailureFact[] = []
+ for (const item of items) {
+ if (item.body.kind === 'status' && isStructuredAgentSessionStartFailureRow(item.itemId)) {
+ const fact = readAgentSessionFailureFact(item.body.failure)
+ if (fact) {
+ facts.push(fact)
+ }
+ }
+ }
+ return facts
+}
+
+/** Whether two facts are one failure: a start's row and the messages it rejected share one. */
+export function sameAgentSessionFailureFact(
+ a: AgentSessionFailureFact,
+ b: AgentSessionFailureFact
+): boolean {
+ return (
+ a.kind === b.kind &&
+ a.detail?.text === b.detail?.text &&
+ a.detail?.audience === b.detail?.audience &&
+ a.refusal?.code === b.refusal?.code &&
+ a.refusal?.details?.reason === b.refusal?.details?.reason &&
+ a.attachment?.reason === b.attachment?.reason &&
+ a.attachment?.limit === b.attachment?.limit &&
+ a.retry?.error === b.retry?.error &&
+ a.retry?.status === b.retry?.status
+ )
+}
+
+/** Whether a loaded start-failure row already states this failure. Matching is identity, not
+ * wording: what this build can read is enough. */
+export function agentSessionFailureStatedByStartRow(
+ failure: unknown,
+ startFailures: readonly AgentSessionFailureFact[]
+): boolean {
+ const fact = readAgentSessionFailureFact(failure)
+ return (
+ fact !== undefined && startFailures.some((stated) => sameAgentSessionFailureFact(stated, fact))
+ )
+}
+
+/** The words for a recorded rejection that no outbox entry on this client carries. */
+export function structuredAgentSessionRecordedRejectionParts(
+ submission: AgentJournalSubmission,
+ context: AgentSessionFailureWordsContext,
+ startFailures: readonly AgentSessionFailureFact[]
+): AgentSessionWriteNoticePart[] {
+ if (agentSessionFailureStatedByStartRow(submission.rejection, startFailures)) {
+ return agentSessionWriteNotDoneParts('send')
+ }
+ return structuredAgentSessionAttemptFailureParts(
+ structuredAgentSessionRejectedFailure(submission),
+ context,
+ readWholeAgentSessionFailureFact(submission.rejection)
+ )
+}
From 64ab6f4d939eca871ae628f86a2bfbc7647a36ac Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 17:43:44 -0700
Subject: [PATCH 03/34] test(native-chat): a Retry of a recorded rejection
leaves the not-sent original in place
---
...ured-agent-session-message-projection.test.ts | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts
index 2b9ba38f897c..8f171c57e303 100644
--- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts
+++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts
@@ -99,6 +99,22 @@ describe('structured agent session message projection', () => {
])
})
+ it("keeps the not-sent original when the sender's Retry sends it again as a new message", () => {
+ const rejected = { ...submission(0), dispatchState: 'rejected' as const, providerItemId: null }
+ const refusedItem = { ...item(0), itemId: agentJournalSubmissionKey(rejected.clientMessageId) }
+ const resend = createStructuredAgentSessionOutboxEntry({
+ clientMessageId: 'rotated-id',
+ sessionId: 'session-1',
+ text: 'send 0',
+ attachments: [],
+ queuedAt: 2
+ })
+ expect(projectStructuredAgentSessionMessages([refusedItem], [resend], [rejected])).toEqual([
+ expect.objectContaining({ id: refusedItem.itemId, unsent: true }),
+ expect.objectContaining({ id: agentJournalSubmissionKey('rotated-id') })
+ ])
+ })
+
it.each([5, 10])('renders %i rapid accepted desktop sends exactly once', (sendCount) => {
const outbox = Array.from({ length: sendCount }, (_, index) =>
createStructuredAgentSessionOutboxEntry({
From 0b9a8a08ddf12f3e272c87cbb14cf0ff34dce0d8 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 18:02:59 -0700
Subject: [PATCH 04/34] revert(native-chat): keep the older-host recovered-send
predicate out of this change
It is unrelated to recorded messages staying in the chat; it moves to its own follow-up.
---
...red-agent-session-send-disposition.test.ts | 27 -----------
...ructured-agent-session-send-disposition.ts | 3 +-
...-agent-session-unanswered-dispatch.test.ts | 48 -------------------
...tured-agent-session-unanswered-dispatch.ts | 17 ++-----
4 files changed, 4 insertions(+), 91 deletions(-)
delete mode 100644 src/shared/structured-agent-session-unanswered-dispatch.test.ts
diff --git a/src/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts
index 5514d422015f..98ea2ff8b676 100644
--- a/src/shared/structured-agent-session-send-disposition.test.ts
+++ b/src/shared/structured-agent-session-send-disposition.test.ts
@@ -411,33 +411,6 @@ describe('ambiguous operation refusals', () => {
expect(disposition.entries).toMatchObject([{ clientMessageId: 'fresh-id', state: 'rejected' }])
})
- it("parks an older host's restart answer that carries the reason but not the recovered marker", () => {
- const result = rejectedWith(null)
- if (!result.ok || !('submission' in result.value)) {
- throw new Error('expected a send result')
- }
- result.value.submission = {
- ...result.value.submission,
- dispatchState: 'unknown',
- reason: 'host_restarted_before_acknowledgement'
- }
-
- const disposition = disposeStructuredAgentSessionSendResult({
- entries: [entry],
- entry,
- result,
- createOperationId: () => 'unused'
- })
-
- expect(disposition.entries).toMatchObject([
- {
- clientMessageId: entry.clientMessageId,
- state: 'unconfirmed',
- retryAfterUnknownSubmittedAt: -1
- }
- ])
- })
-
it('parks a recovered missing submission without polling forever', () => {
const result = rejectedWith(null)
if (!result.ok || !('submission' in result.value)) {
diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts
index a8f68057fd59..f717e54f2d43 100644
--- a/src/shared/structured-agent-session-send-disposition.ts
+++ b/src/shared/structured-agent-session-send-disposition.ts
@@ -22,7 +22,6 @@ import {
import type { AgentSessionFailureFact } from './agent-session-failure'
import type { AgentSessionFailureWordsContext } from './agent-session-failure-words'
import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection'
-import { isRecoveredStructuredAgentSessionSubmission } from './structured-agent-session-unanswered-dispatch'
import {
classifyStructuredAgentSessionSendFailure,
requeueStructuredAgentSessionSendRefusal,
@@ -278,7 +277,7 @@ export function disposeStructuredAgentSessionSendResult(
error: null
}
}
- if (isRecoveredStructuredAgentSessionSubmission(submission)) {
+ if (submission.dispatchState === 'unknown' && submission.recovered) {
return {
entries: input.entries.map((candidate) =>
candidate.clientMessageId === input.entry.clientMessageId
diff --git a/src/shared/structured-agent-session-unanswered-dispatch.test.ts b/src/shared/structured-agent-session-unanswered-dispatch.test.ts
deleted file mode 100644
index 39a67f880e71..000000000000
--- a/src/shared/structured-agent-session-unanswered-dispatch.test.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import type { AgentJournalSubmission } from './agent-session-journal-types'
-import {
- isRecoveredStructuredAgentSessionSubmission,
- isUnansweredStructuredAgentSessionDispatch
-} from './structured-agent-session-unanswered-dispatch'
-
-function submission(patch: Partial): AgentJournalSubmission {
- return {
- clientMessageId: 'client-1',
- fence: 1,
- payloadFingerprint: 'fingerprint',
- dispatchState: 'unknown',
- providerItemId: null,
- reason: null,
- submittedAt: 1,
- resolvedAt: 1,
- ...patch
- }
-}
-
-describe('a send whose outcome the host lost for good', () => {
- it('is one the host marked recovered', () => {
- const row = submission({ recovered: true, reason: 'provider_exited_before_acknowledgement' })
- expect(isRecoveredStructuredAgentSessionSubmission(row)).toBe(true)
- expect(isUnansweredStructuredAgentSessionDispatch(row)).toBe(false)
- })
-
- it("is an older host's restart row, which carries the reason without the marker", () => {
- const row = submission({ reason: 'host_restarted_before_acknowledgement' })
- expect(isRecoveredStructuredAgentSessionSubmission(row)).toBe(true)
- expect(isUnansweredStructuredAgentSessionDispatch(row)).toBe(false)
- })
-
- it('is never a live unknown, which something still running may answer', () => {
- const row = submission({ reason: 'provider_ack_ambiguous' })
- expect(isRecoveredStructuredAgentSessionSubmission(row)).toBe(false)
- expect(isUnansweredStructuredAgentSessionDispatch(row)).toBe(true)
- })
-
- it('is never a settled send, whatever its reason says', () => {
- expect(
- isRecoveredStructuredAgentSessionSubmission(
- submission({ dispatchState: 'rejected', reason: 'host_restarted_before_acknowledgement' })
- )
- ).toBe(false)
- })
-})
diff --git a/src/shared/structured-agent-session-unanswered-dispatch.ts b/src/shared/structured-agent-session-unanswered-dispatch.ts
index 2113525564d3..412b3e981968 100644
--- a/src/shared/structured-agent-session-unanswered-dispatch.ts
+++ b/src/shared/structured-agent-session-unanswered-dispatch.ts
@@ -1,19 +1,6 @@
import type { AgentJournalSubmission } from './agent-session-journal-types'
import { isQueuedAgentJournalSubmission } from './agent-session-queued-submission'
-/** A send whose outcome the host lost for good when the process that sent it went away (a restart,
- * a provider exit, an idle release): nothing still running can answer it. */
-export function isRecoveredStructuredAgentSessionSubmission(
- submission: Pick
-): boolean {
- return (
- submission.dispatchState === 'unknown' &&
- (submission.recovered === true ||
- // Older hosts publish the recovery reason but omit the optional marker.
- submission.reason === 'host_restarted_before_acknowledgement')
- )
-}
-
/** One send the provider has neither opened a turn for nor refused; the rule is explained on
* `hasUnansweredStructuredAgentSessionDispatch`, which asks it of every send. */
export function isUnansweredStructuredAgentSessionDispatch(
@@ -28,7 +15,9 @@ export function isUnansweredStructuredAgentSessionDispatch(
(currentFence == null || submission.fence >= currentFence) &&
(submission.dispatchState === 'pending' ||
(submission.dispatchState === 'unknown' &&
- !isRecoveredStructuredAgentSessionSubmission(submission)))
+ submission.recovered !== true &&
+ // Older hosts publish the recovery reason but omit the optional marker.
+ submission.reason !== 'host_restarted_before_acknowledgement'))
)
}
From 412ab128bc507fc8c598b7799ff0b1aea824c4c1 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 18:07:13 -0700
Subject: [PATCH 05/34] fix(native-chat): the desktop words a not-sent message
it holds no outbox entry for
The pane read the journal's rejection rows only while its own outbox held a
rejected entry, so another window, or the sender after its entry was gone,
drew the host's not-sent message as a plain bubble with no line under it.
It now reads them whenever a row is shown as not sent.
---
...turedSession.start-failure-notice.test.tsx | 52 ++++++++++++++++++-
.../NativeChatStructuredSession.tsx | 5 +-
2 files changed, 53 insertions(+), 4 deletions(-)
diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx
index 6f529c1d3c9f..762f0f90989d 100644
--- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx
@@ -8,7 +8,11 @@ import {
agentJournalItemKey,
agentJournalSubmissionKey
} from '../../../../shared/agent-session-journal-item-key'
-import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
+import type {
+ AgentJournalRenderItem,
+ AgentJournalSubmission
+} from '../../../../shared/agent-session-journal-types'
+import { projectStructuredAgentSessionMessages } from '../../../../shared/structured-agent-session-message-projection'
import { structuredAgentSessionStartFailureRowIdentity } from '../../../../shared/structured-agent-session-start-failure-row-key'
const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () =>
@@ -78,7 +82,7 @@ function rejected(clientMessageId: string, reason: string, rejection: AgentSessi
rejection,
submittedAt: 1,
resolvedAt: 1
- }
+ } satisfies AgentJournalSubmission
}
}
@@ -161,3 +165,47 @@ it("keeps the start failure's own words when its row is not loaded", async () =>
within(await notice('first')).getByText('Claude stopped before it finished starting.')
).toBeTruthy()
})
+
+// Another window, or the sender after its entry is gone: the host's record alone says it.
+it('words a recorded rejection this window holds no outbox entry for, with no Retry', async () => {
+ const userRow = (clientMessageId: string, sequence: number): AgentJournalRenderItem => ({
+ itemId: agentJournalSubmissionKey(clientMessageId),
+ revision: 1,
+ sequence,
+ observedAt: sequence,
+ body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }
+ })
+ const providerRejected: AgentSessionFailureFact = {
+ kind: 'providerRejected',
+ detail: { text: 'Image type .bmp', audience: 'person' }
+ }
+ mocks.journalItems = [startFailureRow(START_FAILED), userRow('stated', 2), userRow('other', 3)]
+ const submissions = [
+ rejected('stated', START_FAILED_REASON, START_FAILED).submission,
+ rejected(
+ 'other',
+ 'The provider did not accept this message: Image type .bmp.',
+ providerRejected
+ ).submission
+ ]
+ mocks.submissions = submissions
+ mocks.messages = projectStructuredAgentSessionMessages(mocks.journalItems, [], submissions)
+ render(
+
+ )
+
+ expect(within(await notice('stated')).getByText('Your message was not sent.')).toBeTruthy()
+ expect(
+ within(await notice('other')).getByText(
+ 'The provider did not accept this message: Image type .bmp.'
+ )
+ ).toBeTruthy()
+ expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
+})
diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
index 6b79c8a7fc89..48e24f4d4649 100644
--- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
@@ -122,8 +122,9 @@ export function NativeChatStructuredSession(
retryRef.current(clientMessageId)
}, [])
const agentLabel = structuredAgentLabel(props.agent === 'codex' ? 'codex' : 'claude')
- // Only a rejected message reads the journal's rows, so a new batch of them re-renders no row else.
- const hasRejected = controller.outbox.some((entry) => entry.state === 'rejected')
+ // Only a row shown as not sent reads the journal's rows, so a new batch of them re-renders no row
+ // else. Read from the transcript, not this window's outbox: the host's record alone shows one.
+ const hasRejected = controller.messages.some((message) => message.unsent === true)
const rejectionRows = hasRejected ? controller.submissions : NO_SUBMISSIONS
const startFailures = useStructuredAgentSessionStartFailureFacts(
controller.journalItems,
From c3ce6bcd7f36b0e70072a7487ccc09e990d0d792 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 18:08:39 -0700
Subject: [PATCH 06/34] fix(native-chat): a failed /compact or queued draft is
said once, where it already is
A rejected /compact drew as a not-sent "/compact" message while its reply or
its turn's "Compaction failed" row already said it failed, so the failure
showed twice. A rejected hand-off of a queued draft drew as a not-sent bubble
while the draft's card kept the same text. Both stay hidden as before: the
command entry is read from the journal's own item, the hand-off from its
queued-draft link.
---
...d-agent-session-message-projection.test.ts | 131 +++++++++++++++++-
...ctured-agent-session-message-projection.ts | 15 +-
2 files changed, 139 insertions(+), 7 deletions(-)
diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts
index 8f171c57e303..27a0d68584c8 100644
--- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts
+++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts
@@ -3,7 +3,14 @@ import type {
AgentJournalRenderItem,
AgentJournalSubmission
} from '../../../../shared/agent-session-journal-types'
-import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key'
+import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure'
+import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words'
+import {
+ agentJournalItemKey,
+ agentJournalSubmissionKey
+} from '../../../../shared/agent-session-journal-item-key'
+import { agentJournalTurnBody } from '../../../../shared/agent-session-turn-record'
+import { projectStructuredAgentSessionMessages as projectForPhone } from '../../../../shared/structured-agent-session-message-projection'
import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection'
@@ -67,7 +74,7 @@ describe('structured agent session message projection', () => {
])
})
- it('leaves out a send a Stop withdrew: its text went back to its sender', () => {
+ it('leaves out a send the user withdrew with Stop', () => {
const withdrawn = {
...submission(0),
dispatchState: 'rejected' as const,
@@ -115,6 +122,126 @@ describe('structured agent session message projection', () => {
])
})
+ // The command's reply (blocked) or its turn's result row (refused) already says it failed.
+ const busy = { text: 'thread busy', audience: 'person' as const }
+ it.each<{
+ name: string
+ rejection: AgentSessionFailureFact
+ resultRow?: AgentSessionFailureFact
+ }>([
+ { name: 'blocked at handover', rejection: { kind: 'commandRefused' } },
+ {
+ name: 'refused by the provider',
+ rejection: { kind: 'providerRejected', detail: busy },
+ resultRow: { kind: 'compactionFailed', detail: busy }
+ }
+ ])(
+ 'says a /compact $name failed only where the command reports it',
+ ({ rejection, resultRow }) => {
+ const opensTurn = resultRow !== undefined
+ const compact = {
+ ...submission(0),
+ dispatchState: 'rejected' as const,
+ providerItemId: null,
+ rejection
+ }
+ const userItemId = agentJournalSubmissionKey(compact.clientMessageId)
+ const commandItem: AgentJournalRenderItem = {
+ itemId: userItemId,
+ revision: 1,
+ sequence: 0,
+ observedAt: 0,
+ body: {
+ kind: 'message',
+ role: 'user',
+ blocks: [{ type: 'text', text: '/compact' }],
+ command: { name: 'compact' }
+ }
+ }
+ const turnItemId = agentJournalItemKey({
+ provider: 'orca',
+ clientMessageId: 'command-turn:c'
+ })
+ const resultId = agentJournalItemKey({
+ provider: 'orca',
+ clientMessageId: 'command-result:c'
+ })
+ const turnRows: AgentJournalRenderItem[] = resultRow
+ ? [
+ {
+ itemId: turnItemId,
+ revision: 1,
+ sequence: 1,
+ observedAt: 1,
+ body: agentJournalTurnBody({
+ turnId: 'compact:c',
+ state: 'completed',
+ outcome: 'failure',
+ userItemId,
+ requestedAt: 0,
+ startedAt: 1,
+ completedAt: 2
+ })
+ },
+ {
+ itemId: resultId,
+ revision: 1,
+ sequence: 2,
+ observedAt: 2,
+ turnScope: { kind: 'turn', turnItemId },
+ body: {
+ kind: 'status',
+ tone: 'error',
+ ...agentSessionFailureWords(resultRow, { agentName: 'Codex', surface: 'row' })
+ }
+ }
+ ]
+ : []
+ const items = [commandItem, ...turnRows]
+ for (const messages of [
+ projectStructuredAgentSessionMessages(items, [], [compact]),
+ projectForPhone(items, [], [compact])
+ ]) {
+ expect(messages.find((message) => message.id === userItemId)).toBeUndefined()
+ expect(messages.some((message) => message.unsent === true)).toBe(false)
+ expect(messages.some((message) => message.id === resultId)).toBe(opensTurn)
+ }
+ }
+ )
+
+ // The host sends a queued draft under a fresh id per hand-off; its card keeps the text meanwhile.
+ it('leaves a rejected queued-draft hand-off to its card: one bubble once a later hand-off lands', () => {
+ const handOff = (index: number, dispatchState: 'rejected' | 'accepted') => ({
+ ...submission(index),
+ clientMessageId: `handoff-${index}`,
+ queuedMessageId: 'draft-1',
+ dispatchState,
+ ...(dispatchState === 'rejected'
+ ? {
+ providerItemId: null,
+ reason: 'host_restarted',
+ rejection: { kind: 'hostRestarted' as const }
+ }
+ : {})
+ })
+ const handOffRow = (index: number): AgentJournalRenderItem => ({
+ ...item(index),
+ itemId: agentJournalSubmissionKey(`handoff-${index}`),
+ body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'same draft text' }] }
+ })
+ const items = [handOffRow(1), handOffRow(2)]
+ const submissions = [handOff(1, 'rejected'), handOff(2, 'accepted')]
+ for (const messages of [
+ projectStructuredAgentSessionMessages(items, [], submissions),
+ projectForPhone(items, [], submissions)
+ ]) {
+ expect(messages).toEqual([
+ expect.objectContaining({ id: agentJournalSubmissionKey('handoff-2') })
+ ])
+ expect(messages[0]?.unsent).toBeUndefined()
+ }
+ })
+
it.each([5, 10])('renders %i rapid accepted desktop sends exactly once', (sendCount) => {
const outbox = Array.from({ length: sendCount }, (_, index) =>
createStructuredAgentSessionOutboxEntry({
diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts
index 5223b4c84856..7de8a923efdf 100644
--- a/src/shared/structured-agent-session-message-projection.ts
+++ b/src/shared/structured-agent-session-message-projection.ts
@@ -8,6 +8,7 @@ import type { StructuredAgentSessionOutboxEntry } from './structured-agent-sessi
import { structuredAgentSessionEntryHeldForRetry } from './structured-agent-session-outbox-admission'
import { reconcileStructuredAgentSessionOutboxWithQueue } from './structured-agent-session-draft-hand-off'
import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection'
+import { isStructuredAgentSessionCommandEntry } from './structured-agent-session-command-entry'
import { projectStructuredItemsToNativeChat } from './structured-agent-session-projection'
export function projectStructuredAgentSessionMessages(
@@ -19,16 +20,17 @@ export function projectStructuredAgentSessionMessages(
const optimistic = reconcileStructuredAgentSessionOutboxWithQueue(outbox, submissions)
// A send the host recorded and then did not deliver stays in the conversation from the host's
// record, shown as not sent, for every viewer: dropping the sender's outbox entry can never make
- // it vanish. Only a Stop's withdrawal leaves it, since its text went back to its sender.
+ // it vanish. It leaves only where something else owns it: the user withdrew it with Stop, its
+ // queued draft's card keeps the text, or it is a command whose reply or result row says it failed.
const notSent = new Set()
- const withdrawn = new Set()
+ const ownedElsewhere = new Set()
for (const submission of submissions) {
if (submission.dispatchState !== 'rejected') {
continue
}
const key = agentJournalSubmissionKey(submission.clientMessageId)
- if (dispatchWasWithdrawn(submission)) {
- withdrawn.add(key)
+ if (dispatchWasWithdrawn(submission) || submission.queuedMessageId !== undefined) {
+ ownedElsewhere.add(key)
} else {
notSent.add(key)
}
@@ -36,7 +38,10 @@ export function projectStructuredAgentSessionMessages(
const visibleItems: AgentJournalRenderItem[] = []
const refused = new Map()
for (const item of items) {
- if (withdrawn.has(item.itemId)) {
+ if (
+ ownedElsewhere.has(item.itemId) ||
+ (notSent.has(item.itemId) && isStructuredAgentSessionCommandEntry(item.body))
+ ) {
refused.set(item.itemId, item)
} else {
visibleItems.push(item)
From 71e43557bfb38cbd403bdccd5746d02c574f3c95 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 18:09:35 -0700
Subject: [PATCH 07/34] fix(native-chat): the rail stays lit while the reader
is on a not-sent message
A message shown as not sent has no rail tick, but the active-tick rule still
lit its own id, so the rail lit nothing while it was the row being read, for
example at the bottom of a chat ending in one. The tick before it stays lit.
---
.../native-chat-active-rail-item.test.ts | 41 ++++++++++++++++++-
.../native-chat-active-rail-item.ts | 18 +++++---
2 files changed, 52 insertions(+), 7 deletions(-)
diff --git a/src/renderer/src/components/native-chat/native-chat-active-rail-item.test.ts b/src/renderer/src/components/native-chat/native-chat-active-rail-item.test.ts
index 72e4898ce04c..ee55329caa60 100644
--- a/src/renderer/src/components/native-chat/native-chat-active-rail-item.test.ts
+++ b/src/renderer/src/components/native-chat/native-chat-active-rail-item.test.ts
@@ -123,7 +123,46 @@ describe('active rail item', () => {
).toBe('u2')
})
- // A rejected send the journal recorded keeps its place but opens no turn.
+ // A send shown as not sent keeps its place but has no tick, as the rail's items agree.
+ it('keeps the tick before a not-sent row lit, never the row itself', () => {
+ const slots: NativeChatRailSlot[] = [
+ ...TURNS,
+ { turnKey: undefined, message: { id: 'not-sent', role: 'user', unsent: true } },
+ { turnKey: undefined, message: { id: 'not-sent-2', role: 'user', unsent: true } }
+ ]
+ expect(
+ findActiveNativeChatRailItem({
+ slots,
+ virtualItems: rows(12),
+ scrollTop: 900,
+ clientHeight: VIEWPORT,
+ scrollHeight: 1200,
+ previousActiveId: null
+ })
+ ).toBe('u2')
+ expect(
+ findActiveNativeChatRailItem({
+ slots,
+ virtualItems: rows(12),
+ scrollTop: 1010,
+ ...MID_SCROLL,
+ scrollHeight: 2000
+ })
+ ).toBe('u2')
+ expect(
+ findActiveNativeChatRailItem({
+ slots: [
+ { turnKey: undefined, message: { id: 'first', role: 'user', unsent: true } },
+ ...TURNS
+ ],
+ virtualItems: rows(11),
+ scrollTop: 50,
+ ...MID_SCROLL,
+ scrollHeight: 1100
+ })
+ ).toBeNull()
+ })
+
it('lights a prompt in no turn by its own id', () => {
const slots: NativeChatRailSlot[] = [
...TURNS,
diff --git a/src/renderer/src/components/native-chat/native-chat-active-rail-item.ts b/src/renderer/src/components/native-chat/native-chat-active-rail-item.ts
index 5ca6c1584661..9647a55d48fb 100644
--- a/src/renderer/src/components/native-chat/native-chat-active-rail-item.ts
+++ b/src/renderer/src/components/native-chat/native-chat-active-rail-item.ts
@@ -27,11 +27,17 @@ export type NativeChatRailVirtualItem = {
/** Only the fields the rail reads, so a test needs no slot builder. */
export type NativeChatRailSlot = {
turnKey: string | undefined
- /** A user row in no turn (one shown as not sent) lights its own tick. */
- message?: { id: string; role: string }
+ /** A user row in no turn lights its own tick. */
+ message?: { id: string; role: string; unsent?: true }
}
-function railTickOf(slot: NativeChatRailSlot | undefined): string | null {
+/** A row shown as not sent has no tick and no turn, so the tick before it stays lit. */
+function railTickAt(slots: readonly NativeChatRailSlot[], index: number): string | null {
+ let at = index
+ while (slots[at]?.message?.unsent === true) {
+ at -= 1
+ }
+ const slot = slots[at]
return slot?.turnKey ?? (slot?.message?.role === 'user' ? slot.message.id : null)
}
@@ -59,7 +65,7 @@ export function findActiveNativeChatRailItem({
const atBottom = scrollHeight - clientHeight - scrollTop <= NATIVE_CHAT_BOTTOM_THRESHOLD_PX
if (atBottom) {
const last = virtualItems.at(-1)
- return last === undefined ? previousActiveId : railTickOf(slots[last.index])
+ return last === undefined ? previousActiveId : railTickAt(slots, last.index)
}
let fold: NativeChatRailVirtualItem | undefined
@@ -71,12 +77,12 @@ export function findActiveNativeChatRailItem({
// Scrolled above everything the window holds: the first windowed row is the
// nearest thing to the fold.
if (fold === undefined) {
- return railTickOf(slots[virtualItems[0]?.index ?? -1])
+ return railTickAt(slots, virtualItems[0]?.index ?? -1)
}
// The window lags the scroll by a commit, so a fold past every row it holds is
// a stale read, not an answer. Holding the previous tick beats blanking one.
if (fold.end <= scrollTop) {
return previousActiveId
}
- return railTickOf(slots[fold.index])
+ return railTickAt(slots, fold.index)
}
From d9e060543fef9f4a18c1e9f19c05835d86ccba6c Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 18:22:22 -0700
Subject: [PATCH 08/34] docs(native-chat): say a not-sent row lights no tick of
its own
---
.../src/components/native-chat/native-chat-active-rail-item.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/renderer/src/components/native-chat/native-chat-active-rail-item.ts b/src/renderer/src/components/native-chat/native-chat-active-rail-item.ts
index 9647a55d48fb..fc45d2d1d105 100644
--- a/src/renderer/src/components/native-chat/native-chat-active-rail-item.ts
+++ b/src/renderer/src/components/native-chat/native-chat-active-rail-item.ts
@@ -27,7 +27,7 @@ export type NativeChatRailVirtualItem = {
/** Only the fields the rail reads, so a test needs no slot builder. */
export type NativeChatRailSlot = {
turnKey: string | undefined
- /** A user row in no turn lights its own tick. */
+ /** A user row in no turn lights its own tick, unless it is shown as not sent. */
message?: { id: string; role: string; unsent?: true }
}
From 87f2c0f92ad27c49b71807ce07f1e48aa4066e0b Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Sat, 3 Oct 2026 22:46:54 -0700
Subject: [PATCH 09/34] fix(native-chat): read an older host's restart answer
as recovered when sending, too
Hosts from before the recovered marker publish only the restart reason. The
working-state rule already read that reason as recovered; the send answer now
uses the same predicate, so both agree on those hosts.
---
...red-agent-session-send-disposition.test.ts | 27 +++++++++++
...ructured-agent-session-send-disposition.ts | 3 +-
...-agent-session-unanswered-dispatch.test.ts | 48 +++++++++++++++++++
...tured-agent-session-unanswered-dispatch.ts | 17 +++++--
4 files changed, 91 insertions(+), 4 deletions(-)
create mode 100644 src/shared/structured-agent-session-unanswered-dispatch.test.ts
diff --git a/src/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts
index 98ea2ff8b676..5514d422015f 100644
--- a/src/shared/structured-agent-session-send-disposition.test.ts
+++ b/src/shared/structured-agent-session-send-disposition.test.ts
@@ -411,6 +411,33 @@ describe('ambiguous operation refusals', () => {
expect(disposition.entries).toMatchObject([{ clientMessageId: 'fresh-id', state: 'rejected' }])
})
+ it("parks an older host's restart answer that carries the reason but not the recovered marker", () => {
+ const result = rejectedWith(null)
+ if (!result.ok || !('submission' in result.value)) {
+ throw new Error('expected a send result')
+ }
+ result.value.submission = {
+ ...result.value.submission,
+ dispatchState: 'unknown',
+ reason: 'host_restarted_before_acknowledgement'
+ }
+
+ const disposition = disposeStructuredAgentSessionSendResult({
+ entries: [entry],
+ entry,
+ result,
+ createOperationId: () => 'unused'
+ })
+
+ expect(disposition.entries).toMatchObject([
+ {
+ clientMessageId: entry.clientMessageId,
+ state: 'unconfirmed',
+ retryAfterUnknownSubmittedAt: -1
+ }
+ ])
+ })
+
it('parks a recovered missing submission without polling forever', () => {
const result = rejectedWith(null)
if (!result.ok || !('submission' in result.value)) {
diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts
index f717e54f2d43..a8f68057fd59 100644
--- a/src/shared/structured-agent-session-send-disposition.ts
+++ b/src/shared/structured-agent-session-send-disposition.ts
@@ -22,6 +22,7 @@ import {
import type { AgentSessionFailureFact } from './agent-session-failure'
import type { AgentSessionFailureWordsContext } from './agent-session-failure-words'
import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection'
+import { isRecoveredStructuredAgentSessionSubmission } from './structured-agent-session-unanswered-dispatch'
import {
classifyStructuredAgentSessionSendFailure,
requeueStructuredAgentSessionSendRefusal,
@@ -277,7 +278,7 @@ export function disposeStructuredAgentSessionSendResult(
error: null
}
}
- if (submission.dispatchState === 'unknown' && submission.recovered) {
+ if (isRecoveredStructuredAgentSessionSubmission(submission)) {
return {
entries: input.entries.map((candidate) =>
candidate.clientMessageId === input.entry.clientMessageId
diff --git a/src/shared/structured-agent-session-unanswered-dispatch.test.ts b/src/shared/structured-agent-session-unanswered-dispatch.test.ts
new file mode 100644
index 000000000000..39a67f880e71
--- /dev/null
+++ b/src/shared/structured-agent-session-unanswered-dispatch.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest'
+import type { AgentJournalSubmission } from './agent-session-journal-types'
+import {
+ isRecoveredStructuredAgentSessionSubmission,
+ isUnansweredStructuredAgentSessionDispatch
+} from './structured-agent-session-unanswered-dispatch'
+
+function submission(patch: Partial): AgentJournalSubmission {
+ return {
+ clientMessageId: 'client-1',
+ fence: 1,
+ payloadFingerprint: 'fingerprint',
+ dispatchState: 'unknown',
+ providerItemId: null,
+ reason: null,
+ submittedAt: 1,
+ resolvedAt: 1,
+ ...patch
+ }
+}
+
+describe('a send whose outcome the host lost for good', () => {
+ it('is one the host marked recovered', () => {
+ const row = submission({ recovered: true, reason: 'provider_exited_before_acknowledgement' })
+ expect(isRecoveredStructuredAgentSessionSubmission(row)).toBe(true)
+ expect(isUnansweredStructuredAgentSessionDispatch(row)).toBe(false)
+ })
+
+ it("is an older host's restart row, which carries the reason without the marker", () => {
+ const row = submission({ reason: 'host_restarted_before_acknowledgement' })
+ expect(isRecoveredStructuredAgentSessionSubmission(row)).toBe(true)
+ expect(isUnansweredStructuredAgentSessionDispatch(row)).toBe(false)
+ })
+
+ it('is never a live unknown, which something still running may answer', () => {
+ const row = submission({ reason: 'provider_ack_ambiguous' })
+ expect(isRecoveredStructuredAgentSessionSubmission(row)).toBe(false)
+ expect(isUnansweredStructuredAgentSessionDispatch(row)).toBe(true)
+ })
+
+ it('is never a settled send, whatever its reason says', () => {
+ expect(
+ isRecoveredStructuredAgentSessionSubmission(
+ submission({ dispatchState: 'rejected', reason: 'host_restarted_before_acknowledgement' })
+ )
+ ).toBe(false)
+ })
+})
diff --git a/src/shared/structured-agent-session-unanswered-dispatch.ts b/src/shared/structured-agent-session-unanswered-dispatch.ts
index 412b3e981968..2113525564d3 100644
--- a/src/shared/structured-agent-session-unanswered-dispatch.ts
+++ b/src/shared/structured-agent-session-unanswered-dispatch.ts
@@ -1,6 +1,19 @@
import type { AgentJournalSubmission } from './agent-session-journal-types'
import { isQueuedAgentJournalSubmission } from './agent-session-queued-submission'
+/** A send whose outcome the host lost for good when the process that sent it went away (a restart,
+ * a provider exit, an idle release): nothing still running can answer it. */
+export function isRecoveredStructuredAgentSessionSubmission(
+ submission: Pick
+): boolean {
+ return (
+ submission.dispatchState === 'unknown' &&
+ (submission.recovered === true ||
+ // Older hosts publish the recovery reason but omit the optional marker.
+ submission.reason === 'host_restarted_before_acknowledgement')
+ )
+}
+
/** One send the provider has neither opened a turn for nor refused; the rule is explained on
* `hasUnansweredStructuredAgentSessionDispatch`, which asks it of every send. */
export function isUnansweredStructuredAgentSessionDispatch(
@@ -15,9 +28,7 @@ export function isUnansweredStructuredAgentSessionDispatch(
(currentFence == null || submission.fence >= currentFence) &&
(submission.dispatchState === 'pending' ||
(submission.dispatchState === 'unknown' &&
- submission.recovered !== true &&
- // Older hosts publish the recovery reason but omit the optional marker.
- submission.reason !== 'host_restarted_before_acknowledgement'))
+ !isRecoveredStructuredAgentSessionSubmission(submission)))
)
}
From 4f50e4057852bba7f7953508cc76a6ae8e2a9935 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Sat, 3 Oct 2026 22:48:45 -0700
Subject: [PATCH 10/34] fix(mobile): never hand back a message the host
recorded and then rejected
The phone put a rejected message's text back in the composer even when the
host had recorded it, so the same text showed in the chat as not sent and in
the composer. The host's row now holds it, as on the desktop: the send reports
that the host holds the text, so the composer and attachments are not
restored. A message a Stop withdrew still goes back with its notice.
---
mobile/src/session/mobile-native-chat-send.ts | 5 ++--
.../mobile-structured-send-delivery.test.ts | 6 ++--
.../mobile-structured-send-delivery.ts | 17 ++++++-----
...ile-structured-agent-session-send.test.tsx | 28 +++++++++++++++++--
...bile-structured-native-chat-send-bridge.ts | 4 +--
5 files changed, 44 insertions(+), 16 deletions(-)
diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts
index 2a3bc5a60b2a..22d2874b4ee1 100644
--- a/mobile/src/session/mobile-native-chat-send.ts
+++ b/mobile/src/session/mobile-native-chat-send.ts
@@ -29,8 +29,9 @@ type MobileNativeChatSendArgs = {
/** 'unknown' = the RPC failed without proof the request never reached the
* desktop (ack loss after a write, or a cutover that cannot tell whether the
* frame was written) — callers must not present it as a definite send failure.
- * 'queued' = structured lane only: the host holds the message as a queued
- * draft, so it shows as a card above the composer, never a transcript echo. */
+ * 'queued' = structured lane only: the host holds the message, as a queued
+ * draft's card above the composer or as a recorded row in the transcript, so
+ * the client draws no echo and hands no text back. */
export type MobileNativeChatSendOutcome = 'accepted' | 'rejected' | 'unknown' | 'queued'
/** What a terminal write can answer: the PTY lane has no draft queue. */
diff --git a/mobile/src/session/mobile-structured-send-delivery.test.ts b/mobile/src/session/mobile-structured-send-delivery.test.ts
index bad17106e162..2178a8d2756e 100644
--- a/mobile/src/session/mobile-structured-send-delivery.test.ts
+++ b/mobile/src/session/mobile-structured-send-delivery.test.ts
@@ -112,13 +112,13 @@ describe('mobileStructuredSendDelivery', () => {
}
})
- it('spends the id of a recorded rejection and leaves saying it to its row in the chat', () => {
+ it('spends the id of a recorded rejection and leaves the message to its row in the chat', () => {
// Provably undelivered and terminal, so the id can only replay it: spending the
// id makes the retry a first delivery. The host recorded the message, so the
- // transcript shows it as not sent, with why; a banner would say it twice.
+ // transcript holds it and shows it as not sent, with why: no banner, no hand-back.
for (const reason of ['provider_write_failed: broken pipe', 'Claude does not support .bmp']) {
expect(mobileStructuredSendDelivery(accepted('rejected', reason))).toEqual({
- outcome: 'rejected',
+ outcome: 'queued',
operationIdSpent: true,
error: null
})
diff --git a/mobile/src/session/mobile-structured-send-delivery.ts b/mobile/src/session/mobile-structured-send-delivery.ts
index 55961aead50b..a348b55a2c52 100644
--- a/mobile/src/session/mobile-structured-send-delivery.ts
+++ b/mobile/src/session/mobile-structured-send-delivery.ts
@@ -13,7 +13,8 @@
//
// accepted/pending — the send happened. The id is spent; a later identical
// message is a new message and must carry a new id.
-// rejected — a terminal refusal or rejected submission spends a fresh id. A
+// rejected — a terminal refusal or rejected submission spends a fresh id (a
+// recorded one reports `queued`: the host holds its text, not the composer). A
// pending-admission refusal, or any refusal after earlier transport doubt,
// keeps it because neither proves a retained delivery did not happen. The
// one exception is a host that refuses the replay's request shape itself
@@ -87,17 +88,19 @@ export function mobileStructuredSendDelivery(
if (!submission || submission.dispatchState === 'unknown') {
return { outcome: 'unknown', operationIdSpent: false, error: null }
}
- if (submission.dispatchState === 'rejected') {
+ if (submission.dispatchState === 'rejected' && dispatchWasWithdrawn(submission)) {
+ // A Stop withdrew it, so no row holds it: its text goes back to the composer, with why.
return {
outcome: 'rejected',
operationIdSpent: true,
- // The host recorded it, so its row in the chat says it was not sent and why; only a Stop's
- // withdrawal leaves no row to say it.
- error: dispatchWasWithdrawn(submission)
- ? structuredAgentSessionRejectionNotice(submission.reason, 'composer-send')
- : null
+ error: structuredAgentSessionRejectionNotice(submission.reason, 'composer-send')
}
}
+ if (submission.dispatchState === 'rejected') {
+ // The host recorded it: its row in the chat holds the text and says it was not sent and why,
+ // so nothing is handed back.
+ return { outcome: 'queued', operationIdSpent: true, error: null }
+ }
if (retained) {
// A payload match cannot distinguish retrying the ambiguous action from a
// later identical intent. Wait for the stream to settle and release it.
diff --git a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx
index 34b66f1dd1fc..85640ff842ee 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
@@ -21,13 +21,13 @@ function ok(result: unknown) {
return { ok: true, result, _meta: { runtimeId: 'runtime-1' } }
}
-function sendResult(dispatchState: AgentJournalDispatchState) {
+function sendResult(dispatchState: AgentJournalDispatchState, reason: string | null = null) {
return ok({
ok: true,
replayed: false,
fence: 3,
cursor: { epoch: 'epoch-1', sequence: 1 },
- value: structuredSendResultFixture(dispatchState)
+ value: structuredSendResultFixture(dispatchState, reason)
})
}
@@ -257,6 +257,30 @@ describe('mobile structured send retries', () => {
expect(calls().every(([, params]) => !('retryUnknown' in (params as object)))).toBe(true)
})
+ // The transcript owns a message the host recorded, so the composer never gets it back.
+ it('hands back only a send a Stop withdrew, never one the host recorded and rejected', async () => {
+ let reason = 'provider_write_failed: broken pipe'
+ sendRequest.mockImplementation(async (method) =>
+ method === 'agentSession.send'
+ ? sendResult('rejected', reason)
+ : method === 'agentSession.options'
+ ? ok({ models: [], current: {} })
+ : ok({})
+ )
+ await mountSession()
+
+ await act(async () => {
+ expect(await hook!.sendWithOutcome('recorded, then rejected')).toBe('queued')
+ })
+ expect(onSendError).not.toHaveBeenCalled()
+
+ reason = 'provider_cancelled_before_start'
+ await act(async () => {
+ expect(await hook!.sendWithOutcome('withdrawn by Stop')).toBe('rejected')
+ })
+ expect(onSendError).toHaveBeenCalledWith('Your message was not sent. Send it again.')
+ })
+
it('reuses the original uploaded attachment identity after acknowledgement loss', async () => {
let attempts = 0
sendRequest.mockImplementation(async (method) => {
diff --git a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts
index 5abe42b39e60..f8b9d7f64b22 100644
--- a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts
+++ b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts
@@ -77,8 +77,8 @@ export function useMobileStructuredNativeChatSendBridge(args: {
return 'accepted'
}
if (outcome === 'queued') {
- // The host holds the draft and publishes it as a card above the
- // composer — never an optimistic transcript bubble.
+ // The host holds the text, as a card above the composer or a recorded
+ // row in the transcript — never an optimistic bubble or a hand-back.
return 'queued'
}
if (outcome === 'unknown') {
From e6c5c84546227d142817a22188101fed2b7e6369 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Sat, 3 Oct 2026 22:54:09 -0700
Subject: [PATCH 11/34] fix(native-chat): a not-sent line reads muted, not as
an error
The line under a message that was not sent, on desktop and phone, used the
error color, as if the user had something to fix. It is a plain label now,
in the muted text color. A line that is still in doubt ("Message delivery is
unconfirmed.", or an expired id Orca can't confirm) keeps the error color,
as does the terminal chat's notice.
---
.../session/MobileNativeChatMessage.test.ts | 10 +++++
.../mobile-native-chat-message-styles.ts | 2 +-
.../native-chat/NativeChatMessageRow.test.tsx | 16 ++++++-
.../native-chat/NativeChatMessageRow.tsx | 9 +++-
...red-agent-session-delivery-notices.test.ts | 45 ++++++++++++++++++-
...ructured-agent-session-delivery-notices.ts | 29 ++++++++----
6 files changed, 99 insertions(+), 12 deletions(-)
diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts
index d96d26c3100e..e92a096e18b0 100644
--- a/mobile/src/session/MobileNativeChatMessage.test.ts
+++ b/mobile/src/session/MobileNativeChatMessage.test.ts
@@ -3,6 +3,7 @@ import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'rea
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MAX_TOOL_DETAIL_LENGTH } from '../../../src/shared/native-chat-tool-summary'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
+import { colors } from '../theme/mobile-theme'
vi.mock('react-native', async () => {
const React = await import('react')
@@ -124,6 +125,15 @@ describe('MobileNativeChatMessage', () => {
expect(textIn(tree.root)).toEqual(['hello', 'Your message was not sent.'])
})
+ it('says it was not sent as a muted label, not an error', () => {
+ const tree = render({ ...userMessage([{ type: 'text', text: 'hello' }]), unsent: true })
+ const [label] = tree.root.findAll(
+ (node) =>
+ node.props.style !== undefined && node.children.join('') === 'Your message was not sent.'
+ )
+ expect(label?.props.style).toMatchObject({ color: colors.textMuted })
+ })
+
it('says nothing more under a delivered message', () => {
const tree = render(userMessage([{ type: 'text', text: 'hello' }]))
expect(textIn(tree.root)).toEqual(['hello'])
diff --git a/mobile/src/session/mobile-native-chat-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts
index b3455c898301..18895b648ecc 100644
--- a/mobile/src/session/mobile-native-chat-message-styles.ts
+++ b/mobile/src/session/mobile-native-chat-message-styles.ts
@@ -34,7 +34,7 @@ export const styles = StyleSheet.create({
},
unsentLabel: {
marginTop: spacing.xs,
- color: colors.statusRed,
+ color: colors.textMuted,
fontSize: typography.metaSize
},
toolRun: {
diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx
index bcecab05bc69..91a54f3c9f92 100644
--- a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx
@@ -164,7 +164,7 @@ describe('MessageRow send mode', () => {
})
describe('a user message that did not go through', () => {
- function renderUser(deliveryNotice?: { text: string; onRetry?: () => void }) {
+ function renderUser(deliveryNotice?: { text: string; notSent?: true; onRetry?: () => void }) {
return render(
{
expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
})
+ // A plain "not sent" is a label, not an error; a doubt to check keeps the error color.
+ it('reads muted when it says only that the message was not sent', () => {
+ renderUser({ text: 'Your message was not sent.', notSent: true })
+ const notSent = screen.getByText('Your message was not sent.').parentElement
+ expect(notSent).toHaveClass('text-muted-foreground')
+ expect(notSent).not.toHaveClass('text-destructive/80')
+ cleanup()
+
+ renderUser({ text: 'Message delivery is unconfirmed.' })
+ expect(screen.getByText('Message delivery is unconfirmed.').parentElement).toHaveClass(
+ 'text-destructive/80'
+ )
+ })
+
it('says nothing when it went through', () => {
renderUser()
expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx
index 234934664f4a..07542ea89c5b 100644
--- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx
@@ -32,6 +32,8 @@ import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
* surface can send it again. */
export type NativeChatDeliveryNotice = {
text: string
+ /** Says only that the message did not go out, so it reads muted, not as an error. */
+ notSent?: true
onRetry?: () => void
onDismiss?: () => void
}
@@ -187,7 +189,12 @@ export const MessageRow = memo(function MessageRow({
) : null}
{deliveryNotice ? (
-