Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
4847926
fix(native-chat): keep a message accepted before a quit or crash as a…
brennanb2025 Oct 2, 2026
740f68b
Merge origin/main into brennanb2025/accepted-message-survives-quit
brennanb2025 Oct 2, 2026
2880804
fix(native-chat): keep an unsent message as a held card at every clos…
brennanb2025 Oct 2, 2026
e066f6e
test(native-chat): the dispatch preamble records its source
brennanb2025 Oct 2, 2026
3fdef59
fix(native-chat): skip a kept card like a failed one, and make a quit…
brennanb2025 Oct 2, 2026
1041d54
refactor(native-chat): one dispose gate in the queue drain's step
brennanb2025 Oct 2, 2026
0dd6fe2
test(native-chat): the downgrade test names the older build's /clear …
brennanb2025 Oct 2, 2026
a6ae443
Merge remote-tracking branch 'origin/main' into brennanb2025/accepted…
brennanb2025 Oct 2, 2026
272bfa8
fix(native-chat): re-check the drain's quit gate right before it appe…
brennanb2025 Oct 2, 2026
5e6ea97
fix(native-chat): a card held on its own starts no restart pause for …
brennanb2025 Oct 2, 2026
40e5c8f
fix(native-chat): record on a kept send which card holds it, so no tr…
brennanb2025 Oct 2, 2026
6ce6c1e
chore(native-chat): leave the unused submission schema as main has it
brennanb2025 Oct 2, 2026
7063155
fix(native-chat): retire a kept send's local copy instead of only hid…
brennanb2025 Oct 2, 2026
907a7c3
Merge remote-tracking branch 'origin/main' into brennanb2025/accepted…
brennanb2025 Oct 2, 2026
1eacd65
Merge remote-tracking branch 'origin/main' into brennanb2025/accepted…
brennanb2025 Oct 2, 2026
fb2f9c9
Merge origin/main into brennanb2025/accepted-message-survives-quit
brennanb2025 Oct 4, 2026
9c8c818
test(native-chat): move the queued-message rig's scripted provider in…
brennanb2025 Oct 4, 2026
a924659
Merge origin/main into brennanb2025/accepted-message-survives-quit
brennanb2025 Oct 4, 2026
c2be443
Merge origin/main into brennanb2025/accepted-message-survives-quit
brennanb2025 Oct 4, 2026
59efcc2
fix(native-chat): hide a kept send by its own record, not by its card…
brennanb2025 Oct 4, 2026
c647cab
refactor(native-chat): move a sent message's row writes into their ow…
brennanb2025 Oct 4, 2026
1c322e2
fix(native-chat): list a kept send's card id in the submission schema
brennanb2025 Oct 4, 2026
4a4d5b5
test(native-chat): match the transcript and outbox hook signatures ma…
brennanb2025 Oct 4, 2026
5a3afd3
test(native-chat): import the journal types once in the queue-deliver…
brennanb2025 Oct 4, 2026
ca48493
Merge origin/main into brennanb2025/accepted-message-survives-quit
brennanb2025 Oct 5, 2026
b7b9df2
Merge commit 'a154562a89846c944c13fcd460f8da463354f434' into brennanb…
brennanb2025 Oct 5, 2026
a70aa69
Merge commit 'e2da3a1eb1962f31ad01f0e2172119d95ae10015' into brennanb…
brennanb2025 Oct 5, 2026
fda0bb3
Merge commit 'ca2cdc0ad9638695306da1be553aeee8b4723818' into brennanb…
brennanb2025 Oct 5, 2026
a8cdc0d
fix(mobile): a resend the host kept as a card shows no error and retu…
brennanb2025 Oct 5, 2026
95bb260
Merge commit 'e9ed75a5de18b2f194d65fce6ac63c5cfe4c4fef' into brennanb…
brennanb2025 Oct 5, 2026
a04591e
test(native-chat): pin that quit's first step stops the queue's hand-off
brennanb2025 Oct 5, 2026
798cf71
Merge commit '990b0ed5cdd8f541f3b63fbcea068b4ef6097eae' into brennanb…
brennanb2025 Oct 5, 2026
c14cfe8
Merge commit '9756daeced4eda1dc888f14fefeeed1762aa5822' into brennanb…
brennanb2025 Oct 6, 2026
c2f306c
Merge commit 'a47e0f56578b2f3f4f07b6401fa0d38a39d67593' into brennanb…
brennanb2025 Oct 6, 2026
5e15249
test(native-chat): give cards their source and store unknown sources …
brennanb2025 Oct 6, 2026
c63b8a5
Merge commit '4e64fa9940f5a4a9bfb7b021a3c405fd76643ca5' into brennanb…
brennanb2025 Oct 6, 2026
4c182fd
Merge commit '0bcd49c04c4151f47858483215a7947689c9be53' into brennanb…
brennanb2025 Oct 6, 2026
1c8a2d0
refactor(native-chat): stop delivery and the queue drain in one line …
brennanb2025 Oct 6, 2026
611528a
Merge commit '4de9f9f85a858c711a1513852407354337e1bd36' into brennanb…
brennanb2025 Oct 6, 2026
d467e44
Merge commit '5b8a982f8f76afea8bacbab437173fb617f0e6aa' into brennanb…
brennanb2025 Oct 6, 2026
1ced9bb
Merge commit 'b75213100b5358bedb7e16b48afd30645e54de14' into brennanb…
brennanb2025 Oct 6, 2026
b04a101
test(native-chat): hold the drain step without reading the call stack
brennanb2025 Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions mobile/src/session/MobileNativeChatQueuedMessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ const RESUME_KEY = '\u0000resume'

