fix(native-chat): keep a message accepted before a quit or crash as a held card (#24660)

* fix(native-chat): keep a message accepted before a quit or crash as a held card

A send the host accepted while the agent was still starting, and never handed
over, was rejected unseen at quit or at the next open after a crash. The next
open now keeps a person's message (typed, or a launch's first prompt) as a
waiting card at the head of the queue, held until Resume, Send now, Edit or
Delete; quit no longer rejects it. Each submission records its source so a
restart knows which leftovers to keep. A direct send's replay answers from its
own record, never the queued arm. Cards shown without the queue capability
hide Turn off queueing and the steer chord.

* fix(native-chat): keep an unsent message as a held card at every close, not only a restart

The host now keeps a person's message it accepted and never handed over with one
rule wherever it can no longer hand it over: a quit or crash (settled at the next
open) and a close of the chat (tab close, worktree teardown, orchestration stop).

- The hold is card state: a new per-card hold_reason 'kept', published as the
  existing pausedReason, instead of a fake host_instance value. host_instance
  means the owner again, and the pause clause, adoption filter and /clear carry
  special cases are gone. A kept card holds the cards behind it until the
  person sends, edits or deletes it; a send_failed card still does not.
- A card hand-off rejected by a restart or a close returns as a kept card
  (rejectedDraftSettlement), so a person's next message can never release it.
- One hold function, parameterized by cause (hostRestarted / chatClosed),
  replaces the close path's plain rejection.
- The phone shows published cards and per-card holds whatever the queue
  capability says; only queueing a new send stays gated.
- source gains 'dispatch' for the orchestration preamble (still rejected); an
  unknown source is kept as written and never takes the legacy rule.
- Kept cards from earlier settlements stay ahead of a batch's new ones.

* test(native-chat): the dispatch preamble records its source

* fix(native-chat): skip a kept card like a failed one, and make a quit leave the queue as a crash does

- A kept card is held on its own, as a send_failed one is: the queue sends the
  cards behind it, and the "a message ahead needs attention" caption no longer
  appears behind it (desktop and phone).
- Quit disposes the queue's drain together with delivery, so it mints no
  hand-off that only the next process could settle.
- Only a Send the person asked for (origin client) that a restart or close cut
  short returns kept; the queue's own hand-off returns where it stood, under the
  restart's pause, as on main.
- Comments that said only a capable host gets cards or the card actions now say
  the capability gates only queueing a new send.

* refactor(native-chat): one dispose gate in the queue drain's step

* test(native-chat): the downgrade test names the older build's /clear exception

* fix(native-chat): re-check the drain's quit gate right before it appends a hand-off

A drain step already past its first check when quit begins no longer makes a
hand-off. Adds regression tests for that, for Send now on an ordinary card cut
short by a quit, and for the open-time repair deriving kept from the hand-off's
origin; fixes the pause and settlement comments that called a kept card one the
queue never passes.

* fix(native-chat): a card held on its own starts no restart pause for the others

After a second restart a kept (or send_failed) card is another process's, but it
waits for its own Send, so it no longer pauses every other card under
"Queue paused because Orca restarted".

* fix(native-chat): record on a kept send which card holds it, so no trace survives an Edit or Delete

The rejection row of a send the host kept as a card now names that card
(`keptAsQueuedMessageId`), in the same transaction that writes the card, and the
fold publishes it on the submission. The shared projection draws such a send
only as its card: once the card is sent, edited or deleted, neither the send
nor the sending desktop's local copy of it shows.

* chore(native-chat): leave the unused submission schema as main has it

