Skip to content
11 changes: 6 additions & 5 deletions config/reliability-gates.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -948,7 +948,7 @@
"invariant": "One structured-send operation id causes at most one provider dispatch. A recorded or transport-ambiguous send reuses that id across retry, caller reconnect, client remount, and journal recovery; only a terminal rejection may rotate to a first delivery.",
"oracle": "Inject adapter acknowledgement loss, RPC response loss, caller replacement, logical-client close after response, auth recovery with a written request, missing journal submissions, legacy pending rows, stale fences, operation expiry, mobile remount, and durable-journal capacity. Assert one provider dispatch or one operation id for every ambiguous retry, fresh identity only after rejection, and no eviction of ambiguous mobile ids.",
"commands": [
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts",
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts",
"ORCA_BACKGROUND_LAUNCH=1 pnpm --dir mobile test ../mobile/src/session/mobile-native-chat-image-attachment.test.ts ../mobile/src/session/use-mobile-native-chat-image-attachments.test.ts ../mobile/src/session/mobile-structured-send-operation-journal.test.ts ../mobile/src/session/mobile-structured-send-delivery.test.ts ../mobile/src/session/use-mobile-structured-agent-session-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session.test.tsx ../mobile/src/transport/mobile-relay-rpc-session.test.ts ../mobile/src/transport/rpc-client-delivery-ambiguity.test.ts ../mobile/src/transport/stable-logical-rpc-client.test.ts"
],
"testFiles": [
Expand All @@ -963,6 +963,7 @@
"src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx",
"src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx",
"src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx",
"src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx",
"src/renderer/src/lib/launch-structured-agent-session.test.ts",
"mobile/src/session/mobile-native-chat-image-attachment.test.ts",
"mobile/src/session/use-mobile-native-chat-image-attachments.test.ts",
Expand Down Expand Up @@ -1033,13 +1034,13 @@
],
"evidenceRuns": [
{
"date": "2026-09-12",
"date": "2026-10-04",
"runner": "local",
"platform": "macos",
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts",
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts",
"result": "passed",
"durationSeconds": 22.1,
"summary": "Thirteen focused host, shared, renderer, and orchestration files passed 148 tests."
"durationSeconds": 42.3,
"summary": "Thirteen focused host, shared, renderer, and orchestration files passed 197 tests after the delivery probe tests moved to their own file."
},
{
"date": "2026-09-12",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import '@testing-library/jest-dom/vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { MessageRow } from './NativeChatMessageRow'
import { MessageRow, type NativeChatDeliveryNotice } from './NativeChatMessageRow'

afterEach(cleanup)

Expand Down Expand Up @@ -163,8 +163,8 @@ describe('MessageRow send mode', () => {
})
})

