From 9d11a75cd3ce405a7bd3e5ae370ebc79a734bdb4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:36:08 -0700 Subject: [PATCH] fix(native-chat): each chat failure says why in plain words, on the thing that failed (#23608) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. * 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. * 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. * fix(native-chat): an unreadable history is worded from its refusal, and damage stops the retry The structured chat's read failure showed the host's text on the status line, and the pane always said Orca keeps trying. The read transport now takes the refusal from the error's data (a stream payload or a thrown RPC error), the reducer keeps it beside the failure text, and the pane and the status line word it through the notice table, once: on the pane when the failure took it, else beside the transcript that stays. A damaged journal says "Unable to load this chat." and the read stops reconnecting for that run; reopening the chat reads again. An open that can clear names its cause without "Try again", since the pane retries on its own. A failure that names no reason keeps today's generic line. Finality comes from the refusal's reason, never its message, which is the bare code for both. * fix(native-chat): a rejected message is worded from its stored fact A message the host recorded and then rejected keeps the host's typed fact beside its reason, but the Retry words re-read the reason alone. Now the fact decides: a hand-over failure says Orca couldn't reach the agent, a kind whose reason may be a legacy marker gets its fact's own sentence (a full queue now says so instead of only "not sent"), and any other kind shows the sentence the host wrote for it, which carries the agent's name and any words the provider wrote for a person. A row with no fact reads as before. * fix(native-chat): each message that did not go through says why on its own row The structured chat showed one Retry strip under the transcript for whichever single entry it picked, so a second failed message had no reason and no Retry of its own. The terminal-backed chat's existing per-row delivery marker now carries a notice and an optional Retry, and the structured pane derives one per message from the outbox on each render: every rejected message, and the one the queue stopped on (read through the drain's own rule, so a Retry never names a message waiting behind it). Each is worded from that message's stored failure. The single strip is deleted. Nothing new is stored, and the shared message projection is untouched. * fix(native-chat): say each chat failure's words where its own control already acts 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. * 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. * 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. * 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. * 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. * 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. * test(native-chat): a rejected message's hidden Retry only avoids waiting unseen * 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. * test(native-chat): read the sent message id without a type assertion * 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. * 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. * 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. * 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. * 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. * 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. * 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. * test(native-chat): the pane harness types its journal rows without a cast * test(native-chat): the admission test passes no start-failure rows to the notices * test(native-chat): import the journal types once * 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. --- .../journal-open-failure.test.ts | 57 ++- .../journal-open-failure.ts | 58 +++ ...red-agent-session-conversation-lifetime.ts | 17 +- .../structured-agent-session-options-read.ts | 5 +- ...uctured-agent-session-start-failure-row.ts | 9 +- .../structured-agent-session-at-rest.test.ts | 72 +++ .../NativeChatDeliveryRetry.test.tsx | 57 --- .../native-chat/NativeChatDeliveryRetry.tsx | 59 --- .../native-chat/NativeChatEmptyState.test.tsx | 22 +- .../native-chat/NativeChatEmptyState.tsx | 38 +- .../native-chat/NativeChatLaunchRetry.tsx | 19 +- .../native-chat/NativeChatMessageList.tsx | 9 +- .../native-chat/NativeChatMessageRow.test.tsx | 42 ++ .../native-chat/NativeChatMessageRow.tsx | 29 +- .../native-chat/NativeChatResolvedView.tsx | 14 +- .../NativeChatSessionOptionPickers.test.tsx | 53 +++ .../NativeChatSessionOptionPickers.tsx | 14 +- ...tructuredSession.launch-lifecycle.test.tsx | 57 ++- ...eChatStructuredSession.read-error.test.tsx | 71 ++- ...turedSession.start-failure-notice.test.tsx | 163 +++++++ ...tiveChatStructuredSession.test-harness.tsx | 54 ++- .../NativeChatStructuredSession.tsx | 72 ++- ...tiveChatStructuredSessionDelivery.test.tsx | 145 +++++- .../NativeChatStructuredSessionStatus.tsx | 7 + .../native-chat/NativeChatTranscriptRow.tsx | 7 +- ...red-agent-session-delivery-notices.test.ts | 414 ++++++++++++++++++ ...ructured-agent-session-delivery-notices.ts | 137 ++++++ ...tured-agent-session-read-failure-notice.ts | 32 ++ .../structured-agent-session-read-owner.ts | 2 +- ...tured-agent-session-read-transport.test.ts | 205 ++++++++- ...structured-agent-session-read-transport.ts | 61 ++- ...tive-chat-launch-prompt-delivery-notice.ts | 22 + .../use-native-chat-provisional-launch.ts | 6 +- .../use-structured-agent-session-mutate.ts | 5 +- ...ed-agent-session-outbox-admission.test.tsx | 101 ++++- ...nt-session-outbox-rejection-cause.test.tsx | 47 +- .../use-structured-agent-session-outbox.ts | 24 +- ...-agent-session-start-failure-facts.test.ts | 42 ++ ...tured-agent-session-start-failure-facts.ts | 33 ++ ...tured-agent-session-write-refusal.test.tsx | 40 ++ .../use-structured-agent-session.ts | 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 + .../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 +- .../runtime-host-contact-regained.test.ts | 111 +++++ .../runtime/runtime-host-contact-regained.ts | 31 ++ src/shared/agent-session-failure-words.ts | 21 +- .../agent-session-refusal-notice.test.ts | 121 +++++ src/shared/agent-session-refusal-notice.ts | 56 ++- .../agent-session-write-failure.test.ts | 45 +- src/shared/agent-session-write-failure.ts | 31 ++ src/shared/structured-agent-session-outbox.ts | 5 +- .../structured-agent-session-read-refusal.ts | 16 + .../structured-agent-session-reducer.test.ts | 40 ++ .../structured-agent-session-reducer.ts | 11 +- ...red-agent-session-send-disposition.test.ts | 65 ++- ...ructured-agent-session-send-disposition.ts | 77 +++- ...red-agent-session-start-failure-row-key.ts | 17 + 69 files changed, 2790 insertions(+), 329 deletions(-) delete mode 100644 src/renderer/src/components/native-chat/NativeChatDeliveryRetry.test.tsx delete mode 100644 src/renderer/src/components/native-chat/NativeChatDeliveryRetry.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-read-failure-notice.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-launch-prompt-delivery-notice.ts 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/renderer/src/lib/structured-agent-session-launch-failure.ts 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 create mode 100644 src/shared/structured-agent-session-start-failure-row-key.ts diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts index d2e1bf3b350..a5427f76fdc 100644 --- a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts @@ -1,9 +1,14 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { openJournalDatabase } from './journal-database' -import { classifyJournalOpenFailure } from './journal-open-failure' +import { + classifyJournalOpenFailure, + createJournalOpenReadRefusals, + journalOpenReadRefusal +} from './journal-open-failure' import { loadJournal } from './journal-open' import { journalDatabaseFile } from './journal-paths' @@ -104,3 +109,51 @@ describe('classifyJournalOpenFailure', () => { expect(classifyJournalOpenFailure(first)).toBe('journalUnavailable') }) }) + +describe('journalOpenReadRefusal', () => { + it('names the reason, keeps the message the code and the storage text only as the cause', () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const storage = nodeSqliteError(26) + const refusal = journalOpenReadRefusal(storage) + expect(refusal.message).toBe('agent_session_journal_unreadable') + expect(refusal.refusal).toMatchObject({ + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + }) + expect(refusal.cause).toBe(storage) + vi.restoreAllMocks() + }) + + it('passes a refusal the open already raised through unchanged', () => { + const raised = agentSessionRefusalError('agent_session_identity_required', { + reason: 'recordMissing' + }) + expect(journalOpenReadRefusal(raised)).toBe(raised) + }) +}) + +describe('createJournalOpenReadRefusals', () => { + it('logs a session once per failure until it opens, and each session on its own', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const refusals = createJournalOpenReadRefusals() + const denied = systemError('EACCES', -13) + const corrupt = nodeSqliteError(26) + + for (let attempt = 0; attempt < 3; attempt += 1) { + const refusal = refusals.refusal('session-1', denied) + expect(refusal.refusal).toMatchObject({ details: { reason: 'journalUnavailable' } }) + expect(refusal.cause).toBe(denied) + } + expect(warn).toHaveBeenCalledTimes(1) + refusals.refusal('session-2', denied) + expect(warn).toHaveBeenCalledTimes(2) + expect(refusals.refusal('session-1', corrupt).refusal).toMatchObject({ + details: { reason: 'journalCorrupt' } + }) + expect(warn).toHaveBeenCalledTimes(3) + refusals.forget('session-1') + refusals.refusal('session-1', corrupt) + expect(warn).toHaveBeenCalledTimes(4) + vi.restoreAllMocks() + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.ts index 1835f691cb7..5e4cff1f3ee 100644 --- a/src/main/native-chat/agent-session-journal/journal-open-failure.ts +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.ts @@ -2,6 +2,11 @@ // past, or the open failed in a way that can clear (a lock, permissions, too many open files). import type { AgentSessionRefusalReason } from '../../../shared/agent-session-refusal-details' +import { + AgentSessionRefusalError, + isAgentSessionRefusalError, + refuse +} from '../../../shared/agent-session-wire-refusals' import { isSqliteCorruption } from '../../sqlite/sqlite-read-failure' export type JournalOpenFailure = AgentSessionRefusalReason<'agent_session_journal_unreadable'> @@ -20,3 +25,56 @@ export function classifyJournalOpenFailure(error: unknown): JournalOpenFailure { } return 'journalUnavailable' } + +/** + * What a read throws when the conversation it reaches cannot be opened. The storage's own text + * (a path, "file is not a database") goes to the log only; the reader gets the classified refusal, + * whose message stays the bare code. + */ +export function journalOpenReadRefusal(error: unknown): AgentSessionRefusalError { + if (isAgentSessionRefusalError(error)) { + return error + } + return unreadableRefusal(error, classifyJournalOpenFailure(error), true) +} + +const MAX_LOGGED_SESSIONS = 256 + +/** + * The read door's refusals for one host. A reader reconnects on a timer while an open can clear, + * so a session's failure is logged once until that session opens or the failure changes. + */ +export function createJournalOpenReadRefusals() { + const logged = new Map() + return { + refusal: (sessionId: string, error: unknown): AgentSessionRefusalError => { + if (isAgentSessionRefusalError(error)) { + return error + } + const reason = classifyJournalOpenFailure(error) + const failure = `${reason}:${error instanceof Error ? error.message : String(error)}` + const repeat = logged.get(sessionId) === failure + // Past the cap a new session logs every failure rather than evict another's. + if (!repeat && (logged.has(sessionId) || logged.size < MAX_LOGGED_SESSIONS)) { + logged.set(sessionId, failure) + } + return unreadableRefusal(error, reason, !repeat) + }, + /** The session opened or closed: its next failure is news. */ + forget: (sessionId: string): void => { + logged.delete(sessionId) + } + } +} + +function unreadableRefusal( + error: unknown, + reason: JournalOpenFailure, + log: boolean +): AgentSessionRefusalError { + if (log) { + console.warn('[agent-session] opening the conversation for a read failed:', error) + } + const code = 'agent_session_journal_unreadable' + return new AgentSessionRefusalError(refuse(code, { reason }, code), { cause: error }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index e81eb9ae976..0fbd1a0a233 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -5,11 +5,11 @@ // public entry point here takes the session's serialize once and calls the under-serialize forms, // because the queue is not reentrant. -import { TUI_AGENT_DISPLAY_NAMES } from '../../../shared/tui-agent-display-names' import { AgentSessionRefusalError, agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { createJournalOpenReadRefusals } from '../agent-session-journal/journal-open-failure' import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations' import { abandonQueuedStructuredAgentSessionMessages, @@ -40,6 +40,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { let disposed = false const { sessions, serialize } = host const deps = () => host.context().deps + const readRefusals = createJournalOpenReadRefusals() // The sweep's stop puts an idle agent to rest: nothing is queued, so no loop reads its cause. const stopAgent = (sessionId: string) => stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId) @@ -69,12 +70,11 @@ export function createStructuredAgentSessionConversationLifetime(host: { return record !== null && deps().hasOpenDispatch?.(record) === true }, stopAgent, - // A host stop with its reason: the delivery loop waiting on this child writes the one error - // row and rejects what is queued with it. + // A host stop: the delivery loop waiting on this child writes the one error row and rejects + // what is queued with it, both worded from the hostStopped fact. stopStartingAgent: (sessionId) => stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { - cause: 'host-stop', - reason: `${TUI_AGENT_DISPLAY_NAMES[sessions.get(sessionId)?.params.provider ?? 'claude']} never finished starting, so Orca stopped it.` + cause: 'host-stop' }), closeConversation, onError: (sessionId, error) => deps().onEventSinkError?.({ sessionId, error }), @@ -98,6 +98,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { conversation: async (sessionId: string): Promise => { const open = sessions.get(sessionId) if (open) { + readRefusals.forget(sessionId) return open } const record = deps().store.getRecord(sessionId) @@ -116,12 +117,15 @@ export function createStructuredAgentSessionConversationLifetime(host: { if (disposed) { throw new AgentSessionRefusalError(AGENT_SESSION_NOT_ATTACHED) } - const session = await host.open(sessionId) + const session = await host.open(sessionId).catch((error: unknown) => { + throw readRefusals.refusal(sessionId, error) + }) if (!session) { throw agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) } + readRefusals.forget(sessionId) return session }) }, @@ -129,6 +133,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { * still queued will not be sent. */ close: (sessionId: string): Promise => serialize(sessionId, async () => { + readRefusals.forget(sessionId) const session = sessions.get(sessionId) if (session) { // Abandoned before the stop, so no start delivers it. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index 8fd9672b62f..3bad8d9789f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -12,6 +12,7 @@ import { } from '../../../shared/agent-session-wire' import { decodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { journalOpenReadRefusal } from '../agent-session-journal/journal-open-failure' import { isClaudeStructuredOptionKey } from '../../claude/claude-structured-options' import { isCodexTurnOptionKey } from '../../codex/codex-structured-turn-start' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' @@ -96,7 +97,9 @@ export async function readStructuredAgentSessionOptions( ): Promise { const { adapter, store } = context.deps const live = await context.serialize(sessionId, async () => { - const session = await context.openConversation(sessionId) + const session = await context.openConversation(sessionId).catch((error: unknown) => { + throw journalOpenReadRefusal(error) + }) const child = session?.child if (!child) { return null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts index c0660eb9791..77ba7fe5f58 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts @@ -1,5 +1,6 @@ import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { structuredAgentSessionStartFailureRowIdentity } from '../../../shared/structured-agent-session-start-failure-row-key' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import type { StructuredAgentSessionStartFailureWords } from './structured-agent-session-failure-text' @@ -21,7 +22,7 @@ export function structuredAgentSessionStartFailureRow( ): JournalLifecycleMutationInput { return { kind: 'item', - identity: startFailureRowIdentity(startKey), + identity: structuredAgentSessionStartFailureRowIdentity(startKey), // The row repeats the sentence the start's rejected messages carry. body: { kind: 'status', text: words.reason, tone: 'error', failure: words.rejection } } @@ -33,14 +34,10 @@ export function hasStructuredAgentSessionStartFailureRow( items: readonly { itemId: string }[], startKey: string ): boolean { - const itemId = agentJournalItemKey(startFailureRowIdentity(startKey)) + const itemId = agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity(startKey)) return items.some((item) => item.itemId === itemId) } -function startFailureRowIdentity(startKey: string) { - return { provider: 'orca' as const, clientMessageId: `start-failure:${startKey}` } -} - /** * A start the delivery loop needed and did not get: the start's row, and every queued message * rejected with the same words. Writes nothing when nothing is still queued: a start whose diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index a3d598a69d8..df00957215f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -236,6 +236,78 @@ describe('the accessor', () => { }) }) + it('refuses a read whose journal will not open with the classified reason, never the storage text', async () => { + await restingChat() + const open = vi.spyOn(rig.host.collaboratorsForTests().conversationDelivery, 'open') + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const storagePath = '/Users/someone/.orca/journals/session-1/journal.sqlite' + const failWith = (error: Error): void => { + open.mockRejectedValue(error) + } + const failures = async (): Promise => + [ + ...(await call('agentSession.history', { sessionId: SESSION, direction: 'tail' })), + ...(await call('agentSession.subscribe', { sessionId: SESSION })), + ...(await call('agentSession.options', { sessionId: SESSION })) + ].filter((reply) => !reply.ok) + + failWith( + Object.assign(new Error(`file is not a database: ${storagePath}`), { + code: 'ERR_SQLITE_ERROR', + errcode: 26 + }) + ) + const corrupt = await failures() + failWith(Object.assign(new Error(`EACCES: permission denied, open '${storagePath}'`), {})) + const unavailable = await failures() + + for (const [replies, reason] of [ + [corrupt, 'journalCorrupt'], + [unavailable, 'journalUnavailable'] + ] as const) { + expect(replies).toHaveLength(3) + for (const reply of replies) { + expect(reply).toMatchObject({ + ok: false, + error: { + // Not a passthrough code: released clients read the message, which stays the code. + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { refusal: { code: 'agent_session_journal_unreadable', details: { reason } } } + } + }) + expect(JSON.stringify(reply)).not.toContain(storagePath) + } + } + }) + + it('logs a reader reconnecting to a journal that will not open once per failure', async () => { + await restingChat() + const open = vi.spyOn(rig.host.collaboratorsForTests().conversationDelivery, 'open') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const logged = (): unknown[] => + warn.mock.calls + .filter(([line]) => line === '[agent-session] opening the conversation for a read failed:') + .map(([, error]) => error) + const reconnect = async (): Promise => [ + ...(await call('agentSession.subscribe', { sessionId: SESSION })), + ...(await call('agentSession.history', { sessionId: SESSION, direction: 'tail' })) + ] + const denied = new Error('EACCES: permission denied') + const exhausted = new Error('EMFILE: too many open files') + + open.mockRejectedValue(denied) + for (let attempt = 0; attempt < 4; attempt += 1) { + // Every attempt is still refused with its reason; only the log is quiet. + expect((await reconnect()).filter((reply) => !reply.ok)).toHaveLength(2) + } + expect(logged()).toEqual([denied]) + open.mockRejectedValue(exhausted) + await reconnect() + await reconnect() + expect(logged()).toEqual([denied, exhausted]) + }) + it('opens a corrupt journal through the recovering open and still accepts a send (P2-03)', async () => { await foundRestTestChat(rig) await rig.host.flushAllStreamedEvents() diff --git a/src/renderer/src/components/native-chat/NativeChatDeliveryRetry.test.tsx b/src/renderer/src/components/native-chat/NativeChatDeliveryRetry.test.tsx deleted file mode 100644 index a897588f7c5..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatDeliveryRetry.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -// @vitest-environment happy-dom -import '@testing-library/jest-dom/vitest' -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'vitest' -import { - createStructuredAgentSessionOutboxEntry, - type StructuredAgentSessionOutboxEntry -} from '../../../../shared/structured-agent-session-outbox' -import { NativeChatDeliveryRetry } from './NativeChatDeliveryRetry' - -const blocked = createStructuredAgentSessionOutboxEntry({ - clientMessageId: 'client-1', - sessionId: 'session-1', - text: 'hello', - attachments: [], - queuedAt: 1 -}) - -function renderRow(entry: StructuredAgentSessionOutboxEntry): void { - render( - {}} - /> - ) -} - -afterEach(cleanup) - -describe("the Retry row's reason", () => { - it('chooses its words from the saved failure when it is shown', () => { - renderRow({ - ...blocked, - lastFailure: { kind: 'refused', code: 'agent_session_owner_restart_failed' } - }) - - expect( - screen.getByText("The agent couldn't restart. Your message was not sent.") - ).toBeInTheDocument() - }) - - it("shows a provider's rejection in the provider's words", () => { - renderRow({ - ...blocked, - lastFailure: { kind: 'rejected', reason: 'Claude messages support at most 20 images' } - }) - - expect(screen.getByText('Claude messages support at most 20 images')).toBeInTheDocument() - }) - - it('says only that the message was not sent when nothing more is known', () => { - renderRow(blocked) - - expect(screen.getByText('Message was not sent.')).toBeInTheDocument() - }) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatDeliveryRetry.tsx b/src/renderer/src/components/native-chat/NativeChatDeliveryRetry.tsx deleted file mode 100644 index e37016d646d..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatDeliveryRetry.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { RotateCcw } from 'lucide-react' -import { - admitStructuredAgentSessionOutboxEntry, - type StructuredAgentSessionOutboxEntry -} from '../../../../shared/structured-agent-session-outbox' -import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition' -import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' -import { Button } from '@/components/ui/button' -import { translate } from '@/i18n/i18n' - -export function NativeChatDeliveryRetry({ - outbox, - blockedClientMessageId, - retry -}: { - outbox: readonly StructuredAgentSessionOutboxEntry[] - blockedClientMessageId: string | null - retry: (clientMessageId: string) => void -}): React.JSX.Element | null { - // Why: read through the drain's own rule, so Retry can never name an entry other than the one - // the queue actually stopped on -- which is no longer always the head. A rejected entry holds - // nothing up, so it is offered only when the queue itself is not stopped. - const admission = admitStructuredAgentSessionOutboxEntry(outbox, blockedClientMessageId) - const retryable = - admission.state === 'blocked' - ? admission.entry - : (outbox.find((entry) => entry.state === 'rejected') ?? null) - if (!retryable) { - return null - } - return ( -
- - {retryable.state === 'unconfirmed' - ? translate( - 'auto.components.native.chat.NativeChatStructuredSession.1f772bb5d0', - 'Message delivery is unconfirmed.' - ) - : retryable.lastFailure - ? agentSessionWriteNoticeText( - structuredAgentSessionAttemptFailureParts(retryable.lastFailure) - ) - : translate( - 'auto.components.native.chat.NativeChatStructuredSession.93ef441197', - 'Message was not sent.' - )} - - -
- ) -} diff --git a/src/renderer/src/components/native-chat/NativeChatEmptyState.test.tsx b/src/renderer/src/components/native-chat/NativeChatEmptyState.test.tsx index dc2639afca2..dccfaba728c 100644 --- a/src/renderer/src/components/native-chat/NativeChatEmptyState.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatEmptyState.test.tsx @@ -30,9 +30,27 @@ it('shows the host message in place of the terminal-backed default', () => { expect(screen.queryByText(/Toggle back to the terminal/)).toBeNull() }) -it('keeps the structured chat retrying line when the host sent a message', () => { - render() +// The structured chat passes words only from the notice table, never the host's. +it("says the structured chat's own words for the failure once, as the title, above its retrying line", () => { + render( + + ) + expect(screen.getByText("Orca couldn't open this chat's history right now.")).toHaveClass( + 'font-medium' + ) + expect(screen.queryByText('Could not load conversation')).toBeNull() expect( screen.getByText('The transcript could not be read. Orca keeps trying to load it.') ).toBeInTheDocument() }) + +it('says a failure no retry gets past in its one line, with nothing of trying again', () => { + render() + expect(screen.getByText('Unable to load this chat.')).toHaveClass('font-medium') + expect(screen.queryByText('Could not load conversation')).toBeNull() + expect(screen.queryByText(/keeps trying|Toggle back/)).toBeNull() +}) diff --git a/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx b/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx index ed2299d2fc2..ec84f554cd7 100644 --- a/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx +++ b/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx @@ -7,17 +7,20 @@ import type { NativeChatSession } from '../../../../shared/native-chat-types' export function NativeChatEmptyState({ kind, message, + headline, agent, retrying = false }: { kind: 'loading' | 'empty' | 'error' | 'not-agent' message?: string + /** The chat's own sentence for the failure, said once: it takes the generic title's place. */ + headline?: string agent?: NativeChatSession['agent'] /** The read retries on its own (structured chat), so the error says so instead of pointing - * back to the terminal. The host's message is on the pane's status line already. */ + * back to the terminal. */ retrying?: boolean }): React.JSX.Element { - const copy = emptyStateCopy(kind, message, agent, retrying) + const copy = emptyStateCopy(kind, { message, headline }, agent, retrying) return (
void }): React.JSX.Element | null { if (lifecycle !== 'failed' && lifecycle !== 'visibility-unknown') { @@ -25,11 +32,17 @@ export function NativeChatLaunchRetry({ 'auto.components.native.chat.NativeChatLaunchRetry.unknown', 'Chat connection could not be confirmed.' ) + const cause = + lifecycle === 'failed' && failure + ? agentSessionWriteNoticeText( + agentSessionRefusalCauseParts(failure, agentLabel ? { agentName: agentLabel } : {}) + ) + : '' return (
{message} - {lifecycle === 'failed' && failureReason ? ` ${failureReason}` : null} + {cause ? ` ${cause}` : null}
) : 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 e711d62245a..fb432e1e5b4 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -6,6 +6,7 @@ import { useNativeChatRetainedSession } from './use-native-chat-retained-session import { isNativeChatTranscriptUnsettled } from './native-chat-live-session-contract' import { selectNativeChatViewState } from './native-chat-view-state' import { NativeChatMessageList } from './NativeChatMessageList' +import { useNativeChatLaunchPromptDeliveryNotice } from './use-native-chat-launch-prompt-delivery-notice' import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatComposer' import { useNativeChatFontScale } from './use-native-chat-font-scale' import { useNativeChatCanSend } from './use-native-chat-can-send' @@ -246,13 +247,10 @@ export function NativeChatResolvedView({ ? sessionWithLaunchPrompt : { ...sessionWithLaunchPrompt, messages } }, [sessionWithLaunchPrompt, commandMarkers]) - const failedLaunchPromptMessageIds = useMemo(() => { - const id = paneLaunchPrompt?.failed ? launchPromptMessage?.id : null - if (!id || !sessionAfterCommandBoundaries.messages.some((message) => message.id === id)) { - return undefined - } - return new Set([id]) - }, [paneLaunchPrompt?.failed, launchPromptMessage?.id, sessionAfterCommandBoundaries.messages]) + const launchPromptDeliveryNotices = useNativeChatLaunchPromptDeliveryNotice( + paneLaunchPrompt?.failed ? launchPromptMessage?.id : null, + sessionAfterCommandBoundaries.messages + ) // The streaming preview bubble (if any) sits after the transcript but before // the optimistic user echoes — same order mobile uses. @@ -408,7 +406,7 @@ export function NativeChatResolvedView({ showTurnStatus={false} onLinkClick={onLinkClick} allowFileUriLinks={fileLinkContext !== null} - failedDeliveryMessageIds={failedLaunchPromptMessageIds} + deliveryNotices={launchPromptDeliveryNotices} /> )}
diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx index cb777f910f2..f86fc972c22 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx @@ -4,6 +4,10 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react' import type * as ReactModule from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' + +const toastError = vi.hoisted(() => vi.fn()) +vi.mock('sonner', () => ({ toast: { error: toastError } })) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string, values?: Record) => { @@ -441,6 +445,55 @@ describe('NativeChatSessionOptionPickers', () => { await waitFor(() => expect(invokeAction).toHaveBeenCalledWith('model')) }) + it.each([ + [ + "the table's words for a host's refusal, not its message", + new RuntimeRpcCallError({ + id: 'request-1', + ok: false, + error: { + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + } + } + }, + _meta: { runtimeId: 'runtime-1' } + }), + "Unable to load this chat. The setting wasn't changed." + ], + [ + "a local surface's own sentence", + new Error('The terminal did not accept the command.'), + 'The terminal did not accept the command.' + ] + ])('describes a failed option change with %s', async (_label, error, description) => { + toastError.mockClear() + const invokeAction = vi.fn().mockRejectedValue(error) + render( + + ) + screen.getByRole('button', { name: 'Choose in agent picker…' }).click() + await waitFor(() => + expect(toastError).toHaveBeenCalledExactlyOnceWith('Could not update option', { + description + }) + ) + }) + it('uses a Toggle action for unknown flip-only options via invokeAction', async () => { const invokeAction = vi.fn().mockResolvedValue({ snapshot: [] }) const setOption = vi.fn().mockResolvedValue({ snapshot: [] }) diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx index 507103d485f..0dacff0289e 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx @@ -32,6 +32,9 @@ import { nativeChatSessionOptionLabel } from './native-chat-session-option-labels' import type { NativeChatOptionPickerRequest } from './native-chat-composer-types' +import { agentSessionThrownFailure } from '../../../../shared/agent-session-write-failure' +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' +import { agentSessionWriteFailureText } from './agent-session-write-notice-text' export type NativeChatSessionOptionPickersProps = { surface: SessionOptionsSurface | null @@ -204,6 +207,15 @@ function DescriptorMenuRows(props: { ) } +/** A host's error is words for its log, so it gets the table's; a local surface's error is + * already written for the person. */ +function optionUpdateFailureDescription(error: unknown): string { + if (error instanceof RuntimeRpcCallError) { + return agentSessionWriteFailureText(agentSessionThrownFailure(error, error.code), 'option') + } + return error instanceof Error ? error.message : String(error) +} + function runSurfaceCall( pendingKey: string, setPendingId: (id: string | null) => void, @@ -214,7 +226,7 @@ function runSurfaceCall( .catch((error) => { toast.error( translate('components.native-chat.composer.optionUpdateFailed', 'Could not update option'), - { description: error instanceof Error ? error.message : String(error) } + { description: optionUpdateFailureDescription(error) } ) }) .finally(() => setPendingId(null)) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx index 9513d0cfeb6..644c1c65750 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx @@ -26,6 +26,14 @@ vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard import { NativeChatStructuredSession } from './NativeChatStructuredSession' import { readOutbox } from './structured-agent-session-outbox-storage' +const NOT_SIGNED_IN = { + kind: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'notSignedIn' } +} as const +// Retry beside it is the resend, so the words keep only the step before it. +const NOT_SIGNED_IN_TEXT = 'Codex is not signed in for the selected account. Sign in first.' + function sessionView(): React.JSX.Element { return ( { expect(mocks.retryLaunch).toHaveBeenCalledWith('wt-1', 'session-1') }) - it('shows why a failed launch failed beside Retry', () => { + it('words why a failed launch failed beside Retry from the refusal, never its code', () => { mocks.launchLifecycle = 'failed' - mocks.launchFailureReason = 'claude is not signed in' + mocks.launchFailure = NOT_SIGNED_IN render(sessionView()) - expect(screen.getByText('Chat could not be started. claude is not signed in')).toBeTruthy() + expect(screen.getByText(`Chat could not be started. ${NOT_SIGNED_IN_TEXT}`)).toBeTruthy() + expect(screen.queryByText(/agent_session_/)).toBeNull() + }) + + it("keeps a step the Retry doesn't take, and drops one it does", () => { + mocks.launchLifecycle = 'failed' + mocks.launchFailure = { + kind: 'refused', + code: 'agent_session_conflict', + details: { reason: 'claimConflicted' } + } + const { rerender } = render(sessionView()) + expect( + screen.getByText( + 'Chat could not be started. This chat is still open in a terminal agent. Quit that agent to continue the chat here.' + ) + ).toBeTruthy() + + mocks.launchFailure = { + kind: 'refused', + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' } + } + rerender(sessionView()) + expect( + screen.getByText( + "Chat could not be started. Orca couldn't open this chat's history right now." + ) + ).toBeTruthy() + }) + + it('says only that the chat could not start when the refusal names no reason', () => { + mocks.launchLifecycle = 'failed' + mocks.launchFailure = { kind: 'refused', code: 'agent_session_operation_invalid' } + render(sessionView()) + + expect(screen.getByText('Chat could not be started.')).toBeTruthy() + expect(screen.queryByText(/agent_session_/)).toBeNull() }) it('keeps a stale reason off a launch that is no longer failed', () => { mocks.launchLifecycle = 'visibility-unknown' - mocks.launchFailureReason = 'claude is not signed in' + mocks.launchFailure = NOT_SIGNED_IN render(sessionView()) expect(screen.getByText('Chat connection could not be confirmed.')).toBeTruthy() @@ -177,10 +222,10 @@ describe('NativeChatStructuredSession launch lifecycle', () => { const { rerender } = render(sessionView()) expect(composerSend()('still there?', [])).toBe(true) - mocks.launchFailureReason = 'claude is not signed in' + mocks.launchFailure = NOT_SIGNED_IN rerender(sessionView()) - expect(screen.getByText('Chat could not be started. claude is not signed in')).toBeTruthy() + expect(screen.getByText(`Chat could not be started. ${NOT_SIGNED_IN_TEXT}`)).toBeTruthy() expect(mocks.call).not.toHaveBeenCalled() expect(readOutbox('session-1')).toEqual([ expect.objectContaining({ diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx index 8edcd625ef4..89ef518feb3 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx @@ -26,13 +26,7 @@ afterEach(() => { resetStructuredSessionMocks() }) -// The read transport always hands the pane the host's words, so the retrying line must not hide -// behind them; those words show once, on the status line. -it('says a failed read keeps retrying, and shows the host message once', () => { - mocks.status = 'error' - mocks.readError = 'journal unreadable' - mocks.messages = [] - +function renderPane(): void { render( { agent="codex" /> ) +} + +function journalRefusal(reason: 'journalCorrupt' | 'journalUnavailable') { + return { code: 'agent_session_journal_unreadable', details: { reason } } as const +} + +// The host's message and code never reach the pane; it words the refusal, and says it once. +it('says a failed read with no refusal keeps retrying, and adds nothing of the host', () => { + mocks.status = 'error' + mocks.messages = [] + + renderPane() expect(screen.getByText('Could not load conversation')).toBeTruthy() expect( screen.getByText('The transcript could not be read. Orca keeps trying to load it.') ).toBeTruthy() - expect(screen.getAllByText('journal unreadable')).toHaveLength(1) + expect(screen.queryByText(/history couldn't be loaded/)).toBeNull() expect(screen.queryByText(/Toggle back to the terminal/)).toBeNull() }) + +it('says a damaged history cannot load in one line, without claiming Orca keeps trying', () => { + mocks.status = 'error' + mocks.readRefusal = journalRefusal('journalCorrupt') + mocks.messages = [] + + renderPane() + + expect(screen.getAllByText('Unable to load this chat.')).toHaveLength(1) + expect(screen.queryByText('Could not load conversation')).toBeNull() + expect(screen.queryByText(/keeps trying/)).toBeNull() + expect(screen.queryByText(/agent_session_/)).toBeNull() +}) + +it("names a history that couldn't open right now once, and that the pane keeps trying", () => { + mocks.status = 'error' + mocks.readRefusal = journalRefusal('journalUnavailable') + mocks.messages = [] + + renderPane() + + expect(screen.getAllByText("Orca couldn't open this chat's history right now.")).toHaveLength(1) + expect(screen.queryByText('Could not load conversation')).toBeNull() + expect( + screen.getByText('The transcript could not be read. Orca keeps trying to load it.') + ).toBeTruthy() + expect(screen.queryByText(/Try again/)).toBeNull() +}) + +it('says only that it is reconnecting, not as an error, when a failure names nothing', () => { + mocks.status = 'error' + + renderPane() + + expect(screen.getByTestId('message-list')).toBeTruthy() + const reconnecting = screen.getByText('Reconnecting to this chat…') + expect(reconnecting.className).not.toContain('text-destructive') + expect(screen.queryByText(/history couldn't be loaded/)).toBeNull() +}) + +it('words a failed reconnect beside a transcript it keeps', () => { + mocks.status = 'error' + mocks.readRefusal = journalRefusal('journalUnavailable') + + renderPane() + + expect(screen.getByTestId('message-list')).toBeTruthy() + expect(screen.getByText("Orca couldn't open this chat's history right now.")).toBeTruthy() +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx new file mode 100644 index 00000000000..6f529c1d3c9 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx @@ -0,0 +1,163 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../../shared/agent-session-journal-item-key' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { structuredAgentSessionStartFailureRowIdentity } from '../../../../shared/structured-agent-session-start-failure-row-key' + +const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => + (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() +) + +vi.mock('@/runtime/structured-agent-session-client', () => + moduleFactories.structuredAgentSessionClient() +) +vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) +vi.mock('./use-native-chat-font-scale', () => moduleFactories.useNativeChatFontScale()) +vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) +vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) +vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) +vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) +vi.mock('./NativeChatEmptyState', () => moduleFactories.nativeChatEmptyState()) +vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) +vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) + +import { NativeChatStructuredSession } from './NativeChatStructuredSession' + +afterEach(() => { + cleanup() + localStorage.clear() + resetStructuredSessionMocks() +}) + +const SESSION_ID = 'start-failure-session' +const START_FAILED: AgentSessionFailureFact = { kind: 'providerStartFailed' } +const START_FAILED_REASON = + 'Claude stopped before it finished starting. Send your message to try again.' + +function startFailureRow(fact: AgentSessionFailureFact): AgentJournalRenderItem { + return { + itemId: agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('generation-1')), + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'status', + tone: 'error', + ...agentSessionFailureWords(fact, { agentName: 'Claude', surface: 'row' }) + } + } +} + +function rejected(clientMessageId: string, reason: string, rejection: AgentSessionFailureFact) { + return { + outbox: { + clientMessageId, + sessionId: SESSION_ID, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }, + previewUris: [], + state: 'rejected', + queuedAt: 1, + lastAttemptAt: null, + retryAfterUnknownSubmittedAt: null, + lastFailure: { kind: 'rejected', reason, rejection: { kind: rejection.kind } } + }, + submission: { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: 'rejected', + providerItemId: null, + reason, + rejection, + submittedAt: 1, + resolvedAt: 1 + } + } +} + +function renderPane(messages: ReturnType[]): void { + mocks.mode = 'outbox' + mocks.submissions = messages.map((message) => message.submission) + localStorage.setItem( + `orca:desktopStructuredAgentSessionOutbox:v1:${encodeURIComponent(SESSION_ID)}`, + JSON.stringify(messages.map((message) => message.outbox)) + ) + render( + + ) +} + +async function notice(clientMessageId: string): Promise { + return waitFor(() => { + const row = document.querySelector( + `[data-message-id="${agentJournalSubmissionKey(clientMessageId)}"]` + ) + if (!row) { + throw new Error(`no notice on ${clientMessageId}`) + } + return row + }) +} + +// The start's own row says why, so its rejected messages say only that they were not sent. +it("says only 'not sent', with its Retry, on each message the failed start's row explains", async () => { + mocks.journalItems = [startFailureRow(START_FAILED)] + + renderPane([ + rejected('first', START_FAILED_REASON, START_FAILED), + rejected('second', START_FAILED_REASON, START_FAILED) + ]) + + for (const id of ['first', 'second']) { + const row = await notice(id) + expect(within(row).getByText('Your message was not sent.')).toBeTruthy() + expect(within(row).getByRole('button', { name: 'Retry' })).toBeTruthy() + } + expect(screen.queryByText(/stopped before it finished starting/)).toBeNull() +}) + +it('keeps the full notice on a message rejected for a reason no start-failure row states', async () => { + mocks.journalItems = [startFailureRow(START_FAILED)] + const providerRejected: AgentSessionFailureFact = { + kind: 'providerRejected', + detail: { text: 'Image type .bmp', audience: 'person' } + } + + renderPane([ + rejected('stated', START_FAILED_REASON, START_FAILED), + rejected( + 'other', + 'The provider did not accept this message: Image type .bmp.', + providerRejected + ) + ]) + + expect(within(await notice('stated')).getByText('Your message was not sent.')).toBeTruthy() + expect( + within(await notice('other')).getByText( + 'The provider did not accept this message: Image type .bmp.' + ) + ).toBeTruthy() +}) + +it("keeps the start failure's own words when its row is not loaded", async () => { + renderPane([rejected('first', START_FAILED_REASON, START_FAILED)]) + + expect( + within(await notice('first')).getByText('Claude stopped before it finished starting.') + ).toBeTruthy() +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index 5690c879db1..5689cf9689f 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -2,7 +2,10 @@ import { forwardRef, useImperativeHandle, useRef } from 'react' import { vi } from 'vitest' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import type { AgentSessionBackgroundTask } from '../../../../shared/agent-session-wire' +import type { AgentSessionWriteRefusal } from '../../../../shared/agent-session-write-failure' +import type { AgentSessionRefusalReference } from '../../../../shared/agent-session-wire-refusals' import type { NativeChatApprovalCardProps } from './NativeChatApprovalCard' +import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' import type { NativeChatQuestionCardProps } from './NativeChatQuestionCard' import type { NativeChatLaunchSeed } from './native-chat-composer-types' import type { NativeChatOlderPageResult } from './native-chat-pagination' @@ -19,6 +22,32 @@ function nullable(): T | null { return null } +function absent(): T | undefined { + return undefined +} + +/** Stands in for the transcript: renders only each message's delivery notice and its Retry. */ +export function DeliveryNoticesMock({ + notices +}: { + notices?: ReadonlyMap +}): React.JSX.Element { + return ( +
+ {[...(notices ?? [])].map(([id, notice]) => ( +
+ {notice.text} + {notice.onRetry ? ( + + ) : null} +
+ ))} +
+ ) +} + type StructuredSessionMessageListProps = { allowFileUriLinks?: boolean isVisible?: boolean @@ -28,6 +57,7 @@ type StructuredSessionMessageListProps = { isWorking?: boolean runtimeContext?: unknown session?: { hasMore: boolean; loadingEarlier: boolean; loadEarlier: () => Promise } + deliveryNotices?: ReadonlyMap } const initialMessageListProps: StructuredSessionMessageListProps | null = null @@ -43,13 +73,13 @@ export function createStructuredSessionMocks() { call: vi.fn<(...args: never[]) => unknown>(), fileLinkClick: vi.fn<(...args: never[]) => unknown>(), launchLifecycle: nullable(), - launchFailureReason: nullable(), + launchFailure: nullable(), launchResumes: false, retryLaunch: vi.fn<(...args: never[]) => unknown>(), controllerProps: nullable<{ transportEnabled?: boolean }>(), mode: 'static' as 'static' | 'outbox', status: 'ready' as 'idle' | 'loading' | 'ready' | 'error', - readError: nullable(), + readRefusal: absent(), messages: null as null | unknown[], messageListProps: initialMessageListProps, composerProps: null as null | { @@ -60,6 +90,7 @@ export function createStructuredSessionMocks() { approvalCardProps: initialApprovalCardProps, questionCardProps: null as NativeChatQuestionCardProps | null, promptItems: [] as AgentJournalRenderItem[], + journalItems: new Array(), respond: vi.fn<(...args: never[]) => unknown>(), cancel: vi.fn<(...args: never[]) => unknown>(), handlePasteEvent: vi.fn<(...args: never[]) => unknown>(), @@ -90,6 +121,8 @@ export function createStructuredSessionMocks() { useStructuredAgentSession: async () => { const { useStructuredAgentSessionOutbox } = await import('./use-structured-agent-session-outbox') + const { projectStructuredAgentSessionMessages } = + await import('../../../../shared/structured-agent-session-message-projection') return { useStructuredAgentSession: (props: { sessionId: string @@ -104,11 +137,11 @@ export function createStructuredSessionMocks() { submissions: mocks.submissions as never }) return { - journalItems: [], + journalItems: mocks.journalItems, messages: mocks.messages ?? (mocks.mode === 'outbox' - ? [] + ? projectStructuredAgentSessionMessages([], outbox.outbox, []) : [ { id: 'message-1', @@ -124,13 +157,15 @@ export function createStructuredSessionMocks() { } ]), status: mocks.status, - error: mocks.readError ?? outbox.error, + error: outbox.error, + readRefusal: mocks.readRefusal, hasOlder: mocks.hasOlder, loadingOlder: mocks.loadingOlder, olderHistoryGeneration: mocks.olderHistoryGeneration, loadOlder: mocks.loadOlder, prompts: mocks.promptItems, outbox: outbox.outbox, + submissions: mocks.submissions, blockedClientMessageId: outbox.blockedClientMessageId, send: outbox.send, retry: outbox.retry, @@ -182,7 +217,7 @@ export function createStructuredSessionMocks() { getStructuredAgentSessionLaunchResumes: () => mocks.launchResumes, useStructuredAgentSessionLaunchSelection: () => null, useStructuredAgentSessionLaunchLifecycle: () => mocks.launchLifecycle, - useStructuredAgentSessionLaunchFailureReason: () => mocks.launchFailureReason + useStructuredAgentSessionLaunchFailure: () => mocks.launchFailure }), useNativeChatFontScale: () => ({ useNativeChatFontScale: () => ({ scale: 1 }) @@ -200,7 +235,7 @@ export function createStructuredSessionMocks() { nativeChatMessageList: () => ({ NativeChatMessageList: (props: typeof mocks.messageListProps) => { mocks.messageListProps = props - return
+ return } }), nativeChatComposer: () => ({ @@ -238,19 +273,20 @@ export function createStructuredSessionMocks() { const resetStructuredSessionMocks = (): void => { mocks.call.mockReset() mocks.launchLifecycle = null - mocks.launchFailureReason = null + mocks.launchFailure = null mocks.launchResumes = false mocks.retryLaunch.mockReset() mocks.controllerProps = null mocks.mode = 'static' mocks.status = 'ready' - mocks.readError = null + mocks.readRefusal = undefined mocks.messages = null mocks.messageListProps = null mocks.composerProps = null mocks.approvalCardProps = null mocks.questionCardProps = null mocks.promptItems = [] + mocks.journalItems = [] mocks.respond.mockReset() mocks.cancel.mockReset() mocks.handlePasteEvent.mockReset() diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 8d20c65bd48..6daa3d3f62c 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { agentSessionPromptQuestions } from '../../../../shared/agent-session-question-answer' import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' @@ -22,10 +22,15 @@ import { NativeChatStructuredSessionStatus } from './NativeChatStructuredSession import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-adoption' import { NativeChatLaunchRetry } from './NativeChatLaunchRetry' import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-launch' -import { NativeChatDeliveryRetry } from './NativeChatDeliveryRetry' import { useStructuredAgentSessionHostExecutionPhase } from './StructuredAgentSessionStatusBridge' import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner' +import { structuredAgentSessionReadFailureNotice } from './structured-agent-session-read-failure-notice' +import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' + +const NO_SUBMISSIONS: readonly AgentJournalSubmission[] = [] export function NativeChatStructuredSession( props: Omit @@ -100,7 +105,46 @@ export function NativeChatStructuredSession( }), [controller, props.agent, props.sessionId] ) + // Read at click time, so the notices stay put while the outbox's Retry is rebuilt each render. + const retryRef = useRef(controller.retry) + useEffect(() => { + retryRef.current = controller.retry + }) + const retryDelivery = useCallback((clientMessageId: string) => { + retryRef.current(clientMessageId) + }, []) + const agentLabel = structuredAgentLabel(props.agent === 'codex' ? 'codex' : 'claude') + // Only a rejected message reads the journal's rows, so a new batch of them re-renders no row else. + const hasRejected = controller.outbox.some((entry) => entry.state === 'rejected') + const rejectionRows = hasRejected ? controller.submissions : NO_SUBMISSIONS + const startFailures = useStructuredAgentSessionStartFailureFacts( + controller.journalItems, + hasRejected + ) + const deliveryNotices = useMemo( + () => + structuredAgentSessionDeliveryNotices( + controller.outbox, + controller.blockedClientMessageId, + agentLabel, + retryDelivery, + rejectionRows, + startFailures + ), + [ + controller.outbox, + controller.blockedClientMessageId, + agentLabel, + retryDelivery, + rejectionRows, + startFailures + ] + ) const viewState = selectNativeChatViewState(session, { readRetries: true }) + const readFailure = + controller.status === 'error' + ? structuredAgentSessionReadFailureNotice(controller.readRefusal) + : null const fontScale = useNativeChatFontScale(viewState.kind === 'ready') const imageRuntimeContext = useNativeChatImageRuntimeContext(props.tabId) const { onLinkClick, linkActionRequest, closeLinkActions } = useNativeChatLinkActions( @@ -217,7 +261,11 @@ export function NativeChatStructuredSession( {viewState.kind === 'loading' ? ( ) : viewState.kind === 'error' ? ( - + ) : viewState.kind === 'empty' ? ( ) : ( @@ -237,24 +285,26 @@ export function NativeChatStructuredSession( onLinkClick={onLinkClick} allowFileUriLinks={onLinkClick !== undefined} runtimeContext={imageRuntimeContext} + deliveryNotices={deliveryNotices} /> )}
- ({ call: vi.fn(), @@ -45,6 +46,8 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ vi.mock('./use-structured-agent-session', async () => { const { useStructuredAgentSessionOutbox } = await import('./use-structured-agent-session-outbox') + const { projectStructuredAgentSessionMessages } = + await import('../../../../shared/structured-agent-session-message-projection') return { useStructuredAgentSession: (props: { sessionId: string @@ -60,7 +63,7 @@ vi.mock('./use-structured-agent-session', async () => { journalItems: [], messages: mocks.mode === 'outbox' - ? [] + ? projectStructuredAgentSessionMessages([], outbox.outbox, []) : [ { id: 'message-1', @@ -77,6 +80,7 @@ vi.mock('./use-structured-agent-session', async () => { loadOlder: vi.fn(), prompts: mocks.promptItems, outbox: outbox.outbox, + submissions: mocks.submissions, blockedClientMessageId: outbox.blockedClientMessageId, send: outbox.send, retry: outbox.retry, @@ -131,12 +135,19 @@ vi.mock('./use-native-chat-file-link-click', () => ({ useNativeChatFileLinkClick: (context: unknown) => (context ? mocks.fileLinkClick : undefined) })) -vi.mock('./NativeChatMessageList', () => ({ - NativeChatMessageList: (props: typeof mocks.messageListProps) => { - mocks.messageListProps = props - return
+vi.mock('./NativeChatMessageList', async () => { + const { DeliveryNoticesMock } = await import('./NativeChatStructuredSession.test-harness') + return { + NativeChatMessageList: ( + props: NonNullable & { + deliveryNotices?: ReadonlyMap + } + ) => { + mocks.messageListProps = props + return + } } -})) +}) vi.mock('./NativeChatComposer', () => ({ NativeChatComposer: forwardRef((props: typeof mocks.composerProps, ref) => { @@ -280,6 +291,128 @@ describe('NativeChatStructuredSession delivery', () => { expect(request.envelope.clientOperationId).toBe('op-head') }) + it('offers a rejected message no Retry while the queue is stopped, and gives it back once it moves', async () => { + mocks.mode = 'outbox' + mocks.submissions = [] + mocks.call.mockResolvedValue({ + ok: true, + value: { submission: { clientMessageId: 'op-head', dispatchState: 'accepted' } } + }) + seedOutbox('session-held-rejected', [ + seededEntry('session-held-rejected', 'op-head', 'first', 'unconfirmed'), + { + ...seededEntry('session-held-rejected', 'op-rejected', 'second', 'queued'), + state: 'rejected', + lastFailure: { kind: 'rejected', reason: 'Claude messages support at most 20 images' } + } + ]) + + render( + + ) + + await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) + expect(screen.getByText('Claude messages support at most 20 images')).toBeTruthy() + // One Retry, the stopped message's: it sends only that one. + fireEvent.click(screen.getByRole('button', { name: /Retry/ })) + await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + expect(mocks.call.mock.calls[0]?.[2]).toMatchObject({ + envelope: { clientOperationId: 'op-head' } + }) + + // The queue moved, so the rejected message offers its own Retry again. + await waitFor(() => expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull()) + expect(screen.getByText('Claude messages support at most 20 images')).toBeTruthy() + expect(screen.getAllByRole('button', { name: /Retry/ })).toHaveLength(1) + expect(mocks.call).toHaveBeenCalledOnce() + }) + + it("words a failed start on each message by the chat's agent, leaving the resend to its Retry", async () => { + mocks.mode = 'outbox' + mocks.submissions = [] + const startFailed = (clientMessageId: string, text: string) => ({ + ...seededEntry('session-start-failed', clientMessageId, text, 'queued'), + state: 'rejected' as const, + lastFailure: { + kind: 'rejected' as const, + reason: 'Codex stopped before it finished starting. Send your message to try again.', + rejection: { kind: 'providerStartFailed' as const } + } + }) + seedOutbox('session-start-failed', [ + startFailed('op-first', 'first'), + startFailed('op-second', 'second') + ]) + + render( + + ) + + await waitFor(() => + expect(screen.getAllByText('Codex stopped before it finished starting.')).toHaveLength(2) + ) + expect(screen.getAllByRole('button', { name: /Retry/ })).toHaveLength(2) + expect(screen.queryByText(/Send your message to try again/)).toBeNull() + }) + + it("words a rejected message from its loaded journal row, not the message's own copy", async () => { + mocks.mode = 'outbox' + const reason = "Claude couldn't start. Send your message to try again." + mocks.submissions = [ + { + clientMessageId: 'op-recorded', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + reason, + rejection: { + kind: 'startFailed', + refusal: { code: 'agent_session_identity_required', details: { reason: 'recordMissing' } } + }, + submittedAt: 1, + resolvedAt: 1 + } + ] + seedOutbox('session-recorded', [ + { + ...seededEntry('session-recorded', 'op-recorded', 'first', 'queued'), + state: 'rejected', + lastFailure: { kind: 'rejected', reason, rejection: { kind: 'startFailed' } } + } + ]) + + render( + + ) + + await waitFor(() => + expect(screen.getByText("Codex couldn't start. Start a new chat to continue.")).toBeTruthy() + ) + expect(screen.queryByText(reason)).toBeNull() + }) + it('names the stuck message behind an admitted head, and its Retry sends that one', async () => { mocks.mode = 'outbox' mocks.submissions = [] diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx index 86a2b858a42..2f33b0ed0c3 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx @@ -18,6 +18,8 @@ export function NativeChatStructuredSessionStatus(props: { /** The host's word on the provider child; `starting` is published but not yet answering. */ startupPhase: 'starting' | 'ready' | null error: string | null + /** A read the pane is reconnecting on its own: said plainly, not as an error. */ + reconnecting?: boolean composerError: string | null isVisible: boolean backgroundTasks: StructuredSessionBackgroundTasksView @@ -68,6 +70,11 @@ export function NativeChatStructuredSessionStatus(props: { )}

) : null} + {props.reconnecting && !props.error ? ( +

+ {translate('components.native-chat.state.reconnecting', 'Reconnecting to this chat…')} +

+ ) : null} {props.error || props.composerError ? (

{props.error ?? props.composerError} diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx index b27736c71bb..fb58acb7280 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx @@ -1,7 +1,7 @@ import { memo } from 'react' import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' -import { MessageRow } from './NativeChatMessageRow' +import { MessageRow, type NativeChatDeliveryNotice } from './NativeChatMessageRow' import { NativeChatResolutionReceipt } from './NativeChatResolutionReceipt' import { NativeChatWorkingStatus } from './NativeChatWorkingStatus' import { NativeChatTurnDiffRollup } from './NativeChatTurnDiffRollup' @@ -17,7 +17,8 @@ export type NativeChatTranscriptRowContext = { revealedDiff: NativeChatDiffReveal | null taskListPredecessors: ReadonlyMap expandedTurnIds: ReadonlySet - failedDeliveryMessageIds?: ReadonlySet + /** Keyed by message id: the user messages that did not go through, each with its own words. */ + deliveryNotices?: ReadonlyMap allowFileUriLinks: boolean runtimeContext?: RuntimeFileOperationArgs | null onLinkClick?: CommentMarkdownLinkClickHandler @@ -60,7 +61,7 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({ onScrollMessageToTop={context.onScrollMessageToTop} onLinkClick={context.onLinkClick} allowFileUriLinks={context.allowFileUriLinks} - deliveryFailed={context.failedDeliveryMessageIds?.has(message.id) === true} + deliveryNotice={context.deliveryNotices?.get(message.id)} structuredActivityUi={context.showTurnStatus} folded={slot.folded} runtimeContext={context.runtimeContext} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts new file mode 100644 index 00000000000..faadac1127e --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -0,0 +1,414 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import { + createStructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' +import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../../shared/agent-session-journal-item-key' +import { structuredAgentSessionStartFailureRowIdentity } from '../../../../shared/structured-agent-session-start-failure-row-key' +import { + structuredAgentSessionDeliveryNotices, + structuredAgentSessionStartFailureFacts +} from './structured-agent-session-delivery-notices' + +function entry( + clientMessageId: string, + patch: Partial = {} +): StructuredAgentSessionOutboxEntry { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId, + sessionId: 'session-1', + text: clientMessageId, + attachments: [], + queuedAt: 1 + }), + ...patch + } +} + +function texts( + outbox: StructuredAgentSessionOutboxEntry[], + blocked: string | null = null, + submissions: readonly AgentJournalSubmission[] = [], + startFailures: readonly AgentSessionFailureFact[] = [] +): Record { + const notices = structuredAgentSessionDeliveryNotices( + outbox, + blocked, + 'Claude', + () => {}, + submissions, + startFailures + ) + return Object.fromEntries([...notices].map(([id, notice]) => [id, notice.text])) +} + +describe('the notice on each message that did not go through', () => { + it('gives two failed messages each their own reason and their own Retry', () => { + const retry = vi.fn() + const notices = structuredAgentSessionDeliveryNotices( + [ + entry('first', { + state: 'rejected', + lastFailure: { kind: 'rejected', reason: 'Claude messages support at most 20 images' } + }), + entry('second', { + state: 'rejected', + lastFailure: { + kind: 'rejected', + reason: 'Claude never finished starting, so Orca stopped it.', + rejection: { kind: 'hostStopped' } + } + }) + ], + null, + 'Claude', + retry, + [], + [] + ) + + expect([...notices.keys()]).toEqual([ + agentJournalSubmissionKey('first'), + agentJournalSubmissionKey('second') + ]) + expect(notices.get(agentJournalSubmissionKey('first'))?.text).toBe( + 'Claude messages support at most 20 images' + ) + expect(notices.get(agentJournalSubmissionKey('second'))?.text).toBe( + 'Claude never finished starting, so Orca stopped it.' + ) + notices.get(agentJournalSubmissionKey('second'))?.onRetry?.() + expect(retry).toHaveBeenCalledExactlyOnceWith('second') + }) + + it('chooses the words from the saved refusal on the message the queue stopped on', () => { + expect( + texts( + [ + entry('held', { + lastFailure: { kind: 'refused', code: 'agent_session_owner_restart_failed' } + }) + ], + 'held' + ) + ).toEqual({ + [agentJournalSubmissionKey('held')]: "The agent couldn't restart. Your message was not sent." + }) + }) + + // The same rule as a rejected row's: its own Retry is the resend step, and any other step stays. + it('leaves a retry step to the Retry beside the message the queue stopped on', () => { + const held = (lastFailure: StructuredAgentSessionOutboxEntry['lastFailure']) => + texts([entry('held', { lastFailure })], 'held') + expect( + held({ + kind: 'refused', + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' } + }) + ).toEqual({ + [agentJournalSubmissionKey('held')]: + "Orca couldn't open this chat's history right now. Your message was not sent." + }) + expect( + held({ + kind: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'notSignedIn' } + }) + ).toEqual({ + [agentJournalSubmissionKey('held')]: + 'Your message was not sent. Claude is not signed in for the selected account. Sign in first.' + }) + }) + + it('says a message is unconfirmed, and only that it was not sent when nothing more is known', () => { + expect(texts([entry('doubt', { state: 'unconfirmed' })])).toEqual({ + [agentJournalSubmissionKey('doubt')]: 'Message delivery is unconfirmed.' + }) + expect(texts([entry('bare')], 'bare')).toEqual({ + [agentJournalSubmissionKey('bare')]: 'Message was not sent.' + }) + }) + + // The drain's own rule: a message behind the one the queue stopped on is only waiting, so it says + // nothing. A rejected message holds nothing up and keeps its words. + it('says why on the message the queue stopped on and on every rejected one', () => { + expect( + texts([ + entry('sent', { state: 'dispatching' }), + entry('rejected', { state: 'rejected' }), + entry('stuck', { state: 'unconfirmed' }), + entry('behind', { state: 'unconfirmed' }), + entry('queued') + ]) + ).toEqual({ + [agentJournalSubmissionKey('rejected')]: 'Message was not sent.', + [agentJournalSubmissionKey('stuck')]: 'Message delivery is unconfirmed.' + }) + }) + + // Its Retry would put it back in the queue to wait unseen behind the stopped message. + it('keeps a rejected message its words but not its Retry while the queue is stopped', () => { + const retry = vi.fn() + for (const [outbox, blocked] of [ + [[entry('stuck', { state: 'unconfirmed' }), entry('rejected', { state: 'rejected' })], null], + [[entry('rejected', { state: 'rejected' }), entry('held')], 'held'] + ] as const) { + const notices = structuredAgentSessionDeliveryNotices( + [...outbox], + blocked, + 'Claude', + retry, + [], + [] + ) + expect(notices.get(agentJournalSubmissionKey('rejected'))).toEqual({ + text: 'Message was not sent.' + }) + } + }) + + // Beside its own Retry the resend step is the button; without one the words keep it. + it('leaves out sending again only where the message has its own Retry', () => { + const startFailed = (clientMessageId: string): StructuredAgentSessionOutboxEntry => + entry(clientMessageId, { + state: 'rejected', + lastFailure: { + kind: 'rejected', + reason: 'Claude stopped before it finished starting. Send your message to try again.', + rejection: { kind: 'providerStartFailed' } + } + }) + expect(texts([startFailed('first'), startFailed('second')])).toEqual({ + [agentJournalSubmissionKey('first')]: 'Claude stopped before it finished starting.', + [agentJournalSubmissionKey('second')]: 'Claude stopped before it finished starting.' + }) + expect(texts([startFailed('rejected'), entry('held')], 'held')).toMatchObject({ + [agentJournalSubmissionKey('rejected')]: + 'Claude stopped before it finished starting. Send your message to try again.' + }) + }) + + it.each([ + [ + 'notDelivered', + 'This message was not delivered. Send it again to continue.', + 'This message was not delivered.' + ], + [ + 'hostFault', + "Orca ran into a problem, so this didn't go through. Try again.", + "Orca ran into a problem, so this didn't go through." + ] + ] as const)('leaves the step to the Retry beside a %s message', (kind, reason, shown) => { + expect( + texts([ + entry('rejected', { + state: 'rejected', + lastFailure: { kind: 'rejected', reason, rejection: { kind } } + }) + ]) + ).toEqual({ [agentJournalSubmissionKey('rejected')]: shown }) + }) + + // The journal holds the whole fact; the message's own copy keeps only its kind and attachment. + it("words a recorded rejection from the journal's fact, whatever reason the host wrote", () => { + const rejected = (id: string): StructuredAgentSessionOutboxEntry => + entry(id, { + state: 'rejected', + // An older host's words, and not the pane's agent name: never compared, never shown. + lastFailure: { + kind: 'rejected', + reason: "The agent couldn't be started.", + rejection: { kind: 'startFailed' } + } + }) + const recorded = (id: string, rejection: AgentSessionFailureFact): AgentJournalSubmission => ({ + clientMessageId: id, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + reason: "The agent couldn't be started.", + rejection, + submittedAt: 1, + resolvedAt: 1 + }) + const facts: [string, AgentSessionFailureFact, string][] = [ + [ + 'gone', + { + kind: 'startFailed', + refusal: { + code: 'agent_session_identity_required', + details: { reason: 'recordMissing' } + } + }, + "Claude couldn't start. Start a new chat to continue." + ], + [ + 'claimed', + { + kind: 'startFailed', + refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } } + }, + "Claude couldn't start. This chat is still open in a terminal agent. Quit that agent to continue the chat here." + ], + [ + 'resumable', + { kind: 'startFailed', refusal: { code: 'agent_session_ownership_unknown' } }, + "Claude couldn't start." + ], + [ + 'provider', + { kind: 'providerRejected', detail: { text: 'Image type .bmp', audience: 'person' } }, + 'The provider did not accept this message: Image type .bmp.' + ], + [ + 'logged', + { kind: 'providerRejected', detail: { text: 'HTTP 400 at /v1', audience: 'log' } }, + 'The provider did not accept this message.' + ] + ] + expect( + texts( + facts.map(([id]) => rejected(id)), + null, + facts.map(([id, fact]) => recorded(id, fact)) + ) + ).toEqual( + Object.fromEntries(facts.map(([id, , shown]) => [agentJournalSubmissionKey(id), shown])) + ) + // Not loaded (older than the loaded page): the message's copy has no refusal or detail, so the + // host's sentence, which holds them, is shown for those kinds; the table words the rest. + expect( + texts([ + rejected('gone'), + entry('provider', { + state: 'rejected', + lastFailure: { + kind: 'rejected', + reason: 'The provider did not accept this message: Image type .bmp.', + rejection: { kind: 'providerRejected' } + } + }), + entry('stopped', { + state: 'rejected', + lastFailure: { + kind: 'rejected', + reason: 'The agent stopped before this message was sent.', + rejection: { kind: 'providerExited' } + } + }) + ]) + ).toEqual({ + [agentJournalSubmissionKey('gone')]: "The agent couldn't be started.", + [agentJournalSubmissionKey('provider')]: + 'The provider did not accept this message: Image type .bmp.', + [agentJournalSubmissionKey('stopped')]: 'Claude stopped before this message was sent.' + }) + }) + + it('says nothing on a message that is only waiting its turn or on its way', () => { + expect(texts([entry('queued'), entry('sending', { state: 'dispatching' })])).toEqual({}) + }) + + // Matched on the typed fact of a row found by its identity, never on either sentence. + describe('a message rejected by a start whose row already says why', () => { + const startFailed: AgentSessionFailureFact = { + kind: 'startFailed', + refusal: { code: 'agent_session_identity_required', details: { reason: 'recordMissing' } } + } + const rejected = (id: string, fact: AgentSessionFailureFact) => + entry(id, { + state: 'rejected', + lastFailure: { + kind: 'rejected', + reason: 'Written by the host.', + rejection: { kind: fact.kind } + } + }) + const recorded = (id: string, fact: AgentSessionFailureFact): AgentJournalSubmission => ({ + clientMessageId: id, + fence: 1, + payloadFingerprint: id, + dispatchState: 'rejected', + providerItemId: null, + reason: 'Written by the host.', + rejection: fact, + submittedAt: 1, + resolvedAt: 1 + }) + const statusRow = (itemId: string, fact: AgentSessionFailureFact): AgentJournalRenderItem => ({ + itemId, + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'status', + tone: 'error', + ...agentSessionFailureWords(fact, { agentName: 'Claude', surface: 'row' }) + } + }) + const startRowKey = agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('gen')) + + it('reads only the start-failure rows', () => { + expect( + structuredAgentSessionStartFailureFacts([ + statusRow(startRowKey, startFailed), + statusRow(agentJournalSubmissionKey('exit-row'), { kind: 'providerExited' }) + ]) + ).toEqual([startFailed]) + }) + + it('says only that each was not sent, and words any other rejection in full', () => { + const otherRefusal: AgentSessionFailureFact = { + kind: 'startFailed', + refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } } + } + const facts = structuredAgentSessionStartFailureFacts([statusRow(startRowKey, startFailed)]) + expect( + texts( + [ + rejected('first', startFailed), + rejected('second', startFailed), + rejected('other', otherRefusal) + ], + null, + [ + recorded('first', startFailed), + recorded('second', startFailed), + recorded('other', otherRefusal) + ], + facts + ) + ).toEqual({ + [agentJournalSubmissionKey('first')]: 'Your message was not sent.', + [agentJournalSubmissionKey('second')]: 'Your message was not sent.', + [agentJournalSubmissionKey('other')]: + "Claude couldn't start. This chat is still open in a terminal agent. Quit that agent to continue the chat here." + }) + }) + + it('keeps the full notice when the rejection is not loaded, or no start row states it', () => { + const shown = "Claude couldn't start. Start a new chat to continue." + expect(texts([rejected('first', startFailed)], null, [], [startFailed])).toEqual({ + [agentJournalSubmissionKey('first')]: 'Written by the host.' + }) + expect( + texts([rejected('first', startFailed)], null, [recorded('first', startFailed)], []) + ).toEqual({ [agentJournalSubmissionKey('first')]: shown }) + }) + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts new file mode 100644 index 00000000000..ec3eb60e687 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts @@ -0,0 +1,137 @@ +// Which of the structured chat's own messages say, on their row, that they did not go through. +// +// Derived from the outbox on every render and never stored: each failed or held message carries +// its own typed failure, so each row words its own reason. Read through the drain's own rule: while +// the queue is stopped, only the message it stopped on has a Retry; another's would wait unseen +// behind it. One waiting behind says nothing; a rejected message holds nothing up, so it keeps its +// words and gets its Retry once the queue moves. +// +// A message the host recorded and then rejected is worded from the journal's own fact, found by id; +// the message keeps only a smaller copy, read when its submission is not loaded. A rejection that +// is a failed start's, the fact its loaded row states, says only that it was not sent: the row +// already says why. + +import { + readAgentSessionFailureFact, + type AgentSessionFailureFact +} from '../../../../shared/agent-session-failure' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import { agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice' +import { isStructuredAgentSessionStartFailureRow } from '../../../../shared/structured-agent-session-start-failure-row-key' +import { + admitStructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' +import type { AgentSessionFailureWordsContext } from '../../../../shared/agent-session-failure-words' +import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition' +import { translate } from '@/i18n/i18n' +import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' +import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' + +/** The facts the chat's loaded start-failure rows state. */ +export function structuredAgentSessionStartFailureFacts( + items: readonly AgentJournalRenderItem[] +): AgentSessionFailureFact[] { + const facts: AgentSessionFailureFact[] = [] + for (const item of items) { + if (item.body.kind === 'status' && isStructuredAgentSessionStartFailureRow(item.itemId)) { + const fact = readAgentSessionFailureFact(item.body.failure) + if (fact) { + facts.push(fact) + } + } + } + return facts +} + +/** Whether two facts are one failure: a start's row and the messages it rejected share one. */ +export function sameAgentSessionFailureFact( + a: AgentSessionFailureFact, + b: AgentSessionFailureFact +): boolean { + return ( + a.kind === b.kind && + a.detail?.text === b.detail?.text && + a.detail?.audience === b.detail?.audience && + a.refusal?.code === b.refusal?.code && + a.refusal?.details?.reason === b.refusal?.details?.reason && + a.attachment?.reason === b.attachment?.reason && + a.attachment?.limit === b.attachment?.limit && + a.retry?.error === b.retry?.error && + a.retry?.status === b.retry?.status + ) +} + +function deliveryNoticeText( + entry: StructuredAgentSessionOutboxEntry, + context: AgentSessionFailureWordsContext, + recorded: AgentJournalSubmission | undefined, + startFailures: readonly AgentSessionFailureFact[] +): string { + if (entry.state === 'unconfirmed') { + return translate( + 'auto.components.native.chat.NativeChatStructuredSession.1f772bb5d0', + 'Message delivery is unconfirmed.' + ) + } + if (!entry.lastFailure) { + return translate( + 'auto.components.native.chat.NativeChatStructuredSession.93ef441197', + 'Message was not sent.' + ) + } + const fact = readAgentSessionFailureFact(recorded?.rejection) + if ( + entry.state === 'rejected' && + fact && + startFailures.some((stated) => sameAgentSessionFailureFact(stated, fact)) + ) { + return agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send')) + } + return agentSessionWriteNoticeText( + structuredAgentSessionAttemptFailureParts(entry.lastFailure, context, fact) + ) +} + +/** Keyed by the message id the transcript renders each entry under. `blockedClientMessageId` is + * the entry a refusal stopped the queue on; `agentName` is the chat's agent, for the words. */ +export function structuredAgentSessionDeliveryNotices( + outbox: readonly StructuredAgentSessionOutboxEntry[], + blockedClientMessageId: string | null, + agentName: string, + retry: (clientMessageId: string) => void, + /** The journal's rows, whose rejected ones carry more of a rejection than the message keeps. */ + submissions: readonly AgentJournalSubmission[], + /** What the loaded start-failure rows state, from `structuredAgentSessionStartFailureFacts`. */ + startFailures: readonly AgentSessionFailureFact[] +): ReadonlyMap { + const admission = admitStructuredAgentSessionOutboxEntry(outbox, blockedClientMessageId) + const held = admission.state === 'blocked' ? admission.entry.clientMessageId : null + const rejected = new Map( + submissions + .filter((submission) => submission.dispatchState === 'rejected') + .map((submission) => [submission.clientMessageId, submission]) + ) + const notices = new Map() + for (const entry of outbox) { + if (entry.state === 'rejected' || entry.clientMessageId === held) { + // Its own Retry is the step, so the words leave out sending again. + const retryControl = held === null || entry.clientMessageId === held + const text = deliveryNoticeText( + entry, + { agentName, retryControl }, + rejected.get(entry.clientMessageId), + startFailures + ) + notices.set( + agentJournalSubmissionKey(entry.clientMessageId), + retryControl ? { text, onRetry: () => retry(entry.clientMessageId) } : { text } + ) + } + } + return notices +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-failure-notice.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-failure-notice.ts new file mode 100644 index 00000000000..8dee55de82e --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-failure-notice.ts @@ -0,0 +1,32 @@ +// What the structured chat says when reading its history failed: the notice table's words for the +// refusal the read met, never the host's message or code. + +import { + agentSessionReadHistoryRefusalParts, + agentSessionWriteNotDoneParts +} from '../../../../shared/agent-session-refusal-notice' +import type { AgentSessionRefusalReference } from '../../../../shared/agent-session-wire-refusals' +import { isFinalAgentSessionReadRefusal } from '../../../../shared/structured-agent-session-read-refusal' +import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' + +export type StructuredAgentSessionReadFailureNotice = { + text: string + /** The host named the situation, so the words say more than that the history did not load. */ + named: boolean + /** Nothing the read retries gets past it, so the pane no longer says it keeps trying. */ + final: boolean +} + +export function structuredAgentSessionReadFailureNotice( + refusal: AgentSessionRefusalReference | undefined +): StructuredAgentSessionReadFailureNotice { + // The pane reconnects on its own, so it is the Retry beside the words. + const parts = refusal + ? agentSessionReadHistoryRefusalParts(refusal.code, refusal.details, { retryControl: true }) + : agentSessionWriteNotDoneParts('read-history') + return { + text: agentSessionWriteNoticeText(parts), + named: refusal?.details?.reason !== undefined, + final: isFinalAgentSessionReadRefusal(refusal) + } +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts index 6ec7b38f8b2..e3f87a8e95e 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts @@ -223,7 +223,7 @@ function createReadOwner( } const transport = startStructuredAgentSessionReadTransport({ applyEvent: (event) => apply({ type: 'event', event }), - applyError: (message) => apply({ type: 'error', message }), + applyError: (message, refusal) => apply({ type: 'error', message, refusal }), getCursor: () => snapshot.state.cursor, onHistoryReadInvalidated: invalidateOlderPages, hydrate: snapshot.state.epoch === null ? hydrate : undefined, diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts index 0305b93cb6d..683676c99d6 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts @@ -9,12 +9,16 @@ import type { AgentSessionSubscribeEvent } from '../../../../shared/agent-session-wire' -const mocks = vi.hoisted(() => ({ subscribe: vi.fn() })) +const mocks = vi.hoisted(() => ({ subscribe: vi.fn(), watchHostContact: vi.fn() })) vi.mock('@/runtime/structured-agent-session-client', () => ({ subscribeStructuredAgentSession: mocks.subscribe })) +vi.mock('@/runtime/runtime-host-contact-regained', () => ({ + subscribeRuntimeHostContactRegained: mocks.watchHostContact +})) + import { startStructuredAgentSessionReadTransport } from './structured-agent-session-read-transport' type SubscribeAttempt = { @@ -311,6 +315,81 @@ describe('structured agent-session read transport unattached refusals', () => { } }) + it('hands the pane the refusal a read met, and stops reconnecting only past damage', async () => { + vi.useFakeTimers() + try { + const journalRefusal = (reason: string) => ({ + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { refusal: { code: 'agent_session_journal_unreadable', details: { reason } } } + }) + const applyError = vi.fn() + const transport = startWithHydration(async () => undefined, applyError) + await flushPromises() + expect(attempts).toHaveLength(1) + + // An open that can clear keeps reconnecting. + attempts[0].onError(journalRefusal('journalUnavailable')) + attempts[0].closed.resolve({ unsubscribe: attempts[0].unsubscribe }) + await flushPromises() + expect(applyError).toHaveBeenLastCalledWith('agent_session_journal_unreadable', { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' } + }) + await vi.advanceTimersByTimeAsync(750) + expect(attempts).toHaveLength(2) + + // Damage no retry reads past: decided from the reason, not the message, which is the same. + attempts[1].onError(journalRefusal('journalCorrupt')) + attempts[1].closed.resolve({ unsubscribe: attempts[1].unsubscribe }) + await flushPromises() + expect(applyError).toHaveBeenLastCalledWith('agent_session_journal_unreadable', { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + }) + await vi.advanceTimersByTimeAsync(60_000) + expect(attempts).toHaveLength(2) + transport.dispose() + } finally { + vi.useRealTimers() + } + }) + + it('reads a thrown hydrate refusal the same way, and a new run reads again', async () => { + vi.useFakeTimers() + try { + const corrupt = Object.assign(new Error('agent_session_journal_unreadable'), { + response: { + error: { + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + } + } + } + } + }) + const hydrate = vi.fn(async () => { + throw corrupt + }) + const first = startWithHydration(hydrate, vi.fn()) + await vi.advanceTimersByTimeAsync(60_000) + expect(attempts).toHaveLength(0) + first.dispose() + + // Reopening the chat is a new run, which reads again. + const second = startWithHydration(hydrate, vi.fn()) + await flushPromises() + expect(hydrate).toHaveBeenCalledTimes(2) + second.dispose() + } finally { + vi.useRealTimers() + } + }) + it('re-opens after a failed open and leaves the error once the conversation reads (P2-04)', async () => { vi.useFakeTimers() try { @@ -356,4 +435,128 @@ describe('structured agent-session read transport unattached refusals', () => { vi.useRealTimers() } }) + + it('waits twice as long after each failed open, up to 30 s, and starts over once a read lands', async () => { + vi.useFakeTimers() + try { + const unavailable = { + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' } + } + } + } + const transport = startWithHydration(async () => undefined, vi.fn()) + await flushPromises() + // As a local subscribe does: the open resolves, then the host's refusal arrives. + const refuseLatest = async (): Promise => { + const attempt = attempts.at(-1)! + attempt.closed.resolve({ unsubscribe: attempt.unsubscribe }) + await flushPromises() + attempt.onError(unavailable) + } + const expectReopenAfter = async (delay: number): Promise => { + const opened = attempts.length + await vi.advanceTimersByTimeAsync(delay - 1) + expect(attempts).toHaveLength(opened) + await vi.advanceTimersByTimeAsync(1) + expect(attempts).toHaveLength(opened + 1) + } + + for (const delay of [750, 1_500, 3_000, 6_000, 12_000, 24_000, 30_000, 30_000]) { + await refuseLatest() + await expectReopenAfter(delay) + } + + // A read that delivers is the success the next failure starts over from. + attempts.at(-1)!.closed.resolve({ unsubscribe: attempts.at(-1)!.unsubscribe }) + await flushPromises() + attempts.at(-1)!.onEvent(snapshot(1)) + attempts.at(-1)!.onError(unavailable) + await expectReopenAfter(750) + await refuseLatest() + await expectReopenAfter(1_500) + + // Damage still ends reconnecting, however far the wait has grown. + const latest = attempts.at(-1)! + latest.closed.resolve({ unsubscribe: latest.unsubscribe }) + await flushPromises() + latest.onError({ + ...unavailable, + data: { + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + } + } + }) + const opened = attempts.length + await vi.advanceTimersByTimeAsync(120_000) + expect(attempts).toHaveLength(opened) + transport.dispose() + } finally { + vi.useRealTimers() + } + }) + + it('reads again as soon as a remote host is reachable, not after the grown wait', async () => { + vi.useFakeTimers() + try { + let hostContactRegained = (): void => {} + const stopWatch = vi.fn() + mocks.watchHostContact.mockImplementation((_environmentId, listener: () => void) => { + hostContactRegained = listener + return stopWatch + }) + const unavailable = { code: 'runtime_unavailable', message: 'Remote runtime is unavailable.' } + const transport = startStructuredAgentSessionReadTransport({ + applyEvent: vi.fn(), + applyError: vi.fn(), + getCursor: () => null, + onHistoryReadInvalidated: () => undefined, + hydrate: async () => undefined, + sessionId: 'session-a', + target: { kind: 'environment', environmentId: 'env-a' } + }) + expect(mocks.watchHostContact).toHaveBeenCalledWith('env-a', expect.any(Function)) + await flushPromises() + const refuseLatest = async (): Promise => { + const attempt = attempts.at(-1)! + attempt.closed.resolve({ unsubscribe: attempt.unsubscribe }) + await flushPromises() + attempt.onError(unavailable) + } + // An outage long enough that the wait reaches its cap. + for (const delay of [750, 1_500, 3_000, 6_000, 12_000, 24_000]) { + await refuseLatest() + await vi.advanceTimersByTimeAsync(delay) + } + await refuseLatest() + const opened = attempts.length + await vi.advanceTimersByTimeAsync(29_000) + expect(attempts).toHaveLength(opened) + + hostContactRegained() + await vi.advanceTimersByTimeAsync(0) + expect(attempts).toHaveLength(opened + 1) + + // The wait starts over too, so a failure right after reconnect is retried soon. + await refuseLatest() + await vi.advanceTimersByTimeAsync(750) + expect(attempts).toHaveLength(opened + 2) + + transport.dispose() + expect(stopWatch).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('does not watch host contact for a local read', () => { + startWithHydration(async () => undefined, vi.fn()).dispose() + expect(mocks.watchHostContact).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts index ed8423fd079..a0a693e92a9 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts @@ -3,8 +3,12 @@ import type { AgentSessionSubscribeEvent } from '../../../../shared/agent-sessio import { createStructuredAgentSessionEventCoalescer } from '../../../../shared/structured-agent-session-coalescer' import { AGENT_SESSION_UNATTACHED_READ_GRACE_MS, + isFinalAgentSessionReadRefusal, isUnattachedAgentSessionReadRefusal } from '../../../../shared/structured-agent-session-read-refusal' +import type { AgentSessionRefusalReference } from '../../../../shared/agent-session-wire-refusals' +import { readAgentSessionErrorRefusal } from '../../../../shared/agent-session-write-failure' +import { subscribeRuntimeHostContactRegained } from '@/runtime/runtime-host-contact-regained' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { subscribeStructuredAgentSession } from '@/runtime/structured-agent-session-client' @@ -27,13 +31,20 @@ function readFailureText(error: unknown): string { return 'Something went wrong.' } +const RECONNECT_FIRST_DELAY_MS = 750 +const RECONNECT_MAX_DELAY_MS = 30_000 + +/** Each reconnect waits twice the last, up to the cap, until a read delivers. */ function createReconnectScheduler(args: { shouldStop: () => boolean; reconnect: () => void }) { let timer: ReturnType | null = null + let nextDelay = RECONNECT_FIRST_DELAY_MS return { - schedule(delay = 750): void { + schedule(): void { if (args.shouldStop() || timer) { return } + const delay = nextDelay + nextDelay = Math.min(delay * 2, RECONNECT_MAX_DELAY_MS) timer = setTimeout(() => { timer = null if (!args.shouldStop()) { @@ -41,6 +52,22 @@ function createReconnectScheduler(args: { shouldStop: () => boolean; reconnect: } }, delay) }, + /** A read delivered, so the next failure is retried soon again. */ + reset(): void { + nextDelay = RECONNECT_FIRST_DELAY_MS + }, + /** The host is reachable again: a waiting retry runs now instead of after the grown wait. */ + retryNow(): void { + nextDelay = RECONNECT_FIRST_DELAY_MS + if (!timer) { + return + } + clearTimeout(timer) + timer = null + if (!args.shouldStop()) { + args.reconnect() + } + }, dispose(): void { if (timer) { clearTimeout(timer) @@ -52,7 +79,8 @@ function createReconnectScheduler(args: { shouldStop: () => boolean; reconnect: export function startStructuredAgentSessionReadTransport(args: { applyEvent: (event: AgentSessionSubscribeEvent) => void - applyError: (message: string) => void + /** `message` is the failure's own text, for logs; `refusal` is what a surface words. */ + applyError: (message: string, refusal?: AgentSessionRefusalReference) => void getCursor: () => AgentJournalCursor | null onHistoryReadInvalidated: () => void hydrate?: (shouldStop: () => boolean) => Promise @@ -64,6 +92,8 @@ export function startStructuredAgentSessionReadTransport(args: { } { let stopped = false let connected = false + // A refusal no retry reads past ends reconnecting for this run; reopening the chat starts another. + let failedFinally = false let unattachedSince: number | null = null let opening = false let openGeneration = 0 @@ -76,9 +106,16 @@ export function startStructuredAgentSessionReadTransport(args: { } }) const reconnectScheduler = createReconnectScheduler({ - shouldStop: () => stopped || connected, + shouldStop: () => stopped || connected || failedFinally, reconnect: () => void open() }) + // Why: an outage grows the retry wait to its cap; a host that is back should not wait it out. + const stopHostContactWatch = + args.target.kind === 'environment' + ? subscribeRuntimeHostContactRegained(args.target.environmentId, () => + reconnectScheduler.retryNow() + ) + : () => {} const isCurrentOpenGeneration = (candidate: number): boolean => !stopped && candidate === openGeneration const clearUnattachedReadGrace = (): void => { @@ -95,16 +132,25 @@ export function startStructuredAgentSessionReadTransport(args: { * A window, not a mute. An unattached read still refusing past the grace is no longer * transitional, so the pane is owed the failure rather than a spinner that never resolves. */ + const applyReadFailure = (error: unknown): void => { + const refusal = readAgentSessionErrorRefusal(error) + failedFinally = isFinalAgentSessionReadRefusal(refusal) + if (refusal) { + args.applyError(readFailureText(error), refusal) + } else { + args.applyError(readFailureText(error)) + } + } const reportReadFailure = (error: unknown): void => { if (!isUnattachedAgentSessionReadRefusal(error)) { clearUnattachedReadGrace() - args.applyError(readFailureText(error)) + applyReadFailure(error) return } const now = Date.now() unattachedSince ??= now if (now - unattachedSince >= AGENT_SESSION_UNATTACHED_READ_GRACE_MS) { - args.applyError(readFailureText(error)) + applyReadFailure(error) } } const captureHistoryReadGuard = (): (() => boolean) => { @@ -118,6 +164,10 @@ export function startStructuredAgentSessionReadTransport(args: { return } clearUnattachedReadGrace() + // Not on connect: a local subscribe resolves before the host's open refuses. + if (event.type !== 'end') { + reconnectScheduler.reset() + } if (event.type === 'snapshot' || event.type === 'reset') { coalescer.flush() if (!isCurrentOpenGeneration(eventOpenGeneration)) { @@ -229,6 +279,7 @@ export function startStructuredAgentSessionReadTransport(args: { stopped = true openGeneration += 1 args.onHistoryReadInvalidated() + stopHostContactWatch() reconnectScheduler.dispose() coalescer.dispose() unsubscribe() diff --git a/src/renderer/src/components/native-chat/use-native-chat-launch-prompt-delivery-notice.ts b/src/renderer/src/components/native-chat/use-native-chat-launch-prompt-delivery-notice.ts new file mode 100644 index 00000000000..c3d7974c160 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-launch-prompt-delivery-notice.ts @@ -0,0 +1,22 @@ +import { useMemo } from 'react' +import { translate } from '@/i18n/i18n' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' + +/** The terminal-backed chat's one delivery notice: its launch prompt, when the terminal refused it + * and the prompt's row is on screen. The terminal is where to act, so it offers no Retry. */ +export function useNativeChatLaunchPromptDeliveryNotice( + failedMessageId: string | null | undefined, + messages: readonly NativeChatMessage[] +): ReadonlyMap | undefined { + return useMemo(() => { + if (!failedMessageId || !messages.some((message) => message.id === failedMessageId)) { + return undefined + } + const text = translate( + 'components.native-chat.launchPromptNotDelivered', + 'Not delivered — check the terminal' + ) + return new Map([[failedMessageId, { text }]]) + }, [failedMessageId, messages]) +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts index 0661eb0df88..12528859d71 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts @@ -3,7 +3,7 @@ import { getStructuredAgentSessionLaunchLifecycle, getStructuredAgentSessionLaunchResumes, retryStructuredAgentSessionLaunch, - useStructuredAgentSessionLaunchFailureReason, + useStructuredAgentSessionLaunchFailure, useStructuredAgentSessionLaunchLifecycle, useStructuredAgentSessionLaunchSelection } from '@/lib/structured-agent-session-launch' @@ -66,7 +66,7 @@ export function useNativeChatProvisionalLaunch( sessionId: string ) { const lifecycle = useStructuredAgentSessionLaunchLifecycle(worktreeId ?? '', sessionId) - const failureReason = useStructuredAgentSessionLaunchFailureReason(worktreeId ?? '', sessionId) + const failure = useStructuredAgentSessionLaunchFailure(worktreeId ?? '', sessionId) const launch = useLatchedLaunchView(sessionId, worktreeId, lifecycle !== null) const retry = useCallback(() => { if (worktreeId) { @@ -91,7 +91,7 @@ export function useNativeChatProvisionalLaunch( return { lifecycle, launch, - failureReason, + failure, retry, sendThroughRelaunch, transportEnabled: lifecycle === null || lifecycle === 'published' diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts index 328d76d0534..40979780b36 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts @@ -12,7 +12,7 @@ import * as conversationCommands from './structured-conversation-command-send' import type { AgentSessionMutationResult } from '../../../../shared/agent-session-wire' import { agentSessionRefusalFailure, - agentSessionRpcErrorFailure, + agentSessionThrownFailure, agentSessionWriteKindForMethod as writeKind } from '../../../../shared/agent-session-write-failure' import { agentSessionRefusalOperationState } from '../../../../shared/agent-session-refusal-retry' @@ -98,7 +98,8 @@ export function useStructuredAgentSessionMutate(args: { ? { kind: 'not-done', notice: agentSessionWriteFailureText( - agentSessionRpcErrorFailure( + agentSessionThrownFailure( + error, error instanceof RuntimeRpcCallError ? error.code : undefined ), writeKind(fingerprintMethod, fields) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx index 0fff127cd2c..feb9f898ce2 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx @@ -21,6 +21,8 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' import { enqueueStructuredAgentSessionLaunchPrompt } from './structured-agent-session-outbox-storage' import { settleStructuredAgentLaunchPrompt } from '@/lib/structured-agent-session-launch-prompt' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' const LOCAL_TARGET = { kind: 'local' } as const @@ -32,12 +34,19 @@ function deferred() { return { promise, resolve } } -type SendRequest = { body?: { blocks?: { text?: string }[] } } +type SendRequest = { + body?: { blocks?: { text?: string }[] } + envelope?: { clientOperationId?: string } +} function requestText(params: SendRequest | undefined): string | undefined { return params?.body?.blocks?.[0]?.text } +function requestId(params: SendRequest | undefined): string { + return String(params?.envelope?.clientOperationId) +} + function sentTexts(): (string | undefined)[] { return mocks.call.mock.calls.map((call) => requestText(call[2])) } @@ -219,4 +228,94 @@ describe('structured agent session outbox admission', () => { await act(async () => admission.resolve(submissionResult(String(id), 'pending', 10))) expect(sentTexts()).toEqual(['first', 'second']) }) + + // A message the queue is held on goes out only by its own Retry, never another message's. + it("keeps a refused message held when a rejected message's Retry shows while a head waits", async () => { + const inFlight = deferred>() + mocks.call.mockImplementation((_target, _method, params) => { + const text = requestText(params) + const attempt = sentTexts().filter((sent) => sent === text).length + const id = requestId(params) + if (text === 'held') { + return Promise.resolve(refusedResult('agent_session_ownership_unknown')) + } + if (attempt === 1) { + return Promise.resolve(refusedResult('agent_session_owner_restart_failed')) + } + return text === 'x' ? inFlight.promise : Promise.resolve(submissionResult(id, 'accepted', 20)) + }) + const { result } = renderOutbox() + for (const text of ['x', 'a', 'c']) { + act(() => expect(result.current.send(text)).toBe(true)) + await waitFor(() => expect(result.current.outbox.at(-1)?.state).toBe('rejected')) + } + act(() => expect(result.current.send('held')).toBe(true)) + await waitFor(() => expect(result.current.blockedClientMessageId).not.toBeNull()) + const heldId = result.current.blockedClientMessageId + const [x, a, c] = result.current.outbox + + // x goes out and stays in flight, so a, retried after it, waits queued ahead of the held one. + act(() => result.current.retry(x!.clientMessageId)) + await waitFor(() => expect(sentTexts()).toEqual(['x', 'a', 'c', 'held', 'x'])) + act(() => result.current.retry(a!.clientMessageId)) + await settleTimers(20) + // The drain would send a next, so the queue reads as moving and c offers its own Retry. + const notices = structuredAgentSessionDeliveryNotices( + result.current.outbox, + result.current.blockedClientMessageId, + 'Claude', + () => {}, + [], + [] + ) + expect(notices.get(agentJournalSubmissionKey(c!.clientMessageId))?.onRetry).toBeDefined() + + act(() => result.current.retry(c!.clientMessageId)) + expect(result.current.blockedClientMessageId).toBe(heldId) + await act(async () => inFlight.resolve(submissionResult(x!.clientMessageId, 'accepted', 20))) + await waitFor(() => expect(result.current.outbox).toHaveLength(1)) + await settleTimers(50) + expect(sentTexts().filter((text) => text === 'held')).toHaveLength(1) + expect(result.current.blockedClientMessageId).toBe(heldId) + }) + + it('keeps a refused message held when an unconfirmed message ahead of it is retried', async () => { + const none: readonly AgentJournalSubmission[] = [] + mocks.call.mockImplementation((_target, _method, params) => { + const id = requestId(params) + if (requestText(params) === 'held') { + return Promise.resolve(refusedResult('agent_session_ownership_unknown')) + } + const sent = sentTexts().filter((text) => text === 'first').length + return Promise.resolve(submissionResult(id, sent === 1 ? 'pending' : 'accepted', 10)) + }) + const { result, rerender } = renderHook( + ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions + }), + { initialProps: { submissions: none } } + ) + act(() => expect(result.current.send('first')).toBe(true)) + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('dispatching')) + act(() => expect(result.current.send('held')).toBe(true)) + await waitFor(() => expect(result.current.blockedClientMessageId).not.toBeNull()) + const heldId = result.current.blockedClientMessageId + const firstId = String(result.current.outbox[0]?.clientMessageId) + const unknown: AgentJournalSubmission = { + ...submissionResult(firstId, 'pending', 10).value.submission, + dispatchState: 'unknown' + } + rerender({ submissions: [unknown] }) + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('unconfirmed')) + + act(() => result.current.retry(firstId)) + await waitFor(() => expect(result.current.outbox).toHaveLength(1)) + await settleTimers(50) + expect(sentTexts()).toEqual(['first', 'held', 'first']) + expect(result.current.blockedClientMessageId).toBe(heldId) + }) }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx index a8b9f01b20b..3ce99e6fd4b 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx @@ -17,7 +17,11 @@ import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-cap import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' import { agentSessionWriteNoticeEnglish } from '../../../../shared/agent-session-refusal-notice' import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition' -import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { + createStructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' +import { writeOutbox } from './structured-agent-session-outbox-storage' function shownFailure(entry: StructuredAgentSessionOutboxEntry | undefined): string | undefined { return ( @@ -269,6 +273,47 @@ describe('a send the host rejected because the agent never started', () => { await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) }) + // After a restart nothing in memory remembers the rejection, and its journal row may be older + // than the loaded page: the message's own state is what says a resend needs a new id. + it('retries a message rejected before a restart under a new id', async () => { + const rejected = createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'rejected-before-restart', + sessionId: 'session-1', + text: 'first', + attachments: [], + queuedAt: 1 + }) + writeOutbox('session-1', [ + { + ...rejected, + state: 'rejected', + lastFailure: { + kind: 'rejected', + reason: 'The provider did not accept this message.', + rejection: { kind: 'providerRejected' } + } + } + ]) + mocks.call.mockImplementation(async (_target, _method, params) => + acceptedResultFor(String(params.envelope.clientOperationId)) + ) + const { result } = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: { kind: 'local' }, + fence: 1, + submissions: [] + }) + ) + expect(result.current.outbox[0]?.state).toBe('rejected') + + act(() => result.current.retry('rejected-before-restart')) + await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + const sentId: unknown = mocks.call.mock.calls[0]![2].envelope.clientOperationId + expect(sentId).not.toBe('rejected-before-restart') + await waitFor(() => expect(result.current.outbox).toHaveLength(0)) + }) + it('keeps the rejection when the journal settles the message before the send answers', async () => { const reason = "Codex couldn't restart: spawn codex ENOENT." let answer: (value: unknown) => void = () => undefined diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts index 254c936cf63..ae2a1ae0a61 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts @@ -52,7 +52,6 @@ export function useStructuredAgentSessionOutbox(args: { const inFlightIdRef = useRef(null) const dispatchGenerationRef = useRef(0) const blockedIdRef = useRef(null) - const retryWithFreshClientMessageIdRef = useRef(null) const probeAttemptsRef = useRef({ id: null as string | null, attempts: 0 }) const [error, setError] = useState(null) const [errorSession, setErrorSession] = useState(sessionId) @@ -71,7 +70,6 @@ export function useStructuredAgentSessionOutbox(args: { dispatchGenerationRef.current += 1 inFlightIdRef.current = null blockedIdRef.current = null - retryWithFreshClientMessageIdRef.current = null probeAttemptsRef.current = { id: null, attempts: 0 } }, [owner.ownerChange, owner.targetKey, sessionId]) @@ -139,7 +137,6 @@ export function useStructuredAgentSessionOutbox(args: { // drain, so a later microtask would leave the queue with no trigger to move on. inFlightIdRef.current = null blockedIdRef.current = disposition.blockedClientMessageId - retryWithFreshClientMessageIdRef.current = disposition.retryWithFreshClientMessageId setError(disposition.error) outboxRef.current = disposition.entries setOutbox(disposition.entries) @@ -286,21 +283,22 @@ export function useStructuredAgentSessionOutbox(args: { ) const retry = (clientMessageId: string): void => { - blockedIdRef.current = null + // Another message's Retry must not send the one the queue is held on. + if (blockedIdRef.current === clientMessageId) { + blockedIdRef.current = null + } setError(null) const submission = submissions.find( (candidate) => candidate.clientMessageId === clientMessageId ) const current = outboxRef.current.find((entry) => entry.clientMessageId === clientMessageId) - // A provider-history reconciliation can settle an earlier unknown as - // rejected before the user presses Retry. Reusing that operation id only - // replays the settled rejection forever, so rotate the id for a safe resend. - if ( - current && - (submission?.dispatchState === 'rejected' || - retryWithFreshClientMessageIdRef.current === clientMessageId) - ) { - retryWithFreshClientMessageIdRef.current = null + // The host settled this id as rejected, and reusing it only replays that forever, so rotate the + // id for a safe resend. Read from the message itself, which outlives a restart, or from a + // reconciliation that settled an earlier unknown before the outbox caught up. A refusal that + // settled the message already rotated it. + const recordedRejection = + current?.state === 'rejected' && current.lastFailure?.kind === 'rejected' + if (current && (recordedRejection || submission?.dispatchState === 'rejected')) { const rotated = outboxRef.current.map((entry) => entry.clientMessageId === clientMessageId ? { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts new file mode 100644 index 00000000000..97ef023dffb --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts @@ -0,0 +1,42 @@ +// @vitest-environment happy-dom + +import { renderHook } from '@testing-library/react' +import { expect, it } from 'vitest' +import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' +import { agentJournalItemKey } from '../../../../shared/agent-session-journal-item-key' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { structuredAgentSessionStartFailureRowIdentity } from '../../../../shared/structured-agent-session-start-failure-row-key' +import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts' + +function startFailureRow(startKey: string, fact: AgentSessionFailureFact): AgentJournalRenderItem { + return { + itemId: agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity(startKey)), + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'status', + tone: 'error', + ...agentSessionFailureWords(fact, { agentName: 'Claude', surface: 'row' }) + } + } +} + +// A streaming turn hands over a new item list on every delta; the facts must not follow it. +it('holds the same facts while the start rows state nothing new', () => { + const row = startFailureRow('gen-1', { kind: 'providerStartFailed' }) + const { result, rerender } = renderHook( + ({ items }) => useStructuredAgentSessionStartFailureFacts(items, true), + { initialProps: { items: [row] } } + ) + const first = result.current + expect(first).toEqual([{ kind: 'providerStartFailed' }]) + + rerender({ items: [row, startFailureRow('other-start', { kind: 'providerStartFailed' })] }) + expect(result.current).not.toBe(first) + const second = result.current + + rerender({ items: [row, startFailureRow('other-start', { kind: 'providerStartFailed' })] }) + expect(result.current).toBe(second) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts new file mode 100644 index 00000000000..83f20103de1 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts @@ -0,0 +1,33 @@ +import { useEffect, useMemo, useRef } from 'react' +import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { + sameAgentSessionFailureFact, + structuredAgentSessionStartFailureFacts +} from './structured-agent-session-delivery-notices' + +const NO_FACTS: readonly AgentSessionFailureFact[] = [] + +/** What the loaded start-failure rows state, read only while `enabled`. Held while unchanged, so a + * streaming turn does not rebuild every row's delivery notice. */ +export function useStructuredAgentSessionStartFailureFacts( + items: readonly AgentJournalRenderItem[], + enabled: boolean +): readonly AgentSessionFailureFact[] { + const facts = useMemo( + () => (enabled ? structuredAgentSessionStartFailureFacts(items) : NO_FACTS), + [enabled, items] + ) + const previousRef = useRef(NO_FACTS) + const previous = previousRef.current + const stable = + previous.length === facts.length && + previous.every((fact, index) => sameAgentSessionFailureFact(fact, facts[index])) + ? previous + : facts + // Written after commit, so render stays pure. + useEffect(() => { + previousRef.current = stable + }, [stable]) + return stable +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx index 0336e81401a..8cc467a3d28 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx @@ -115,6 +115,46 @@ describe('a chat write the host refused', () => { ) }) + it('words a refusal the host threw from its data, never the bare code it carries as its message', async () => { + mocks.call.mockImplementation((_target, method) => + method === 'agentSession.options' + ? Promise.resolve(OPTIONS) + : Promise.reject( + new RuntimeRpcCallError({ + id: 'request-1', + ok: false, + error: { + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' } + } + } + }, + _meta: { runtimeId: 'runtime-1' } + }) + ) + ) + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'claude', + isVisible: true + }) + ) + + await act(async () => { + await expect(result.current.cancel('turn-1')).resolves.toBeNull() + }) + + expect(mocks.toastError).toHaveBeenCalledExactlyOnceWith( + "Orca couldn't open this chat's history right now. The agent wasn't stopped. Try again." + ) + }) + it('answers a refused conversation command inline, where the command was typed', async () => { mocks.call.mockImplementation((_target, method) => method === 'agentSession.options' diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index e45699e830c..659bbafbbd9 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -136,7 +136,10 @@ export function useStructuredAgentSession(args: { journalItems: transportState.journalItems, messages, status: transportEnabled ? state.status : 'ready', - error: transportEnabled ? (state.error ?? outboxController.error) : outboxController.error, + /** The outbox's own line; a failed read is worded from `readRefusal`, never its text. */ + error: outboxController.error, + /** The refusal the failed read met, while `status` is `error`. */ + readRefusal: transportEnabled ? state.readRefusal : undefined, hasOlder: transportEnabled && state.hasOlder, railOutline: transportEnabled ? railOutline : null, loadingOlder: transportEnabled && loadingOlder, @@ -144,6 +147,8 @@ export function useStructuredAgentSession(args: { loadOlder, prompts, outbox, + /** The journal's rows for sent messages, which carry a rejected message's whole fact. */ + submissions: transportState.submissions, blockedClientMessageId: outboxController.blockedClientMessageId, send: (...input: Parameters) => !commandPending.current && outboxController.send(...input), diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 1e6ca9ecd65..57703fb7d1f 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2687,7 +2687,8 @@ "notAgent": { "subtitle": "This terminal is not running a recognized coding agent.", "title": "No conversation here" - } + }, + "reconnecting": "Reconnecting to this chat…" }, "status": { "responding": "Agent is responding", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 033e0ecbb23..b7aac035aef 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17765,6 +17765,7 @@ "subtitle": "The transcript could not be read. Toggle back to the terminal to keep working.", "retryingSubtitle": "The transcript could not be read. Orca keeps trying to load it." }, + "reconnecting": "Reconnecting to this chat…", "pairHost": "Pair a host to view agent chat history.", "notAgent": { "title": "No conversation here", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 5ce31869994..835eb4ab440 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15058,6 +15058,7 @@ "subtitle": "No se pudo leer la transcripción. Vuelva a la terminal para seguir trabajando.", "retryingSubtitle": "No se pudo leer la transcripción. Orca sigue intentando cargarla." }, + "reconnecting": "Reconectando con este chat…", "pairHost": "Empareje un host para ver el historial de chat del agente.", "notAgent": { "title": "No hay conversación aquí", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index b8b96da9322..d2a04423ace 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17620,6 +17620,7 @@ "subtitle": "La transcription n'a pas pu être lue. Rebasculez sur le terminal pour continuer à travailler.", "retryingSubtitle": "La transcription n'a pas pu être lue. Orca continue d'essayer de la charger." }, + "reconnecting": "Reconnexion à ce chat…", "pairHost": "Associez un hôte pour consulter l'historique de chat des agents.", "notAgent": { "title": "Aucune conversation ici", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c7041073d55..30bbb81cab1 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17556,6 +17556,7 @@ "subtitle": "トランスクリプトを読み取ることができませんでした。作業を続けるには、ターミナルに切り替えてください。", "retryingSubtitle": "トランスクリプトを読み取ることができませんでした。Orca は読み込みを再試行し続けます。" }, + "reconnecting": "このチャットに再接続しています…", "pairHost": "ホストをペアリングして、Agent のチャット履歴を表示します。", "notAgent": { "title": "ここでは会話はありません", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index dfb560ec1ec..2c2318895a6 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17556,6 +17556,7 @@ "subtitle": "스크립트를 읽을 수 없습니다. 계속 작업하려면 terminal로 다시 전환하세요.", "retryingSubtitle": "스크립트를 읽을 수 없습니다. Orca가 계속 다시 불러오기를 시도합니다." }, + "reconnecting": "이 채팅에 다시 연결하는 중…", "pairHost": "호스트를 페어링하여 에이전트 채팅 기록을 확인하세요.", "notAgent": { "title": "여기에는 대화가 없습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 6433b62a232..3c0f5d7aa3d 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17521,6 +17521,7 @@ "subtitle": "无法读取记录。请切回终端继续工作。", "retryingSubtitle": "无法读取记录。Orca 会继续尝试加载。" }, + "reconnecting": "正在重新连接此聊天…", "pairHost": "配对主机以查看智能体聊天记录。", "notAgent": { "title": "这里没有对话", diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts index 759f5c7d614..e1594d94c97 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -12,6 +12,11 @@ import { import { resolveStructuredLaunchSeedOptions } from '../../../shared/native-chat-session-option-defaults' import { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code' import { isDefinitiveAgentSessionCreateRefusal } from '../../../shared/agent-session-definitive-refusal' +import { + readAgentSessionRefusalReference, + type AgentSessionRefusalReference +} from '../../../shared/agent-session-wire-refusals' +import { readAgentSessionErrorRefusal } from '../../../shared/agent-session-write-failure' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { useAppStore } from '@/store' @@ -47,7 +52,9 @@ class StructuredAgentSessionCreateError extends Error { constructor( message: string, /** The wire refusal code, or the RPC error code when the create never reached a handler. */ - readonly code: string + readonly code: string, + /** The host's refusal as a reader may word it; absent from an older host or a local failure. */ + readonly refusal?: AgentSessionRefusalReference ) { super(message) } @@ -58,8 +65,12 @@ class StructuredAgentSessionCreateError extends Error { * `launchStructuredAgentSession` is the only place that decides it against the shared allowlist. */ export class StructuredAgentSessionCreateRefusalError extends StructuredAgentSessionCreateError { - constructor(message: string, code: string = 'structured_agent_session_unsupported') { - super(message, code) + constructor( + message: string, + code: string = 'structured_agent_session_unsupported', + refusal?: AgentSessionRefusalReference + ) { + super(message, code, refusal) this.name = 'StructuredAgentSessionCreateRefusalError' } } @@ -70,8 +81,8 @@ export class StructuredAgentSessionCreateRefusalError extends StructuredAgentSes * down the same path as a lost reply, which replays the intent and reconciles. */ export class StructuredAgentSessionCreateUnknownOutcomeError extends StructuredAgentSessionCreateError { - constructor(message: string, code: string) { - super(message, code) + constructor(message: string, code: string, refusal?: AgentSessionRefusalReference) { + super(message, code, refusal) this.name = 'StructuredAgentSessionCreateUnknownOutcomeError' } } @@ -252,7 +263,8 @@ async function hostSupportsCreate(intent: StructuredAgentSessionLaunchIntent): P } throw new StructuredAgentSessionCreateUnknownOutcomeError( error instanceof Error ? error.message : String(error), - code + code, + readAgentSessionErrorRefusal(error) ) } } @@ -291,20 +303,22 @@ export async function launchStructuredAgentSession( abandonStructuredAgentSessionLaunchIntent(intent) throw new StructuredAgentSessionCreateRefusalError( error instanceof Error ? error.message : String(error), - code + code, + readAgentSessionErrorRefusal(error) ) } throw error } if (!result.ok) { const { code, message, ownerVerdict } = result.refusal + const refusal = readAgentSessionRefusalReference(result.refusal) // A failed operation whose provider is proven gone is a failure a new operation may retry. if (!isDefinitiveAgentSessionCreateRefusal(code) && ownerVerdict !== 'exited') { // Keep the focus intent: the session may exist, and recovery still has to adopt it. - throw new StructuredAgentSessionCreateUnknownOutcomeError(message, code) + throw new StructuredAgentSessionCreateUnknownOutcomeError(message, code, refusal) } abandonStructuredAgentSessionLaunchIntent(intent) - throw new StructuredAgentSessionCreateRefusalError(message, code) + throw new StructuredAgentSessionCreateRefusalError(message, code, refusal) } return { sessionId: result.value.sessionId, fence: result.value.fence } } diff --git a/src/renderer/src/lib/structured-agent-session-launch-exited-owner.test.ts b/src/renderer/src/lib/structured-agent-session-launch-exited-owner.test.ts index 1662e4c0430..10ebbc8fd89 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-exited-owner.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-exited-owner.test.ts @@ -1,6 +1,6 @@ // @vitest-environment happy-dom // The launch client against the create wire contract: a failed create whose provider is proven -// gone reads as failed (with its reason) and Retry starts it again; any other verdict stays unknown. +// gone reads as failed (with its refusal) and Retry starts it again; any other verdict stays unknown. import { beforeEach, describe, expect, it, vi } from 'vitest' import type { @@ -49,13 +49,14 @@ import { startStructuredAgentLaunch } from './structured-agent-session-launch' import { - getStructuredAgentSessionLaunchFailureReason, + getStructuredAgentSessionLaunchFailure, resetStructuredAgentLaunchRegistryForTests } from './structured-agent-session-launch-registry' import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence' const WORKTREE = 'wt-1' const EXIT_REASON = 'claude stream-json exited (code 1): stderr tail' +const HOST_REFUSAL = { kind: 'refused', code: 'agent_session_operation_invalid' } /** The verdict the host puts on the failed operation's replay; undefined models an older host. */ let replayVerdict: AgentSessionOwnerVerdict | undefined @@ -125,12 +126,13 @@ beforeEach(() => { }) describe('structured launch after a host-failed create', () => { - it('fails with the host reason when the provider is proven gone, and Retry starts it under a new operation', async () => { + it('fails with the host refusal when the provider is proven gone, and Retry starts it under a new operation', async () => { const sessionId = launch() await settle(sessionId) expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE, sessionId)).toBe('failed') - expect(getStructuredAgentSessionLaunchFailureReason(WORKTREE, sessionId)).toBe(EXIT_REASON) + // The host's message is for its log; the Retry line words the refusal. + expect(getStructuredAgentSessionLaunchFailure(WORKTREE, sessionId)).toEqual(HOST_REFUSAL) expect(createdOperations.every((operation) => operation === failedOperation)).toBe(true) expect(retryStructuredAgentSessionLaunch(WORKTREE, sessionId)).toBe(true) @@ -147,7 +149,8 @@ describe('structured launch after a host-failed create', () => { const sessionId = launch() await settle(sessionId) expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE, sessionId)).toBe('failed') - expect(getStructuredAgentSessionLaunchFailureReason(WORKTREE, sessionId)).toBe(EXIT_REASON) + // The host's message is for its log; the Retry line words the refusal. + expect(getStructuredAgentSessionLaunchFailure(WORKTREE, sessionId)).toEqual(HOST_REFUSAL) expect(createdOperations).toEqual([failedOperation]) expect(retryStructuredAgentSessionLaunch(WORKTREE, sessionId)).toBe(true) await settle(sessionId) diff --git a/src/renderer/src/lib/structured-agent-session-launch-failure.ts b/src/renderer/src/lib/structured-agent-session-launch-failure.ts new file mode 100644 index 00000000000..b9ad791a0c4 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-failure.ts @@ -0,0 +1,18 @@ +// What a failed chat start's Retry line may say: the host's refusal, never its message. + +import { readAgentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' +import { + agentSessionRefusalFailure, + readAgentSessionErrorRefusal, + type AgentSessionWriteRefusal +} from '../../../shared/agent-session-write-failure' + +/** The host's refusal behind a failed launch; undefined when the failure carried none (a lost + * reply, a local fault, an older host). Reads a create error's own refusal or an RPC error's. */ +export function structuredLaunchFailure(error: unknown): AgentSessionWriteRefusal | undefined { + const refusal = + error instanceof Error && 'refusal' in error + ? readAgentSessionRefusalReference(error.refusal) + : readAgentSessionErrorRefusal(error) + return refusal ? agentSessionRefusalFailure(refusal) : undefined +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.test.ts b/src/renderer/src/lib/structured-agent-session-launch-options.test.ts index 8fa72308538..3787ebe3608 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts' import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session' +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' type CallParams = { envelope?: { expectedRuntimeFence?: number | null } @@ -264,6 +265,41 @@ describe('picks made while a chat launches', () => { ]) }) + it('reports a refusal the host threw as its fact, never the bare code it carries as a message', async () => { + mocks.launch.mockResolvedValue({ sessionId: SESSION_ID, fence: 1 }) + startStructuredAgentLaunch(WORKTREE_ID, 'codex') + const pick = holdStructuredAgentSessionLaunchOption(SESSION_ID, 'model', 'gpt-missing') + await settle() + setOptionReplies[0]!.resolve( + Promise.reject( + new RuntimeRpcCallError({ + id: 'request-1', + ok: false, + error: { + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' } + } + } + }, + _meta: { runtimeId: 'runtime-1' } + }) + ) + ) + + await expect(pick).resolves.toEqual({ + kind: 'refused', + failure: { + kind: 'refused', + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' } + } + }) + }) + it('keeps picks held through a failed start and applies them to the retry', async () => { mocks.launch .mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported')) diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.ts b/src/renderer/src/lib/structured-agent-session-launch-options.ts index 091e7543f88..b087264ce25 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.ts @@ -23,7 +23,7 @@ import { } from './structured-agent-session-launch-registry' import { agentSessionRefusalFailure, - agentSessionRpcErrorFailure, + agentSessionThrownFailure, type AgentSessionWriteFailure } from '../../../shared/agent-session-write-failure' @@ -110,7 +110,8 @@ async function setLaunchOption( } catch (error) { return { kind: 'refused', - failure: agentSessionRpcErrorFailure( + failure: agentSessionThrownFailure( + error, error instanceof RuntimeRpcCallError ? error.code : undefined ) } diff --git a/src/renderer/src/lib/structured-agent-session-launch-registry.ts b/src/renderer/src/lib/structured-agent-session-launch-registry.ts index dcafea65803..2f444446e03 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-registry.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-registry.ts @@ -1,5 +1,6 @@ import { useSyncExternalStore } from 'react' import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { AgentSessionWriteRefusal } from '../../../shared/agent-session-write-failure' import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' import type { StructuredLaunchRecoveryState } from './structured-agent-session-launch-recovery' import type { StructuredLaunchSelection } from './structured-agent-session-launch-options' @@ -26,8 +27,9 @@ export type StructuredLaunchState = StructuredLaunchRecoveryState & { /** Fixed by the caller that opened this launch so coalesced prompts use one delivery mode. */ promptDelivery: StructuredAgentLaunchOptions['promptDelivery'] callers: StructuredLaunchCallerGroup - /** Why the last attempt failed, shown beside Retry; the toast stays generic. */ - failureReason?: string + /** The host's refusal behind the last failed attempt, worded beside Retry; the toast stays + * generic. Absent when the failure named none. */ + failure?: AgentSessionWriteRefusal selection: StructuredLaunchSelection } @@ -176,25 +178,25 @@ export function getStructuredAgentSessionLaunchResumes(sessionId: string): boole return resumeFrom !== undefined } -export function getStructuredAgentSessionLaunchFailureReason( +export function getStructuredAgentSessionLaunchFailure( worktreeId: string, sessionId: string -): string | null { +): AgentSessionWriteRefusal | null { const state = getStructuredLaunchStateBySessionId(sessionId) return state && matchesLaunchWorktree(state, worktreeId) && launchStateLifecycle(state) === 'failed' - ? (state.failureReason ?? null) + ? (state.failure ?? null) : null } -export function useStructuredAgentSessionLaunchFailureReason( +export function useStructuredAgentSessionLaunchFailure( worktreeId: string, sessionId: string -): string | null { +): AgentSessionWriteRefusal | null { return useSyncExternalStore( subscribeStructuredAgentLaunchStatus, - () => getStructuredAgentSessionLaunchFailureReason(worktreeId, sessionId), + () => getStructuredAgentSessionLaunchFailure(worktreeId, sessionId), () => null ) } diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index 5ad75c36ed6..4c62128813b 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -27,6 +27,7 @@ import { } from '@/lib/structured-agent-session-launch-callers' import * as launchDraft from './structured-agent-session-launch-draft' import { trackStructuredLaunchFailureToast } from './structured-agent-session-launch-failure-toast' +import { structuredLaunchFailure } from './structured-agent-session-launch-failure' import { deleteStructuredLaunchStateIfCurrent, getStructuredLaunchState, @@ -51,7 +52,7 @@ export { retireStructuredAgentSessionLaunchCancellationTombstone, shouldRetainStructuredAgentSessionLaunchTab, subscribeStructuredAgentLaunchStatus, - useStructuredAgentSessionLaunchFailureReason, + useStructuredAgentSessionLaunchFailure, useStructuredAgentSessionLaunchLifecycle, type StructuredAgentLaunchStatus, type StructuredAgentSessionLaunchLifecycle @@ -138,7 +139,13 @@ function trackLaunchSettlement( } return } - state.failureReason = error instanceof Error ? error.message : String(error) + // The host's message is for its log; the Retry line words the refusal itself. + const failure = structuredLaunchFailure(error) + if (failure) { + state.failure = failure + } else { + delete state.failure + } if (error instanceof StructuredAgentSessionCreateRefusalError) { settleStructuredLaunchRefusal(state) } else if (!state.visibilityUnknown) { @@ -171,7 +178,7 @@ function restartStructuredLaunchState(state: StructuredLaunchState): void { state.intent = retryStructuredAgentSessionLaunchIntent(state.intent) } resetStructuredLaunchCallers(state) - delete state.failureReason + delete state.failure state.callers.outcome = 'pending' // A new create seeds from the settings of now; picks held through the failure still apply. state.selection = { ...state.selection, seed: state.intent.seedOptions } diff --git a/src/renderer/src/runtime/runtime-host-contact-regained.test.ts b/src/renderer/src/runtime/runtime-host-contact-regained.test.ts new file mode 100644 index 00000000000..5daabf37790 --- /dev/null +++ b/src/renderer/src/runtime/runtime-host-contact-regained.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeStatus } from '../../../shared/runtime-types' + +vi.mock('sonner', () => ({ + toast: { warning: vi.fn(), dismiss: vi.fn() } +})) + +vi.mock('@/store', async () => { + const { create } = await import('zustand') + const { createRuntimeStatusSlice } = await import('@/store/slices/runtime-status') + type Slice = ReturnType + return { + useAppStore: create()((...a) => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the slice reads only its own fields. + createRuntimeStatusSlice(...(a as unknown as Parameters)) + ) + } +}) + +import { useAppStore } from '@/store' +import { clearRuntimeEnvironmentConnectionGenerationsForTests } from '@/store/slices/runtime-status' +import { subscribeRuntimeHostContactRegained } from './runtime-host-contact-regained' + +function makeStatus(runtimeId: string): RuntimeStatus { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fixture carries only the fields the slice reads. + return { + runtimeId, + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 3 + } as RuntimeStatus +} + +let lastVerified: RuntimeStatus | null = null + +// As the host-status snapshot does: an outage keeps the last verified status, so a same-runtime +// return moves only the contact epoch, not the connection generation. +function publish(status: RuntimeStatus | null, checkedAt: number): void { + lastVerified = status ?? lastVerified + useAppStore.getState().setRuntimeEnvironmentStatus('env-a', { + status, + checkedAt, + snapshot: { + environmentId: 'env-a', + pairingRevision: 1, + sequence: checkedAt, + checkedAt, + status: lastVerified, + verification: status ? 'verified' : 'unavailable', + transport: status ? 'ready' : 'disconnected' + } + }) +} + +beforeEach(() => { + clearRuntimeEnvironmentConnectionGenerationsForTests() + vi.stubGlobal('window', { api: {}, dispatchEvent: vi.fn() }) + useAppStore.setState({ runtimeStatusByEnvironmentId: new Map() }) + lastVerified = null +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('subscribeRuntimeHostContactRegained', () => { + it('fires when the runtime answers again after an outage, and not while it stays down', () => { + publish(makeStatus('runtime-a'), 1) + const listener = vi.fn() + const stop = subscribeRuntimeHostContactRegained('env-a', listener) + + publish(null, 2) + publish(null, 3) + expect(listener).not.toHaveBeenCalled() + + // Same runtime back: only the contact epoch moves. + const generation = useAppStore + .getState() + .runtimeStatusByEnvironmentId.get('env-a')?.connectionGeneration + publish(makeStatus('runtime-a'), 4) + expect( + useAppStore.getState().runtimeStatusByEnvironmentId.get('env-a')?.connectionGeneration + ).toBe(generation) + expect(listener).toHaveBeenCalledTimes(1) + publish(makeStatus('runtime-a'), 5) + expect(listener).toHaveBeenCalledTimes(1) + + // A restarted runtime: the connection generation moves. + publish(makeStatus('runtime-b'), 6) + expect(listener).toHaveBeenCalledTimes(2) + + stop() + publish(null, 7) + publish(makeStatus('runtime-b'), 8) + expect(listener).toHaveBeenCalledTimes(2) + }) + + it('ignores other environments', () => { + publish(makeStatus('runtime-a'), 1) + const listener = vi.fn() + const stop = subscribeRuntimeHostContactRegained('env-b', listener) + publish(null, 2) + publish(makeStatus('runtime-a'), 3) + expect(listener).not.toHaveBeenCalled() + stop() + }) +}) diff --git a/src/renderer/src/runtime/runtime-host-contact-regained.ts b/src/renderer/src/runtime/runtime-host-contact-regained.ts new file mode 100644 index 00000000000..b829f51d99a --- /dev/null +++ b/src/renderer/src/runtime/runtime-host-contact-regained.ts @@ -0,0 +1,31 @@ +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' + +function contactMarks(state: AppState, environmentId: string): string | null { + const entry = state.runtimeStatusByEnvironmentId.get(environmentId) + return entry ? `${entry.hostContactEpoch ?? 0}:${entry.connectionGeneration ?? 0}` : null +} + +/** + * Calls `listener` each time this client regains contact with an environment's runtime: the + * same-runtime return (`hostContactEpoch`) or a new runtime session (`connectionGeneration`), + * both published by runtime-status.ts. + */ +export function subscribeRuntimeHostContactRegained( + environmentId: string, + listener: () => void +): () => void { + let lastMarks = contactMarks(useAppStore.getState(), environmentId) + return useAppStore.subscribe((state, previousState) => { + if (state.runtimeStatusByEnvironmentId === previousState.runtimeStatusByEnvironmentId) { + return + } + const marks = contactMarks(state, environmentId) + // Why keep the last marks across a missing entry: a cleared status that returns is a reconnect. + if (marks === null || marks === lastMarks) { + return + } + lastMarks = marks + listener() + }) +} diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index f02d02abdea..edd399edc47 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -53,6 +53,9 @@ export type AgentSessionFailureWordsContext = { /** The conversation command a failed start was for, so the next step is to run it again * rather than to send a message. */ command?: 'clear' + /** The surface retries for the person — its own Retry beside the words, or a read that reconnects + * on its own — so they leave out sending or trying again. */ + retryControl?: boolean } /** @@ -110,7 +113,10 @@ function retryStep({ command }: AgentSessionFailureWordsContext): string { } /** The next step after a start or restart that failed: the command, or the message, again. */ -function startRetry({ command }: AgentSessionFailureWordsContext): string { +function startRetry({ command, retryControl }: AgentSessionFailureWordsContext): string { + if (retryControl) { + return '' + } return command === 'clear' ? ' Run /clear again.' : ' Send your message to try again.' } @@ -162,8 +168,9 @@ const FAILURE_SENTENCES = { providerStartFailed: (context) => `${context.agentName ?? 'The agent'} stopped before it finished starting.${startRetry(context)}`, startFailed: couldNot('start'), + // Beside a Retry the resend is the button, but signing in is still a step to take first. notSignedIn: (context) => - `${context.agentName ?? 'The agent'} is not signed in for the selected account. Sign in, then ${retryStep(context)}.`, + `${context.agentName ?? 'The agent'} is not signed in for the selected account. ${context.retryControl ? 'Sign in first.' : `Sign in, then ${retryStep(context)}.`}`, historyTooLarge: () => `This conversation's history is too large to restore here. ${START_NEW_CHAT}`, managedAccountEnvOverride: () => @@ -171,7 +178,7 @@ const FAILURE_SENTENCES = { accountSwitchInProgress: () => 'A Claude account switch is in progress. Try again after it finishes.', managedAccountUnsupported: (context) => - `While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings, then ${retryStep(context)}.`, + `While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings${context.retryControl ? '' : `, then ${retryStep(context)}`}.`, providerExited: ({ agentName }, _, surface) => surface === 'row' ? `${agentName ?? 'The agent'} stopped while this response was in progress. You can continue in this conversation.` @@ -190,12 +197,16 @@ const FAILURE_SENTENCES = { cancelled: () => 'This message was withdrawn before the agent started it.', chatClosed: () => 'The chat closed before this message was sent.', hostRestarted: () => 'Orca restarted before this message was sent.', - notDelivered: () => 'This message was not delivered. Send it again to continue.', + notDelivered: ({ retryControl }) => + retryControl + ? 'This message was not delivered.' + : 'This message was not delivered. Send it again to continue.', compactionFailed: (_, fact) => quotingPersonDetail('Compaction failed', fact.detail), compactionUnconfirmed: () => 'Compaction completion is unconfirmed.', cancelUnconfirmed: () => 'Cancellation was not confirmed.', answerUnconfirmed: () => 'Your answer was recorded but the agent did not confirm it.', - hostFault: () => "Orca ran into a problem, so this didn't go through. Try again.", + hostFault: ({ retryControl }) => + `Orca ran into a problem, so this didn't go through.${retryControl ? '' : ' Try again.'}`, hostStopped: ({ agentName }) => `${agentName ?? 'The agent'} never finished starting, so Orca stopped it.`, providerRetrying: ({ agentName }, { retry }) => diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index 73652adbf1a..a83a95c4388 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -5,6 +5,7 @@ import { } from './agent-session-wire-refusals' import { agentSessionReadHistoryRefusalParts, + agentSessionRefusalCauseParts, agentSessionRefusalNotice, agentSessionRefusalReasonWords, agentSessionWriteFailureNotice, @@ -368,6 +369,45 @@ describe('the notice for every reason a host names', () => { } }) + // Beside a Retry, that Retry is the step for a reason whose action is to retry, and only for it. + it('leaves out only a retry step when a Retry stands beside the words', () => { + const beside = { agentName: 'Codex', retryControl: true } + for (const { failure, write, parts, words, cell } of cells) { + if (words && 'cause' in words) { + const retried = words.action === 'retry' ? words.step : undefined + expect(agentSessionWriteNoticeParts(failure, write, beside), cell).toEqual( + parts.filter((part) => part !== retried) + ) + } + } + const send = (failure: AgentSessionWriteRefusal) => + agentSessionWriteNoticeParts(failure, 'send', beside) + expect( + send({ + kind: 'refused', + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' } + }) + ).toEqual(['historyUnavailable', 'notDoneSend']) + expect( + send({ + kind: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'notSignedIn' } + }) + ).toEqual([ + 'notDoneSend', + { text: 'Codex is not signed in for the selected account. Sign in first.' } + ]) + expect( + send({ + kind: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'providerStartFailed' } + }) + ).toEqual(['notDoneSend', { text: 'Codex stopped before it finished starting.' }]) + }) + it('says the write did not happen, once, and never shows the host message', () => { for (const { failure, write, parts, cell } of cells) { const english = agentSessionWriteNoticeEnglish(parts) @@ -522,3 +562,84 @@ describe('a chat whose history the host could not open', () => { ]) }) }) + +// For a line that already says what did not happen and shows its own Retry, such as a chat that +// could not start: the Retry is the step for retrying and for sending again, and only for those. +describe('agentSessionRefusalCauseParts', () => { + it('keeps a step other than retrying, beside the cause', () => { + expect( + agentSessionRefusalCauseParts({ + kind: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'turnActive' } + }) + ).toEqual(['turnActive', 'waitForTurn']) + expect( + agentSessionRefusalCauseParts({ + kind: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'historyTooLarge' } + }) + ).toEqual([ + { + text: "This conversation's history is too large to restore here. Start a new chat to continue." + } + ]) + }) + + it('leaves out a step the Retry beside it takes', () => { + expect( + agentSessionRefusalCauseParts({ + kind: 'refused', + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' } + }) + ).toEqual(['historyUnavailable']) + expect( + agentSessionRefusalCauseParts( + { + kind: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'notSignedIn' } + }, + { agentName: 'Claude' } + ) + ).toEqual([{ text: 'Claude is not signed in for the selected account. Sign in first.' }]) + expect( + agentSessionRefusalCauseParts({ + kind: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'providerStartFailed' } + }) + ).toEqual([{ text: 'The agent stopped before it finished starting.' }]) + expect( + agentSessionRefusalCauseParts({ + kind: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'managedAccountUnsupported' } + }) + ).toEqual([ + { + text: 'While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings.' + } + ]) + }) + + it('writes the same sentences as before where no Retry stands beside them', () => { + expect(agentSessionFailureSentence({ kind: 'notSignedIn' }, 'rejection')).toBe( + 'The agent is not signed in for the selected account. Sign in, then send your message again.' + ) + }) + + it.each<[string, AgentSessionWriteFailure]>([ + ['a refusal with no reason', { kind: 'refused', code: 'agent_session_operation_invalid' }], + [ + "a reason whose code's words stand", + { kind: 'refused', code: 'agent_session_conflict', details: { reason: 'ownerAlive' } } + ], + ['a failed request', { kind: 'failed' }], + ['an unconfirmed one', { kind: 'unconfirmed' }] + ])('names nothing for %s', (_label, failure) => { + expect(agentSessionRefusalCauseParts(failure)).toEqual([]) + }) +}) diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index 818031b8191..0f4121dc9b3 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -6,11 +6,15 @@ // own row names a cause only where every emitter of the code means it; it is also the words for a // host too old to send a reason. A notice says how to get past a refusal only where the person has // a step to take; retrying is the control that sent the write, except on the phone, whose message -// goes back to the composer, and a history that couldn't open right now says to try again. +// goes back to the composer, and a history that couldn't open right now says to try again unless +// something beside the words retries it. // Surfaces keep the fact and choose the words when they show it, so nothing saved carries copy. import type { AgentSessionFailureKind } from './agent-session-failure' -import { agentSessionFailureSentence } from './agent-session-failure-words' +import { + agentSessionFailureSentence, + type AgentSessionFailureWordsContext +} from './agent-session-failure-words' import type { AgentSessionRefusalReason } from './agent-session-refusal-details' import { AGENT_SESSION_WRITE_NOTICE_COPY, @@ -80,8 +84,6 @@ export type AgentSessionRefusalReasonWords = /** A start that failed: the sentence that failure has everywhere, whose next step is a send. */ | { fact: AgentSessionFailureKind; action: AgentSessionRefusalAction } -type AgentSessionRefusalCauseWords = Extract - function codeWords(action: AgentSessionRefusalAction): AgentSessionRefusalReasonWords { return { words: 'code', action } } @@ -197,7 +199,7 @@ const REASON_WORDS = { agent_session_journal_unreadable: { // No retry reads past damage, and the words name no step: it only can't load. journalCorrupt: causeWords('historyUnusable', 'hostFinding'), - // Says its step despite 'retry': released clients and the phone often show no Retry here. + // Says its step despite 'retry' unless a Retry stands beside it: the phone often has none. journalUnavailable: causeWords('historyUnavailable', 'retry', 'tryAgain') }, // Thrown, so a client meets these only as an RPC error; the code's words stand. @@ -242,34 +244,47 @@ function causeParts( return saysNotDone ? [cause] : [cause, NOT_DONE[write]] } -function causeWordsParts( - words: AgentSessionRefusalCauseWords, - write: AgentSessionWriteKind -): AgentSessionWriteNoticeSentence[] { - const said = causeParts(words.cause, write) - return words.step ? [...said, words.step] : said -} - /** The notice a named reason has of its own; undefined leaves the code's words. */ function reasonParts( failure: AgentSessionWriteRefusal, - write: AgentSessionWriteKind + write: AgentSessionWriteKind, + context: AgentSessionFailureWordsContext ): AgentSessionWriteNoticePart[] | undefined { const words = agentSessionRefusalReasonWords(failure) if (!words || 'words' in words) { return undefined } if ('fact' in words) { + const sentence = agentSessionFailureSentence({ kind: words.fact }, 'rejection', context) return write === 'send' || write === 'composer-send' - ? [NOT_DONE[write], { text: agentSessionFailureSentence({ kind: words.fact }, 'rejection') }] + ? [NOT_DONE[write], { text: sentence }] : undefined } - return causeWordsParts(words, write) + const said = causeParts(words.cause, write) + // A Retry beside the words is the step for a reason whose action is to retry. + const retried = context.retryControl && words.action === 'retry' + return words.step && !retried ? [...said, words.step] : said } +/** What stopped a refused start, for a line that already says the chat did not start and shows its + * own Retry. Empty when the refusal names no reason with words of its own. */ +export function agentSessionRefusalCauseParts( + failure: AgentSessionWriteFailure, + context: { agentName?: string } = {} +): AgentSessionWriteNoticePart[] { + const parts = + failure.kind === 'refused' + ? reasonParts(failure, 'send', { ...context, retryControl: true }) + : undefined + return parts?.filter((part) => part !== 'notDoneSend') ?? [] +} + +/** `context.retryControl`: a Retry beside the words is the step for a reason whose action is to + * retry, and for sending again; any other step stays. */ export function agentSessionWriteNoticeParts( failure: AgentSessionWriteFailure, - write: AgentSessionWriteKind + write: AgentSessionWriteKind, + context: AgentSessionFailureWordsContext = {} ): AgentSessionWriteNoticePart[] { const notDone = NOT_DONE[write] if (failure.kind === 'failed') { @@ -278,7 +293,7 @@ export function agentSessionWriteNoticeParts( if (failure.kind === 'unconfirmed') { return ['outcomeUnknown'] } - const byReason = reasonParts(failure, write) + const byReason = reasonParts(failure, write, context) if (byReason) { return byReason } @@ -350,10 +365,11 @@ export function agentSessionWriteFailureNotice(write: AgentSessionWriteKind): st * host sent; a code this build does not know says only that the history did not load. */ export function agentSessionReadHistoryRefusalParts( code: string, - details?: unknown + details?: unknown, + context: AgentSessionFailureWordsContext = {} ): AgentSessionWriteNoticePart[] { const failure = parseAgentSessionWriteFailure({ kind: 'refused', code, details }) return failure - ? agentSessionWriteNoticeParts(failure, 'read-history') + ? agentSessionWriteNoticeParts(failure, 'read-history', context) : agentSessionWriteNotDoneParts('read-history') } diff --git a/src/shared/agent-session-write-failure.test.ts b/src/shared/agent-session-write-failure.test.ts index 83d8faea790..0b614b3a265 100644 --- a/src/shared/agent-session-write-failure.test.ts +++ b/src/shared/agent-session-write-failure.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' import { agentSessionRefusalFailure, - parseAgentSessionWriteFailure + agentSessionThrownFailure, + parseAgentSessionWriteFailure, + readAgentSessionErrorRefusal } from './agent-session-write-failure' const HOST_TEXT = 'Expected runtime fence 1; the session is at 3.' @@ -122,3 +124,44 @@ describe('agentSessionRefusalFailure', () => { }) }) }) + +describe('a refusal a failed request carried in its error', () => { + const refusal = { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt', stray: 'dropped' } + } + const payload = { code: 'runtime_error', message: 'agent_session_journal_unreadable' } + + it('is read from a thrown RPC error and from a stream payload alike', () => { + const expected = { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + } + expect( + readAgentSessionErrorRefusal({ response: { error: { ...payload, data: { refusal } } } }) + ).toEqual(expected) + expect(readAgentSessionErrorRefusal(saved({ ...payload, data: { refusal } }))).toEqual(expected) + }) + + it.each([ + ['an older host', payload], + ['a thrown error without a response', new Error('agent_session_journal_unreadable')], + ['a code this build does not know', { ...payload, data: { refusal: { code: 'from_later' } } }], + ['nothing', undefined] + ])('is absent from %s', (_label, error) => { + expect(readAgentSessionErrorRefusal(error)).toBeUndefined() + }) + + it("words the refusal when there is one, else what the request's error code proves", () => { + expect(agentSessionThrownFailure({ ...payload, data: { refusal } }, 'runtime_error')).toEqual({ + kind: 'refused', + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + }) + expect(agentSessionThrownFailure(payload, 'runtime_error')).toEqual({ kind: 'unconfirmed' }) + expect(agentSessionThrownFailure(payload, 'method_not_found')).toEqual({ + kind: 'refused', + code: 'structured_agent_session_unsupported' + }) + }) +}) diff --git a/src/shared/agent-session-write-failure.ts b/src/shared/agent-session-write-failure.ts index dd5c8f26caa..81809133871 100644 --- a/src/shared/agent-session-write-failure.ts +++ b/src/shared/agent-session-write-failure.ts @@ -9,7 +9,9 @@ import { import type { AgentSessionRewindReason } from './agent-session-rewind' import { isAgentSessionWireRefusalCode, + readAgentSessionRefusalReference, type AgentSessionOwnerVerdict, + type AgentSessionRefusalReference, type AgentSessionWireRefusal, type AgentSessionWireRefusalCode } from './agent-session-wire-refusals' @@ -136,6 +138,35 @@ export function agentSessionRpcErrorFailure(code: string | undefined): AgentSess : { kind: 'unconfirmed' } } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +/** The refusal a failed request's error carries in its data. Takes both shapes a client meets: an + * RPC call's thrown error, whose payload is on `response.error`, and the payload a stream hands + * its error callback. Undefined from an older host, or for a failure that was not a refusal. */ +export function readAgentSessionErrorRefusal( + error: unknown +): AgentSessionRefusalReference | undefined { + const payload = + isRecord(error) && isRecord(error.response) && isRecord(error.response.error) + ? error.response.error + : error + const data = isRecord(payload) ? payload.data : undefined + return isRecord(data) ? readAgentSessionRefusalReference(data.refusal) : undefined +} + +/** What to say about a request that threw: the host's refusal when its error carried one, else + * what the RPC error code proves. Words only: whether the write may have happened stays the + * caller's own classification. */ +export function agentSessionThrownFailure( + error: unknown, + rpcCode: string | undefined +): AgentSessionWriteFailure { + const refusal = readAgentSessionErrorRefusal(error) + return refusal ? agentSessionRefusalFailure(refusal) : agentSessionRpcErrorFailure(rpcCode) +} + /** A saved failure, or undefined when it is not one this build wrote. */ export function parseAgentSessionWriteFailure( value: unknown diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index 4211bbb3961..c14209a716c 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -36,8 +36,9 @@ export type StructuredAgentSessionOutboxEntry = { lastFailure?: StructuredAgentSessionAttemptFailure } -/** A host's rejection fact as a message keeps it: never its provider detail, whose person-facing - * words are already in the reason and whose log text is not kept client-side. */ +/** A host's rejection fact as a message keeps it: never its provider detail, whose log text is not + * kept client-side, or its refusal. The journal row keeps the whole fact, and words the notice + * while it is loaded; this copy words it when it is not. */ export type StructuredAgentSessionRejectionFact = Pick< AgentSessionFailureFact, 'kind' | 'attachment' diff --git a/src/shared/structured-agent-session-read-refusal.ts b/src/shared/structured-agent-session-read-refusal.ts index 784f38ed2a2..74279d7811d 100644 --- a/src/shared/structured-agent-session-read-refusal.ts +++ b/src/shared/structured-agent-session-read-refusal.ts @@ -1,3 +1,5 @@ +import type { AgentSessionRefusalReference } from './agent-session-wire-refusals' + /** * The one refusal a structured-session READ can raise that is not a failure to read. * @@ -43,3 +45,17 @@ export function isUnattachedAgentSessionReadRefusal(error: unknown): boolean { message === AGENT_SESSION_UNATTACHED_REFUSAL_CODE ) } + +/** + * A read refusal no retry reads past: SQLite reported the chat's journal damaged. Decided from the + * reason, never the message, which is the bare code for every journal refusal; a journal that + * failed to open for any other reason can clear, so its read keeps reconnecting. + */ +export function isFinalAgentSessionReadRefusal( + refusal: AgentSessionRefusalReference | undefined +): boolean { + return ( + refusal?.code === 'agent_session_journal_unreadable' && + refusal.details?.reason === 'journalCorrupt' + ) +} diff --git a/src/shared/structured-agent-session-reducer.test.ts b/src/shared/structured-agent-session-reducer.test.ts index a374c794d7f..ca108710bcf 100644 --- a/src/shared/structured-agent-session-reducer.test.ts +++ b/src/shared/structured-agent-session-reducer.test.ts @@ -158,6 +158,46 @@ describe('structured agent session reducer', () => { ]) }) + it("keeps a failed read's refusal beside its text until the read recovers", () => { + const refusal = { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + } as const + const failed = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'error', + message: 'agent_session_journal_unreadable', + refusal + }) + expect(failed).toMatchObject({ status: 'error', readRefusal: refusal }) + + expect(reduceStructuredAgentSession(failed, { type: 'loading' }).readRefusal).toBeUndefined() + const live = reduceStructuredAgentSession( + reduceStructuredAgentSession(failed, { + type: 'history-page', + page: hydrationPage([item('a', 1)]) + }), + { type: 'error', message: 'transport died' } + ) + expect(live.readRefusal).toBeUndefined() + const recovered = reduceStructuredAgentSession( + { ...live, readRefusal: refusal }, + { + type: 'event', + event: { + type: 'batch', + sessionId: 'session-a', + batch: { + cursor: { epoch: 'epoch-a', sequence: 2 }, + items: [item('b', 2)], + removedItemIds: [], + submissions: [] + } + } + } + ) + expect(recovered).toMatchObject({ status: 'ready', error: undefined, readRefusal: undefined }) + }) + it('uses the bounded hydration page pagination boundary', () => { const restored = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { type: 'event', diff --git a/src/shared/structured-agent-session-reducer.ts b/src/shared/structured-agent-session-reducer.ts index e47ed5ad57b..02c554628fb 100644 --- a/src/shared/structured-agent-session-reducer.ts +++ b/src/shared/structured-agent-session-reducer.ts @@ -10,6 +10,7 @@ import type { AgentSessionSubscribeEvent, AgentSessionTurnActivity } from './agent-session-wire' +import type { AgentSessionRefusalReference } from './agent-session-wire-refusals' import { backgroundTaskStatesEqual } from './agent-session-background-task-state-equality' import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import { compareAgentJournalItems } from './agent-session-journal-position' @@ -32,7 +33,10 @@ export type StructuredAgentSessionState = { retainedItemLimit: number hasOlder: boolean status: 'idle' | 'loading' | 'ready' | 'error' + /** The failed read's own text, for logs; a surface words `readRefusal` instead. */ error?: string + /** The refusal the failed read met, when the host sent one; cleared with `error`. */ + readRefusal?: AgentSessionRefusalReference backgroundTasks?: AgentSessionBackgroundTaskState | null commands?: AgentSessionSlashCommand[] | null activity?: AgentSessionTurnActivity | null @@ -45,7 +49,7 @@ export type StructuredAgentSessionState = { export type StructuredAgentSessionAction = | { type: 'loading' } - | { type: 'error'; message: string } + | { type: 'error'; message: string; refusal?: AgentSessionRefusalReference } | { type: 'event'; event: AgentSessionSubscribeEvent } | { type: 'history-page'; page: AgentSessionHistoryPage } | { type: 'older-page'; requestedCursor: AgentJournalCursor; page: AgentSessionHistoryPage } @@ -176,10 +180,10 @@ export function reduceStructuredAgentSession( ): StructuredAgentSessionState { if (action.type === 'loading') { // Keep the last transcript visible while a reconnect rehydrates the stream. - return { ...state, status: 'loading', error: undefined } + return { ...state, status: 'loading', error: undefined, readRefusal: undefined } } if (action.type === 'error') { - return { ...state, status: 'error', error: action.message } + return { ...state, status: 'error', error: action.message, readRefusal: action.refusal } } if (action.type === 'history-page') { return { @@ -272,6 +276,7 @@ export function reduceStructuredAgentSession( : mergeSubmissions(state.submissions, event.batch.submissions, items), status: 'ready', error: undefined, + readRefusal: undefined, commands: event.commands !== undefined ? event.commands : state.commands, ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), ...(activity !== undefined ? { activity } : {}), diff --git a/src/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts index 08fad831af2..e95a21937d1 100644 --- a/src/shared/structured-agent-session-send-disposition.test.ts +++ b/src/shared/structured-agent-session-send-disposition.test.ts @@ -55,12 +55,12 @@ function rejectedWith( } } -function notice(reason: string | null): string | undefined { +function notice(reason: string | null, rejection?: AgentSessionFailureFact): string | undefined { const disposition = disposeStructuredAgentSessionSendResult({ entries: [entry], entry, blockedClientMessageId: null, - result: rejectedWith(reason), + result: rejectedWith(reason, rejection ? { rejection } : {}), createOperationId: () => 'unused' }) // The reason travels with the message it explains, never as a separate error. @@ -121,8 +121,7 @@ describe('what a rejection shows the user', () => { expect(disposition).toEqual({ entries: [], error: null, - blockedClientMessageId: null, - retryWithFreshClientMessageId: null + blockedClientMessageId: null }) } }) @@ -157,6 +156,64 @@ describe('what a rejection shows the user', () => { }) }) +// A row that carries the host's fact is worded from it; the reason is not read. +describe('what a rejection with a typed fact shows the user', () => { + it('says Orca could not hand the message over, whatever the reason holds', () => { + expect(notice('provider_write_failed', { kind: 'writeFailed' })).toBe( + "Orca couldn't reach the agent. Your message was not sent." + ) + expect(notice('Something unrelated.', { kind: 'writeFailed' })).toBe( + "Orca couldn't reach the agent. Your message was not sent." + ) + }) + + it("rebuilds the fact's sentence where a marker stands in for it", () => { + expect(notice(DISPATCH_REJECTED_QUEUE_FULL, { kind: 'queueFull' })).toBe( + 'Too many messages were waiting for the agent, so this one was not sent.' + ) + }) + + // The surface names the agent; the host's own sentence is never compared or shown. + it('words the fact itself, never the sentence the host wrote beside it', () => { + expect( + notice('Claude never finished starting, so Orca stopped it.', { kind: 'hostStopped' }) + ).toBe('The agent never finished starting, so Orca stopped it.') + }) + + // The message keeps no fact it cannot place, so the host's sentence stands, as on an older host. + it("shows a newer host's sentence when its fact cannot be placed", () => { + expect(notice('A sentence a newer host wrote.', JSON.parse('{"kind":"fromTheFuture"}'))).toBe( + 'A sentence a newer host wrote.' + ) + }) + + // The message's copy drops the detail and the refusal these kinds are worded from, so the + // sentence the host wrote for the person stands in for them; with none, the table's words. + it("shows the host's sentence for a kind whose words its copy cannot rebuild", () => { + expect( + notice('The provider did not accept this message: Image type .bmp.', { + kind: 'providerRejected', + detail: { text: 'Image type .bmp', audience: 'person' } + }) + ).toBe('The provider did not accept this message: Image type .bmp.') + expect( + notice("Claude couldn't start. Start a new chat to continue.", { + kind: 'startFailed', + refusal: { code: 'agent_session_identity_required' } + }) + ).toBe("Claude couldn't start. Start a new chat to continue.") + expect(notice(null, { kind: 'providerRejected' })).toBe( + 'The provider did not accept this message.' + ) + }) + + it('says only that the message was not sent for a fact no message can carry', () => { + expect(notice('Compaction failed.', { kind: 'compactionFailed' })).toBe( + 'Your message was not sent.' + ) + }) +}) + describe('what a refusal shows the user', () => { it('keeps the refusal as a fact on the message, without the host diagnostic', () => { const disposition = disposeStructuredAgentSessionSendResult({ diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index 7ffce942b6e..11e2b45fc0b 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -16,6 +16,11 @@ import { } from './agent-session-refusal-notice' import type { AgentSessionWriteNoticePart } from './agent-session-write-notice-copy' import { agentSessionRefusalFailure } from './agent-session-write-failure' +import type { AgentSessionFailureFact } from './agent-session-failure' +import { + agentSessionFailureSentence, + type AgentSessionFailureWordsContext +} from './agent-session-failure-words' import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' import { classifyStructuredAgentSessionSendFailure, @@ -32,8 +37,6 @@ export type StructuredAgentSessionSendDisposition = { /** The entry the queue is stuck on, or null when nothing blocks it. Always the * next value, never "unchanged": the caller assigns it verbatim. */ blockedClientMessageId: string | null - /** A rejected result arrived before the journal snapshot; Retry must rotate this id. */ - retryWithFreshClientMessageId: string | null } type SendDispositionInput = { @@ -129,8 +132,14 @@ export function structuredAgentSessionRejectionNotice( export function structuredAgentSessionRejectionParts( reason: string | null, - write: 'send' | 'composer-send' + write: 'send' | 'composer-send', + /** The host's typed fact, which decides when the row carried one. */ + fact?: AgentSessionFailureFact, + context: AgentSessionFailureWordsContext = {} ): AgentSessionWriteNoticePart[] { + if (fact) { + return rejectionFactParts(write, fact, context) + } if (reason === null) { return ['notDoneSend'] } @@ -143,13 +152,43 @@ export function structuredAgentSessionRejectionParts( return rejection.kind ? agentSessionWriteNotDoneParts(write) : [{ text: reason }] } +function rejectionFactParts( + write: 'send' | 'composer-send', + fact: AgentSessionFailureFact, + context: AgentSessionFailureWordsContext +): AgentSessionWriteNoticePart[] { + const { kind } = classifyDispatchRejection({ reason: null, rejection: fact }) + if (kind === 'writeFailed') { + return ['unreachable', ...agentSessionWriteNotDoneParts(write)] + } + // A fact this build cannot place proves only that the message did not happen. + return kind + ? [{ text: agentSessionFailureSentence({ ...fact, kind }, 'rejection', context) }] + : agentSessionWriteNotDoneParts(write) +} + +/** Kinds whose words need what the message's copy drops: the provider's detail, or the refusal. */ +const WORDED_FROM_WHOLE_FACT: ReadonlySet = new Set< + AgentSessionFailureFact['kind'] +>(['providerRejected', 'startFailed', 'restartFailed']) + /** What the Retry row says about why its message did not go through. */ export function structuredAgentSessionAttemptFailureParts( - failure: StructuredAgentSessionAttemptFailure + failure: StructuredAgentSessionAttemptFailure, + context: AgentSessionFailureWordsContext = {}, + /** The journal's whole fact for a recorded rejection, when its submission is loaded: the + * message's own copy keeps only its kind and attachment. */ + recorded?: AgentSessionFailureFact ): AgentSessionWriteNoticePart[] { - return failure.kind === 'rejected' - ? structuredAgentSessionRejectionParts(failure.reason, 'send') - : agentSessionWriteNoticeParts(failure, 'send') + if (failure.kind !== 'rejected') { + return agentSessionWriteNoticeParts(failure, 'send', context) + } + const fact = recorded ?? failure.rejection + // Without the journal's fact, the host's sentence still holds what the copy dropped. + if (!recorded && fact && WORDED_FROM_WHOLE_FACT.has(fact.kind) && failure.reason !== null) { + return [{ text: failure.reason }] + } + return structuredAgentSessionRejectionParts(failure.reason, 'send', fact, context) } export function disposeStructuredAgentSessionSendResult( @@ -187,8 +226,7 @@ export function disposeStructuredAgentSessionSendResult( blockedClientMessageId: !refused || refused.state === 'rejected' ? input.blockedClientMessageId - : refused.clientMessageId, - retryWithFreshClientMessageId: null + : refused.clientMessageId } } const submission = result.value.submission @@ -196,16 +234,14 @@ export function disposeStructuredAgentSessionSendResult( return { entries: dropEntry(input), error: 'Message delivery is unconfirmed and Orca will not send it again', - blockedClientMessageId: input.blockedClientMessageId, - retryWithFreshClientMessageId: null + blockedClientMessageId: input.blockedClientMessageId } } if (submission.dispatchState === 'accepted') { return { entries: dropEntry(input), error: null, - blockedClientMessageId: input.blockedClientMessageId, - retryWithFreshClientMessageId: null + blockedClientMessageId: input.blockedClientMessageId } } // A Stop's withdrawal failed nothing, first reply or replay: the entry leaves as the reconcile @@ -217,8 +253,7 @@ export function disposeStructuredAgentSessionSendResult( return { entries: dropEntry(input), error: null, - blockedClientMessageId: input.blockedClientMessageId, - retryWithFreshClientMessageId: null + blockedClientMessageId: input.blockedClientMessageId } } if (submission.dispatchState === 'rejected') { @@ -229,8 +264,7 @@ export function disposeStructuredAgentSessionSendResult( structuredAgentSessionRejectedFailure(submission) ), error: null, - blockedClientMessageId: input.blockedClientMessageId, - retryWithFreshClientMessageId: input.entry.clientMessageId + blockedClientMessageId: input.blockedClientMessageId } } if (submission.dispatchState === 'unknown' && submission.recovered) { @@ -241,8 +275,7 @@ export function disposeStructuredAgentSessionSendResult( : candidate ), error: null, - blockedClientMessageId: input.blockedClientMessageId, - retryWithFreshClientMessageId: null + blockedClientMessageId: input.blockedClientMessageId } } // `pending` is the host saying the message was written and is awaiting the @@ -257,8 +290,7 @@ export function disposeStructuredAgentSessionSendResult( submission.dispatchState === 'unknown' ? 'unconfirmed' : 'dispatching' ), error: null, - blockedClientMessageId: input.blockedClientMessageId, - retryWithFreshClientMessageId: null + blockedClientMessageId: input.blockedClientMessageId } } @@ -278,7 +310,6 @@ export function disposeStructuredAgentSessionSendFailure( error: null, blockedClientMessageId: deliveryUnknown ? input.blockedClientMessageId - : input.entry.clientMessageId, - retryWithFreshClientMessageId: null + : input.entry.clientMessageId } } diff --git a/src/shared/structured-agent-session-start-failure-row-key.ts b/src/shared/structured-agent-session-start-failure-row-key.ts new file mode 100644 index 00000000000..6dc4a4817b1 --- /dev/null +++ b/src/shared/structured-agent-session-start-failure-row-key.ts @@ -0,0 +1,17 @@ +// A failed start's one row is keyed by the start, so a reader finds it by identity, not by its words. + +import { parseAgentJournalItemKey } from './agent-session-journal-item-key' +import type { AgentJournalItemIdentity } from './agent-session-journal-types' + +const START_FAILURE_ROW = 'start-failure:' + +export function structuredAgentSessionStartFailureRowIdentity( + startKey: string +): Extract { + return { provider: 'orca', clientMessageId: `${START_FAILURE_ROW}${startKey}` } +} + +export function isStructuredAgentSessionStartFailureRow(itemId: string): boolean { + const identity = parseAgentJournalItemKey(itemId) + return identity?.provider === 'orca' && identity.clientMessageId.startsWith(START_FAILURE_ROW) +}