From 0784bc31be05e4f92131f00c28b24c9602aebea4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 02:24:35 -0700 Subject: [PATCH 01/52] fix(native-chat): a read whose history will not open is refused with its reason History, subscribe, snapshot and options reads reach a chat through one accessor, whose open had no catch: a journal that would not open reached every client as a runtime error carrying the storage's own text (a path, "file is not a database"). The accessor, and the options read's own open, now throw the classified journal refusal: journalCorrupt when SQLite reports damage, journalUnavailable otherwise. The storage text goes to the log only. The wire code stays runtime_error and the message becomes the bare code, as for every thrown refusal; the reason rides in the error's data. --- .../journal-open-failure.test.ts | 27 ++++++++++- .../journal-open-failure.ts | 22 +++++++++ ...red-agent-session-conversation-lifetime.ts | 5 ++- .../structured-agent-session-options-read.ts | 5 ++- .../structured-agent-session-at-rest.test.ts | 45 +++++++++++++++++++ 5 files changed, 100 insertions(+), 4 deletions(-) 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 d2e1bf3b3501..d8ecd6c8996b 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,10 @@ 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, journalOpenReadRefusal } from './journal-open-failure' import { loadJournal } from './journal-open' import { journalDatabaseFile } from './journal-paths' @@ -104,3 +105,25 @@ 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) + }) +}) 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 1835f691cb72..e99ef323938e 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,20 @@ 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 + } + console.warn('[agent-session] opening the conversation for a read failed:', error) + const code = 'agent_session_journal_unreadable' + return new AgentSessionRefusalError( + refuse(code, { reason: classifyJournalOpenFailure(error) }, 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 e81eb9ae9763..e6c5e7b11ad5 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 @@ -10,6 +10,7 @@ import { AgentSessionRefusalError, agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { journalOpenReadRefusal } from '../agent-session-journal/journal-open-failure' import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations' import { abandonQueuedStructuredAgentSessionMessages, @@ -116,7 +117,9 @@ 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 journalOpenReadRefusal(error) + }) if (!session) { throw agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' 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 2391ffa0205e..1cabd0a2aab2 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' @@ -88,7 +89,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/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index a3d598a69d88..83c5be359b06 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,51 @@ 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('opens a corrupt journal through the recovering open and still accepts a send (P2-03)', async () => { await foundRestTestChat(rig) await rig.host.flushAllStreamedEvents() From 112292192e7e3f24f5c1581089b9c03531b878c9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 02:31:00 -0700 Subject: [PATCH 02/52] refactor(native-chat): the idle sweep's stop of a hung start carries no hand-written reason The sweep passed an English sentence as the stop's reason. It lived only in memory and nothing read it: the delivery loop words the error row and the rejection from the hostStopped fact. Dropped, with the display-name lookup that built it. --- .../structured-agent-session-conversation-lifetime.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) 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 e6c5e7b11ad5..c4c487f9e760 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,7 +5,6 @@ // 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 @@ -70,12 +69,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 }), From 101d4e6baf60d15f831ce92cd93c4e3460cb9166 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 02:39:01 -0700 Subject: [PATCH 03/52] fix(native-chat): a refusal the host throws is worded from its data, never its message A thrown agent-session refusal reaches the client as runtime_error with the bare code as its message and the typed refusal in error.data. Stop, answers, options and goals, a launch's held option pick, the option picker's failure toast, and the Retry line of a chat that could not start now word it from that refusal through the shared notice table. What each caller decides about the outcome is unchanged: only the words move. The Retry line of a failed start no longer prints the host's message or a thrown error's text; it keeps the refusal as a fact and says the cause and step its reason names, or only that the chat could not be started. The option toast keeps a local option surface's own sentence. --- .../native-chat/NativeChatLaunchRetry.tsx | 14 +++-- .../NativeChatSessionOptionPickers.test.tsx | 53 +++++++++++++++++++ .../NativeChatSessionOptionPickers.tsx | 14 ++++- ...tructuredSession.launch-lifecycle.test.tsx | 30 ++++++++--- ...tiveChatStructuredSession.test-harness.tsx | 7 +-- .../NativeChatStructuredSession.tsx | 2 +- .../use-native-chat-provisional-launch.ts | 6 +-- .../use-structured-agent-session-mutate.ts | 5 +- ...tured-agent-session-write-refusal.test.tsx | 40 ++++++++++++++ .../lib/launch-structured-agent-session.ts | 32 +++++++---- ...-agent-session-launch-exited-owner.test.ts | 13 +++-- ...structured-agent-session-launch-failure.ts | 18 +++++++ ...tured-agent-session-launch-options.test.ts | 36 +++++++++++++ ...structured-agent-session-launch-options.ts | 5 +- ...tructured-agent-session-launch-registry.ts | 18 ++++--- .../lib/structured-agent-session-launch.ts | 13 +++-- .../agent-session-refusal-notice.test.ts | 33 ++++++++++++ src/shared/agent-session-refusal-notice.ts | 16 ++++++ .../agent-session-write-failure.test.ts | 45 +++++++++++++++- src/shared/agent-session-write-failure.ts | 31 +++++++++++ 20 files changed, 384 insertions(+), 47 deletions(-) create mode 100644 src/renderer/src/lib/structured-agent-session-launch-failure.ts diff --git a/src/renderer/src/components/native-chat/NativeChatLaunchRetry.tsx b/src/renderer/src/components/native-chat/NativeChatLaunchRetry.tsx index 1a4204a326fa..fcd258dece30 100644 --- a/src/renderer/src/components/native-chat/NativeChatLaunchRetry.tsx +++ b/src/renderer/src/components/native-chat/NativeChatLaunchRetry.tsx @@ -2,14 +2,18 @@ import { RotateCcw } from 'lucide-react' import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' import type { StructuredAgentSessionLaunchLifecycle } from '@/lib/structured-agent-session-launch' +import { agentSessionRefusalCauseParts } from '../../../../shared/agent-session-refusal-notice' +import type { AgentSessionWriteRefusal } from '../../../../shared/agent-session-write-failure' +import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' export function NativeChatLaunchRetry({ lifecycle, - failureReason = null, + failure = null, onRetry }: { lifecycle: StructuredAgentSessionLaunchLifecycle | null - failureReason?: string | null + /** The host's refusal behind the failed start; its message is never shown. */ + failure?: AgentSessionWriteRefusal | null onRetry: () => void }): React.JSX.Element | null { if (lifecycle !== 'failed' && lifecycle !== 'visibility-unknown') { @@ -25,11 +29,15 @@ export function NativeChatLaunchRetry({ 'auto.components.native.chat.NativeChatLaunchRetry.unknown', 'Chat connection could not be confirmed.' ) + const cause = + lifecycle === 'failed' && failure + ? agentSessionWriteNoticeText(agentSessionRefusalCauseParts(failure)) + : '' 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 d8ea96d2f0cd..3562065c1514 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 4b29ebf57c50..f11e41a6e2d4 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 52dc6f62a3b1..c7ed087e9181 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 ? ( + + ) : null}
) : null}
diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index e711d62245a3..fb432e1e5b4c 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/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index a48050af1014..6291986f6daa 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -5,6 +5,7 @@ import type { AgentSessionBackgroundTask } from '../../../../shared/agent-sessio 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' @@ -21,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 ? ( + + ) : null} +
+ ))} +
+ ) +} + type StructuredSessionMessageListProps = { allowFileUriLinks?: boolean isVisible?: boolean @@ -30,6 +57,7 @@ type StructuredSessionMessageListProps = { isWorking?: boolean runtimeContext?: unknown session?: { hasMore: boolean; loadingEarlier: boolean; loadEarlier: () => Promise } + deliveryNotices?: ReadonlyMap } const initialMessageListProps: StructuredSessionMessageListProps | null = null @@ -51,7 +79,7 @@ export function createStructuredSessionMocks() { controllerProps: nullable<{ transportEnabled?: boolean }>(), mode: 'static' as 'static' | 'outbox', status: 'ready' as 'idle' | 'loading' | 'ready' | 'error', - readRefusal: undefined as AgentSessionRefusalReference | undefined, + readRefusal: absent(), messages: null as null | unknown[], messageListProps: initialMessageListProps, composerProps: null as null | { @@ -92,6 +120,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 @@ -110,7 +140,7 @@ export function createStructuredSessionMocks() { messages: mocks.messages ?? (mocks.mode === 'outbox' - ? [] + ? projectStructuredAgentSessionMessages([], outbox.outbox, []) : [ { id: 'message-1', @@ -203,7 +233,7 @@ export function createStructuredSessionMocks() { nativeChatMessageList: () => ({ NativeChatMessageList: (props: typeof mocks.messageListProps) => { mocks.messageListProps = props - return
+ return } }), nativeChatComposer: () => ({ diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index e27dade1a718..99f685343ba5 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,11 +22,11 @@ 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 { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' export function NativeChatStructuredSession( props: Omit @@ -101,6 +101,23 @@ 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 deliveryNotices = useMemo( + () => + structuredAgentSessionDeliveryNotices( + controller.outbox, + controller.blockedClientMessageId, + retryDelivery + ), + [controller.outbox, controller.blockedClientMessageId, retryDelivery] + ) const viewState = selectNativeChatViewState(session, { readRetries: true }) const readFailure = controller.status === 'error' @@ -246,14 +263,10 @@ 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', @@ -131,12 +134,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) => { diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx index b27736c71bbe..fb58acb7280d 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 000000000000..b3a6743df2bc --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'vitest' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import { + createStructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' +import { structuredAgentSessionDeliveryNotices } 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 +): Record { + const notices = structuredAgentSessionDeliveryNotices(outbox, blocked, () => {}) + 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, + 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." + }) + }) + + 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 Retry on a message behind the one the queue stopped on would send + // nothing, so only the stopped one offers it. A rejected message holds nothing up. + it('gives a Retry only to the message the queue stopped on, beside 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.' + }) + }) + + 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({}) + }) +}) 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 000000000000..140f6e881483 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts @@ -0,0 +1,52 @@ +// 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 and offers its own Retry. Read through +// the drain's own rule, so of the messages still to be sent only the one the queue stopped on has a +// Retry; one waiting behind it says nothing. A rejected message holds nothing and keeps its own. + +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import { + admitStructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' +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' + +function deliveryNoticeText(entry: StructuredAgentSessionOutboxEntry): string { + if (entry.state === 'unconfirmed') { + return translate( + 'auto.components.native.chat.NativeChatStructuredSession.1f772bb5d0', + 'Message delivery is unconfirmed.' + ) + } + return entry.lastFailure + ? agentSessionWriteNoticeText(structuredAgentSessionAttemptFailureParts(entry.lastFailure)) + : translate( + 'auto.components.native.chat.NativeChatStructuredSession.93ef441197', + 'Message was not sent.' + ) +} + +/** Keyed by the message id the transcript renders each entry under. `blockedClientMessageId` is + * the entry a refusal stopped the queue on. */ +export function structuredAgentSessionDeliveryNotices( + outbox: readonly StructuredAgentSessionOutboxEntry[], + blockedClientMessageId: string | null, + retry: (clientMessageId: string) => void +): ReadonlyMap { + const admission = admitStructuredAgentSessionOutboxEntry(outbox, blockedClientMessageId) + const held = admission.state === 'blocked' ? admission.entry.clientMessageId : null + const notices = new Map() + for (const entry of outbox) { + if (entry.state === 'rejected' || entry.clientMessageId === held) { + notices.set(agentJournalSubmissionKey(entry.clientMessageId), { + text: deliveryNoticeText(entry), + onRetry: () => retry(entry.clientMessageId) + }) + } + } + return notices +} 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 000000000000..c3d7974c160a --- /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]) +} From 8985ffc6b4476ac55ce0cbb7e560b1fdde5b2f6d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:03:29 -0700 Subject: [PATCH 07/52] fix(native-chat): say each chat failure's words where its own control already acts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three wording rules for the desktop chat: - A chat that could not start shows Retry beside its reason, so the reason stops at its cause where the Retry is the step: a reason whose action is to retry, and a start failure's "send your message again". Any other step stays (quit the terminal agent, start a new chat). The start-failure sentences take a retryControl context for this; what the host writes is unchanged. - A history that couldn't open right now still reconnects, so the pane keeps "Orca keeps trying to load it" under its cause. Only a damaged history, which no retry reads past, drops it. - A read failure that names no reason while the transcript is shown is only the pane reconnecting: the status line says "Reconnecting to this chat…" in muted text, not an error. New key components.native-chat.state.reconnecting, hand-translated for es/fr/ja/ko/zh. --- .../native-chat/NativeChatEmptyState.test.tsx | 12 +++- .../native-chat/NativeChatEmptyState.tsx | 31 ++++++---- .../native-chat/NativeChatLaunchRetry.tsx | 7 ++- ...tructuredSession.launch-lifecycle.test.tsx | 31 +++++++++- ...eChatStructuredSession.read-error.test.tsx | 16 +++++- .../NativeChatStructuredSession.tsx | 10 +++- .../NativeChatStructuredSessionStatus.tsx | 7 +++ .../src/i18n/en-runtime-required.json | 3 +- src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + src/shared/agent-session-failure-words.ts | 14 ++++- .../agent-session-refusal-notice.test.ts | 57 +++++++++++++++++-- src/shared/agent-session-refusal-notice.ts | 18 ++++-- 17 files changed, 177 insertions(+), 35 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatEmptyState.test.tsx b/src/renderer/src/components/native-chat/NativeChatEmptyState.test.tsx index 3fd6da94eff6..90ffc205063c 100644 --- a/src/renderer/src/components/native-chat/NativeChatEmptyState.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatEmptyState.test.tsx @@ -31,7 +31,7 @@ it('shows the host message in place of the terminal-backed default', () => { }) // The structured chat passes words only from the notice table, never the host's. -it("puts the structured chat's own words for the failure in place of its retrying line", () => { +it("puts the structured chat's own words for the failure above its retrying line", () => { render( ) expect(screen.getByText("Orca couldn't open this chat's history right now.")).toBeInTheDocument() - expect(screen.queryByText(/keeps trying/)).toBeNull() + expect( + screen.getByText('The transcript could not be read. Orca keeps trying to load it.') + ).toBeInTheDocument() +}) + +it('says nothing of trying again for a failure no retry gets past', () => { + render() + expect(screen.getByText('Unable to load this chat.')).toBeInTheDocument() + 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 88f312bd9ffe..51dd7fffe076 100644 --- a/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx +++ b/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx @@ -14,7 +14,7 @@ export function NativeChatEmptyState({ message?: string agent?: NativeChatSession['agent'] /** The read retries on its own (structured chat), so the error says so instead of pointing - * back to the terminal. A `message` takes its place when the caller has words of its own. */ + * back to the terminal, under the caller's `message` when it has words of its own. */ retrying?: boolean }): React.JSX.Element { const copy = emptyStateCopy(kind, message, agent, retrying) @@ -37,6 +37,9 @@ export function NativeChatEmptyState({ {copy.subtitle ? (

{copy.subtitle}

) : null} + {copy.note ? ( +

{copy.note}

+ ) : null}
) } @@ -46,7 +49,7 @@ function emptyStateCopy( message?: string, agent?: NativeChatSession['agent'], retrying = false -): { title: string; subtitle: string | null } { +): { title: string; subtitle: string | null; note?: string } { switch (kind) { case 'loading': return { @@ -59,7 +62,13 @@ function emptyStateCopy( NATIVE_CHAT_EMPTY_STATE_COPY.loading.subtitle ) } - case 'error': + case 'error': { + const retryingLine = retrying + ? translate( + 'components.native-chat.state.error.retryingSubtitle', + NATIVE_CHAT_EMPTY_STATE_COPY.retryingError.subtitle + ) + : null return { title: translate( 'components.native-chat.state.error.title', @@ -67,16 +76,14 @@ function emptyStateCopy( ), subtitle: message ?? - (retrying - ? translate( - 'components.native-chat.state.error.retryingSubtitle', - NATIVE_CHAT_EMPTY_STATE_COPY.retryingError.subtitle - ) - : translate( - 'components.native-chat.state.error.subtitle', - NATIVE_CHAT_EMPTY_STATE_COPY.error.subtitle - )) + retryingLine ?? + translate( + 'components.native-chat.state.error.subtitle', + NATIVE_CHAT_EMPTY_STATE_COPY.error.subtitle + ), + ...(message && retryingLine ? { note: retryingLine } : {}) } + } case 'not-agent': return { title: translate( diff --git a/src/renderer/src/components/native-chat/NativeChatLaunchRetry.tsx b/src/renderer/src/components/native-chat/NativeChatLaunchRetry.tsx index fcd258dece30..b186bbc4e344 100644 --- a/src/renderer/src/components/native-chat/NativeChatLaunchRetry.tsx +++ b/src/renderer/src/components/native-chat/NativeChatLaunchRetry.tsx @@ -9,9 +9,12 @@ import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' export function NativeChatLaunchRetry({ lifecycle, failure = null, + agentLabel, onRetry }: { lifecycle: StructuredAgentSessionLaunchLifecycle | null + /** Names the agent in a start failure's words. */ + agentLabel?: string /** The host's refusal behind the failed start; its message is never shown. */ failure?: AgentSessionWriteRefusal | null onRetry: () => void @@ -31,7 +34,9 @@ export function NativeChatLaunchRetry({ ) const cause = lifecycle === 'failed' && failure - ? agentSessionWriteNoticeText(agentSessionRefusalCauseParts(failure)) + ? agentSessionWriteNoticeText( + agentSessionRefusalCauseParts(failure, agentLabel ? { agentName: agentLabel } : {}) + ) : '' return (
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 8001ff2df860..aee03244cb68 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 @@ -31,8 +31,8 @@ const NOT_SIGNED_IN = { code: 'agent_session_operation_invalid', details: { reason: 'notSignedIn' } } as const -const NOT_SIGNED_IN_TEXT = - 'The agent is not signed in for the selected account. Sign in, then send your message again.' +// Retry beside it is the resend, so the words stop at the cause. +const NOT_SIGNED_IN_TEXT = 'Codex is not signed in for the selected account.' function sessionView(): React.JSX.Element { return ( @@ -116,6 +116,33 @@ describe('NativeChatStructuredSession launch lifecycle', () => { 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' } 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 0f53ad1fe0ef..38a56adeb507 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 @@ -70,7 +70,7 @@ it('says a damaged history cannot load, without claiming Orca keeps trying', () expect(screen.queryByText(/agent_session_/)).toBeNull() }) -it("names a history that couldn't open right now, with no step the pane already takes", () => { +it("names a history that couldn't open right now, and that the pane keeps trying", () => { mocks.status = 'error' mocks.readRefusal = journalRefusal('journalUnavailable') mocks.messages = [] @@ -78,9 +78,23 @@ it("names a history that couldn't open right now, with no step the pane already renderPane() expect(screen.getAllByText("Orca couldn't open this chat's history right now.")).toHaveLength(1) + 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') diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 99f685343ba5..b6a2732c7562 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -118,6 +118,7 @@ export function NativeChatStructuredSession( ), [controller.outbox, controller.blockedClientMessageId, retryDelivery] ) + const agentLabel = structuredAgentLabel(props.agent === 'codex' ? 'codex' : 'claude') const viewState = selectNativeChatViewState(session, { readRetries: true }) const readFailure = controller.status === 'error' @@ -270,16 +271,19 @@ export function NativeChatStructuredSession( ) : 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/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 1e6ca9ecd659..57703fb7d1f4 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 cc182bd97346..03f9e72b8732 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17762,6 +17762,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 42417b214f30..59c8d6504b70 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15057,6 +15057,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 115bd685b56a..2a71ce56d2e8 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17619,6 +17619,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 fc54f4c97d58..aeb72f2791b6 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17555,6 +17555,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 23bddadf1399..0c7ce2fc79f0 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17555,6 +17555,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 1356f6b8cf6b..9c4c4cb9aef4 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17520,6 +17520,7 @@ "subtitle": "无法读取记录。请切回终端继续工作。", "retryingSubtitle": "无法读取记录。Orca 会继续尝试加载。" }, + "reconnecting": "正在重新连接此聊天…", "pairHost": "配对主机以查看智能体聊天记录。", "notAgent": { "title": "这里没有对话", diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 2c30040c0049..5f371276a01a 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -53,6 +53,8 @@ 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 shows its own Retry beside the words, so they leave out sending again. */ + retryControl?: boolean } /** @@ -110,7 +112,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.' } @@ -163,7 +168,10 @@ const FAILURE_SENTENCES = { `${context.agentName ?? 'The agent'} stopped before it finished starting.${startRetry(context)}`, startFailed: couldNot('start'), notSignedIn: (context) => - `${context.agentName ?? 'The agent'} is not signed in for the selected account. Sign in, then ${retryStep(context)}.`, + // "Not signed in" already says what to do; beside a Retry the resend step is the button. + context.retryControl + ? `${context.agentName ?? 'The agent'} is not signed in for the selected account.` + : `${context.agentName ?? 'The agent'} is not signed in for the selected account. Sign in, then ${retryStep(context)}.`, historyTooLarge: () => `This conversation's history is too large to restore here. ${START_NEW_CHAT}`, managedAccountEnvOverride: () => @@ -171,7 +179,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.` diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index 4d166c6f1874..220f7d440da0 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -524,9 +524,10 @@ describe('a chat whose history the host could not open', () => { }) }) -// For a line that already says what did not happen, such as a chat that could not start. +// 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('gives the cause and step a reason names, without the write that did not happen', () => { + it('keeps a step other than retrying, beside the cause', () => { expect( agentSessionRefusalCauseParts({ kind: 'refused', @@ -538,9 +539,57 @@ describe('agentSessionRefusalCauseParts', () => { agentSessionRefusalCauseParts({ kind: 'refused', code: 'agent_session_operation_invalid', - details: { reason: 'notSignedIn' } + details: { reason: 'historyTooLarge' } }) - ).toEqual([{ text: agentSessionFailureSentence({ kind: 'notSignedIn' }, 'rejection') }]) + ).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.' }]) + 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]>([ diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index f20c23db1dec..7cd0e5875dcf 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -267,20 +267,26 @@ function reasonParts( return causeWordsParts(words, write) } -/** What stopped a refused write and the step past it, without saying which write did not happen: - * for a line that already says so, such as a chat that could not start. Empty when the refusal - * names no reason with words of its own. */ +/** What stopped a refused write, without saying which write did not happen: for a line that + * already says so and shows its own Retry, such as a chat that could not start. The Retry is the + * step for a reason whose action is to retry, and for sending again; any other step stays. Empty + * when the refusal names no reason with words of its own. */ export function agentSessionRefusalCauseParts( - failure: AgentSessionWriteFailure + failure: AgentSessionWriteFailure, + context: { agentName?: string } = {} ): AgentSessionWriteNoticePart[] { const words = failure.kind === 'refused' ? agentSessionRefusalReasonWords(failure) : undefined if (!words || 'words' in words) { return [] } if ('fact' in words) { - return [{ text: agentSessionFailureSentence({ kind: words.fact }, 'rejection') }] + const sentence = agentSessionFailureSentence({ kind: words.fact }, 'rejection', { + ...context, + retryControl: true + }) + return [{ text: sentence }] } - return words.step ? [words.cause, words.step] : [words.cause] + return words.step && words.action !== 'retry' ? [words.cause, words.step] : [words.cause] } export function agentSessionWriteNoticeParts( From 26f0c9938585ab5236c2f00ab350e47e478fe4d2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:23:13 -0700 Subject: [PATCH 08/52] fix(native-chat): a rejected message offers Retry only once the queue is moving Each rejected message's row offered its own Retry even while the queue was stopped on another message. Any Retry clears the stopped queue, so pressing a rejected message's Retry also sent the message the queue was holding, which the user had not retried; behind a message whose delivery is unconfirmed, the retried one instead went back into the queue with no notice and waited there. While the queue is stopped, only the message it stopped on offers Retry, as the single Retry strip this replaced did. A rejected message keeps its words on its row and gets its Retry back once the queue moves. --- ...tiveChatStructuredSessionDelivery.test.tsx | 43 +++++++++++++++++++ ...red-agent-session-delivery-notices.test.ts | 20 +++++++-- ...ructured-agent-session-delivery-notices.ts | 18 +++++--- 3 files changed, 71 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index c9c7519f932e..322eed9e6ff1 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -290,6 +290,49 @@ 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('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/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index b3a6743df2bc..dae7c3204d42 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -90,9 +90,9 @@ describe('the notice on each message that did not go through', () => { }) }) - // The drain's own rule: a Retry on a message behind the one the queue stopped on would send - // nothing, so only the stopped one offers it. A rejected message holds nothing up. - it('gives a Retry only to the message the queue stopped on, beside every rejected one', () => { + // 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' }), @@ -107,6 +107,20 @@ describe('the notice on each message that did not go through', () => { }) }) + // Its Retry would release the stopped queue and send the stopped message too, or wait unseen. + 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, retry) + expect(notices.get(agentJournalSubmissionKey('rejected'))).toEqual({ + text: 'Message was not 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({}) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts index 140f6e881483..f5c253495cbc 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts @@ -1,9 +1,10 @@ // 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 and offers its own Retry. Read through -// the drain's own rule, so of the messages still to be sent only the one the queue stopped on has a -// Retry; one waiting behind it says nothing. A rejected message holds nothing and keeps its own. +// 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 release the +// queue and send the stopped message too, or 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. import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' import { @@ -42,10 +43,13 @@ export function structuredAgentSessionDeliveryNotices( const notices = new Map() for (const entry of outbox) { if (entry.state === 'rejected' || entry.clientMessageId === held) { - notices.set(agentJournalSubmissionKey(entry.clientMessageId), { - text: deliveryNoticeText(entry), - onRetry: () => retry(entry.clientMessageId) - }) + const text = deliveryNoticeText(entry) + notices.set( + agentJournalSubmissionKey(entry.clientMessageId), + held === null || entry.clientMessageId === held + ? { text, onRetry: () => retry(entry.clientMessageId) } + : { text } + ) } } return notices From d29a373af3d33adc9579b5f12f536ef45b9a9e8d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:42:32 -0700 Subject: [PATCH 09/52] fix(native-chat): a chat whose history will not open logs once, not on every reconnect A reader reconnects every 750 ms while a journal open can clear, and each attempt logged the failure with its full stack. The read door now logs a session's failure once until that session opens, closes, or fails differently; every attempt is still refused with its reason. --- .../journal-open-failure.test.ts | 32 ++++++++++++- .../journal-open-failure.ts | 46 +++++++++++++++++-- ...red-agent-session-conversation-lifetime.ts | 8 +++- .../structured-agent-session-at-rest.test.ts | 27 +++++++++++ 4 files changed, 105 insertions(+), 8 deletions(-) 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 d8ecd6c8996b..a5427f76fdc7 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 @@ -4,7 +4,11 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { openJournalDatabase } from './journal-database' -import { classifyJournalOpenFailure, journalOpenReadRefusal } from './journal-open-failure' +import { + classifyJournalOpenFailure, + createJournalOpenReadRefusals, + journalOpenReadRefusal +} from './journal-open-failure' import { loadJournal } from './journal-open' import { journalDatabaseFile } from './journal-paths' @@ -127,3 +131,29 @@ describe('journalOpenReadRefusal', () => { 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 e99ef323938e..5e4cff1f3ee2 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 @@ -35,10 +35,46 @@ export function journalOpenReadRefusal(error: unknown): AgentSessionRefusalError if (isAgentSessionRefusalError(error)) { return error } - console.warn('[agent-session] opening the conversation for a read failed:', 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: classifyJournalOpenFailure(error) }, code), - { cause: error } - ) + 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 c4c487f9e760..0fbd1a0a233c 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 @@ -9,7 +9,7 @@ import { AgentSessionRefusalError, agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' -import { journalOpenReadRefusal } from '../agent-session-journal/journal-open-failure' +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) @@ -97,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,13 +118,14 @@ export function createStructuredAgentSessionConversationLifetime(host: { throw new AgentSessionRefusalError(AGENT_SESSION_NOT_ATTACHED) } const session = await host.open(sessionId).catch((error: unknown) => { - throw journalOpenReadRefusal(error) + throw readRefusals.refusal(sessionId, error) }) if (!session) { throw agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) } + readRefusals.forget(sessionId) return session }) }, @@ -130,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/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index 83c5be359b06..df00957215f0 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 @@ -281,6 +281,33 @@ describe('the accessor', () => { } }) + 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() From 55f25132c4cf69b1dfa22ac8cc8648b8148f608c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:48:09 -0700 Subject: [PATCH 10/52] fix(native-chat): a message's own Retry is its resend step, so its notice stops at the cause A rejected message offering Retry read "Claude stopped before it finished starting. Send your message to try again." beside that button. Its row now takes the rule the launch strip already follows: beside its own Retry the words leave out sending or trying again, worded from the stored fact with the chat's agent name. The stored fact keeps less than the host wrote from (a refusal, the provider's words), so a reason it cannot rebuild exactly is kept as written. A rejected message without a Retry, while the queue is held, keeps the step. What the host writes and the phone's notice are unchanged. --- .../NativeChatStructuredSession.tsx | 5 +- ...tiveChatStructuredSessionDelivery.test.tsx | 35 ++++++++++ ...red-agent-session-delivery-notices.test.ts | 66 ++++++++++++++++++- ...ructured-agent-session-delivery-notices.ts | 21 ++++-- src/shared/agent-session-failure-words.ts | 10 ++- ...ructured-agent-session-send-disposition.ts | 32 +++++---- 6 files changed, 144 insertions(+), 25 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index b6a2732c7562..991fc95d0faa 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -109,16 +109,17 @@ export function NativeChatStructuredSession( const retryDelivery = useCallback((clientMessageId: string) => { retryRef.current(clientMessageId) }, []) + const agentLabel = structuredAgentLabel(props.agent === 'codex' ? 'codex' : 'claude') const deliveryNotices = useMemo( () => structuredAgentSessionDeliveryNotices( controller.outbox, controller.blockedClientMessageId, + agentLabel, retryDelivery ), - [controller.outbox, controller.blockedClientMessageId, retryDelivery] + [controller.outbox, controller.blockedClientMessageId, agentLabel, retryDelivery] ) - const agentLabel = structuredAgentLabel(props.agent === 'codex' ? 'codex' : 'claude') const viewState = selectNativeChatViewState(session, { readRetries: true }) const readFailure = controller.status === 'error' diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index 322eed9e6ff1..7f962400f8f3 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -333,6 +333,41 @@ describe('NativeChatStructuredSession delivery', () => { 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('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/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index dae7c3204d42..b90c55d8a7bf 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -26,7 +26,7 @@ function texts( outbox: StructuredAgentSessionOutboxEntry[], blocked: string | null = null ): Record { - const notices = structuredAgentSessionDeliveryNotices(outbox, blocked, () => {}) + const notices = structuredAgentSessionDeliveryNotices(outbox, blocked, 'Claude', () => {}) return Object.fromEntries([...notices].map(([id, notice]) => [id, notice.text])) } @@ -49,6 +49,7 @@ describe('the notice on each message that did not go through', () => { }) ], null, + 'Claude', retry ) @@ -114,13 +115,74 @@ describe('the notice on each message that did not go through', () => { [[entry('stuck', { state: 'unconfirmed' }), entry('rejected', { state: 'rejected' })], null], [[entry('rejected', { state: 'rejected' }), entry('held')], 'held'] ] as const) { - const notices = structuredAgentSessionDeliveryNotices([...outbox], blocked, retry) + 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 stored fact keeps less than the host wrote from, so it rewords only a reason it rebuilds. + it('keeps a reason its stored fact cannot rebuild, beside its Retry too', () => { + const rejected = (reason: string): StructuredAgentSessionOutboxEntry => + entry('rejected', { + state: 'rejected', + lastFailure: { kind: 'rejected', reason, rejection: { kind: 'startFailed' } } + }) + for (const reason of [ + "Claude couldn't start. Start a new chat to continue.", + "Claude couldn't start. This chat is still open in a terminal agent. Quit that agent to continue the chat here." + ]) { + expect(texts([rejected(reason)])).toEqual({ [agentJournalSubmissionKey('rejected')]: reason }) + } + expect(texts([rejected("Claude couldn't start. Send your message to try again.")])).toEqual({ + [agentJournalSubmissionKey('rejected')]: "Claude couldn't start." + }) + }) + 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({}) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts index f5c253495cbc..722a01a09f73 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts @@ -11,12 +11,16 @@ 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' -function deliveryNoticeText(entry: StructuredAgentSessionOutboxEntry): string { +function deliveryNoticeText( + entry: StructuredAgentSessionOutboxEntry, + context: AgentSessionFailureWordsContext +): string { if (entry.state === 'unconfirmed') { return translate( 'auto.components.native.chat.NativeChatStructuredSession.1f772bb5d0', @@ -24,7 +28,9 @@ function deliveryNoticeText(entry: StructuredAgentSessionOutboxEntry): string { ) } return entry.lastFailure - ? agentSessionWriteNoticeText(structuredAgentSessionAttemptFailureParts(entry.lastFailure)) + ? agentSessionWriteNoticeText( + structuredAgentSessionAttemptFailureParts(entry.lastFailure, context) + ) : translate( 'auto.components.native.chat.NativeChatStructuredSession.93ef441197', 'Message was not sent.' @@ -32,10 +38,11 @@ function deliveryNoticeText(entry: StructuredAgentSessionOutboxEntry): string { } /** Keyed by the message id the transcript renders each entry under. `blockedClientMessageId` is - * the entry a refusal stopped the queue on. */ + * 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 ): ReadonlyMap { const admission = admitStructuredAgentSessionOutboxEntry(outbox, blockedClientMessageId) @@ -43,12 +50,12 @@ export function structuredAgentSessionDeliveryNotices( const notices = new Map() for (const entry of outbox) { if (entry.state === 'rejected' || entry.clientMessageId === held) { - const text = deliveryNoticeText(entry) + // 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 }) notices.set( agentJournalSubmissionKey(entry.clientMessageId), - held === null || entry.clientMessageId === held - ? { text, onRetry: () => retry(entry.clientMessageId) } - : { text } + retryControl ? { text, onRetry: () => retry(entry.clientMessageId) } : { text } ) } } diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 5f371276a01a..a9de43964143 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -53,7 +53,7 @@ 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 shows its own Retry beside the words, so they leave out sending again. */ + /** The surface shows its own Retry beside the words, so they leave out sending or trying again. */ retryControl?: boolean } @@ -198,12 +198,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/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index 433a062d772d..f505b85c5f0f 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -18,7 +18,8 @@ import type { AgentSessionWriteNoticePart } from './agent-session-write-notice-c import { agentSessionRefusalFailure } from './agent-session-write-failure' import { agentSessionFailureSentence, - agentSessionRejectionMayHoldMarker + agentSessionRejectionMayHoldMarker, + type AgentSessionFailureWordsContext } from './agent-session-failure-words' import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' import { @@ -136,10 +137,11 @@ export function structuredAgentSessionRejectionParts( reason: string | null, write: 'send' | 'composer-send', /** The host's typed fact, which decides when the row carried one. */ - fact?: StructuredAgentSessionRejectionFact + fact?: StructuredAgentSessionRejectionFact, + context: AgentSessionFailureWordsContext = {} ): AgentSessionWriteNoticePart[] { if (fact) { - return rejectionFactParts(reason, write, fact) + return rejectionFactParts(reason, write, fact, context) } if (reason === null) { return ['notDoneSend'] @@ -156,7 +158,8 @@ export function structuredAgentSessionRejectionParts( function rejectionFactParts( reason: string | null, write: 'send' | 'composer-send', - fact: StructuredAgentSessionRejectionFact + fact: StructuredAgentSessionRejectionFact, + context: AgentSessionFailureWordsContext ): AgentSessionWriteNoticePart[] { const { kind } = classifyDispatchRejection({ reason, rejection: fact }) if (kind === 'writeFailed') { @@ -166,19 +169,26 @@ function rejectionFactParts( if (!kind) { return agentSessionWriteNotDoneParts(write) } - // The host wrote the fact's sentence as the reason, with the agent's name and any words the - // provider wrote for a person; only where a marker may stand in is the sentence rebuilt. - return reason !== null && !agentSessionRejectionMayHoldMarker(kind) - ? [{ text: reason }] - : [{ text: agentSessionFailureSentence({ ...fact, kind }, 'rejection') }] + const sentence = (words: AgentSessionFailureWordsContext): string => + agentSessionFailureSentence({ ...fact, kind }, 'rejection', words) + if (reason === null || agentSessionRejectionMayHoldMarker(kind)) { + return [{ text: sentence(context) }] + } + // The host wrote the fact's sentence as the reason, with what the stored fact drops (a refusal, + // the provider's words): beside a Retry, reword only a reason the fact rebuilds exactly. + const { retryControl, ...written } = context + return retryControl && sentence(written) === reason + ? [{ text: sentence(context) }] + : [{ text: reason }] } /** What the Retry row says about why its message did not go through. */ export function structuredAgentSessionAttemptFailureParts( - failure: StructuredAgentSessionAttemptFailure + failure: StructuredAgentSessionAttemptFailure, + context: AgentSessionFailureWordsContext = {} ): AgentSessionWriteNoticePart[] { return failure.kind === 'rejected' - ? structuredAgentSessionRejectionParts(failure.reason, 'send', failure.rejection) + ? structuredAgentSessionRejectionParts(failure.reason, 'send', failure.rejection, context) : agentSessionWriteNoticeParts(failure, 'send') } From 5298d8dc2f9e885d4f155c3873a51c9e8c661b5e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:06:52 -0700 Subject: [PATCH 11/52] fix(native-chat): a message's Retry sends only that message, never the one the queue is held on Retry released the queue's refusal hold whichever message it was pressed on. While a queued message waited ahead of a held one, every rejected message offered Retry, and pressing it also sent the held message the person had not retried. Retrying an unconfirmed message ahead of a held one did the same. Retry now releases the hold only for its own message. --- ...ructured-agent-session-delivery-notices.ts | 6 +- ...ed-agent-session-outbox-admission.test.tsx | 89 +++++++++++++++++++ .../use-structured-agent-session-outbox.ts | 5 +- 3 files changed, 96 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts index 722a01a09f73..8d60f65cee17 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts @@ -2,9 +2,9 @@ // // 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 release the -// queue and send the stopped message too, or 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. +// 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. import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' import { 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 0fff127cd2c2..c4ebf1dca9b5 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 @@ -219,4 +221,91 @@ 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 = (params as { envelope: { clientOperationId: string } }).envelope.clientOperationId + 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 () => { + mocks.call.mockImplementation((_target, _method, params) => { + const id = (params as { envelope: { clientOperationId: string } }).envelope.clientOperationId + 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: [] as readonly AgentJournalSubmission[] } } + ) + 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.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts index 254c936cf63b..aed05b56778f 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 @@ -286,7 +286,10 @@ 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 From 9e131b25de051160e2c91040c2875d147804cee4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:07:28 -0700 Subject: [PATCH 12/52] fix(native-chat): a not-signed-in failure beside Retry still says to sign in first Beside a Retry the notice dropped the whole next step, so a chat that could not start because the agent was not signed in read only the cause. Pressing Retry without signing in fails the same way again. The words now keep the sign-in step and leave out only the resend, which the Retry button is. --- .../NativeChatStructuredSession.launch-lifecycle.test.tsx | 4 ++-- src/shared/agent-session-failure-words.ts | 6 ++---- src/shared/agent-session-refusal-notice.test.ts | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) 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 aee03244cb68..644c1c657500 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 @@ -31,8 +31,8 @@ const NOT_SIGNED_IN = { code: 'agent_session_operation_invalid', details: { reason: 'notSignedIn' } } as const -// Retry beside it is the resend, so the words stop at the cause. -const NOT_SIGNED_IN_TEXT = 'Codex is not signed in for the selected account.' +// 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 ( diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index a9de43964143..30cace08eaf0 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -167,11 +167,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) => - // "Not signed in" already says what to do; beside a Retry the resend step is the button. - context.retryControl - ? `${context.agentName ?? 'The agent'} is not signed in for the selected account.` - : `${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: () => diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index 220f7d440da0..1e9db70d7457 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -565,7 +565,7 @@ describe('agentSessionRefusalCauseParts', () => { }, { agentName: 'Claude' } ) - ).toEqual([{ text: 'Claude is not signed in for the selected account.' }]) + ).toEqual([{ text: 'Claude is not signed in for the selected account. Sign in first.' }]) expect( agentSessionRefusalCauseParts({ kind: 'refused', From 0eb4c549fda3c5c24b3de72dfbae40ea8aaa9ada Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:09:23 -0700 Subject: [PATCH 13/52] test(native-chat): a rejected message's hidden Retry only avoids waiting unseen --- .../structured-agent-session-delivery-notices.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index b90c55d8a7bf..9e6ae3570eba 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -108,7 +108,7 @@ describe('the notice on each message that did not go through', () => { }) }) - // Its Retry would release the stopped queue and send the stopped message too, or wait unseen. + // 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 [ From 8ac3d2d6ddfaf4df90c690ee7dc97beb381f8f4d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:11:49 -0700 Subject: [PATCH 14/52] fix(native-chat): every Retry beside a notice leaves out the retry step the same way A message the queue stopped on worded its refusal with no agent name and with its retry step, beside its own Retry, while a rejected message next to it named the agent and left the step to the button. The launch strip and the history pane each had their own copy of the same rule. One wording context now goes through the one notice table for every surface: a Retry beside the words, or a pane that reconnects on its own, is the step for a reason whose action is to retry, and every other step stays. What the phone and the host write is unchanged. --- ...red-agent-session-delivery-notices.test.ts | 26 ++++++++ ...tured-agent-session-read-failure-notice.ts | 6 +- src/shared/agent-session-failure-words.ts | 3 +- .../agent-session-refusal-notice.test.ts | 39 +++++++++++ src/shared/agent-session-refusal-notice.ts | 66 +++++++++---------- ...ructured-agent-session-send-disposition.ts | 2 +- 6 files changed, 101 insertions(+), 41 deletions(-) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index 9e6ae3570eba..5bfe2045d7f6 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -82,6 +82,32 @@ describe('the notice on each message that did not go through', () => { }) }) + // 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.' 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 index c5613ddac6d5..8dee55de82e6 100644 --- 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 @@ -20,12 +20,12 @@ export type StructuredAgentSessionReadFailureNotice = { 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) + ? agentSessionReadHistoryRefusalParts(refusal.code, refusal.details, { retryControl: true }) : agentSessionWriteNotDoneParts('read-history') return { - // The pane reconnects on its own, so a "try again" step would ask for what already happens. - text: agentSessionWriteNoticeText(parts.filter((part) => part !== 'tryAgain')), + text: agentSessionWriteNoticeText(parts), named: refusal?.details?.reason !== undefined, final: isFinalAgentSessionReadRefusal(refusal) } diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 30cace08eaf0..d88e1dd5bbd5 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -53,7 +53,8 @@ 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 shows its own Retry beside the words, so they leave out sending or trying again. */ + /** 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 } diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index 1e9db70d7457..a83a95c43880 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -369,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) diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index 7cd0e5875dcf..0f4121dc9b31 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,56 +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 write, without saying which write did not happen: for a line that - * already says so and shows its own Retry, such as a chat that could not start. The Retry is the - * step for a reason whose action is to retry, and for sending again; any other step stays. Empty - * when the refusal names no reason with words of its own. */ +/** 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 words = failure.kind === 'refused' ? agentSessionRefusalReasonWords(failure) : undefined - if (!words || 'words' in words) { - return [] - } - if ('fact' in words) { - const sentence = agentSessionFailureSentence({ kind: words.fact }, 'rejection', { - ...context, - retryControl: true - }) - return [{ text: sentence }] - } - return words.step && words.action !== 'retry' ? [words.cause, words.step] : [words.cause] + 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') { @@ -300,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 } @@ -372,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/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index f505b85c5f0f..dcea3167a57e 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -189,7 +189,7 @@ export function structuredAgentSessionAttemptFailureParts( ): AgentSessionWriteNoticePart[] { return failure.kind === 'rejected' ? structuredAgentSessionRejectionParts(failure.reason, 'send', failure.rejection, context) - : agentSessionWriteNoticeParts(failure, 'send') + : agentSessionWriteNoticeParts(failure, 'send', context) } export function disposeStructuredAgentSessionSendResult( From f3b8d2f8851a37c9260fcda91a8c0d2ad58c2c86 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:12:57 -0700 Subject: [PATCH 15/52] test(native-chat): read the sent message id without a type assertion --- ...tured-agent-session-outbox-admission.test.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) 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 c4ebf1dca9b5..3fbf4c5bfb69 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 @@ -34,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])) } @@ -228,7 +235,7 @@ describe('structured agent session outbox admission', () => { mocks.call.mockImplementation((_target, _method, params) => { const text = requestText(params) const attempt = sentTexts().filter((sent) => sent === text).length - const id = (params as { envelope: { clientOperationId: string } }).envelope.clientOperationId + const id = requestId(params) if (text === 'held') { return Promise.resolve(refusedResult('agent_session_ownership_unknown')) } @@ -271,8 +278,9 @@ describe('structured agent session outbox admission', () => { }) 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 = (params as { envelope: { clientOperationId: string } }).envelope.clientOperationId + const id = requestId(params) if (requestText(params) === 'held') { return Promise.resolve(refusedResult('agent_session_ownership_unknown')) } @@ -287,7 +295,7 @@ describe('structured agent session outbox admission', () => { fence: 1, submissions }), - { initialProps: { submissions: [] as readonly AgentJournalSubmission[] } } + { initialProps: { submissions: none } } ) act(() => expect(result.current.send('first')).toBe(true)) await waitFor(() => expect(result.current.outbox[0]?.state).toBe('dispatching')) From bac487a63c37de757d4de2e56c0973ec24999960 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:19:40 -0700 Subject: [PATCH 16/52] fix(native-chat): a rejected message is worded from the journal's own fact, never by comparing sentences A message the host recorded and then rejected kept only the rejection's kind on the message, so its notice was reworded from that smaller copy only when it rebuilt the host's sentence word for word. A different agent name, an older host's wording, or anything the copy dropped (why a start failed, the provider's own words) left the host's sentence in place, beside a Retry that repeated its resend step. The notice now reads the journal's own rejection for that message, found by id, with the pane's agent name and Retry, and shows the provider's words only when they were written for a person. The message's smaller copy words it only when that journal row is not loaded. Nothing new is stored. --- ...tiveChatStructuredSession.test-harness.tsx | 1 + .../NativeChatStructuredSession.tsx | 12 +- ...tiveChatStructuredSessionDelivery.test.tsx | 1 + ...red-agent-session-delivery-notices.test.ts | 106 +++++++++++++++--- ...ructured-agent-session-delivery-notices.ts | 29 ++++- ...ed-agent-session-outbox-admission.test.tsx | 3 +- .../use-structured-agent-session.ts | 2 + src/shared/agent-session-failure-words.ts | 6 - src/shared/structured-agent-session-outbox.ts | 5 +- ...red-agent-session-send-disposition.test.ts | 11 +- ...ructured-agent-session-send-disposition.ts | 43 ++++--- 11 files changed, 158 insertions(+), 61 deletions(-) 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 6291986f6daa..2344b362d170 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -164,6 +164,7 @@ export function createStructuredSessionMocks() { loadOlder: mocks.loadOlder, prompts: mocks.promptItems, outbox: outbox.outbox, + submissions: mocks.submissions, blockedClientMessageId: outbox.blockedClientMessageId, send: outbox.send, retry: outbox.retry, diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 991fc95d0faa..7ce0c8190ccb 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -27,6 +27,9 @@ import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-labe import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner' import { structuredAgentSessionReadFailureNotice } from './structured-agent-session-read-failure-notice' 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 @@ -110,15 +113,20 @@ export function NativeChatStructuredSession( retryRef.current(clientMessageId) }, []) const agentLabel = structuredAgentLabel(props.agent === 'codex' ? 'codex' : 'claude') + // Only a rejected message reads the journal's rows, so a new batch of them re-renders no row else. + const rejectionRows = controller.outbox.some((entry) => entry.state === 'rejected') + ? controller.submissions + : NO_SUBMISSIONS const deliveryNotices = useMemo( () => structuredAgentSessionDeliveryNotices( controller.outbox, controller.blockedClientMessageId, agentLabel, - retryDelivery + retryDelivery, + rejectionRows ), - [controller.outbox, controller.blockedClientMessageId, agentLabel, retryDelivery] + [controller.outbox, controller.blockedClientMessageId, agentLabel, retryDelivery, rejectionRows] ) const viewState = selectNativeChatViewState(session, { readRetries: true }) const readFailure = diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index 7f962400f8f3..5c3fa6b22c07 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -80,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, diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index 5bfe2045d7f6..e27ead25410f 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { createStructuredAgentSessionOutboxEntry, type StructuredAgentSessionOutboxEntry @@ -24,9 +26,16 @@ function entry( function texts( outbox: StructuredAgentSessionOutboxEntry[], - blocked: string | null = null + blocked: string | null = null, + submissions: readonly AgentJournalSubmission[] = [] ): Record { - const notices = structuredAgentSessionDeliveryNotices(outbox, blocked, 'Claude', () => {}) + const notices = structuredAgentSessionDeliveryNotices( + outbox, + blocked, + 'Claude', + () => {}, + submissions + ) return Object.fromEntries([...notices].map(([id, notice]) => [id, notice.text])) } @@ -50,7 +59,8 @@ describe('the notice on each message that did not go through', () => { ], null, 'Claude', - retry + retry, + [] ) expect([...notices.keys()]).toEqual([ @@ -141,7 +151,13 @@ describe('the notice on each message that did not go through', () => { [[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) + const notices = structuredAgentSessionDeliveryNotices( + [...outbox], + blocked, + 'Claude', + retry, + [] + ) expect(notices.get(agentJournalSubmissionKey('rejected'))).toEqual({ text: 'Message was not sent.' }) @@ -191,21 +207,77 @@ describe('the notice on each message that did not go through', () => { ).toEqual({ [agentJournalSubmissionKey('rejected')]: shown }) }) - // The stored fact keeps less than the host wrote from, so it rewords only a reason it rebuilds. - it('keeps a reason its stored fact cannot rebuild, beside its Retry too', () => { - const rejected = (reason: string): StructuredAgentSessionOutboxEntry => - entry('rejected', { + // 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', - lastFailure: { kind: 'rejected', reason, rejection: { kind: 'startFailed' } } + // 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' } + } }) - for (const reason of [ - "Claude couldn't start. Start a new chat to continue.", - "Claude couldn't start. This chat is still open in a terminal agent. Quit that agent to continue the chat here." - ]) { - expect(texts([rejected(reason)])).toEqual({ [agentJournalSubmissionKey('rejected')]: reason }) - } - expect(texts([rejected("Claude couldn't start. Send your message to try again.")])).toEqual({ - [agentJournalSubmissionKey('rejected')]: "Claude couldn't start." + 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 own copy, which has no refusal. + expect(texts([rejected('gone')])).toEqual({ + [agentJournalSubmissionKey('gone')]: "Claude couldn't start." }) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts index 8d60f65cee17..cd089f54785e 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts @@ -5,8 +5,13 @@ // 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. +import { readAgentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { admitStructuredAgentSessionOutboxEntry, type StructuredAgentSessionOutboxEntry @@ -19,7 +24,8 @@ import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' function deliveryNoticeText( entry: StructuredAgentSessionOutboxEntry, - context: AgentSessionFailureWordsContext + context: AgentSessionFailureWordsContext, + recorded: AgentJournalSubmission | undefined ): string { if (entry.state === 'unconfirmed') { return translate( @@ -29,7 +35,11 @@ function deliveryNoticeText( } return entry.lastFailure ? agentSessionWriteNoticeText( - structuredAgentSessionAttemptFailureParts(entry.lastFailure, context) + structuredAgentSessionAttemptFailureParts( + entry.lastFailure, + context, + readAgentSessionFailureFact(recorded?.rejection) + ) ) : translate( 'auto.components.native.chat.NativeChatStructuredSession.93ef441197', @@ -43,16 +53,27 @@ export function structuredAgentSessionDeliveryNotices( outbox: readonly StructuredAgentSessionOutboxEntry[], blockedClientMessageId: string | null, agentName: string, - retry: (clientMessageId: string) => void + retry: (clientMessageId: string) => void, + /** The journal's rows, whose rejected ones carry more of a rejection than the message keeps. */ + submissions: readonly AgentJournalSubmission[] ): 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 }) + const text = deliveryNoticeText( + entry, + { agentName, retryControl }, + rejected.get(entry.clientMessageId) + ) notices.set( agentJournalSubmissionKey(entry.clientMessageId), retryControl ? { text, onRetry: () => retry(entry.clientMessageId) } : { text } 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 3fbf4c5bfb69..1b0c18d78684 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 @@ -264,7 +264,8 @@ describe('structured agent session outbox admission', () => { result.current.outbox, result.current.blockedClientMessageId, 'Claude', - () => {} + () => {}, + [] ) expect(notices.get(agentJournalSubmissionKey(c!.clientMessageId))?.onRetry).toBeDefined() 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 1c23ad68ab98..659bbafbbd94 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 @@ -147,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/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index d88e1dd5bbd5..edd399edc473 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -240,12 +240,6 @@ const LEGACY_REJECTION_MARKERS: Partial< : undefined } -/** Whether a rejection of this kind may hold a legacy marker as its reason instead of a sentence, - * so a reader words it from the fact. */ -export function agentSessionRejectionMayHoldMarker(kind: SubmissionRejectionKind): boolean { - return LEGACY_REJECTION_MARKERS[kind] !== undefined -} - /** The words a status row reporting this fact records. */ export function agentSessionFailureWords( fact: AgentSessionFailureFact, diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index 4211bbb3961d..c14209a716cc 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-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts index 323c85564c17..3fd4817ac7d9 100644 --- a/src/shared/structured-agent-session-send-disposition.test.ts +++ b/src/shared/structured-agent-session-send-disposition.test.ts @@ -157,8 +157,7 @@ describe('what a rejection shows the user', () => { }) }) -// A row that carries the host's fact is worded from it; the reason is read only as the fact's own -// sentence, never classified again. +// 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( @@ -175,9 +174,11 @@ describe('what a rejection with a typed fact shows the user', () => { ) }) - it("shows the sentence the host wrote for the fact, with the agent's name it knew", () => { - const reason = 'Claude never finished starting, so Orca stopped it.' - expect(notice(reason, { kind: 'hostStopped' })).toBe(reason) + // 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. diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index dcea3167a57e..902b82b663da 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -16,9 +16,9 @@ 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, - agentSessionRejectionMayHoldMarker, type AgentSessionFailureWordsContext } from './agent-session-failure-words' import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' @@ -27,8 +27,7 @@ import { requeueStructuredAgentSessionSendRefusal, structuredAgentSessionRejectedFailure, type StructuredAgentSessionAttemptFailure, - type StructuredAgentSessionOutboxEntry, - type StructuredAgentSessionRejectionFact + type StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' export type StructuredAgentSessionSendDisposition = { @@ -137,11 +136,11 @@ export function structuredAgentSessionRejectionParts( reason: string | null, write: 'send' | 'composer-send', /** The host's typed fact, which decides when the row carried one. */ - fact?: StructuredAgentSessionRejectionFact, + fact?: AgentSessionFailureFact, context: AgentSessionFailureWordsContext = {} ): AgentSessionWriteNoticePart[] { if (fact) { - return rejectionFactParts(reason, write, fact, context) + return rejectionFactParts(write, fact, context) } if (reason === null) { return ['notDoneSend'] @@ -156,39 +155,35 @@ export function structuredAgentSessionRejectionParts( } function rejectionFactParts( - reason: string | null, write: 'send' | 'composer-send', - fact: StructuredAgentSessionRejectionFact, + fact: AgentSessionFailureFact, context: AgentSessionFailureWordsContext ): AgentSessionWriteNoticePart[] { - const { kind } = classifyDispatchRejection({ reason, rejection: fact }) + 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. - if (!kind) { - return agentSessionWriteNotDoneParts(write) - } - const sentence = (words: AgentSessionFailureWordsContext): string => - agentSessionFailureSentence({ ...fact, kind }, 'rejection', words) - if (reason === null || agentSessionRejectionMayHoldMarker(kind)) { - return [{ text: sentence(context) }] - } - // The host wrote the fact's sentence as the reason, with what the stored fact drops (a refusal, - // the provider's words): beside a Retry, reword only a reason the fact rebuilds exactly. - const { retryControl, ...written } = context - return retryControl && sentence(written) === reason - ? [{ text: sentence(context) }] - : [{ text: reason }] + return kind + ? [{ text: agentSessionFailureSentence({ ...fact, kind }, 'rejection', context) }] + : agentSessionWriteNotDoneParts(write) } /** What the Retry row says about why its message did not go through. */ export function structuredAgentSessionAttemptFailureParts( failure: StructuredAgentSessionAttemptFailure, - context: AgentSessionFailureWordsContext = {} + 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', failure.rejection, context) + ? structuredAgentSessionRejectionParts( + failure.reason, + 'send', + recorded ?? failure.rejection, + context + ) : agentSessionWriteNoticeParts(failure, 'send', context) } From 8ff7387196000f0ab21b5bc60ad86990fcf6c9f3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:22:19 -0700 Subject: [PATCH 17/52] fix(native-chat): a message rejected before a restart retries under a new id the first time Whether a Retry needed a new message id was remembered in memory for one message, or read from the journal row when it was loaded. After a restart, or for an older message whose row was not loaded, the first Retry resent under the old id, the host answered with the same settled rejection, and nothing visibly happened. The message now says so itself: one the host recorded and rejected always retries under a new id, including after a restart. A refusal that already gave the message a fresh id, and a message whose delivery is unconfirmed or in flight, keep their id as before. --- ...nt-session-outbox-rejection-cause.test.tsx | 47 ++++++++++++++++++- .../use-structured-agent-session-outbox.ts | 19 +++----- ...red-agent-session-send-disposition.test.ts | 3 +- ...ructured-agent-session-send-disposition.ts | 26 ++++------ 4 files changed, 62 insertions(+), 33 deletions(-) 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 a8b9f01b20b9..3ce99e6fd4b4 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 aed05b56778f..ae2a1ae0a61c 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) @@ -295,15 +292,13 @@ export function useStructuredAgentSessionOutbox(args: { (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/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts index 3fd4817ac7d9..c98c20d1f9e8 100644 --- a/src/shared/structured-agent-session-send-disposition.test.ts +++ b/src/shared/structured-agent-session-send-disposition.test.ts @@ -121,8 +121,7 @@ describe('what a rejection shows the user', () => { expect(disposition).toEqual({ entries: [], error: null, - blockedClientMessageId: null, - retryWithFreshClientMessageId: null + blockedClientMessageId: null }) } }) diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index 902b82b663da..43b2574be860 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -37,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 = { @@ -222,8 +220,7 @@ export function disposeStructuredAgentSessionSendResult( blockedClientMessageId: !refused || refused.state === 'rejected' ? input.blockedClientMessageId - : refused.clientMessageId, - retryWithFreshClientMessageId: null + : refused.clientMessageId } } const submission = result.value.submission @@ -231,16 +228,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 @@ -252,8 +247,7 @@ export function disposeStructuredAgentSessionSendResult( return { entries: dropEntry(input), error: null, - blockedClientMessageId: input.blockedClientMessageId, - retryWithFreshClientMessageId: null + blockedClientMessageId: input.blockedClientMessageId } } if (submission.dispatchState === 'rejected') { @@ -264,8 +258,7 @@ export function disposeStructuredAgentSessionSendResult( structuredAgentSessionRejectedFailure(submission) ), error: null, - blockedClientMessageId: input.blockedClientMessageId, - retryWithFreshClientMessageId: input.entry.clientMessageId + blockedClientMessageId: input.blockedClientMessageId } } if (submission.dispatchState === 'unknown' && submission.recovered) { @@ -276,8 +269,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 @@ -292,8 +284,7 @@ export function disposeStructuredAgentSessionSendResult( submission.dispatchState === 'unknown' ? 'unconfirmed' : 'dispatching' ), error: null, - blockedClientMessageId: input.blockedClientMessageId, - retryWithFreshClientMessageId: null + blockedClientMessageId: input.blockedClientMessageId } } @@ -313,7 +304,6 @@ export function disposeStructuredAgentSessionSendFailure( error: null, blockedClientMessageId: deliveryUnknown ? input.blockedClientMessageId - : input.entry.clientMessageId, - retryWithFreshClientMessageId: null + : input.entry.clientMessageId } } From 4fd67d7cd59e3b11c3e093e6ca20defe16ce400e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:25:49 -0700 Subject: [PATCH 18/52] fix(native-chat): a rejected message older than the loaded history keeps the provider's words When the journal row that rejected a message is not loaded, the message's own copy of the fact has no provider detail or start refusal. For the kinds worded from those, the row now shows the sentence the host wrote for the person instead of a thinner rebuilt one. --- ...red-agent-session-delivery-notices.test.ts | 30 +++++++++++++++++-- ...red-agent-session-send-disposition.test.ts | 20 +++++++++++++ ...ructured-agent-session-send-disposition.ts | 22 +++++++++----- 3 files changed, 61 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index e27ead25410f..ab8241582f9d 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -275,9 +275,33 @@ describe('the notice on each message that did not go through', () => { ).toEqual( Object.fromEntries(facts.map(([id, , shown]) => [agentJournalSubmissionKey(id), shown])) ) - // Not loaded (older than the loaded page): the message's own copy, which has no refusal. - expect(texts([rejected('gone')])).toEqual({ - [agentJournalSubmissionKey('gone')]: "Claude couldn't start." + // 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.' }) }) diff --git a/src/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts index c98c20d1f9e8..e95a21937d1c 100644 --- a/src/shared/structured-agent-session-send-disposition.test.ts +++ b/src/shared/structured-agent-session-send-disposition.test.ts @@ -187,6 +187,26 @@ describe('what a rejection with a typed fact shows the user', () => { ) }) + // 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.' diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index 43b2574be860..11e2b45fc0be 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -167,6 +167,11 @@ function rejectionFactParts( : 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, @@ -175,14 +180,15 @@ export function structuredAgentSessionAttemptFailureParts( * message's own copy keeps only its kind and attachment. */ recorded?: AgentSessionFailureFact ): AgentSessionWriteNoticePart[] { - return failure.kind === 'rejected' - ? structuredAgentSessionRejectionParts( - failure.reason, - 'send', - recorded ?? failure.rejection, - context - ) - : agentSessionWriteNoticeParts(failure, 'send', context) + 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( From 5c1647d3445a48d289ab6c7f4c7ab785233519c2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:38:36 -0700 Subject: [PATCH 19/52] test(native-chat): the chat pane words a rejected message from its loaded journal row Nothing covered the pane handing the journal's rows to the per-message notices, so a pane that stopped passing them would quietly fall back to the message's smaller copy of the rejection and show the host's sentence, resend step and all. The new case renders the pane with a rejected message whose journal row is loaded and checks that it reads that row's refusal in the chat's own agent name. --- ...tiveChatStructuredSessionDelivery.test.tsx | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index 5c3fa6b22c07..0e6994a7b8bb 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -369,6 +369,50 @@ describe('NativeChatStructuredSession delivery', () => { 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 = [] From 0b9a7b06c10a9dd21701243d6a18e50a34759511 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:48:56 -0700 Subject: [PATCH 20/52] fix(native-chat): a chat whose history won't load says why in one line A read the host refused for a named reason put its sentence under the generic "Could not load conversation" title, so a damaged history read as two lines saying the same thing. The pane's own sentence now takes the title's place; a history that can come back keeps its line saying Orca keeps trying. A failure that names nothing keeps the generic title. --- .../native-chat/NativeChatEmptyState.test.tsx | 16 +++++++++----- .../native-chat/NativeChatEmptyState.tsx | 22 ++++++++++--------- ...eChatStructuredSession.read-error.test.tsx | 6 +++-- .../NativeChatStructuredSession.tsx | 2 +- 4 files changed, 27 insertions(+), 19 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatEmptyState.test.tsx b/src/renderer/src/components/native-chat/NativeChatEmptyState.test.tsx index 90ffc205063c..dccfaba728c8 100644 --- a/src/renderer/src/components/native-chat/NativeChatEmptyState.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatEmptyState.test.tsx @@ -31,22 +31,26 @@ it('shows the host message in place of the terminal-backed default', () => { }) // The structured chat passes words only from the notice table, never the host's. -it("puts the structured chat's own words for the failure above its retrying line", () => { +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.")).toBeInTheDocument() + 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 nothing of trying again for a failure no retry gets past', () => { - render() - expect(screen.getByText('Unable to load this chat.')).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 51dd7fffe076..ec84f554cd76 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, under the caller's `message` when it has words of its own. */ + * 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 (

{copy.subtitle}

) : null} - {copy.note ? ( -

{copy.note}

- ) : null}
) } function emptyStateCopy( kind: 'loading' | 'empty' | 'error' | 'not-agent', - message?: string, + words: { message?: string; headline?: string }, agent?: NativeChatSession['agent'], retrying = false -): { title: string; subtitle: string | null; note?: string } { +): { title: string; subtitle: string | null } { switch (kind) { case 'loading': return { @@ -69,19 +69,21 @@ function emptyStateCopy( NATIVE_CHAT_EMPTY_STATE_COPY.retryingError.subtitle ) : null + if (words.headline) { + return { title: words.headline, subtitle: retryingLine } + } return { title: translate( 'components.native-chat.state.error.title', NATIVE_CHAT_EMPTY_STATE_COPY.error.title ), subtitle: - message ?? retryingLine ?? + words.message ?? translate( 'components.native-chat.state.error.subtitle', NATIVE_CHAT_EMPTY_STATE_COPY.error.subtitle - ), - ...(message && retryingLine ? { note: retryingLine } : {}) + ) } } case 'not-agent': 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 38a56adeb507..89ef518feb3b 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 @@ -58,7 +58,7 @@ it('says a failed read with no refusal keeps retrying, and adds nothing of the h expect(screen.queryByText(/Toggle back to the terminal/)).toBeNull() }) -it('says a damaged history cannot load, without claiming Orca keeps trying', () => { +it('says a damaged history cannot load in one line, without claiming Orca keeps trying', () => { mocks.status = 'error' mocks.readRefusal = journalRefusal('journalCorrupt') mocks.messages = [] @@ -66,11 +66,12 @@ it('says a damaged history cannot load, without claiming Orca keeps trying', () 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, and that the pane keeps trying", () => { +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 = [] @@ -78,6 +79,7 @@ it("names a history that couldn't open right now, and that the pane keeps trying 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() diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 7ce0c8190ccb..4da609e829c5 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -252,7 +252,7 @@ export function NativeChatStructuredSession( ) : viewState.kind === 'empty' ? ( From e3f23a8a171a8fc2959f7f0cd8591bf7cb8da24b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:54:17 -0700 Subject: [PATCH 21/52] fix(native-chat): a message a failed start rejected says only that it was not sent When an agent stopped before it finished starting, the chat showed the start's red row ("Claude stopped before it finished starting. Send your message to try again.") and then repeated that cause under every message the start rejected. Each of those messages now reads "Your message was not sent." beside its Retry. The match is made on typed facts, not on the words: the host writes the start's row and the rejection of its queued messages from the same failure fact, and the row is keyed by the start. The pane finds the loaded start-failure rows by that key and shortens a message's notice only when its loaded journal submission was rejected with the same fact. Any other rejection, or one whose submission or row is not loaded, keeps its full notice. The row key moves to a shared module so the host that writes it and the pane that reads it use one definition. --- ...uctured-agent-session-start-failure-row.ts | 9 +- ...turedSession.start-failure-notice.test.tsx | 163 ++++++++++++++++++ ...tiveChatStructuredSession.test-harness.tsx | 4 +- .../NativeChatStructuredSession.tsx | 22 ++- ...red-agent-session-delivery-notices.test.ts | 109 +++++++++++- ...ructured-agent-session-delivery-notices.ts | 89 ++++++++-- ...-agent-session-start-failure-facts.test.ts | 42 +++++ ...tured-agent-session-start-failure-facts.ts | 33 ++++ ...red-agent-session-start-failure-row-key.ts | 17 ++ 9 files changed, 454 insertions(+), 34 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts create mode 100644 src/shared/structured-agent-session-start-failure-row-key.ts 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 c0660eb9791a..77ba7fe5f583 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/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 000000000000..6f529c1d3c9f --- /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 2344b362d170..dc4eeb122a2f 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -90,6 +90,7 @@ export function createStructuredSessionMocks() { approvalCardProps: initialApprovalCardProps, questionCardProps: null as NativeChatQuestionCardProps | null, promptItems: [] as AgentJournalRenderItem[], + journalItems: [] as AgentJournalRenderItem[], respond: vi.fn<(...args: never[]) => unknown>(), cancel: vi.fn<(...args: never[]) => unknown>(), handlePasteEvent: vi.fn<(...args: never[]) => unknown>(), @@ -136,7 +137,7 @@ export function createStructuredSessionMocks() { submissions: mocks.submissions as never }) return { - journalItems: [], + journalItems: mocks.journalItems, messages: mocks.messages ?? (mocks.mode === 'outbox' @@ -285,6 +286,7 @@ export function createStructuredSessionMocks() { 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 4da609e829c5..6daa3d3f62c9 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -26,6 +26,7 @@ import { useStructuredAgentSessionHostExecutionPhase } from './StructuredAgentSe 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' @@ -114,9 +115,12 @@ export function NativeChatStructuredSession( }, []) 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 rejectionRows = controller.outbox.some((entry) => entry.state === 'rejected') - ? controller.submissions - : NO_SUBMISSIONS + 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( @@ -124,9 +128,17 @@ export function NativeChatStructuredSession( controller.blockedClientMessageId, agentLabel, retryDelivery, - rejectionRows + rejectionRows, + startFailures ), - [controller.outbox, controller.blockedClientMessageId, agentLabel, retryDelivery, rejectionRows] + [ + controller.outbox, + controller.blockedClientMessageId, + agentLabel, + retryDelivery, + rejectionRows, + startFailures + ] ) const viewState = selectNativeChatViewState(session, { readRetries: true }) const readFailure = diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index ab8241582f9d..d13872ee34c6 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -1,12 +1,21 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' -import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { createStructuredAgentSessionOutboxEntry, type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' -import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' +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' +import { + structuredAgentSessionDeliveryNotices, + structuredAgentSessionStartFailureFacts +} from './structured-agent-session-delivery-notices' function entry( clientMessageId: string, @@ -27,14 +36,16 @@ function entry( function texts( outbox: StructuredAgentSessionOutboxEntry[], blocked: string | null = null, - submissions: readonly AgentJournalSubmission[] = [] + submissions: readonly AgentJournalSubmission[] = [], + startFailures: readonly AgentSessionFailureFact[] = [] ): Record { const notices = structuredAgentSessionDeliveryNotices( outbox, blocked, 'Claude', () => {}, - submissions + submissions, + startFailures ) return Object.fromEntries([...notices].map(([id, notice]) => [id, notice.text])) } @@ -60,6 +71,7 @@ describe('the notice on each message that did not go through', () => { null, 'Claude', retry, + [], [] ) @@ -156,6 +168,7 @@ describe('the notice on each message that did not go through', () => { blocked, 'Claude', retry, + [], [] ) expect(notices.get(agentJournalSubmissionKey('rejected'))).toEqual({ @@ -308,4 +321,92 @@ describe('the notice on each message that did not go through', () => { 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 index cd089f54785e..ec3eb60e6875 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts @@ -7,11 +7,21 @@ // 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. +// 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 } from '../../../../shared/agent-session-failure' +import { + readAgentSessionFailureFact, + type AgentSessionFailureFact +} from '../../../../shared/agent-session-failure' import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' -import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +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 @@ -22,10 +32,45 @@ 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 + recorded: AgentJournalSubmission | undefined, + startFailures: readonly AgentSessionFailureFact[] ): string { if (entry.state === 'unconfirmed') { return translate( @@ -33,18 +78,23 @@ function deliveryNoticeText( 'Message delivery is unconfirmed.' ) } - return entry.lastFailure - ? agentSessionWriteNoticeText( - structuredAgentSessionAttemptFailureParts( - entry.lastFailure, - context, - readAgentSessionFailureFact(recorded?.rejection) - ) - ) - : translate( - 'auto.components.native.chat.NativeChatStructuredSession.93ef441197', - 'Message was not sent.' - ) + 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 @@ -55,7 +105,9 @@ export function structuredAgentSessionDeliveryNotices( 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[] + 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 @@ -72,7 +124,8 @@ export function structuredAgentSessionDeliveryNotices( const text = deliveryNoticeText( entry, { agentName, retryControl }, - rejected.get(entry.clientMessageId) + rejected.get(entry.clientMessageId), + startFailures ) notices.set( agentJournalSubmissionKey(entry.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 000000000000..97ef023dffb7 --- /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 000000000000..83f20103de11 --- /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/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 000000000000..6dc4a4817b14 --- /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) +} From c2eb6898ba787ddcbc34376e74cf0e428d59d3e2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:57:36 -0700 Subject: [PATCH 22/52] fix(native-chat): a chat whose history keeps failing to open retries less often A read the host kept refusing (its history store could not be opened right now) reopened every 750 ms for as long as the chat stayed open, about 40 opens every 30 seconds. Each reconnect now waits twice as long as the last, from 750 ms up to 30 seconds, and never gives up; the first read that delivers anything starts the wait over at 750 ms. A damaged history still stops reconnecting at once. Reset happens on a delivered read, not on connect: a local subscribe resolves before the host's open refuses, so resetting there would keep the 750 ms loop. --- ...tured-agent-session-read-transport.test.ts | 66 +++++++++++++++++++ ...structured-agent-session-read-transport.ts | 17 ++++- 2 files changed, 82 insertions(+), 1 deletion(-) 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 efd658949fc9..909ba4e3e8ad 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 @@ -431,4 +431,70 @@ 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() + } + }) }) 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 828608d094b5..86f12612a414 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 @@ -30,13 +30,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()) { @@ -44,6 +51,10 @@ 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 + }, dispose(): void { if (timer) { clearTimeout(timer) @@ -133,6 +144,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)) { From 708e1cca29b1f6a79e40a984bb431ea304457438 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:58:36 -0700 Subject: [PATCH 23/52] test(native-chat): the pane harness types its journal rows without a cast --- .../native-chat/NativeChatStructuredSession.test-harness.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 dc4eeb122a2f..5689cf9689f8 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -90,7 +90,7 @@ export function createStructuredSessionMocks() { approvalCardProps: initialApprovalCardProps, questionCardProps: null as NativeChatQuestionCardProps | null, promptItems: [] as AgentJournalRenderItem[], - journalItems: [] as AgentJournalRenderItem[], + journalItems: new Array(), respond: vi.fn<(...args: never[]) => unknown>(), cancel: vi.fn<(...args: never[]) => unknown>(), handlePasteEvent: vi.fn<(...args: never[]) => unknown>(), From 5e41e506a9865537fab5fd96910a6f03098071c6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:09:10 -0700 Subject: [PATCH 24/52] test(native-chat): the admission test passes no start-failure rows to the notices --- .../use-structured-agent-session-outbox-admission.test.tsx | 1 + 1 file changed, 1 insertion(+) 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 1b0c18d78684..feb9f898ce25 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 @@ -265,6 +265,7 @@ describe('structured agent session outbox admission', () => { result.current.blockedClientMessageId, 'Claude', () => {}, + [], [] ) expect(notices.get(agentJournalSubmissionKey(c!.clientMessageId))?.onRetry).toBeDefined() From f7892dc8211540abbbd7024ee2419c6e4de87ee1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:24:53 -0700 Subject: [PATCH 25/52] test(native-chat): import the journal types once --- .../structured-agent-session-delivery-notices.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index d13872ee34c6..faadac1127ef 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' -import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' import { createStructuredAgentSessionOutboxEntry, type StructuredAgentSessionOutboxEntry @@ -10,7 +13,6 @@ 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' import { structuredAgentSessionDeliveryNotices, From 4c0397511a5d63b14ec4c1ff574b0fc38cc0462b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:00:42 -0700 Subject: [PATCH 26/52] fix(native-chat): a remote chat reads again as soon as its host is back The read retry doubles its wait up to 30 s during an outage, and nothing reset it when the remote runtime reconnected, so the transcript could lag the reconnect by up to 30 s. The read now watches the runtime status store's contact-regained edges (hostContactEpoch for a same-runtime return, connectionGeneration for a new runtime session) and, when one lands, runs a waiting retry immediately with the wait reset to its base. --- ...tured-agent-session-read-transport.test.ts | 64 +++++++++- ...structured-agent-session-read-transport.ts | 21 ++++ .../runtime-host-contact-regained.test.ts | 111 ++++++++++++++++++ .../runtime/runtime-host-contact-regained.ts | 31 +++++ 4 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/runtime/runtime-host-contact-regained.test.ts create mode 100644 src/renderer/src/runtime/runtime-host-contact-regained.ts 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 909ba4e3e8ad..683676c99d65 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 = { @@ -497,4 +501,62 @@ describe('structured agent-session read transport unattached refusals', () => { 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 86f12612a414..a0a693e92a9c 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 @@ -8,6 +8,7 @@ import { } 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' @@ -55,6 +56,18 @@ function createReconnectScheduler(args: { shouldStop: () => boolean; reconnect: 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) @@ -96,6 +109,13 @@ export function startStructuredAgentSessionReadTransport(args: { 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 => { @@ -259,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/runtime/runtime-host-contact-regained.test.ts b/src/renderer/src/runtime/runtime-host-contact-regained.test.ts new file mode 100644 index 000000000000..5daabf377901 --- /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 000000000000..b829f51d99a5 --- /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() + }) +} From 55fda332c1d884300a2d70c0fa120991ccb12a8c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:12:28 -0700 Subject: [PATCH 27/52] refactor(native-chat): build each failure sentence from whole pieces Every sentence agentSessionFailureWords writes is now assembled from a table of whole English pieces, so a reader can supply its own words for each piece. The host still fills them in English, byte for byte as before. --- src/shared/agent-session-failure-copy.ts | 103 +++++++++ src/shared/agent-session-failure-words.ts | 198 ++++++++++-------- src/shared/agent-session-write-notice-copy.ts | 2 +- 3 files changed, 212 insertions(+), 91 deletions(-) create mode 100644 src/shared/agent-session-failure-copy.ts diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts new file mode 100644 index 000000000000..dfcf47c2dfee --- /dev/null +++ b/src/shared/agent-session-failure-copy.ts @@ -0,0 +1,103 @@ +// The pieces every failure sentence is made of, in English. The host fills them in as they are; +// desktop translates each piece whole with this as its fallback, so the two never differ. + +/** Sentences a refusal notice shows too, so a chat says them one way. */ +export const TERMINAL_AGENT_HOLDS_CHAT = 'This chat is still open in a terminal agent.' +export const QUIT_TERMINAL_AGENT = 'Quit that agent to continue the chat here.' +export const START_NEW_CHAT = 'Start a new chat to continue.' + +/** Every piece a failure sentence is made of, whole so desktop can translate each on its own. + * `{{agent}}` is the agent's name or `theAgent`; the host fills them in English. */ +export const AGENT_SESSION_FAILURE_COPY = { + theAgent: 'The agent', + providerStartFailed: '{{agent}} stopped before it finished starting.', + runClearAgain: 'Run /clear again.', + sendToTryAgain: 'Send your message to try again.', + couldNotStart: "{{agent}} couldn't start.", + couldNotRestart: "{{agent}} couldn't restart.", + terminalAgentHoldsChat: TERMINAL_AGENT_HOLDS_CHAT, + quitTerminalAgent: QUIT_TERMINAL_AGENT, + startNewChat: START_NEW_CHAT, + notSignedIn: '{{agent}} is not signed in for the selected account.', + signInFirst: 'Sign in first.', + signInThenRunClear: 'Sign in, then run /clear again.', + signInThenSend: 'Sign in, then send your message again.', + historyTooLarge: "This conversation's history is too large to restore here.", + managedAccountEnvOverride: + 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.', + accountSwitchInProgress: 'A Claude account switch is in progress. Try again after it finishes.', + managedAccountUnsupported: + 'While a Claude account is added in WSL, Claude chats need a Windows Claude account.', + chooseClaudeAccount: 'Choose or add one in Claude Accounts settings.', + chooseClaudeAccountThenRunClear: + 'Choose or add one in Claude Accounts settings, then run /clear again.', + chooseClaudeAccountThenSend: + 'Choose or add one in Claude Accounts settings, then send your message again.', + providerExitedRow: + '{{agent}} stopped while this response was in progress. You can continue in this conversation.', + providerExitedRejection: '{{agent}} stopped before this message was sent.', + providerRejected: 'The provider did not accept this message.', + providerRejectedQuoted: 'The provider did not accept this message: {{detail}}.', + attachmentEmpty: 'An image on this message is empty, so the message was not sent.', + attachmentTooLarge: 'An image on this message is too large, so the message was not sent.', + attachmentLargerThan: + 'An image on this message is larger than {{size}}, so the message was not sent.', + attachmentTooMany: 'This message has too many images, so it was not sent.', + attachmentAtMost: + '{{agent}} accepts at most {{limit}} images in one message, so this message was not sent.', + attachmentTotalTooLarge: + 'The images on this message are too large together, so the message was not sent.', + attachmentTotalMoreThan: + 'The images on this message add up to more than {{size}}, so the message was not sent.', + attachmentUnsupportedType: + '{{agent}} accepts only PNG, JPEG, GIF, and WebP images, so this message was not sent.', + attachmentNotAFile: "An image on this message isn't a file, so the message was not sent.", + attachmentNoSource: 'An image on this message has no file to send, so the message was not sent.', + attachmentInvalid: "An attachment on this message can't be sent to the agent.", + attachmentUnreadable: + "An attachment on this message couldn't be read, so the message was not sent.", + emptyMessage: 'This message is empty, so it was not sent.', + queueFull: 'Too many messages were waiting for the agent, so this one was not sent.', + writeFailed: "Orca couldn't hand this message to the agent, so it was not sent.", + 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.', + notDeliveredSendAgain: 'This message was not delivered. Send it again to continue.', + compactionFailed: 'Compaction failed.', + compactionFailedQuoted: 'Compaction failed: {{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.", + hostFaultTryAgain: "Orca ran into a problem, so this didn't go through. Try again.", + hostStopped: '{{agent}} never finished starting, so Orca stopped it.', + providerRateLimited: '{{agent}} is rate-limited and retrying.', + providerRetrying: '{{agent}} hit a temporary problem and is retrying.' +} as const + +export type AgentSessionFailureCopyId = keyof typeof AGENT_SESSION_FAILURE_COPY + +/** What a piece's `{{name}}` placeholders stand for. */ +export type AgentSessionFailureCopyValues = { + agent?: string + detail?: string + limit?: string + size?: string +} + +/** One piece in the reader's language, placeholders filled. */ +export type AgentSessionFailureSay = ( + id: AgentSessionFailureCopyId, + values?: AgentSessionFailureCopyValues +) => string + +/** The host's words, and any surface without translations. */ +export const sayAgentSessionFailureEnglish: AgentSessionFailureSay = (id, values = {}) => { + const filled = new Map(Object.entries(values)) + // One pass over the template, so a provider's words are never read as a placeholder. + return AGENT_SESSION_FAILURE_COPY[id].replace( + /\{\{(\w+)\}\}/g, + (placeholder, name: string) => filled.get(name) ?? placeholder + ) +} diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index edd399edc473..7b472095dcdb 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -3,8 +3,9 @@ // A row's `text`, a rejected message's `reason` and a conversation command's `error` are what // every released client prints as they are, so the host writes them here, from the fact, and // nowhere else: never Orca's own error text, a refusal's message, or a probe's evidence. A -// provider's words reach the sentence only when the provider wrote them for a person. The table -// is also the English default for a client that chooses its own copy from the fact. +// provider's words reach the sentence only when the provider wrote them for a person. Each sentence +// is built from the pieces in `agent-session-failure-copy`, which desktop translates to word the +// same fact in the reader's language. import { isSubmissionRejectionFact, @@ -16,6 +17,11 @@ import { type SubmissionRejectionFact, type SubmissionRejectionKind } from './agent-session-failure' +import { + sayAgentSessionFailureEnglish, + type AgentSessionFailureCopyId, + type AgentSessionFailureSay +} from './agent-session-failure-copy' import type { AgentSessionWireRefusalCode } from './agent-session-wire-refusals' import { DISPATCH_REJECTED_CANCELLED, @@ -81,11 +87,6 @@ export const START_REFUSAL_RESUMABLE: Record string -function quotingPersonDetail(lead: string, detail: ProviderDiagnostic | undefined): string { +function agent(say: AgentSessionFailureSay, { agentName }: AgentSessionFailureWordsContext) { + return { agent: agentName ?? say('theAgent') } +} + +function quotingPersonDetail( + say: AgentSessionFailureSay, + lead: AgentSessionFailureCopyId, + quotedLead: AgentSessionFailureCopyId, + detail: ProviderDiagnostic | undefined +): string { const quoted = detail?.audience === 'person' ? detail.text @@ -104,34 +115,31 @@ function quotingPersonDetail(lead: string, detail: ProviderDiagnostic | undefine .trim() .replace(/[.\s]+$/, '') : '' - return quoted ? `${lead}: ${quoted}.` : `${lead}.` -} - -/** What to do once the start can work, for a sentence that ends in it. */ -function retryStep({ command }: AgentSessionFailureWordsContext): string { - return command === 'clear' ? 'run /clear again' : 'send your message again' + return quoted ? say(quotedLead, { detail: quoted }) : say(lead) } /** The next step after a start or restart that failed: the command, or the message, again. */ -function startRetry({ command, retryControl }: AgentSessionFailureWordsContext): string { +function startRetry( + say: AgentSessionFailureSay, + { command, retryControl }: AgentSessionFailureWordsContext +): string { if (retryControl) { return '' } - return command === 'clear' ? ' Run /clear again.' : ' Send your message to try again.' + return ` ${say(command === 'clear' ? 'runClearAgain' : 'sendToTryAgain')}` } -function couldNot(verb: string): Sentence { - return (context, fact) => { - const { agentName } = context - const failed = `${agentName ?? 'The agent'} couldn't ${verb}.` +function couldNot(verb: 'couldNotStart' | 'couldNotRestart'): Sentence { + return (context, fact, _surface, say) => { + const failed = say(verb, agent(say, context)) // Only a terminal agent an older build recorded holds a claim; quitting it frees the chat. if (fact.refusal?.details?.reason === 'claimConflicted') { - return `${failed} ${TERMINAL_AGENT_HOLDS_CHAT} ${QUIT_TERMINAL_AGENT}` + return `${failed} ${say('terminalAgentHoldsChat')} ${say('quitTerminalAgent')}` } const code = fact.refusal?.code return code && !START_REFUSAL_RESUMABLE[code] - ? `${failed} ${START_NEW_CHAT}` - : `${failed}${startRetry(context)}` + ? `${failed} ${say('startNewChat')}` + : `${failed}${startRetry(say, context)}` } } @@ -139,90 +147,100 @@ function megabytes(bytes: number): string { return `${Math.round((bytes / BYTES_PER_MB) * 10) / 10} MB` } -const NOT_SENT = 'so the message was not sent.' - const ATTACHMENT_SENTENCES = { - empty: () => `An image on this message is empty, ${NOT_SENT}`, - tooLarge: (_, { limit }) => - limit - ? `An image on this message is larger than ${megabytes(limit)}, ${NOT_SENT}` - : `An image on this message is too large, ${NOT_SENT}`, - tooMany: ({ agentName }, { limit }) => + empty: (say) => say('attachmentEmpty'), + tooLarge: (say, _, { limit }) => + limit ? say('attachmentLargerThan', { size: megabytes(limit) }) : say('attachmentTooLarge'), + tooMany: (say, context, { limit }) => limit - ? `${agentName ?? 'The agent'} accepts at most ${limit} images in one message, so this message was not sent.` - : 'This message has too many images, so it was not sent.', - totalTooLarge: (_, { limit }) => + ? say('attachmentAtMost', { ...agent(say, context), limit: String(limit) }) + : say('attachmentTooMany'), + totalTooLarge: (say, _, { limit }) => limit - ? `The images on this message add up to more than ${megabytes(limit)}, ${NOT_SENT}` - : `The images on this message are too large together, ${NOT_SENT}`, - unsupportedType: ({ agentName }) => - `${agentName ?? 'The agent'} accepts only PNG, JPEG, GIF, and WebP images, so this message was not sent.`, - notAFile: () => `An image on this message isn't a file, ${NOT_SENT}`, - noSource: () => `An image on this message has no file to send, ${NOT_SENT}` + ? say('attachmentTotalMoreThan', { size: megabytes(limit) }) + : say('attachmentTotalTooLarge'), + unsupportedType: (say, context) => say('attachmentUnsupportedType', agent(say, context)), + notAFile: (say) => say('attachmentNotAFile'), + noSource: (say) => say('attachmentNoSource') } satisfies Record< AgentSessionAttachmentProblemReason, - (context: AgentSessionFailureWordsContext, problem: AgentSessionAttachmentProblem) => string + ( + say: AgentSessionFailureSay, + context: AgentSessionFailureWordsContext, + problem: AgentSessionAttachmentProblem + ) => string > const FAILURE_SENTENCES = { - providerStartFailed: (context) => - `${context.agentName ?? 'The agent'} stopped before it finished starting.${startRetry(context)}`, - startFailed: couldNot('start'), + providerStartFailed: (context, _fact, _surface, say) => + `${say('providerStartFailed', agent(say, context))}${startRetry(say, context)}`, + startFailed: couldNot('couldNotStart'), // 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. ${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: () => - 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.', - 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${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.` - : `${agentName ?? 'The agent'} stopped before this message was sent.`, - restartFailed: couldNot('restart'), - providerRejected: (_, fact) => - quotingPersonDetail('The provider did not accept this message', fact.detail), - attachmentInvalid: (context, fact) => + notSignedIn: (context, _fact, _surface, say) => + `${say('notSignedIn', agent(say, context))} ${say( + context.retryControl + ? 'signInFirst' + : context.command === 'clear' + ? 'signInThenRunClear' + : 'signInThenSend' + )}`, + historyTooLarge: (_context, _fact, _surface, say) => + `${say('historyTooLarge')} ${say('startNewChat')}`, + managedAccountEnvOverride: (_context, _fact, _surface, say) => say('managedAccountEnvOverride'), + accountSwitchInProgress: (_context, _fact, _surface, say) => say('accountSwitchInProgress'), + managedAccountUnsupported: (context, _fact, _surface, say) => + `${say('managedAccountUnsupported')} ${say( + context.retryControl + ? 'chooseClaudeAccount' + : context.command === 'clear' + ? 'chooseClaudeAccountThenRunClear' + : 'chooseClaudeAccountThenSend' + )}`, + providerExited: (context, _fact, surface, say) => + say(surface === 'row' ? 'providerExitedRow' : 'providerExitedRejection', agent(say, context)), + restartFailed: couldNot('couldNotRestart'), + providerRejected: (_context, fact, _surface, say) => + quotingPersonDetail(say, 'providerRejected', 'providerRejectedQuoted', fact.detail), + attachmentInvalid: (context, fact, _surface, say) => fact.attachment - ? ATTACHMENT_SENTENCES[fact.attachment.reason](context, fact.attachment) - : "An attachment on this message can't be sent to the agent.", - attachmentUnreadable: () => `An attachment on this message couldn't be read, ${NOT_SENT}`, - emptyMessage: () => 'This message is empty, so it was not sent.', - queueFull: () => 'Too many messages were waiting for the agent, so this one was not sent.', - writeFailed: () => "Orca couldn't hand this message to the agent, so it was not sent.", - 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: ({ 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: ({ 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 }) => - retry?.error === 'rate_limit' || retry?.status === 429 - ? `${agentName ?? 'The agent'} is rate-limited and retrying.` - : `${agentName ?? 'The agent'} hit a temporary problem and is retrying.` + ? ATTACHMENT_SENTENCES[fact.attachment.reason](say, context, fact.attachment) + : say('attachmentInvalid'), + attachmentUnreadable: (_context, _fact, _surface, say) => say('attachmentUnreadable'), + emptyMessage: (_context, _fact, _surface, say) => say('emptyMessage'), + queueFull: (_context, _fact, _surface, say) => say('queueFull'), + writeFailed: (_context, _fact, _surface, say) => say('writeFailed'), + cancelled: (_context, _fact, _surface, say) => say('cancelled'), + chatClosed: (_context, _fact, _surface, say) => say('chatClosed'), + hostRestarted: (_context, _fact, _surface, say) => say('hostRestarted'), + notDelivered: ({ retryControl }, _fact, _surface, say) => + say(retryControl ? 'notDelivered' : 'notDeliveredSendAgain'), + compactionFailed: (_context, fact, _surface, say) => + quotingPersonDetail(say, 'compactionFailed', 'compactionFailedQuoted', fact.detail), + compactionUnconfirmed: (_context, _fact, _surface, say) => say('compactionUnconfirmed'), + cancelUnconfirmed: (_context, _fact, _surface, say) => say('cancelUnconfirmed'), + answerUnconfirmed: (_context, _fact, _surface, say) => say('answerUnconfirmed'), + hostFault: ({ retryControl }, _fact, _surface, say) => + say(retryControl ? 'hostFault' : 'hostFaultTryAgain'), + hostStopped: (context, _fact, _surface, say) => say('hostStopped', agent(say, context)), + providerRetrying: (context, { retry }, _surface, say) => + say( + retry?.error === 'rate_limit' || retry?.status === 429 + ? 'providerRateLimited' + : 'providerRetrying', + agent(say, context) + ) } satisfies Record /** The sentence a person reads for this fact on this surface; never a marker. */ export function agentSessionFailureSentence( fact: AgentSessionFailureFact, surface: AgentSessionFailureSurface, - context: AgentSessionFailureWordsContext = {} + context: AgentSessionFailureWordsContext = {}, + /** Desktop passes its translations; the host and the phone keep English. */ + say: AgentSessionFailureSay = sayAgentSessionFailureEnglish ): string { const sentence: Sentence = FAILURE_SENTENCES[fact.kind] - return sentence(context, fact, surface) + return sentence(context, fact, surface, say) } /** The markers released clients hide, for the rejections that had one before rows carried a fact. diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts index d0c36e27a407..8db0723559e8 100644 --- a/src/shared/agent-session-write-notice-copy.ts +++ b/src/shared/agent-session-write-notice-copy.ts @@ -4,7 +4,7 @@ import { QUIT_TERMINAL_AGENT, START_NEW_CHAT, TERMINAL_AGENT_HOLDS_CHAT -} from './agent-session-failure-words' +} from './agent-session-failure-copy' /** Every sentence a notice is made of. Desktop translates each whole sentence with this as its * fallback; mobile shows it as is. */ From 6ab9b24610091f57320f997fed737156089f90c1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:12:58 -0700 Subject: [PATCH 28/52] fix(native-chat): translate the failure sentences desktop notices show A refused start and a rejected message now carry their failure fact to the notice instead of its English sentence, and desktop words that fact through translate keys whose English defaults are the host's own pieces. The host keeps writing English into rows and reasons, and a host sentence with no fact beside it still shows as written. --- .../agent-session-failure-words-text.test.ts | 153 +++++++++++++ .../agent-session-failure-words-text.ts | 202 ++++++++++++++++++ .../agent-session-write-notice-text.ts | 17 +- .../src/i18n/en-runtime-required.json | 53 +++++ src/renderer/src/i18n/locales/en.json | 53 +++++ src/renderer/src/i18n/locales/es.json | 53 +++++ src/renderer/src/i18n/locales/fr.json | 53 +++++ src/renderer/src/i18n/locales/ja.json | 53 +++++ src/renderer/src/i18n/locales/ko.json | 53 +++++ src/renderer/src/i18n/locales/zh.json | 53 +++++ .../agent-session-refusal-notice.test.ts | 109 +++++----- src/shared/agent-session-refusal-notice.ts | 11 +- src/shared/agent-session-write-notice-copy.ts | 19 +- ...ructured-agent-session-send-disposition.ts | 7 +- 14 files changed, 829 insertions(+), 60 deletions(-) create mode 100644 src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts create mode 100644 src/renderer/src/components/native-chat/agent-session-failure-words-text.ts diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts new file mode 100644 index 000000000000..23e3034bf589 --- /dev/null +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts @@ -0,0 +1,153 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { i18n, translate } from '@/i18n/i18n' +import type * as I18nModule from '@/i18n/i18n' +import en from '@/i18n/locales/en.json' +import { + AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS, + AGENT_SESSION_FAILURE_KINDS, + type AgentSessionFailureFact +} from '../../../../shared/agent-session-failure' +import { + AGENT_SESSION_FAILURE_COPY, + sayAgentSessionFailureEnglish, + type AgentSessionFailureCopyId +} from '../../../../shared/agent-session-failure-copy' +import { + agentSessionFailureSentence, + type AgentSessionFailureWordsContext +} from '../../../../shared/agent-session-failure-words' +import { AGENT_SESSION_WRITE_NOTICE_COPY } from '../../../../shared/agent-session-write-notice-copy' +import { agentSessionWriteNoticeParts } from '../../../../shared/agent-session-refusal-notice' +import { agentSessionRefusalFailure } from '../../../../shared/agent-session-write-failure' +import { structuredAgentSessionRejectionParts } from '../../../../shared/structured-agent-session-send-disposition' +import { sayAgentSessionFailureTranslated } from './agent-session-failure-words-text' +import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' + +vi.mock('@/i18n/i18n', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, translate: vi.fn(actual.translate) } +}) + +const IDS = Object.keys(AGENT_SESSION_FAILURE_COPY).filter( + (id): id is AgentSessionFailureCopyId => id in AGENT_SESSION_FAILURE_COPY +) +// Said by a refusal notice too, so they keep the notice's keys. +const NOTICE_PIECES = new Set([ + 'terminalAgentHoldsChat', + 'quitTerminalAgent', + 'startNewChat' +]) +const VALUES = { agent: 'Claude', detail: 'Image type .bmp', limit: '20', size: '5 MB' } + +function factsFor(kind: AgentSessionFailureFact['kind']): AgentSessionFailureFact[] { + const facts: AgentSessionFailureFact[] = [ + { kind }, + { kind, detail: { text: 'Context window exceeded.', audience: 'person' } }, + { kind, detail: { text: 'exit 1', audience: 'log' } }, + { kind, refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } } }, + { kind, refusal: { code: 'structured_agent_session_unsupported' } }, + { kind, retry: { error: 'rate_limit', status: 429 } } + ] + for (const reason of AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS) { + facts.push({ kind, attachment: { reason } }, { kind, attachment: { reason, limit: 20 } }) + } + return facts +} + +const CONTEXTS: AgentSessionFailureWordsContext[] = [ + {}, + { agentName: 'Codex' }, + { agentName: 'Claude', command: 'clear' }, + { retryControl: true }, + { agentName: 'Claude', command: 'clear', retryControl: true } +] + +afterEach(async () => { + await i18n.changeLanguage('en') +}) + +describe('desktop words for a failure fact', () => { + it('has a key for every piece, whose English default is the shared sentence', () => { + for (const id of IDS) { + expect([id, sayAgentSessionFailureTranslated(id, VALUES)]).toEqual([ + id, + sayAgentSessionFailureEnglish(id, VALUES) + ]) + } + }) + + // The catalog answers first in English, so read the default each key is given directly. + it('gives each key the shared sentence as its English default', () => { + for (const id of IDS) { + vi.mocked(translate).mockClear() + sayAgentSessionFailureTranslated(id, VALUES) + const section = NOTICE_PIECES.has(id) ? 'writeNotice' : 'failureWords' + expect(vi.mocked(translate).mock.calls.map(([key, fallback]) => [key, fallback])).toEqual([ + [`components.native-chat.${section}.${id}`, AGENT_SESSION_FAILURE_COPY[id]] + ]) + } + }) + + it('keeps the English catalog in step with the shared copy', () => { + const own = Object.fromEntries( + Object.entries(AGENT_SESSION_FAILURE_COPY).filter(([id]) => !NOTICE_PIECES.has(id)) + ) + expect(en.components['native-chat'].failureWords).toEqual(own) + for (const id of NOTICE_PIECES) { + expect(AGENT_SESSION_WRITE_NOTICE_COPY).toHaveProperty(id, AGENT_SESSION_FAILURE_COPY[id]) + } + }) + + it('says every sentence exactly as the host writes it, in English', () => { + for (const kind of AGENT_SESSION_FAILURE_KINDS) { + for (const fact of factsFor(kind)) { + for (const surface of ['row', 'rejection'] as const) { + for (const context of CONTEXTS) { + expect( + agentSessionFailureSentence(fact, surface, context, sayAgentSessionFailureTranslated) + ).toBe(agentSessionFailureSentence(fact, surface, context)) + } + } + } + } + }) + + it("words a refused start and a rejected message in the reader's language", async () => { + await i18n.changeLanguage('fr') + const refused = agentSessionRefusalFailure({ + code: 'agent_session_operation_invalid', + details: { reason: 'notSignedIn' } + }) + expect( + agentSessionWriteNoticeText( + agentSessionWriteNoticeParts(refused, 'send', { agentName: 'Claude' }) + ) + ).toBe( + "Votre message n'a pas été envoyé. Claude n'est pas connecté avec le compte sélectionné. Connectez-vous, puis renvoyez votre message." + ) + const rejected = structuredAgentSessionRejectionParts( + 'The provider did not accept this message: Uses {{agent}}.', + 'send', + { kind: 'providerRejected', detail: { text: 'Uses {{agent}}', audience: 'person' } }, + { agentName: 'Codex' } + ) + // The provider's own words stay as written, placeholders and all. + expect(agentSessionWriteNoticeText(rejected)).toBe( + "Le fournisseur n'a pas accepté ce message : Uses {{agent}}." + ) + expect( + agentSessionWriteNoticeText( + structuredAgentSessionRejectionParts(null, 'send', { kind: 'providerExited' }, {}) + ) + ).toBe("L'agent s'est arrêté avant l'envoi de ce message.") + }) + + it('shows a host sentence with no fact beside it as written', async () => { + await i18n.changeLanguage('fr') + expect( + agentSessionWriteNoticeText( + structuredAgentSessionRejectionParts('Claude does not support .bmp images', 'send') + ) + ).toBe('Claude does not support .bmp images') + }) +}) diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts new file mode 100644 index 000000000000..e1e9edd6342e --- /dev/null +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -0,0 +1,202 @@ +// Desktop words for a failure fact: each piece of the shared sentence translated whole, with the +// shared English as its fallback, so the host's row and desktop's notice never say it differently. + +import { translate } from '@/i18n/i18n' +import { + AGENT_SESSION_FAILURE_COPY as COPY, + type AgentSessionFailureCopyId, + type AgentSessionFailureCopyValues, + type AgentSessionFailureSay +} from '../../../../shared/agent-session-failure-copy' + +// The pieces a refusal notice says too keep the notice's keys, so each has one translation. +const PIECES: Record string> = + { + theAgent: () => translate('components.native-chat.failureWords.theAgent', COPY.theAgent), + providerStartFailed: (values) => + translate( + 'components.native-chat.failureWords.providerStartFailed', + COPY.providerStartFailed, + values + ), + runClearAgain: () => + translate('components.native-chat.failureWords.runClearAgain', COPY.runClearAgain), + sendToTryAgain: () => + translate('components.native-chat.failureWords.sendToTryAgain', COPY.sendToTryAgain), + couldNotStart: (values) => + translate('components.native-chat.failureWords.couldNotStart', COPY.couldNotStart, values), + couldNotRestart: (values) => + translate( + 'components.native-chat.failureWords.couldNotRestart', + COPY.couldNotRestart, + values + ), + terminalAgentHoldsChat: () => + translate( + 'components.native-chat.writeNotice.terminalAgentHoldsChat', + COPY.terminalAgentHoldsChat + ), + quitTerminalAgent: () => + translate('components.native-chat.writeNotice.quitTerminalAgent', COPY.quitTerminalAgent), + startNewChat: () => + translate('components.native-chat.writeNotice.startNewChat', COPY.startNewChat), + notSignedIn: (values) => + translate('components.native-chat.failureWords.notSignedIn', COPY.notSignedIn, values), + signInFirst: () => + translate('components.native-chat.failureWords.signInFirst', COPY.signInFirst), + signInThenRunClear: () => + translate('components.native-chat.failureWords.signInThenRunClear', COPY.signInThenRunClear), + signInThenSend: () => + translate('components.native-chat.failureWords.signInThenSend', COPY.signInThenSend), + historyTooLarge: () => + translate('components.native-chat.failureWords.historyTooLarge', COPY.historyTooLarge), + managedAccountEnvOverride: () => + translate( + 'components.native-chat.failureWords.managedAccountEnvOverride', + COPY.managedAccountEnvOverride + ), + accountSwitchInProgress: () => + translate( + 'components.native-chat.failureWords.accountSwitchInProgress', + COPY.accountSwitchInProgress + ), + managedAccountUnsupported: () => + translate( + 'components.native-chat.failureWords.managedAccountUnsupported', + COPY.managedAccountUnsupported + ), + chooseClaudeAccount: () => + translate( + 'components.native-chat.failureWords.chooseClaudeAccount', + COPY.chooseClaudeAccount + ), + chooseClaudeAccountThenRunClear: () => + translate( + 'components.native-chat.failureWords.chooseClaudeAccountThenRunClear', + COPY.chooseClaudeAccountThenRunClear + ), + chooseClaudeAccountThenSend: () => + translate( + 'components.native-chat.failureWords.chooseClaudeAccountThenSend', + COPY.chooseClaudeAccountThenSend + ), + providerExitedRow: (values) => + translate( + 'components.native-chat.failureWords.providerExitedRow', + COPY.providerExitedRow, + values + ), + providerExitedRejection: (values) => + translate( + 'components.native-chat.failureWords.providerExitedRejection', + COPY.providerExitedRejection, + values + ), + providerRejected: () => + translate('components.native-chat.failureWords.providerRejected', COPY.providerRejected), + providerRejectedQuoted: (values) => + translate( + 'components.native-chat.failureWords.providerRejectedQuoted', + COPY.providerRejectedQuoted, + values + ), + attachmentEmpty: () => + translate('components.native-chat.failureWords.attachmentEmpty', COPY.attachmentEmpty), + attachmentTooLarge: () => + translate('components.native-chat.failureWords.attachmentTooLarge', COPY.attachmentTooLarge), + attachmentLargerThan: (values) => + translate( + 'components.native-chat.failureWords.attachmentLargerThan', + COPY.attachmentLargerThan, + values + ), + attachmentTooMany: () => + translate('components.native-chat.failureWords.attachmentTooMany', COPY.attachmentTooMany), + attachmentAtMost: (values) => + translate( + 'components.native-chat.failureWords.attachmentAtMost', + COPY.attachmentAtMost, + values + ), + attachmentTotalTooLarge: () => + translate( + 'components.native-chat.failureWords.attachmentTotalTooLarge', + COPY.attachmentTotalTooLarge + ), + attachmentTotalMoreThan: (values) => + translate( + 'components.native-chat.failureWords.attachmentTotalMoreThan', + COPY.attachmentTotalMoreThan, + values + ), + attachmentUnsupportedType: (values) => + translate( + 'components.native-chat.failureWords.attachmentUnsupportedType', + COPY.attachmentUnsupportedType, + values + ), + attachmentNotAFile: () => + translate('components.native-chat.failureWords.attachmentNotAFile', COPY.attachmentNotAFile), + attachmentNoSource: () => + translate('components.native-chat.failureWords.attachmentNoSource', COPY.attachmentNoSource), + attachmentInvalid: () => + translate('components.native-chat.failureWords.attachmentInvalid', COPY.attachmentInvalid), + attachmentUnreadable: () => + translate( + 'components.native-chat.failureWords.attachmentUnreadable', + COPY.attachmentUnreadable + ), + emptyMessage: () => + translate('components.native-chat.failureWords.emptyMessage', COPY.emptyMessage), + queueFull: () => translate('components.native-chat.failureWords.queueFull', COPY.queueFull), + writeFailed: () => + translate('components.native-chat.failureWords.writeFailed', COPY.writeFailed), + cancelled: () => translate('components.native-chat.failureWords.cancelled', COPY.cancelled), + chatClosed: () => translate('components.native-chat.failureWords.chatClosed', COPY.chatClosed), + hostRestarted: () => + translate('components.native-chat.failureWords.hostRestarted', COPY.hostRestarted), + notDelivered: () => + translate('components.native-chat.failureWords.notDelivered', COPY.notDelivered), + notDeliveredSendAgain: () => + translate( + 'components.native-chat.failureWords.notDeliveredSendAgain', + COPY.notDeliveredSendAgain + ), + compactionFailed: () => + translate('components.native-chat.failureWords.compactionFailed', COPY.compactionFailed), + compactionFailedQuoted: (values) => + translate( + 'components.native-chat.failureWords.compactionFailedQuoted', + COPY.compactionFailedQuoted, + values + ), + compactionUnconfirmed: () => + translate( + 'components.native-chat.failureWords.compactionUnconfirmed', + COPY.compactionUnconfirmed + ), + cancelUnconfirmed: () => + translate('components.native-chat.failureWords.cancelUnconfirmed', COPY.cancelUnconfirmed), + answerUnconfirmed: () => + translate('components.native-chat.failureWords.answerUnconfirmed', COPY.answerUnconfirmed), + hostFault: () => translate('components.native-chat.failureWords.hostFault', COPY.hostFault), + hostFaultTryAgain: () => + translate('components.native-chat.failureWords.hostFaultTryAgain', COPY.hostFaultTryAgain), + hostStopped: (values) => + translate('components.native-chat.failureWords.hostStopped', COPY.hostStopped, values), + providerRateLimited: (values) => + translate( + 'components.native-chat.failureWords.providerRateLimited', + COPY.providerRateLimited, + values + ), + providerRetrying: (values) => + translate( + 'components.native-chat.failureWords.providerRetrying', + COPY.providerRetrying, + values + ) + } + +export const sayAgentSessionFailureTranslated: AgentSessionFailureSay = (id, values = {}) => + PIECES[id](values) diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts index 812197d6e336..dfd29e8fcbff 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts @@ -2,6 +2,7 @@ // shared English as its fallback so desktop and mobile never say it differently. import { translate } from '@/i18n/i18n' +import { agentSessionFailureSentence } from '../../../../shared/agent-session-failure-words' import { agentSessionWriteNoticeParts } from '../../../../shared/agent-session-refusal-notice' import { AGENT_SESSION_WRITE_NOTICE_COPY as COPY, @@ -12,6 +13,7 @@ import type { AgentSessionWriteFailure, AgentSessionWriteKind } from '../../../../shared/agent-session-write-failure' +import { sayAgentSessionFailureTranslated } from './agent-session-failure-words-text' const SENTENCES: Record string> = { notDoneReadHistory: () => @@ -113,7 +115,20 @@ const SENTENCES: Record string> = { } export function agentSessionWriteNoticeText(parts: readonly AgentSessionWriteNoticePart[]): string { - return parts.map((part) => (typeof part === 'string' ? SENTENCES[part]() : part.text)).join(' ') + return parts + .map((part) => + typeof part === 'string' + ? SENTENCES[part]() + : 'text' in part + ? part.text + : agentSessionFailureSentence( + part.failure, + part.surface, + part.context, + sayAgentSessionFailureTranslated + ) + ) + .join(' ') } export function agentSessionWriteFailureText( diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 57703fb7d1f4..a0ddc43e96ed 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2667,6 +2667,59 @@ "effort": "Effort", "imageUnsupported": "Image paste is not supported for this agent." }, + "failureWords": { + "accountSwitchInProgress": "A Claude account switch is in progress. Try again after it finishes.", + "answerUnconfirmed": "Your answer was recorded but the agent did not confirm it.", + "attachmentAtMost": "{{agent}} accepts at most {{limit}} images in one message, so this message was not sent.", + "attachmentEmpty": "An image on this message is empty, so the message was not sent.", + "attachmentInvalid": "An attachment on this message can't be sent to the agent.", + "attachmentLargerThan": "An image on this message is larger than {{size}}, so the message was not sent.", + "attachmentNoSource": "An image on this message has no file to send, so the message was not sent.", + "attachmentNotAFile": "An image on this message isn't a file, so the message was not sent.", + "attachmentTooLarge": "An image on this message is too large, so the message was not sent.", + "attachmentTooMany": "This message has too many images, so it was not sent.", + "attachmentTotalMoreThan": "The images on this message add up to more than {{size}}, so the message was not sent.", + "attachmentTotalTooLarge": "The images on this message are too large together, so the message was not sent.", + "attachmentUnreadable": "An attachment on this message couldn't be read, so the message was not sent.", + "attachmentUnsupportedType": "{{agent}} accepts only PNG, JPEG, GIF, and WebP images, so this message was not sent.", + "cancelUnconfirmed": "Cancellation was not confirmed.", + "cancelled": "This message was withdrawn before the agent started it.", + "chatClosed": "The chat closed before this message was sent.", + "chooseClaudeAccount": "Choose or add one in Claude Accounts settings.", + "chooseClaudeAccountThenRunClear": "Choose or add one in Claude Accounts settings, then run /clear again.", + "chooseClaudeAccountThenSend": "Choose or add one in Claude Accounts settings, then send your message again.", + "compactionFailed": "Compaction failed.", + "compactionFailedQuoted": "Compaction failed: {{detail}}.", + "compactionUnconfirmed": "Compaction completion is unconfirmed.", + "couldNotRestart": "{{agent}} couldn't restart.", + "couldNotStart": "{{agent}} couldn't start.", + "emptyMessage": "This message is empty, so it was not sent.", + "historyTooLarge": "This conversation's history is too large to restore here.", + "hostFault": "Orca ran into a problem, so this didn't go through.", + "hostFaultTryAgain": "Orca ran into a problem, so this didn't go through. Try again.", + "hostRestarted": "Orca restarted before this message was sent.", + "hostStopped": "{{agent}} never finished starting, so Orca stopped it.", + "managedAccountEnvOverride": "This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.", + "managedAccountUnsupported": "While a Claude account is added in WSL, Claude chats need a Windows Claude account.", + "notDelivered": "This message was not delivered.", + "notDeliveredSendAgain": "This message was not delivered. Send it again to continue.", + "notSignedIn": "{{agent}} is not signed in for the selected account.", + "providerExitedRejection": "{{agent}} stopped before this message was sent.", + "providerExitedRow": "{{agent}} stopped while this response was in progress. You can continue in this conversation.", + "providerRateLimited": "{{agent}} is rate-limited and retrying.", + "providerRejected": "The provider did not accept this message.", + "providerRejectedQuoted": "The provider did not accept this message: {{detail}}.", + "providerRetrying": "{{agent}} hit a temporary problem and is retrying.", + "providerStartFailed": "{{agent}} stopped before it finished starting.", + "queueFull": "Too many messages were waiting for the agent, so this one was not sent.", + "runClearAgain": "Run /clear again.", + "sendToTryAgain": "Send your message to try again.", + "signInFirst": "Sign in first.", + "signInThenRunClear": "Sign in, then run /clear again.", + "signInThenSend": "Sign in, then send your message again.", + "theAgent": "The agent", + "writeFailed": "Orca couldn't hand this message to the agent, so it was not sent." + }, "question": { "other": "Other…" }, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 03f9e72b8732..159d01435f4d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17476,6 +17476,59 @@ "empty": "No users found" }, "native-chat": { + "failureWords": { + "theAgent": "The agent", + "providerStartFailed": "{{agent}} stopped before it finished starting.", + "runClearAgain": "Run /clear again.", + "sendToTryAgain": "Send your message to try again.", + "couldNotStart": "{{agent}} couldn't start.", + "couldNotRestart": "{{agent}} couldn't restart.", + "notSignedIn": "{{agent}} is not signed in for the selected account.", + "signInFirst": "Sign in first.", + "signInThenRunClear": "Sign in, then run /clear again.", + "signInThenSend": "Sign in, then send your message again.", + "historyTooLarge": "This conversation's history is too large to restore here.", + "managedAccountEnvOverride": "This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.", + "accountSwitchInProgress": "A Claude account switch is in progress. Try again after it finishes.", + "managedAccountUnsupported": "While a Claude account is added in WSL, Claude chats need a Windows Claude account.", + "chooseClaudeAccount": "Choose or add one in Claude Accounts settings.", + "chooseClaudeAccountThenRunClear": "Choose or add one in Claude Accounts settings, then run /clear again.", + "chooseClaudeAccountThenSend": "Choose or add one in Claude Accounts settings, then send your message again.", + "providerExitedRow": "{{agent}} stopped while this response was in progress. You can continue in this conversation.", + "providerExitedRejection": "{{agent}} stopped before this message was sent.", + "providerRejected": "The provider did not accept this message.", + "providerRejectedQuoted": "The provider did not accept this message: {{detail}}.", + "attachmentEmpty": "An image on this message is empty, so the message was not sent.", + "attachmentTooLarge": "An image on this message is too large, so the message was not sent.", + "attachmentLargerThan": "An image on this message is larger than {{size}}, so the message was not sent.", + "attachmentTooMany": "This message has too many images, so it was not sent.", + "attachmentAtMost": "{{agent}} accepts at most {{limit}} images in one message, so this message was not sent.", + "attachmentTotalTooLarge": "The images on this message are too large together, so the message was not sent.", + "attachmentTotalMoreThan": "The images on this message add up to more than {{size}}, so the message was not sent.", + "attachmentUnsupportedType": "{{agent}} accepts only PNG, JPEG, GIF, and WebP images, so this message was not sent.", + "attachmentNotAFile": "An image on this message isn't a file, so the message was not sent.", + "attachmentNoSource": "An image on this message has no file to send, so the message was not sent.", + "attachmentInvalid": "An attachment on this message can't be sent to the agent.", + "attachmentUnreadable": "An attachment on this message couldn't be read, so the message was not sent.", + "emptyMessage": "This message is empty, so it was not sent.", + "queueFull": "Too many messages were waiting for the agent, so this one was not sent.", + "writeFailed": "Orca couldn't hand this message to the agent, so it was not sent.", + "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.", + "notDeliveredSendAgain": "This message was not delivered. Send it again to continue.", + "compactionFailed": "Compaction failed.", + "compactionFailedQuoted": "Compaction failed: {{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.", + "hostFaultTryAgain": "Orca ran into a problem, so this didn't go through. Try again.", + "hostStopped": "{{agent}} never finished starting, so Orca stopped it.", + "providerRateLimited": "{{agent}} is rate-limited and retrying.", + "providerRetrying": "{{agent}} hit a temporary problem and is retrying." + }, "writeNotice": { "notDoneReadHistory": "This chat's history couldn't be loaded.", "notDoneSend": "Your message was not sent.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 59c8d6504b70..882fb719cdb3 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14927,6 +14927,59 @@ }, "components": { "native-chat": { + "failureWords": { + "theAgent": "El agente", + "providerStartFailed": "{{agent}} se detuvo antes de terminar de iniciarse.", + "runClearAgain": "Vuelva a ejecutar /clear.", + "sendToTryAgain": "Envíe su mensaje para volver a intentarlo.", + "couldNotStart": "{{agent}} no pudo iniciarse.", + "couldNotRestart": "{{agent}} no pudo reiniciarse.", + "notSignedIn": "{{agent}} no tiene la sesión iniciada con la cuenta seleccionada.", + "signInFirst": "Inicie sesión primero.", + "signInThenRunClear": "Inicie sesión y vuelva a ejecutar /clear.", + "signInThenSend": "Inicie sesión y vuelva a enviar su mensaje.", + "historyTooLarge": "El historial de esta conversación es demasiado grande para restaurarlo aquí.", + "managedAccountEnvOverride": "Este inicio de Claude define sus propias variables de inicio de sesión de Anthropic. Quítelas para usar una cuenta de Claude administrada.", + "accountSwitchInProgress": "Hay un cambio de cuenta de Claude en curso. Vuelva a intentarlo cuando termine.", + "managedAccountUnsupported": "Mientras haya una cuenta de Claude agregada en WSL, los chats de Claude necesitan una cuenta de Claude de Windows.", + "chooseClaudeAccount": "Elija o agregue una en la configuración de Cuentas de Claude.", + "chooseClaudeAccountThenRunClear": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a ejecutar /clear.", + "chooseClaudeAccountThenSend": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a enviar su mensaje.", + "providerExitedRow": "{{agent}} se detuvo mientras esta respuesta estaba en curso. Puede continuar en esta conversación.", + "providerExitedRejection": "{{agent}} se detuvo antes de que se enviara este mensaje.", + "providerRejected": "El proveedor no aceptó este mensaje.", + "providerRejectedQuoted": "El proveedor no aceptó este mensaje: {{detail}}.", + "attachmentEmpty": "Una imagen de este mensaje está vacía, así que el mensaje no se envió.", + "attachmentTooLarge": "Una imagen de este mensaje es demasiado grande, así que el mensaje no se envió.", + "attachmentLargerThan": "Una imagen de este mensaje supera los {{size}}, así que el mensaje no se envió.", + "attachmentTooMany": "Este mensaje tiene demasiadas imágenes, así que no se envió.", + "attachmentAtMost": "{{agent}} acepta como máximo {{limit}} imágenes por mensaje, así que este mensaje no se envió.", + "attachmentTotalTooLarge": "Las imágenes de este mensaje son demasiado grandes en conjunto, así que el mensaje no se envió.", + "attachmentTotalMoreThan": "Las imágenes de este mensaje suman más de {{size}}, así que el mensaje no se envió.", + "attachmentUnsupportedType": "{{agent}} solo acepta imágenes PNG, JPEG, GIF y WebP, así que este mensaje no se envió.", + "attachmentNotAFile": "Una imagen de este mensaje no es un archivo, así que el mensaje no se envió.", + "attachmentNoSource": "Una imagen de este mensaje no tiene ningún archivo que enviar, así que el mensaje no se envió.", + "attachmentInvalid": "Un archivo adjunto de este mensaje no se puede enviar al agente.", + "attachmentUnreadable": "No se pudo leer un archivo adjunto de este mensaje, así que el mensaje no se envió.", + "emptyMessage": "Este mensaje está vacío, así que no se envió.", + "queueFull": "Había demasiados mensajes esperando al agente, así que este no se envió.", + "writeFailed": "Orca no pudo entregar este mensaje al agente, así que no se envió.", + "cancelled": "Este mensaje se retiró antes de que el agente lo iniciara.", + "chatClosed": "El chat se cerró antes de que se enviara este mensaje.", + "hostRestarted": "Orca se reinició antes de que se enviara este mensaje.", + "notDelivered": "Este mensaje no se entregó.", + "notDeliveredSendAgain": "Este mensaje no se entregó. Vuelva a enviarlo para continuar.", + "compactionFailed": "La compactación falló.", + "compactionFailedQuoted": "La compactación falló: {{detail}}.", + "compactionUnconfirmed": "No se confirmó que la compactación haya terminado.", + "cancelUnconfirmed": "No se confirmó la cancelación.", + "answerUnconfirmed": "Su respuesta se registró, pero el agente no la confirmó.", + "hostFault": "Orca tuvo un problema, así que esto no se completó.", + "hostFaultTryAgain": "Orca tuvo un problema, así que esto no se completó. Vuelva a intentarlo.", + "hostStopped": "{{agent}} no terminó de iniciarse, así que Orca lo detuvo.", + "providerRateLimited": "{{agent}} alcanzó un límite de uso y está reintentando.", + "providerRetrying": "{{agent}} tuvo un problema temporal y está reintentando." + }, "writeNotice": { "notDoneReadHistory": "No se pudo cargar el historial de este chat.", "notDoneSend": "Su mensaje no se envió.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 2a71ce56d2e8..a286e93ab138 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17410,6 +17410,59 @@ }, "components": { "native-chat": { + "failureWords": { + "theAgent": "L'agent", + "providerStartFailed": "{{agent}} s'est arrêté avant d'avoir fini de démarrer.", + "runClearAgain": "Relancez /clear.", + "sendToTryAgain": "Envoyez votre message pour réessayer.", + "couldNotStart": "{{agent}} n'a pas pu démarrer.", + "couldNotRestart": "{{agent}} n'a pas pu redémarrer.", + "notSignedIn": "{{agent}} n'est pas connecté avec le compte sélectionné.", + "signInFirst": "Connectez-vous d'abord.", + "signInThenRunClear": "Connectez-vous, puis relancez /clear.", + "signInThenSend": "Connectez-vous, puis renvoyez votre message.", + "historyTooLarge": "L'historique de cette conversation est trop volumineux pour être restauré ici.", + "managedAccountEnvOverride": "Ce lancement de Claude définit ses propres variables de connexion Anthropic. Supprimez-les pour utiliser un compte Claude géré.", + "accountSwitchInProgress": "Un changement de compte Claude est en cours. Réessayez une fois qu'il sera terminé.", + "managedAccountUnsupported": "Tant qu'un compte Claude est ajouté dans WSL, les chats Claude nécessitent un compte Claude Windows.", + "chooseClaudeAccount": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude.", + "chooseClaudeAccountThenRunClear": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis relancez /clear.", + "chooseClaudeAccountThenSend": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis renvoyez votre message.", + "providerExitedRow": "{{agent}} s'est arrêté pendant que cette réponse était en cours. Vous pouvez continuer dans cette conversation.", + "providerExitedRejection": "{{agent}} s'est arrêté avant l'envoi de ce message.", + "providerRejected": "Le fournisseur n'a pas accepté ce message.", + "providerRejectedQuoted": "Le fournisseur n'a pas accepté ce message : {{detail}}.", + "attachmentEmpty": "Une image de ce message est vide, le message n'a donc pas été envoyé.", + "attachmentTooLarge": "Une image de ce message est trop volumineuse, le message n'a donc pas été envoyé.", + "attachmentLargerThan": "Une image de ce message dépasse {{size}}, le message n'a donc pas été envoyé.", + "attachmentTooMany": "Ce message contient trop d'images, il n'a donc pas été envoyé.", + "attachmentAtMost": "{{agent}} accepte au maximum {{limit}} images par message, ce message n'a donc pas été envoyé.", + "attachmentTotalTooLarge": "Les images de ce message sont trop volumineuses au total, le message n'a donc pas été envoyé.", + "attachmentTotalMoreThan": "Les images de ce message dépassent {{size}} au total, le message n'a donc pas été envoyé.", + "attachmentUnsupportedType": "{{agent}} n'accepte que les images PNG, JPEG, GIF et WebP, ce message n'a donc pas été envoyé.", + "attachmentNotAFile": "Une image de ce message n'est pas un fichier, le message n'a donc pas été envoyé.", + "attachmentNoSource": "Une image de ce message n'a aucun fichier à envoyer, le message n'a donc pas été envoyé.", + "attachmentInvalid": "Une pièce jointe de ce message ne peut pas être envoyée à l'agent.", + "attachmentUnreadable": "Une pièce jointe de ce message n'a pas pu être lue, le message n'a donc pas été envoyé.", + "emptyMessage": "Ce message est vide, il n'a donc pas été envoyé.", + "queueFull": "Trop de messages attendaient l'agent, celui-ci n'a donc pas été envoyé.", + "writeFailed": "Orca n'a pas pu transmettre ce message à l'agent, il n'a donc pas été envoyé.", + "cancelled": "Ce message a été retiré avant que l'agent ne le commence.", + "chatClosed": "Le chat s'est fermé avant l'envoi de ce message.", + "hostRestarted": "Orca a redémarré avant l'envoi de ce message.", + "notDelivered": "Ce message n'a pas été remis.", + "notDeliveredSendAgain": "Ce message n'a pas été remis. Renvoyez-le pour continuer.", + "compactionFailed": "La compaction a échoué.", + "compactionFailedQuoted": "La compaction a échoué : {{detail}}.", + "compactionUnconfirmed": "La fin de la compaction n'est pas confirmée.", + "cancelUnconfirmed": "L'annulation n'a pas été confirmée.", + "answerUnconfirmed": "Votre réponse a été enregistrée, mais l'agent ne l'a pas confirmée.", + "hostFault": "Orca a rencontré un problème, l'opération n'a donc pas abouti.", + "hostFaultTryAgain": "Orca a rencontré un problème, l'opération n'a donc pas abouti. Réessayez.", + "hostStopped": "{{agent}} n'a jamais fini de démarrer, Orca l'a donc arrêté.", + "providerRateLimited": "{{agent}} est limité en débit et réessaie.", + "providerRetrying": "{{agent}} a rencontré un problème temporaire et réessaie." + }, "writeNotice": { "notDoneReadHistory": "L'historique de ce chat n'a pas pu être chargé.", "notDoneSend": "Votre message n'a pas été envoyé.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index aeb72f2791b6..e25bf8bbd523 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17346,6 +17346,59 @@ } }, "native-chat": { + "failureWords": { + "theAgent": "Agent", + "providerStartFailed": "{{agent}} は起動が完了する前に停止しました。", + "runClearAgain": "/clear をもう一度実行してください。", + "sendToTryAgain": "もう一度試すには、メッセージを送信してください。", + "couldNotStart": "{{agent}} を起動できませんでした。", + "couldNotRestart": "{{agent}} を再起動できませんでした。", + "notSignedIn": "{{agent}} は選択したアカウントでサインインしていません。", + "signInFirst": "先にサインインしてください。", + "signInThenRunClear": "サインインしてから、/clear をもう一度実行してください。", + "signInThenSend": "サインインしてから、メッセージをもう一度送信してください。", + "historyTooLarge": "この会話の履歴は大きすぎるため、ここでは復元できません。", + "managedAccountEnvOverride": "この Claude の起動では独自の Anthropic サインイン変数が設定されています。管理対象の Claude アカウントを使用するには、それらを削除してください。", + "accountSwitchInProgress": "Claude アカウントの切り替え中です。完了してからもう一度お試しください。", + "managedAccountUnsupported": "WSL に Claude アカウントが追加されている間、Claude チャットには Windows の Claude アカウントが必要です。", + "chooseClaudeAccount": "Claude アカウントの設定で選択または追加してください。", + "chooseClaudeAccountThenRunClear": "Claude アカウントの設定で選択または追加してから、/clear をもう一度実行してください。", + "chooseClaudeAccountThenSend": "Claude アカウントの設定で選択または追加してから、メッセージをもう一度送信してください。", + "providerExitedRow": "この応答の途中で {{agent}} が停止しました。この会話で続けることができます。", + "providerExitedRejection": "このメッセージが送信される前に {{agent}} が停止しました。", + "providerRejected": "プロバイダーはこのメッセージを受け付けませんでした。", + "providerRejectedQuoted": "プロバイダーはこのメッセージを受け付けませんでした: {{detail}}。", + "attachmentEmpty": "このメッセージの画像が空のため、メッセージは送信されませんでした。", + "attachmentTooLarge": "このメッセージの画像が大きすぎるため、メッセージは送信されませんでした。", + "attachmentLargerThan": "このメッセージの画像が {{size}} を超えているため、メッセージは送信されませんでした。", + "attachmentTooMany": "このメッセージには画像が多すぎるため、送信されませんでした。", + "attachmentAtMost": "{{agent}} が 1 つのメッセージで受け付ける画像は最大 {{limit}} 枚のため、このメッセージは送信されませんでした。", + "attachmentTotalTooLarge": "このメッセージの画像の合計サイズが大きすぎるため、メッセージは送信されませんでした。", + "attachmentTotalMoreThan": "このメッセージの画像の合計が {{size}} を超えているため、メッセージは送信されませんでした。", + "attachmentUnsupportedType": "{{agent}} が受け付けるのは PNG、JPEG、GIF、WebP 画像のみのため、このメッセージは送信されませんでした。", + "attachmentNotAFile": "このメッセージの画像がファイルではないため、メッセージは送信されませんでした。", + "attachmentNoSource": "このメッセージの画像に送信するファイルがないため、メッセージは送信されませんでした。", + "attachmentInvalid": "このメッセージの添付ファイルは Agent に送信できません。", + "attachmentUnreadable": "このメッセージの添付ファイルを読み取れなかったため、メッセージは送信されませんでした。", + "emptyMessage": "このメッセージは空のため、送信されませんでした。", + "queueFull": "Agent を待っているメッセージが多すぎたため、このメッセージは送信されませんでした。", + "writeFailed": "Orca はこのメッセージを Agent に渡せなかったため、送信されませんでした。", + "cancelled": "このメッセージは Agent が処理を始める前に取り消されました。", + "chatClosed": "このメッセージが送信される前にチャットが閉じられました。", + "hostRestarted": "このメッセージが送信される前に Orca が再起動しました。", + "notDelivered": "このメッセージは配信されませんでした。", + "notDeliveredSendAgain": "このメッセージは配信されませんでした。続けるには、もう一度送信してください。", + "compactionFailed": "圧縮に失敗しました。", + "compactionFailedQuoted": "圧縮に失敗しました: {{detail}}。", + "compactionUnconfirmed": "圧縮の完了は確認されていません。", + "cancelUnconfirmed": "キャンセルは確認されませんでした。", + "answerUnconfirmed": "回答は記録されましたが、Agent は確認しませんでした。", + "hostFault": "Orca で問題が発生したため、この操作は完了しませんでした。", + "hostFaultTryAgain": "Orca で問題が発生したため、この操作は完了しませんでした。もう一度お試しください。", + "hostStopped": "{{agent}} の起動が完了しなかったため、Orca が停止しました。", + "providerRateLimited": "{{agent}} はレート制限を受けているため、再試行しています。", + "providerRetrying": "{{agent}} で一時的な問題が発生したため、再試行しています。" + }, "writeNotice": { "notDoneReadHistory": "このチャットの履歴を読み込めませんでした。", "notDoneSend": "メッセージは送信されませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 0c7ce2fc79f0..9f3b67706dc7 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17346,6 +17346,59 @@ } }, "native-chat": { + "failureWords": { + "theAgent": "에이전트", + "providerStartFailed": "{{agent}}이(가) 시작을 마치기 전에 중지되었습니다.", + "runClearAgain": "/clear를 다시 실행하세요.", + "sendToTryAgain": "다시 시도하려면 메시지를 보내세요.", + "couldNotStart": "{{agent}}을(를) 시작하지 못했습니다.", + "couldNotRestart": "{{agent}}을(를) 다시 시작하지 못했습니다.", + "notSignedIn": "{{agent}}이(가) 선택한 계정으로 로그인되어 있지 않습니다.", + "signInFirst": "먼저 로그인하세요.", + "signInThenRunClear": "로그인한 다음 /clear를 다시 실행하세요.", + "signInThenSend": "로그인한 다음 메시지를 다시 보내세요.", + "historyTooLarge": "이 대화의 기록이 너무 커서 여기에서 복원할 수 없습니다.", + "managedAccountEnvOverride": "이 Claude 실행은 자체 Anthropic 로그인 변수를 설정합니다. 관리되는 Claude 계정을 사용하려면 해당 변수를 제거하세요.", + "accountSwitchInProgress": "Claude 계정 전환이 진행 중입니다. 완료된 후 다시 시도하세요.", + "managedAccountUnsupported": "WSL에 Claude 계정이 추가되어 있는 동안에는 Claude 채팅에 Windows Claude 계정이 필요합니다.", + "chooseClaudeAccount": "Claude 계정 설정에서 선택하거나 추가하세요.", + "chooseClaudeAccountThenRunClear": "Claude 계정 설정에서 선택하거나 추가한 다음 /clear를 다시 실행하세요.", + "chooseClaudeAccountThenSend": "Claude 계정 설정에서 선택하거나 추가한 다음 메시지를 다시 보내세요.", + "providerExitedRow": "이 응답이 진행되는 동안 {{agent}}이(가) 중지되었습니다. 이 대화에서 계속할 수 있습니다.", + "providerExitedRejection": "이 메시지가 전송되기 전에 {{agent}}이(가) 중지되었습니다.", + "providerRejected": "공급자가 이 메시지를 받아들이지 않았습니다.", + "providerRejectedQuoted": "공급자가 이 메시지를 받아들이지 않았습니다: {{detail}}.", + "attachmentEmpty": "이 메시지의 이미지가 비어 있어 메시지가 전송되지 않았습니다.", + "attachmentTooLarge": "이 메시지의 이미지가 너무 커서 메시지가 전송되지 않았습니다.", + "attachmentLargerThan": "이 메시지의 이미지가 {{size}}보다 커서 메시지가 전송되지 않았습니다.", + "attachmentTooMany": "이 메시지에 이미지가 너무 많아 전송되지 않았습니다.", + "attachmentAtMost": "{{agent}}은(는) 메시지 하나에 이미지를 최대 {{limit}}개까지 받을 수 있어 이 메시지가 전송되지 않았습니다.", + "attachmentTotalTooLarge": "이 메시지의 이미지 전체 크기가 너무 커서 메시지가 전송되지 않았습니다.", + "attachmentTotalMoreThan": "이 메시지의 이미지 합계가 {{size}}를 넘어 메시지가 전송되지 않았습니다.", + "attachmentUnsupportedType": "{{agent}}은(는) PNG, JPEG, GIF, WebP 이미지만 받을 수 있어 이 메시지가 전송되지 않았습니다.", + "attachmentNotAFile": "이 메시지의 이미지가 파일이 아니어서 메시지가 전송되지 않았습니다.", + "attachmentNoSource": "이 메시지의 이미지에 보낼 파일이 없어 메시지가 전송되지 않았습니다.", + "attachmentInvalid": "이 메시지의 첨부 파일은 에이전트에 보낼 수 없습니다.", + "attachmentUnreadable": "이 메시지의 첨부 파일을 읽을 수 없어 메시지가 전송되지 않았습니다.", + "emptyMessage": "이 메시지가 비어 있어 전송되지 않았습니다.", + "queueFull": "에이전트를 기다리는 메시지가 너무 많아 이 메시지는 전송되지 않았습니다.", + "writeFailed": "Orca가 이 메시지를 에이전트에 전달하지 못해 전송되지 않았습니다.", + "cancelled": "이 메시지는 에이전트가 시작하기 전에 철회되었습니다.", + "chatClosed": "이 메시지가 전송되기 전에 채팅이 닫혔습니다.", + "hostRestarted": "이 메시지가 전송되기 전에 Orca가 다시 시작되었습니다.", + "notDelivered": "이 메시지는 전달되지 않았습니다.", + "notDeliveredSendAgain": "이 메시지는 전달되지 않았습니다. 계속하려면 다시 보내세요.", + "compactionFailed": "압축에 실패했습니다.", + "compactionFailedQuoted": "압축에 실패했습니다: {{detail}}.", + "compactionUnconfirmed": "압축 완료가 확인되지 않았습니다.", + "cancelUnconfirmed": "취소가 확인되지 않았습니다.", + "answerUnconfirmed": "응답이 기록되었지만 에이전트가 확인하지 않았습니다.", + "hostFault": "Orca에 문제가 발생하여 이 작업이 완료되지 않았습니다.", + "hostFaultTryAgain": "Orca에 문제가 발생하여 이 작업이 완료되지 않았습니다. 다시 시도하세요.", + "hostStopped": "{{agent}}이(가) 시작을 마치지 못해 Orca가 중지했습니다.", + "providerRateLimited": "{{agent}}이(가) 속도 제한에 걸려 다시 시도하고 있습니다.", + "providerRetrying": "{{agent}}에 일시적인 문제가 발생하여 다시 시도하고 있습니다." + }, "writeNotice": { "notDoneReadHistory": "이 채팅의 기록을 불러오지 못했습니다.", "notDoneSend": "메시지가 전송되지 않았습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 9c4c4cb9aef4..c8c5319ad409 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17311,6 +17311,59 @@ }, "components": { "native-chat": { + "failureWords": { + "theAgent": "智能体", + "providerStartFailed": "{{agent}} 在完成启动前已停止。", + "runClearAgain": "请重新运行 /clear。", + "sendToTryAgain": "请发送您的消息以重试。", + "couldNotStart": "{{agent}} 无法启动。", + "couldNotRestart": "{{agent}} 无法重新启动。", + "notSignedIn": "{{agent}} 未使用所选账户登录。", + "signInFirst": "请先登录。", + "signInThenRunClear": "请登录,然后重新运行 /clear。", + "signInThenSend": "请登录,然后重新发送您的消息。", + "historyTooLarge": "此对话的历史记录过大,无法在此恢复。", + "managedAccountEnvOverride": "此次 Claude 启动设置了自己的 Anthropic 登录变量。请移除这些变量以使用受管理的 Claude 账户。", + "accountSwitchInProgress": "正在切换 Claude 账户。请在切换完成后重试。", + "managedAccountUnsupported": "当 WSL 中添加了 Claude 账户时,Claude 聊天需要 Windows Claude 账户。", + "chooseClaudeAccount": "请在 Claude 账户设置中选择或添加一个。", + "chooseClaudeAccountThenRunClear": "请在 Claude 账户设置中选择或添加一个,然后重新运行 /clear。", + "chooseClaudeAccountThenSend": "请在 Claude 账户设置中选择或添加一个,然后重新发送您的消息。", + "providerExitedRow": "{{agent}} 在生成此回复时停止了。您可以在此对话中继续。", + "providerExitedRejection": "{{agent}} 在发送此消息前已停止。", + "providerRejected": "提供商未接受此消息。", + "providerRejectedQuoted": "提供商未接受此消息:{{detail}}。", + "attachmentEmpty": "此消息中的一张图片为空,因此消息未发送。", + "attachmentTooLarge": "此消息中的一张图片过大,因此消息未发送。", + "attachmentLargerThan": "此消息中的一张图片超过 {{size}},因此消息未发送。", + "attachmentTooMany": "此消息包含的图片过多,因此未发送。", + "attachmentAtMost": "{{agent}} 每条消息最多接受 {{limit}} 张图片,因此此消息未发送。", + "attachmentTotalTooLarge": "此消息中的图片总大小过大,因此消息未发送。", + "attachmentTotalMoreThan": "此消息中的图片总计超过 {{size}},因此消息未发送。", + "attachmentUnsupportedType": "{{agent}} 仅接受 PNG、JPEG、GIF 和 WebP 图片,因此此消息未发送。", + "attachmentNotAFile": "此消息中的一张图片不是文件,因此消息未发送。", + "attachmentNoSource": "此消息中的一张图片没有可发送的文件,因此消息未发送。", + "attachmentInvalid": "此消息中的附件无法发送给智能体。", + "attachmentUnreadable": "无法读取此消息中的附件,因此消息未发送。", + "emptyMessage": "此消息为空,因此未发送。", + "queueFull": "等待智能体处理的消息过多,因此此消息未发送。", + "writeFailed": "Orca 无法将此消息交给智能体,因此消息未发送。", + "cancelled": "此消息在智能体开始处理前已撤回。", + "chatClosed": "此消息发送前聊天已关闭。", + "hostRestarted": "此消息发送前 Orca 已重新启动。", + "notDelivered": "此消息未送达。", + "notDeliveredSendAgain": "此消息未送达。请重新发送以继续。", + "compactionFailed": "压缩失败。", + "compactionFailedQuoted": "压缩失败:{{detail}}。", + "compactionUnconfirmed": "未确认压缩是否完成。", + "cancelUnconfirmed": "未确认取消。", + "answerUnconfirmed": "您的回答已记录,但智能体未确认。", + "hostFault": "Orca 遇到问题,因此此操作未完成。", + "hostFaultTryAgain": "Orca 遇到问题,因此此操作未完成。请重试。", + "hostStopped": "{{agent}} 始终未完成启动,因此 Orca 已将其停止。", + "providerRateLimited": "{{agent}} 已被限流,正在重试。", + "providerRetrying": "{{agent}} 遇到临时问题,正在重试。" + }, "writeNotice": { "notDoneReadHistory": "无法加载此聊天的历史记录。", "notDoneSend": "您的消息未发送。", diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index a83a95c43880..cb4497d5e857 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -390,22 +390,25 @@ describe('the notice for every reason a host names', () => { }) ).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.' } - ]) + agentSessionWriteNoticeEnglish( + send({ + kind: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'notSignedIn' } + }) + ) + ).toBe( + 'Your message was not sent. 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.' }]) + agentSessionWriteNoticeEnglish( + send({ + kind: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'providerStartFailed' } + }) + ) + ).toBe('Your message was not sent. Codex stopped before it finished starting.') }) it('says the write did not happen, once, and never shows the host message', () => { @@ -435,10 +438,14 @@ describe('the notice for every reason a host names', () => { code: 'agent_session_operation_invalid', details: { reason: 'notSignedIn' } }) + // The fact, not its English, so desktop words it in the reader's language. expect(agentSessionWriteNoticeParts(failure, 'send')).toEqual([ 'notDoneSend', - { text: agentSessionFailureSentence({ kind: 'notSignedIn' }, 'rejection') } + { failure: { kind: 'notSignedIn' }, surface: 'rejection', context: {} } ]) + expect(agentSessionWriteNoticeEnglish(agentSessionWriteNoticeParts(failure, 'send'))).toBe( + `Your message was not sent. ${agentSessionFailureSentence({ kind: 'notSignedIn' }, 'rejection')}` + ) // Its next step is to send the message again, which a Stop cannot do. expect(agentSessionWriteNoticeParts(failure, 'stop')).toEqual(['notDoneStop']) }) @@ -575,16 +582,16 @@ describe('agentSessionRefusalCauseParts', () => { }) ).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." - } - ]) + agentSessionWriteNoticeEnglish( + agentSessionRefusalCauseParts({ + kind: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'historyTooLarge' } + }) + ) + ).toBe( + "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', () => { @@ -596,33 +603,37 @@ describe('agentSessionRefusalCauseParts', () => { }) ).toEqual(['historyUnavailable']) expect( - agentSessionRefusalCauseParts( - { + agentSessionWriteNoticeEnglish( + agentSessionRefusalCauseParts( + { + kind: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'notSignedIn' } + }, + { agentName: 'Claude' } + ) + ) + ).toBe('Claude is not signed in for the selected account. Sign in first.') + expect( + agentSessionWriteNoticeEnglish( + agentSessionRefusalCauseParts({ kind: 'refused', code: 'agent_session_operation_invalid', - details: { reason: 'notSignedIn' } - }, - { agentName: 'Claude' } + details: { reason: 'providerStartFailed' } + }) ) - ).toEqual([{ text: 'Claude is not signed in for the selected account. Sign in first.' }]) + ).toBe('The agent stopped before it finished starting.') 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.' - } - ]) + agentSessionWriteNoticeEnglish( + agentSessionRefusalCauseParts({ + kind: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'managedAccountUnsupported' } + }) + ) + ).toBe( + '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', () => { diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index 0f4121dc9b31..e922f5933f9e 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -255,9 +255,8 @@ function reasonParts( return undefined } if ('fact' in words) { - const sentence = agentSessionFailureSentence({ kind: words.fact }, 'rejection', context) return write === 'send' || write === 'composer-send' - ? [NOT_DONE[write], { text: sentence }] + ? [NOT_DONE[write], { failure: { kind: words.fact }, surface: 'rejection', context }] : undefined } const said = causeParts(words.cause, write) @@ -341,7 +340,13 @@ export function agentSessionWriteNoticeEnglish( parts: readonly AgentSessionWriteNoticePart[] ): string { return parts - .map((part) => (typeof part === 'string' ? AGENT_SESSION_WRITE_NOTICE_COPY[part] : part.text)) + .map((part) => + typeof part === 'string' + ? AGENT_SESSION_WRITE_NOTICE_COPY[part] + : 'text' in part + ? part.text + : agentSessionFailureSentence(part.failure, part.surface, part.context) + ) .join(' ') } diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts index 8db0723559e8..1d3cb0306021 100644 --- a/src/shared/agent-session-write-notice-copy.ts +++ b/src/shared/agent-session-write-notice-copy.ts @@ -1,10 +1,15 @@ // The sentences a chat notice is made of, each whole so desktop can translate it on its own. +import type { AgentSessionFailureFact } from './agent-session-failure' import { QUIT_TERMINAL_AGENT, START_NEW_CHAT, TERMINAL_AGENT_HOLDS_CHAT } from './agent-session-failure-copy' +import type { + AgentSessionFailureSurface, + AgentSessionFailureWordsContext +} from './agent-session-failure-words' /** Every sentence a notice is made of. Desktop translates each whole sentence with this as its * fallback; mobile shows it as is. */ @@ -60,5 +65,15 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { } as const export type AgentSessionWriteNoticeSentence = keyof typeof AGENT_SESSION_WRITE_NOTICE_COPY -/** A notice as whole sentences, each translated on its own; `text` is a provider's own words. */ -export type AgentSessionWriteNoticePart = AgentSessionWriteNoticeSentence | { text: string } +/** A failure fact, worded where it is shown so desktop can say it in the reader's language. */ +export type AgentSessionWriteNoticeFailurePart = { + failure: AgentSessionFailureFact + surface: AgentSessionFailureSurface + context: AgentSessionFailureWordsContext +} +/** A notice as whole sentences, each translated on its own; `text` is words someone else wrote: a + * provider's, or a host's sentence with no fact beside it. */ +export type AgentSessionWriteNoticePart = + | AgentSessionWriteNoticeSentence + | { text: string } + | AgentSessionWriteNoticeFailurePart diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index 11e2b45fc0be..be96b31fd54f 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -17,10 +17,7 @@ import { 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 type { AgentSessionFailureWordsContext } from './agent-session-failure-words' import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' import { classifyStructuredAgentSessionSendFailure, @@ -163,7 +160,7 @@ function rejectionFactParts( } // A fact this build cannot place proves only that the message did not happen. return kind - ? [{ text: agentSessionFailureSentence({ ...fact, kind }, 'rejection', context) }] + ? [{ failure: { ...fact, kind }, surface: 'rejection', context }] : agentSessionWriteNotDoneParts(write) } From 87c51f25081c59e28043c8333c55520f0bbcce5d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:13:11 -0700 Subject: [PATCH 29/52] fix(native-chat): the history pane says only that Orca keeps trying When the pane's title already says Orca couldn't open this chat's history right now, the line under it no longer repeats that the transcript could not be read; it says only that Orca keeps trying to load it. The pane with no named reason keeps its two-part line. --- .../native-chat/NativeChatEmptyState.tsx | 30 +++++++++++++------ ...eChatStructuredSession.read-error.test.tsx | 6 ++-- .../NativeChatStructuredSession.tsx | 4 ++- ...tured-agent-session-read-failure-notice.ts | 5 +++- .../src/i18n/en-runtime-required.json | 1 + src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/fr.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- src/shared/native-chat-empty-state.ts | 3 ++ 12 files changed, 47 insertions(+), 20 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx b/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx index ec84f554cd76..2c68df370084 100644 --- a/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx +++ b/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx @@ -1,13 +1,17 @@ import { MessageSquare, TriangleAlert } from 'lucide-react' import { translate } from '@/i18n/i18n' import { formatAgentTypeLabel } from '@/lib/agent-status' -import { NATIVE_CHAT_EMPTY_STATE_COPY } from '../../../../shared/native-chat-empty-state' +import { + NATIVE_CHAT_EMPTY_STATE_COPY, + NATIVE_CHAT_KEEPS_TRYING_COPY +} from '../../../../shared/native-chat-empty-state' import type { NativeChatSession } from '../../../../shared/native-chat-types' export function NativeChatEmptyState({ kind, message, headline, + headlineSaysUnread = false, agent, retrying = false }: { @@ -15,12 +19,15 @@ export function NativeChatEmptyState({ message?: string /** The chat's own sentence for the failure, said once: it takes the generic title's place. */ headline?: string + /** The headline already says the history couldn't be opened, so the retrying line says only + * that Orca keeps trying. */ + headlineSaysUnread?: boolean agent?: NativeChatSession['agent'] /** The read retries on its own (structured chat), so the error says so instead of pointing * back to the terminal. */ retrying?: boolean }): React.JSX.Element { - const copy = emptyStateCopy(kind, { message, headline }, agent, retrying) + const copy = emptyStateCopy(kind, { message, headline, headlineSaysUnread }, agent, retrying) return (
) : viewState.kind === 'empty' ? ( 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 index 8dee55de82e6..ac743100cfcc 100644 --- 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 @@ -15,6 +15,8 @@ export type StructuredAgentSessionReadFailureNotice = { named: boolean /** Nothing the read retries gets past it, so the pane no longer says it keeps trying. */ final: boolean + /** The words already say the history couldn't be opened, so the pane need not say it again. */ + saysUnread: boolean } export function structuredAgentSessionReadFailureNotice( @@ -27,6 +29,7 @@ export function structuredAgentSessionReadFailureNotice( return { text: agentSessionWriteNoticeText(parts), named: refusal?.details?.reason !== undefined, - final: isFinalAgentSessionReadRefusal(refusal) + final: isFinalAgentSessionReadRefusal(refusal), + saysUnread: parts.includes('historyUnavailable') } } diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index a0ddc43e96ed..8c8c99c647b8 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2729,6 +2729,7 @@ "title": "Start a chat with {{value0}}" }, "error": { + "keepsTrying": "Orca keeps trying to load it.", "retryingSubtitle": "The transcript could not be read. Orca keeps trying to load it.", "subtitle": "The transcript could not be read. Toggle back to the terminal to keep working.", "title": "Could not load conversation" diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 159d01435f4d..990a2dbcddf6 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17813,7 +17813,8 @@ "error": { "title": "Could not load conversation", "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." + "retryingSubtitle": "The transcript could not be read. Orca keeps trying to load it.", + "keepsTrying": "Orca keeps trying to load it." }, "reconnecting": "Reconnecting to this chat…", "pairHost": "Pair a host to view agent chat history.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 882fb719cdb3..d5dae7a9e47f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15108,7 +15108,8 @@ "error": { "title": "No se pudo cargar la conversación", "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." + "retryingSubtitle": "No se pudo leer la transcripción. Orca sigue intentando cargarla.", + "keepsTrying": "Orca sigue intentando cargarlo." }, "reconnecting": "Reconectando con este chat…", "pairHost": "Empareje un host para ver el historial de chat del agente.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index a286e93ab138..3a67f9afbf40 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17670,7 +17670,8 @@ "error": { "title": "Impossible de charger la conversation", "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." + "retryingSubtitle": "La transcription n'a pas pu être lue. Orca continue d'essayer de la charger.", + "keepsTrying": "Orca continue d'essayer de le charger." }, "reconnecting": "Reconnexion à ce chat…", "pairHost": "Associez un hôte pour consulter l'historique de chat des agents.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e25bf8bbd523..f9b5cc5f67b0 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17606,7 +17606,8 @@ "error": { "title": "会話を読み込めませんでした", "subtitle": "トランスクリプトを読み取ることができませんでした。作業を続けるには、ターミナルに切り替えてください。", - "retryingSubtitle": "トランスクリプトを読み取ることができませんでした。Orca は読み込みを再試行し続けます。" + "retryingSubtitle": "トランスクリプトを読み取ることができませんでした。Orca は読み込みを再試行し続けます。", + "keepsTrying": "Orca は読み込みを再試行し続けます。" }, "reconnecting": "このチャットに再接続しています…", "pairHost": "ホストをペアリングして、Agent のチャット履歴を表示します。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 9f3b67706dc7..92fa509faec1 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17606,7 +17606,8 @@ "error": { "title": "대화를 로드할 수 없습니다.", "subtitle": "스크립트를 읽을 수 없습니다. 계속 작업하려면 terminal로 다시 전환하세요.", - "retryingSubtitle": "스크립트를 읽을 수 없습니다. Orca가 계속 다시 불러오기를 시도합니다." + "retryingSubtitle": "스크립트를 읽을 수 없습니다. Orca가 계속 다시 불러오기를 시도합니다.", + "keepsTrying": "Orca가 계속 다시 불러오기를 시도합니다." }, "reconnecting": "이 채팅에 다시 연결하는 중…", "pairHost": "호스트를 페어링하여 에이전트 채팅 기록을 확인하세요.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index c8c5319ad409..c3a187256283 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17571,7 +17571,8 @@ "error": { "title": "无法加载对话", "subtitle": "无法读取记录。请切回终端继续工作。", - "retryingSubtitle": "无法读取记录。Orca 会继续尝试加载。" + "retryingSubtitle": "无法读取记录。Orca 会继续尝试加载。", + "keepsTrying": "Orca 会继续尝试加载。" }, "reconnecting": "正在重新连接此聊天…", "pairHost": "配对主机以查看智能体聊天记录。", diff --git a/src/shared/native-chat-empty-state.ts b/src/shared/native-chat-empty-state.ts index 3ef7b1a19025..33eee138b7ca 100644 --- a/src/shared/native-chat-empty-state.ts +++ b/src/shared/native-chat-empty-state.ts @@ -30,6 +30,9 @@ export const NATIVE_CHAT_EMPTY_STATE_COPY = { } } as const satisfies Record +/** The retrying half alone, under a title that already says the history couldn't be opened. */ +export const NATIVE_CHAT_KEEPS_TRYING_COPY = 'Orca keeps trying to load it.' + /** Resolve the empty-state copy with the agent label substituted for `{{value0}}`. * For platforms without an i18n layer (mobile). */ export function formatNativeChatEmptyStateCopy( From c4d7d9323f543c7ea6ad3fc4416c478a8a419c89 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:15:49 -0700 Subject: [PATCH 30/52] test(native-chat): type the failure pieces a refusal notice shares --- .../agent-session-failure-words-text.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts index 23e3034bf589..132733657c60 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts @@ -32,11 +32,11 @@ const IDS = Object.keys(AGENT_SESSION_FAILURE_COPY).filter( (id): id is AgentSessionFailureCopyId => id in AGENT_SESSION_FAILURE_COPY ) // Said by a refusal notice too, so they keep the notice's keys. -const NOTICE_PIECES = new Set([ +const NOTICE_PIECES: readonly AgentSessionFailureCopyId[] = [ 'terminalAgentHoldsChat', 'quitTerminalAgent', 'startNewChat' -]) +] const VALUES = { agent: 'Claude', detail: 'Image type .bmp', limit: '20', size: '5 MB' } function factsFor(kind: AgentSessionFailureFact['kind']): AgentSessionFailureFact[] { @@ -81,7 +81,7 @@ describe('desktop words for a failure fact', () => { for (const id of IDS) { vi.mocked(translate).mockClear() sayAgentSessionFailureTranslated(id, VALUES) - const section = NOTICE_PIECES.has(id) ? 'writeNotice' : 'failureWords' + const section = NOTICE_PIECES.includes(id) ? 'writeNotice' : 'failureWords' expect(vi.mocked(translate).mock.calls.map(([key, fallback]) => [key, fallback])).toEqual([ [`components.native-chat.${section}.${id}`, AGENT_SESSION_FAILURE_COPY[id]] ]) @@ -90,7 +90,10 @@ describe('desktop words for a failure fact', () => { it('keeps the English catalog in step with the shared copy', () => { const own = Object.fromEntries( - Object.entries(AGENT_SESSION_FAILURE_COPY).filter(([id]) => !NOTICE_PIECES.has(id)) + IDS.filter((id) => !NOTICE_PIECES.includes(id)).map((id) => [ + id, + AGENT_SESSION_FAILURE_COPY[id] + ]) ) expect(en.components['native-chat'].failureWords).toEqual(own) for (const id of NOTICE_PIECES) { From dd7bb0c4c2c1ee034a007cf456adc419dd981920 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:19:06 -0700 Subject: [PATCH 31/52] test(native-chat): the Chinese failure words use no Japanese-only characters --- .../agent-session-failure-words-text.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts index 132733657c60..e0059027f5e9 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { i18n, translate } from '@/i18n/i18n' import type * as I18nModule from '@/i18n/i18n' import en from '@/i18n/locales/en.json' +import zh from '@/i18n/locales/zh.json' import { AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS, AGENT_SESSION_FAILURE_KINDS, @@ -37,6 +38,8 @@ const NOTICE_PIECES: readonly AgentSessionFailureCopyId[] = [ 'quitTerminalAgent', 'startNewChat' ] +// Kana, and kanji whose simplified Chinese form differs (続 is 续, 読 is 读, ...). +const JAPANESE_ONLY = /[\u3040-\u30ff続読変済図気帰戻検択転権単圧応対発処実証覧関専]/u const VALUES = { agent: 'Claude', detail: 'Image type .bmp', limit: '20', size: '5 MB' } function factsFor(kind: AgentSessionFailureFact['kind']): AgentSessionFailureFact[] { @@ -153,4 +156,11 @@ describe('desktop words for a failure fact', () => { ) ).toBe('Claude does not support .bmp images') }) + + it('keeps Japanese-only characters out of the Chinese words', () => { + const chat = zh.components['native-chat'] + for (const words of [...Object.values(chat.failureWords), chat.state.error.keepsTrying]) { + expect(words).not.toMatch(JAPANESE_ONLY) + } + }) }) From 32c9c2d753735a4b943ebd69e5ccc783c85765b8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:19:08 -0700 Subject: [PATCH 32/52] fix(native-chat): every history pane that says it didn't load says only that Orca keeps trying A pane whose title is a code's own words ("This chat's history couldn't be loaded.") now gets the short retrying line too. Only the pane with no named reason keeps the two-part line. --- .../native-chat/NativeChatEmptyState.tsx | 4 ++-- ...ativeChatStructuredSession.read-error.test.tsx | 15 +++++++++++++++ ...tructured-agent-session-read-failure-notice.ts | 13 +++++++++++-- src/shared/native-chat-empty-state.ts | 2 +- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx b/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx index 2c68df370084..d6c7094a2961 100644 --- a/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx +++ b/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx @@ -19,8 +19,8 @@ export function NativeChatEmptyState({ message?: string /** The chat's own sentence for the failure, said once: it takes the generic title's place. */ headline?: string - /** The headline already says the history couldn't be opened, so the retrying line says only - * that Orca keeps trying. */ + /** The headline already says the history didn't load, so the retrying line says only that + * Orca keeps trying. */ headlineSaysUnread?: boolean agent?: NativeChatSession['agent'] /** The read retries on its own (structured chat), so the error says so instead of pointing 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 d53527908fbe..feb6a2ad08e8 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 @@ -86,6 +86,21 @@ it("names a history that couldn't open right now once, and that the pane keeps t expect(screen.queryByText(/Try again/)).toBeNull() }) +it("says only that it keeps trying under a code's own words that the history didn't load", () => { + mocks.status = 'error' + mocks.readRefusal = { + code: 'agent_session_checkpoint_stale', + details: { reason: 'fenceStale' } + } as const + mocks.messages = [] + + renderPane() + + expect(screen.getAllByText("This chat's history couldn't be loaded.")).toHaveLength(1) + expect(screen.getByText('Orca keeps trying to load it.')).toBeTruthy() + expect(screen.queryByText(/could not be read/)).toBeNull() +}) + it('says only that it is reconnecting, not as an error, when a failure names nothing', () => { mocks.status = 'error' 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 index ac743100cfcc..977fc6368d99 100644 --- 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 @@ -5,17 +5,26 @@ import { agentSessionReadHistoryRefusalParts, agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice' +import type { AgentSessionWriteNoticeSentence } from '../../../../shared/agent-session-write-notice-copy' 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' +// Each says the history didn't load, so a pane headed by one need not say it again. +const SAYS_HISTORY_UNREAD: ReadonlySet = new Set([ + 'notDoneReadHistory', + 'historyUnavailable', + 'historyUnreadable', + 'historyUnusable' +]) + 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 - /** The words already say the history couldn't be opened, so the pane need not say it again. */ + /** The words already say the history didn't load, so the pane need not say it again. */ saysUnread: boolean } @@ -30,6 +39,6 @@ export function structuredAgentSessionReadFailureNotice( text: agentSessionWriteNoticeText(parts), named: refusal?.details?.reason !== undefined, final: isFinalAgentSessionReadRefusal(refusal), - saysUnread: parts.includes('historyUnavailable') + saysUnread: parts.some((part) => typeof part === 'string' && SAYS_HISTORY_UNREAD.has(part)) } } diff --git a/src/shared/native-chat-empty-state.ts b/src/shared/native-chat-empty-state.ts index 33eee138b7ca..2957698ae09f 100644 --- a/src/shared/native-chat-empty-state.ts +++ b/src/shared/native-chat-empty-state.ts @@ -30,7 +30,7 @@ export const NATIVE_CHAT_EMPTY_STATE_COPY = { } } as const satisfies Record -/** The retrying half alone, under a title that already says the history couldn't be opened. */ +/** The retrying half alone, under a title that already says the history didn't load. */ export const NATIVE_CHAT_KEEPS_TRYING_COPY = 'Orca keeps trying to load it.' /** Resolve the empty-state copy with the agent label substituted for `{{value0}}`. From e645141022645f28a7e74db38b73aa01628e7139 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:40:53 -0700 Subject: [PATCH 33/52] test(native-chat): a provider's words with nesting and markup stay as written in a translated notice --- .../native-chat/agent-session-failure-words-text.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts index e0059027f5e9..215b3007c5ac 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts @@ -131,15 +131,16 @@ describe('desktop words for a failure fact', () => { ).toBe( "Votre message n'a pas été envoyé. Claude n'est pas connecté avec le compte sélectionné. Connectez-vous, puis renvoyez votre message." ) + const detail = 'Uses {{agent}} $t(components.native-chat.failureWords.theAgent) &' const rejected = structuredAgentSessionRejectionParts( - 'The provider did not accept this message: Uses {{agent}}.', + `The provider did not accept this message: ${detail}.`, 'send', - { kind: 'providerRejected', detail: { text: 'Uses {{agent}}', audience: 'person' } }, + { kind: 'providerRejected', detail: { text: detail, audience: 'person' } }, { agentName: 'Codex' } ) - // The provider's own words stay as written, placeholders and all. + // The provider's own words stay as written: placeholders, nesting and markup are not read. expect(agentSessionWriteNoticeText(rejected)).toBe( - "Le fournisseur n'a pas accepté ce message : Uses {{agent}}." + `Le fournisseur n'a pas accepté ce message : ${detail}.` ) expect( agentSessionWriteNoticeText( From fca1e46ab304777b1222fa1baad48030ef274aff Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:41:01 -0700 Subject: [PATCH 34/52] fix(native-chat): French and Spanish say a withdrawn message was withdrawn before the agent began working on it --- src/renderer/src/i18n/locales/es.json | 2 +- src/renderer/src/i18n/locales/fr.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index d5dae7a9e47f..aad4b8ebb84f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14964,7 +14964,7 @@ "emptyMessage": "Este mensaje está vacío, así que no se envió.", "queueFull": "Había demasiados mensajes esperando al agente, así que este no se envió.", "writeFailed": "Orca no pudo entregar este mensaje al agente, así que no se envió.", - "cancelled": "Este mensaje se retiró antes de que el agente lo iniciara.", + "cancelled": "Este mensaje se retiró antes de que el agente empezara a procesarlo.", "chatClosed": "El chat se cerró antes de que se enviara este mensaje.", "hostRestarted": "Orca se reinició antes de que se enviara este mensaje.", "notDelivered": "Este mensaje no se entregó.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 3a67f9afbf40..f9c0a70b10dd 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17447,7 +17447,7 @@ "emptyMessage": "Ce message est vide, il n'a donc pas été envoyé.", "queueFull": "Trop de messages attendaient l'agent, celui-ci n'a donc pas été envoyé.", "writeFailed": "Orca n'a pas pu transmettre ce message à l'agent, il n'a donc pas été envoyé.", - "cancelled": "Ce message a été retiré avant que l'agent ne le commence.", + "cancelled": "Ce message a été retiré avant que l'agent ne commence à le traiter.", "chatClosed": "Le chat s'est fermé avant l'envoi de ce message.", "hostRestarted": "Orca a redémarré avant l'envoi de ce message.", "notDelivered": "Ce message n'a pas été remis.", From 81e37076d4c5c1353e519c2ca263d61f37eb4a1e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:56:16 -0700 Subject: [PATCH 35/52] fix(native-chat): Japanese and Chinese notices run their sentences on without a space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A notice joined its sentences with a space in every language, so Japanese and Chinese read "Claude 无法启动。 请重新发送消息。" with a stray gap after the full stop. The failure-sentence builder now takes the joiner alongside its words, and desktop joins in the UI language: no space in Japanese and Chinese (including a plugin pack that declares either), one space elsewhere. The host and the phone keep English, joined with a space as before. --- .../native-chat/NativeChatLaunchRetry.tsx | 4 +- .../agent-session-failure-words-text.test.ts | 7 +- .../agent-session-failure-words-text.ts | 7 ++ .../agent-session-write-notice-text.test.ts | 60 ++++++++++++++- .../agent-session-write-notice-text.ts | 11 +-- src/renderer/src/i18n/sentence-joining.ts | 11 +++ src/shared/agent-session-failure-copy.ts | 12 +++ src/shared/agent-session-failure-words.ts | 75 ++++++++++--------- 8 files changed, 143 insertions(+), 44 deletions(-) create mode 100644 src/renderer/src/i18n/sentence-joining.ts diff --git a/src/renderer/src/components/native-chat/NativeChatLaunchRetry.tsx b/src/renderer/src/components/native-chat/NativeChatLaunchRetry.tsx index b186bbc4e344..8ca3091dae41 100644 --- a/src/renderer/src/components/native-chat/NativeChatLaunchRetry.tsx +++ b/src/renderer/src/components/native-chat/NativeChatLaunchRetry.tsx @@ -1,6 +1,7 @@ import { RotateCcw } from 'lucide-react' import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' +import { joinUiSentences } from '@/i18n/sentence-joining' import type { StructuredAgentSessionLaunchLifecycle } from '@/lib/structured-agent-session-launch' import { agentSessionRefusalCauseParts } from '../../../../shared/agent-session-refusal-notice' import type { AgentSessionWriteRefusal } from '../../../../shared/agent-session-write-failure' @@ -41,8 +42,7 @@ export function NativeChatLaunchRetry({ return (
- {message} - {cause ? ` ${cause}` : null} + {cause ? joinUiSentences([message, cause]) : message}