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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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 05d001e5b1455beec920489eb8b275c92771b620 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:08:23 -0700 Subject: [PATCH 27/37] feat(native-chat): a rejection can name the failed start that wrote it A failure fact gains an optional startKey: the key of the start-failure row whose start rejected the message. Readers keep it when it is a non-empty string and drop it otherwise; the classifier, the copy table and the outbox's saved copy ignore it, and the journal schema admits it however a host wrote it, so a row with or without one reads as before. --- ...tured-agent-session-outbox-storage.test.ts | 6 ++++- src/shared/agent-session-failure.test.ts | 16 +++++++++++++ src/shared/agent-session-failure.ts | 8 ++++++- .../agent-session-journal-schemas.test.ts | 24 +++++++++++++++++++ ...d-agent-session-dispatch-rejection.test.ts | 7 ++++++ 5 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.test.ts index 9b8b3c537a67..5c2547cd408a 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.test.ts @@ -185,7 +185,11 @@ describe("a saved message's last failure", () => { lastFailure: { kind: 'rejected', reason: 'Claude stopped before it finished starting.', - rejection: { kind: 'providerStartFailed', detail: { text: 'exit 1', audience: 'log' } } + rejection: { + kind: 'providerStartFailed', + detail: { text: 'exit 1', audience: 'log' }, + startKey: 'generation-1' + } } }, { diff --git a/src/shared/agent-session-failure.test.ts b/src/shared/agent-session-failure.test.ts index 4dc12f9bfe8b..5cb815c9fbf6 100644 --- a/src/shared/agent-session-failure.test.ts +++ b/src/shared/agent-session-failure.test.ts @@ -79,6 +79,22 @@ describe('reading a failure fact', () => { ).toEqual({ kind: 'restartFailed', refusal: { code: 'agent_session_conflict' } }) }) + // Written only by a failed start's one writer; a fact from before it, or from any other writer, + // has none. + it('keeps the start a rejection came from, and reads a fact without one as before', () => { + expect(readAgentSessionFailureFact({ kind: 'startFailed', startKey: 'generation-2' })).toEqual({ + kind: 'startFailed', + startKey: 'generation-2' + }) + expect(readAgentSessionFailureFact({ kind: 'startFailed' })).toEqual({ kind: 'startFailed' }) + expect(readAgentSessionFailureFact({ kind: 'startFailed', startKey: '' })).toEqual({ + kind: 'startFailed' + }) + expect(readAgentSessionFailureFact({ kind: 'startFailed', startKey: 7 })).toEqual({ + kind: 'startFailed' + }) + }) + it('drops what a newer host wrote that this build cannot place', () => { expect(readAgentSessionFailureFact({ kind: 'futureKind' })).toBeUndefined() expect(readAgentSessionFailureFact(undefined)).toBeUndefined() diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index ed8b677b359e..48f9df19c80e 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -122,6 +122,9 @@ export type AgentSessionFailureFact = { attachment?: AgentSessionAttachmentProblem /** On `providerRetrying`: why the provider is retrying. */ retry?: AgentSessionProviderRetry + /** Which failed start wrote this, the key of its `start-failure:` row; set only by that start's + * writer, on the row and on every message it rejected. Identity, never part of the situation. */ + startKey?: string } /** A fact as a row stores it: its kind may be one a newer host added, so only @@ -219,12 +222,15 @@ export function readAgentSessionFailureFact(value: unknown): AgentSessionFailure const refusal = readAgentSessionRefusalReference(value.refusal) const attachment = readAttachmentProblem(value.attachment) const retry = readProviderRetry(value.retry) - return agentSessionFailureFact(value.kind, { + const fact = agentSessionFailureFact(value.kind, { ...(isProviderDiagnostic(value.detail) ? { detail: value.detail } : {}), ...(refusal ? { refusal } : {}), ...(attachment ? { attachment } : {}), ...(retry ? { retry } : {}) }) + return typeof value.startKey === 'string' && value.startKey + ? { ...fact, startKey: value.startKey } + : fact } /** The provider-authored diagnostic an error carries, set only where it was composed. Follows the diff --git a/src/shared/agent-session-journal-schemas.test.ts b/src/shared/agent-session-journal-schemas.test.ts index 594d6a04b11c..5d825805fab5 100644 --- a/src/shared/agent-session-journal-schemas.test.ts +++ b/src/shared/agent-session-journal-schemas.test.ts @@ -372,6 +372,30 @@ describe('typed failure facts', () => { expect(refused({ code: 'agent_session_conflict', cause: 'claimConflicted' })).toBe(true) }) + // The schema is the one older builds of this stack read with: the start's key, well-formed or + // not, never makes a row malformed; the fact reader keeps or drops it. + it('admits the start a rejection came from, whatever a host wrote for it', () => { + for (const startKey of ['generation-2', 7]) { + const failure = { kind: 'startFailed', startKey } + expect( + isAdmissibleAgentJournalItemBody({ kind: 'status', text: "Codex couldn't start.", failure }) + ).toBe(true) + expect( + isAdmissibleAgentJournalSubmission({ + clientMessageId: 'cm-1', + fence: 1, + payloadFingerprint: 'fp', + dispatchState: 'rejected', + providerItemId: null, + reason: "Codex couldn't start.", + submittedAt: 1, + resolvedAt: 2, + rejection: failure + }) + ).toBe(true) + } + }) + it('keeps a kind or audience a newer host writes admissible', () => { expect( isAdmissibleAgentJournalItemBody({ diff --git a/src/shared/structured-agent-session-dispatch-rejection.test.ts b/src/shared/structured-agent-session-dispatch-rejection.test.ts index be16c8a3fbda..ef1251ff40ca 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.test.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.test.ts @@ -85,6 +85,13 @@ describe('classifyDispatchRejection', () => { ).toMatchObject({ category: 'transport', verdict: 'failure' }) }) + it('classifies a rejection the same whichever start it names', () => { + const reason = "Claude couldn't start." + expect( + classifyDispatchRejection({ reason, rejection: { kind: 'startFailed', startKey: 'g-2' } }) + ).toEqual(classifyDispatchRejection({ reason, rejection: { kind: 'startFailed' } })) + }) + it('writes a sentence, not a marker released clients would print, for a restart or a close', () => { for (const kind of ['hostRestarted', 'chatClosed'] as const) { const written = agentSessionFailureWords(agentSessionFailureFact(kind), { From c8c6fca01efdab93b5bb94451a7a9e1217d246af 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 28/37] feat(native-chat): a failed start's row and the messages it rejected name that start The one start-failure writer already keyed its row by the start and rejected every queued message with the same words; it now also puts that key on the fact it writes to both, so a reader can tell which row explains a rejected message without comparing the failures. --- ...-agent-session-accept-then-deliver.test.ts | 6 ++++-- ...gent-session-provider-child-record.test.ts | 21 +++++++++++-------- ...uctured-agent-session-start-failure-row.ts | 9 ++++---- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index fb4ee607e38d..757b2b1688a2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -332,9 +332,11 @@ describe('a start the chat needed and did not get', () => { // Orca's spawn error goes to the log; the row and every message say what failed, typed. The // child is gone, but no exit was observed, so nothing blames the provider. expect(rows[0]).toBe("Codex couldn't restart. Send your message to try again.") + // No child was published, so the start is keyed by the oldest message it was for. const failure = { kind: 'restartFailed', - refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } + refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } }, + startKey: first } expect(await errorFailures()).toEqual([failure]) for (const id of [first, second]) { @@ -444,7 +446,7 @@ describe('a start the chat needed and did not get', () => { await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) expect(await errorRows()).toEqual([row.text]) - expect(await errorFailures()).toEqual([row.failure]) + expect(await errorFailures()).toEqual([{ ...row.failure, startKey: id }]) const framedRows = events.flatMap((event) => event.type === 'batch' || event.type === 'snapshot' ? (event.type === 'batch' ? event.batch.items : event.page.items).filter( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index fb66a1e1a8fd..e6922010453e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -410,14 +410,14 @@ describe('a published child that dies while it proves its start', () => { itemId: `orca:${encodeURIComponent(`start-failure:${child.acquisitionGeneration}`)}`, text: TEXT, tone: 'error', - failure: START_FAILURE + failure: { ...START_FAILURE, startKey: child.acquisitionGeneration } } ]) for (const id of [first, second]) { expect(await submission(id)).toMatchObject({ dispatchState: 'rejected', reason: TEXT, - rejection: START_FAILURE + rejection: { ...START_FAILURE, startKey: child.acquisitionGeneration } }) } expect(rejectedIn(events, second)).toBe(true) @@ -466,7 +466,7 @@ describe('a start another operation made that dies while a sent message waits on itemId: `orca:${encodeURIComponent(`start-failure:${operationChild.acquisitionGeneration}`)}`, text: TEXT, tone: 'error', - failure: START_FAILURE + failure: { ...START_FAILURE, startKey: operationChild.acquisitionGeneration } } ]) expect(rejectedIn(events, id)).toBe(true) @@ -549,7 +549,12 @@ describe('a child that ends before its message is handed over', () => { rejection: failure }) expect(await statusRows()).toEqual([ - { itemId: expect.any(String), text: (await submission(id))?.reason, tone: 'error', failure } + { + itemId: expect.any(String), + text: (await submission(id))?.reason, + tone: 'error', + failure: { ...failure, startKey: 'generation-2' } + } ]) expect(rejectedIn(events, id)).toBe(true) await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) @@ -730,12 +735,10 @@ describe('how a stopped child ends the start its loop was waiting on', () => { await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) // The sentence is the constructor's, not the reason the stop was given. const text = 'Codex never finished starting, so Orca stopped it.' - expect(await submission(second)).toMatchObject({ - reason: text, - rejection: { kind: 'hostStopped' } - }) + const failure = { kind: 'hostStopped', startKey: 'generation-2' } + expect(await submission(second)).toMatchObject({ reason: text, rejection: failure }) expect(await statusRows()).toEqual([ - { itemId: expect.any(String), text, tone: 'error', failure: { kind: 'hostStopped' } } + { itemId: expect.any(String), text, tone: 'error', failure } ]) expect(dispatch).not.toHaveBeenCalled() }) 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 77ba7fe5f583..2f329ad907f1 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 @@ -52,16 +52,15 @@ export async function recordStructuredAgentSessionStartFailure( return } const startKey = failure.startKey ?? oldest.clientMessageId + // The row and each message it rejects name this start, so a reader pairs them by identity. + const words = { reason: failure.reason, rejection: { ...failure.rejection, startKey } } await session.journal.appendLifecycleBatch({ settlementId: `start-failure:${startKey}`, fence: session.fence, recovered: true, - mutations: [structuredAgentSessionStartFailureRow(startKey, failure)] - }) - await session.journal.rejectQueuedSubmissions(session.fence, { - reason: failure.reason, - rejection: failure.rejection + mutations: [structuredAgentSessionStartFailureRow(startKey, words)] }) + await session.journal.rejectQueuedSubmissions(session.fence, words) } export function oldestQueuedSubmission( From 18211f9846581c0c8af0d24d3684abf3cd4720bd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:16:40 -0700 Subject: [PATCH 29/37] fix(native-chat): a start that fails at the handover is recorded once for every message When the child the delivery loop waited on had not proven its start and could not take the message handed to it, each message was rejected on its own as a start that failed, and the child's later exit wrote the start's row as the provider stopping: the row and the messages disagreed, and no row existed until the exit. The handover now returns that start failure to the loop, whose one start-failure writer writes the row and rejects the handed message and every one queued behind it with the same words and start key. --- .../structured-agent-session-delivery-loop.ts | 22 ++++++-- ...uctured-agent-session-start-failure-row.ts | 22 +++++--- ...agent-session-start-failure-writer.test.ts | 51 +++++++++++++++++-- .../structured-agent-session-turns.ts | 32 +++++------- 4 files changed, 94 insertions(+), 33 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 7dd17306105d..69cfb07e4dae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -201,21 +201,32 @@ export class StructuredAgentSessionDeliveryLoop { if (!next) { return this.stop(sessionId) } - await handOverSubmission( + const unstarted = await handOverSubmission( { sessionId, journal: session.journal, fence: awaitedChild.fence, adapter: this.deps.adapter, - providerChildPhase: () => this.deps.sessions.get(sessionId)?.child?.phase, - failureTextContext: this.deps.failureTextContext(sessionId) + providerChildPhase: () => this.deps.sessions.get(sessionId)?.child?.phase }, next ) + if (unstarted) { + // The start failed for this message and every one behind it, not for this one alone. + return this.fail( + sessionId, + { startKey: awaitedChild.generation, cause: unstarted }, + next.clientMessageId + ) + } return 'continue' } - private async fail(sessionId: string, failure: StartFailure): Promise<'stop'> { + private async fail( + sessionId: string, + failure: StartFailure, + handedOver?: string + ): Promise<'stop'> { const session = this.deps.sessions.get(sessionId) if (session) { await recordStructuredAgentSessionStartFailure( @@ -226,7 +237,8 @@ export class StructuredAgentSessionDeliveryLoop { failure.cause, this.deps.failureTextContext(sessionId) ) - } + }, + handedOver ) } return this.stop(sessionId) 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 2f329ad907f1..88ce18efcee2 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 @@ -39,19 +39,21 @@ export function hasStructuredAgentSessionStartFailureRow( } /** - * 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 - * messages Stop withdrew did not fail anyone. + * A start the delivery loop needed and did not get: the start's row, and every message it was for + * rejected with the same words — each queued one, and `handedOver`, the one handed to a child that + * could not take it. Writes nothing when neither is left: a start whose messages Stop withdrew did + * not fail anyone. */ export async function recordStructuredAgentSessionStartFailure( session: Pick & { fence: number }, - failure: StructuredAgentSessionStartFailure + failure: StructuredAgentSessionStartFailure, + handedOver?: string ): Promise { - const oldest = oldestQueuedSubmission(session) + const oldest = handedOver ?? oldestQueuedSubmission(session)?.clientMessageId if (!oldest) { return } - const startKey = failure.startKey ?? oldest.clientMessageId + const startKey = failure.startKey ?? oldest // The row and each message it rejects name this start, so a reader pairs them by identity. const words = { reason: failure.reason, rejection: { ...failure.rejection, startKey } } await session.journal.appendLifecycleBatch({ @@ -60,6 +62,14 @@ export async function recordStructuredAgentSessionStartFailure( recovered: true, mutations: [structuredAgentSessionStartFailureRow(startKey, words)] }) + if (handedOver) { + await session.journal.resolveDispatch({ + clientMessageId: handedOver, + state: 'rejected', + ...words, + fence: session.fence + }) + } await session.journal.rejectQueuedSubmissions(session.fence, words) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index 5ce1fdb3694e..30fd9cbec9f5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -1,7 +1,7 @@ // A start a queued message waited on can be seen failing twice: by the delivery loop, when the -// adapter settles the start without proving it, and by the exit settlement, when the child's exit -// lands. The chat gets one row for that start, the loop's, in the words the message was rejected -// with — whichever of the two reports first. +// adapter settles the start without proving it or cannot take the message it was handed, and by +// the exit settlement, when the child's exit lands. The chat gets one row for that start, the +// loop's, in the words the message was rejected with — whichever of the two reports first. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -49,6 +49,7 @@ let host: StructuredAgentSessionHost let generation = 0 let settleStart: (failure: SubmissionRejectionFact | undefined) => void = () => {} let awaitStarted = vi.fn<() => Promise>() +let dispatch = vi.fn<() => Promise<{ state: 'admitted' }>>() let frames: AgentSessionSubscribeEvent[] = [] function exitBeforeProof(): Promise { @@ -116,6 +117,7 @@ beforeEach(async () => { awaitStarted = vi.fn( () => new Promise((resolve) => (settleStart = resolve)) ) + dispatch = vi.fn(async () => ({ state: 'admitted' as const })) store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) host = new StructuredAgentSessionHost({ store, @@ -135,7 +137,7 @@ beforeEach(async () => { awaitStarted, releaseAcquisition: vi.fn(async () => true), closeSession: vi.fn(async () => true), - dispatch: vi.fn(async () => ({ state: 'admitted' as const })), + dispatch, cancelTurn: vi.fn(async () => ({ cancelled: true })), answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) @@ -192,3 +194,44 @@ describe('a queued message whose start fails and whose child then exits', () => expect(publishedStartRows()).toEqual([EXIT_TEXT]) }) }) + +// The adapter's session is gone before the host has seen the exit: the start settles with no +// reason, and the child the host still holds as starting cannot take the message handed to it. +describe('a start whose child cannot take the message it was handed', () => { + it('rejects every message it was for with the one row the exit then leaves alone', async () => { + const first = await sendQueued('first') + const second = await sendQueued('second') + dispatch.mockImplementation(() => { + throw new Error(`no live claude stream-json session for ${SESSION}`) + }) + awaitStarted.mockImplementation(async () => undefined) + + settleStart(undefined) + await eventually(async () => { + expect(await submission(first)).toMatchObject({ dispatchState: 'rejected' }) + expect(await submission(second)).toMatchObject({ dispatchState: 'rejected' }) + }) + await exitBeforeProof() + await host.flushStreamedEvents(SESSION) + + const row = (await host.journalSnapshot(SESSION)).items.find( + (item) => item.itemId === START_ROW + ) + const firstRejected = await submission(first) + const secondRejected = await submission(second) + // Nothing saw the provider stop when the handover failed; the row names the start it keys. + expect(firstRejected?.rejection).toEqual({ kind: 'startFailed', startKey: 'generation-2' }) + expect(row?.body).toMatchObject({ + kind: 'status', + text: firstRejected?.reason, + failure: firstRejected?.rejection + }) + expect(secondRejected).toMatchObject({ + reason: firstRejected?.reason, + rejection: firstRejected?.rejection + }) + expect(publishedStartRows()).toEqual([firstRejected?.reason]) + // One start failed, so one handover tried it. + expect(dispatch).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 49642b9a897e..e5f8ca07a397 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -7,10 +7,7 @@ // turn the provider already accepted. import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { - agentSessionFailureWords, - type AgentSessionFailureWordsContext -} from '../../../shared/agent-session-failure-words' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import type { AgentJournalMessageItem, AgentJournalStatusItem, @@ -31,7 +28,6 @@ import type { StructuredAgentSessionAdapter, StructuredAgentSessionProviderChildPhase } from './structured-agent-session-adapter' -import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' import { validatePendingPrompt } from './structured-agent-session-prompt-state' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' export { performSetOption } from './structured-agent-session-turns-options' @@ -70,13 +66,13 @@ function invalid( /** A thrown adapter error is indistinguishable from a lost reply, so it settles as `unknown` * rather than as a rejection — unless the child had not proven its start. Such a child has * accepted nothing (input is written only after it initializes), so a dispatch it could not - * take is provably unwritten and is rejected with the cause the adapter gave. */ + * take is provably unwritten: its start failed, with the cause the adapter gave. */ async function dispatchSafely( ctx: AgentSessionHandoverContext, clientMessageId: string, body: AgentJournalMessageItem, requestedAt: number -): Promise { +): Promise { try { return await ctx.adapter.dispatch({ sessionId: ctx.sessionId, @@ -87,10 +83,7 @@ async function dispatchSafely( }) } catch (error) { if (ctx.providerChildPhase?.() === 'starting') { - return { - state: 'rejected', - ...structuredAgentSessionStartFailure({ error }, ctx.failureTextContext) - } + return { state: 'startFailed', cause: { error } } } return { state: 'unknown', reason: error instanceof Error ? error.message : String(error) } } @@ -154,19 +147,18 @@ export async function performSend( export type AgentSessionHandoverContext = Pick< AgentSessionTurnContext, 'sessionId' | 'journal' | 'fence' | 'adapter' | 'providerChildPhase' -> & { - /** Who a start failure met at dispatch names, as the start's own row does. */ - failureTextContext?: AgentSessionFailureWordsContext -} +> /** * Hands one queued submission to the provider. The `dispatch{pending}` row goes first: a crash * after it leaves a message in doubt, never one that reads as queued and so provably unwritten. + * Returns the cause when the child's start failed at the handover, leaving the message pending for + * the caller to record that start's failure for it and everything queued behind it. */ export async function handOverSubmission( ctx: AgentSessionHandoverContext, submission: AgentJournalSubmission -): Promise { +): Promise<{ error: unknown } | null> { const { clientMessageId } = submission const body = ctx.journal.itemBody(agentJournalSubmissionKey(clientMessageId)) if (body?.kind !== 'message') { @@ -176,15 +168,18 @@ export async function handOverSubmission( ...agentSessionFailureWords(agentSessionFailureFact('hostFault'), { surface: 'rejection' }), fence: ctx.fence }) - return + return null } await ctx.journal.resolveDispatch({ clientMessageId, state: 'pending', fence: ctx.fence }) // The row written at acceptance is the send's instant on the host clock; the turn this // dispatch opens records it so the live counter never re-anchors at turn-open. const outcome = await dispatchSafely(ctx, clientMessageId, body, submission.submittedAt) + if (outcome.state === 'startFailed') { + return outcome.cause + } // An admission needs no dispatch row: the submission is already pending. if (outcome.state === 'admitted') { - return + return null } try { await ctx.journal.resolveDispatch( @@ -220,6 +215,7 @@ export async function handOverSubmission( } throw error } + return null } function requireSubmission( From b6d18ae66dd1f61aa3dfd431534920acbeea71b4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:19:14 -0700 Subject: [PATCH 30/37] fix(native-chat): say only 'not sent' on a message whose own start's row is loaded The short notice applied whenever a loaded start-failure row stated a failure equal to the message's, so a rejection no start wrote could borrow an unrelated start's row. It now applies only when the rejection names its start and that start's row is loaded; a rejection from an older host, which names none, keeps its full notice. --- ...turedSession.start-failure-notice.test.tsx | 16 ++++- .../NativeChatStructuredSession.tsx | 8 +-- ...red-agent-session-delivery-notices.test.ts | 53 ++++++++++----- ...ructured-agent-session-delivery-notices.ts | 64 ++++++------------- ...tured-agent-session-start-failure-facts.ts | 33 ---------- ...-agent-session-start-failure-keys.test.ts} | 24 +++---- ...ctured-agent-session-start-failure-keys.ts | 28 ++++++++ ...red-agent-session-start-failure-row-key.ts | 8 ++- 8 files changed, 122 insertions(+), 112 deletions(-) delete mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts rename src/renderer/src/components/native-chat/{use-structured-agent-session-start-failure-facts.test.ts => use-structured-agent-session-start-failure-keys.test.ts} (54%) create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-keys.ts 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 index 6f529c1d3c9f..0002820ef7a9 100644 --- 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 @@ -37,13 +37,14 @@ afterEach(() => { }) const SESSION_ID = 'start-failure-session' -const START_FAILED: AgentSessionFailureFact = { kind: 'providerStartFailed' } +const START_KEY = 'generation-1' +const START_FAILED: AgentSessionFailureFact = { kind: 'providerStartFailed', startKey: START_KEY } 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')), + itemId: agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity(START_KEY)), revision: 1, sequence: 1, observedAt: 1, @@ -154,6 +155,17 @@ it('keeps the full notice on a message rejected for a reason no start-failure ro ).toBeTruthy() }) +// A host from before starts were named writes the same failure with no start on it. +it('keeps the full notice on a rejection that names no start, beside an equal start row', async () => { + mocks.journalItems = [startFailureRow(START_FAILED)] + + renderPane([rejected('older', START_FAILED_REASON, { kind: 'providerStartFailed' })]) + + expect( + within(await notice('older')).getByText('Claude stopped before it finished starting.') + ).toBeTruthy() +}) + it("keeps the start failure's own words when its row is not loaded", async () => { renderPane([rejected('first', START_FAILED_REASON, START_FAILED)]) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 6daa3d3f62c9..328da2335d2c 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -26,7 +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 { useStructuredAgentSessionStartFailureKeys } from './use-structured-agent-session-start-failure-keys' import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' @@ -117,7 +117,7 @@ export function NativeChatStructuredSession( // Only a rejected message reads the journal's rows, so a new batch of them re-renders no row else. const hasRejected = controller.outbox.some((entry) => entry.state === 'rejected') const rejectionRows = hasRejected ? controller.submissions : NO_SUBMISSIONS - const startFailures = useStructuredAgentSessionStartFailureFacts( + const startFailureKeys = useStructuredAgentSessionStartFailureKeys( controller.journalItems, hasRejected ) @@ -129,7 +129,7 @@ export function NativeChatStructuredSession( agentLabel, retryDelivery, rejectionRows, - startFailures + startFailureKeys ), [ controller.outbox, @@ -137,7 +137,7 @@ export function NativeChatStructuredSession( agentLabel, retryDelivery, rejectionRows, - startFailures + startFailureKeys ] ) const viewState = selectNativeChatViewState(session, { readRetries: true }) 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 faadac1127ef..99397b7f4be0 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 @@ -16,7 +16,7 @@ import { import { structuredAgentSessionStartFailureRowIdentity } from '../../../../shared/structured-agent-session-start-failure-row-key' import { structuredAgentSessionDeliveryNotices, - structuredAgentSessionStartFailureFacts + structuredAgentSessionStartFailureKeys } from './structured-agent-session-delivery-notices' function entry( @@ -39,7 +39,7 @@ function texts( outbox: StructuredAgentSessionOutboxEntry[], blocked: string | null = null, submissions: readonly AgentJournalSubmission[] = [], - startFailures: readonly AgentSessionFailureFact[] = [] + startFailureKeys: readonly string[] = [] ): Record { const notices = structuredAgentSessionDeliveryNotices( outbox, @@ -47,7 +47,7 @@ function texts( 'Claude', () => {}, submissions, - startFailures + startFailureKeys ) return Object.fromEntries([...notices].map(([id, notice]) => [id, notice.text])) } @@ -324,12 +324,14 @@ describe('the notice on each message that did not go through', () => { 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. + // Matched on the start the rejection names and a loaded row keyed by it, never on either sentence + // or on the failure the two state. 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 byStart = (startKey: string): AgentSessionFailureFact => ({ ...startFailed, startKey }) const rejected = (id: string, fact: AgentSessionFailureFact) => entry(id, { state: 'rejected', @@ -361,15 +363,17 @@ describe('the notice on each message that did not go through', () => { ...agentSessionFailureWords(fact, { agentName: 'Claude', surface: 'row' }) } }) - const startRowKey = agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('gen')) + const startRowKey = (startKey: string) => + agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity(startKey)) + const shown = "Claude couldn't start. Start a new chat to continue." it('reads only the start-failure rows', () => { expect( - structuredAgentSessionStartFailureFacts([ - statusRow(startRowKey, startFailed), + structuredAgentSessionStartFailureKeys([ + statusRow(startRowKey('gen'), byStart('gen')), statusRow(agentJournalSubmissionKey('exit-row'), { kind: 'providerExited' }) ]) - ).toEqual([startFailed]) + ).toEqual(['gen']) }) it('says only that each was not sent, and words any other rejection in full', () => { @@ -377,7 +381,9 @@ describe('the notice on each message that did not go through', () => { kind: 'startFailed', refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } } } - const facts = structuredAgentSessionStartFailureFacts([statusRow(startRowKey, startFailed)]) + const keys = structuredAgentSessionStartFailureKeys([ + statusRow(startRowKey('gen'), byStart('gen')) + ]) expect( texts( [ @@ -387,11 +393,11 @@ describe('the notice on each message that did not go through', () => { ], null, [ - recorded('first', startFailed), - recorded('second', startFailed), + recorded('first', byStart('gen')), + recorded('second', byStart('gen')), recorded('other', otherRefusal) ], - facts + keys ) ).toEqual({ [agentJournalSubmissionKey('first')]: 'Your message was not sent.', @@ -401,13 +407,28 @@ describe('the notice on each message that did not go through', () => { }) }) - 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({ + // A rejection no start named — from a host before starts were named, or another writer — may + // state the same failure as a loaded start's row; the row does not explain it. + it('keeps the full notice on an equal failure that names no start, or another start', () => { + expect( + texts( + [rejected('unnamed', startFailed), rejected('elsewhere', startFailed)], + null, + [recorded('unnamed', startFailed), recorded('elsewhere', byStart('earlier'))], + ['gen'] + ) + ).toEqual({ + [agentJournalSubmissionKey('unnamed')]: shown, + [agentJournalSubmissionKey('elsewhere')]: shown + }) + }) + + it('keeps the full notice when the rejection is not loaded, or its start row is not', () => { + expect(texts([rejected('first', startFailed)], null, [], ['gen'])).toEqual({ [agentJournalSubmissionKey('first')]: 'Written by the host.' }) expect( - texts([rejected('first', startFailed)], null, [recorded('first', startFailed)], []) + texts([rejected('first', startFailed)], null, [recorded('first', byStart('gen'))], []) ).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 ec3eb60e6875..56d2c390adba 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 @@ -8,20 +8,16 @@ // // A message the host recorded and then rejected is worded from the journal's own fact, found by id; // the message keeps only a smaller copy, read when its submission is not loaded. A rejection that -// is a failed start's, the fact its loaded row states, says only that it was not sent: the row -// already says why. +// names a failed start whose row is loaded says only that it was not sent: the row already says why. -import { - readAgentSessionFailureFact, - type AgentSessionFailureFact -} from '../../../../shared/agent-session-failure' +import { readAgentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' import type { AgentJournalRenderItem, AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice' -import { isStructuredAgentSessionStartFailureRow } from '../../../../shared/structured-agent-session-start-failure-row-key' +import { structuredAgentSessionStartFailureRowStartKey } from '../../../../shared/structured-agent-session-start-failure-row-key' import { admitStructuredAgentSessionOutboxEntry, type StructuredAgentSessionOutboxEntry @@ -32,45 +28,28 @@ 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( +/** The starts the chat's loaded start-failure rows are for. */ +export function structuredAgentSessionStartFailureKeys( items: readonly AgentJournalRenderItem[] -): AgentSessionFailureFact[] { - const facts: AgentSessionFailureFact[] = [] +): string[] { + const keys: string[] = [] for (const item of items) { - if (item.body.kind === 'status' && isStructuredAgentSessionStartFailureRow(item.itemId)) { - const fact = readAgentSessionFailureFact(item.body.failure) - if (fact) { - facts.push(fact) - } + const startKey = + item.body.kind === 'status' + ? structuredAgentSessionStartFailureRowStartKey(item.itemId) + : null + if (startKey) { + keys.push(startKey) } } - 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 - ) + return keys } function deliveryNoticeText( entry: StructuredAgentSessionOutboxEntry, context: AgentSessionFailureWordsContext, recorded: AgentJournalSubmission | undefined, - startFailures: readonly AgentSessionFailureFact[] + startFailureKeys: readonly string[] ): string { if (entry.state === 'unconfirmed') { return translate( @@ -85,11 +64,8 @@ function deliveryNoticeText( ) } const fact = readAgentSessionFailureFact(recorded?.rejection) - if ( - entry.state === 'rejected' && - fact && - startFailures.some((stated) => sameAgentSessionFailureFact(stated, fact)) - ) { + // Only the start's own writer names it, so an equal failure from elsewhere keeps its words. + if (entry.state === 'rejected' && fact?.startKey && startFailureKeys.includes(fact.startKey)) { return agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send')) } return agentSessionWriteNoticeText( @@ -106,8 +82,8 @@ export function structuredAgentSessionDeliveryNotices( retry: (clientMessageId: string) => void, /** The journal's rows, whose rejected ones carry more of a rejection than the message keeps. */ submissions: readonly AgentJournalSubmission[], - /** What the loaded start-failure rows state, from `structuredAgentSessionStartFailureFacts`. */ - startFailures: readonly AgentSessionFailureFact[] + /** The starts the loaded start-failure rows are for, from `structuredAgentSessionStartFailureKeys`. */ + startFailureKeys: readonly string[] ): ReadonlyMap { const admission = admitStructuredAgentSessionOutboxEntry(outbox, blockedClientMessageId) const held = admission.state === 'blocked' ? admission.entry.clientMessageId : null @@ -125,7 +101,7 @@ export function structuredAgentSessionDeliveryNotices( entry, { agentName, retryControl }, rejected.get(entry.clientMessageId), - startFailures + startFailureKeys ) notices.set( agentJournalSubmissionKey(entry.clientMessageId), 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 deleted file mode 100644 index 83f20103de11..000000000000 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { useEffect, useMemo, useRef } from 'react' -import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' -import { - sameAgentSessionFailureFact, - structuredAgentSessionStartFailureFacts -} from './structured-agent-session-delivery-notices' - -const NO_FACTS: readonly AgentSessionFailureFact[] = [] - -/** What the loaded start-failure rows state, read only while `enabled`. Held while unchanged, so a - * streaming turn does not rebuild every row's delivery notice. */ -export function useStructuredAgentSessionStartFailureFacts( - items: readonly AgentJournalRenderItem[], - enabled: boolean -): readonly AgentSessionFailureFact[] { - const facts = useMemo( - () => (enabled ? structuredAgentSessionStartFailureFacts(items) : NO_FACTS), - [enabled, items] - ) - const previousRef = useRef(NO_FACTS) - const previous = previousRef.current - const stable = - previous.length === facts.length && - previous.every((fact, index) => sameAgentSessionFailureFact(fact, facts[index])) - ? previous - : facts - // Written after commit, so render stays pure. - useEffect(() => { - previousRef.current = stable - }, [stable]) - return stable -} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-keys.test.ts similarity index 54% rename from src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts rename to src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-keys.test.ts index 97ef023dffb7..a53278b62dde 100644 --- 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-keys.test.ts @@ -2,14 +2,13 @@ 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' +import { useStructuredAgentSessionStartFailureKeys } from './use-structured-agent-session-start-failure-keys' -function startFailureRow(startKey: string, fact: AgentSessionFailureFact): AgentJournalRenderItem { +function startFailureRow(startKey: string): AgentJournalRenderItem { return { itemId: agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity(startKey)), revision: 1, @@ -18,25 +17,28 @@ function startFailureRow(startKey: string, fact: AgentSessionFailureFact): Agent body: { kind: 'status', tone: 'error', - ...agentSessionFailureWords(fact, { agentName: 'Claude', surface: 'row' }) + ...agentSessionFailureWords( + { kind: 'providerStartFailed', startKey }, + { 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' }) +// A streaming turn hands over a new item list on every delta; the keys must not follow it. +it('holds the same keys while the start rows name no new start', () => { + const row = startFailureRow('gen-1') const { result, rerender } = renderHook( - ({ items }) => useStructuredAgentSessionStartFailureFacts(items, true), + ({ items }) => useStructuredAgentSessionStartFailureKeys(items, true), { initialProps: { items: [row] } } ) const first = result.current - expect(first).toEqual([{ kind: 'providerStartFailed' }]) + expect(first).toEqual(['gen-1']) - rerender({ items: [row, startFailureRow('other-start', { kind: 'providerStartFailed' })] }) + rerender({ items: [row, startFailureRow('other-start')] }) expect(result.current).not.toBe(first) const second = result.current - rerender({ items: [row, startFailureRow('other-start', { kind: 'providerStartFailed' })] }) + rerender({ items: [row, startFailureRow('other-start')] }) expect(result.current).toBe(second) }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-keys.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-keys.ts new file mode 100644 index 000000000000..31ad438b924d --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-keys.ts @@ -0,0 +1,28 @@ +import { useEffect, useMemo, useRef } from 'react' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { structuredAgentSessionStartFailureKeys } from './structured-agent-session-delivery-notices' + +const NO_KEYS: readonly string[] = [] + +/** The starts the loaded start-failure rows are for, read only while `enabled`. Held while + * unchanged, so a streaming turn does not rebuild every row's delivery notice. */ +export function useStructuredAgentSessionStartFailureKeys( + items: readonly AgentJournalRenderItem[], + enabled: boolean +): readonly string[] { + const keys = useMemo( + () => (enabled ? structuredAgentSessionStartFailureKeys(items) : NO_KEYS), + [enabled, items] + ) + const previousRef = useRef(NO_KEYS) + const previous = previousRef.current + const stable = + previous.length === keys.length && previous.every((key, index) => key === keys[index]) + ? previous + : keys + // 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 index 6dc4a4817b14..f4e523f4853e 100644 --- a/src/shared/structured-agent-session-start-failure-row-key.ts +++ b/src/shared/structured-agent-session-start-failure-row-key.ts @@ -11,7 +11,11 @@ export function structuredAgentSessionStartFailureRowIdentity( return { provider: 'orca', clientMessageId: `${START_FAILURE_ROW}${startKey}` } } -export function isStructuredAgentSessionStartFailureRow(itemId: string): boolean { +/** The start a start-failure row is for; null for any other row. */ +export function structuredAgentSessionStartFailureRowStartKey(itemId: string): string | null { const identity = parseAgentJournalItemKey(itemId) - return identity?.provider === 'orca' && identity.clientMessageId.startsWith(START_FAILURE_ROW) + if (identity?.provider !== 'orca' || !identity.clientMessageId.startsWith(START_FAILURE_ROW)) { + return null + } + return identity.clientMessageId.slice(START_FAILURE_ROW.length) || null } From 04faafe663b18584bc5cfc309d2b9a20bab7e569 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:31:15 -0700 Subject: [PATCH 31/37] fix(native-chat): every report of a failed start names it, in its row's words The start's row is first-writer-wins, but a second report of the same start rejected its messages in its own words under the same start key, so the short notice could sit under a row giving a different reason. Both writers of a start's row and its paired rejections, the delivery loop's and the exit settlement's, now read the start's row: once it is written they reject with its words and fact and leave it as it is; otherwise they write it. The exit settlement also names the start on the row and the messages it rejects, with the key that forms the row's identity. --- ...session-dead-generation-settlement.test.ts | 70 ++++++++++++++-- ...gent-session-dead-generation-settlement.ts | 38 ++++++--- ...ed-agent-session-start-failure-row.test.ts | 82 +++++++++++++++++++ ...uctured-agent-session-start-failure-row.ts | 58 +++++++++---- ...agent-session-startup-failure-exit.test.ts | 5 +- 5 files changed, 220 insertions(+), 33 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index 4a0febb6ceb9..28de30296966 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -10,6 +10,8 @@ import { providerDiagnostic } from '../../../shared/agent-session-failure' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { structuredAgentSessionStartFailureRow } from './structured-agent-session-start-failure-row' import { captureUnfinishedStructuredAgentSessionWork, settleStructuredAgentSessionDeadGeneration, @@ -20,6 +22,7 @@ const UNEXPECTED_PROVIDER_EXIT_OUTCOME = 'The agent stopped while this response was in progress. You can continue in this conversation.' const SESSION = 'session-dead-generation' +const START_ROW = `orca:${encodeURIComponent('start-failure:generation-1')}` const THREAD = 'thread-1' let root: string let journal: AgentSessionJournal @@ -315,18 +318,73 @@ describe('dead structured-session generation settlement', () => { exitedDuringStartup: { generation: 'generation-1' } }) - // The sentence is Orca's; the stderr the exit carried rides as a log detail only. + // The sentence is Orca's; the stderr the exit carried rides as a log detail only. The message + // and the start's row name the start, the row's own key. + const reason = 'The agent stopped before it finished starting. Send your message to try again.' + const rejection = { + kind: 'providerStartFailed', + detail: { text: 'code 1\nnot signed in', audience: 'log' }, + startKey: 'generation-1' + } + expect(journal.submissions()).toEqual([ + expect.objectContaining({ + clientMessageId: 'client-held', + dispatchState: 'rejected', + reason, + rejection + }) + ]) + expect(journal.itemBody(START_ROW)).toEqual({ + kind: 'status', + text: reason, + tone: 'error', + failure: rejection + }) + }) + + // An earlier report of the same start wrote its row first; its words are the start's. + it("rejects a send the start left pending in the start's row's words, leaving the row", async () => { + const first = agentSessionFailureWords(agentSessionFailureFact('notSignedIn'), { + surface: 'rejection' + }) + const row = structuredAgentSessionStartFailureRow('generation-1', { + reason: first.reason, + rejection: { ...first.rejection, startKey: 'generation-1' } + }) + await journal.appendLifecycleBatch({ + settlementId: 'start-failure:generation-1', + fence: 7, + recovered: true, + mutations: [row] + }) + const written = journal.itemBody(START_ROW) + await journal.appendSubmission({ + clientMessageId: 'client-held', + payloadFingerprint: 'fingerprint', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello?' }] }, + fence: 7 + }) + + await settleStructuredAgentSessionDeadGeneration({ + journal, + sessionId: SESSION, + fence: 7, + settlementId: `provider-exit:${SESSION}:7:generation-1`, + pendingSubmissionReason: 'provider_closed_before_acknowledgement', + verdict: { state: 'interrupted', completedAt: 1_000 }, + exitFailure: agentSessionFailureFact('providerExited'), + exitedDuringStartup: { generation: 'generation-1' } + }) + expect(journal.submissions()).toEqual([ expect.objectContaining({ clientMessageId: 'client-held', dispatchState: 'rejected', - reason: 'The agent stopped before it finished starting. Send your message to try again.', - rejection: { - kind: 'providerStartFailed', - detail: { text: 'code 1\nnot signed in', audience: 'log' } - } + reason: first.reason, + rejection: { kind: 'notSignedIn', startKey: 'generation-1' } }) ]) + expect(journal.itemBody(START_ROW)).toEqual(written) }) it("keeps a subagent's settled rows the subagent's, in one batch and after a reopen", async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index f6a166c7c02d..2b7a86d50495 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -20,8 +20,9 @@ import { } from '../../../shared/agent-session-failure-words' import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' import { - hasStructuredAgentSessionStartFailureRow, - structuredAgentSessionStartFailureRow + structuredAgentSessionStartFailureRejection, + structuredAgentSessionStartFailureRow, + structuredAgentSessionStartFailureRowItemId } from './structured-agent-session-start-failure-row' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' import { @@ -96,6 +97,14 @@ export function unfinishedStructuredAgentSessionWorkWasInterrupted( return outcomeItems.some((item) => !isCleanlySettled(currentItems.get(item.itemId))) } +function startFailureRowBody( + journal: DeadGenerationJournal, + startKey: string +): AgentJournalItemBody | undefined { + const itemId = structuredAgentSessionStartFailureRowItemId(startKey) + return journal.snapshot().items.find((item) => item.itemId === itemId)?.body +} + export async function settleStructuredAgentSessionDeadGeneration(input: { journal: DeadGenerationJournal sessionId: string @@ -123,24 +132,33 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { // child that never proved its start accepted nothing either — input is written only after it // initializes — so every send it was handed is rejected with the child's own diagnostic. A // proven child's handed-over sends stay in doubt. - const startupFailure = input.exitedDuringStartup - ? structuredAgentSessionStartFailure({ exit: input.exitFailure }, input.failureTextContext) + const startKey = input.exitedDuringStartup + ? (input.exitedDuringStartup.generation ?? input.settlementId) : null + const startupFailure = + startKey === null + ? null + : structuredAgentSessionStartFailureRejection( + startFailureRowBody(input.journal, startKey), + startKey, + structuredAgentSessionStartFailure( + { exit: input.exitFailure }, + input.failureTextContext + ) + ) await (startupFailure - ? input.journal.rejectPendingSubmissions(input.fence, startupFailure) + ? input.journal.rejectPendingSubmissions(input.fence, startupFailure.words) : input.journal.markPendingSubmissionsUnknown(input.fence, input.pendingSubmissionReason)) const items = input.journal.snapshot().items const mutations: JournalLifecycleMutationInput[] = [] - if (showUnexpectedExitOutcome && input.exitedDuringStartup && startupFailure) { - const startKey = input.exitedDuringStartup.generation ?? input.settlementId + if (showUnexpectedExitOutcome && startKey !== null && startupFailure) { // A start a message waited on is the delivery loop's to record, before or after this exit, // in the words it rejected the message with; this row is for a command, goal or rewind start. // A row already written stays: rejected is terminal, so its words are not reworded. const recordedByDeliveryLoop = - input.journal.submissions?.().some(isQueuedAgentJournalSubmission) || - hasStructuredAgentSessionStartFailureRow(items, startKey) + input.journal.submissions?.().some(isQueuedAgentJournalSubmission) || startupFailure.written if (!recordedByDeliveryLoop) { - mutations.push(structuredAgentSessionStartFailureRow(startKey, startupFailure)) + mutations.push(structuredAgentSessionStartFailureRow(startKey, startupFailure.words)) } } else if (showUnexpectedExitOutcome) { mutations.push({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.test.ts new file mode 100644 index 000000000000..4cf86a08a951 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.test.ts @@ -0,0 +1,82 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' +import { + recordStructuredAgentSessionStartFailure, + structuredAgentSessionStartFailureRow, + structuredAgentSessionStartFailureRowItemId +} from './structured-agent-session-start-failure-row' + +const START_KEY = 'generation-1' +let root: string +let journal: AgentSessionJournal + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-start-failure-row-')) + journal = await openAgentSessionJournal({ + identity: { + sessionId: 'session-start-failure', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: root, + now: () => 1_000 + }) +}) + +afterEach(async () => { + await journal.close() + await rm(root, { recursive: true, force: true }) +}) + +// A second report of a start whose row is written: the short notice beside that row must not sit +// under a different reason, so the messages take the row's words. +it("rejects a later report's messages in the start's row's words, leaving the row", async () => { + const first = agentSessionFailureWords(agentSessionFailureFact('notSignedIn'), { + surface: 'rejection' + }) + await journal.appendLifecycleBatch({ + settlementId: `start-failure:${START_KEY}`, + fence: 7, + recovered: true, + mutations: [ + structuredAgentSessionStartFailureRow(START_KEY, { + reason: first.reason, + rejection: { ...first.rejection, startKey: START_KEY } + }) + ] + }) + const row = journal.itemBody(structuredAgentSessionStartFailureRowItemId(START_KEY)) + await journal.appendSubmission({ + clientMessageId: 'client-queued', + payloadFingerprint: 'fingerprint', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello?' }] }, + fence: 7, + handoverRecorded: true + }) + + const later = structuredAgentSessionStartFailure({ exit: undefined }) + expect(later.reason).not.toBe(first.reason) + await recordStructuredAgentSessionStartFailure( + { journal, fence: 7 }, + { startKey: START_KEY, ...later } + ) + + expect(journal.submissions()).toEqual([ + expect.objectContaining({ + clientMessageId: 'client-queued', + dispatchState: 'rejected', + reason: first.reason, + rejection: { kind: 'notSignedIn', startKey: START_KEY } + }) + ]) + expect(journal.itemBody(structuredAgentSessionStartFailureRowItemId(START_KEY))).toEqual(row) +}) 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 88ce18efcee2..9d7bf911fe3a 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,4 +1,9 @@ +import { + isSubmissionRejectionFact, + readAgentSessionFailureFact +} from '../../../shared/agent-session-failure' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' 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' @@ -28,14 +33,31 @@ export function structuredAgentSessionStartFailureRow( } } -/** Whether the start's row is already written. Its words are the ones its rejected messages carry, - * and rejected is terminal, so the exit's later report of the same start must not reword it. */ -export function hasStructuredAgentSessionStartFailureRow( - items: readonly { itemId: string }[], - startKey: string -): boolean { - const itemId = agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity(startKey)) - return items.some((item) => item.itemId === itemId) +export function structuredAgentSessionStartFailureRowItemId(startKey: string): string { + return agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity(startKey)) +} + +/** + * What a failed start rejects its messages with, naming the start. Once its row is written, the + * row's words win: a later report of the same start never puts other words beside it, and never + * rewrites the row (`written`). A written row whose fact this build cannot read names no start. + */ +export function structuredAgentSessionStartFailureRejection( + row: AgentJournalItemBody | null | undefined, + startKey: string, + words: StructuredAgentSessionStartFailureWords +): { words: StructuredAgentSessionStartFailureWords; written: boolean } { + if (!row) { + return { words: { ...words, rejection: { ...words.rejection, startKey } }, written: false } + } + if (row.kind !== 'status' || !row.failure) { + return { words, written: true } + } + const fact = readAgentSessionFailureFact(row.failure) + if (!fact || !isSubmissionRejectionFact(fact)) { + return { words, written: true } + } + return { words: { reason: row.text, rejection: { ...fact, startKey } }, written: true } } /** @@ -55,13 +77,19 @@ export async function recordStructuredAgentSessionStartFailure( } const startKey = failure.startKey ?? oldest // The row and each message it rejects name this start, so a reader pairs them by identity. - const words = { reason: failure.reason, rejection: { ...failure.rejection, startKey } } - await session.journal.appendLifecycleBatch({ - settlementId: `start-failure:${startKey}`, - fence: session.fence, - recovered: true, - mutations: [structuredAgentSessionStartFailureRow(startKey, words)] - }) + const { words, written } = structuredAgentSessionStartFailureRejection( + session.journal.itemBody(structuredAgentSessionStartFailureRowItemId(startKey)), + startKey, + { reason: failure.reason, rejection: failure.rejection } + ) + if (!written) { + await session.journal.appendLifecycleBatch({ + settlementId: `start-failure:${startKey}`, + fence: session.fence, + recovered: true, + mutations: [structuredAgentSessionStartFailureRow(startKey, words)] + }) + } if (handedOver) { await session.journal.resolveDispatch({ clientMessageId: handedOver, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index dae8635cd69d..74f7fd74d5e2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -91,7 +91,7 @@ describe('a provider that ends before it finished starting', () => { kind: 'status', text: 'Claude is not signed in for the selected account. Sign in, then send your message again.', tone: 'error', - failure: { kind: 'notSignedIn' } + failure: { kind: 'notSignedIn', startKey: GENERATION } } }) ] @@ -132,7 +132,8 @@ describe('a provider that ends before it finished starting', () => { tone: 'error', failure: { kind: 'providerStartFailed', - detail: { text: REASON, audience: 'log' } + detail: { text: REASON, audience: 'log' }, + startKey: GENERATION } } }) From c138f693bf4d1885efb6022a1cb516bab22c186f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:51:38 -0700 Subject: [PATCH 32/37] fix(native-chat): a rejected message names the start that rejected it on its own record The start that rejected a message is who rejected it, a delivery fact, not part of the failure. It moves off the failure fact onto the submission as `rejectedByStartKey`, persisted on the rejected dispatch row beside `rejection`; the start's row stays keyed by it and states only the failure. The exit of a child that never started now writes the start's row whenever it rejects what the child was handed, keyed by the child's generation as the delivery loop keys it, so the row and those messages never wait on the loop to agree. --- .../journal-dispatch-reducer.ts | 9 ++ .../journal-pending-submission-recovery.ts | 6 +- .../journal-reducer-rejection-fact.test.ts | 51 ++++++- .../journal-row-builders.ts | 3 + .../journal-row-schema.ts | 3 + .../journal-store-contracts.ts | 8 +- .../agent-session-journal/journal-store.ts | 6 +- ...-agent-session-accept-then-deliver.test.ts | 13 +- ...session-dead-generation-settlement.test.ts | 127 ++++++++++++++++-- ...gent-session-dead-generation-settlement.ts | 18 ++- ...gent-session-provider-child-record.test.ts | 31 +++-- ...ed-agent-session-start-failure-row.test.ts | 10 +- ...uctured-agent-session-start-failure-row.ts | 19 ++- ...agent-session-start-failure-writer.test.ts | 14 +- ...agent-session-startup-failure-exit.test.ts | 5 +- ...turedSession.start-failure-notice.test.tsx | 38 ++++-- ...red-agent-session-delivery-notices.test.ts | 26 ++-- ...ructured-agent-session-delivery-notices.ts | 10 +- ...tured-agent-session-outbox-storage.test.ts | 6 +- ...d-agent-session-start-failure-keys.test.ts | 2 +- src/shared/agent-session-failure.test.ts | 16 --- src/shared/agent-session-failure.ts | 8 +- .../agent-session-journal-schemas.test.ts | 15 +-- src/shared/agent-session-journal-schemas.ts | 4 +- src/shared/agent-session-journal-types.ts | 3 + ...d-agent-session-dispatch-rejection.test.ts | 7 - 26 files changed, 319 insertions(+), 139 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts index 470ed3dfcb24..77fd500cae43 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts @@ -36,6 +36,15 @@ export function applyJournalDispatchRow( } else { delete submission.rejection } + if ( + row.state === 'rejected' && + typeof row.rejectedByStartKey === 'string' && + row.rejectedByStartKey + ) { + submission.rejectedByStartKey = row.rejectedByStartKey + } else { + delete submission.rejectedByStartKey + } submission.resolvedAt = row.state === 'pending' ? null : row.ts if (row.state === 'pending') { submission.handedOverAt = row.ts diff --git a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts index c252a57d8a57..7048d0d4210d 100644 --- a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts +++ b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts @@ -1,8 +1,8 @@ -import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { DISPATCH_DOUBT_HOST_RESTARTED } from './journal-dispatch-doubt-reasons' import type { AgentSessionJournal } from './journal-store' +import type { JournalDispatchRejectionInput } from './journal-store-contracts' /** Settles every submission a process fact left unanswerable. Doubt is never * proof of non-delivery, so nothing here ever becomes re-deliverable. A queued @@ -41,7 +41,7 @@ export async function markJournalPendingSubmissionsUnknown( export async function rejectJournalPendingSubmissions( journal: AgentSessionJournal, fence: number, - rejection: AgentJournalDispatchRejection + rejection: JournalDispatchRejectionInput ): Promise { const unwritten = journal .submissions() @@ -67,7 +67,7 @@ export async function rejectJournalPendingSubmissions( export async function rejectJournalQueuedSubmissions( journal: AgentSessionJournal, fence: number, - rejection: AgentJournalDispatchRejection, + rejection: JournalDispatchRejectionInput, which: (submission: AgentJournalSubmission) => boolean = () => true ): Promise { const queued = journal diff --git a/src/main/native-chat/agent-session-journal/journal-reducer-rejection-fact.test.ts b/src/main/native-chat/agent-session-journal/journal-reducer-rejection-fact.test.ts index 3332e53b0b3e..296256af3f29 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer-rejection-fact.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer-rejection-fact.test.ts @@ -10,7 +10,7 @@ import { createJournalReducerState, type JournalReducerState } from './journal-reducer' -import type { JournalRow } from './journal-row-schema' +import { parseJournalRow, serializeJournalRow, type JournalRow } from './journal-row-schema' const EPOCH = 'epoch-1' @@ -97,4 +97,53 @@ describe('a rejected dispatch', () => { expect(malformed).not.toHaveProperty('rejection') expect(malformed && classifyDispatchRejection(malformed)).toMatchObject({ kind: 'queueFull' }) }) + + // Set only by a failed start's writer; a host from before it, or any other writer, writes none. + describe('the start that rejected it', () => { + const rejectedBy = (rejectedByStartKey: unknown, state = 'rejected'): JournalRow => { + const row: JournalRow = { + kind: 'dispatch', + clientMessageId: 'cm_1', + state: state === 'rejected' ? 'rejected' : 'unknown', + providerItemId: null, + reason: "Codex couldn't start.", + rejection: { kind: 'startFailed' }, + ...base(2) + } + // A row read from disk carries whatever the host that wrote it did. + return Object.assign(row, { rejectedByStartKey }) + } + + it('copies it onto a rejected submission, beside the fact it leaves alone', () => { + const settled = fold([submission, rejectedBy('generation-2')]).submissions.get('cm_1') + expect(settled).toMatchObject({ + dispatchState: 'rejected', + rejection: { kind: 'startFailed' }, + rejectedByStartKey: 'generation-2' + }) + const unnamed = fold([submission, rejectedBy(undefined)]).submissions.get('cm_1') + expect(unnamed).not.toHaveProperty('rejectedByStartKey') + // Identity, not the situation: the verdict is the same whichever start it names. + expect(settled && classifyDispatchRejection(settled)).toEqual( + unnamed && classifyDispatchRejection(unnamed) + ) + const doubt = fold([submission, rejectedBy('generation-2', 'unknown')]).submissions.get( + 'cm_1' + ) + expect(doubt).not.toHaveProperty('rejectedByStartKey') + }) + + it('drops a malformed one when read, never the row', () => { + for (const malformed of ['', 7, { generation: 'generation-2' }]) { + const parsed = parseJournalRow(serializeJournalRow(rejectedBy(malformed))) + expect(parsed.ok).toBe(true) + const settled = parsed.ok ? fold([submission, parsed.row]).submissions.get('cm_1') : null + expect(settled).toMatchObject({ + dispatchState: 'rejected', + rejection: { kind: 'startFailed' } + }) + expect(settled).not.toHaveProperty('rejectedByStartKey') + } + }) + }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index ee56f28f8a25..19944f6b3e0e 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -85,6 +85,9 @@ export function journalDispatchRowBuilder( providerItemId, reason: boundedDispatchReason(input), ...(input.state === 'rejected' ? { rejection: input.rejection } : {}), + ...(input.state === 'rejected' && input.rejectedByStartKey + ? { rejectedByStartKey: input.rejectedByStartKey } + : {}), ...journalRowBase(state().epoch, seq, input.fence, ts), ...(input.recovered ? { recovered: input.recovered } : {}) }) diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 786d422876cb..cb887e0e06e5 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -93,6 +93,9 @@ export type JournalDispatchRow = JournalRowBase & { /** On `rejected`: why, typed. Older readers keep the key and ignore it; a malformed one is * dropped when read, never the row. */ rejection?: AgentSessionFailureFact + /** On `rejected` by a failed start: that start's key. Older readers keep the key and ignore it; + * anything but a non-empty string is dropped when read, never the row. */ + rejectedByStartKey?: string } /** An item mutation may name its own producer, because one batch can CREATE diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index c2210efb253b..e0130c952b8b 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -34,10 +34,16 @@ export type ResolveDispatchInput = { | { state: 'pending' } /** `reason` is what released clients print, `rejection` what newer ones read: both from * `agentSessionFailureWords`, never written by hand. */ - | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | ({ state: 'rejected' } & JournalDispatchRejectionInput) | { state: 'unknown'; reason?: string | null } ) +/** A rejection, and the failed start that wrote it when it is one: only that start's writer + * names it, so its messages pair with its row by the row's own key. */ +export type JournalDispatchRejectionInput = AgentJournalDispatchRejection & { + rejectedByStartKey?: string +} + export type JournalAppendResult = { cursor: AgentJournalCursor itemId: string diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 45c42b45f8ad..e90a615f19de 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -1,6 +1,5 @@ // Append-only journal store for one agent session. -import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import { randomUUID } from 'node:crypto' import type { AgentJournalAcceptanceReceipt, @@ -47,6 +46,7 @@ import { import type { AgentSessionJournalOptions, JournalAppendResult, + JournalDispatchRejectionInput, JournalItemAppendOptions, JournalLifecycleBatchInput, JournalReadSince, @@ -322,7 +322,7 @@ export class AgentSessionJournal { /** Reject unanswered sends after an owner that never proved its start ended: none was written. */ async rejectPendingSubmissions( fence: number, - rejection: AgentJournalDispatchRejection + rejection: JournalDispatchRejectionInput ): Promise { return rejectJournalPendingSubmissions(this, fence, rejection) } @@ -330,7 +330,7 @@ export class AgentSessionJournal { /** Reject sends accepted but never handed over, optionally only those `which` names. */ async rejectQueuedSubmissions( fence: number, - rejection: AgentJournalDispatchRejection, + rejection: JournalDispatchRejectionInput, which?: (submission: AgentJournalSubmission) => boolean ): Promise { return rejectJournalQueuedSubmissions(this, fence, rejection, which) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index 757b2b1688a2..d3e61a62e3ae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -332,18 +332,18 @@ describe('a start the chat needed and did not get', () => { // Orca's spawn error goes to the log; the row and every message say what failed, typed. The // child is gone, but no exit was observed, so nothing blames the provider. expect(rows[0]).toBe("Codex couldn't restart. Send your message to try again.") - // No child was published, so the start is keyed by the oldest message it was for. const failure = { kind: 'restartFailed', - refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } }, - startKey: first + refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } } expect(await errorFailures()).toEqual([failure]) + // No child was published, so the start is keyed by the oldest message it was for. for (const id of [first, second]) { expect(await submission(id)).toMatchObject({ dispatchState: 'rejected', reason: rows[0], - rejection: failure + rejection: failure, + rejectedByStartKey: first }) } @@ -446,7 +446,8 @@ describe('a start the chat needed and did not get', () => { await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) expect(await errorRows()).toEqual([row.text]) - expect(await errorFailures()).toEqual([{ ...row.failure, startKey: id }]) + expect(await errorFailures()).toEqual([row.failure]) + expect(await submission(id)).toMatchObject({ rejectedByStartKey: id }) const framedRows = events.flatMap((event) => event.type === 'batch' || event.type === 'snapshot' ? (event.type === 'batch' ? event.batch.items : event.page.items).filter( @@ -538,6 +539,8 @@ describe('what an earlier host process left behind', () => { surface: 'rejection' }) }) + // No start rejected it, so it names none. + expect(await submission('queued')).not.toHaveProperty('rejectedByStartKey') expect(dispatch).not.toHaveBeenCalled() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index 28de30296966..21d2e792a979 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -11,7 +11,11 @@ import { } from '../../../shared/agent-session-failure' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { structuredAgentSessionStartFailureRow } from './structured-agent-session-start-failure-row' +import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' +import { + recordStructuredAgentSessionStartFailure, + structuredAgentSessionStartFailureRow +} from './structured-agent-session-start-failure-row' import { captureUnfinishedStructuredAgentSessionWork, settleStructuredAgentSessionDeadGeneration, @@ -319,19 +323,19 @@ describe('dead structured-session generation settlement', () => { }) // The sentence is Orca's; the stderr the exit carried rides as a log detail only. The message - // and the start's row name the start, the row's own key. + // names the start, the row's own key; the row's fact is only the failure. const reason = 'The agent stopped before it finished starting. Send your message to try again.' const rejection = { kind: 'providerStartFailed', - detail: { text: 'code 1\nnot signed in', audience: 'log' }, - startKey: 'generation-1' + detail: { text: 'code 1\nnot signed in', audience: 'log' } } expect(journal.submissions()).toEqual([ expect.objectContaining({ clientMessageId: 'client-held', dispatchState: 'rejected', reason, - rejection + rejection, + rejectedByStartKey: 'generation-1' }) ]) expect(journal.itemBody(START_ROW)).toEqual({ @@ -347,10 +351,7 @@ describe('dead structured-session generation settlement', () => { const first = agentSessionFailureWords(agentSessionFailureFact('notSignedIn'), { surface: 'rejection' }) - const row = structuredAgentSessionStartFailureRow('generation-1', { - reason: first.reason, - rejection: { ...first.rejection, startKey: 'generation-1' } - }) + const row = structuredAgentSessionStartFailureRow('generation-1', first) await journal.appendLifecycleBatch({ settlementId: 'start-failure:generation-1', fence: 7, @@ -381,12 +382,118 @@ describe('dead structured-session generation settlement', () => { clientMessageId: 'client-held', dispatchState: 'rejected', reason: first.reason, - rejection: { kind: 'notSignedIn', startKey: 'generation-1' } + rejection: { kind: 'notSignedIn' }, + rejectedByStartKey: 'generation-1' }) ]) expect(journal.itemBody(START_ROW)).toEqual(written) }) + // The exit and the delivery loop each report one start: the exit what the child was handed, the + // loop what is still queued. Whichever reports first writes the start's row; both reject in it. + describe('a start that failed with one message handed over and one still queued', () => { + const START_KEY = 'generation-1' + const loopFailure = structuredAgentSessionStartFailure({ + failure: agentSessionFailureFact('hostStopped') + }) + + async function handedOverAndQueued(): Promise { + for (const clientMessageId of ['client-handed', 'client-queued']) { + await journal.appendSubmission({ + clientMessageId, + payloadFingerprint: 'fingerprint', + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: clientMessageId }] + }, + fence: 7, + handoverRecorded: true + }) + } + await journal.resolveDispatch({ + clientMessageId: 'client-handed', + state: 'pending', + fence: 7 + }) + } + + function settleExit(): Promise { + return settleStructuredAgentSessionDeadGeneration({ + journal, + sessionId: SESSION, + fence: 7, + settlementId: `provider-exit:${SESSION}:7:${START_KEY}`, + pendingSubmissionReason: 'provider_closed_before_acknowledgement', + verdict: { state: 'interrupted', completedAt: 1_000 }, + exitFailure: agentSessionFailureFact('notSignedIn'), + exitedDuringStartup: { generation: START_KEY } + }) + } + + function reportFromLoop(): Promise { + return recordStructuredAgentSessionStartFailure( + { journal, fence: 7 }, + { startKey: START_KEY, ...loopFailure } + ) + } + + function startRows(): unknown[] { + return journal + .snapshot() + .items.filter((item) => + item.itemId.startsWith(`orca:${encodeURIComponent('start-failure:')}`) + ) + } + + it.each([ + ['the exit first', [settleExit, reportFromLoop]], + ['the delivery loop first', [reportFromLoop, settleExit]] + ] as const)('writes one row and rejects both in its words, %s', async (_order, reports) => { + await handedOverAndQueued() + + for (const report of reports) { + await report() + } + + expect(startRows()).toHaveLength(1) + const row = journal.itemBody(START_ROW) + if (row?.kind !== 'status') { + throw new Error('no start row') + } + expect(journal.submissions()).toEqual( + ['client-handed', 'client-queued'].map((clientMessageId) => + expect.objectContaining({ + clientMessageId, + dispatchState: 'rejected', + reason: row.text, + rejection: row.failure, + rejectedByStartKey: START_KEY + }) + ) + ) + }) + + // With no generation the loop keys the start by its oldest queued message, not the exit's id, + // so the exit leaves the row to it rather than write a second one. + it('leaves the row to the delivery loop when the exit has no generation to key it by', async () => { + await handedOverAndQueued() + + await settleStructuredAgentSessionDeadGeneration({ + journal, + sessionId: SESSION, + fence: 7, + settlementId: `provider-exit:${SESSION}:7:null`, + pendingSubmissionReason: 'provider_closed_before_acknowledgement', + verdict: { state: 'interrupted', completedAt: 1_000 }, + exitFailure: agentSessionFailureFact('notSignedIn'), + exitedDuringStartup: { generation: null } + }) + + expect(startRows()).toEqual([]) + }) + }) + it("keeps a subagent's settled rows the subagent's, in one batch and after a reopen", async () => { // One batch settles rows several agents wrote and names none of them. Each // row keeps the producer its first write named, including after a replay. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 2b7a86d50495..e791faedb00d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -146,18 +146,22 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { input.failureTextContext ) ) - await (startupFailure + const rejected = await (startupFailure ? input.journal.rejectPendingSubmissions(input.fence, startupFailure.words) : input.journal.markPendingSubmissionsUnknown(input.fence, input.pendingSubmissionReason)) const items = input.journal.snapshot().items const mutations: JournalLifecycleMutationInput[] = [] if (showUnexpectedExitOutcome && startKey !== null && startupFailure) { - // A start a message waited on is the delivery loop's to record, before or after this exit, - // in the words it rejected the message with; this row is for a command, goal or rewind start. - // A row already written stays: rejected is terminal, so its words are not reworded. - const recordedByDeliveryLoop = - input.journal.submissions?.().some(isQueuedAgentJournalSubmission) || startupFailure.written - if (!recordedByDeliveryLoop) { + // A row already written stays: rejected is terminal, so its words are not reworded. A start + // that rejected what it was handed writes its row now, beside those messages; the delivery + // loop's later report of it rejects what is queued in the row's words. Only a generation + // keys both alike: without one the loop keys by the oldest queued message, so the row for a + // start messages still wait on is the loop's. With nothing queued, this is a command, goal or + // rewind start, and the row is this settlement's. + const queued = input.journal.submissions?.().some(isQueuedAgentJournalSubmission) + const keyedLikeDeliveryLoop = + rejected.length > 0 && input.exitedDuringStartup?.generation != null + if (!startupFailure.written && (!queued || keyedLikeDeliveryLoop)) { mutations.push(structuredAgentSessionStartFailureRow(startKey, startupFailure.words)) } } else if (showUnexpectedExitOutcome) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index e6922010453e..fceeedd1d10b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -410,14 +410,15 @@ describe('a published child that dies while it proves its start', () => { itemId: `orca:${encodeURIComponent(`start-failure:${child.acquisitionGeneration}`)}`, text: TEXT, tone: 'error', - failure: { ...START_FAILURE, startKey: child.acquisitionGeneration } + failure: START_FAILURE } ]) for (const id of [first, second]) { expect(await submission(id)).toMatchObject({ dispatchState: 'rejected', reason: TEXT, - rejection: { ...START_FAILURE, startKey: child.acquisitionGeneration } + rejection: START_FAILURE, + rejectedByStartKey: child.acquisitionGeneration }) } expect(rejectedIn(events, second)).toBe(true) @@ -460,13 +461,16 @@ describe('a start another operation made that dies while a sent message waits on await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) await settleLoop() - expect((await submission(id))?.reason).toBe(TEXT) + expect(await submission(id)).toMatchObject({ + reason: TEXT, + rejectedByStartKey: operationChild.acquisitionGeneration + }) expect(await statusRows()).toEqual([ { itemId: `orca:${encodeURIComponent(`start-failure:${operationChild.acquisitionGeneration}`)}`, text: TEXT, tone: 'error', - failure: { ...START_FAILURE, startKey: operationChild.acquisitionGeneration } + failure: START_FAILURE } ]) expect(rejectedIn(events, id)).toBe(true) @@ -546,15 +550,11 @@ describe('a child that ends before its message is handed over', () => { } expect(await submission(id)).toMatchObject({ reason: 'Codex stopped before this message was sent.', - rejection: failure + rejection: failure, + rejectedByStartKey: 'generation-2' }) expect(await statusRows()).toEqual([ - { - itemId: expect.any(String), - text: (await submission(id))?.reason, - tone: 'error', - failure: { ...failure, startKey: 'generation-2' } - } + { itemId: expect.any(String), text: (await submission(id))?.reason, tone: 'error', failure } ]) expect(rejectedIn(events, id)).toBe(true) await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) @@ -735,10 +735,13 @@ describe('how a stopped child ends the start its loop was waiting on', () => { await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) // The sentence is the constructor's, not the reason the stop was given. const text = 'Codex never finished starting, so Orca stopped it.' - const failure = { kind: 'hostStopped', startKey: 'generation-2' } - expect(await submission(second)).toMatchObject({ reason: text, rejection: failure }) + expect(await submission(second)).toMatchObject({ + reason: text, + rejection: { kind: 'hostStopped' }, + rejectedByStartKey: 'generation-2' + }) expect(await statusRows()).toEqual([ - { itemId: expect.any(String), text, tone: 'error', failure } + { itemId: expect.any(String), text, tone: 'error', failure: { kind: 'hostStopped' } } ]) expect(dispatch).not.toHaveBeenCalled() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.test.ts index 4cf86a08a951..d8d6df2b5d59 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.test.ts @@ -47,12 +47,7 @@ it("rejects a later report's messages in the start's row's words, leaving the ro settlementId: `start-failure:${START_KEY}`, fence: 7, recovered: true, - mutations: [ - structuredAgentSessionStartFailureRow(START_KEY, { - reason: first.reason, - rejection: { ...first.rejection, startKey: START_KEY } - }) - ] + mutations: [structuredAgentSessionStartFailureRow(START_KEY, first)] }) const row = journal.itemBody(structuredAgentSessionStartFailureRowItemId(START_KEY)) await journal.appendSubmission({ @@ -75,7 +70,8 @@ it("rejects a later report's messages in the start's row's words, leaving the ro clientMessageId: 'client-queued', dispatchState: 'rejected', reason: first.reason, - rejection: { kind: 'notSignedIn', startKey: START_KEY } + rejection: { kind: 'notSignedIn' }, + rejectedByStartKey: START_KEY }) ]) expect(journal.itemBody(structuredAgentSessionStartFailureRowItemId(START_KEY))).toEqual(row) 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 9d7bf911fe3a..cbdafb7d1092 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 @@ -7,6 +7,7 @@ import type { AgentJournalItemBody } from '../../../shared/agent-session-journal 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 { JournalDispatchRejectionInput } from '../agent-session-journal/journal-store-contracts' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import type { StructuredAgentSessionStartFailureWords } from './structured-agent-session-failure-text' @@ -38,17 +39,18 @@ export function structuredAgentSessionStartFailureRowItemId(startKey: string): s } /** - * What a failed start rejects its messages with, naming the start. Once its row is written, the - * row's words win: a later report of the same start never puts other words beside it, and never - * rewrites the row (`written`). A written row whose fact this build cannot read names no start. + * What a failed start rejects its messages with, naming the start on each. Once its row is written, + * the row's words win: a later report of the same start never puts other words beside it, and never + * rewrites the row (`written`). Beside a written row whose fact this build cannot read, the + * messages keep their own words and name no start. */ export function structuredAgentSessionStartFailureRejection( row: AgentJournalItemBody | null | undefined, startKey: string, words: StructuredAgentSessionStartFailureWords -): { words: StructuredAgentSessionStartFailureWords; written: boolean } { +): { words: JournalDispatchRejectionInput; written: boolean } { if (!row) { - return { words: { ...words, rejection: { ...words.rejection, startKey } }, written: false } + return { words: { ...words, rejectedByStartKey: startKey }, written: false } } if (row.kind !== 'status' || !row.failure) { return { words, written: true } @@ -57,7 +59,10 @@ export function structuredAgentSessionStartFailureRejection( if (!fact || !isSubmissionRejectionFact(fact)) { return { words, written: true } } - return { words: { reason: row.text, rejection: { ...fact, startKey } }, written: true } + return { + words: { reason: row.text, rejection: fact, rejectedByStartKey: startKey }, + written: true + } } /** @@ -76,7 +81,7 @@ export async function recordStructuredAgentSessionStartFailure( return } const startKey = failure.startKey ?? oldest - // The row and each message it rejects name this start, so a reader pairs them by identity. + // Each message it rejects names this start, the row's own key, so a reader pairs them by identity. const { words, written } = structuredAgentSessionStartFailureRejection( session.journal.itemBody(structuredAgentSessionStartFailureRowItemId(startKey)), startKey, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index 30fd9cbec9f5..a69cc8e83390 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -219,16 +219,22 @@ describe('a start whose child cannot take the message it was handed', () => { ) const firstRejected = await submission(first) const secondRejected = await submission(second) - // Nothing saw the provider stop when the handover failed; the row names the start it keys. - expect(firstRejected?.rejection).toEqual({ kind: 'startFailed', startKey: 'generation-2' }) - expect(row?.body).toMatchObject({ + // Nothing saw the provider stop when the handover failed; each message names the start the row + // is keyed by. + expect(firstRejected).toMatchObject({ + rejection: { kind: 'startFailed' }, + rejectedByStartKey: 'generation-2' + }) + expect(row?.body).toEqual({ kind: 'status', text: firstRejected?.reason, + tone: 'error', failure: firstRejected?.rejection }) expect(secondRejected).toMatchObject({ reason: firstRejected?.reason, - rejection: firstRejected?.rejection + rejection: firstRejected?.rejection, + rejectedByStartKey: 'generation-2' }) expect(publishedStartRows()).toEqual([firstRejected?.reason]) // One start failed, so one handover tried it. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index 74f7fd74d5e2..dae8635cd69d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -91,7 +91,7 @@ describe('a provider that ends before it finished starting', () => { kind: 'status', text: 'Claude is not signed in for the selected account. Sign in, then send your message again.', tone: 'error', - failure: { kind: 'notSignedIn', startKey: GENERATION } + failure: { kind: 'notSignedIn' } } }) ] @@ -132,8 +132,7 @@ describe('a provider that ends before it finished starting', () => { tone: 'error', failure: { kind: 'providerStartFailed', - detail: { text: REASON, audience: 'log' }, - startKey: GENERATION + detail: { text: REASON, audience: 'log' } } } }) 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 index 0002820ef7a9..839efb18863e 100644 --- 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 @@ -38,7 +38,7 @@ afterEach(() => { const SESSION_ID = 'start-failure-session' const START_KEY = 'generation-1' -const START_FAILED: AgentSessionFailureFact = { kind: 'providerStartFailed', startKey: START_KEY } +const START_FAILED: AgentSessionFailureFact = { kind: 'providerStartFailed' } const START_FAILED_REASON = 'Claude stopped before it finished starting. Send your message to try again.' @@ -56,7 +56,12 @@ function startFailureRow(fact: AgentSessionFailureFact): AgentJournalRenderItem } } -function rejected(clientMessageId: string, reason: string, rejection: AgentSessionFailureFact) { +function rejected( + clientMessageId: string, + reason: string, + rejection: AgentSessionFailureFact, + rejectedByStartKey?: string +) { return { outbox: { clientMessageId, @@ -78,7 +83,8 @@ function rejected(clientMessageId: string, reason: string, rejection: AgentSessi reason, rejection, submittedAt: 1, - resolvedAt: 1 + resolvedAt: 1, + ...(rejectedByStartKey ? { rejectedByStartKey } : {}) } } } @@ -119,8 +125,8 @@ it("says only 'not sent', with its Retry, on each message the failed start's row mocks.journalItems = [startFailureRow(START_FAILED)] renderPane([ - rejected('first', START_FAILED_REASON, START_FAILED), - rejected('second', START_FAILED_REASON, START_FAILED) + rejected('first', START_FAILED_REASON, START_FAILED, START_KEY), + rejected('second', START_FAILED_REASON, START_FAILED, START_KEY) ]) for (const id of ['first', 'second']) { @@ -139,7 +145,7 @@ it('keeps the full notice on a message rejected for a reason no start-failure ro } renderPane([ - rejected('stated', START_FAILED_REASON, START_FAILED), + rejected('stated', START_FAILED_REASON, START_FAILED, START_KEY), rejected( 'other', 'The provider did not accept this message: Image type .bmp.', @@ -155,19 +161,25 @@ it('keeps the full notice on a message rejected for a reason no start-failure ro ).toBeTruthy() }) -// A host from before starts were named writes the same failure with no start on it. -it('keeps the full notice on a rejection that names no start, beside an equal start row', async () => { +// A host from before starts were named records the same failure with no start beside it; another +// start's row does not explain a message it did not reject. +it('keeps the full notice on a rejection that names no start, or another, beside an equal start row', async () => { mocks.journalItems = [startFailureRow(START_FAILED)] - renderPane([rejected('older', START_FAILED_REASON, { kind: 'providerStartFailed' })]) + renderPane([ + rejected('older', START_FAILED_REASON, START_FAILED), + rejected('elsewhere', START_FAILED_REASON, START_FAILED, 'generation-0') + ]) - expect( - within(await notice('older')).getByText('Claude stopped before it finished starting.') - ).toBeTruthy() + for (const id of ['older', 'elsewhere']) { + expect( + within(await notice(id)).getByText('Claude stopped before it finished starting.') + ).toBeTruthy() + } }) it("keeps the start failure's own words when its row is not loaded", async () => { - renderPane([rejected('first', START_FAILED_REASON, START_FAILED)]) + renderPane([rejected('first', START_FAILED_REASON, START_FAILED, START_KEY)]) expect( within(await notice('first')).getByText('Claude stopped before it finished starting.') 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 99397b7f4be0..c1dd4e915149 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 @@ -324,14 +324,13 @@ describe('the notice on each message that did not go through', () => { expect(texts([entry('queued'), entry('sending', { state: 'dispatching' })])).toEqual({}) }) - // Matched on the start the rejection names and a loaded row keyed by it, never on either sentence - // or on the failure the two state. + // Matched on the start the host recorded as rejecting the message and a loaded row keyed by it, + // never on either sentence or on the failure the two state. 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 byStart = (startKey: string): AgentSessionFailureFact => ({ ...startFailed, startKey }) const rejected = (id: string, fact: AgentSessionFailureFact) => entry(id, { state: 'rejected', @@ -341,7 +340,11 @@ describe('the notice on each message that did not go through', () => { rejection: { kind: fact.kind } } }) - const recorded = (id: string, fact: AgentSessionFailureFact): AgentJournalSubmission => ({ + const recorded = ( + id: string, + fact: AgentSessionFailureFact, + rejectedByStartKey?: string + ): AgentJournalSubmission => ({ clientMessageId: id, fence: 1, payloadFingerprint: id, @@ -350,7 +353,8 @@ describe('the notice on each message that did not go through', () => { reason: 'Written by the host.', rejection: fact, submittedAt: 1, - resolvedAt: 1 + resolvedAt: 1, + ...(rejectedByStartKey ? { rejectedByStartKey } : {}) }) const statusRow = (itemId: string, fact: AgentSessionFailureFact): AgentJournalRenderItem => ({ itemId, @@ -370,7 +374,7 @@ describe('the notice on each message that did not go through', () => { it('reads only the start-failure rows', () => { expect( structuredAgentSessionStartFailureKeys([ - statusRow(startRowKey('gen'), byStart('gen')), + statusRow(startRowKey('gen'), startFailed), statusRow(agentJournalSubmissionKey('exit-row'), { kind: 'providerExited' }) ]) ).toEqual(['gen']) @@ -382,7 +386,7 @@ describe('the notice on each message that did not go through', () => { refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } } } const keys = structuredAgentSessionStartFailureKeys([ - statusRow(startRowKey('gen'), byStart('gen')) + statusRow(startRowKey('gen'), startFailed) ]) expect( texts( @@ -393,8 +397,8 @@ describe('the notice on each message that did not go through', () => { ], null, [ - recorded('first', byStart('gen')), - recorded('second', byStart('gen')), + recorded('first', startFailed, 'gen'), + recorded('second', startFailed, 'gen'), recorded('other', otherRefusal) ], keys @@ -414,7 +418,7 @@ describe('the notice on each message that did not go through', () => { texts( [rejected('unnamed', startFailed), rejected('elsewhere', startFailed)], null, - [recorded('unnamed', startFailed), recorded('elsewhere', byStart('earlier'))], + [recorded('unnamed', startFailed), recorded('elsewhere', startFailed, 'earlier')], ['gen'] ) ).toEqual({ @@ -428,7 +432,7 @@ describe('the notice on each message that did not go through', () => { [agentJournalSubmissionKey('first')]: 'Written by the host.' }) expect( - texts([rejected('first', startFailed)], null, [recorded('first', byStart('gen'))], []) + texts([rejected('first', startFailed)], null, [recorded('first', startFailed, 'gen')], []) ).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 56d2c390adba..87b958cab2eb 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,8 +7,9 @@ // words and gets its Retry once the queue moves. // // A message the host recorded and then rejected is worded from the journal's own fact, found by id; -// the message keeps only a smaller copy, read when its submission is not loaded. A rejection that -// names a failed start whose row is loaded says only that it was not sent: the row already says why. +// the message keeps only a smaller copy, read when its submission is not loaded. A submission the +// host records as rejected by a failed start whose row is loaded says only that it was not sent: the +// row already says why. import { readAgentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' @@ -63,11 +64,12 @@ function deliveryNoticeText( 'Message was not sent.' ) } - const fact = readAgentSessionFailureFact(recorded?.rejection) // Only the start's own writer names it, so an equal failure from elsewhere keeps its words. - if (entry.state === 'rejected' && fact?.startKey && startFailureKeys.includes(fact.startKey)) { + const startKey = recorded?.rejectedByStartKey + if (entry.state === 'rejected' && startKey && startFailureKeys.includes(startKey)) { return agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send')) } + const fact = readAgentSessionFailureFact(recorded?.rejection) return agentSessionWriteNoticeText( structuredAgentSessionAttemptFailureParts(entry.lastFailure, context, fact) ) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.test.ts index 5c2547cd408a..9b8b3c537a67 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.test.ts @@ -185,11 +185,7 @@ describe("a saved message's last failure", () => { lastFailure: { kind: 'rejected', reason: 'Claude stopped before it finished starting.', - rejection: { - kind: 'providerStartFailed', - detail: { text: 'exit 1', audience: 'log' }, - startKey: 'generation-1' - } + rejection: { kind: 'providerStartFailed', detail: { text: 'exit 1', audience: 'log' } } } }, { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-keys.test.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-keys.test.ts index a53278b62dde..0d3982d24fcf 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-keys.test.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-keys.test.ts @@ -18,7 +18,7 @@ function startFailureRow(startKey: string): AgentJournalRenderItem { kind: 'status', tone: 'error', ...agentSessionFailureWords( - { kind: 'providerStartFailed', startKey }, + { kind: 'providerStartFailed' }, { agentName: 'Claude', surface: 'row' } ) } diff --git a/src/shared/agent-session-failure.test.ts b/src/shared/agent-session-failure.test.ts index 5cb815c9fbf6..4dc12f9bfe8b 100644 --- a/src/shared/agent-session-failure.test.ts +++ b/src/shared/agent-session-failure.test.ts @@ -79,22 +79,6 @@ describe('reading a failure fact', () => { ).toEqual({ kind: 'restartFailed', refusal: { code: 'agent_session_conflict' } }) }) - // Written only by a failed start's one writer; a fact from before it, or from any other writer, - // has none. - it('keeps the start a rejection came from, and reads a fact without one as before', () => { - expect(readAgentSessionFailureFact({ kind: 'startFailed', startKey: 'generation-2' })).toEqual({ - kind: 'startFailed', - startKey: 'generation-2' - }) - expect(readAgentSessionFailureFact({ kind: 'startFailed' })).toEqual({ kind: 'startFailed' }) - expect(readAgentSessionFailureFact({ kind: 'startFailed', startKey: '' })).toEqual({ - kind: 'startFailed' - }) - expect(readAgentSessionFailureFact({ kind: 'startFailed', startKey: 7 })).toEqual({ - kind: 'startFailed' - }) - }) - it('drops what a newer host wrote that this build cannot place', () => { expect(readAgentSessionFailureFact({ kind: 'futureKind' })).toBeUndefined() expect(readAgentSessionFailureFact(undefined)).toBeUndefined() diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index 48f9df19c80e..ed8b677b359e 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -122,9 +122,6 @@ export type AgentSessionFailureFact = { attachment?: AgentSessionAttachmentProblem /** On `providerRetrying`: why the provider is retrying. */ retry?: AgentSessionProviderRetry - /** Which failed start wrote this, the key of its `start-failure:` row; set only by that start's - * writer, on the row and on every message it rejected. Identity, never part of the situation. */ - startKey?: string } /** A fact as a row stores it: its kind may be one a newer host added, so only @@ -222,15 +219,12 @@ export function readAgentSessionFailureFact(value: unknown): AgentSessionFailure const refusal = readAgentSessionRefusalReference(value.refusal) const attachment = readAttachmentProblem(value.attachment) const retry = readProviderRetry(value.retry) - const fact = agentSessionFailureFact(value.kind, { + return agentSessionFailureFact(value.kind, { ...(isProviderDiagnostic(value.detail) ? { detail: value.detail } : {}), ...(refusal ? { refusal } : {}), ...(attachment ? { attachment } : {}), ...(retry ? { retry } : {}) }) - return typeof value.startKey === 'string' && value.startKey - ? { ...fact, startKey: value.startKey } - : fact } /** The provider-authored diagnostic an error carries, set only where it was composed. Follows the diff --git a/src/shared/agent-session-journal-schemas.test.ts b/src/shared/agent-session-journal-schemas.test.ts index 5d825805fab5..79ff5ce860ea 100644 --- a/src/shared/agent-session-journal-schemas.test.ts +++ b/src/shared/agent-session-journal-schemas.test.ts @@ -372,14 +372,10 @@ describe('typed failure facts', () => { expect(refused({ code: 'agent_session_conflict', cause: 'claimConflicted' })).toBe(true) }) - // The schema is the one older builds of this stack read with: the start's key, well-formed or - // not, never makes a row malformed; the fact reader keeps or drops it. - it('admits the start a rejection came from, whatever a host wrote for it', () => { - for (const startKey of ['generation-2', 7]) { - const failure = { kind: 'startFailed', startKey } - expect( - isAdmissibleAgentJournalItemBody({ kind: 'status', text: "Codex couldn't start.", failure }) - ).toBe(true) + // Older builds read with a schema that lists no such key; this one lists it as any value. Either + // way the start that rejected a message, well-formed or not, never makes it inadmissible. + it('admits the start that rejected a message, whatever a host wrote for it', () => { + for (const rejectedByStartKey of ['generation-2', '', 7, { generation: 'generation-2' }]) { expect( isAdmissibleAgentJournalSubmission({ clientMessageId: 'cm-1', @@ -390,7 +386,8 @@ describe('typed failure facts', () => { reason: "Codex couldn't start.", submittedAt: 1, resolvedAt: 2, - rejection: failure + rejection: { kind: 'startFailed' }, + rejectedByStartKey }) ).toBe(true) } diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index d193db01846a..28e6f5600399 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -318,7 +318,9 @@ export const AgentJournalSubmissionSchema = z.object({ recovered: z.literal(true).optional(), handoverRecorded: z.literal(true).optional(), handedOverAt: z.number().optional(), - rejection: FailureFact.optional() + rejection: FailureFact.optional(), + // Any value: a malformed one is dropped by the host's reader, never the submission. + rejectedByStartKey: z.unknown().optional() }) export function isAgentJournalResolution(value: unknown): value is AgentJournalResolution { diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index a17561e6b810..4f1be37cd0c1 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -386,6 +386,9 @@ export type AgentJournalSubmission = { reason: string | null /** On `rejected`, why, typed; absent on rows from older hosts. */ rejection?: UnreadAgentSessionFailureFact + /** On `rejected` by a failed start: that start's key, the one its `start-failure:` row is keyed + * by. Set only by the start's own writer; absent on every other rejection and on older hosts. */ + rejectedByStartKey?: string submittedAt: number resolvedAt: number | null /** Set when crash reconciliation resolved the dispatch, not the provider. A live diff --git a/src/shared/structured-agent-session-dispatch-rejection.test.ts b/src/shared/structured-agent-session-dispatch-rejection.test.ts index ef1251ff40ca..be16c8a3fbda 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.test.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.test.ts @@ -85,13 +85,6 @@ describe('classifyDispatchRejection', () => { ).toMatchObject({ category: 'transport', verdict: 'failure' }) }) - it('classifies a rejection the same whichever start it names', () => { - const reason = "Claude couldn't start." - expect( - classifyDispatchRejection({ reason, rejection: { kind: 'startFailed', startKey: 'g-2' } }) - ).toEqual(classifyDispatchRejection({ reason, rejection: { kind: 'startFailed' } })) - }) - it('writes a sentence, not a marker released clients would print, for a restart or a close', () => { for (const kind of ['hostRestarted', 'chatClosed'] as const) { const written = agentSessionFailureWords(agentSessionFailureFact(kind), { From 2d4235123f118e214b03e2a77a0521f650bb8bf2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:54:00 -0700 Subject: [PATCH 33/37] test(native-chat): a message's saved copy keeps no start a failed start named Also says in the exit settlement's input that it keys a failed start as the delivery loop does. --- ...agent-session-dead-generation-settlement.ts | 2 +- ...ured-agent-session-send-disposition.test.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index e791faedb00d..fa7f26c055dd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -118,7 +118,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { /** Who a failed start's sentence names. */ failureTextContext?: AgentSessionFailureWordsContext /** The provider never finished starting: the start that failed, keyed by the child's - * generation. Its row is the one the delivery loop writes for the same start. */ + * generation, as the delivery loop keys the same start's one row. */ exitedDuringStartup?: { generation: string | null } onError?: (sessionId: string, error: unknown) => void }): Promise { diff --git a/src/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts index e95a21937d1c..1cd9eaad7888 100644 --- a/src/shared/structured-agent-session-send-disposition.test.ts +++ b/src/shared/structured-agent-session-send-disposition.test.ts @@ -297,6 +297,24 @@ describe('what a refusal shows the user', () => { }) }) + // The pane reads the start from the journal's submission; the message's saved copy never holds it. + it('keeps no start on the saved copy of a message a failed start rejected', () => { + const result = rejectedWith("Claude couldn't start.", { rejection: { kind: 'startFailed' } }) + if (!result.ok) { + throw new Error('expected rejected submission fixture') + } + const [saved] = reconcileStructuredAgentSessionOutbox( + [{ ...entry, state: 'dispatching' }], + [{ ...result.value.submission, rejectedByStartKey: 'generation-2' }] + ) + + expect(saved?.lastFailure).toEqual({ + kind: 'rejected', + reason: "Claude couldn't start.", + rejection: { kind: 'startFailed' } + }) + }) + it('keeps a failed request as a fact, not a transport error string', () => { const disposition = disposeStructuredAgentSessionSendFailure({ entries: [entry], From 9f6895040ff73b1c245a9262b87ca49fb272dcb2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:21:51 -0700 Subject: [PATCH 34/37] test(native-chat): a failed start's exit leaves the row to the delivery loop when it was handed nothing A start that rejected nothing it was handed has only queued messages, which the delivery loop reports; the exit writes no row for them. --- ...session-dead-generation-settlement.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index 21d2e792a979..6ba46f8afbde 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -492,6 +492,35 @@ describe('dead structured-session generation settlement', () => { expect(startRows()).toEqual([]) }) + + // Its messages were never handed to this child, so the start is the loop's to report. + it('leaves the row to the delivery loop when the exit rejected nothing it was handed', async () => { + await journal.appendSubmission({ + clientMessageId: 'client-queued', + payloadFingerprint: 'fingerprint', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello?' }] }, + fence: 7, + handoverRecorded: true + }) + + // As the exit of a start always settles: a failed start says why. + await settleStructuredAgentSessionDeadGeneration({ + journal, + sessionId: SESSION, + fence: 7, + settlementId: `provider-exit:${SESSION}:7:${START_KEY}`, + pendingSubmissionReason: 'provider_closed_before_acknowledgement', + verdict: { state: 'interrupted', completedAt: 1_000 }, + showUnexpectedExitOutcome: true, + exitFailure: agentSessionFailureFact('notSignedIn'), + exitedDuringStartup: { generation: START_KEY } + }) + + expect(startRows()).toEqual([]) + expect(journal.submissions()).toEqual([ + expect.objectContaining({ clientMessageId: 'client-queued', dispatchState: 'pending' }) + ]) + }) }) it("keeps a subagent's settled rows the subagent's, in one batch and after a reopen", async () => { From bce2c9aef8ef87f00c90f28ae1ed42dbecffa898 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:37:23 -0700 Subject: [PATCH 35/37] refactor(native-chat): a failed start's exit keys the start by its child's generation only An exit is only settled for the child it names, and that event always carries the child's generation, so the settlement never met a start without one. Drop the fallback key and the condition and test for that case; the delivery loop keys the same start by the same generation. --- ...session-dead-generation-settlement.test.ts | 19 ------------------- ...gent-session-dead-generation-settlement.ts | 18 +++++++----------- ...agent-session-start-failure-writer.test.ts | 6 ++++-- 3 files changed, 11 insertions(+), 32 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index 6ba46f8afbde..9036693b6089 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -474,25 +474,6 @@ describe('dead structured-session generation settlement', () => { ) }) - // With no generation the loop keys the start by its oldest queued message, not the exit's id, - // so the exit leaves the row to it rather than write a second one. - it('leaves the row to the delivery loop when the exit has no generation to key it by', async () => { - await handedOverAndQueued() - - await settleStructuredAgentSessionDeadGeneration({ - journal, - sessionId: SESSION, - fence: 7, - settlementId: `provider-exit:${SESSION}:7:null`, - pendingSubmissionReason: 'provider_closed_before_acknowledgement', - verdict: { state: 'interrupted', completedAt: 1_000 }, - exitFailure: agentSessionFailureFact('notSignedIn'), - exitedDuringStartup: { generation: null } - }) - - expect(startRows()).toEqual([]) - }) - // Its messages were never handed to this child, so the start is the loop's to report. it('leaves the row to the delivery loop when the exit rejected nothing it was handed', async () => { await journal.appendSubmission({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index fa7f26c055dd..dc5c85f503db 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -119,7 +119,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { failureTextContext?: AgentSessionFailureWordsContext /** The provider never finished starting: the start that failed, keyed by the child's * generation, as the delivery loop keys the same start's one row. */ - exitedDuringStartup?: { generation: string | null } + exitedDuringStartup?: { generation: string } onError?: (sessionId: string, error: unknown) => void }): Promise { try { @@ -132,9 +132,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { // child that never proved its start accepted nothing either — input is written only after it // initializes — so every send it was handed is rejected with the child's own diagnostic. A // proven child's handed-over sends stay in doubt. - const startKey = input.exitedDuringStartup - ? (input.exitedDuringStartup.generation ?? input.settlementId) - : null + const startKey = input.exitedDuringStartup?.generation ?? null const startupFailure = startKey === null ? null @@ -154,14 +152,12 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { if (showUnexpectedExitOutcome && startKey !== null && startupFailure) { // A row already written stays: rejected is terminal, so its words are not reworded. A start // that rejected what it was handed writes its row now, beside those messages; the delivery - // loop's later report of it rejects what is queued in the row's words. Only a generation - // keys both alike: without one the loop keys by the oldest queued message, so the row for a - // start messages still wait on is the loop's. With nothing queued, this is a command, goal or - // rewind start, and the row is this settlement's. + // loop keys the same start by the same generation, so its later report rejects what is queued + // in the row's words. A start that was handed nothing leaves the row to the loop's report. + // With nothing queued, this is a command, goal or rewind start, and the row is this + // settlement's. const queued = input.journal.submissions?.().some(isQueuedAgentJournalSubmission) - const keyedLikeDeliveryLoop = - rejected.length > 0 && input.exitedDuringStartup?.generation != null - if (!startupFailure.written && (!queued || keyedLikeDeliveryLoop)) { + if (!startupFailure.written && (!queued || rejected.length > 0)) { mutations.push(structuredAgentSessionStartFailureRow(startKey, startupFailure.words)) } } else if (showUnexpectedExitOutcome) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index a69cc8e83390..a02c5613b91a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -1,7 +1,9 @@ // A start a queued message waited on can be seen failing twice: by the delivery loop, when the // adapter settles the start without proving it or cannot take the message it was handed, and by -// the exit settlement, when the child's exit lands. The chat gets one row for that start, the -// loop's, in the words the message was rejected with — whichever of the two reports first. +// the exit settlement, when the child's exit lands. Both key the start by the child's generation, so +// the chat gets one row for it, in the words every message it was for was rejected with. The exit +// writes it only when it rejected a message handed to the child and no row is there yet; else the +// loop does. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' From 42eb817cf1f39fd5a01edb4a1e9e9ffa7ed9ed13 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:44:24 -0700 Subject: [PATCH 36/37] fix(native-chat): a /compact whose start fails names that start, like any message The command handover rejected a waiting /compact with the start-failure words but no start key, under its own result row. It now returns the failed start to the delivery loop, which records the start's one row and rejects the command and every message behind it with that row's key; the command's turn then ends failed with no second row. --- .../structured-agent-session-command-turn.ts | 83 ++++++++----- .../structured-agent-session-delivery-loop.ts | 16 ++- ...agent-session-start-failure-writer.test.ts | 115 ++++++++++++++++++ .../structured-agent-session-turns.ts | 3 +- 4 files changed, 184 insertions(+), 33 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts index cb83dba870a1..166e5268cc1d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts @@ -25,6 +25,7 @@ import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalItemIdentity, type AgentJournalMessageItem, + type AgentJournalStatusItem, type AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionConversationCommand } from '../../../shared/agent-session-conversation-command' @@ -41,7 +42,6 @@ import type { StructuredAgentSessionAdapter, StructuredAgentSessionProviderChildPhase } from './structured-agent-session-adapter' -import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' import { conversationCommandBlocked } from './structured-conversation-command-admission' export const STRUCTURED_AGENT_SESSION_COMPACT_COMMAND = 'compact' @@ -157,12 +157,14 @@ export type StructuredAgentSessionCommandHandoverContext = { now: () => number } -/** Refuses the command, or opens its turn and sends it. */ +/** Refuses the command, or opens its turn and sends it. Returns the cause when the child's start + * failed at the handover, leaving the command pending for the caller to record that start's + * failure for it and everything queued behind it. */ export async function handOverStructuredAgentSessionCommand( ctx: StructuredAgentSessionCommandHandoverContext, submission: AgentJournalSubmission, body: AgentJournalMessageItem -): Promise { +): Promise<{ error: unknown } | null> { const { clientMessageId } = submission // Provider frames already received decide whether a turn is running. await ctx.flushStreamedEvents() @@ -174,7 +176,7 @@ export async function handOverStructuredAgentSessionCommand( ...agentSessionFailureWords(blocked, { ...ctx.failureTextContext, surface: 'rejection' }), fence: ctx.fence }) - return + return null } const turn = structuredAgentSessionCommandTurn(clientMessageId) await ctx.journal.resolveDispatch({ @@ -204,20 +206,16 @@ export async function handOverStructuredAgentSessionCommand( command: { clientMessageId, ...turn, running } }) } catch (error) { - // A child that had not proven its start took nothing, so the command provably did not run. Any - // other throw is a lost reply: the command may have run. - const unsent = - ctx.providerChildPhase?.() === 'starting' - ? { - state: 'rejected' as const, - ...structuredAgentSessionStartFailure({ error }, ctx.failureTextContext) - } - : { - state: 'unknown' as const, - reason: error instanceof Error ? error.message : String(error) - } - await settleUnsentCommand(ctx, clientMessageId, unsent) - return + // A child that had not proven its start took nothing: its start failed. Any other throw is a + // lost reply: the command may have run. + if (ctx.providerChildPhase?.() === 'starting') { + return { error } + } + await settleUnsentCommand(ctx, clientMessageId, { + state: 'unknown', + reason: error instanceof Error ? error.message : String(error) + }) + return null } if (admission.state === 'rejected') { // The provider refused the compaction itself: its row reads as the compaction failing. @@ -231,6 +229,7 @@ export async function handOverStructuredAgentSessionCommand( // An unknown write leaves the turn to the provider's end or the child's: it may have run. await ctx.journal.resolveDispatch({ clientMessageId, ...admission, fence: ctx.fence }) } + return null } /** Where the turn a handed-over submission runs in starts counting: its handover, so time spent @@ -256,28 +255,52 @@ async function settleUnsentCommand( | { state: 'unknown'; reason: string }, /** What the result row reports, when it is not the rejection's own fact. */ rowFailure?: AgentSessionFailureFact +): Promise { + await ctx.journal.resolveDispatch({ clientMessageId, ...unsent, fence: ctx.fence }) + await endUnsentCommandTurn( + ctx, + clientMessageId, + unsent.state === 'rejected' + ? { + kind: 'status', + ...agentSessionFailureWords(rowFailure ?? unsent.rejection, { + ...ctx.failureTextContext, + surface: 'row' + }), + tone: 'error' + } + : null + ) +} + +/** A command whose start failed at its handover, once that start's writer has rejected it: its + * turn ends failed, and the start's row is its one row. */ +export async function endStructuredAgentSessionCommandStartFailure( + ctx: Pick, + clientMessageId: string +): Promise { + await endUnsentCommandTurn(ctx, clientMessageId, 'failed') +} + +/** Ends the command's turn if it still runs: failed when the command was refused (`result` is its + * row, if it has one of its own), else unverifiable. */ +async function endUnsentCommandTurn( + ctx: Pick, + clientMessageId: string, + result: AgentJournalStatusItem | 'failed' | null ): Promise { const turn = structuredAgentSessionCommandTurn(clientMessageId) const running = readAgentJournalTurn(ctx.journal.itemBody(turn.itemId) ?? undefined) - await ctx.journal.resolveDispatch({ clientMessageId, ...unsent, fence: ctx.fence }) if (running?.state !== 'running') { return } - const refused = unsent.state === 'rejected' const mutations: JournalLifecycleMutationInput[] = [ - ...(refused + ...(result && result !== 'failed' ? [ { kind: 'item' as const, identity: turn.resultIdentity, - body: { - kind: 'status' as const, - ...agentSessionFailureWords(rowFailure ?? unsent.rejection, { - ...ctx.failureTextContext, - surface: 'row' - }), - tone: 'error' as const - }, + body: result, turnScope: { kind: 'turn' as const, turnItemId: turn.itemId } } ] @@ -287,7 +310,7 @@ async function settleUnsentCommand( identity: turn.identity, body: agentJournalTurnBody({ ...running, - ...(refused + ...(result ? { state: 'completed', outcome: 'failure', completedAt: ctx.now() } : { state: 'unverifiable' }) }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 50643e4033d6..d9f00c14a55f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -37,7 +37,10 @@ import { } from './structured-agent-session-start-failure-row' import { failedProviderChildStart } from './structured-agent-session-provider-child' import { handOverSubmission } from './structured-agent-session-turns' -import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' +import { + endStructuredAgentSessionCommandStartFailure, + structuredAgentSessionCommandRunning +} from './structured-agent-session-command-turn' export type StructuredAgentSessionDeliveryLoopDeps = { sessions: ReadonlyMap @@ -252,6 +255,17 @@ export class StructuredAgentSessionDeliveryLoop { }, handedOver ) + if (handedOver) { + // A command opens its own turn at handover; the start's row answers it, so the turn ends. + await endStructuredAgentSessionCommandStartFailure( + { + journal: session.journal, + fence: this.deps.conversationFence(sessionId), + now: this.deps.now + }, + handedOver + ) + } } return this.stop(sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index f9ee06a26192..89969800d246 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -36,6 +36,7 @@ const ADAPTER_FAILURE_TEXT = 'Codex is not signed in for the selected account. Sign in, then send your message again.' // The exit's reason is Orca's log text; the row says only that the start stopped. const EXIT_TEXT = 'Codex stopped before it finished starting. Send your message to try again.' +const COMPACT_EXIT_TEXT = 'Codex stopped before it finished starting. Run /compact again.' // The first child (generation-1) is lost at setup; the send starts generation-2. const START_ROW = agentJournalItemKey({ provider: 'orca', @@ -53,6 +54,7 @@ let generation = 0 let settleStart: (failure: SubmissionRejectionFact | undefined) => void = () => {} let awaitStarted = vi.fn<() => Promise>() let dispatch = vi.fn<() => Promise<{ state: 'admitted' }>>() +let compact = vi.fn<() => Promise<{ state: 'admitted' }>>() let frames: AgentSessionSubscribeEvent[] = [] function exitBeforeProof(): Promise { @@ -89,6 +91,27 @@ async function sendQueued(text: string): Promise { return sent.ok ? sent.value.clientMessageId : '' } +/** A /compact sent through the command RPC, whose answer waits for its handover. */ +async function sendCompact(): Promise<{ id: string; answered: Promise }> { + const fields = { command: 'compact' } + const answered = host.conversationCommand(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.conversationCommand', + sessionId: SESSION, + fields + }) + }, + command: 'compact' + }) + await eventually(() => expect(awaitStarted).toHaveBeenCalledOnce()) + const waiting = (await host.journalSnapshot(SESSION)).submissions.at(-1) + return { id: waiting?.clientMessageId ?? '', answered } +} + async function submission(clientMessageId: string) { return (await host.journalSnapshot(SESSION)).submissions.find( (entry) => entry.clientMessageId === clientMessageId @@ -121,6 +144,7 @@ beforeEach(async () => { () => new Promise((resolve) => (settleStart = resolve)) ) dispatch = vi.fn(async () => ({ state: 'admitted' as const })) + compact = vi.fn(async () => ({ state: 'admitted' as const })) store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) host = new StructuredAgentSessionHost({ store, @@ -141,6 +165,7 @@ beforeEach(async () => { releaseAcquisition: vi.fn(async () => true), closeSession: vi.fn(async () => true), dispatch, + compact, cancelTurn: vi.fn(async () => ({ cancelled: true })), answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) @@ -244,3 +269,93 @@ describe('a start whose child cannot take the message it was handed', () => { expect(dispatch).toHaveBeenCalledOnce() }) }) + +// A /compact the start was carrying is handed over like any message, as a command: a child that +// cannot take it failed its start for the command and for every message waiting behind it. +describe('a waiting /compact whose child cannot take it', () => { + function resultRows(items: { itemId: string }[]): string[] { + return items.flatMap((item) => (item.itemId.includes('command-result:') ? [item.itemId] : [])) + } + + it('rejects it and the message behind it naming the one start row, whichever report lands first', async () => { + const { id: waiting, answered } = await sendCompact() + const behind = await sendQueued('behind') + compact.mockImplementation(() => { + throw new Error(`no live claude stream-json session for ${SESSION}`) + }) + awaitStarted.mockImplementation(async () => undefined) + + settleStart(undefined) + await eventually(async () => + expect(await submission(waiting)).toMatchObject({ dispatchState: 'rejected' }) + ) + expect(await submission(waiting)).toMatchObject({ rejectedByStartKey: 'generation-2' }) + await eventually(async () => + expect(await submission(behind)).toMatchObject({ dispatchState: 'rejected' }) + ) + await exitBeforeProof() + await host.flushStreamedEvents(SESSION) + + const { items } = await host.journalSnapshot(SESSION) + const rejected = await submission(waiting) + expect(rejected).toMatchObject({ + rejection: { kind: 'startFailed' }, + rejectedByStartKey: 'generation-2' + }) + expect(await submission(behind)).toMatchObject({ + reason: rejected?.reason, + rejectedByStartKey: 'generation-2' + }) + expect(await startRows()).toEqual([rejected?.reason]) + expect(publishedStartRows()).toEqual([rejected?.reason]) + // The start's row is the command's one row: no result row repeats it. + expect(resultRows(items)).toEqual([]) + // The command's turn ended with its start, not left running to hold the queue. + expect(items.some((item) => item.body.kind === 'turn' && item.body.state === 'running')).toBe( + false + ) + expect(compact).toHaveBeenCalledOnce() + await answered + }) + + it('leaves the row to the loop when the exit lands while the command still waits', async () => { + const { id: waiting, answered } = await sendCompact() + + await exitBeforeProof() + settleStart(undefined) + await eventually(async () => + expect(await submission(waiting)).toMatchObject({ dispatchState: 'rejected' }) + ) + await host.flushStreamedEvents(SESSION) + + const { items } = await host.journalSnapshot(SESSION) + expect(await submission(waiting)).toMatchObject({ + reason: COMPACT_EXIT_TEXT, + rejectedByStartKey: 'generation-2' + }) + expect(publishedStartRows()).toEqual([COMPACT_EXIT_TEXT]) + expect(resultRows(items)).toEqual([]) + expect(compact).not.toHaveBeenCalled() + await answered + }) + + it("keeps the exit's row when the child exits after it took the command", async () => { + const { id: waiting, answered } = await sendCompact() + awaitStarted.mockImplementation(async () => undefined) + + settleStart(undefined) + await eventually(() => expect(compact).toHaveBeenCalledOnce()) + await answered + await exitBeforeProof() + await host.flushStreamedEvents(SESSION) + + const { items } = await host.journalSnapshot(SESSION) + const rejected = await submission(waiting) + expect(rejected).toMatchObject({ + dispatchState: 'rejected', + rejectedByStartKey: 'generation-2' + }) + expect(publishedStartRows()).toEqual([rejected?.reason]) + expect(resultRows(items)).toEqual([]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index a595528df14d..51a5448103ca 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -185,8 +185,7 @@ export async function handOverSubmission( return null } if (body.command) { - await handOverStructuredAgentSessionCommand(ctx, submission, body) - return null + return handOverStructuredAgentSessionCommand(ctx, submission, body) } // The message joins the turn running at handover, a steer, or opens its own. await ctx.journal.resolveDispatch({ From 81f942c5d78355d4e7f15acf79e8ec5ed9ffb08c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:47:45 -0700 Subject: [PATCH 37/37] test(native-chat): the branch's tests use main's pending turn scope and queued send result --- ...structured-agent-session-dead-generation-settlement.test.ts | 3 ++- src/shared/structured-agent-session-send-disposition.test.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index f0e58191985e..757373eedea8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -417,7 +417,8 @@ describe('dead structured-session generation settlement', () => { await journal.resolveDispatch({ clientMessageId: 'client-handed', state: 'pending', - fence: 7 + fence: 7, + turnScope: AGENT_JOURNAL_THREAD_SCOPE }) } diff --git a/src/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts index 21d5a5bed5e9..1dc52d9f7046 100644 --- a/src/shared/structured-agent-session-send-disposition.test.ts +++ b/src/shared/structured-agent-session-send-disposition.test.ts @@ -344,7 +344,7 @@ describe('what a refusal shows the user', () => { // The pane reads the start from the journal's submission; the message's saved copy never holds it. it('keeps no start on the saved copy of a message a failed start rejected', () => { const result = rejectedWith("Claude couldn't start.", { rejection: { kind: 'startFailed' } }) - if (!result.ok) { + if (!result.ok || !('submission' in result.value)) { throw new Error('expected rejected submission fixture') } const [saved] = reconcileStructuredAgentSessionOutbox(