No client parses published submissions with it (they arrive as typed frames,
and the host's history pages carry the field, as the Edit/Delete test reads);
listing the field put the file over its line budget.

* fix(native-chat): retire a kept send's local copy instead of only hiding it

The outbox reconcile and the send disposition drop an entry whose submission
the host rejected as kept as a card, as they already do for a Stop's
withdrawal, so the copy never comes back as "Not sent / Retry" once the
submission falls out of the loaded page. The projection reads the reconciled
outbox, so its separate filter goes.

* test(native-chat): move the queued-message rig's scripted provider into its own fixture

The rig fixture grew past the 300-line limit once main's changes merged in.

* fix(native-chat): hide a kept send by its own record, not by its card still existing

The transcript hid a rejected send while a queued card held it under its id, so the card's Edit or
Delete brought back a "Not sent" row. It now reads the send's own keptAsQueuedMessageId, which the
host records with the rejection, and the live card list is no longer threaded to the transcript or
the delivery notices.

* refactor(native-chat): move a sent message's row writes into their own journal collaborator

The journal store went past its line limit once main's ledger receipt joined this branch's
transaction hook. The submission and dispatch-transition writes, and what commits in their
transaction, now live in JournalSubmissionWriter; the store's methods delegate to it unchanged.

* fix(native-chat): list a kept send's card id in the submission schema

The schema drops keys it does not list, so a reader that kept a parsed submission would lose
keptAsQueuedMessageId and source, both persisted with the row. The submission schema and the
failure fact it shares with item bodies move to their own modules, with room for both fields.

* test(native-chat): match the transcript and outbox hook signatures main and the swap changed

* test(native-chat): import the journal types once in the queue-delivery test

* fix(mobile): a resend the host kept as a card shows no error and returns no text

The host answers a resend of a message it kept as a card with that
message's rejected submission, marked keptAsQueuedMessageId. The phone read
it as any rejection: "Message not sent" and the text back in the composer,
while the card showed the same text. It now answers like a queued send, as
the desktop's send disposition does: the id is spent, no error, and the card
holds the text.

* test(native-chat): pin that quit's first step stops the queue's hand-off

Quit now stops delivery, the queue drain included, at its first step
(stopDelivery), before teardown drains recovery. The drain-step quit test
runs from that step as well as from the flush.

* test(native-chat): give cards their source and store unknown sources as another build would

#25078 made a card's source required, so the tests that insert a card pass the
person's. The hold's unknown-kind and unreadable-source cases now rewrite the
stored row the way a newer build would leave it, instead of casting a type.

* refactor(native-chat): stop delivery and the queue drain in one line at quit

Main's #25159 left the host at its line limit; quit's stop now disposes both in one
expression instead of a block.

* test(native-chat): hold the drain step without reading the call stack

Bun formats a method's stack frame without its class ("at step"), so the quit
test's caller check never matched, the step was never held, and both cases timed
out once CI ran Vitest on Bun (#25840). Only the drain step heals owed queue
bookkeeping, so the hold needs no caller check.
This commit is contained in:
Brennan Benson
2026-10-06 05:35:24 -07:00
committed by GitHub
parent b75213100b
commit 5cf3585b78
109 changed files with 3314 additions and 642 deletions
@@ -3,9 +3,9 @@ import {
AgentJournalItemBodySchema,
isAdmissibleAgentJournalItemBody,
isAdmissibleAgentJournalMessageBody,
isAdmissibleAgentJournalRenderItem,
isAdmissibleAgentJournalSubmission
isAdmissibleAgentJournalRenderItem
} from './agent-session-journal-schemas'
import { isAdmissibleAgentJournalSubmission } from './agent-session-journal-submission-schema'
import type {
AgentJournalItemBody,
AgentJournalRenderItem,
+2 -32
View File
@@ -28,14 +28,12 @@
import { z } from 'zod'
import { AgentSessionContextUsageSchema } from './agent-session-context-usage-schema'
import { AgentSessionFailureFactSchema } from './agent-session-failure-fact-schema'
import { AgentJournalAnsweredTurnSchema } from './agent-session-answered-turn-schema'
import { knownTags, openDiscriminatedUnion } from './agent-session-journal-open-union'
import type {
AgentJournalItemBody,
AgentJournalMessageItem,
AgentJournalResolution,
AgentJournalRenderItem,
AgentJournalSubmission
AgentJournalRenderItem
} from './agent-session-journal-types'
const BoundedPayload = z.object({
@@ -310,25 +308,6 @@ export const AgentJournalRenderItemSchema = z.object({
...AgentJournalProducerLinkageFields
})
export const AgentJournalSubmissionSchema = z.object({
clientMessageId: z.string().min(1),
fence: z.number().int(),
payloadFingerprint: z.string(),
dispatchState: z.string().min(1),
providerItemId: z.string().nullable(),
reason: z.string().nullable(),
submittedAt: z.number(),
resolvedAt: z.number().nullable(),
submittedSequence: z.number().int().optional(),
answeredInTurn: AgentJournalAnsweredTurnSchema.optional(),
recovered: z.literal(true).optional(),
handoverRecorded: z.literal(true).optional(),
handedOverAt: z.number().optional(),
rejection: AgentSessionFailureFactSchema.optional(),
// Listed, or the parse strips it: this schema drops unknown keys.
queuedMessageId: z.string().min(1).optional()
})
export function isAgentJournalResolution(value: unknown): value is AgentJournalResolution {
return Resolution.safeParse(value).success
}
@@ -350,12 +329,6 @@ export function isAdmissibleAgentJournalRenderItem(
return AgentJournalRenderItemSchema.safeParse(value).success
}
export function isAdmissibleAgentJournalSubmission(
value: unknown
): value is AgentJournalSubmission {
return AgentJournalSubmissionSchema.safeParse(value).success
}
/** Compile-time proof that every canonical value is admissible, so replay can
* never reject a row a writer in this build produced. The schemas are
* deliberately wider on open string fields, so only this direction holds. */
@@ -363,8 +336,5 @@ type Admits<T extends true> = T
export type CanonicalJournalTypesAreAdmissible = [
Admits<AgentJournalItemBody extends z.input<typeof AgentJournalItemBodySchema> ? true : false>,
Admits<AgentJournalMessageItem extends z.input<typeof MessageBody> ? true : false>,
Admits<
AgentJournalRenderItem extends z.input<typeof AgentJournalRenderItemSchema> ? true : false
>,
Admits<AgentJournalSubmission extends z.input<typeof AgentJournalSubmissionSchema> ? true : false>
Admits<AgentJournalRenderItem extends z.input<typeof AgentJournalRenderItemSchema> ? true : false>
]
@@ -0,0 +1,43 @@
import { expect, it } from 'vitest'
import type { AgentJournalSubmission } from './agent-session-journal-types'
import {
AgentJournalSubmissionSchema,
isAdmissibleAgentJournalSubmission
} from './agent-session-journal-submission-schema'
// Persisted and published: a reader that keeps the parsed value must not lose either fact.
it('keeps a kept send’s card id and its source through a parse', () => {
const kept: AgentJournalSubmission = {
clientMessageId: 'client-kept',
fence: 2,
payloadFingerprint: 'fingerprint-kept',
dispatchState: 'rejected',
providerItemId: null,
reason: 'Orca restarted before this message was sent.',
rejection: { kind: 'hostRestarted' },
submittedAt: 1,
resolvedAt: 2,
recovered: true,
source: { kind: 'user' },
keptAsQueuedMessageId: 'client-kept'
}
expect(isAdmissibleAgentJournalSubmission(kept)).toBe(true)
expect(AgentJournalSubmissionSchema.parse(kept)).toEqual(kept)
expect(AgentJournalSubmissionSchema.parse(JSON.parse(JSON.stringify(kept)))).toEqual(kept)
})
it('refuses an empty card id rather than reading it as kept', () => {
expect(
isAdmissibleAgentJournalSubmission({
clientMessageId: 'client-kept',
fence: 2,
payloadFingerprint: 'fingerprint-kept',
dispatchState: 'rejected',
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: 2,
keptAsQueuedMessageId: ''
})
).toBe(false)
})
@@ -0,0 +1,42 @@
// The deep validator for a journal submission (`AgentJournalSubmission`), the row a sent message's
// dispatch state lives on. Open string fields stay type-checked, never enum-checked, as in
// `agent-session-journal-schemas.ts`.
import { z } from 'zod'
import { AgentJournalAnsweredTurnSchema } from './agent-session-answered-turn-schema'
import { AgentSessionFailureFactSchema } from './agent-session-failure-fact-schema'
import type { AgentJournalSubmission } from './agent-session-journal-types'
// Every persisted field is listed, or a parse strips it: this schema drops unknown keys.
export const AgentJournalSubmissionSchema = z.object({
clientMessageId: z.string().min(1),
fence: z.number().int(),
payloadFingerprint: z.string(),
dispatchState: z.string().min(1),
providerItemId: z.string().nullable(),
reason: z.string().nullable(),
submittedAt: z.number(),
resolvedAt: z.number().nullable(),
submittedSequence: z.number().int().optional(),
answeredInTurn: AgentJournalAnsweredTurnSchema.optional(),
recovered: z.literal(true).optional(),
handoverRecorded: z.literal(true).optional(),
handedOverAt: z.number().optional(),
rejection: AgentSessionFailureFactSchema.optional(),
queuedMessageId: z.string().min(1).optional(),
// The kind only; an object's other keys (a sender) are stripped, never published.
source: z.object({ kind: z.string() }).optional(),
keptAsQueuedMessageId: z.string().min(1).optional()
})
export function isAdmissibleAgentJournalSubmission(
value: unknown
): value is AgentJournalSubmission {
return AgentJournalSubmissionSchema.safeParse(value).success
}
/** Compile-time proof that every submission this build writes is admissible. */
type Admits<T extends true> = T
export type CanonicalJournalSubmissionIsAdmissible = Admits<
AgentJournalSubmission extends z.input<typeof AgentJournalSubmissionSchema> ? true : false
>
@@ -478,6 +478,15 @@ export type AgentJournalSubmission = {
* A person's turn is what ends a Stop's queue pause. The snapshot still carries it; no released
* client reads it. */
origin?: 'client' | 'host'
/** Who it is from: the kind of its `AgentSessionMessageSource` ('user' or 'agent'), so a restart
* or a close keeps only a person's unsent send as a card. Only the kind: the senders stay on the
* card, host-only, and publishing them here would need a strip. A newer build's kind is kept as
* written, never read as absent. Absent when its sender named none (a dispatch preamble, a restart continuation). */
source?: { kind: string }
/** On a rejected send the host kept as a card: that card's message id. The text lives on the
* card, so no surface draws this send, before or after the card is sent, edited or deleted.
* Recorded in the rejection's own transaction (`journal-unsent-send-hold.ts`). */
keptAsQueuedMessageId?: string
}
/** Durable answer to "did my send land?", keyed by client message id. Only an
@@ -7,7 +7,13 @@ import type { AgentJournalMessageItem } from './agent-session-journal-types'
/** The draft could not be converted into a send; an explicit Send retries it. */
export const QUEUED_MESSAGE_PAUSED_SEND_FAILED = 'send_failed' as const
export type AgentSessionQueuedMessagePausedReason = typeof QUEUED_MESSAGE_PAUSED_SEND_FAILED
/** A person's message the host accepted and never handed over before a restart or a close of the
* chat. It waits for the person's own Send; the cards behind it still send, as past a failed one. */
export const QUEUED_MESSAGE_PAUSED_KEPT = 'kept' as const
export type AgentSessionQueuedMessagePausedReason =
| typeof QUEUED_MESSAGE_PAUSED_SEND_FAILED
| typeof QUEUED_MESSAGE_PAUSED_KEPT
/** The whole queue is paused and sends nothing on its own: 'stopped' — the user
* interrupted ("Queue paused because you interrupted") — 'cleared' — a /clear
@@ -30,11 +36,11 @@ export type AgentSessionQueuedMessage = {
position: number
body: AgentJournalMessageItem
state: 'waiting' | 'returned'
/** This one card is held, whatever the queue's pause: its conversion failed. */
/** This one card is held, whatever the queue's pause: its conversion failed, or the host kept it. */
paused?: true
/** Why it is held, as a marker the client localizes: 'send_failed' ("couldn't
* send"; only an explicit Send releases it). A client must treat an unknown
* marker as a plain hold, so a newer host can add one. The queue-level
* send") or 'kept' (accepted, never sent); only an explicit Send releases either. A client
* must treat an unknown marker as a plain hold, so a newer host can add one. The queue-level
* pause is `queuePause`, published beside the list. */
pausedReason?: AgentSessionQueuedMessagePausedReason
/** A returned card's refusal: the `reason` and `rejection` pair its submission settled with.
+7 -5
View File
@@ -215,14 +215,16 @@ export const AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY =
export const AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY =
'agent-session.send-answers-proof.v1' as const
// Why: `agentSession.send`'s params are strict, so an older host rejects `delivery`; and only a
// capable client can render the `queued` result arm, the draft list, and returned cards. DARK ON
// PURPOSE — not in RUNTIME_CAPABILITIES: advertising still requires the integrated Codex steer
// matrix (#21062) in the shipped host, and the desktop and phone clients that render the queue.
// capable client can render the `queued` result arm. It gates `delivery: 'queue-if-active'` and
// the client's queueing setting and chord, never the published draft list or its card actions: a
// host without it still publishes a message it kept unsent across a restart or a close, and both
// clients show that card and call its actions. DARK ON PURPOSE — not in RUNTIME_CAPABILITIES:
// advertising still requires the integrated Codex steer matrix (#21062) in the shipped host.
// v1 includes `submission.queuedMessageId` on every draft hand-off: a client reads that link and
// never compares a draft id with a submission id. It also publishes the queue's pause once, as
// `queuePause` beside the list, lifted by `agentSession.queuedMessagesResume` or the user's next
// turn; cards carry a hold of their own only when their conversion failed. The host mechanism lands first; the constant
// gates the rollout.
// turn; a card carries a hold of its own when its conversion failed (`send_failed`) or the host
// kept it unsent (`kept`). The host mechanism lands first; the constant gates the rollout.
export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY =
'agent-session.queued-messages.v1' as const
// Why: paired clients advertise Claude-structured support so the host can gate its agent-specific
@@ -0,0 +1,85 @@
// A send the host kept as a card is drawn only as that card: the rule rides on the submission's
// own fact, never on the card still being there.
import { describe, expect, it } from 'vitest'
import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types'
import { agentJournalSubmissionKey } from './agent-session-journal-item-key'
import { createStructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox'
import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection'
const KEPT_ID = 'client-kept'
// The desktop draws a rejected send in place, as not sent, unless the host kept it as a card.
const DESKTOP = { rejectedInPlace: true }
function rejected(fields: Partial<AgentJournalSubmission> = {}): AgentJournalSubmission {
return {
clientMessageId: KEPT_ID,
fence: 1,
payloadFingerprint: 'fingerprint-kept',
dispatchState: 'rejected',
providerItemId: null,
reason: 'Orca restarted before this message was sent.',
rejection: { kind: 'hostRestarted' },
submittedAt: 1,
resolvedAt: 2,
...fields
}
}
function userItem(id: string, text: string, sequence: number): AgentJournalRenderItem {
return {
itemId: agentJournalSubmissionKey(id),
revision: 1,
sequence,
observedAt: sequence,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }
}
}
/** The sending desktop's own copy, still marked not sent. */
const lingeringCopy = {
...createStructuredAgentSessionOutboxEntry({
clientMessageId: KEPT_ID,
sessionId: 'session-1',
text: 'the kept words',
attachments: [],
queuedAt: 1
}),
state: 'rejected' as const
}
describe('a send kept as a card', () => {
it('shows nothing of the original send, even beside its own local copy', () => {
const kept = rejected({ keptAsQueuedMessageId: KEPT_ID })
const items = [userItem(KEPT_ID, 'the kept words', 1)]
expect(projectStructuredAgentSessionMessages(items, [lingeringCopy], [kept], DESKTOP)).toEqual(
[]
)
// A rejection with no card keeps its local copy, marked not sent.
expect(
projectStructuredAgentSessionMessages(items, [lingeringCopy], [rejected()], DESKTOP)
).toEqual([expect.objectContaining({ id: agentJournalSubmissionKey(KEPT_ID), unsent: true })])
})
// Edit is the card's text in the composer, then the card's Delete, then a new send.
it('after an Edit sent again, shows exactly the new send', () => {
const kept = rejected({ keptAsQueuedMessageId: KEPT_ID })
const edited: AgentJournalSubmission = {
...rejected(),
clientMessageId: 'client-edited',
dispatchState: 'accepted',
providerItemId: 'provider-edited',
reason: null
}
delete edited.rejection
const shown = projectStructuredAgentSessionMessages(
[userItem(KEPT_ID, 'the kept words', 1), userItem('client-edited', 'the edited words', 2)],
[lingeringCopy],
[kept, edited],
DESKTOP
)
expect(shown).toHaveLength(1)
expect(shown[0]).toMatchObject({ blocks: [{ text: 'the edited words' }] })
expect(shown[0]).not.toHaveProperty('unsent')
})
})
@@ -13,8 +13,6 @@ export type StructuredAgentSessionMessageProjectionOptions = {
/** Draw a message the host accepted and then rejected where the host recorded it, as not sent.
* Off for a client that hands such a message back to its composer instead. */
rejectedInPlace: boolean
/** The queue's live cards: a rejected message one of them holds is drawn there, not here. */
queuedMessageIds?: readonly string[]
}
/** The loaded items that are a conversation command such as `/compact`, by item id. */
@@ -31,15 +29,13 @@ export function structuredAgentSessionCommandItemIds(
/**
* The rejected submissions the host's history shows in place as not sent, by item id; `submissions`
* in submission order, as the client keeps them. A withdrawn one went back to its sender, one the
* queue holds (a draft's hand-off, or a card under its id) is drawn as its card, and a command such
* as `/compact` has its rejection reported as its own reply.
* queue holds (a draft's hand-off, or a send kept as a card) is drawn as its card, and a command
* such as `/compact` has its rejection reported as its own reply.
*/
export function structuredAgentSessionRejectedShownInPlace(
submissions: readonly AgentJournalSubmission[],
queuedMessageIds: readonly string[],
commandItemIds: ReadonlySet<string>
): Set<string> {
const cards = new Set(queuedMessageIds)
// Each body's copies, as positions in submission order. A withdrawn one is hidden too, so it
// supersedes nothing.
const copies = new Map<string, { index: number; submittedAt: number }[]>()
@@ -61,7 +57,8 @@ export function structuredAgentSessionRejectedShownInPlace(
submission.dispatchState !== 'rejected' ||
dispatchWasWithdrawn(submission) ||
submission.queuedMessageId !== undefined ||
cards.has(submission.clientMessageId) ||
// Recorded on the send itself, so an Edit or Delete of its card brings no row back.
submission.keptAsQueuedMessageId !== undefined ||
commandItemIds.has(agentJournalSubmissionKey(submission.clientMessageId)) ||
// Collapses resends of a rejected message: past Retries resent it under a new id, and the
// host re-delivers its own messages under new ids. Only a later copy sent once the rejection
@@ -95,7 +92,6 @@ export function projectStructuredAgentSessionMessages(
const inPlace = options.rejectedInPlace
? structuredAgentSessionRejectedShownInPlace(
submissions,
options.queuedMessageIds ?? [],
structuredAgentSessionCommandItemIds(items)
)
: new Set<string>()
@@ -98,3 +98,12 @@ it('returns the list it was given when nothing changes', () => {
entries
)
})
// The card carries the text from here; the copy would draw a "Not sent" row the transcript hides.
it('drops the copy of a send the host kept as a card, with its row not yet loaded', () => {
const kept = { ...submission('kept', 'rejected'), keptAsQueuedMessageId: 'kept' }
expect(reconcileStructuredAgentSessionOutbox([entry('kept')], [kept], [])).toEqual([])
expect(
reconcileStructuredAgentSessionOutbox([entry('kept')], [submission('kept', 'rejected')], [])
).toEqual([expect.objectContaining({ clientMessageId: 'kept', state: 'rejected' })])
})
@@ -27,6 +27,13 @@ export function reconcileStructuredAgentSessionOutbox(
if (submission?.dispatchState === 'accepted' || dispatchWasWithdrawn(submission)) {
return []
}
// The host kept it as a card, which carries the text from here, edited or deleted included.
if (
submission?.dispatchState === 'rejected' &&
submission.keptAsQueuedMessageId !== undefined
) {
return []
}
if (submission?.dispatchState === 'rejected') {
// Its row draws it once loaded; until then the entry does, as the host recorded it. An older
// host leaves that row where it was sent, which may be outside the loaded window.
@@ -274,6 +274,14 @@ export function disposeStructuredAgentSessionSendResult(
error: null
}
}
// The host kept it as a card, first reply or replay: the card owns the text, so the entry
// leaves as the reconcile drops it, with no Retry that could send words the card's Delete took.
if (submission.dispatchState === 'rejected' && submission.keptAsQueuedMessageId !== undefined) {
return {
entries: dropEntry(input),
error: null
}
}
// Recorded, so the journal's row shows it once it arrives; until then the entry draws it, saying
// why and offering no Retry.
if (submission.dispatchState === 'rejected') {