diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts
index d2e1bf3b350..a5427f76fdc 100644
--- a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts
+++ b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts
@@ -1,9 +1,14 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
-import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals'
import { openJournalDatabase } from './journal-database'
-import { classifyJournalOpenFailure } from './journal-open-failure'
+import {
+ classifyJournalOpenFailure,
+ createJournalOpenReadRefusals,
+ journalOpenReadRefusal
+} from './journal-open-failure'
import { loadJournal } from './journal-open'
import { journalDatabaseFile } from './journal-paths'
@@ -104,3 +109,51 @@ describe('classifyJournalOpenFailure', () => {
expect(classifyJournalOpenFailure(first)).toBe('journalUnavailable')
})
})
+
+describe('journalOpenReadRefusal', () => {
+ it('names the reason, keeps the message the code and the storage text only as the cause', () => {
+ vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ const storage = nodeSqliteError(26)
+ const refusal = journalOpenReadRefusal(storage)
+ expect(refusal.message).toBe('agent_session_journal_unreadable')
+ expect(refusal.refusal).toMatchObject({
+ code: 'agent_session_journal_unreadable',
+ details: { reason: 'journalCorrupt' }
+ })
+ expect(refusal.cause).toBe(storage)
+ vi.restoreAllMocks()
+ })
+
+ it('passes a refusal the open already raised through unchanged', () => {
+ const raised = agentSessionRefusalError('agent_session_identity_required', {
+ reason: 'recordMissing'
+ })
+ expect(journalOpenReadRefusal(raised)).toBe(raised)
+ })
+})
+
+describe('createJournalOpenReadRefusals', () => {
+ it('logs a session once per failure until it opens, and each session on its own', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ const refusals = createJournalOpenReadRefusals()
+ const denied = systemError('EACCES', -13)
+ const corrupt = nodeSqliteError(26)
+
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ const refusal = refusals.refusal('session-1', denied)
+ expect(refusal.refusal).toMatchObject({ details: { reason: 'journalUnavailable' } })
+ expect(refusal.cause).toBe(denied)
+ }
+ expect(warn).toHaveBeenCalledTimes(1)
+ refusals.refusal('session-2', denied)
+ expect(warn).toHaveBeenCalledTimes(2)
+ expect(refusals.refusal('session-1', corrupt).refusal).toMatchObject({
+ details: { reason: 'journalCorrupt' }
+ })
+ expect(warn).toHaveBeenCalledTimes(3)
+ refusals.forget('session-1')
+ refusals.refusal('session-1', corrupt)
+ expect(warn).toHaveBeenCalledTimes(4)
+ vi.restoreAllMocks()
+ })
+})
diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.ts
index 1835f691cb7..5e4cff1f3ee 100644
--- a/src/main/native-chat/agent-session-journal/journal-open-failure.ts
+++ b/src/main/native-chat/agent-session-journal/journal-open-failure.ts
@@ -2,6 +2,11 @@
// past, or the open failed in a way that can clear (a lock, permissions, too many open files).
import type { AgentSessionRefusalReason } from '../../../shared/agent-session-refusal-details'
+import {
+ AgentSessionRefusalError,
+ isAgentSessionRefusalError,
+ refuse
+} from '../../../shared/agent-session-wire-refusals'
import { isSqliteCorruption } from '../../sqlite/sqlite-read-failure'
export type JournalOpenFailure = AgentSessionRefusalReason<'agent_session_journal_unreadable'>
@@ -20,3 +25,56 @@ export function classifyJournalOpenFailure(error: unknown): JournalOpenFailure {
}
return 'journalUnavailable'
}
+
+/**
+ * What a read throws when the conversation it reaches cannot be opened. The storage's own text
+ * (a path, "file is not a database") goes to the log only; the reader gets the classified refusal,
+ * whose message stays the bare code.
+ */
+export function journalOpenReadRefusal(error: unknown): AgentSessionRefusalError {
+ if (isAgentSessionRefusalError(error)) {
+ return error
+ }
+ return unreadableRefusal(error, classifyJournalOpenFailure(error), true)
+}
+
+const MAX_LOGGED_SESSIONS = 256
+
+/**
+ * The read door's refusals for one host. A reader reconnects on a timer while an open can clear,
+ * so a session's failure is logged once until that session opens or the failure changes.
+ */
+export function createJournalOpenReadRefusals() {
+ const logged = new Map()
+ return {
+ refusal: (sessionId: string, error: unknown): AgentSessionRefusalError => {
+ if (isAgentSessionRefusalError(error)) {
+ return error
+ }
+ const reason = classifyJournalOpenFailure(error)
+ const failure = `${reason}:${error instanceof Error ? error.message : String(error)}`
+ const repeat = logged.get(sessionId) === failure
+ // Past the cap a new session logs every failure rather than evict another's.
+ if (!repeat && (logged.has(sessionId) || logged.size < MAX_LOGGED_SESSIONS)) {
+ logged.set(sessionId, failure)
+ }
+ return unreadableRefusal(error, reason, !repeat)
+ },
+ /** The session opened or closed: its next failure is news. */
+ forget: (sessionId: string): void => {
+ logged.delete(sessionId)
+ }
+ }
+}
+
+function unreadableRefusal(
+ error: unknown,
+ reason: JournalOpenFailure,
+ log: boolean
+): AgentSessionRefusalError {
+ if (log) {
+ console.warn('[agent-session] opening the conversation for a read failed:', error)
+ }
+ const code = 'agent_session_journal_unreadable'
+ return new AgentSessionRefusalError(refuse(code, { reason }, code), { cause: error })
+}
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts
index e81eb9ae976..0fbd1a0a233 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts
@@ -5,11 +5,11 @@
// public entry point here takes the session's serialize once and calls the under-serialize forms,
// because the queue is not reentrant.
-import { TUI_AGENT_DISPLAY_NAMES } from '../../../shared/tui-agent-display-names'
import {
AgentSessionRefusalError,
agentSessionRefusalError
} from '../../../shared/agent-session-wire-refusals'
+import { createJournalOpenReadRefusals } from '../agent-session-journal/journal-open-failure'
import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations'
import {
abandonQueuedStructuredAgentSessionMessages,
@@ -40,6 +40,7 @@ export function createStructuredAgentSessionConversationLifetime(host: {
let disposed = false
const { sessions, serialize } = host
const deps = () => host.context().deps
+ const readRefusals = createJournalOpenReadRefusals()
// The sweep's stop puts an idle agent to rest: nothing is queued, so no loop reads its cause.
const stopAgent = (sessionId: string) =>
stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId)
@@ -69,12 +70,11 @@ export function createStructuredAgentSessionConversationLifetime(host: {
return record !== null && deps().hasOpenDispatch?.(record) === true
},
stopAgent,
- // A host stop with its reason: the delivery loop waiting on this child writes the one error
- // row and rejects what is queued with it.
+ // A host stop: the delivery loop waiting on this child writes the one error row and rejects
+ // what is queued with it, both worded from the hostStopped fact.
stopStartingAgent: (sessionId) =>
stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, {
- cause: 'host-stop',
- reason: `${TUI_AGENT_DISPLAY_NAMES[sessions.get(sessionId)?.params.provider ?? 'claude']} never finished starting, so Orca stopped it.`
+ cause: 'host-stop'
}),
closeConversation,
onError: (sessionId, error) => deps().onEventSinkError?.({ sessionId, error }),
@@ -98,6 +98,7 @@ export function createStructuredAgentSessionConversationLifetime(host: {
conversation: async (sessionId: string): Promise => {
const open = sessions.get(sessionId)
if (open) {
+ readRefusals.forget(sessionId)
return open
}
const record = deps().store.getRecord(sessionId)
@@ -116,12 +117,15 @@ export function createStructuredAgentSessionConversationLifetime(host: {
if (disposed) {
throw new AgentSessionRefusalError(AGENT_SESSION_NOT_ATTACHED)
}
- const session = await host.open(sessionId)
+ const session = await host.open(sessionId).catch((error: unknown) => {
+ throw readRefusals.refusal(sessionId, error)
+ })
if (!session) {
throw agentSessionRefusalError('agent_session_identity_required', {
reason: 'recordMissing'
})
}
+ readRefusals.forget(sessionId)
return session
})
},
@@ -129,6 +133,7 @@ export function createStructuredAgentSessionConversationLifetime(host: {
* still queued will not be sent. */
close: (sessionId: string): Promise =>
serialize(sessionId, async () => {
+ readRefusals.forget(sessionId)
const session = sessions.get(sessionId)
if (session) {
// Abandoned before the stop, so no start delivers it.
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts
index 8fd9672b62f..3bad8d9789f 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts
@@ -12,6 +12,7 @@ import {
} from '../../../shared/agent-session-wire'
import { decodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
+import { journalOpenReadRefusal } from '../agent-session-journal/journal-open-failure'
import { isClaudeStructuredOptionKey } from '../../claude/claude-structured-options'
import { isCodexTurnOptionKey } from '../../codex/codex-structured-turn-start'
import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'
@@ -96,7 +97,9 @@ export async function readStructuredAgentSessionOptions(
): Promise {
const { adapter, store } = context.deps
const live = await context.serialize(sessionId, async () => {
- const session = await context.openConversation(sessionId)
+ const session = await context.openConversation(sessionId).catch((error: unknown) => {
+ throw journalOpenReadRefusal(error)
+ })
const child = session?.child
if (!child) {
return null
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts
index c0660eb9791..77ba7fe5f58 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts
@@ -1,5 +1,6 @@
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission'
+import { structuredAgentSessionStartFailureRowIdentity } from '../../../shared/structured-agent-session-start-failure-row-key'
import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders'
import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types'
import type { StructuredAgentSessionStartFailureWords } from './structured-agent-session-failure-text'
@@ -21,7 +22,7 @@ export function structuredAgentSessionStartFailureRow(
): JournalLifecycleMutationInput {
return {
kind: 'item',
- identity: startFailureRowIdentity(startKey),
+ identity: structuredAgentSessionStartFailureRowIdentity(startKey),
// The row repeats the sentence the start's rejected messages carry.
body: { kind: 'status', text: words.reason, tone: 'error', failure: words.rejection }
}
@@ -33,14 +34,10 @@ export function hasStructuredAgentSessionStartFailureRow(
items: readonly { itemId: string }[],
startKey: string
): boolean {
- const itemId = agentJournalItemKey(startFailureRowIdentity(startKey))
+ const itemId = agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity(startKey))
return items.some((item) => item.itemId === itemId)
}
-function startFailureRowIdentity(startKey: string) {
- return { provider: 'orca' as const, clientMessageId: `start-failure:${startKey}` }
-}
-
/**
* A start the delivery loop needed and did not get: the start's row, and every queued message
* rejected with the same words. Writes nothing when nothing is still queued: a start whose
diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts
index a3d598a69d8..df00957215f 100644
--- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts
+++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts
@@ -236,6 +236,78 @@ describe('the accessor', () => {
})
})
+ it('refuses a read whose journal will not open with the classified reason, never the storage text', async () => {
+ await restingChat()
+ const open = vi.spyOn(rig.host.collaboratorsForTests().conversationDelivery, 'open')
+ vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ const storagePath = '/Users/someone/.orca/journals/session-1/journal.sqlite'
+ const failWith = (error: Error): void => {
+ open.mockRejectedValue(error)
+ }
+ const failures = async (): Promise =>
+ [
+ ...(await call('agentSession.history', { sessionId: SESSION, direction: 'tail' })),
+ ...(await call('agentSession.subscribe', { sessionId: SESSION })),
+ ...(await call('agentSession.options', { sessionId: SESSION }))
+ ].filter((reply) => !reply.ok)
+
+ failWith(
+ Object.assign(new Error(`file is not a database: ${storagePath}`), {
+ code: 'ERR_SQLITE_ERROR',
+ errcode: 26
+ })
+ )
+ const corrupt = await failures()
+ failWith(Object.assign(new Error(`EACCES: permission denied, open '${storagePath}'`), {}))
+ const unavailable = await failures()
+
+ for (const [replies, reason] of [
+ [corrupt, 'journalCorrupt'],
+ [unavailable, 'journalUnavailable']
+ ] as const) {
+ expect(replies).toHaveLength(3)
+ for (const reply of replies) {
+ expect(reply).toMatchObject({
+ ok: false,
+ error: {
+ // Not a passthrough code: released clients read the message, which stays the code.
+ code: 'runtime_error',
+ message: 'agent_session_journal_unreadable',
+ data: { refusal: { code: 'agent_session_journal_unreadable', details: { reason } } }
+ }
+ })
+ expect(JSON.stringify(reply)).not.toContain(storagePath)
+ }
+ }
+ })
+
+ it('logs a reader reconnecting to a journal that will not open once per failure', async () => {
+ await restingChat()
+ const open = vi.spyOn(rig.host.collaboratorsForTests().conversationDelivery, 'open')
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ const logged = (): unknown[] =>
+ warn.mock.calls
+ .filter(([line]) => line === '[agent-session] opening the conversation for a read failed:')
+ .map(([, error]) => error)
+ const reconnect = async (): Promise => [
+ ...(await call('agentSession.subscribe', { sessionId: SESSION })),
+ ...(await call('agentSession.history', { sessionId: SESSION, direction: 'tail' }))
+ ]
+ const denied = new Error('EACCES: permission denied')
+ const exhausted = new Error('EMFILE: too many open files')
+
+ open.mockRejectedValue(denied)
+ for (let attempt = 0; attempt < 4; attempt += 1) {
+ // Every attempt is still refused with its reason; only the log is quiet.
+ expect((await reconnect()).filter((reply) => !reply.ok)).toHaveLength(2)
+ }
+ expect(logged()).toEqual([denied])
+ open.mockRejectedValue(exhausted)
+ await reconnect()
+ await reconnect()
+ expect(logged()).toEqual([denied, exhausted])
+ })
+
it('opens a corrupt journal through the recovering open and still accepts a send (P2-03)', async () => {
await foundRestTestChat(rig)
await rig.host.flushAllStreamedEvents()
diff --git a/src/renderer/src/components/native-chat/NativeChatDeliveryRetry.test.tsx b/src/renderer/src/components/native-chat/NativeChatDeliveryRetry.test.tsx
deleted file mode 100644
index a897588f7c5..00000000000
--- a/src/renderer/src/components/native-chat/NativeChatDeliveryRetry.test.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-// @vitest-environment happy-dom
-import '@testing-library/jest-dom/vitest'
-import { cleanup, render, screen } from '@testing-library/react'
-import { afterEach, describe, expect, it } from 'vitest'
-import {
- createStructuredAgentSessionOutboxEntry,
- type StructuredAgentSessionOutboxEntry
-} from '../../../../shared/structured-agent-session-outbox'
-import { NativeChatDeliveryRetry } from './NativeChatDeliveryRetry'
-
-const blocked = createStructuredAgentSessionOutboxEntry({
- clientMessageId: 'client-1',
- sessionId: 'session-1',
- text: 'hello',
- attachments: [],
- queuedAt: 1
-})
-
-function renderRow(entry: StructuredAgentSessionOutboxEntry): void {
- render(
- {}}
- />
- )
-}
-
-afterEach(cleanup)
-
-describe("the Retry row's reason", () => {
- it('chooses its words from the saved failure when it is shown', () => {
- renderRow({
- ...blocked,
- lastFailure: { kind: 'refused', code: 'agent_session_owner_restart_failed' }
- })
-
- expect(
- screen.getByText("The agent couldn't restart. Your message was not sent.")
- ).toBeInTheDocument()
- })
-
- it("shows a provider's rejection in the provider's words", () => {
- renderRow({
- ...blocked,
- lastFailure: { kind: 'rejected', reason: 'Claude messages support at most 20 images' }
- })
-
- expect(screen.getByText('Claude messages support at most 20 images')).toBeInTheDocument()
- })
-
- it('says only that the message was not sent when nothing more is known', () => {
- renderRow(blocked)
-
- expect(screen.getByText('Message was not sent.')).toBeInTheDocument()
- })
-})
diff --git a/src/renderer/src/components/native-chat/NativeChatDeliveryRetry.tsx b/src/renderer/src/components/native-chat/NativeChatDeliveryRetry.tsx
deleted file mode 100644
index e37016d646d..00000000000
--- a/src/renderer/src/components/native-chat/NativeChatDeliveryRetry.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import { RotateCcw } from 'lucide-react'
-import {
- admitStructuredAgentSessionOutboxEntry,
- type StructuredAgentSessionOutboxEntry
-} from '../../../../shared/structured-agent-session-outbox'
-import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition'
-import { agentSessionWriteNoticeText } from './agent-session-write-notice-text'
-import { Button } from '@/components/ui/button'
-import { translate } from '@/i18n/i18n'
-
-export function NativeChatDeliveryRetry({
- outbox,
- blockedClientMessageId,
- retry
-}: {
- outbox: readonly StructuredAgentSessionOutboxEntry[]
- blockedClientMessageId: string | null
- retry: (clientMessageId: string) => void
-}): React.JSX.Element | null {
- // Why: read through the drain's own rule, so Retry can never name an entry other than the one
- // the queue actually stopped on -- which is no longer always the head. A rejected entry holds
- // nothing up, so it is offered only when the queue itself is not stopped.
- const admission = admitStructuredAgentSessionOutboxEntry(outbox, blockedClientMessageId)
- const retryable =
- admission.state === 'blocked'
- ? admission.entry
- : (outbox.find((entry) => entry.state === 'rejected') ?? null)
- if (!retryable) {
- return null
- }
- return (
-
-
- {retryable.state === 'unconfirmed'
- ? translate(
- 'auto.components.native.chat.NativeChatStructuredSession.1f772bb5d0',
- 'Message delivery is unconfirmed.'
- )
- : retryable.lastFailure
- ? agentSessionWriteNoticeText(
- structuredAgentSessionAttemptFailureParts(retryable.lastFailure)
- )
- : translate(
- 'auto.components.native.chat.NativeChatStructuredSession.93ef441197',
- 'Message was not sent.'
- )}
-
- retry(retryable.clientMessageId)}
- >
-
- {translate('auto.components.native.chat.NativeChatStructuredSession.a5e7f14068', 'Retry')}
-
-
- )
-}
diff --git a/src/renderer/src/components/native-chat/NativeChatEmptyState.test.tsx b/src/renderer/src/components/native-chat/NativeChatEmptyState.test.tsx
index dc2639afca2..dccfaba728c 100644
--- a/src/renderer/src/components/native-chat/NativeChatEmptyState.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatEmptyState.test.tsx
@@ -30,9 +30,27 @@ it('shows the host message in place of the terminal-backed default', () => {
expect(screen.queryByText(/Toggle back to the terminal/)).toBeNull()
})
-it('keeps the structured chat retrying line when the host sent a message', () => {
- render( )
+// The structured chat passes words only from the notice table, never the host's.
+it("says the structured chat's own words for the failure once, as the title, above its retrying line", () => {
+ render(
+
+ )
+ expect(screen.getByText("Orca couldn't open this chat's history right now.")).toHaveClass(
+ 'font-medium'
+ )
+ expect(screen.queryByText('Could not load conversation')).toBeNull()
expect(
screen.getByText('The transcript could not be read. Orca keeps trying to load it.')
).toBeInTheDocument()
})
+
+it('says a failure no retry gets past in its one line, with nothing of trying again', () => {
+ render( )
+ expect(screen.getByText('Unable to load this chat.')).toHaveClass('font-medium')
+ expect(screen.queryByText('Could not load conversation')).toBeNull()
+ expect(screen.queryByText(/keeps trying|Toggle back/)).toBeNull()
+})
diff --git a/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx b/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx
index ed2299d2fc2..ec84f554cd7 100644
--- a/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx
@@ -7,17 +7,20 @@ import type { NativeChatSession } from '../../../../shared/native-chat-types'
export function NativeChatEmptyState({
kind,
message,
+ headline,
agent,
retrying = false
}: {
kind: 'loading' | 'empty' | 'error' | 'not-agent'
message?: string
+ /** The chat's own sentence for the failure, said once: it takes the generic title's place. */
+ headline?: string
agent?: NativeChatSession['agent']
/** The read retries on its own (structured chat), so the error says so instead of pointing
- * back to the terminal. The host's message is on the pane's status line already. */
+ * back to the terminal. */
retrying?: boolean
}): React.JSX.Element {
- const copy = emptyStateCopy(kind, message, agent, retrying)
+ const copy = emptyStateCopy(kind, { message, headline }, agent, retrying)
return (
void
}): React.JSX.Element | null {
if (lifecycle !== 'failed' && lifecycle !== 'visibility-unknown') {
@@ -25,11 +32,17 @@ export function NativeChatLaunchRetry({
'auto.components.native.chat.NativeChatLaunchRetry.unknown',
'Chat connection could not be confirmed.'
)
+ const cause =
+ lifecycle === 'failed' && failure
+ ? agentSessionWriteNoticeText(
+ agentSessionRefusalCauseParts(failure, agentLabel ? { agentName: agentLabel } : {})
+ )
+ : ''
return (
{message}
- {lifecycle === 'failed' && failureReason ? ` ${failureReason}` : null}
+ {cause ? ` ${cause}` : null}
diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx
index d8ea96d2f0c..3562065c151 100644
--- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx
@@ -22,6 +22,7 @@ import {
} from './native-chat-disclosure-store'
import { NativeChatTranscriptItems } from './NativeChatTranscriptItems'
import type { NativeChatTranscriptRowContext } from './NativeChatTranscriptRow'
+import type { NativeChatDeliveryNotice } from './NativeChatMessageRow'
import {
buildNativeChatTranscriptSlots,
nativeChatSlotIndexOf
@@ -69,7 +70,7 @@ export function NativeChatMessageList({
allowFileUriLinks = false,
workingStartedAt,
settledTurns,
- failedDeliveryMessageIds,
+ deliveryNotices,
showTurnStatus = true,
showLiveTurnActivity = true,
turnActivity,
@@ -90,7 +91,7 @@ export function NativeChatMessageList({
settledTurns?: NativeChatSettledTurns
onLinkClick?: CommentMarkdownLinkClickHandler
allowFileUriLinks?: boolean
- failedDeliveryMessageIds?: ReadonlySet
+ deliveryNotices?: ReadonlyMap
/** Turn timing and disclosure are available on structured agent sessions. */
showTurnStatus?: boolean
/** Whether the active turn's foreground activity row should be visible. */
@@ -328,7 +329,7 @@ export function NativeChatMessageList({
revealedDiff,
taskListPredecessors,
expandedTurnIds,
- failedDeliveryMessageIds,
+ deliveryNotices,
allowFileUriLinks,
runtimeContext,
onLinkClick,
@@ -340,7 +341,7 @@ export function NativeChatMessageList({
allowFileUriLinks,
expandSignal,
expandedTurnIds,
- failedDeliveryMessageIds,
+ deliveryNotices,
onLinkClick,
revealDiff,
revealedDiff,
diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx
index 4b29ebf57c5..f11e41a6e2d 100644
--- a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx
@@ -162,3 +162,45 @@ describe('MessageRow send mode', () => {
expect(screen.queryByText('Sent as goal')).not.toBeInTheDocument()
})
})
+
+describe('a user message that did not go through', () => {
+ function renderUser(deliveryNotice?: { text: string; onRetry?: () => void }) {
+ return render(
+
+ )
+ }
+
+ it('says why under the message, with a Retry that sends this one', () => {
+ const onRetry = vi.fn()
+ renderUser({ text: "The agent couldn't restart. Your message was not sent.", onRetry })
+
+ expect(
+ screen.getByText("The agent couldn't restart. Your message was not sent.")
+ ).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
+ expect(onRetry).toHaveBeenCalledOnce()
+ })
+
+ it('offers no Retry where the surface cannot send it again', () => {
+ renderUser({ text: 'Not delivered — check the terminal' })
+
+ expect(screen.getByText('Not delivered — check the terminal')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
+ })
+
+ 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 52dc6f62a3b..c7ed087e918 100644
--- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx
@@ -1,5 +1,6 @@
import { memo, useCallback, useRef } from 'react'
-import { Goal } from 'lucide-react'
+import { Goal, RotateCcw } from 'lucide-react'
+import { Button } from '@/components/ui/button'
import CommentMarkdown, {
type CommentMarkdownLinkClickHandler
} from '@/components/sidebar/CommentMarkdown'
@@ -23,6 +24,10 @@ import {
import type { NativeChatDiffReveal } from './native-chat-turn-diffs'
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 }
+
/** One message: its prose first, then a collapsible run folding all of the
* turn's tool activity. Monochrome per STYLEGUIDE: user prompts read as a
* lifted card, assistant prose as body copy, reasoning de-emphasized.
@@ -39,7 +44,7 @@ export const MessageRow = memo(function MessageRow({
onScrollMessageToTop,
onLinkClick,
allowFileUriLinks = false,
- deliveryFailed = false,
+ deliveryNotice,
structuredActivityUi = true,
folded = false,
runtimeContext
@@ -56,7 +61,7 @@ export const MessageRow = memo(function MessageRow({
onScrollMessageToTop: (el: HTMLElement) => void
onLinkClick?: CommentMarkdownLinkClickHandler
allowFileUriLinks?: boolean
- deliveryFailed?: boolean
+ deliveryNotice?: NativeChatDeliveryNotice
structuredActivityUi?: boolean
/** Behind a folded turn: the row keeps only what outlives the turn. */
folded?: boolean
@@ -167,12 +172,18 @@ export const MessageRow = memo(function MessageRow({
) : null}
- {deliveryFailed ? (
-
- {translate(
- 'components.native-chat.launchPromptNotDelivered',
- 'Not delivered — check the terminal'
- )}
+ {deliveryNotice ? (
+
+ {deliveryNotice.text}
+ {deliveryNotice.onRetry ? (
+
+
+ {translate(
+ 'auto.components.native.chat.NativeChatStructuredSession.a5e7f14068',
+ 'Retry'
+ )}
+
+ ) : null}
) : null}
diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx
index e711d62245a..fb432e1e5b4 100644
--- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx
@@ -6,6 +6,7 @@ import { useNativeChatRetainedSession } from './use-native-chat-retained-session
import { isNativeChatTranscriptUnsettled } from './native-chat-live-session-contract'
import { selectNativeChatViewState } from './native-chat-view-state'
import { NativeChatMessageList } from './NativeChatMessageList'
+import { useNativeChatLaunchPromptDeliveryNotice } from './use-native-chat-launch-prompt-delivery-notice'
import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatComposer'
import { useNativeChatFontScale } from './use-native-chat-font-scale'
import { useNativeChatCanSend } from './use-native-chat-can-send'
@@ -246,13 +247,10 @@ export function NativeChatResolvedView({
? sessionWithLaunchPrompt
: { ...sessionWithLaunchPrompt, messages }
}, [sessionWithLaunchPrompt, commandMarkers])
- const failedLaunchPromptMessageIds = useMemo(() => {
- const id = paneLaunchPrompt?.failed ? launchPromptMessage?.id : null
- if (!id || !sessionAfterCommandBoundaries.messages.some((message) => message.id === id)) {
- return undefined
- }
- return new Set([id])
- }, [paneLaunchPrompt?.failed, launchPromptMessage?.id, sessionAfterCommandBoundaries.messages])
+ const launchPromptDeliveryNotices = useNativeChatLaunchPromptDeliveryNotice(
+ paneLaunchPrompt?.failed ? launchPromptMessage?.id : null,
+ sessionAfterCommandBoundaries.messages
+ )
// The streaming preview bubble (if any) sits after the transcript but before
// the optimistic user echoes — same order mobile uses.
@@ -408,7 +406,7 @@ export function NativeChatResolvedView({
showTurnStatus={false}
onLinkClick={onLinkClick}
allowFileUriLinks={fileLinkContext !== null}
- failedDeliveryMessageIds={failedLaunchPromptMessageIds}
+ deliveryNotices={launchPromptDeliveryNotices}
/>
)}
diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx
index cb777f910f2..f86fc972c22 100644
--- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx
@@ -4,6 +4,10 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react'
import type * as ReactModule from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options'
+import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result'
+
+const toastError = vi.hoisted(() => vi.fn())
+vi.mock('sonner', () => ({ toast: { error: toastError } }))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, values?: Record
) => {
@@ -441,6 +445,55 @@ describe('NativeChatSessionOptionPickers', () => {
await waitFor(() => expect(invokeAction).toHaveBeenCalledWith('model'))
})
+ it.each([
+ [
+ "the table's words for a host's refusal, not its message",
+ new RuntimeRpcCallError({
+ id: 'request-1',
+ ok: false,
+ error: {
+ code: 'runtime_error',
+ message: 'agent_session_journal_unreadable',
+ data: {
+ refusal: {
+ code: 'agent_session_journal_unreadable',
+ details: { reason: 'journalCorrupt' }
+ }
+ }
+ },
+ _meta: { runtimeId: 'runtime-1' }
+ }),
+ "Unable to load this chat. The setting wasn't changed."
+ ],
+ [
+ "a local surface's own sentence",
+ new Error('The terminal did not accept the command.'),
+ 'The terminal did not accept the command.'
+ ]
+ ])('describes a failed option change with %s', async (_label, error, description) => {
+ toastError.mockClear()
+ const invokeAction = vi.fn().mockRejectedValue(error)
+ render(
+
+ )
+ screen.getByRole('button', { name: 'Choose in agent picker…' }).click()
+ await waitFor(() =>
+ expect(toastError).toHaveBeenCalledExactlyOnceWith('Could not update option', {
+ description
+ })
+ )
+ })
+
it('uses a Toggle action for unknown flip-only options via invokeAction', async () => {
const invokeAction = vi.fn().mockResolvedValue({ snapshot: [] })
const setOption = vi.fn().mockResolvedValue({ snapshot: [] })
diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx
index 507103d485f..0dacff0289e 100644
--- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx
@@ -32,6 +32,9 @@ import {
nativeChatSessionOptionLabel
} from './native-chat-session-option-labels'
import type { NativeChatOptionPickerRequest } from './native-chat-composer-types'
+import { agentSessionThrownFailure } from '../../../../shared/agent-session-write-failure'
+import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result'
+import { agentSessionWriteFailureText } from './agent-session-write-notice-text'
export type NativeChatSessionOptionPickersProps = {
surface: SessionOptionsSurface | null
@@ -204,6 +207,15 @@ function DescriptorMenuRows(props: {
)
}
+/** A host's error is words for its log, so it gets the table's; a local surface's error is
+ * already written for the person. */
+function optionUpdateFailureDescription(error: unknown): string {
+ if (error instanceof RuntimeRpcCallError) {
+ return agentSessionWriteFailureText(agentSessionThrownFailure(error, error.code), 'option')
+ }
+ return error instanceof Error ? error.message : String(error)
+}
+
function runSurfaceCall(
pendingKey: string,
setPendingId: (id: string | null) => void,
@@ -214,7 +226,7 @@ function runSurfaceCall(
.catch((error) => {
toast.error(
translate('components.native-chat.composer.optionUpdateFailed', 'Could not update option'),
- { description: error instanceof Error ? error.message : String(error) }
+ { description: optionUpdateFailureDescription(error) }
)
})
.finally(() => setPendingId(null))
diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx
index 9513d0cfeb6..644c1c65750 100644
--- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx
@@ -26,6 +26,14 @@ vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard
import { NativeChatStructuredSession } from './NativeChatStructuredSession'
import { readOutbox } from './structured-agent-session-outbox-storage'
+const NOT_SIGNED_IN = {
+ kind: 'refused',
+ code: 'agent_session_operation_invalid',
+ details: { reason: 'notSignedIn' }
+} as const
+// Retry beside it is the resend, so the words keep only the step before it.
+const NOT_SIGNED_IN_TEXT = 'Codex is not signed in for the selected account. Sign in first.'
+
function sessionView(): React.JSX.Element {
return (
{
expect(mocks.retryLaunch).toHaveBeenCalledWith('wt-1', 'session-1')
})
- it('shows why a failed launch failed beside Retry', () => {
+ it('words why a failed launch failed beside Retry from the refusal, never its code', () => {
mocks.launchLifecycle = 'failed'
- mocks.launchFailureReason = 'claude is not signed in'
+ mocks.launchFailure = NOT_SIGNED_IN
render(sessionView())
- expect(screen.getByText('Chat could not be started. claude is not signed in')).toBeTruthy()
+ expect(screen.getByText(`Chat could not be started. ${NOT_SIGNED_IN_TEXT}`)).toBeTruthy()
+ expect(screen.queryByText(/agent_session_/)).toBeNull()
+ })
+
+ it("keeps a step the Retry doesn't take, and drops one it does", () => {
+ mocks.launchLifecycle = 'failed'
+ mocks.launchFailure = {
+ kind: 'refused',
+ code: 'agent_session_conflict',
+ details: { reason: 'claimConflicted' }
+ }
+ const { rerender } = render(sessionView())
+ expect(
+ screen.getByText(
+ 'Chat could not be started. This chat is still open in a terminal agent. Quit that agent to continue the chat here.'
+ )
+ ).toBeTruthy()
+
+ mocks.launchFailure = {
+ kind: 'refused',
+ code: 'agent_session_journal_unreadable',
+ details: { reason: 'journalUnavailable' }
+ }
+ rerender(sessionView())
+ expect(
+ screen.getByText(
+ "Chat could not be started. Orca couldn't open this chat's history right now."
+ )
+ ).toBeTruthy()
+ })
+
+ it('says only that the chat could not start when the refusal names no reason', () => {
+ mocks.launchLifecycle = 'failed'
+ mocks.launchFailure = { kind: 'refused', code: 'agent_session_operation_invalid' }
+ render(sessionView())
+
+ expect(screen.getByText('Chat could not be started.')).toBeTruthy()
+ expect(screen.queryByText(/agent_session_/)).toBeNull()
})
it('keeps a stale reason off a launch that is no longer failed', () => {
mocks.launchLifecycle = 'visibility-unknown'
- mocks.launchFailureReason = 'claude is not signed in'
+ mocks.launchFailure = NOT_SIGNED_IN
render(sessionView())
expect(screen.getByText('Chat connection could not be confirmed.')).toBeTruthy()
@@ -177,10 +222,10 @@ describe('NativeChatStructuredSession launch lifecycle', () => {
const { rerender } = render(sessionView())
expect(composerSend()('still there?', [])).toBe(true)
- mocks.launchFailureReason = 'claude is not signed in'
+ mocks.launchFailure = NOT_SIGNED_IN
rerender(sessionView())
- expect(screen.getByText('Chat could not be started. claude is not signed in')).toBeTruthy()
+ expect(screen.getByText(`Chat could not be started. ${NOT_SIGNED_IN_TEXT}`)).toBeTruthy()
expect(mocks.call).not.toHaveBeenCalled()
expect(readOutbox('session-1')).toEqual([
expect.objectContaining({
diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx
index 8edcd625ef4..89ef518feb3 100644
--- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx
@@ -26,13 +26,7 @@ afterEach(() => {
resetStructuredSessionMocks()
})
-// The read transport always hands the pane the host's words, so the retrying line must not hide
-// behind them; those words show once, on the status line.
-it('says a failed read keeps retrying, and shows the host message once', () => {
- mocks.status = 'error'
- mocks.readError = 'journal unreadable'
- mocks.messages = []
-
+function renderPane(): void {
render(
{
agent="codex"
/>
)
+}
+
+function journalRefusal(reason: 'journalCorrupt' | 'journalUnavailable') {
+ return { code: 'agent_session_journal_unreadable', details: { reason } } as const
+}
+
+// The host's message and code never reach the pane; it words the refusal, and says it once.
+it('says a failed read with no refusal keeps retrying, and adds nothing of the host', () => {
+ mocks.status = 'error'
+ mocks.messages = []
+
+ renderPane()
expect(screen.getByText('Could not load conversation')).toBeTruthy()
expect(
screen.getByText('The transcript could not be read. Orca keeps trying to load it.')
).toBeTruthy()
- expect(screen.getAllByText('journal unreadable')).toHaveLength(1)
+ expect(screen.queryByText(/history couldn't be loaded/)).toBeNull()
expect(screen.queryByText(/Toggle back to the terminal/)).toBeNull()
})
+
+it('says a damaged history cannot load in one line, without claiming Orca keeps trying', () => {
+ mocks.status = 'error'
+ mocks.readRefusal = journalRefusal('journalCorrupt')
+ mocks.messages = []
+
+ renderPane()
+
+ expect(screen.getAllByText('Unable to load this chat.')).toHaveLength(1)
+ expect(screen.queryByText('Could not load conversation')).toBeNull()
+ expect(screen.queryByText(/keeps trying/)).toBeNull()
+ expect(screen.queryByText(/agent_session_/)).toBeNull()
+})
+
+it("names a history that couldn't open right now once, and that the pane keeps trying", () => {
+ mocks.status = 'error'
+ mocks.readRefusal = journalRefusal('journalUnavailable')
+ mocks.messages = []
+
+ renderPane()
+
+ expect(screen.getAllByText("Orca couldn't open this chat's history right now.")).toHaveLength(1)
+ expect(screen.queryByText('Could not load conversation')).toBeNull()
+ expect(
+ screen.getByText('The transcript could not be read. Orca keeps trying to load it.')
+ ).toBeTruthy()
+ expect(screen.queryByText(/Try again/)).toBeNull()
+})
+
+it('says only that it is reconnecting, not as an error, when a failure names nothing', () => {
+ mocks.status = 'error'
+
+ renderPane()
+
+ expect(screen.getByTestId('message-list')).toBeTruthy()
+ const reconnecting = screen.getByText('Reconnecting to this chat…')
+ expect(reconnecting.className).not.toContain('text-destructive')
+ expect(screen.queryByText(/history couldn't be loaded/)).toBeNull()
+})
+
+it('words a failed reconnect beside a transcript it keeps', () => {
+ mocks.status = 'error'
+ mocks.readRefusal = journalRefusal('journalUnavailable')
+
+ renderPane()
+
+ expect(screen.getByTestId('message-list')).toBeTruthy()
+ expect(screen.getByText("Orca couldn't open this chat's history right now.")).toBeTruthy()
+})
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
new file mode 100644
index 00000000000..6f529c1d3c9
--- /dev/null
+++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx
@@ -0,0 +1,163 @@
+// @vitest-environment happy-dom
+
+import { cleanup, render, screen, waitFor, within } from '@testing-library/react'
+import { afterEach, expect, it, vi } from 'vitest'
+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 type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
+import { structuredAgentSessionStartFailureRowIdentity } from '../../../../shared/structured-agent-session-start-failure-row-key'
+
+const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () =>
+ (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks()
+)
+
+vi.mock('@/runtime/structured-agent-session-client', () =>
+ moduleFactories.structuredAgentSessionClient()
+)
+vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession())
+vi.mock('./use-native-chat-font-scale', () => moduleFactories.useNativeChatFontScale())
+vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext())
+vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick())
+vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList())
+vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer())
+vi.mock('./NativeChatEmptyState', () => moduleFactories.nativeChatEmptyState())
+vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard())
+vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard())
+
+import { NativeChatStructuredSession } from './NativeChatStructuredSession'
+
+afterEach(() => {
+ cleanup()
+ localStorage.clear()
+ resetStructuredSessionMocks()
+})
+
+const SESSION_ID = 'start-failure-session'
+const START_FAILED: AgentSessionFailureFact = { kind: 'providerStartFailed' }
+const START_FAILED_REASON =
+ 'Claude stopped before it finished starting. Send your message to try again.'
+
+function startFailureRow(fact: AgentSessionFailureFact): AgentJournalRenderItem {
+ return {
+ itemId: agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('generation-1')),
+ revision: 1,
+ sequence: 1,
+ observedAt: 1,
+ body: {
+ kind: 'status',
+ tone: 'error',
+ ...agentSessionFailureWords(fact, { agentName: 'Claude', surface: 'row' })
+ }
+ }
+}
+
+function rejected(clientMessageId: string, reason: string, rejection: AgentSessionFailureFact) {
+ return {
+ outbox: {
+ clientMessageId,
+ sessionId: SESSION_ID,
+ body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] },
+ previewUris: [],
+ state: 'rejected',
+ queuedAt: 1,
+ lastAttemptAt: null,
+ retryAfterUnknownSubmittedAt: null,
+ lastFailure: { kind: 'rejected', reason, rejection: { kind: rejection.kind } }
+ },
+ submission: {
+ clientMessageId,
+ fence: 1,
+ payloadFingerprint: clientMessageId,
+ dispatchState: 'rejected',
+ providerItemId: null,
+ reason,
+ rejection,
+ submittedAt: 1,
+ resolvedAt: 1
+ }
+ }
+}
+
+function renderPane(messages: ReturnType[]): void {
+ mocks.mode = 'outbox'
+ mocks.submissions = messages.map((message) => message.submission)
+ localStorage.setItem(
+ `orca:desktopStructuredAgentSessionOutbox:v1:${encodeURIComponent(SESSION_ID)}`,
+ JSON.stringify(messages.map((message) => message.outbox))
+ )
+ render(
+
+ )
+}
+
+async function notice(clientMessageId: string): Promise {
+ return waitFor(() => {
+ const row = document.querySelector(
+ `[data-message-id="${agentJournalSubmissionKey(clientMessageId)}"]`
+ )
+ if (!row) {
+ throw new Error(`no notice on ${clientMessageId}`)
+ }
+ return row
+ })
+}
+
+// The start's own row says why, so its rejected messages say only that they were not sent.
+it("says only 'not sent', with its Retry, on each message the failed start's row explains", async () => {
+ mocks.journalItems = [startFailureRow(START_FAILED)]
+
+ renderPane([
+ rejected('first', START_FAILED_REASON, START_FAILED),
+ rejected('second', START_FAILED_REASON, START_FAILED)
+ ])
+
+ for (const id of ['first', 'second']) {
+ const row = await notice(id)
+ expect(within(row).getByText('Your message was not sent.')).toBeTruthy()
+ expect(within(row).getByRole('button', { name: 'Retry' })).toBeTruthy()
+ }
+ expect(screen.queryByText(/stopped before it finished starting/)).toBeNull()
+})
+
+it('keeps the full notice on a message rejected for a reason no start-failure row states', async () => {
+ mocks.journalItems = [startFailureRow(START_FAILED)]
+ const providerRejected: AgentSessionFailureFact = {
+ kind: 'providerRejected',
+ detail: { text: 'Image type .bmp', audience: 'person' }
+ }
+
+ renderPane([
+ rejected('stated', START_FAILED_REASON, START_FAILED),
+ rejected(
+ 'other',
+ 'The provider did not accept this message: Image type .bmp.',
+ providerRejected
+ )
+ ])
+
+ 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()
+})
+
+it("keeps the start failure's own words when its row is not loaded", async () => {
+ renderPane([rejected('first', START_FAILED_REASON, START_FAILED)])
+
+ expect(
+ within(await notice('first')).getByText('Claude stopped before it finished starting.')
+ ).toBeTruthy()
+})
diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx
index 5690c879db1..5689cf9689f 100644
--- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx
@@ -2,7 +2,10 @@ import { forwardRef, useImperativeHandle, useRef } from 'react'
import { vi } from 'vitest'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
import type { AgentSessionBackgroundTask } from '../../../../shared/agent-session-wire'
+import type { AgentSessionWriteRefusal } from '../../../../shared/agent-session-write-failure'
+import type { AgentSessionRefusalReference } from '../../../../shared/agent-session-wire-refusals'
import type { NativeChatApprovalCardProps } from './NativeChatApprovalCard'
+import type { NativeChatDeliveryNotice } from './NativeChatMessageRow'
import type { NativeChatQuestionCardProps } from './NativeChatQuestionCard'
import type { NativeChatLaunchSeed } from './native-chat-composer-types'
import type { NativeChatOlderPageResult } from './native-chat-pagination'
@@ -19,6 +22,32 @@ function nullable(): T | null {
return null
}
+function absent(): T | undefined {
+ return undefined
+}
+
+/** Stands in for the transcript: renders only each message's delivery notice and its Retry. */
+export function DeliveryNoticesMock({
+ notices
+}: {
+ notices?: ReadonlyMap
+}): React.JSX.Element {
+ return (
+
+ {[...(notices ?? [])].map(([id, notice]) => (
+
+ {notice.text}
+ {notice.onRetry ? (
+
+ Retry
+
+ ) : null}
+
+ ))}
+
+ )
+}
+
type StructuredSessionMessageListProps = {
allowFileUriLinks?: boolean
isVisible?: boolean
@@ -28,6 +57,7 @@ type StructuredSessionMessageListProps = {
isWorking?: boolean
runtimeContext?: unknown
session?: { hasMore: boolean; loadingEarlier: boolean; loadEarlier: () => Promise }
+ deliveryNotices?: ReadonlyMap
}
const initialMessageListProps: StructuredSessionMessageListProps | null = null
@@ -43,13 +73,13 @@ export function createStructuredSessionMocks() {
call: vi.fn<(...args: never[]) => unknown>(),
fileLinkClick: vi.fn<(...args: never[]) => unknown>(),
launchLifecycle: nullable(),
- launchFailureReason: nullable(),
+ launchFailure: nullable(),
launchResumes: false,
retryLaunch: vi.fn<(...args: never[]) => unknown>(),
controllerProps: nullable<{ transportEnabled?: boolean }>(),
mode: 'static' as 'static' | 'outbox',
status: 'ready' as 'idle' | 'loading' | 'ready' | 'error',
- readError: nullable(),
+ readRefusal: absent(),
messages: null as null | unknown[],
messageListProps: initialMessageListProps,
composerProps: null as null | {
@@ -60,6 +90,7 @@ export function createStructuredSessionMocks() {
approvalCardProps: initialApprovalCardProps,
questionCardProps: null as NativeChatQuestionCardProps | null,
promptItems: [] as AgentJournalRenderItem[],
+ journalItems: new Array(),
respond: vi.fn<(...args: never[]) => unknown>(),
cancel: vi.fn<(...args: never[]) => unknown>(),
handlePasteEvent: vi.fn<(...args: never[]) => unknown>(),
@@ -90,6 +121,8 @@ export function createStructuredSessionMocks() {
useStructuredAgentSession: async () => {
const { useStructuredAgentSessionOutbox } =
await import('./use-structured-agent-session-outbox')
+ const { projectStructuredAgentSessionMessages } =
+ await import('../../../../shared/structured-agent-session-message-projection')
return {
useStructuredAgentSession: (props: {
sessionId: string
@@ -104,11 +137,11 @@ export function createStructuredSessionMocks() {
submissions: mocks.submissions as never
})
return {
- journalItems: [],
+ journalItems: mocks.journalItems,
messages:
mocks.messages ??
(mocks.mode === 'outbox'
- ? []
+ ? projectStructuredAgentSessionMessages([], outbox.outbox, [])
: [
{
id: 'message-1',
@@ -124,13 +157,15 @@ export function createStructuredSessionMocks() {
}
]),
status: mocks.status,
- error: mocks.readError ?? outbox.error,
+ error: outbox.error,
+ readRefusal: mocks.readRefusal,
hasOlder: mocks.hasOlder,
loadingOlder: mocks.loadingOlder,
olderHistoryGeneration: mocks.olderHistoryGeneration,
loadOlder: mocks.loadOlder,
prompts: mocks.promptItems,
outbox: outbox.outbox,
+ submissions: mocks.submissions,
blockedClientMessageId: outbox.blockedClientMessageId,
send: outbox.send,
retry: outbox.retry,
@@ -182,7 +217,7 @@ export function createStructuredSessionMocks() {
getStructuredAgentSessionLaunchResumes: () => mocks.launchResumes,
useStructuredAgentSessionLaunchSelection: () => null,
useStructuredAgentSessionLaunchLifecycle: () => mocks.launchLifecycle,
- useStructuredAgentSessionLaunchFailureReason: () => mocks.launchFailureReason
+ useStructuredAgentSessionLaunchFailure: () => mocks.launchFailure
}),
useNativeChatFontScale: () => ({
useNativeChatFontScale: () => ({ scale: 1 })
@@ -200,7 +235,7 @@ export function createStructuredSessionMocks() {
nativeChatMessageList: () => ({
NativeChatMessageList: (props: typeof mocks.messageListProps) => {
mocks.messageListProps = props
- return
+ return
}
}),
nativeChatComposer: () => ({
@@ -238,19 +273,20 @@ export function createStructuredSessionMocks() {
const resetStructuredSessionMocks = (): void => {
mocks.call.mockReset()
mocks.launchLifecycle = null
- mocks.launchFailureReason = null
+ mocks.launchFailure = null
mocks.launchResumes = false
mocks.retryLaunch.mockReset()
mocks.controllerProps = null
mocks.mode = 'static'
mocks.status = 'ready'
- mocks.readError = null
+ mocks.readRefusal = undefined
mocks.messages = null
mocks.messageListProps = null
mocks.composerProps = null
mocks.approvalCardProps = null
mocks.questionCardProps = null
mocks.promptItems = []
+ mocks.journalItems = []
mocks.respond.mockReset()
mocks.cancel.mockReset()
mocks.handlePasteEvent.mockReset()
diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
index 8d20c65bd48..6daa3d3f62c 100644
--- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useRef, useState } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { agentSessionPromptQuestions } from '../../../../shared/agent-session-question-answer'
import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer'
import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection'
@@ -22,10 +22,15 @@ import { NativeChatStructuredSessionStatus } from './NativeChatStructuredSession
import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-adoption'
import { NativeChatLaunchRetry } from './NativeChatLaunchRetry'
import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-launch'
-import { NativeChatDeliveryRetry } from './NativeChatDeliveryRetry'
import { useStructuredAgentSessionHostExecutionPhase } from './StructuredAgentSessionStatusBridge'
import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label'
import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner'
+import { structuredAgentSessionReadFailureNotice } from './structured-agent-session-read-failure-notice'
+import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts'
+import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices'
+import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
+
+const NO_SUBMISSIONS: readonly AgentJournalSubmission[] = []
export function NativeChatStructuredSession(
props: Omit
@@ -100,7 +105,46 @@ export function NativeChatStructuredSession(
}),
[controller, props.agent, props.sessionId]
)
+ // Read at click time, so the notices stay put while the outbox's Retry is rebuilt each render.
+ const retryRef = useRef(controller.retry)
+ useEffect(() => {
+ retryRef.current = controller.retry
+ })
+ const retryDelivery = useCallback((clientMessageId: string) => {
+ 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')
+ const rejectionRows = hasRejected ? controller.submissions : NO_SUBMISSIONS
+ const startFailures = useStructuredAgentSessionStartFailureFacts(
+ controller.journalItems,
+ hasRejected
+ )
+ const deliveryNotices = useMemo(
+ () =>
+ structuredAgentSessionDeliveryNotices(
+ controller.outbox,
+ controller.blockedClientMessageId,
+ agentLabel,
+ retryDelivery,
+ rejectionRows,
+ startFailures
+ ),
+ [
+ controller.outbox,
+ controller.blockedClientMessageId,
+ agentLabel,
+ retryDelivery,
+ rejectionRows,
+ startFailures
+ ]
+ )
const viewState = selectNativeChatViewState(session, { readRetries: true })
+ const readFailure =
+ controller.status === 'error'
+ ? structuredAgentSessionReadFailureNotice(controller.readRefusal)
+ : null
const fontScale = useNativeChatFontScale(viewState.kind === 'ready')
const imageRuntimeContext = useNativeChatImageRuntimeContext(props.tabId)
const { onLinkClick, linkActionRequest, closeLinkActions } = useNativeChatLinkActions(
@@ -217,7 +261,11 @@ export function NativeChatStructuredSession(
{viewState.kind === 'loading' ? (
) : viewState.kind === 'error' ? (
-
+
) : viewState.kind === 'empty' ? (
) : (
@@ -237,24 +285,26 @@ export function NativeChatStructuredSession(
onLinkClick={onLinkClick}
allowFileUriLinks={onLinkClick !== undefined}
runtimeContext={imageRuntimeContext}
+ deliveryNotices={deliveryNotices}
/>
)}
-
({
call: vi.fn(),
@@ -45,6 +46,8 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({
vi.mock('./use-structured-agent-session', async () => {
const { useStructuredAgentSessionOutbox } = await import('./use-structured-agent-session-outbox')
+ const { projectStructuredAgentSessionMessages } =
+ await import('../../../../shared/structured-agent-session-message-projection')
return {
useStructuredAgentSession: (props: {
sessionId: string
@@ -60,7 +63,7 @@ vi.mock('./use-structured-agent-session', async () => {
journalItems: [],
messages:
mocks.mode === 'outbox'
- ? []
+ ? projectStructuredAgentSessionMessages([], outbox.outbox, [])
: [
{
id: 'message-1',
@@ -77,6 +80,7 @@ vi.mock('./use-structured-agent-session', async () => {
loadOlder: vi.fn(),
prompts: mocks.promptItems,
outbox: outbox.outbox,
+ submissions: mocks.submissions,
blockedClientMessageId: outbox.blockedClientMessageId,
send: outbox.send,
retry: outbox.retry,
@@ -131,12 +135,19 @@ vi.mock('./use-native-chat-file-link-click', () => ({
useNativeChatFileLinkClick: (context: unknown) => (context ? mocks.fileLinkClick : undefined)
}))
-vi.mock('./NativeChatMessageList', () => ({
- NativeChatMessageList: (props: typeof mocks.messageListProps) => {
- mocks.messageListProps = props
- return
+vi.mock('./NativeChatMessageList', async () => {
+ const { DeliveryNoticesMock } = await import('./NativeChatStructuredSession.test-harness')
+ return {
+ NativeChatMessageList: (
+ props: NonNullable & {
+ deliveryNotices?: ReadonlyMap
+ }
+ ) => {
+ mocks.messageListProps = props
+ return
+ }
}
-}))
+})
vi.mock('./NativeChatComposer', () => ({
NativeChatComposer: forwardRef((props: typeof mocks.composerProps, ref) => {
@@ -280,6 +291,128 @@ describe('NativeChatStructuredSession delivery', () => {
expect(request.envelope.clientOperationId).toBe('op-head')
})
+ it('offers a rejected message no Retry while the queue is stopped, and gives it back once it moves', async () => {
+ mocks.mode = 'outbox'
+ mocks.submissions = []
+ mocks.call.mockResolvedValue({
+ ok: true,
+ value: { submission: { clientMessageId: 'op-head', dispatchState: 'accepted' } }
+ })
+ seedOutbox('session-held-rejected', [
+ seededEntry('session-held-rejected', 'op-head', 'first', 'unconfirmed'),
+ {
+ ...seededEntry('session-held-rejected', 'op-rejected', 'second', 'queued'),
+ state: 'rejected',
+ lastFailure: { kind: 'rejected', reason: 'Claude messages support at most 20 images' }
+ }
+ ])
+
+ render(
+
+ )
+
+ await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy())
+ expect(screen.getByText('Claude messages support at most 20 images')).toBeTruthy()
+ // One Retry, the stopped message's: it sends only that one.
+ fireEvent.click(screen.getByRole('button', { name: /Retry/ }))
+ await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
+ expect(mocks.call.mock.calls[0]?.[2]).toMatchObject({
+ envelope: { clientOperationId: 'op-head' }
+ })
+
+ // The queue moved, so the rejected message offers its own Retry again.
+ await waitFor(() => expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull())
+ expect(screen.getByText('Claude messages support at most 20 images')).toBeTruthy()
+ expect(screen.getAllByRole('button', { name: /Retry/ })).toHaveLength(1)
+ expect(mocks.call).toHaveBeenCalledOnce()
+ })
+
+ it("words a failed start on each message by the chat's agent, leaving the resend to its Retry", async () => {
+ mocks.mode = 'outbox'
+ mocks.submissions = []
+ const startFailed = (clientMessageId: string, text: string) => ({
+ ...seededEntry('session-start-failed', clientMessageId, text, 'queued'),
+ state: 'rejected' as const,
+ lastFailure: {
+ kind: 'rejected' as const,
+ reason: 'Codex stopped before it finished starting. Send your message to try again.',
+ rejection: { kind: 'providerStartFailed' as const }
+ }
+ })
+ seedOutbox('session-start-failed', [
+ startFailed('op-first', 'first'),
+ startFailed('op-second', 'second')
+ ])
+
+ render(
+
+ )
+
+ await waitFor(() =>
+ expect(screen.getAllByText('Codex stopped before it finished starting.')).toHaveLength(2)
+ )
+ expect(screen.getAllByRole('button', { name: /Retry/ })).toHaveLength(2)
+ expect(screen.queryByText(/Send your message to try again/)).toBeNull()
+ })
+
+ it("words a rejected message from its loaded journal row, not the message's own copy", async () => {
+ mocks.mode = 'outbox'
+ const reason = "Claude couldn't start. Send your message to try again."
+ mocks.submissions = [
+ {
+ clientMessageId: 'op-recorded',
+ fence: 1,
+ payloadFingerprint: 'fingerprint',
+ dispatchState: 'rejected',
+ providerItemId: null,
+ reason,
+ rejection: {
+ kind: 'startFailed',
+ refusal: { code: 'agent_session_identity_required', details: { reason: 'recordMissing' } }
+ },
+ submittedAt: 1,
+ resolvedAt: 1
+ }
+ ]
+ seedOutbox('session-recorded', [
+ {
+ ...seededEntry('session-recorded', 'op-recorded', 'first', 'queued'),
+ state: 'rejected',
+ lastFailure: { kind: 'rejected', reason, rejection: { kind: 'startFailed' } }
+ }
+ ])
+
+ render(
+
+ )
+
+ await waitFor(() =>
+ expect(screen.getByText("Codex couldn't start. Start a new chat to continue.")).toBeTruthy()
+ )
+ expect(screen.queryByText(reason)).toBeNull()
+ })
+
it('names the stuck message behind an admitted head, and its Retry sends that one', async () => {
mocks.mode = 'outbox'
mocks.submissions = []
diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx
index 86a2b858a42..2f33b0ed0c3 100644
--- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx
@@ -18,6 +18,8 @@ export function NativeChatStructuredSessionStatus(props: {
/** The host's word on the provider child; `starting` is published but not yet answering. */
startupPhase: 'starting' | 'ready' | null
error: string | null
+ /** A read the pane is reconnecting on its own: said plainly, not as an error. */
+ reconnecting?: boolean
composerError: string | null
isVisible: boolean
backgroundTasks: StructuredSessionBackgroundTasksView
@@ -68,6 +70,11 @@ export function NativeChatStructuredSessionStatus(props: {
)}
) : null}
+ {props.reconnecting && !props.error ? (
+
+ {translate('components.native-chat.state.reconnecting', 'Reconnecting to this chat…')}
+
+ ) : null}
{props.error || props.composerError ? (
{props.error ?? props.composerError}
diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx
index b27736c71bb..fb58acb7280 100644
--- a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx
@@ -1,7 +1,7 @@
import { memo } from 'react'
import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown'
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
-import { MessageRow } from './NativeChatMessageRow'
+import { MessageRow, type NativeChatDeliveryNotice } from './NativeChatMessageRow'
import { NativeChatResolutionReceipt } from './NativeChatResolutionReceipt'
import { NativeChatWorkingStatus } from './NativeChatWorkingStatus'
import { NativeChatTurnDiffRollup } from './NativeChatTurnDiffRollup'
@@ -17,7 +17,8 @@ export type NativeChatTranscriptRowContext = {
revealedDiff: NativeChatDiffReveal | null
taskListPredecessors: ReadonlyMap
expandedTurnIds: ReadonlySet
- failedDeliveryMessageIds?: ReadonlySet
+ /** Keyed by message id: the user messages that did not go through, each with its own words. */
+ deliveryNotices?: ReadonlyMap
allowFileUriLinks: boolean
runtimeContext?: RuntimeFileOperationArgs | null
onLinkClick?: CommentMarkdownLinkClickHandler
@@ -60,7 +61,7 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({
onScrollMessageToTop={context.onScrollMessageToTop}
onLinkClick={context.onLinkClick}
allowFileUriLinks={context.allowFileUriLinks}
- deliveryFailed={context.failedDeliveryMessageIds?.has(message.id) === true}
+ deliveryNotice={context.deliveryNotices?.get(message.id)}
structuredActivityUi={context.showTurnStatus}
folded={slot.folded}
runtimeContext={context.runtimeContext}
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
new file mode 100644
index 00000000000..faadac1127e
--- /dev/null
+++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts
@@ -0,0 +1,414 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure'
+import type {
+ AgentJournalRenderItem,
+ AgentJournalSubmission
+} from '../../../../shared/agent-session-journal-types'
+import {
+ createStructuredAgentSessionOutboxEntry,
+ type StructuredAgentSessionOutboxEntry
+} from '../../../../shared/structured-agent-session-outbox'
+import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words'
+import {
+ agentJournalItemKey,
+ 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'
+
+function entry(
+ clientMessageId: string,
+ patch: Partial = {}
+): StructuredAgentSessionOutboxEntry {
+ return {
+ ...createStructuredAgentSessionOutboxEntry({
+ clientMessageId,
+ sessionId: 'session-1',
+ text: clientMessageId,
+ attachments: [],
+ queuedAt: 1
+ }),
+ ...patch
+ }
+}
+
+function texts(
+ outbox: StructuredAgentSessionOutboxEntry[],
+ blocked: string | null = null,
+ submissions: readonly AgentJournalSubmission[] = [],
+ startFailures: readonly AgentSessionFailureFact[] = []
+): Record {
+ const notices = structuredAgentSessionDeliveryNotices(
+ outbox,
+ blocked,
+ 'Claude',
+ () => {},
+ submissions,
+ startFailures
+ )
+ return Object.fromEntries([...notices].map(([id, notice]) => [id, notice.text]))
+}
+
+describe('the notice on each message that did not go through', () => {
+ it('gives two failed messages each their own reason and their own Retry', () => {
+ const retry = vi.fn()
+ const notices = structuredAgentSessionDeliveryNotices(
+ [
+ entry('first', {
+ state: 'rejected',
+ lastFailure: { kind: 'rejected', reason: 'Claude messages support at most 20 images' }
+ }),
+ entry('second', {
+ state: 'rejected',
+ lastFailure: {
+ kind: 'rejected',
+ reason: 'Claude never finished starting, so Orca stopped it.',
+ rejection: { kind: 'hostStopped' }
+ }
+ })
+ ],
+ null,
+ 'Claude',
+ retry,
+ [],
+ []
+ )
+
+ expect([...notices.keys()]).toEqual([
+ agentJournalSubmissionKey('first'),
+ agentJournalSubmissionKey('second')
+ ])
+ expect(notices.get(agentJournalSubmissionKey('first'))?.text).toBe(
+ 'Claude messages support at most 20 images'
+ )
+ expect(notices.get(agentJournalSubmissionKey('second'))?.text).toBe(
+ 'Claude never finished starting, so Orca stopped it.'
+ )
+ notices.get(agentJournalSubmissionKey('second'))?.onRetry?.()
+ expect(retry).toHaveBeenCalledExactlyOnceWith('second')
+ })
+
+ it('chooses the words from the saved refusal on the message the queue stopped on', () => {
+ expect(
+ texts(
+ [
+ entry('held', {
+ lastFailure: { kind: 'refused', code: 'agent_session_owner_restart_failed' }
+ })
+ ],
+ 'held'
+ )
+ ).toEqual({
+ [agentJournalSubmissionKey('held')]: "The agent couldn't restart. Your message was not sent."
+ })
+ })
+
+ // The same rule as a rejected row's: its own Retry is the resend step, and any other step stays.
+ it('leaves a retry step to the Retry beside the message the queue stopped on', () => {
+ const held = (lastFailure: StructuredAgentSessionOutboxEntry['lastFailure']) =>
+ texts([entry('held', { lastFailure })], 'held')
+ expect(
+ held({
+ kind: 'refused',
+ code: 'agent_session_journal_unreadable',
+ details: { reason: 'journalUnavailable' }
+ })
+ ).toEqual({
+ [agentJournalSubmissionKey('held')]:
+ "Orca couldn't open this chat's history right now. Your message was not sent."
+ })
+ expect(
+ held({
+ kind: 'refused',
+ code: 'agent_session_operation_invalid',
+ details: { reason: 'notSignedIn' }
+ })
+ ).toEqual({
+ [agentJournalSubmissionKey('held')]:
+ 'Your message was not sent. Claude is not signed in for the selected account. Sign in first.'
+ })
+ })
+
+ it('says a message is unconfirmed, and only that it was not sent when nothing more is known', () => {
+ expect(texts([entry('doubt', { state: 'unconfirmed' })])).toEqual({
+ [agentJournalSubmissionKey('doubt')]: 'Message delivery is unconfirmed.'
+ })
+ expect(texts([entry('bare')], 'bare')).toEqual({
+ [agentJournalSubmissionKey('bare')]: 'Message was not sent.'
+ })
+ })
+
+ // The drain's own rule: a message behind the one the queue stopped on is only waiting, so it says
+ // nothing. A rejected message holds nothing up and keeps its words.
+ it('says why on the message the queue stopped on and on every rejected one', () => {
+ expect(
+ texts([
+ entry('sent', { state: 'dispatching' }),
+ entry('rejected', { state: 'rejected' }),
+ entry('stuck', { state: 'unconfirmed' }),
+ entry('behind', { state: 'unconfirmed' }),
+ entry('queued')
+ ])
+ ).toEqual({
+ [agentJournalSubmissionKey('rejected')]: 'Message was not sent.',
+ [agentJournalSubmissionKey('stuck')]: 'Message delivery is unconfirmed.'
+ })
+ })
+
+ // Its Retry would put it back in the queue to wait unseen behind the stopped message.
+ it('keeps a rejected message its words but not its Retry while the queue is stopped', () => {
+ const retry = vi.fn()
+ for (const [outbox, blocked] of [
+ [[entry('stuck', { state: 'unconfirmed' }), entry('rejected', { state: 'rejected' })], null],
+ [[entry('rejected', { state: 'rejected' }), entry('held')], 'held']
+ ] as const) {
+ const notices = structuredAgentSessionDeliveryNotices(
+ [...outbox],
+ blocked,
+ 'Claude',
+ retry,
+ [],
+ []
+ )
+ expect(notices.get(agentJournalSubmissionKey('rejected'))).toEqual({
+ text: 'Message was not sent.'
+ })
+ }
+ })
+
+ // Beside its own Retry the resend step is the button; without one the words keep it.
+ it('leaves out sending again only where the message has its own Retry', () => {
+ const startFailed = (clientMessageId: string): StructuredAgentSessionOutboxEntry =>
+ entry(clientMessageId, {
+ state: 'rejected',
+ lastFailure: {
+ kind: 'rejected',
+ reason: 'Claude stopped before it finished starting. Send your message to try again.',
+ rejection: { kind: 'providerStartFailed' }
+ }
+ })
+ expect(texts([startFailed('first'), startFailed('second')])).toEqual({
+ [agentJournalSubmissionKey('first')]: 'Claude stopped before it finished starting.',
+ [agentJournalSubmissionKey('second')]: 'Claude stopped before it finished starting.'
+ })
+ expect(texts([startFailed('rejected'), entry('held')], 'held')).toMatchObject({
+ [agentJournalSubmissionKey('rejected')]:
+ 'Claude stopped before it finished starting. Send your message to try again.'
+ })
+ })
+
+ it.each([
+ [
+ 'notDelivered',
+ 'This message was not delivered. Send it again to continue.',
+ 'This message was not delivered.'
+ ],
+ [
+ 'hostFault',
+ "Orca ran into a problem, so this didn't go through. Try again.",
+ "Orca ran into a problem, so this didn't go through."
+ ]
+ ] as const)('leaves the step to the Retry beside a %s message', (kind, reason, shown) => {
+ expect(
+ texts([
+ entry('rejected', {
+ state: 'rejected',
+ lastFailure: { kind: 'rejected', reason, rejection: { kind } }
+ })
+ ])
+ ).toEqual({ [agentJournalSubmissionKey('rejected')]: shown })
+ })
+
+ // The journal holds the whole fact; the message's own copy keeps only its kind and attachment.
+ it("words a recorded rejection from the journal's fact, whatever reason the host wrote", () => {
+ const rejected = (id: string): StructuredAgentSessionOutboxEntry =>
+ entry(id, {
+ state: 'rejected',
+ // An older host's words, and not the pane's agent name: never compared, never shown.
+ lastFailure: {
+ kind: 'rejected',
+ reason: "The agent couldn't be started.",
+ rejection: { kind: 'startFailed' }
+ }
+ })
+ const recorded = (id: string, rejection: AgentSessionFailureFact): AgentJournalSubmission => ({
+ clientMessageId: id,
+ fence: 1,
+ payloadFingerprint: 'fingerprint',
+ dispatchState: 'rejected',
+ providerItemId: null,
+ reason: "The agent couldn't be started.",
+ rejection,
+ submittedAt: 1,
+ resolvedAt: 1
+ })
+ const facts: [string, AgentSessionFailureFact, string][] = [
+ [
+ 'gone',
+ {
+ kind: 'startFailed',
+ refusal: {
+ code: 'agent_session_identity_required',
+ details: { reason: 'recordMissing' }
+ }
+ },
+ "Claude couldn't start. Start a new chat to continue."
+ ],
+ [
+ 'claimed',
+ {
+ kind: 'startFailed',
+ refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } }
+ },
+ "Claude couldn't start. This chat is still open in a terminal agent. Quit that agent to continue the chat here."
+ ],
+ [
+ 'resumable',
+ { kind: 'startFailed', refusal: { code: 'agent_session_ownership_unknown' } },
+ "Claude couldn't start."
+ ],
+ [
+ 'provider',
+ { kind: 'providerRejected', detail: { text: 'Image type .bmp', audience: 'person' } },
+ 'The provider did not accept this message: Image type .bmp.'
+ ],
+ [
+ 'logged',
+ { kind: 'providerRejected', detail: { text: 'HTTP 400 at /v1', audience: 'log' } },
+ 'The provider did not accept this message.'
+ ]
+ ]
+ expect(
+ texts(
+ facts.map(([id]) => rejected(id)),
+ null,
+ facts.map(([id, fact]) => recorded(id, fact))
+ )
+ ).toEqual(
+ Object.fromEntries(facts.map(([id, , shown]) => [agentJournalSubmissionKey(id), shown]))
+ )
+ // Not loaded (older than the loaded page): the message's copy has no refusal or detail, so the
+ // host's sentence, which holds them, is shown for those kinds; the table words the rest.
+ expect(
+ texts([
+ rejected('gone'),
+ entry('provider', {
+ state: 'rejected',
+ lastFailure: {
+ kind: 'rejected',
+ reason: 'The provider did not accept this message: Image type .bmp.',
+ rejection: { kind: 'providerRejected' }
+ }
+ }),
+ entry('stopped', {
+ state: 'rejected',
+ lastFailure: {
+ kind: 'rejected',
+ reason: 'The agent stopped before this message was sent.',
+ rejection: { kind: 'providerExited' }
+ }
+ })
+ ])
+ ).toEqual({
+ [agentJournalSubmissionKey('gone')]: "The agent couldn't be started.",
+ [agentJournalSubmissionKey('provider')]:
+ 'The provider did not accept this message: Image type .bmp.',
+ [agentJournalSubmissionKey('stopped')]: 'Claude stopped before this message was sent.'
+ })
+ })
+
+ it('says nothing on a message that is only waiting its turn or on its way', () => {
+ expect(texts([entry('queued'), entry('sending', { state: 'dispatching' })])).toEqual({})
+ })
+
+ // Matched on the typed fact of a row found by its identity, never on either sentence.
+ describe('a message rejected by a start whose row already says why', () => {
+ const startFailed: AgentSessionFailureFact = {
+ kind: 'startFailed',
+ refusal: { code: 'agent_session_identity_required', details: { reason: 'recordMissing' } }
+ }
+ const rejected = (id: string, fact: AgentSessionFailureFact) =>
+ entry(id, {
+ state: 'rejected',
+ lastFailure: {
+ kind: 'rejected',
+ reason: 'Written by the host.',
+ rejection: { kind: fact.kind }
+ }
+ })
+ const recorded = (id: string, fact: AgentSessionFailureFact): AgentJournalSubmission => ({
+ clientMessageId: id,
+ fence: 1,
+ payloadFingerprint: id,
+ dispatchState: 'rejected',
+ providerItemId: null,
+ reason: 'Written by the host.',
+ rejection: fact,
+ submittedAt: 1,
+ resolvedAt: 1
+ })
+ const statusRow = (itemId: string, fact: AgentSessionFailureFact): AgentJournalRenderItem => ({
+ itemId,
+ revision: 1,
+ sequence: 1,
+ observedAt: 1,
+ body: {
+ kind: 'status',
+ tone: 'error',
+ ...agentSessionFailureWords(fact, { agentName: 'Claude', surface: 'row' })
+ }
+ })
+ const startRowKey = agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('gen'))
+
+ it('reads only the start-failure rows', () => {
+ expect(
+ structuredAgentSessionStartFailureFacts([
+ statusRow(startRowKey, startFailed),
+ statusRow(agentJournalSubmissionKey('exit-row'), { kind: 'providerExited' })
+ ])
+ ).toEqual([startFailed])
+ })
+
+ it('says only that each was not sent, and words any other rejection in full', () => {
+ const otherRefusal: AgentSessionFailureFact = {
+ kind: 'startFailed',
+ refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } }
+ }
+ const facts = structuredAgentSessionStartFailureFacts([statusRow(startRowKey, startFailed)])
+ expect(
+ texts(
+ [
+ rejected('first', startFailed),
+ rejected('second', startFailed),
+ rejected('other', otherRefusal)
+ ],
+ null,
+ [
+ recorded('first', startFailed),
+ recorded('second', startFailed),
+ recorded('other', otherRefusal)
+ ],
+ facts
+ )
+ ).toEqual({
+ [agentJournalSubmissionKey('first')]: 'Your message was not sent.',
+ [agentJournalSubmissionKey('second')]: 'Your message was not sent.',
+ [agentJournalSubmissionKey('other')]:
+ "Claude couldn't start. This chat is still open in a terminal agent. Quit that agent to continue the chat here."
+ })
+ })
+
+ it('keeps the full notice when the rejection is not loaded, or no start row states it', () => {
+ const shown = "Claude couldn't start. Start a new chat to continue."
+ expect(texts([rejected('first', startFailed)], null, [], [startFailed])).toEqual({
+ [agentJournalSubmissionKey('first')]: 'Written by the host.'
+ })
+ expect(
+ texts([rejected('first', startFailed)], null, [recorded('first', startFailed)], [])
+ ).toEqual({ [agentJournalSubmissionKey('first')]: shown })
+ })
+ })
+})
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
new file mode 100644
index 00000000000..ec3eb60e687
--- /dev/null
+++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts
@@ -0,0 +1,137 @@
+// Which of the structured chat's own messages say, on their row, that they did not go through.
+//
+// Derived from the outbox on every render and never stored: each failed or held message carries
+// its own typed failure, so each row words its own reason. Read through the drain's own rule: while
+// the queue is stopped, only the message it stopped on has a Retry; another's would wait unseen
+// behind it. One waiting behind says nothing; a rejected message holds nothing up, so it keeps its
+// words and gets its Retry once the queue moves.
+//
+// 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.
+
+import {
+ readAgentSessionFailureFact,
+ 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 { agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice'
+import { isStructuredAgentSessionStartFailureRow } from '../../../../shared/structured-agent-session-start-failure-row-key'
+import {
+ admitStructuredAgentSessionOutboxEntry,
+ type StructuredAgentSessionOutboxEntry
+} from '../../../../shared/structured-agent-session-outbox'
+import type { AgentSessionFailureWordsContext } from '../../../../shared/agent-session-failure-words'
+import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition'
+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
+ )
+}
+
+function deliveryNoticeText(
+ entry: StructuredAgentSessionOutboxEntry,
+ context: AgentSessionFailureWordsContext,
+ recorded: AgentJournalSubmission | undefined,
+ startFailures: readonly AgentSessionFailureFact[]
+): string {
+ if (entry.state === 'unconfirmed') {
+ return translate(
+ 'auto.components.native.chat.NativeChatStructuredSession.1f772bb5d0',
+ 'Message delivery is unconfirmed.'
+ )
+ }
+ if (!entry.lastFailure) {
+ return translate(
+ 'auto.components.native.chat.NativeChatStructuredSession.93ef441197',
+ 'Message was not sent.'
+ )
+ }
+ const fact = readAgentSessionFailureFact(recorded?.rejection)
+ if (
+ entry.state === 'rejected' &&
+ fact &&
+ startFailures.some((stated) => sameAgentSessionFailureFact(stated, fact))
+ ) {
+ return agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send'))
+ }
+ return agentSessionWriteNoticeText(
+ structuredAgentSessionAttemptFailureParts(entry.lastFailure, context, fact)
+ )
+}
+
+/** Keyed by the message id the transcript renders each entry under. `blockedClientMessageId` is
+ * the entry a refusal stopped the queue on; `agentName` is the chat's agent, for the words. */
+export function structuredAgentSessionDeliveryNotices(
+ outbox: readonly StructuredAgentSessionOutboxEntry[],
+ blockedClientMessageId: string | null,
+ agentName: string,
+ retry: (clientMessageId: string) => void,
+ /** The journal's rows, whose rejected ones carry more of a rejection than the message keeps. */
+ submissions: readonly AgentJournalSubmission[],
+ /** What the loaded start-failure rows state, from `structuredAgentSessionStartFailureFacts`. */
+ startFailures: readonly AgentSessionFailureFact[]
+): ReadonlyMap {
+ const admission = admitStructuredAgentSessionOutboxEntry(outbox, blockedClientMessageId)
+ const held = admission.state === 'blocked' ? admission.entry.clientMessageId : null
+ const rejected = new Map(
+ submissions
+ .filter((submission) => submission.dispatchState === 'rejected')
+ .map((submission) => [submission.clientMessageId, submission])
+ )
+ const notices = new Map()
+ for (const entry of outbox) {
+ if (entry.state === 'rejected' || entry.clientMessageId === held) {
+ // Its own Retry is the step, so the words leave out sending again.
+ const retryControl = held === null || entry.clientMessageId === held
+ const text = deliveryNoticeText(
+ entry,
+ { agentName, retryControl },
+ rejected.get(entry.clientMessageId),
+ startFailures
+ )
+ notices.set(
+ agentJournalSubmissionKey(entry.clientMessageId),
+ retryControl ? { text, onRetry: () => retry(entry.clientMessageId) } : { text }
+ )
+ }
+ }
+ return notices
+}
diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-failure-notice.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-failure-notice.ts
new file mode 100644
index 00000000000..8dee55de82e
--- /dev/null
+++ b/src/renderer/src/components/native-chat/structured-agent-session-read-failure-notice.ts
@@ -0,0 +1,32 @@
+// What the structured chat says when reading its history failed: the notice table's words for the
+// refusal the read met, never the host's message or code.
+
+import {
+ agentSessionReadHistoryRefusalParts,
+ agentSessionWriteNotDoneParts
+} from '../../../../shared/agent-session-refusal-notice'
+import type { AgentSessionRefusalReference } from '../../../../shared/agent-session-wire-refusals'
+import { isFinalAgentSessionReadRefusal } from '../../../../shared/structured-agent-session-read-refusal'
+import { agentSessionWriteNoticeText } from './agent-session-write-notice-text'
+
+export type StructuredAgentSessionReadFailureNotice = {
+ text: string
+ /** The host named the situation, so the words say more than that the history did not load. */
+ named: boolean
+ /** Nothing the read retries gets past it, so the pane no longer says it keeps trying. */
+ final: boolean
+}
+
+export function structuredAgentSessionReadFailureNotice(
+ refusal: AgentSessionRefusalReference | undefined
+): StructuredAgentSessionReadFailureNotice {
+ // The pane reconnects on its own, so it is the Retry beside the words.
+ const parts = refusal
+ ? agentSessionReadHistoryRefusalParts(refusal.code, refusal.details, { retryControl: true })
+ : agentSessionWriteNotDoneParts('read-history')
+ return {
+ text: agentSessionWriteNoticeText(parts),
+ named: refusal?.details?.reason !== undefined,
+ final: isFinalAgentSessionReadRefusal(refusal)
+ }
+}
diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts
index 6ec7b38f8b2..e3f87a8e95e 100644
--- a/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts
+++ b/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts
@@ -223,7 +223,7 @@ function createReadOwner(
}
const transport = startStructuredAgentSessionReadTransport({
applyEvent: (event) => apply({ type: 'event', event }),
- applyError: (message) => apply({ type: 'error', message }),
+ applyError: (message, refusal) => apply({ type: 'error', message, refusal }),
getCursor: () => snapshot.state.cursor,
onHistoryReadInvalidated: invalidateOlderPages,
hydrate: snapshot.state.epoch === null ? hydrate : undefined,
diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts
index 0305b93cb6d..683676c99d6 100644
--- a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts
+++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts
@@ -9,12 +9,16 @@ import type {
AgentSessionSubscribeEvent
} from '../../../../shared/agent-session-wire'
-const mocks = vi.hoisted(() => ({ subscribe: vi.fn() }))
+const mocks = vi.hoisted(() => ({ subscribe: vi.fn(), watchHostContact: vi.fn() }))
vi.mock('@/runtime/structured-agent-session-client', () => ({
subscribeStructuredAgentSession: mocks.subscribe
}))
+vi.mock('@/runtime/runtime-host-contact-regained', () => ({
+ subscribeRuntimeHostContactRegained: mocks.watchHostContact
+}))
+
import { startStructuredAgentSessionReadTransport } from './structured-agent-session-read-transport'
type SubscribeAttempt = {
@@ -311,6 +315,81 @@ describe('structured agent-session read transport unattached refusals', () => {
}
})
+ it('hands the pane the refusal a read met, and stops reconnecting only past damage', async () => {
+ vi.useFakeTimers()
+ try {
+ const journalRefusal = (reason: string) => ({
+ code: 'runtime_error',
+ message: 'agent_session_journal_unreadable',
+ data: { refusal: { code: 'agent_session_journal_unreadable', details: { reason } } }
+ })
+ const applyError = vi.fn()
+ const transport = startWithHydration(async () => undefined, applyError)
+ await flushPromises()
+ expect(attempts).toHaveLength(1)
+
+ // An open that can clear keeps reconnecting.
+ attempts[0].onError(journalRefusal('journalUnavailable'))
+ attempts[0].closed.resolve({ unsubscribe: attempts[0].unsubscribe })
+ await flushPromises()
+ expect(applyError).toHaveBeenLastCalledWith('agent_session_journal_unreadable', {
+ code: 'agent_session_journal_unreadable',
+ details: { reason: 'journalUnavailable' }
+ })
+ await vi.advanceTimersByTimeAsync(750)
+ expect(attempts).toHaveLength(2)
+
+ // Damage no retry reads past: decided from the reason, not the message, which is the same.
+ attempts[1].onError(journalRefusal('journalCorrupt'))
+ attempts[1].closed.resolve({ unsubscribe: attempts[1].unsubscribe })
+ await flushPromises()
+ expect(applyError).toHaveBeenLastCalledWith('agent_session_journal_unreadable', {
+ code: 'agent_session_journal_unreadable',
+ details: { reason: 'journalCorrupt' }
+ })
+ await vi.advanceTimersByTimeAsync(60_000)
+ expect(attempts).toHaveLength(2)
+ transport.dispose()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('reads a thrown hydrate refusal the same way, and a new run reads again', async () => {
+ vi.useFakeTimers()
+ try {
+ const corrupt = Object.assign(new Error('agent_session_journal_unreadable'), {
+ response: {
+ error: {
+ code: 'runtime_error',
+ message: 'agent_session_journal_unreadable',
+ data: {
+ refusal: {
+ code: 'agent_session_journal_unreadable',
+ details: { reason: 'journalCorrupt' }
+ }
+ }
+ }
+ }
+ })
+ const hydrate = vi.fn(async () => {
+ throw corrupt
+ })
+ const first = startWithHydration(hydrate, vi.fn())
+ await vi.advanceTimersByTimeAsync(60_000)
+ expect(attempts).toHaveLength(0)
+ first.dispose()
+
+ // Reopening the chat is a new run, which reads again.
+ const second = startWithHydration(hydrate, vi.fn())
+ await flushPromises()
+ expect(hydrate).toHaveBeenCalledTimes(2)
+ second.dispose()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
it('re-opens after a failed open and leaves the error once the conversation reads (P2-04)', async () => {
vi.useFakeTimers()
try {
@@ -356,4 +435,128 @@ describe('structured agent-session read transport unattached refusals', () => {
vi.useRealTimers()
}
})
+
+ it('waits twice as long after each failed open, up to 30 s, and starts over once a read lands', async () => {
+ vi.useFakeTimers()
+ try {
+ const unavailable = {
+ code: 'runtime_error',
+ message: 'agent_session_journal_unreadable',
+ data: {
+ refusal: {
+ code: 'agent_session_journal_unreadable',
+ details: { reason: 'journalUnavailable' }
+ }
+ }
+ }
+ const transport = startWithHydration(async () => undefined, vi.fn())
+ await flushPromises()
+ // As a local subscribe does: the open resolves, then the host's refusal arrives.
+ const refuseLatest = async (): Promise => {
+ const attempt = attempts.at(-1)!
+ attempt.closed.resolve({ unsubscribe: attempt.unsubscribe })
+ await flushPromises()
+ attempt.onError(unavailable)
+ }
+ const expectReopenAfter = async (delay: number): Promise => {
+ const opened = attempts.length
+ await vi.advanceTimersByTimeAsync(delay - 1)
+ expect(attempts).toHaveLength(opened)
+ await vi.advanceTimersByTimeAsync(1)
+ expect(attempts).toHaveLength(opened + 1)
+ }
+
+ for (const delay of [750, 1_500, 3_000, 6_000, 12_000, 24_000, 30_000, 30_000]) {
+ await refuseLatest()
+ await expectReopenAfter(delay)
+ }
+
+ // A read that delivers is the success the next failure starts over from.
+ attempts.at(-1)!.closed.resolve({ unsubscribe: attempts.at(-1)!.unsubscribe })
+ await flushPromises()
+ attempts.at(-1)!.onEvent(snapshot(1))
+ attempts.at(-1)!.onError(unavailable)
+ await expectReopenAfter(750)
+ await refuseLatest()
+ await expectReopenAfter(1_500)
+
+ // Damage still ends reconnecting, however far the wait has grown.
+ const latest = attempts.at(-1)!
+ latest.closed.resolve({ unsubscribe: latest.unsubscribe })
+ await flushPromises()
+ latest.onError({
+ ...unavailable,
+ data: {
+ refusal: {
+ code: 'agent_session_journal_unreadable',
+ details: { reason: 'journalCorrupt' }
+ }
+ }
+ })
+ const opened = attempts.length
+ await vi.advanceTimersByTimeAsync(120_000)
+ expect(attempts).toHaveLength(opened)
+ transport.dispose()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('reads again as soon as a remote host is reachable, not after the grown wait', async () => {
+ vi.useFakeTimers()
+ try {
+ let hostContactRegained = (): void => {}
+ const stopWatch = vi.fn()
+ mocks.watchHostContact.mockImplementation((_environmentId, listener: () => void) => {
+ hostContactRegained = listener
+ return stopWatch
+ })
+ const unavailable = { code: 'runtime_unavailable', message: 'Remote runtime is unavailable.' }
+ const transport = startStructuredAgentSessionReadTransport({
+ applyEvent: vi.fn(),
+ applyError: vi.fn(),
+ getCursor: () => null,
+ onHistoryReadInvalidated: () => undefined,
+ hydrate: async () => undefined,
+ sessionId: 'session-a',
+ target: { kind: 'environment', environmentId: 'env-a' }
+ })
+ expect(mocks.watchHostContact).toHaveBeenCalledWith('env-a', expect.any(Function))
+ await flushPromises()
+ const refuseLatest = async (): Promise => {
+ const attempt = attempts.at(-1)!
+ attempt.closed.resolve({ unsubscribe: attempt.unsubscribe })
+ await flushPromises()
+ attempt.onError(unavailable)
+ }
+ // An outage long enough that the wait reaches its cap.
+ for (const delay of [750, 1_500, 3_000, 6_000, 12_000, 24_000]) {
+ await refuseLatest()
+ await vi.advanceTimersByTimeAsync(delay)
+ }
+ await refuseLatest()
+ const opened = attempts.length
+ await vi.advanceTimersByTimeAsync(29_000)
+ expect(attempts).toHaveLength(opened)
+
+ hostContactRegained()
+ await vi.advanceTimersByTimeAsync(0)
+ expect(attempts).toHaveLength(opened + 1)
+
+ // The wait starts over too, so a failure right after reconnect is retried soon.
+ await refuseLatest()
+ await vi.advanceTimersByTimeAsync(750)
+ expect(attempts).toHaveLength(opened + 2)
+
+ transport.dispose()
+ expect(stopWatch).toHaveBeenCalledTimes(1)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('does not watch host contact for a local read', () => {
+ startWithHydration(async () => undefined, vi.fn()).dispose()
+ expect(mocks.watchHostContact).not.toHaveBeenCalled()
+ })
})
diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts
index ed8423fd079..a0a693e92a9 100644
--- a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts
+++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts
@@ -3,8 +3,12 @@ import type { AgentSessionSubscribeEvent } from '../../../../shared/agent-sessio
import { createStructuredAgentSessionEventCoalescer } from '../../../../shared/structured-agent-session-coalescer'
import {
AGENT_SESSION_UNATTACHED_READ_GRACE_MS,
+ isFinalAgentSessionReadRefusal,
isUnattachedAgentSessionReadRefusal
} from '../../../../shared/structured-agent-session-read-refusal'
+import type { AgentSessionRefusalReference } from '../../../../shared/agent-session-wire-refusals'
+import { readAgentSessionErrorRefusal } from '../../../../shared/agent-session-write-failure'
+import { subscribeRuntimeHostContactRegained } from '@/runtime/runtime-host-contact-regained'
import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
import { subscribeStructuredAgentSession } from '@/runtime/structured-agent-session-client'
@@ -27,13 +31,20 @@ function readFailureText(error: unknown): string {
return 'Something went wrong.'
}
+const RECONNECT_FIRST_DELAY_MS = 750
+const RECONNECT_MAX_DELAY_MS = 30_000
+
+/** Each reconnect waits twice the last, up to the cap, until a read delivers. */
function createReconnectScheduler(args: { shouldStop: () => boolean; reconnect: () => void }) {
let timer: ReturnType | null = null
+ let nextDelay = RECONNECT_FIRST_DELAY_MS
return {
- schedule(delay = 750): void {
+ schedule(): void {
if (args.shouldStop() || timer) {
return
}
+ const delay = nextDelay
+ nextDelay = Math.min(delay * 2, RECONNECT_MAX_DELAY_MS)
timer = setTimeout(() => {
timer = null
if (!args.shouldStop()) {
@@ -41,6 +52,22 @@ function createReconnectScheduler(args: { shouldStop: () => boolean; reconnect:
}
}, delay)
},
+ /** A read delivered, so the next failure is retried soon again. */
+ reset(): void {
+ nextDelay = RECONNECT_FIRST_DELAY_MS
+ },
+ /** The host is reachable again: a waiting retry runs now instead of after the grown wait. */
+ retryNow(): void {
+ nextDelay = RECONNECT_FIRST_DELAY_MS
+ if (!timer) {
+ return
+ }
+ clearTimeout(timer)
+ timer = null
+ if (!args.shouldStop()) {
+ args.reconnect()
+ }
+ },
dispose(): void {
if (timer) {
clearTimeout(timer)
@@ -52,7 +79,8 @@ function createReconnectScheduler(args: { shouldStop: () => boolean; reconnect:
export function startStructuredAgentSessionReadTransport(args: {
applyEvent: (event: AgentSessionSubscribeEvent) => void
- applyError: (message: string) => void
+ /** `message` is the failure's own text, for logs; `refusal` is what a surface words. */
+ applyError: (message: string, refusal?: AgentSessionRefusalReference) => void
getCursor: () => AgentJournalCursor | null
onHistoryReadInvalidated: () => void
hydrate?: (shouldStop: () => boolean) => Promise
@@ -64,6 +92,8 @@ export function startStructuredAgentSessionReadTransport(args: {
} {
let stopped = false
let connected = false
+ // A refusal no retry reads past ends reconnecting for this run; reopening the chat starts another.
+ let failedFinally = false
let unattachedSince: number | null = null
let opening = false
let openGeneration = 0
@@ -76,9 +106,16 @@ export function startStructuredAgentSessionReadTransport(args: {
}
})
const reconnectScheduler = createReconnectScheduler({
- shouldStop: () => stopped || connected,
+ shouldStop: () => stopped || connected || failedFinally,
reconnect: () => void open()
})
+ // Why: an outage grows the retry wait to its cap; a host that is back should not wait it out.
+ const stopHostContactWatch =
+ args.target.kind === 'environment'
+ ? subscribeRuntimeHostContactRegained(args.target.environmentId, () =>
+ reconnectScheduler.retryNow()
+ )
+ : () => {}
const isCurrentOpenGeneration = (candidate: number): boolean =>
!stopped && candidate === openGeneration
const clearUnattachedReadGrace = (): void => {
@@ -95,16 +132,25 @@ export function startStructuredAgentSessionReadTransport(args: {
* A window, not a mute. An unattached read still refusing past the grace is no longer
* transitional, so the pane is owed the failure rather than a spinner that never resolves.
*/
+ const applyReadFailure = (error: unknown): void => {
+ const refusal = readAgentSessionErrorRefusal(error)
+ failedFinally = isFinalAgentSessionReadRefusal(refusal)
+ if (refusal) {
+ args.applyError(readFailureText(error), refusal)
+ } else {
+ args.applyError(readFailureText(error))
+ }
+ }
const reportReadFailure = (error: unknown): void => {
if (!isUnattachedAgentSessionReadRefusal(error)) {
clearUnattachedReadGrace()
- args.applyError(readFailureText(error))
+ applyReadFailure(error)
return
}
const now = Date.now()
unattachedSince ??= now
if (now - unattachedSince >= AGENT_SESSION_UNATTACHED_READ_GRACE_MS) {
- args.applyError(readFailureText(error))
+ applyReadFailure(error)
}
}
const captureHistoryReadGuard = (): (() => boolean) => {
@@ -118,6 +164,10 @@ export function startStructuredAgentSessionReadTransport(args: {
return
}
clearUnattachedReadGrace()
+ // Not on connect: a local subscribe resolves before the host's open refuses.
+ if (event.type !== 'end') {
+ reconnectScheduler.reset()
+ }
if (event.type === 'snapshot' || event.type === 'reset') {
coalescer.flush()
if (!isCurrentOpenGeneration(eventOpenGeneration)) {
@@ -229,6 +279,7 @@ export function startStructuredAgentSessionReadTransport(args: {
stopped = true
openGeneration += 1
args.onHistoryReadInvalidated()
+ stopHostContactWatch()
reconnectScheduler.dispose()
coalescer.dispose()
unsubscribe()
diff --git a/src/renderer/src/components/native-chat/use-native-chat-launch-prompt-delivery-notice.ts b/src/renderer/src/components/native-chat/use-native-chat-launch-prompt-delivery-notice.ts
new file mode 100644
index 00000000000..c3d7974c160
--- /dev/null
+++ b/src/renderer/src/components/native-chat/use-native-chat-launch-prompt-delivery-notice.ts
@@ -0,0 +1,22 @@
+import { useMemo } from 'react'
+import { translate } from '@/i18n/i18n'
+import type { NativeChatMessage } from '../../../../shared/native-chat-types'
+import type { NativeChatDeliveryNotice } from './NativeChatMessageRow'
+
+/** The terminal-backed chat's one delivery notice: its launch prompt, when the terminal refused it
+ * and the prompt's row is on screen. The terminal is where to act, so it offers no Retry. */
+export function useNativeChatLaunchPromptDeliveryNotice(
+ failedMessageId: string | null | undefined,
+ messages: readonly NativeChatMessage[]
+): ReadonlyMap | undefined {
+ return useMemo(() => {
+ if (!failedMessageId || !messages.some((message) => message.id === failedMessageId)) {
+ return undefined
+ }
+ const text = translate(
+ 'components.native-chat.launchPromptNotDelivered',
+ 'Not delivered — check the terminal'
+ )
+ return new Map([[failedMessageId, { text }]])
+ }, [failedMessageId, messages])
+}
diff --git a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts
index 0661eb0df88..12528859d71 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts
+++ b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts
@@ -3,7 +3,7 @@ import {
getStructuredAgentSessionLaunchLifecycle,
getStructuredAgentSessionLaunchResumes,
retryStructuredAgentSessionLaunch,
- useStructuredAgentSessionLaunchFailureReason,
+ useStructuredAgentSessionLaunchFailure,
useStructuredAgentSessionLaunchLifecycle,
useStructuredAgentSessionLaunchSelection
} from '@/lib/structured-agent-session-launch'
@@ -66,7 +66,7 @@ export function useNativeChatProvisionalLaunch(
sessionId: string
) {
const lifecycle = useStructuredAgentSessionLaunchLifecycle(worktreeId ?? '', sessionId)
- const failureReason = useStructuredAgentSessionLaunchFailureReason(worktreeId ?? '', sessionId)
+ const failure = useStructuredAgentSessionLaunchFailure(worktreeId ?? '', sessionId)
const launch = useLatchedLaunchView(sessionId, worktreeId, lifecycle !== null)
const retry = useCallback(() => {
if (worktreeId) {
@@ -91,7 +91,7 @@ export function useNativeChatProvisionalLaunch(
return {
lifecycle,
launch,
- failureReason,
+ failure,
retry,
sendThroughRelaunch,
transportEnabled: lifecycle === null || lifecycle === 'published'
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts
index 328d76d0534..40979780b36 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts
@@ -12,7 +12,7 @@ import * as conversationCommands from './structured-conversation-command-send'
import type { AgentSessionMutationResult } from '../../../../shared/agent-session-wire'
import {
agentSessionRefusalFailure,
- agentSessionRpcErrorFailure,
+ agentSessionThrownFailure,
agentSessionWriteKindForMethod as writeKind
} from '../../../../shared/agent-session-write-failure'
import { agentSessionRefusalOperationState } from '../../../../shared/agent-session-refusal-retry'
@@ -98,7 +98,8 @@ export function useStructuredAgentSessionMutate(args: {
? {
kind: 'not-done',
notice: agentSessionWriteFailureText(
- agentSessionRpcErrorFailure(
+ agentSessionThrownFailure(
+ error,
error instanceof RuntimeRpcCallError ? error.code : undefined
),
writeKind(fingerprintMethod, fields)
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx
index 0fff127cd2c..feb9f898ce2 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx
@@ -21,6 +21,8 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({
import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox'
import { enqueueStructuredAgentSessionLaunchPrompt } from './structured-agent-session-outbox-storage'
import { settleStructuredAgentLaunchPrompt } from '@/lib/structured-agent-session-launch-prompt'
+import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices'
+import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key'
const LOCAL_TARGET = { kind: 'local' } as const
@@ -32,12 +34,19 @@ function deferred() {
return { promise, resolve }
}
-type SendRequest = { body?: { blocks?: { text?: string }[] } }
+type SendRequest = {
+ body?: { blocks?: { text?: string }[] }
+ envelope?: { clientOperationId?: string }
+}
function requestText(params: SendRequest | undefined): string | undefined {
return params?.body?.blocks?.[0]?.text
}
+function requestId(params: SendRequest | undefined): string {
+ return String(params?.envelope?.clientOperationId)
+}
+
function sentTexts(): (string | undefined)[] {
return mocks.call.mock.calls.map((call) => requestText(call[2]))
}
@@ -219,4 +228,94 @@ describe('structured agent session outbox admission', () => {
await act(async () => admission.resolve(submissionResult(String(id), 'pending', 10)))
expect(sentTexts()).toEqual(['first', 'second'])
})
+
+ // A message the queue is held on goes out only by its own Retry, never another message's.
+ it("keeps a refused message held when a rejected message's Retry shows while a head waits", async () => {
+ const inFlight = deferred>()
+ mocks.call.mockImplementation((_target, _method, params) => {
+ const text = requestText(params)
+ const attempt = sentTexts().filter((sent) => sent === text).length
+ const id = requestId(params)
+ if (text === 'held') {
+ return Promise.resolve(refusedResult('agent_session_ownership_unknown'))
+ }
+ if (attempt === 1) {
+ return Promise.resolve(refusedResult('agent_session_owner_restart_failed'))
+ }
+ return text === 'x' ? inFlight.promise : Promise.resolve(submissionResult(id, 'accepted', 20))
+ })
+ const { result } = renderOutbox()
+ for (const text of ['x', 'a', 'c']) {
+ act(() => expect(result.current.send(text)).toBe(true))
+ await waitFor(() => expect(result.current.outbox.at(-1)?.state).toBe('rejected'))
+ }
+ act(() => expect(result.current.send('held')).toBe(true))
+ await waitFor(() => expect(result.current.blockedClientMessageId).not.toBeNull())
+ const heldId = result.current.blockedClientMessageId
+ const [x, a, c] = result.current.outbox
+
+ // x goes out and stays in flight, so a, retried after it, waits queued ahead of the held one.
+ act(() => result.current.retry(x!.clientMessageId))
+ await waitFor(() => expect(sentTexts()).toEqual(['x', 'a', 'c', 'held', 'x']))
+ act(() => result.current.retry(a!.clientMessageId))
+ await settleTimers(20)
+ // The drain would send a next, so the queue reads as moving and c offers its own Retry.
+ const notices = structuredAgentSessionDeliveryNotices(
+ result.current.outbox,
+ result.current.blockedClientMessageId,
+ 'Claude',
+ () => {},
+ [],
+ []
+ )
+ expect(notices.get(agentJournalSubmissionKey(c!.clientMessageId))?.onRetry).toBeDefined()
+
+ act(() => result.current.retry(c!.clientMessageId))
+ expect(result.current.blockedClientMessageId).toBe(heldId)
+ await act(async () => inFlight.resolve(submissionResult(x!.clientMessageId, 'accepted', 20)))
+ await waitFor(() => expect(result.current.outbox).toHaveLength(1))
+ await settleTimers(50)
+ expect(sentTexts().filter((text) => text === 'held')).toHaveLength(1)
+ expect(result.current.blockedClientMessageId).toBe(heldId)
+ })
+
+ it('keeps a refused message held when an unconfirmed message ahead of it is retried', async () => {
+ const none: readonly AgentJournalSubmission[] = []
+ mocks.call.mockImplementation((_target, _method, params) => {
+ const id = requestId(params)
+ if (requestText(params) === 'held') {
+ return Promise.resolve(refusedResult('agent_session_ownership_unknown'))
+ }
+ const sent = sentTexts().filter((text) => text === 'first').length
+ return Promise.resolve(submissionResult(id, sent === 1 ? 'pending' : 'accepted', 10))
+ })
+ const { result, rerender } = renderHook(
+ ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) =>
+ useStructuredAgentSessionOutbox({
+ sessionId: 'session-1',
+ target: LOCAL_TARGET,
+ fence: 1,
+ submissions
+ }),
+ { initialProps: { submissions: none } }
+ )
+ act(() => expect(result.current.send('first')).toBe(true))
+ await waitFor(() => expect(result.current.outbox[0]?.state).toBe('dispatching'))
+ act(() => expect(result.current.send('held')).toBe(true))
+ await waitFor(() => expect(result.current.blockedClientMessageId).not.toBeNull())
+ const heldId = result.current.blockedClientMessageId
+ const firstId = String(result.current.outbox[0]?.clientMessageId)
+ const unknown: AgentJournalSubmission = {
+ ...submissionResult(firstId, 'pending', 10).value.submission,
+ dispatchState: 'unknown'
+ }
+ rerender({ submissions: [unknown] })
+ await waitFor(() => expect(result.current.outbox[0]?.state).toBe('unconfirmed'))
+
+ act(() => result.current.retry(firstId))
+ await waitFor(() => expect(result.current.outbox).toHaveLength(1))
+ await settleTimers(50)
+ expect(sentTexts()).toEqual(['first', 'held', 'first'])
+ expect(result.current.blockedClientMessageId).toBe(heldId)
+ })
})
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx
index a8b9f01b20b..3ce99e6fd4b 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx
@@ -17,7 +17,11 @@ import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-cap
import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox'
import { agentSessionWriteNoticeEnglish } from '../../../../shared/agent-session-refusal-notice'
import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition'
-import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
+import {
+ createStructuredAgentSessionOutboxEntry,
+ type StructuredAgentSessionOutboxEntry
+} from '../../../../shared/structured-agent-session-outbox'
+import { writeOutbox } from './structured-agent-session-outbox-storage'
function shownFailure(entry: StructuredAgentSessionOutboxEntry | undefined): string | undefined {
return (
@@ -269,6 +273,47 @@ describe('a send the host rejected because the agent never started', () => {
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
})
+ // After a restart nothing in memory remembers the rejection, and its journal row may be older
+ // than the loaded page: the message's own state is what says a resend needs a new id.
+ it('retries a message rejected before a restart under a new id', async () => {
+ const rejected = createStructuredAgentSessionOutboxEntry({
+ clientMessageId: 'rejected-before-restart',
+ sessionId: 'session-1',
+ text: 'first',
+ attachments: [],
+ queuedAt: 1
+ })
+ writeOutbox('session-1', [
+ {
+ ...rejected,
+ state: 'rejected',
+ lastFailure: {
+ kind: 'rejected',
+ reason: 'The provider did not accept this message.',
+ rejection: { kind: 'providerRejected' }
+ }
+ }
+ ])
+ mocks.call.mockImplementation(async (_target, _method, params) =>
+ acceptedResultFor(String(params.envelope.clientOperationId))
+ )
+ const { result } = renderHook(() =>
+ useStructuredAgentSessionOutbox({
+ sessionId: 'session-1',
+ target: { kind: 'local' },
+ fence: 1,
+ submissions: []
+ })
+ )
+ expect(result.current.outbox[0]?.state).toBe('rejected')
+
+ act(() => result.current.retry('rejected-before-restart'))
+ await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
+ const sentId: unknown = mocks.call.mock.calls[0]![2].envelope.clientOperationId
+ expect(sentId).not.toBe('rejected-before-restart')
+ await waitFor(() => expect(result.current.outbox).toHaveLength(0))
+ })
+
it('keeps the rejection when the journal settles the message before the send answers', async () => {
const reason = "Codex couldn't restart: spawn codex ENOENT."
let answer: (value: unknown) => void = () => undefined
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts
index 254c936cf63..ae2a1ae0a61 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts
@@ -52,7 +52,6 @@ export function useStructuredAgentSessionOutbox(args: {
const inFlightIdRef = useRef(null)
const dispatchGenerationRef = useRef(0)
const blockedIdRef = useRef(null)
- const retryWithFreshClientMessageIdRef = useRef(null)
const probeAttemptsRef = useRef({ id: null as string | null, attempts: 0 })
const [error, setError] = useState(null)
const [errorSession, setErrorSession] = useState(sessionId)
@@ -71,7 +70,6 @@ export function useStructuredAgentSessionOutbox(args: {
dispatchGenerationRef.current += 1
inFlightIdRef.current = null
blockedIdRef.current = null
- retryWithFreshClientMessageIdRef.current = null
probeAttemptsRef.current = { id: null, attempts: 0 }
}, [owner.ownerChange, owner.targetKey, sessionId])
@@ -139,7 +137,6 @@ export function useStructuredAgentSessionOutbox(args: {
// drain, so a later microtask would leave the queue with no trigger to move on.
inFlightIdRef.current = null
blockedIdRef.current = disposition.blockedClientMessageId
- retryWithFreshClientMessageIdRef.current = disposition.retryWithFreshClientMessageId
setError(disposition.error)
outboxRef.current = disposition.entries
setOutbox(disposition.entries)
@@ -286,21 +283,22 @@ export function useStructuredAgentSessionOutbox(args: {
)
const retry = (clientMessageId: string): void => {
- blockedIdRef.current = null
+ // Another message's Retry must not send the one the queue is held on.
+ if (blockedIdRef.current === clientMessageId) {
+ blockedIdRef.current = null
+ }
setError(null)
const submission = submissions.find(
(candidate) => candidate.clientMessageId === clientMessageId
)
const current = outboxRef.current.find((entry) => entry.clientMessageId === clientMessageId)
- // A provider-history reconciliation can settle an earlier unknown as
- // rejected before the user presses Retry. Reusing that operation id only
- // replays the settled rejection forever, so rotate the id for a safe resend.
- if (
- current &&
- (submission?.dispatchState === 'rejected' ||
- retryWithFreshClientMessageIdRef.current === clientMessageId)
- ) {
- retryWithFreshClientMessageIdRef.current = null
+ // The host settled this id as rejected, and reusing it only replays that forever, so rotate the
+ // id for a safe resend. Read from the message itself, which outlives a restart, or from a
+ // reconciliation that settled an earlier unknown before the outbox caught up. A refusal that
+ // settled the message already rotated it.
+ const recordedRejection =
+ current?.state === 'rejected' && current.lastFailure?.kind === 'rejected'
+ if (current && (recordedRejection || submission?.dispatchState === 'rejected')) {
const rotated = outboxRef.current.map((entry) =>
entry.clientMessageId === clientMessageId
? {
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts
new file mode 100644
index 00000000000..97ef023dffb
--- /dev/null
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts
@@ -0,0 +1,42 @@
+// @vitest-environment happy-dom
+
+import { renderHook } from '@testing-library/react'
+import { expect, it } from 'vitest'
+import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure'
+import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words'
+import { agentJournalItemKey } from '../../../../shared/agent-session-journal-item-key'
+import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
+import { structuredAgentSessionStartFailureRowIdentity } from '../../../../shared/structured-agent-session-start-failure-row-key'
+import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts'
+
+function startFailureRow(startKey: string, fact: AgentSessionFailureFact): AgentJournalRenderItem {
+ return {
+ itemId: agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity(startKey)),
+ revision: 1,
+ sequence: 1,
+ observedAt: 1,
+ body: {
+ kind: 'status',
+ tone: 'error',
+ ...agentSessionFailureWords(fact, { agentName: 'Claude', surface: 'row' })
+ }
+ }
+}
+
+// A streaming turn hands over a new item list on every delta; the facts must not follow it.
+it('holds the same facts while the start rows state nothing new', () => {
+ const row = startFailureRow('gen-1', { kind: 'providerStartFailed' })
+ const { result, rerender } = renderHook(
+ ({ items }) => useStructuredAgentSessionStartFailureFacts(items, true),
+ { initialProps: { items: [row] } }
+ )
+ const first = result.current
+ expect(first).toEqual([{ kind: 'providerStartFailed' }])
+
+ rerender({ items: [row, startFailureRow('other-start', { kind: 'providerStartFailed' })] })
+ expect(result.current).not.toBe(first)
+ const second = result.current
+
+ rerender({ items: [row, startFailureRow('other-start', { kind: 'providerStartFailed' })] })
+ expect(result.current).toBe(second)
+})
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
new file mode 100644
index 00000000000..83f20103de1
--- /dev/null
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts
@@ -0,0 +1,33 @@
+import { useEffect, useMemo, useRef } from 'react'
+import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure'
+import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
+import {
+ sameAgentSessionFailureFact,
+ structuredAgentSessionStartFailureFacts
+} from './structured-agent-session-delivery-notices'
+
+const NO_FACTS: readonly AgentSessionFailureFact[] = []
+
+/** What the loaded start-failure rows state, read only while `enabled`. Held while unchanged, so a
+ * streaming turn does not rebuild every row's delivery notice. */
+export function useStructuredAgentSessionStartFailureFacts(
+ items: readonly AgentJournalRenderItem[],
+ enabled: boolean
+): readonly AgentSessionFailureFact[] {
+ const facts = useMemo(
+ () => (enabled ? structuredAgentSessionStartFailureFacts(items) : NO_FACTS),
+ [enabled, items]
+ )
+ const previousRef = useRef(NO_FACTS)
+ const previous = previousRef.current
+ const stable =
+ previous.length === facts.length &&
+ previous.every((fact, index) => sameAgentSessionFailureFact(fact, facts[index]))
+ ? previous
+ : facts
+ // Written after commit, so render stays pure.
+ useEffect(() => {
+ previousRef.current = stable
+ }, [stable])
+ return stable
+}
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx
index 0336e81401a..8cc467a3d28 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx
@@ -115,6 +115,46 @@ describe('a chat write the host refused', () => {
)
})
+ it('words a refusal the host threw from its data, never the bare code it carries as its message', async () => {
+ mocks.call.mockImplementation((_target, method) =>
+ method === 'agentSession.options'
+ ? Promise.resolve(OPTIONS)
+ : Promise.reject(
+ new RuntimeRpcCallError({
+ id: 'request-1',
+ ok: false,
+ error: {
+ code: 'runtime_error',
+ message: 'agent_session_journal_unreadable',
+ data: {
+ refusal: {
+ code: 'agent_session_journal_unreadable',
+ details: { reason: 'journalUnavailable' }
+ }
+ }
+ },
+ _meta: { runtimeId: 'runtime-1' }
+ })
+ )
+ )
+ const { result } = renderHook(() =>
+ useStructuredAgentSession({
+ sessionId: 'session-1',
+ target: LOCAL_TARGET,
+ agent: 'claude',
+ isVisible: true
+ })
+ )
+
+ await act(async () => {
+ await expect(result.current.cancel('turn-1')).resolves.toBeNull()
+ })
+
+ expect(mocks.toastError).toHaveBeenCalledExactlyOnceWith(
+ "Orca couldn't open this chat's history right now. The agent wasn't stopped. Try again."
+ )
+ })
+
it('answers a refused conversation command inline, where the command was typed', async () => {
mocks.call.mockImplementation((_target, method) =>
method === 'agentSession.options'
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 e45699e830c..659bbafbbd9 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
@@ -136,7 +136,10 @@ export function useStructuredAgentSession(args: {
journalItems: transportState.journalItems,
messages,
status: transportEnabled ? state.status : 'ready',
- error: transportEnabled ? (state.error ?? outboxController.error) : outboxController.error,
+ /** The outbox's own line; a failed read is worded from `readRefusal`, never its text. */
+ error: outboxController.error,
+ /** The refusal the failed read met, while `status` is `error`. */
+ readRefusal: transportEnabled ? state.readRefusal : undefined,
hasOlder: transportEnabled && state.hasOlder,
railOutline: transportEnabled ? railOutline : null,
loadingOlder: transportEnabled && loadingOlder,
@@ -144,6 +147,8 @@ export function useStructuredAgentSession(args: {
loadOlder,
prompts,
outbox,
+ /** The journal's rows for sent messages, which carry a rejected message's whole fact. */
+ submissions: transportState.submissions,
blockedClientMessageId: outboxController.blockedClientMessageId,
send: (...input: Parameters) =>
!commandPending.current && outboxController.send(...input),
diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json
index 1e6ca9ecd65..57703fb7d1f 100644
--- a/src/renderer/src/i18n/en-runtime-required.json
+++ b/src/renderer/src/i18n/en-runtime-required.json
@@ -2687,7 +2687,8 @@
"notAgent": {
"subtitle": "This terminal is not running a recognized coding agent.",
"title": "No conversation here"
- }
+ },
+ "reconnecting": "Reconnecting to this chat…"
},
"status": {
"responding": "Agent is responding",
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 033e0ecbb23..b7aac035aef 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -17765,6 +17765,7 @@
"subtitle": "The transcript could not be read. Toggle back to the terminal to keep working.",
"retryingSubtitle": "The transcript could not be read. Orca keeps trying to load it."
},
+ "reconnecting": "Reconnecting to this chat…",
"pairHost": "Pair a host to view agent chat history.",
"notAgent": {
"title": "No conversation here",
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 5ce31869994..835eb4ab440 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -15058,6 +15058,7 @@
"subtitle": "No se pudo leer la transcripción. Vuelva a la terminal para seguir trabajando.",
"retryingSubtitle": "No se pudo leer la transcripción. Orca sigue intentando cargarla."
},
+ "reconnecting": "Reconectando con este chat…",
"pairHost": "Empareje un host para ver el historial de chat del agente.",
"notAgent": {
"title": "No hay conversación aquí",
diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json
index b8b96da9322..d2a04423ace 100644
--- a/src/renderer/src/i18n/locales/fr.json
+++ b/src/renderer/src/i18n/locales/fr.json
@@ -17620,6 +17620,7 @@
"subtitle": "La transcription n'a pas pu être lue. Rebasculez sur le terminal pour continuer à travailler.",
"retryingSubtitle": "La transcription n'a pas pu être lue. Orca continue d'essayer de la charger."
},
+ "reconnecting": "Reconnexion à ce chat…",
"pairHost": "Associez un hôte pour consulter l'historique de chat des agents.",
"notAgent": {
"title": "Aucune conversation ici",
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index c7041073d55..30bbb81cab1 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -17556,6 +17556,7 @@
"subtitle": "トランスクリプトを読み取ることができませんでした。作業を続けるには、ターミナルに切り替えてください。",
"retryingSubtitle": "トランスクリプトを読み取ることができませんでした。Orca は読み込みを再試行し続けます。"
},
+ "reconnecting": "このチャットに再接続しています…",
"pairHost": "ホストをペアリングして、Agent のチャット履歴を表示します。",
"notAgent": {
"title": "ここでは会話はありません",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index dfb560ec1ec..2c2318895a6 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -17556,6 +17556,7 @@
"subtitle": "스크립트를 읽을 수 없습니다. 계속 작업하려면 terminal로 다시 전환하세요.",
"retryingSubtitle": "스크립트를 읽을 수 없습니다. Orca가 계속 다시 불러오기를 시도합니다."
},
+ "reconnecting": "이 채팅에 다시 연결하는 중…",
"pairHost": "호스트를 페어링하여 에이전트 채팅 기록을 확인하세요.",
"notAgent": {
"title": "여기에는 대화가 없습니다.",
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 6433b62a232..3c0f5d7aa3d 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -17521,6 +17521,7 @@
"subtitle": "无法读取记录。请切回终端继续工作。",
"retryingSubtitle": "无法读取记录。Orca 会继续尝试加载。"
},
+ "reconnecting": "正在重新连接此聊天…",
"pairHost": "配对主机以查看智能体聊天记录。",
"notAgent": {
"title": "这里没有对话",
diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts
index 759f5c7d614..e1594d94c97 100644
--- a/src/renderer/src/lib/launch-structured-agent-session.ts
+++ b/src/renderer/src/lib/launch-structured-agent-session.ts
@@ -12,6 +12,11 @@ import {
import { resolveStructuredLaunchSeedOptions } from '../../../shared/native-chat-session-option-defaults'
import { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code'
import { isDefinitiveAgentSessionCreateRefusal } from '../../../shared/agent-session-definitive-refusal'
+import {
+ readAgentSessionRefusalReference,
+ type AgentSessionRefusalReference
+} from '../../../shared/agent-session-wire-refusals'
+import { readAgentSessionErrorRefusal } from '../../../shared/agent-session-write-failure'
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
import { useAppStore } from '@/store'
@@ -47,7 +52,9 @@ class StructuredAgentSessionCreateError extends Error {
constructor(
message: string,
/** The wire refusal code, or the RPC error code when the create never reached a handler. */
- readonly code: string
+ readonly code: string,
+ /** The host's refusal as a reader may word it; absent from an older host or a local failure. */
+ readonly refusal?: AgentSessionRefusalReference
) {
super(message)
}
@@ -58,8 +65,12 @@ class StructuredAgentSessionCreateError extends Error {
* `launchStructuredAgentSession` is the only place that decides it against the shared allowlist.
*/
export class StructuredAgentSessionCreateRefusalError extends StructuredAgentSessionCreateError {
- constructor(message: string, code: string = 'structured_agent_session_unsupported') {
- super(message, code)
+ constructor(
+ message: string,
+ code: string = 'structured_agent_session_unsupported',
+ refusal?: AgentSessionRefusalReference
+ ) {
+ super(message, code, refusal)
this.name = 'StructuredAgentSessionCreateRefusalError'
}
}
@@ -70,8 +81,8 @@ export class StructuredAgentSessionCreateRefusalError extends StructuredAgentSes
* down the same path as a lost reply, which replays the intent and reconciles.
*/
export class StructuredAgentSessionCreateUnknownOutcomeError extends StructuredAgentSessionCreateError {
- constructor(message: string, code: string) {
- super(message, code)
+ constructor(message: string, code: string, refusal?: AgentSessionRefusalReference) {
+ super(message, code, refusal)
this.name = 'StructuredAgentSessionCreateUnknownOutcomeError'
}
}
@@ -252,7 +263,8 @@ async function hostSupportsCreate(intent: StructuredAgentSessionLaunchIntent): P
}
throw new StructuredAgentSessionCreateUnknownOutcomeError(
error instanceof Error ? error.message : String(error),
- code
+ code,
+ readAgentSessionErrorRefusal(error)
)
}
}
@@ -291,20 +303,22 @@ export async function launchStructuredAgentSession(
abandonStructuredAgentSessionLaunchIntent(intent)
throw new StructuredAgentSessionCreateRefusalError(
error instanceof Error ? error.message : String(error),
- code
+ code,
+ readAgentSessionErrorRefusal(error)
)
}
throw error
}
if (!result.ok) {
const { code, message, ownerVerdict } = result.refusal
+ const refusal = readAgentSessionRefusalReference(result.refusal)
// A failed operation whose provider is proven gone is a failure a new operation may retry.
if (!isDefinitiveAgentSessionCreateRefusal(code) && ownerVerdict !== 'exited') {
// Keep the focus intent: the session may exist, and recovery still has to adopt it.
- throw new StructuredAgentSessionCreateUnknownOutcomeError(message, code)
+ throw new StructuredAgentSessionCreateUnknownOutcomeError(message, code, refusal)
}
abandonStructuredAgentSessionLaunchIntent(intent)
- throw new StructuredAgentSessionCreateRefusalError(message, code)
+ throw new StructuredAgentSessionCreateRefusalError(message, code, refusal)
}
return { sessionId: result.value.sessionId, fence: result.value.fence }
}
diff --git a/src/renderer/src/lib/structured-agent-session-launch-exited-owner.test.ts b/src/renderer/src/lib/structured-agent-session-launch-exited-owner.test.ts
index 1662e4c0430..10ebbc8fd89 100644
--- a/src/renderer/src/lib/structured-agent-session-launch-exited-owner.test.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch-exited-owner.test.ts
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
// The launch client against the create wire contract: a failed create whose provider is proven
-// gone reads as failed (with its reason) and Retry starts it again; any other verdict stays unknown.
+// gone reads as failed (with its refusal) and Retry starts it again; any other verdict stays unknown.
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
@@ -49,13 +49,14 @@ import {
startStructuredAgentLaunch
} from './structured-agent-session-launch'
import {
- getStructuredAgentSessionLaunchFailureReason,
+ getStructuredAgentSessionLaunchFailure,
resetStructuredAgentLaunchRegistryForTests
} from './structured-agent-session-launch-registry'
import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence'
const WORKTREE = 'wt-1'
const EXIT_REASON = 'claude stream-json exited (code 1): stderr tail'
+const HOST_REFUSAL = { kind: 'refused', code: 'agent_session_operation_invalid' }
/** The verdict the host puts on the failed operation's replay; undefined models an older host. */
let replayVerdict: AgentSessionOwnerVerdict | undefined
@@ -125,12 +126,13 @@ beforeEach(() => {
})
describe('structured launch after a host-failed create', () => {
- it('fails with the host reason when the provider is proven gone, and Retry starts it under a new operation', async () => {
+ it('fails with the host refusal when the provider is proven gone, and Retry starts it under a new operation', async () => {
const sessionId = launch()
await settle(sessionId)
expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE, sessionId)).toBe('failed')
- expect(getStructuredAgentSessionLaunchFailureReason(WORKTREE, sessionId)).toBe(EXIT_REASON)
+ // The host's message is for its log; the Retry line words the refusal.
+ expect(getStructuredAgentSessionLaunchFailure(WORKTREE, sessionId)).toEqual(HOST_REFUSAL)
expect(createdOperations.every((operation) => operation === failedOperation)).toBe(true)
expect(retryStructuredAgentSessionLaunch(WORKTREE, sessionId)).toBe(true)
@@ -147,7 +149,8 @@ describe('structured launch after a host-failed create', () => {
const sessionId = launch()
await settle(sessionId)
expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE, sessionId)).toBe('failed')
- expect(getStructuredAgentSessionLaunchFailureReason(WORKTREE, sessionId)).toBe(EXIT_REASON)
+ // The host's message is for its log; the Retry line words the refusal.
+ expect(getStructuredAgentSessionLaunchFailure(WORKTREE, sessionId)).toEqual(HOST_REFUSAL)
expect(createdOperations).toEqual([failedOperation])
expect(retryStructuredAgentSessionLaunch(WORKTREE, sessionId)).toBe(true)
await settle(sessionId)
diff --git a/src/renderer/src/lib/structured-agent-session-launch-failure.ts b/src/renderer/src/lib/structured-agent-session-launch-failure.ts
new file mode 100644
index 00000000000..b9ad791a0c4
--- /dev/null
+++ b/src/renderer/src/lib/structured-agent-session-launch-failure.ts
@@ -0,0 +1,18 @@
+// What a failed chat start's Retry line may say: the host's refusal, never its message.
+
+import { readAgentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals'
+import {
+ agentSessionRefusalFailure,
+ readAgentSessionErrorRefusal,
+ type AgentSessionWriteRefusal
+} from '../../../shared/agent-session-write-failure'
+
+/** The host's refusal behind a failed launch; undefined when the failure carried none (a lost
+ * reply, a local fault, an older host). Reads a create error's own refusal or an RPC error's. */
+export function structuredLaunchFailure(error: unknown): AgentSessionWriteRefusal | undefined {
+ const refusal =
+ error instanceof Error && 'refusal' in error
+ ? readAgentSessionRefusalReference(error.refusal)
+ : readAgentSessionErrorRefusal(error)
+ return refusal ? agentSessionRefusalFailure(refusal) : undefined
+}
diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.test.ts b/src/renderer/src/lib/structured-agent-session-launch-options.test.ts
index 8fa72308538..3787ebe3608 100644
--- a/src/renderer/src/lib/structured-agent-session-launch-options.test.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch-options.test.ts
@@ -3,6 +3,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts'
import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session'
+import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result'
type CallParams = {
envelope?: { expectedRuntimeFence?: number | null }
@@ -264,6 +265,41 @@ describe('picks made while a chat launches', () => {
])
})
+ it('reports a refusal the host threw as its fact, never the bare code it carries as a message', async () => {
+ mocks.launch.mockResolvedValue({ sessionId: SESSION_ID, fence: 1 })
+ startStructuredAgentLaunch(WORKTREE_ID, 'codex')
+ const pick = holdStructuredAgentSessionLaunchOption(SESSION_ID, 'model', 'gpt-missing')
+ await settle()
+ setOptionReplies[0]!.resolve(
+ Promise.reject(
+ new RuntimeRpcCallError({
+ id: 'request-1',
+ ok: false,
+ error: {
+ code: 'runtime_error',
+ message: 'agent_session_journal_unreadable',
+ data: {
+ refusal: {
+ code: 'agent_session_journal_unreadable',
+ details: { reason: 'journalUnavailable' }
+ }
+ }
+ },
+ _meta: { runtimeId: 'runtime-1' }
+ })
+ )
+ )
+
+ await expect(pick).resolves.toEqual({
+ kind: 'refused',
+ failure: {
+ kind: 'refused',
+ code: 'agent_session_journal_unreadable',
+ details: { reason: 'journalUnavailable' }
+ }
+ })
+ })
+
it('keeps picks held through a failed start and applies them to the retry', async () => {
mocks.launch
.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported'))
diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.ts b/src/renderer/src/lib/structured-agent-session-launch-options.ts
index 091e7543f88..b087264ce25 100644
--- a/src/renderer/src/lib/structured-agent-session-launch-options.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch-options.ts
@@ -23,7 +23,7 @@ import {
} from './structured-agent-session-launch-registry'
import {
agentSessionRefusalFailure,
- agentSessionRpcErrorFailure,
+ agentSessionThrownFailure,
type AgentSessionWriteFailure
} from '../../../shared/agent-session-write-failure'
@@ -110,7 +110,8 @@ async function setLaunchOption(
} catch (error) {
return {
kind: 'refused',
- failure: agentSessionRpcErrorFailure(
+ failure: agentSessionThrownFailure(
+ error,
error instanceof RuntimeRpcCallError ? error.code : undefined
)
}
diff --git a/src/renderer/src/lib/structured-agent-session-launch-registry.ts b/src/renderer/src/lib/structured-agent-session-launch-registry.ts
index dcafea65803..2f444446e03 100644
--- a/src/renderer/src/lib/structured-agent-session-launch-registry.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch-registry.ts
@@ -1,5 +1,6 @@
import { useSyncExternalStore } from 'react'
import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
+import type { AgentSessionWriteRefusal } from '../../../shared/agent-session-write-failure'
import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create'
import type { StructuredLaunchRecoveryState } from './structured-agent-session-launch-recovery'
import type { StructuredLaunchSelection } from './structured-agent-session-launch-options'
@@ -26,8 +27,9 @@ export type StructuredLaunchState = StructuredLaunchRecoveryState & {
/** Fixed by the caller that opened this launch so coalesced prompts use one delivery mode. */
promptDelivery: StructuredAgentLaunchOptions['promptDelivery']
callers: StructuredLaunchCallerGroup
- /** Why the last attempt failed, shown beside Retry; the toast stays generic. */
- failureReason?: string
+ /** The host's refusal behind the last failed attempt, worded beside Retry; the toast stays
+ * generic. Absent when the failure named none. */
+ failure?: AgentSessionWriteRefusal
selection: StructuredLaunchSelection
}
@@ -176,25 +178,25 @@ export function getStructuredAgentSessionLaunchResumes(sessionId: string): boole
return resumeFrom !== undefined
}
-export function getStructuredAgentSessionLaunchFailureReason(
+export function getStructuredAgentSessionLaunchFailure(
worktreeId: string,
sessionId: string
-): string | null {
+): AgentSessionWriteRefusal | null {
const state = getStructuredLaunchStateBySessionId(sessionId)
return state &&
matchesLaunchWorktree(state, worktreeId) &&
launchStateLifecycle(state) === 'failed'
- ? (state.failureReason ?? null)
+ ? (state.failure ?? null)
: null
}
-export function useStructuredAgentSessionLaunchFailureReason(
+export function useStructuredAgentSessionLaunchFailure(
worktreeId: string,
sessionId: string
-): string | null {
+): AgentSessionWriteRefusal | null {
return useSyncExternalStore(
subscribeStructuredAgentLaunchStatus,
- () => getStructuredAgentSessionLaunchFailureReason(worktreeId, sessionId),
+ () => getStructuredAgentSessionLaunchFailure(worktreeId, sessionId),
() => null
)
}
diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts
index 5ad75c36ed6..4c62128813b 100644
--- a/src/renderer/src/lib/structured-agent-session-launch.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch.ts
@@ -27,6 +27,7 @@ import {
} from '@/lib/structured-agent-session-launch-callers'
import * as launchDraft from './structured-agent-session-launch-draft'
import { trackStructuredLaunchFailureToast } from './structured-agent-session-launch-failure-toast'
+import { structuredLaunchFailure } from './structured-agent-session-launch-failure'
import {
deleteStructuredLaunchStateIfCurrent,
getStructuredLaunchState,
@@ -51,7 +52,7 @@ export {
retireStructuredAgentSessionLaunchCancellationTombstone,
shouldRetainStructuredAgentSessionLaunchTab,
subscribeStructuredAgentLaunchStatus,
- useStructuredAgentSessionLaunchFailureReason,
+ useStructuredAgentSessionLaunchFailure,
useStructuredAgentSessionLaunchLifecycle,
type StructuredAgentLaunchStatus,
type StructuredAgentSessionLaunchLifecycle
@@ -138,7 +139,13 @@ function trackLaunchSettlement(
}
return
}
- state.failureReason = error instanceof Error ? error.message : String(error)
+ // The host's message is for its log; the Retry line words the refusal itself.
+ const failure = structuredLaunchFailure(error)
+ if (failure) {
+ state.failure = failure
+ } else {
+ delete state.failure
+ }
if (error instanceof StructuredAgentSessionCreateRefusalError) {
settleStructuredLaunchRefusal(state)
} else if (!state.visibilityUnknown) {
@@ -171,7 +178,7 @@ function restartStructuredLaunchState(state: StructuredLaunchState): void {
state.intent = retryStructuredAgentSessionLaunchIntent(state.intent)
}
resetStructuredLaunchCallers(state)
- delete state.failureReason
+ delete state.failure
state.callers.outcome = 'pending'
// A new create seeds from the settings of now; picks held through the failure still apply.
state.selection = { ...state.selection, seed: state.intent.seedOptions }
diff --git a/src/renderer/src/runtime/runtime-host-contact-regained.test.ts b/src/renderer/src/runtime/runtime-host-contact-regained.test.ts
new file mode 100644
index 00000000000..5daabf37790
--- /dev/null
+++ b/src/renderer/src/runtime/runtime-host-contact-regained.test.ts
@@ -0,0 +1,111 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { RuntimeStatus } from '../../../shared/runtime-types'
+
+vi.mock('sonner', () => ({
+ toast: { warning: vi.fn(), dismiss: vi.fn() }
+}))
+
+vi.mock('@/store', async () => {
+ const { create } = await import('zustand')
+ const { createRuntimeStatusSlice } = await import('@/store/slices/runtime-status')
+ type Slice = ReturnType
+ return {
+ useAppStore: create()((...a) =>
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the slice reads only its own fields.
+ createRuntimeStatusSlice(...(a as unknown as Parameters))
+ )
+ }
+})
+
+import { useAppStore } from '@/store'
+import { clearRuntimeEnvironmentConnectionGenerationsForTests } from '@/store/slices/runtime-status'
+import { subscribeRuntimeHostContactRegained } from './runtime-host-contact-regained'
+
+function makeStatus(runtimeId: string): RuntimeStatus {
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fixture carries only the fields the slice reads.
+ return {
+ runtimeId,
+ rendererGraphEpoch: 0,
+ graphStatus: 'ready',
+ authoritativeWindowId: null,
+ liveTabCount: 0,
+ liveLeafCount: 0,
+ runtimeProtocolVersion: 3,
+ minCompatibleRuntimeClientVersion: 3
+ } as RuntimeStatus
+}
+
+let lastVerified: RuntimeStatus | null = null
+
+// As the host-status snapshot does: an outage keeps the last verified status, so a same-runtime
+// return moves only the contact epoch, not the connection generation.
+function publish(status: RuntimeStatus | null, checkedAt: number): void {
+ lastVerified = status ?? lastVerified
+ useAppStore.getState().setRuntimeEnvironmentStatus('env-a', {
+ status,
+ checkedAt,
+ snapshot: {
+ environmentId: 'env-a',
+ pairingRevision: 1,
+ sequence: checkedAt,
+ checkedAt,
+ status: lastVerified,
+ verification: status ? 'verified' : 'unavailable',
+ transport: status ? 'ready' : 'disconnected'
+ }
+ })
+}
+
+beforeEach(() => {
+ clearRuntimeEnvironmentConnectionGenerationsForTests()
+ vi.stubGlobal('window', { api: {}, dispatchEvent: vi.fn() })
+ useAppStore.setState({ runtimeStatusByEnvironmentId: new Map() })
+ lastVerified = null
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe('subscribeRuntimeHostContactRegained', () => {
+ it('fires when the runtime answers again after an outage, and not while it stays down', () => {
+ publish(makeStatus('runtime-a'), 1)
+ const listener = vi.fn()
+ const stop = subscribeRuntimeHostContactRegained('env-a', listener)
+
+ publish(null, 2)
+ publish(null, 3)
+ expect(listener).not.toHaveBeenCalled()
+
+ // Same runtime back: only the contact epoch moves.
+ const generation = useAppStore
+ .getState()
+ .runtimeStatusByEnvironmentId.get('env-a')?.connectionGeneration
+ publish(makeStatus('runtime-a'), 4)
+ expect(
+ useAppStore.getState().runtimeStatusByEnvironmentId.get('env-a')?.connectionGeneration
+ ).toBe(generation)
+ expect(listener).toHaveBeenCalledTimes(1)
+ publish(makeStatus('runtime-a'), 5)
+ expect(listener).toHaveBeenCalledTimes(1)
+
+ // A restarted runtime: the connection generation moves.
+ publish(makeStatus('runtime-b'), 6)
+ expect(listener).toHaveBeenCalledTimes(2)
+
+ stop()
+ publish(null, 7)
+ publish(makeStatus('runtime-b'), 8)
+ expect(listener).toHaveBeenCalledTimes(2)
+ })
+
+ it('ignores other environments', () => {
+ publish(makeStatus('runtime-a'), 1)
+ const listener = vi.fn()
+ const stop = subscribeRuntimeHostContactRegained('env-b', listener)
+ publish(null, 2)
+ publish(makeStatus('runtime-a'), 3)
+ expect(listener).not.toHaveBeenCalled()
+ stop()
+ })
+})
diff --git a/src/renderer/src/runtime/runtime-host-contact-regained.ts b/src/renderer/src/runtime/runtime-host-contact-regained.ts
new file mode 100644
index 00000000000..b829f51d99a
--- /dev/null
+++ b/src/renderer/src/runtime/runtime-host-contact-regained.ts
@@ -0,0 +1,31 @@
+import { useAppStore } from '@/store'
+import type { AppState } from '@/store/types'
+
+function contactMarks(state: AppState, environmentId: string): string | null {
+ const entry = state.runtimeStatusByEnvironmentId.get(environmentId)
+ return entry ? `${entry.hostContactEpoch ?? 0}:${entry.connectionGeneration ?? 0}` : null
+}
+
+/**
+ * Calls `listener` each time this client regains contact with an environment's runtime: the
+ * same-runtime return (`hostContactEpoch`) or a new runtime session (`connectionGeneration`),
+ * both published by runtime-status.ts.
+ */
+export function subscribeRuntimeHostContactRegained(
+ environmentId: string,
+ listener: () => void
+): () => void {
+ let lastMarks = contactMarks(useAppStore.getState(), environmentId)
+ return useAppStore.subscribe((state, previousState) => {
+ if (state.runtimeStatusByEnvironmentId === previousState.runtimeStatusByEnvironmentId) {
+ return
+ }
+ const marks = contactMarks(state, environmentId)
+ // Why keep the last marks across a missing entry: a cleared status that returns is a reconnect.
+ if (marks === null || marks === lastMarks) {
+ return
+ }
+ lastMarks = marks
+ listener()
+ })
+}
diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts
index f02d02abdea..edd399edc47 100644
--- a/src/shared/agent-session-failure-words.ts
+++ b/src/shared/agent-session-failure-words.ts
@@ -53,6 +53,9 @@ export type AgentSessionFailureWordsContext = {
/** The conversation command a failed start was for, so the next step is to run it again
* rather than to send a message. */
command?: 'clear'
+ /** The surface retries for the person — its own Retry beside the words, or a read that reconnects
+ * on its own — so they leave out sending or trying again. */
+ retryControl?: boolean
}
/**
@@ -110,7 +113,10 @@ function retryStep({ command }: AgentSessionFailureWordsContext): string {
}
/** The next step after a start or restart that failed: the command, or the message, again. */
-function startRetry({ command }: AgentSessionFailureWordsContext): string {
+function startRetry({ command, retryControl }: AgentSessionFailureWordsContext): string {
+ if (retryControl) {
+ return ''
+ }
return command === 'clear' ? ' Run /clear again.' : ' Send your message to try again.'
}
@@ -162,8 +168,9 @@ const FAILURE_SENTENCES = {
providerStartFailed: (context) =>
`${context.agentName ?? 'The agent'} stopped before it finished starting.${startRetry(context)}`,
startFailed: couldNot('start'),
+ // Beside a Retry the resend is the button, but signing in is still a step to take first.
notSignedIn: (context) =>
- `${context.agentName ?? 'The agent'} is not signed in for the selected account. Sign in, then ${retryStep(context)}.`,
+ `${context.agentName ?? 'The agent'} is not signed in for the selected account. ${context.retryControl ? 'Sign in first.' : `Sign in, then ${retryStep(context)}.`}`,
historyTooLarge: () =>
`This conversation's history is too large to restore here. ${START_NEW_CHAT}`,
managedAccountEnvOverride: () =>
@@ -171,7 +178,7 @@ const FAILURE_SENTENCES = {
accountSwitchInProgress: () =>
'A Claude account switch is in progress. Try again after it finishes.',
managedAccountUnsupported: (context) =>
- `While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings, then ${retryStep(context)}.`,
+ `While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings${context.retryControl ? '' : `, then ${retryStep(context)}`}.`,
providerExited: ({ agentName }, _, surface) =>
surface === 'row'
? `${agentName ?? 'The agent'} stopped while this response was in progress. You can continue in this conversation.`
@@ -190,12 +197,16 @@ const FAILURE_SENTENCES = {
cancelled: () => 'This message was withdrawn before the agent started it.',
chatClosed: () => 'The chat closed before this message was sent.',
hostRestarted: () => 'Orca restarted before this message was sent.',
- notDelivered: () => 'This message was not delivered. Send it again to continue.',
+ notDelivered: ({ retryControl }) =>
+ retryControl
+ ? 'This message was not delivered.'
+ : 'This message was not delivered. Send it again to continue.',
compactionFailed: (_, fact) => quotingPersonDetail('Compaction failed', fact.detail),
compactionUnconfirmed: () => 'Compaction completion is unconfirmed.',
cancelUnconfirmed: () => 'Cancellation was not confirmed.',
answerUnconfirmed: () => 'Your answer was recorded but the agent did not confirm it.',
- hostFault: () => "Orca ran into a problem, so this didn't go through. Try again.",
+ hostFault: ({ retryControl }) =>
+ `Orca ran into a problem, so this didn't go through.${retryControl ? '' : ' Try again.'}`,
hostStopped: ({ agentName }) =>
`${agentName ?? 'The agent'} never finished starting, so Orca stopped it.`,
providerRetrying: ({ agentName }, { retry }) =>
diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts
index 73652adbf1a..a83a95c4388 100644
--- a/src/shared/agent-session-refusal-notice.test.ts
+++ b/src/shared/agent-session-refusal-notice.test.ts
@@ -5,6 +5,7 @@ import {
} from './agent-session-wire-refusals'
import {
agentSessionReadHistoryRefusalParts,
+ agentSessionRefusalCauseParts,
agentSessionRefusalNotice,
agentSessionRefusalReasonWords,
agentSessionWriteFailureNotice,
@@ -368,6 +369,45 @@ describe('the notice for every reason a host names', () => {
}
})
+ // Beside a Retry, that Retry is the step for a reason whose action is to retry, and only for it.
+ it('leaves out only a retry step when a Retry stands beside the words', () => {
+ const beside = { agentName: 'Codex', retryControl: true }
+ for (const { failure, write, parts, words, cell } of cells) {
+ if (words && 'cause' in words) {
+ const retried = words.action === 'retry' ? words.step : undefined
+ expect(agentSessionWriteNoticeParts(failure, write, beside), cell).toEqual(
+ parts.filter((part) => part !== retried)
+ )
+ }
+ }
+ const send = (failure: AgentSessionWriteRefusal) =>
+ agentSessionWriteNoticeParts(failure, 'send', beside)
+ expect(
+ send({
+ kind: 'refused',
+ code: 'agent_session_journal_unreadable',
+ details: { reason: 'journalUnavailable' }
+ })
+ ).toEqual(['historyUnavailable', 'notDoneSend'])
+ expect(
+ send({
+ kind: 'refused',
+ code: 'agent_session_operation_invalid',
+ details: { reason: 'notSignedIn' }
+ })
+ ).toEqual([
+ 'notDoneSend',
+ { text: 'Codex is not signed in for the selected account. Sign in first.' }
+ ])
+ expect(
+ send({
+ kind: 'refused',
+ code: 'agent_session_operation_invalid',
+ details: { reason: 'providerStartFailed' }
+ })
+ ).toEqual(['notDoneSend', { text: 'Codex stopped before it finished starting.' }])
+ })
+
it('says the write did not happen, once, and never shows the host message', () => {
for (const { failure, write, parts, cell } of cells) {
const english = agentSessionWriteNoticeEnglish(parts)
@@ -522,3 +562,84 @@ describe('a chat whose history the host could not open', () => {
])
})
})
+
+// For a line that already says what did not happen and shows its own Retry, such as a chat that
+// could not start: the Retry is the step for retrying and for sending again, and only for those.
+describe('agentSessionRefusalCauseParts', () => {
+ it('keeps a step other than retrying, beside the cause', () => {
+ expect(
+ agentSessionRefusalCauseParts({
+ kind: 'refused',
+ code: 'agent_session_operation_invalid',
+ details: { reason: 'turnActive' }
+ })
+ ).toEqual(['turnActive', 'waitForTurn'])
+ expect(
+ agentSessionRefusalCauseParts({
+ kind: 'refused',
+ code: 'agent_session_operation_invalid',
+ details: { reason: 'historyTooLarge' }
+ })
+ ).toEqual([
+ {
+ text: "This conversation's history is too large to restore here. Start a new chat to continue."
+ }
+ ])
+ })
+
+ it('leaves out a step the Retry beside it takes', () => {
+ expect(
+ agentSessionRefusalCauseParts({
+ kind: 'refused',
+ code: 'agent_session_journal_unreadable',
+ details: { reason: 'journalUnavailable' }
+ })
+ ).toEqual(['historyUnavailable'])
+ expect(
+ agentSessionRefusalCauseParts(
+ {
+ kind: 'refused',
+ code: 'agent_session_operation_invalid',
+ details: { reason: 'notSignedIn' }
+ },
+ { agentName: 'Claude' }
+ )
+ ).toEqual([{ text: 'Claude is not signed in for the selected account. Sign in first.' }])
+ expect(
+ agentSessionRefusalCauseParts({
+ kind: 'refused',
+ code: 'agent_session_operation_invalid',
+ details: { reason: 'providerStartFailed' }
+ })
+ ).toEqual([{ text: 'The agent stopped before it finished starting.' }])
+ expect(
+ agentSessionRefusalCauseParts({
+ kind: 'refused',
+ code: 'agent_session_operation_invalid',
+ details: { reason: 'managedAccountUnsupported' }
+ })
+ ).toEqual([
+ {
+ text: 'While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings.'
+ }
+ ])
+ })
+
+ it('writes the same sentences as before where no Retry stands beside them', () => {
+ expect(agentSessionFailureSentence({ kind: 'notSignedIn' }, 'rejection')).toBe(
+ 'The agent is not signed in for the selected account. Sign in, then send your message again.'
+ )
+ })
+
+ it.each<[string, AgentSessionWriteFailure]>([
+ ['a refusal with no reason', { kind: 'refused', code: 'agent_session_operation_invalid' }],
+ [
+ "a reason whose code's words stand",
+ { kind: 'refused', code: 'agent_session_conflict', details: { reason: 'ownerAlive' } }
+ ],
+ ['a failed request', { kind: 'failed' }],
+ ['an unconfirmed one', { kind: 'unconfirmed' }]
+ ])('names nothing for %s', (_label, failure) => {
+ expect(agentSessionRefusalCauseParts(failure)).toEqual([])
+ })
+})
diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts
index 818031b8191..0f4121dc9b3 100644
--- a/src/shared/agent-session-refusal-notice.ts
+++ b/src/shared/agent-session-refusal-notice.ts
@@ -6,11 +6,15 @@
// own row names a cause only where every emitter of the code means it; it is also the words for a
// host too old to send a reason. A notice says how to get past a refusal only where the person has
// a step to take; retrying is the control that sent the write, except on the phone, whose message
-// goes back to the composer, and a history that couldn't open right now says to try again.
+// goes back to the composer, and a history that couldn't open right now says to try again unless
+// something beside the words retries it.
// Surfaces keep the fact and choose the words when they show it, so nothing saved carries copy.
import type { AgentSessionFailureKind } from './agent-session-failure'
-import { agentSessionFailureSentence } from './agent-session-failure-words'
+import {
+ agentSessionFailureSentence,
+ type AgentSessionFailureWordsContext
+} from './agent-session-failure-words'
import type { AgentSessionRefusalReason } from './agent-session-refusal-details'
import {
AGENT_SESSION_WRITE_NOTICE_COPY,
@@ -80,8 +84,6 @@ export type AgentSessionRefusalReasonWords =
/** A start that failed: the sentence that failure has everywhere, whose next step is a send. */
| { fact: AgentSessionFailureKind; action: AgentSessionRefusalAction }
-type AgentSessionRefusalCauseWords = Extract
-
function codeWords(action: AgentSessionRefusalAction): AgentSessionRefusalReasonWords {
return { words: 'code', action }
}
@@ -197,7 +199,7 @@ const REASON_WORDS = {
agent_session_journal_unreadable: {
// No retry reads past damage, and the words name no step: it only can't load.
journalCorrupt: causeWords('historyUnusable', 'hostFinding'),
- // Says its step despite 'retry': released clients and the phone often show no Retry here.
+ // Says its step despite 'retry' unless a Retry stands beside it: the phone often has none.
journalUnavailable: causeWords('historyUnavailable', 'retry', 'tryAgain')
},
// Thrown, so a client meets these only as an RPC error; the code's words stand.
@@ -242,34 +244,47 @@ function causeParts(
return saysNotDone ? [cause] : [cause, NOT_DONE[write]]
}
-function causeWordsParts(
- words: AgentSessionRefusalCauseWords,
- write: AgentSessionWriteKind
-): AgentSessionWriteNoticeSentence[] {
- const said = causeParts(words.cause, write)
- return words.step ? [...said, words.step] : said
-}
-
/** The notice a named reason has of its own; undefined leaves the code's words. */
function reasonParts(
failure: AgentSessionWriteRefusal,
- write: AgentSessionWriteKind
+ write: AgentSessionWriteKind,
+ context: AgentSessionFailureWordsContext
): AgentSessionWriteNoticePart[] | undefined {
const words = agentSessionRefusalReasonWords(failure)
if (!words || 'words' in words) {
return undefined
}
if ('fact' in words) {
+ const sentence = agentSessionFailureSentence({ kind: words.fact }, 'rejection', context)
return write === 'send' || write === 'composer-send'
- ? [NOT_DONE[write], { text: agentSessionFailureSentence({ kind: words.fact }, 'rejection') }]
+ ? [NOT_DONE[write], { text: sentence }]
: undefined
}
- return causeWordsParts(words, write)
+ const said = causeParts(words.cause, write)
+ // A Retry beside the words is the step for a reason whose action is to retry.
+ const retried = context.retryControl && words.action === 'retry'
+ return words.step && !retried ? [...said, words.step] : said
}
+/** What stopped a refused start, for a line that already says the chat did not start and shows its
+ * own Retry. Empty when the refusal names no reason with words of its own. */
+export function agentSessionRefusalCauseParts(
+ failure: AgentSessionWriteFailure,
+ context: { agentName?: string } = {}
+): AgentSessionWriteNoticePart[] {
+ const parts =
+ failure.kind === 'refused'
+ ? reasonParts(failure, 'send', { ...context, retryControl: true })
+ : undefined
+ return parts?.filter((part) => part !== 'notDoneSend') ?? []
+}
+
+/** `context.retryControl`: a Retry beside the words is the step for a reason whose action is to
+ * retry, and for sending again; any other step stays. */
export function agentSessionWriteNoticeParts(
failure: AgentSessionWriteFailure,
- write: AgentSessionWriteKind
+ write: AgentSessionWriteKind,
+ context: AgentSessionFailureWordsContext = {}
): AgentSessionWriteNoticePart[] {
const notDone = NOT_DONE[write]
if (failure.kind === 'failed') {
@@ -278,7 +293,7 @@ export function agentSessionWriteNoticeParts(
if (failure.kind === 'unconfirmed') {
return ['outcomeUnknown']
}
- const byReason = reasonParts(failure, write)
+ const byReason = reasonParts(failure, write, context)
if (byReason) {
return byReason
}
@@ -350,10 +365,11 @@ export function agentSessionWriteFailureNotice(write: AgentSessionWriteKind): st
* host sent; a code this build does not know says only that the history did not load. */
export function agentSessionReadHistoryRefusalParts(
code: string,
- details?: unknown
+ details?: unknown,
+ context: AgentSessionFailureWordsContext = {}
): AgentSessionWriteNoticePart[] {
const failure = parseAgentSessionWriteFailure({ kind: 'refused', code, details })
return failure
- ? agentSessionWriteNoticeParts(failure, 'read-history')
+ ? agentSessionWriteNoticeParts(failure, 'read-history', context)
: agentSessionWriteNotDoneParts('read-history')
}
diff --git a/src/shared/agent-session-write-failure.test.ts b/src/shared/agent-session-write-failure.test.ts
index 83d8faea790..0b614b3a265 100644
--- a/src/shared/agent-session-write-failure.test.ts
+++ b/src/shared/agent-session-write-failure.test.ts
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest'
import {
agentSessionRefusalFailure,
- parseAgentSessionWriteFailure
+ agentSessionThrownFailure,
+ parseAgentSessionWriteFailure,
+ readAgentSessionErrorRefusal
} from './agent-session-write-failure'
const HOST_TEXT = 'Expected runtime fence 1; the session is at 3.'
@@ -122,3 +124,44 @@ describe('agentSessionRefusalFailure', () => {
})
})
})
+
+describe('a refusal a failed request carried in its error', () => {
+ const refusal = {
+ code: 'agent_session_journal_unreadable',
+ details: { reason: 'journalCorrupt', stray: 'dropped' }
+ }
+ const payload = { code: 'runtime_error', message: 'agent_session_journal_unreadable' }
+
+ it('is read from a thrown RPC error and from a stream payload alike', () => {
+ const expected = {
+ code: 'agent_session_journal_unreadable',
+ details: { reason: 'journalCorrupt' }
+ }
+ expect(
+ readAgentSessionErrorRefusal({ response: { error: { ...payload, data: { refusal } } } })
+ ).toEqual(expected)
+ expect(readAgentSessionErrorRefusal(saved({ ...payload, data: { refusal } }))).toEqual(expected)
+ })
+
+ it.each([
+ ['an older host', payload],
+ ['a thrown error without a response', new Error('agent_session_journal_unreadable')],
+ ['a code this build does not know', { ...payload, data: { refusal: { code: 'from_later' } } }],
+ ['nothing', undefined]
+ ])('is absent from %s', (_label, error) => {
+ expect(readAgentSessionErrorRefusal(error)).toBeUndefined()
+ })
+
+ it("words the refusal when there is one, else what the request's error code proves", () => {
+ expect(agentSessionThrownFailure({ ...payload, data: { refusal } }, 'runtime_error')).toEqual({
+ kind: 'refused',
+ code: 'agent_session_journal_unreadable',
+ details: { reason: 'journalCorrupt' }
+ })
+ expect(agentSessionThrownFailure(payload, 'runtime_error')).toEqual({ kind: 'unconfirmed' })
+ expect(agentSessionThrownFailure(payload, 'method_not_found')).toEqual({
+ kind: 'refused',
+ code: 'structured_agent_session_unsupported'
+ })
+ })
+})
diff --git a/src/shared/agent-session-write-failure.ts b/src/shared/agent-session-write-failure.ts
index dd5c8f26caa..81809133871 100644
--- a/src/shared/agent-session-write-failure.ts
+++ b/src/shared/agent-session-write-failure.ts
@@ -9,7 +9,9 @@ import {
import type { AgentSessionRewindReason } from './agent-session-rewind'
import {
isAgentSessionWireRefusalCode,
+ readAgentSessionRefusalReference,
type AgentSessionOwnerVerdict,
+ type AgentSessionRefusalReference,
type AgentSessionWireRefusal,
type AgentSessionWireRefusalCode
} from './agent-session-wire-refusals'
@@ -136,6 +138,35 @@ export function agentSessionRpcErrorFailure(code: string | undefined): AgentSess
: { kind: 'unconfirmed' }
}
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null
+}
+
+/** The refusal a failed request's error carries in its data. Takes both shapes a client meets: an
+ * RPC call's thrown error, whose payload is on `response.error`, and the payload a stream hands
+ * its error callback. Undefined from an older host, or for a failure that was not a refusal. */
+export function readAgentSessionErrorRefusal(
+ error: unknown
+): AgentSessionRefusalReference | undefined {
+ const payload =
+ isRecord(error) && isRecord(error.response) && isRecord(error.response.error)
+ ? error.response.error
+ : error
+ const data = isRecord(payload) ? payload.data : undefined
+ return isRecord(data) ? readAgentSessionRefusalReference(data.refusal) : undefined
+}
+
+/** What to say about a request that threw: the host's refusal when its error carried one, else
+ * what the RPC error code proves. Words only: whether the write may have happened stays the
+ * caller's own classification. */
+export function agentSessionThrownFailure(
+ error: unknown,
+ rpcCode: string | undefined
+): AgentSessionWriteFailure {
+ const refusal = readAgentSessionErrorRefusal(error)
+ return refusal ? agentSessionRefusalFailure(refusal) : agentSessionRpcErrorFailure(rpcCode)
+}
+
/** A saved failure, or undefined when it is not one this build wrote. */
export function parseAgentSessionWriteFailure(
value: unknown
diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts
index 4211bbb3961..c14209a716c 100644
--- a/src/shared/structured-agent-session-outbox.ts
+++ b/src/shared/structured-agent-session-outbox.ts
@@ -36,8 +36,9 @@ export type StructuredAgentSessionOutboxEntry = {
lastFailure?: StructuredAgentSessionAttemptFailure
}
-/** A host's rejection fact as a message keeps it: never its provider detail, whose person-facing
- * words are already in the reason and whose log text is not kept client-side. */
+/** A host's rejection fact as a message keeps it: never its provider detail, whose log text is not
+ * kept client-side, or its refusal. The journal row keeps the whole fact, and words the notice
+ * while it is loaded; this copy words it when it is not. */
export type StructuredAgentSessionRejectionFact = Pick<
AgentSessionFailureFact,
'kind' | 'attachment'
diff --git a/src/shared/structured-agent-session-read-refusal.ts b/src/shared/structured-agent-session-read-refusal.ts
index 784f38ed2a2..74279d7811d 100644
--- a/src/shared/structured-agent-session-read-refusal.ts
+++ b/src/shared/structured-agent-session-read-refusal.ts
@@ -1,3 +1,5 @@
+import type { AgentSessionRefusalReference } from './agent-session-wire-refusals'
+
/**
* The one refusal a structured-session READ can raise that is not a failure to read.
*
@@ -43,3 +45,17 @@ export function isUnattachedAgentSessionReadRefusal(error: unknown): boolean {
message === AGENT_SESSION_UNATTACHED_REFUSAL_CODE
)
}
+
+/**
+ * A read refusal no retry reads past: SQLite reported the chat's journal damaged. Decided from the
+ * reason, never the message, which is the bare code for every journal refusal; a journal that
+ * failed to open for any other reason can clear, so its read keeps reconnecting.
+ */
+export function isFinalAgentSessionReadRefusal(
+ refusal: AgentSessionRefusalReference | undefined
+): boolean {
+ return (
+ refusal?.code === 'agent_session_journal_unreadable' &&
+ refusal.details?.reason === 'journalCorrupt'
+ )
+}
diff --git a/src/shared/structured-agent-session-reducer.test.ts b/src/shared/structured-agent-session-reducer.test.ts
index a374c794d7f..ca108710bcf 100644
--- a/src/shared/structured-agent-session-reducer.test.ts
+++ b/src/shared/structured-agent-session-reducer.test.ts
@@ -158,6 +158,46 @@ describe('structured agent session reducer', () => {
])
})
+ it("keeps a failed read's refusal beside its text until the read recovers", () => {
+ const refusal = {
+ code: 'agent_session_journal_unreadable',
+ details: { reason: 'journalCorrupt' }
+ } as const
+ const failed = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
+ type: 'error',
+ message: 'agent_session_journal_unreadable',
+ refusal
+ })
+ expect(failed).toMatchObject({ status: 'error', readRefusal: refusal })
+
+ expect(reduceStructuredAgentSession(failed, { type: 'loading' }).readRefusal).toBeUndefined()
+ const live = reduceStructuredAgentSession(
+ reduceStructuredAgentSession(failed, {
+ type: 'history-page',
+ page: hydrationPage([item('a', 1)])
+ }),
+ { type: 'error', message: 'transport died' }
+ )
+ expect(live.readRefusal).toBeUndefined()
+ const recovered = reduceStructuredAgentSession(
+ { ...live, readRefusal: refusal },
+ {
+ type: 'event',
+ event: {
+ type: 'batch',
+ sessionId: 'session-a',
+ batch: {
+ cursor: { epoch: 'epoch-a', sequence: 2 },
+ items: [item('b', 2)],
+ removedItemIds: [],
+ submissions: []
+ }
+ }
+ }
+ )
+ expect(recovered).toMatchObject({ status: 'ready', error: undefined, readRefusal: undefined })
+ })
+
it('uses the bounded hydration page pagination boundary', () => {
const restored = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
type: 'event',
diff --git a/src/shared/structured-agent-session-reducer.ts b/src/shared/structured-agent-session-reducer.ts
index e47ed5ad57b..02c554628fb 100644
--- a/src/shared/structured-agent-session-reducer.ts
+++ b/src/shared/structured-agent-session-reducer.ts
@@ -10,6 +10,7 @@ import type {
AgentSessionSubscribeEvent,
AgentSessionTurnActivity
} from './agent-session-wire'
+import type { AgentSessionRefusalReference } from './agent-session-wire-refusals'
import { backgroundTaskStatesEqual } from './agent-session-background-task-state-equality'
import { agentJournalSubmissionKey } from './agent-session-journal-item-key'
import { compareAgentJournalItems } from './agent-session-journal-position'
@@ -32,7 +33,10 @@ export type StructuredAgentSessionState = {
retainedItemLimit: number
hasOlder: boolean
status: 'idle' | 'loading' | 'ready' | 'error'
+ /** The failed read's own text, for logs; a surface words `readRefusal` instead. */
error?: string
+ /** The refusal the failed read met, when the host sent one; cleared with `error`. */
+ readRefusal?: AgentSessionRefusalReference
backgroundTasks?: AgentSessionBackgroundTaskState | null
commands?: AgentSessionSlashCommand[] | null
activity?: AgentSessionTurnActivity | null
@@ -45,7 +49,7 @@ export type StructuredAgentSessionState = {
export type StructuredAgentSessionAction =
| { type: 'loading' }
- | { type: 'error'; message: string }
+ | { type: 'error'; message: string; refusal?: AgentSessionRefusalReference }
| { type: 'event'; event: AgentSessionSubscribeEvent }
| { type: 'history-page'; page: AgentSessionHistoryPage }
| { type: 'older-page'; requestedCursor: AgentJournalCursor; page: AgentSessionHistoryPage }
@@ -176,10 +180,10 @@ export function reduceStructuredAgentSession(
): StructuredAgentSessionState {
if (action.type === 'loading') {
// Keep the last transcript visible while a reconnect rehydrates the stream.
- return { ...state, status: 'loading', error: undefined }
+ return { ...state, status: 'loading', error: undefined, readRefusal: undefined }
}
if (action.type === 'error') {
- return { ...state, status: 'error', error: action.message }
+ return { ...state, status: 'error', error: action.message, readRefusal: action.refusal }
}
if (action.type === 'history-page') {
return {
@@ -272,6 +276,7 @@ export function reduceStructuredAgentSession(
: mergeSubmissions(state.submissions, event.batch.submissions, items),
status: 'ready',
error: undefined,
+ readRefusal: undefined,
commands: event.commands !== undefined ? event.commands : state.commands,
...(backgroundTasks !== undefined ? { backgroundTasks } : {}),
...(activity !== undefined ? { activity } : {}),
diff --git a/src/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts
index 08fad831af2..e95a21937d1 100644
--- a/src/shared/structured-agent-session-send-disposition.test.ts
+++ b/src/shared/structured-agent-session-send-disposition.test.ts
@@ -55,12 +55,12 @@ function rejectedWith(
}
}
-function notice(reason: string | null): string | undefined {
+function notice(reason: string | null, rejection?: AgentSessionFailureFact): string | undefined {
const disposition = disposeStructuredAgentSessionSendResult({
entries: [entry],
entry,
blockedClientMessageId: null,
- result: rejectedWith(reason),
+ result: rejectedWith(reason, rejection ? { rejection } : {}),
createOperationId: () => 'unused'
})
// The reason travels with the message it explains, never as a separate error.
@@ -121,8 +121,7 @@ describe('what a rejection shows the user', () => {
expect(disposition).toEqual({
entries: [],
error: null,
- blockedClientMessageId: null,
- retryWithFreshClientMessageId: null
+ blockedClientMessageId: null
})
}
})
@@ -157,6 +156,64 @@ describe('what a rejection shows the user', () => {
})
})
+// A row that carries the host's fact is worded from it; the reason is not read.
+describe('what a rejection with a typed fact shows the user', () => {
+ it('says Orca could not hand the message over, whatever the reason holds', () => {
+ expect(notice('provider_write_failed', { kind: 'writeFailed' })).toBe(
+ "Orca couldn't reach the agent. Your message was not sent."
+ )
+ expect(notice('Something unrelated.', { kind: 'writeFailed' })).toBe(
+ "Orca couldn't reach the agent. Your message was not sent."
+ )
+ })
+
+ it("rebuilds the fact's sentence where a marker stands in for it", () => {
+ expect(notice(DISPATCH_REJECTED_QUEUE_FULL, { kind: 'queueFull' })).toBe(
+ 'Too many messages were waiting for the agent, so this one was not sent.'
+ )
+ })
+
+ // The surface names the agent; the host's own sentence is never compared or shown.
+ it('words the fact itself, never the sentence the host wrote beside it', () => {
+ expect(
+ notice('Claude never finished starting, so Orca stopped it.', { kind: 'hostStopped' })
+ ).toBe('The agent never finished starting, so Orca stopped it.')
+ })
+
+ // The message keeps no fact it cannot place, so the host's sentence stands, as on an older host.
+ it("shows a newer host's sentence when its fact cannot be placed", () => {
+ expect(notice('A sentence a newer host wrote.', JSON.parse('{"kind":"fromTheFuture"}'))).toBe(
+ 'A sentence a newer host wrote.'
+ )
+ })
+
+ // The message's copy drops the detail and the refusal these kinds are worded from, so the
+ // sentence the host wrote for the person stands in for them; with none, the table's words.
+ it("shows the host's sentence for a kind whose words its copy cannot rebuild", () => {
+ expect(
+ notice('The provider did not accept this message: Image type .bmp.', {
+ kind: 'providerRejected',
+ detail: { text: 'Image type .bmp', audience: 'person' }
+ })
+ ).toBe('The provider did not accept this message: Image type .bmp.')
+ expect(
+ notice("Claude couldn't start. Start a new chat to continue.", {
+ kind: 'startFailed',
+ refusal: { code: 'agent_session_identity_required' }
+ })
+ ).toBe("Claude couldn't start. Start a new chat to continue.")
+ expect(notice(null, { kind: 'providerRejected' })).toBe(
+ 'The provider did not accept this message.'
+ )
+ })
+
+ it('says only that the message was not sent for a fact no message can carry', () => {
+ expect(notice('Compaction failed.', { kind: 'compactionFailed' })).toBe(
+ 'Your message was not sent.'
+ )
+ })
+})
+
describe('what a refusal shows the user', () => {
it('keeps the refusal as a fact on the message, without the host diagnostic', () => {
const disposition = disposeStructuredAgentSessionSendResult({
diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts
index 7ffce942b6e..11e2b45fc0b 100644
--- a/src/shared/structured-agent-session-send-disposition.ts
+++ b/src/shared/structured-agent-session-send-disposition.ts
@@ -16,6 +16,11 @@ import {
} from './agent-session-refusal-notice'
import type { AgentSessionWriteNoticePart } from './agent-session-write-notice-copy'
import { agentSessionRefusalFailure } from './agent-session-write-failure'
+import type { AgentSessionFailureFact } from './agent-session-failure'
+import {
+ agentSessionFailureSentence,
+ type AgentSessionFailureWordsContext
+} from './agent-session-failure-words'
import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection'
import {
classifyStructuredAgentSessionSendFailure,
@@ -32,8 +37,6 @@ export type StructuredAgentSessionSendDisposition = {
/** The entry the queue is stuck on, or null when nothing blocks it. Always the
* next value, never "unchanged": the caller assigns it verbatim. */
blockedClientMessageId: string | null
- /** A rejected result arrived before the journal snapshot; Retry must rotate this id. */
- retryWithFreshClientMessageId: string | null
}
type SendDispositionInput = {
@@ -129,8 +132,14 @@ export function structuredAgentSessionRejectionNotice(
export function structuredAgentSessionRejectionParts(
reason: string | null,
- write: 'send' | 'composer-send'
+ write: 'send' | 'composer-send',
+ /** The host's typed fact, which decides when the row carried one. */
+ fact?: AgentSessionFailureFact,
+ context: AgentSessionFailureWordsContext = {}
): AgentSessionWriteNoticePart[] {
+ if (fact) {
+ return rejectionFactParts(write, fact, context)
+ }
if (reason === null) {
return ['notDoneSend']
}
@@ -143,13 +152,43 @@ export function structuredAgentSessionRejectionParts(
return rejection.kind ? agentSessionWriteNotDoneParts(write) : [{ text: reason }]
}
+function rejectionFactParts(
+ write: 'send' | 'composer-send',
+ fact: AgentSessionFailureFact,
+ context: AgentSessionFailureWordsContext
+): AgentSessionWriteNoticePart[] {
+ const { kind } = classifyDispatchRejection({ reason: null, rejection: fact })
+ if (kind === 'writeFailed') {
+ return ['unreachable', ...agentSessionWriteNotDoneParts(write)]
+ }
+ // A fact this build cannot place proves only that the message did not happen.
+ return kind
+ ? [{ text: agentSessionFailureSentence({ ...fact, kind }, 'rejection', context) }]
+ : agentSessionWriteNotDoneParts(write)
+}
+
+/** Kinds whose words need what the message's copy drops: the provider's detail, or the refusal. */
+const WORDED_FROM_WHOLE_FACT: ReadonlySet = new Set<
+ AgentSessionFailureFact['kind']
+>(['providerRejected', 'startFailed', 'restartFailed'])
+
/** What the Retry row says about why its message did not go through. */
export function structuredAgentSessionAttemptFailureParts(
- failure: StructuredAgentSessionAttemptFailure
+ failure: StructuredAgentSessionAttemptFailure,
+ context: AgentSessionFailureWordsContext = {},
+ /** The journal's whole fact for a recorded rejection, when its submission is loaded: the
+ * message's own copy keeps only its kind and attachment. */
+ recorded?: AgentSessionFailureFact
): AgentSessionWriteNoticePart[] {
- return failure.kind === 'rejected'
- ? structuredAgentSessionRejectionParts(failure.reason, 'send')
- : agentSessionWriteNoticeParts(failure, 'send')
+ if (failure.kind !== 'rejected') {
+ return agentSessionWriteNoticeParts(failure, 'send', context)
+ }
+ const fact = recorded ?? failure.rejection
+ // Without the journal's fact, the host's sentence still holds what the copy dropped.
+ if (!recorded && fact && WORDED_FROM_WHOLE_FACT.has(fact.kind) && failure.reason !== null) {
+ return [{ text: failure.reason }]
+ }
+ return structuredAgentSessionRejectionParts(failure.reason, 'send', fact, context)
}
export function disposeStructuredAgentSessionSendResult(
@@ -187,8 +226,7 @@ export function disposeStructuredAgentSessionSendResult(
blockedClientMessageId:
!refused || refused.state === 'rejected'
? input.blockedClientMessageId
- : refused.clientMessageId,
- retryWithFreshClientMessageId: null
+ : refused.clientMessageId
}
}
const submission = result.value.submission
@@ -196,16 +234,14 @@ export function disposeStructuredAgentSessionSendResult(
return {
entries: dropEntry(input),
error: 'Message delivery is unconfirmed and Orca will not send it again',
- blockedClientMessageId: input.blockedClientMessageId,
- retryWithFreshClientMessageId: null
+ blockedClientMessageId: input.blockedClientMessageId
}
}
if (submission.dispatchState === 'accepted') {
return {
entries: dropEntry(input),
error: null,
- blockedClientMessageId: input.blockedClientMessageId,
- retryWithFreshClientMessageId: null
+ blockedClientMessageId: input.blockedClientMessageId
}
}
// A Stop's withdrawal failed nothing, first reply or replay: the entry leaves as the reconcile
@@ -217,8 +253,7 @@ export function disposeStructuredAgentSessionSendResult(
return {
entries: dropEntry(input),
error: null,
- blockedClientMessageId: input.blockedClientMessageId,
- retryWithFreshClientMessageId: null
+ blockedClientMessageId: input.blockedClientMessageId
}
}
if (submission.dispatchState === 'rejected') {
@@ -229,8 +264,7 @@ export function disposeStructuredAgentSessionSendResult(
structuredAgentSessionRejectedFailure(submission)
),
error: null,
- blockedClientMessageId: input.blockedClientMessageId,
- retryWithFreshClientMessageId: input.entry.clientMessageId
+ blockedClientMessageId: input.blockedClientMessageId
}
}
if (submission.dispatchState === 'unknown' && submission.recovered) {
@@ -241,8 +275,7 @@ export function disposeStructuredAgentSessionSendResult(
: candidate
),
error: null,
- blockedClientMessageId: input.blockedClientMessageId,
- retryWithFreshClientMessageId: null
+ blockedClientMessageId: input.blockedClientMessageId
}
}
// `pending` is the host saying the message was written and is awaiting the
@@ -257,8 +290,7 @@ export function disposeStructuredAgentSessionSendResult(
submission.dispatchState === 'unknown' ? 'unconfirmed' : 'dispatching'
),
error: null,
- blockedClientMessageId: input.blockedClientMessageId,
- retryWithFreshClientMessageId: null
+ blockedClientMessageId: input.blockedClientMessageId
}
}
@@ -278,7 +310,6 @@ export function disposeStructuredAgentSessionSendFailure(
error: null,
blockedClientMessageId: deliveryUnknown
? input.blockedClientMessageId
- : input.entry.clientMessageId,
- retryWithFreshClientMessageId: null
+ : input.entry.clientMessageId
}
}
diff --git a/src/shared/structured-agent-session-start-failure-row-key.ts b/src/shared/structured-agent-session-start-failure-row-key.ts
new file mode 100644
index 00000000000..6dc4a4817b1
--- /dev/null
+++ b/src/shared/structured-agent-session-start-failure-row-key.ts
@@ -0,0 +1,17 @@
+// A failed start's one row is keyed by the start, so a reader finds it by identity, not by its words.
+
+import { parseAgentJournalItemKey } from './agent-session-journal-item-key'
+import type { AgentJournalItemIdentity } from './agent-session-journal-types'
+
+const START_FAILURE_ROW = 'start-failure:'
+
+export function structuredAgentSessionStartFailureRowIdentity(
+ startKey: string
+): Extract {
+ return { provider: 'orca', clientMessageId: `${START_FAILURE_ROW}${startKey}` }
+}
+
+export function isStructuredAgentSessionStartFailureRow(itemId: string): boolean {
+ const identity = parseAgentJournalItemKey(itemId)
+ return identity?.provider === 'orca' && identity.clientMessageId.startsWith(START_FAILURE_ROW)
+}