export type MobileNativeChatQueuedMessagesProps = {
cards?: MobileQueuedMessageCard[]
/** Steer for a waiting card, the paused queue's included; plain Send for a card whose own send
* failed, or a returned one. */
/** Steer for a waiting card, the paused queue's included; plain Send for a card held on its own
* (its send failed, or the host kept it unsent), or a returned one. */
onSend?: (messageId: string) => Promise<boolean>
onDelete?: (messageId: string) => Promise<boolean>
/** Copy the card's text into the composer, then delete the card. */
Expand Down
4 changes: 2 additions & 2 deletions mobile/src/session/mobile-native-chat-controller-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ export type MobileNativeChatController = {
}) => Promise<boolean>
handleNativeChatRespondPermission: (text: string) => Promise<boolean>
handleNativeChatStop: () => void
/** Host-held queued drafts shown as cards above the composer (structured lane,
* capable host only; empty otherwise). */
/** Host-held queued drafts shown as cards above the composer (structured lane; any host
* that publishes them). */
nativeChatQueued: MobileStructuredQueuedMessageControls
nativeChatFilePaths: string[]
loadNativeChatFiles: (query: string) => void
Expand Down
2 changes: 1 addition & 1 deletion mobile/src/session/mobile-native-chat-pending-echo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export type MobileNativeChatSendOrigin = {
baselineTailMessageId: string | null
baselineResolved: boolean
/** Queued-draft cards already on screen at send time, so an earlier identical
* card cannot confirm this send. Structured lane on a queue-capable host only. */
* card cannot confirm this send. Structured lane, on any host that publishes cards. */
baselineQueuedMessageIds?: readonly string[]
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'
import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure'
import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words'
import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../src/shared/structured-agent-session-dispatch-rejection'
import { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../src/shared/agent-session-wire'
import {
QUEUED_MESSAGE_PAUSED_KEPT,
QUEUED_MESSAGE_PAUSED_SEND_FAILED
} from '../../../src/shared/agent-session-wire'
import type { AgentSessionQueuedMessage } from '../../../src/shared/agent-session-wire'
import {
mobileQueueHasResumableCard,
Expand Down Expand Up @@ -99,6 +102,26 @@ describe('mobileQueuedMessageCards', () => {
expect(resumable([failed])).toBe(false)
expect(resumable([failed, waiting])).toBe(true)
expect(resumable([waiting, { ...returned, position: 3 }])).toBe(true)
// A kept card is held on its own, like a failed one: the drain goes past it.
const kept = draft({ messageId: 'k', paused: true, pausedReason: QUEUED_MESSAGE_PAUSED_KEPT })
expect(resumable([kept])).toBe(false)
expect(resumable([kept, waiting])).toBe(true)
})

// The host kept it unsent across a restart or a close; the cards behind it are not held by it.
it('captions a kept card as not sent yet, and leaves the cards behind it plainly queued', () => {
const cards = mobileQueuedMessageCards(
[
draft({ messageId: 'k', paused: true, pausedReason: QUEUED_MESSAGE_PAUSED_KEPT }),
draft({ messageId: 'b', position: 2 })
],
[],
{ pendingPrompt: false }
)
expect(cards.map(({ caption, needsAttention }) => ({ caption, needsAttention }))).toEqual([
{ caption: 'Not sent yet — tap Send to send it', needsAttention: false },
{ caption: null, needsAttention: false }
])
})

it('words the paused queue by reason, and one this build does not know as a plain pause', () => {
Expand Down
7 changes: 6 additions & 1 deletion mobile/src/session/mobile-structured-queued-message-cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-sessio
import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection'
import { structuredAgentSessionAttemptFailureParts } from '../../../src/shared/structured-agent-session-send-disposition'
import {
QUEUED_MESSAGE_PAUSED_KEPT,
QUEUED_MESSAGE_PAUSED_SEND_FAILED,
type AgentSessionQueuedMessage,
type AgentSessionQueuePause
Expand Down Expand Up @@ -48,11 +49,15 @@ function returnedCaption(
)
}

/** One card's own hold: only a failed conversion; the queue's pause is the list's first row. */
/** One card's own hold: a failed conversion, or a send the host kept; the queue's pause is the
* list's first row. */
function pausedCaption(reason: string | undefined): string {
if (reason === QUEUED_MESSAGE_PAUSED_SEND_FAILED) {
return "Couldn't send — tap Send to retry"
}
if (reason === QUEUED_MESSAGE_PAUSED_KEPT) {
return 'Not sent yet — tap Send to send it'
}
// Absent or unknown (newer host) marker: a plain pause, promising no release rule.
return 'Paused'
}
Expand Down
28 changes: 28 additions & 0 deletions mobile/src/session/mobile-structured-send-delivery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,34 @@ describe('mobileStructuredSendDelivery', () => {
})
})

it('answers a send the host kept as a card like a queued one, first send or replay', () => {
// The card shows the text, so neither an error nor a composer hand-back may repeat it.
const kept: StructuredAgentSessionMutationCallResult<AgentSessionSendResult> = {
status: 'accepted',
value: {
clientMessageId: 'msg-1',
submission: {
clientMessageId: 'msg-1',
fence: 3,
payloadFingerprint: 'fingerprint',
dispatchState: 'rejected',
providerItemId: null,
reason: 'Orca restarted before this was sent.',
submittedAt: 10,
resolvedAt: 10,
keptAsQueuedMessageId: 'msg-1'
}
}
}
for (const retained of [false, true]) {
expect(mobileStructuredSendDelivery(kept, retained)).toEqual({
outcome: 'queued',
operationIdSpent: true,
error: null
})
}
})

it('shows a provider content rejection verbatim', () => {
expect(
mobileStructuredSendDelivery(accepted('rejected', 'Claude does not support .bmp'))
Expand Down
6 changes: 6 additions & 0 deletions mobile/src/session/mobile-structured-send-delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
// and keeping the id would only refuse every later send of the same text:
// a host that refuses the replay's request shape itself (an older host's
// strict schema turning `delivery` away), and an id the host has expired.
// A rejection the host kept as a card answers as `queued`: the card holds the text.
// unknown — the one answer that KEEPS its id, whether it came from the host or
// from an ack-loss on the way back. The message may be with the provider, so
// the retry has to stay a replay. Rotating here is what sent one message to a
Expand Down Expand Up @@ -96,6 +97,11 @@ export function mobileStructuredSendDelivery(
if (!submission || submission.dispatchState === 'unknown') {
return { outcome: 'unknown', operationIdSpent: false, error: null }
}
if (submission.dispatchState === 'rejected' && submission.keptAsQueuedMessageId !== undefined) {
// The host kept it as a card, which holds the text: no error, and nothing handed back to the
// composer, so the words never show twice.
return { outcome: 'queued', operationIdSpent: true, error: null }
}
if (submission.dispatchState === 'rejected') {
return {
outcome: 'rejected',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -568,12 +568,28 @@ describe('mobile structured queued messages', () => {
expect(hook!.queued.cards.map((card) => card.messageId)).toEqual(['same-id'])
})

it('shows no cards from an incapable host even if a list arrives', async () => {
await mountSession(
LEGACY,
snapshotEvent({ queuedMessages: [queuedDraft({ messageId: 'draft-1' })] })
// A host that does not queue sends still keeps a message it accepted and never sent across a
// restart or a close, and publishes it as a card; only queueing a new send is gated.
it('shows the cards a host that does not queue sends publishes', async () => {
await mountSession(LEGACY)
act(() =>
listener?.(
batchEvent(
[
queuedDraft({ messageId: 'kept-1', paused: true, pausedReason: 'kept' }),
queuedDraft({ messageId: 'behind', position: 2 })
],
[],
{ reason: 'restarted' }
)
)
)
expect(hook!.queued.cards).toEqual([])
expect(hook!.queued.cards.map(({ messageId, caption }) => ({ messageId, caption }))).toEqual([
{ messageId: 'kept-1', caption: 'Not sent yet — tap Send to send it' },
{ messageId: 'behind', caption: null }
])
// The kept card is held on its own, so Resume would send the card behind it.
expect(hook!.queued.pause).toEqual({ reason: 'restarted' })
})
})

Expand Down
5 changes: 2 additions & 3 deletions mobile/src/session/use-mobile-structured-agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ type StructuredMobileSession = ReturnType<typeof useMobileStructuredAgentOptions
respondPermission: (optionId: string) => Promise<boolean>
respondQuestion: (answer: string) => Promise<boolean>
cancelPrompt: (prompt?: { itemId: string; expectedRevision: number }) => Promise<boolean>
/** The queued-draft cards and their actions; empty and inert off capable hosts. */
/** The queued-draft cards and their actions, from any host that publishes them. */
queued: MobileStructuredQueuedMessageControls
}

Expand Down Expand Up @@ -97,7 +97,7 @@ export function useMobileStructuredAgentSession(args: {
onSendError,
hostSupport
} = args
// Old host ⇒ exactly today's behavior: no delivery field, no cards, plain Stop.
// Only a host that queues sends gets the delivery field; any host's published cards show.
const queueCapable = hostSupport?.queuedMessages === true
const promptCancelSupported = hostSupport?.promptCancel ?? null
const hostAnswersRepeatedStops = hostSupport?.quietRepeatedStop ?? null
Expand Down Expand Up @@ -196,7 +196,6 @@ export function useMobileStructuredAgentSession(args: {
[state.items]
)
const queued = useMobileStructuredQueuedMessageControls({
queueCapable,
sessionKey,
queuedMessages,
queuePause,
Expand Down
28 changes: 13 additions & 15 deletions mobile/src/session/use-mobile-structured-queued-message-controls.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
// The queued-draft surface the structured session exposes: cards derived from
// the published list and the Send-now / Delete / Edit actions. All of it is
// gated on the host capability — an incapable host gets no cards and no new
// fields. Nothing here is durable: the host owns the queue, and the published
// list is the only truth a card action ever needs.
// the published list and the Send-now / Delete / Edit actions. Shown whatever
// the queue capability says: a host that does not queue sends still publishes a
// message it kept across a restart or a close, and only queueing a new send is
// gated. A host older than the queue publishes no list, so shows no cards.
// Nothing here is durable: the host owns the queue, and the published list is
// the only truth a card action ever needs.

import { useCallback, useMemo } from 'react'
import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types'
Expand All @@ -26,7 +28,7 @@ import type { MobileStructuredAgentMutate } from './use-mobile-structured-agent-
export type MobileQueuedMessageEdit = (messageId: string, onCopied?: () => void) => Promise<boolean>

export type MobileStructuredQueuedMessageControls = {
/** Host-held drafts as cards above the composer; empty off capable hosts. */
/** Host-held drafts as cards above the composer. */
cards: MobileQueuedMessageCard[]
/** Send-now: dispatch this draft into or ahead of the running turn. */
send: (messageId: string) => Promise<boolean>
Expand All @@ -43,7 +45,6 @@ export type MobileStructuredQueuedMessageControls = {
}

export function useMobileStructuredQueuedMessageControls(args: {
queueCapable: boolean
sessionKey: string
queuedMessages: MobileQueuedMessageFeed
queuePause: MobileQueuePause
Expand All @@ -63,21 +64,18 @@ export function useMobileStructuredQueuedMessageControls(args: {
onActionResolved,
onSendError,
pendingPrompt,
queueCapable,
queuedMessages,
queuePause,
sessionKey,
submissions
} = args
const cards = useMemo(
() =>
queueCapable
? mobileQueuedMessageCards(queuedMessages, submissions, {
pendingPrompt,
queuePaused: queuePause !== null
})
: [],
[pendingPrompt, queueCapable, queuePause, queuedMessages, submissions]
mobileQueuedMessageCards(queuedMessages, submissions, {
pendingPrompt,
queuePaused: queuePause !== null
}),
[pendingPrompt, queuePause, queuedMessages, submissions]
)
const resolved = useCallback(
(accepted: boolean): boolean => {
Expand Down Expand Up @@ -160,6 +158,6 @@ export function useMobileStructuredQueuedMessageControls(args: {
[mutate, resolved]
)
// The header shows only while Resume would send something, as on desktop.
const pause = queueCapable && mobileQueueHasResumableCard(cards) ? queuePause : null
const pause = mobileQueueHasResumableCard(cards) ? queuePause : null
return { cards, send, delete: deleteDraft, edit, pause, resume, sessionKey }
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ export function applyJournalDispatchRow(
} else {
delete submission.rejection
}
if (
row.state === 'rejected' &&
typeof row.keptAsQueuedMessageId === 'string' &&
row.keptAsQueuedMessageId.length > 0
) {
submission.keptAsQueuedMessageId = row.keptAsQueuedMessageId
}
if (row.state === 'rejected' && row.answeredInTurn !== undefined) {
submission.answeredInTurn = readAnsweredTurn(row.answeredInTurn)
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,34 @@ import {
} from '../../../shared/structured-agent-session-dispatch-rejection'
import { rejectedDraftSettlement } from './journal-dispatch-settlement'

function settle(kind: SubmissionRejectionKind) {
return rejectedDraftSettlement(
agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'rejection' })
)
function settle(kind: SubmissionRejectionKind, origin?: 'client' | 'host') {
return rejectedDraftSettlement({
...agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'rejection' }),
origin
})
}

describe('what a rejection does to the draft it was consumed from', () => {
it("a Stop's withdrawal sends it back to waiting, under the queue's pause rather than a hold of its own", () => {
expect(settle('cancelled')).toEqual({ state: 'waiting' })
expect(settle('cancelled')).toEqual({ state: 'waiting', kept: false })
expect(rejectedDraftSettlement({ reason: DISPATCH_REJECTED_CANCELLED })).toEqual({
state: 'waiting'
state: 'waiting',
kept: false
})
expect(settle('notDelivered')).toEqual({ state: 'waiting', kept: false })
})

it('a restart or close before hand-over sends it back to waiting too', () => {
for (const kind of ['hostRestarted', 'chatClosed', 'notDelivered'] as const) {
expect(settle(kind)).toEqual({ state: 'waiting' })
// A Send the person asked for and the host never handed over waits for them; the queue's own
// hand-off, or one from a build that recorded no origin, waits under the queue's pause.
it('a restart or close before hand-over sends it back to waiting, kept only when the person sent it', () => {
for (const kind of ['hostRestarted', 'chatClosed'] as const) {
expect(settle(kind, 'client')).toEqual({ state: 'waiting', kept: true })
expect(settle(kind, 'host')).toEqual({ state: 'waiting', kept: false })
expect(settle(kind)).toEqual({ state: 'waiting', kept: false })
}
expect(rejectedDraftSettlement({ reason: DISPATCH_REJECTED_HOST_RESTARTED })).toEqual({
state: 'waiting'
})
expect(
rejectedDraftSettlement({ reason: DISPATCH_REJECTED_HOST_RESTARTED, origin: 'client' })
).toEqual({ state: 'waiting', kept: true })
})

it('a failure returns the card for the user to act on', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,22 +30,27 @@ export function consumedSubmissionWasRejected(
}

/** What a consumed draft becomes when its submission is rejected. */
export type RejectedDraftSettlement = { state: 'returned' } | { state: 'waiting' }
export type RejectedDraftSettlement = { state: 'returned' } | { state: 'waiting'; kept: boolean }

/**
* Where no one failed the user — a Stop withdrew it, or a restart or close
* interrupted it before hand-over — the draft goes back to waiting at its own
* position, under whatever pauses the queue: the Stop's own pause, or the
* restart's, derived from the host instance. A returned card would block the
* drafts behind it on a failure that never happened. A failure returns the
* card with its refusal for the user to act on.
* Where no one failed the user, the draft goes back to waiting at its own position, under whatever
* pauses the queue: a Stop's, or the restart's, derived from the host instance. A Send the person
* asked for (`origin` client) that a restart or a close cut short is kept (`kept`) until they send
* it again, as the host keeps every message a person sent and it never handed over; the queue's
* own hand-off is not theirs, so it waits as any queued card does: under the restart's pause after
* a restart, and plainly queued after a close in the same process. A returned card would block the drafts
* behind it on a failure that never happened. A failure returns the card with its refusal for the
* user to act on.
*/
export function rejectedDraftSettlement(
rejection: Pick<AgentJournalSubmission, 'reason'> & { rejection?: unknown }
rejected: Pick<AgentJournalSubmission, 'reason' | 'origin'> & { rejection?: unknown }
): RejectedDraftSettlement {
return classifyDispatchRejection(rejection).verdict === null
? { state: 'waiting' }
: { state: 'returned' }
const { verdict, kind } = classifyDispatchRejection(rejected)
if (verdict !== null) {
return { state: 'returned' }
}
const cutShort = kind === 'hostRestarted' || kind === 'chatClosed'
return { state: 'waiting', kept: cutShort && rejected.origin === 'client' }
}

/** True when committing this row NEWLY settles the submission to `rejected` —
Expand Down
Loading
Loading