describe('a user message that did not go through', () => {
function renderUser(deliveryNotice?: { text: string; onRetry?: () => void }) {
describe('what a user message says about its delivery', () => {
function renderUser(deliveryNotice?: NativeChatDeliveryNotice) {
return render(
<MessageRow
message={{
Expand Down Expand Up @@ -203,4 +203,41 @@ describe('a user message that did not go through', () => {
renderUser()
expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
})

// Muted, in the time's place, and shown without hover: a message nothing confirmed yet never
// looks like one that went through. Copy keeps its hover reveal, and the row its height.
it('says quietly that it is still sending in place of its time, with no Retry', () => {
renderUser({ sending: true })

const sending = screen.getByText('Sending…')
const copy = screen.getByRole('button', { name: 'Copy message' })
expect(sending).toHaveClass('text-xs', 'text-muted-foreground')
expect(Array.from(sending.parentElement!.children)).toEqual([copy, sending])
expect(sending.parentElement).not.toHaveClass('can-hover:opacity-0')
expect(sending.parentElement!.parentElement).toHaveClass('group')
expect(copy).toHaveClass('can-hover:opacity-0', 'group-hover:opacity-100')
expect(screen.queryByRole('time')).toBeNull()
expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
})

it('keeps the same row when the message is confirmed, with the time back in its place', () => {
const { rerender } = renderUser({ sending: true })
const meta = screen.getByText('Sending…').parentElement
rerender(
<MessageRow
message={{
id: 'message',
role: 'user',
timestamp: 0,
source: 'transcript',
blocks: [{ type: 'text', text: 'Message text' }]
}}
expandSignal={false}
onScrollMessageToTop={vi.fn()}
/>
)
expect(screen.queryByText('Sending…')).toBeNull()
expect(screen.getByRole('time').parentElement).toBe(meta)
expect(meta).toHaveClass('can-hover:opacity-0')
})
})
60 changes: 45 additions & 15 deletions src/renderer/src/components/native-chat/NativeChatMessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,45 @@ import type {
} from './native-chat-subagent-sections'
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'

/** What a user message says under it when it did not go through, with its own Retry when the
* surface can send it again. */
export type NativeChatDeliveryNotice = {
text: string
onRetry?: () => void
onDismiss?: () => void
/** What a user message says about its delivery: that nothing has confirmed it yet, quietly in
* place of its time, or that it did not go through, with its own Retry when the surface can send
* it again. */
export type NativeChatDeliveryNotice =
| { sending: true; text?: never; onRetry?: never; onDismiss?: never }
| { sending?: never; text: string; onRetry?: () => void; onDismiss?: () => void }

const USER_META_REVEAL =
'transition-opacity can-hover:pointer-events-none can-hover:opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 [.group:has(:focus-visible)_&]:pointer-events-auto [.group:has(:focus-visible)_&]:opacity-100'

/** Under a user message: copy + timestamp, revealed together like the agent controls row. Until
* confirmed, a quiet "Sending…" stays visible in the time's place and copy keeps its own reveal,
* so the row keeps its height when it clears. Image-only prompts have no text to copy. */
function UserMessageMeta({
markdown,
timestamp,
sending
}: {
markdown: string
timestamp: number | null
sending: boolean
}): React.JSX.Element | null {
if (!markdown && timestamp === null && !sending) {
return null
}
return (
<div className={cn('flex select-none items-center gap-1', !sending && USER_META_REVEAL)}>
{markdown ? (
<NativeChatCopyButton text={markdown} className={sending ? USER_META_REVEAL : undefined} />
) : null}
{sending ? (
<span className="text-xs whitespace-nowrap text-muted-foreground">
{translate('components.native-chat.messageSending', 'Sending…')}
</span>
) : (
<NativeChatMessageTimestamp timestamp={timestamp} focusable />
)}
</div>
)
}

/** One message: its prose first, then a collapsible run folding all of the
Expand Down Expand Up @@ -178,15 +211,12 @@ export const MessageRow = memo(function MessageRow({
<span>{translate('components.native-chat.goal.sentAsGoal', 'Sent as goal')}</span>
</div>
) : null}
{/* Copy + timestamp reveal together, mirroring the agent controls row.
Image-only prompts have no text to copy, so the button is omitted. */}
{markdown || message.timestamp !== null ? (
<div className="flex select-none items-center gap-1 transition-opacity can-hover:pointer-events-none can-hover:opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 [.group:has(:focus-visible)_&]:pointer-events-auto [.group:has(:focus-visible)_&]:opacity-100">
{markdown ? <NativeChatCopyButton text={markdown} /> : null}
<NativeChatMessageTimestamp timestamp={message.timestamp} focusable />
</div>
) : null}
{deliveryNotice ? (
<UserMessageMeta
markdown={markdown}
timestamp={message.timestamp}
sending={deliveryNotice?.sending === true}
/>
{deliveryNotice?.text !== undefined ? (
<div className="flex max-w-[85%] items-center gap-2 text-[11px] text-destructive/80">
<span className="min-w-0 break-words">{deliveryNotice.text}</span>
{deliveryNotice.onDismiss ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { act } from '@testing-library/react'
import { forwardRef, useImperativeHandle, useRef } from 'react'
import { vi } from 'vitest'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
Expand Down Expand Up @@ -27,7 +28,8 @@ function absent<T>(): T | undefined {
return undefined
}

/** Stands in for the transcript: renders only each message's delivery notice and its Retry. */
/** Stands in for the transcript: renders only each message's delivery notice and its Retry, or the
* row's quiet "Sending…" while nothing has confirmed it. */
export function DeliveryNoticesMock({
notices
}: {
Expand All @@ -37,7 +39,7 @@ export function DeliveryNoticesMock({
<div data-testid="message-list">
{[...(notices ?? [])].map(([id, notice]) => (
<div key={id} data-message-id={id}>
<span>{notice.text}</span>
<span>{notice.sending ? 'Sending…' : notice.text}</span>
{notice.onRetry ? (
<button type="button" onClick={notice.onRetry}>
Retry
Expand All @@ -49,6 +51,43 @@ export function DeliveryNoticesMock({
)
}

export function useProbeClock(): void {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
}

export async function advanceProbeClock(milliseconds: number): Promise<void> {
await act(async () => {
await vi.advanceTimersByTimeAsync(milliseconds)
})
}

export function seededEntry(
sessionId: string,
clientMessageId: string,
text: string,
state: 'queued' | 'unconfirmed'
) {
return {
clientMessageId,
sessionId,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] },
previewUris: [],
state,
queuedAt: clientMessageId === 'op-head' ? 1 : 2,
lastAttemptAt: null,
// Already force-retried once, so the automatic probe leaves the head alone
// and only the user's Retry moves it.
retryAfterUnknownSubmittedAt: -1
}
}

export function seedOutbox(sessionId: string, entries: unknown[]): void {
localStorage.setItem(
`orca:desktopStructuredAgentSessionOutbox:v1:${encodeURIComponent(sessionId)}`,
JSON.stringify(entries)
)
}

type StructuredSessionMessageListProps = {
allowFileUriLinks?: boolean
isVisible?: boolean
Expand Down
Loading
Loading