diff --git a/mobile/src/session/mobile-structured-queued-message-cards.test.ts b/mobile/src/session/mobile-structured-queued-message-cards.test.ts
index 2c6173b54a5a..44d1e0b9729f 100644
--- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts
+++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts
@@ -239,6 +239,28 @@ describe('mobileQueuedMessageCards', () => {
expect(card?.caption).toBe('Words for a kind a newer host added.')
})
+ it("shows the host's sentence for a fact this build cannot read all of, as the desktop card", () => {
+ const reason = "Claude couldn't start. Start a new chat to continue."
+ // As a newer host sends it: a known code with a reason this build doesn't know.
+ const newer = {
+ kind: 'startFailed',
+ refusal: { code: 'agent_session_conflict', details: { reason: 'newerReason' } }
+ }
+ const [card] = mobileQueuedMessageCards(
+ [
+ draft({
+ messageId: 'a',
+ state: 'returned',
+ returnedReason: reason,
+ returnedRejection: newer
+ })
+ ],
+ [],
+ { pendingPrompt: false }
+ )
+ expect(card?.caption).toBe(reason)
+ })
+
it('maps the send-failed pause marker to English and an unknown marker to a plain pause', () => {
const cards = mobileQueuedMessageCards(
[
diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts
index 16393dbbf8ba..f3edb13e53c8 100644
--- a/mobile/src/session/mobile-structured-queued-message-cards.ts
+++ b/mobile/src/session/mobile-structured-queued-message-cards.ts
@@ -2,7 +2,7 @@
// The wire carries no hold copy on purpose: the caption is derived here from the
// draft's own state plus the live facts the client already holds.
-import { readAgentSessionFailureFact } from '../../../src/shared/agent-session-failure'
+import { readWholeAgentSessionFailureFact } from '../../../src/shared/agent-session-failure'
import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types'
import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice'
import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection'
@@ -37,13 +37,13 @@ function returnedCaption(
if (dispatchWasWithdrawn({ dispatchState: 'rejected', reason, rejection })) {
return 'Stopped before it was sent'
}
- // Worded as the desktop card words it: the fact decides, the reason is the fallback, and the
- // card's own Send is the retry, so the words leave out sending again.
+ // Worded as the desktop card words it: a fact read whole decides, the host's reason is the
+ // fallback, and the card's own Send is the retry, so the words leave out sending again.
return agentSessionWriteNoticeEnglish(
structuredAgentSessionAttemptFailureParts(
{ kind: 'rejected', reason },
{ retryControl: true },
- readAgentSessionFailureFact(rejection)
+ readWholeAgentSessionFailureFact(rejection)
)
)
}
diff --git a/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx b/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx
index ec84f554cd76..d6c7094a2961 100644
--- a/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx
@@ -1,13 +1,17 @@
import { MessageSquare, TriangleAlert } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { formatAgentTypeLabel } from '@/lib/agent-status'
-import { NATIVE_CHAT_EMPTY_STATE_COPY } from '../../../../shared/native-chat-empty-state'
+import {
+ NATIVE_CHAT_EMPTY_STATE_COPY,
+ NATIVE_CHAT_KEEPS_TRYING_COPY
+} from '../../../../shared/native-chat-empty-state'
import type { NativeChatSession } from '../../../../shared/native-chat-types'
export function NativeChatEmptyState({
kind,
message,
headline,
+ headlineSaysUnread = false,
agent,
retrying = false
}: {
@@ -15,12 +19,15 @@ export function NativeChatEmptyState({
message?: string
/** The chat's own sentence for the failure, said once: it takes the generic title's place. */
headline?: string
+ /** The headline already says the history didn't load, so the retrying line says only that
+ * Orca keeps trying. */
+ headlineSaysUnread?: boolean
agent?: NativeChatSession['agent']
/** The read retries on its own (structured chat), so the error says so instead of pointing
* back to the terminal. */
retrying?: boolean
}): React.JSX.Element {
- const copy = emptyStateCopy(kind, { message, headline }, agent, retrying)
+ const copy = emptyStateCopy(kind, { message, headline, headlineSaysUnread }, agent, retrying)
return (
- {message}
- {cause ? ` ${cause}` : null}
+ {cause ? joinSentences([message, cause]) : message}
diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx
index c37fd3127b3a..29432e643304 100644
--- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx
@@ -19,7 +19,7 @@ import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
import { translate } from '@/i18n/i18n'
import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition'
import { classifyDispatchRejection } from '../../../../shared/structured-agent-session-dispatch-rejection'
-import { readAgentSessionFailureFact } from '../../../../shared/agent-session-failure'
+import { readWholeAgentSessionFailureFact } from '../../../../shared/agent-session-failure'
import { agentSessionWriteNoticeText } from './agent-session-write-notice-text'
import { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../../shared/agent-session-wire'
import { isMacPlatform } from './native-chat-shortcut'
@@ -47,7 +47,7 @@ export function queuedMessageCardCaption(card: QueuedMessageCard): string | null
{ kind: 'rejected', reason },
// The card's own Send is the retry, so the words leave out sending again.
{ retryControl: true },
- readAgentSessionFailureFact(card.returnedRejection)
+ readWholeAgentSessionFailureFact(card.returnedRejection)
)
)
}
diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx
index c681a3902f99..41fd4852a202 100644
--- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx
@@ -167,6 +167,25 @@ describe('NativeChatQueuedMessageList', () => {
expect(row.textContent).not.toContain('Your message was not sent.')
})
+ it("shows the host's sentence for a fact this build cannot read all of", () => {
+ const reason = "Claude couldn't start. Start a new chat to continue."
+ renderList(
+ controller([
+ card({
+ messageId: 'newer',
+ state: 'returned',
+ hold: 'returned',
+ returnedReason: reason,
+ // As a newer host sends it: a known code with a reason this build doesn't know.
+ returnedRejection: JSON.parse(
+ '{ "kind": "startFailed", "refusal": { "code": "agent_session_conflict", "details": { "reason": "newerReason" } } }'
+ )
+ })
+ ])
+ )
+ expect(screen.getByRole('listitem').textContent).toContain(reason)
+ })
+
it('the words leave out sending again: the card offers its own Send', () => {
renderList(
controller([
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 89ef518feb3b..3422e66254e1 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
@@ -39,7 +39,9 @@ function renderPane(): void {
)
}
-function journalRefusal(reason: 'journalCorrupt' | 'journalUnavailable') {
+function journalRefusal(
+ reason: 'journalCorrupt' | 'journalUnavailable' | 'journalWrittenByNewerOrca'
+) {
return { code: 'agent_session_journal_unreadable', details: { reason } } as const
}
@@ -80,10 +82,38 @@ it("names a history that couldn't open right now once, and that the pane keeps t
expect(screen.getAllByText("Orca couldn't open this chat's history right now.")).toHaveLength(1)
expect(screen.queryByText('Could not load conversation')).toBeNull()
+ // The title already says the history didn't open, so the line under it says only the retrying.
+ expect(screen.getByText('Orca keeps trying to load it.')).toBeTruthy()
+ expect(screen.queryByText(/could not be read/)).toBeNull()
+ expect(screen.queryByText(/Try again/)).toBeNull()
+})
+
+it("says only that it keeps trying under a code's own words that the history didn't load", () => {
+ mocks.status = 'error'
+ mocks.readRefusal = {
+ code: 'agent_session_checkpoint_stale',
+ details: { reason: 'fenceStale' }
+ } as const
+ mocks.messages = []
+
+ renderPane()
+
+ expect(screen.getAllByText("This chat's history couldn't be loaded.")).toHaveLength(1)
+ expect(screen.getByText('Orca keeps trying to load it.')).toBeTruthy()
+ expect(screen.queryByText(/could not be read/)).toBeNull()
+})
+
+it('keeps the whole retrying line under a newer Orca\'s words, which name no one chat for "it"', () => {
+ mocks.status = 'error'
+ mocks.readRefusal = journalRefusal('journalWrittenByNewerOrca')
+ mocks.messages = []
+
+ renderPane()
+
+ expect(screen.getByText(/^Chats were saved by a newer Orca\./)).toBeTruthy()
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', () => {
diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
index 463a63e20938..c78c27419a1e 100644
--- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
@@ -270,7 +270,9 @@ export function NativeChatStructuredSession(
) : viewState.kind === 'empty' ? (
diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts
new file mode 100644
index 000000000000..a327a7cf127a
--- /dev/null
+++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts
@@ -0,0 +1,239 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { i18n, translate } from '@/i18n/i18n'
+import type * as I18nModule from '@/i18n/i18n'
+import en from '@/i18n/locales/en.json'
+import zh from '@/i18n/locales/zh.json'
+import {
+ AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS,
+ AGENT_SESSION_FAILURE_KINDS,
+ type AgentSessionFailureFact
+} from '../../../../shared/agent-session-failure'
+import {
+ AGENT_SESSION_FAILURE_COPY,
+ sayAgentSessionFailureEnglish,
+ type AgentSessionFailureCopyId
+} from '../../../../shared/agent-session-failure-copy'
+import {
+ agentSessionFailureSentence,
+ type AgentSessionFailureWordsContext
+} from '../../../../shared/agent-session-failure-words'
+import { AGENT_SESSION_WRITE_NOTICE_COPY } from '../../../../shared/agent-session-write-notice-copy'
+import { agentSessionWriteNoticeParts } from '../../../../shared/agent-session-refusal-notice'
+import { agentSessionRefusalFailure } from '../../../../shared/agent-session-write-failure'
+import { structuredAgentSessionRejectionParts } from '../../../../shared/structured-agent-session-send-disposition'
+import { sayAgentSessionFailureTranslated } from './agent-session-failure-words-text'
+import { agentSessionWriteNoticeText } from './agent-session-write-notice-text'
+
+vi.mock('@/i18n/i18n', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, translate: vi.fn(actual.translate) }
+})
+
+const IDS = Object.keys(AGENT_SESSION_FAILURE_COPY).filter(
+ (id): id is AgentSessionFailureCopyId => id in AGENT_SESSION_FAILURE_COPY
+)
+// Said by a refusal notice too, so they keep the notice's keys.
+const NOTICE_PIECES: readonly AgentSessionFailureCopyId[] = [
+ 'terminalAgentHoldsChat',
+ 'quitTerminalAgent',
+ 'startNewChat'
+]
+// Kana, and kanji whose simplified Chinese form differs (続 is 续, 読 is 读, ...).
+const JAPANESE_ONLY = /[\u3040-\u30ff続読変済図気帰戻検択転権単圧応対発処実証覧関専]/u
+const VALUES = {
+ agent: 'Claude',
+ command: 'compact',
+ detail: 'Image type .bmp',
+ limit: '20',
+ size: '5'
+}
+
+function factsFor(kind: AgentSessionFailureFact['kind']): AgentSessionFailureFact[] {
+ const facts: AgentSessionFailureFact[] = [
+ { kind },
+ { kind, detail: { text: 'Context window exceeded.', audience: 'person' } },
+ { kind, detail: { text: 'exit 1', audience: 'log' } },
+ // Person words with nothing left once trimmed: the sentence says the lead alone.
+ { kind, detail: { text: ' ... ', audience: 'person' } },
+ { kind, refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } } },
+ { kind, refusal: { code: 'structured_agent_session_unsupported' } },
+ { kind, retry: { error: 'rate_limit', status: 429 } }
+ ]
+ for (const reason of AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS) {
+ facts.push({ kind, attachment: { reason } }, { kind, attachment: { reason, limit: 20 } })
+ }
+ return facts
+}
+
+const CONTEXTS: AgentSessionFailureWordsContext[] = [
+ {},
+ { agentName: 'Codex' },
+ { agentName: 'Claude', command: 'clear' },
+ { retryControl: true },
+ { agentName: 'Claude', command: 'clear', retryControl: true },
+ { agentName: 'Codex', command: 'compact' },
+ { command: 'compact', retryControl: true }
+]
+
+afterEach(async () => {
+ await i18n.changeLanguage('en')
+})
+
+describe('desktop words for a failure fact', () => {
+ it('has a key for every piece, whose English default is the shared sentence', () => {
+ for (const id of IDS) {
+ expect([id, sayAgentSessionFailureTranslated(id, VALUES)]).toEqual([
+ id,
+ sayAgentSessionFailureEnglish(id, VALUES)
+ ])
+ }
+ })
+
+ // The catalog answers first in English, so read the default each key is given directly.
+ it('gives each key the shared sentence as its English default', () => {
+ for (const id of IDS) {
+ vi.mocked(translate).mockClear()
+ sayAgentSessionFailureTranslated(id, VALUES)
+ const section = NOTICE_PIECES.includes(id) ? 'writeNotice' : 'failureWords'
+ expect(vi.mocked(translate).mock.calls.map(([key, fallback]) => [key, fallback])).toEqual([
+ [`components.native-chat.${section}.${id}`, AGENT_SESSION_FAILURE_COPY[id]]
+ ])
+ }
+ })
+
+ it('keeps the English catalog in step with the shared copy', () => {
+ const own = Object.fromEntries(
+ IDS.filter((id) => !NOTICE_PIECES.includes(id)).map((id) => [
+ id,
+ AGENT_SESSION_FAILURE_COPY[id]
+ ])
+ )
+ expect(en.components['native-chat'].failureWords).toEqual(own)
+ for (const id of NOTICE_PIECES) {
+ expect(AGENT_SESSION_WRITE_NOTICE_COPY).toHaveProperty(id, AGENT_SESSION_FAILURE_COPY[id])
+ }
+ })
+
+ it('says every sentence exactly as the host writes it, in English', () => {
+ for (const kind of AGENT_SESSION_FAILURE_KINDS) {
+ for (const fact of factsFor(kind)) {
+ for (const surface of ['row', 'rejection'] as const) {
+ for (const context of CONTEXTS) {
+ expect(
+ agentSessionFailureSentence(fact, surface, context, sayAgentSessionFailureTranslated)
+ ).toBe(agentSessionFailureSentence(fact, surface, context))
+ }
+ }
+ }
+ }
+ })
+
+ it("words a refused start and a rejected message in the reader's language", async () => {
+ await i18n.changeLanguage('fr')
+ const refused = agentSessionRefusalFailure({
+ code: 'agent_session_operation_invalid',
+ details: { reason: 'notSignedIn' }
+ })
+ expect(
+ agentSessionWriteNoticeText(
+ agentSessionWriteNoticeParts(refused, 'send', { agentName: 'Claude' })
+ )
+ ).toBe(
+ "Votre message n'a pas été envoyé. Claude n'est pas connecté avec le compte sélectionné. Connectez-vous, puis renvoyez votre message."
+ )
+ const detail = 'Uses {{agent}} $t(components.native-chat.failureWords.theAgent) & '
+ const rejected = structuredAgentSessionRejectionParts(
+ `The provider did not accept this message: ${detail}.`,
+ 'send',
+ { kind: 'providerRejected', detail: { text: detail, audience: 'person' } },
+ { agentName: 'Codex' }
+ )
+ // The provider's own words stay as written: placeholders, nesting and markup are not read.
+ expect(agentSessionWriteNoticeText(rejected)).toBe(
+ `Le fournisseur n'a pas accepté ce message : ${detail}.`
+ )
+ expect(
+ agentSessionWriteNoticeText(
+ structuredAgentSessionRejectionParts(null, 'send', { kind: 'providerExited' }, {})
+ )
+ ).toBe("L'agent s'est arrêté avant l'envoi de ce message.")
+ })
+
+ it('names the command a failed start was for inside the translated sentence', async () => {
+ const sentence = (kind: AgentSessionFailureFact['kind'], command: 'clear' | 'compact') =>
+ agentSessionFailureSentence(
+ { kind },
+ 'row',
+ { agentName: 'Codex', command },
+ sayAgentSessionFailureTranslated
+ )
+ await i18n.changeLanguage('fr')
+ expect(sentence('restartFailed', 'compact')).toBe(
+ "Codex n'a pas pu redémarrer. Relancez /compact."
+ )
+ expect(sentence('notSignedIn', 'clear')).toBe(
+ "Codex n'est pas connecté avec le compte sélectionné. Connectez-vous, puis relancez /clear."
+ )
+ await i18n.changeLanguage('ja')
+ expect(sentence('providerStartFailed', 'compact')).toBe(
+ 'Codex は起動が完了する前に停止しました。/compact をもう一度実行してください。'
+ )
+ expect(sentence('managedAccountUnsupported', 'compact')).toBe(
+ 'WSL に Claude アカウントが追加されている間、Claude チャットには Windows の Claude アカウントが必要です。Claude アカウントの設定で選択または追加してから、/compact をもう一度実行してください。'
+ )
+ })
+
+ it("says a refused command and a refused Stop in the reader's language", async () => {
+ await i18n.changeLanguage('fr')
+ const sentence = (fact: AgentSessionFailureFact) =>
+ agentSessionFailureSentence(
+ fact,
+ 'row',
+ { agentName: 'Codex' },
+ sayAgentSessionFailureTranslated
+ )
+ expect(sentence({ kind: 'commandRefused' })).toBe(
+ "Cette commande n'a pas été exécutée. Réessayez."
+ )
+ expect(sentence({ kind: 'stopRefused' })).toBe("Codex n'avait aucun tour en cours à arrêter.")
+ expect(
+ sentence({
+ kind: 'stopRefused',
+ detail: { text: 'no active turn to interrupt.', audience: 'person' }
+ })
+ ).toBe("Codex ne s'est pas arrêté : no active turn to interrupt.")
+ })
+
+ it("says an image's size limit in the reader's unit", async () => {
+ await i18n.changeLanguage('fr')
+ const sentence = (reason: 'tooLarge' | 'totalTooLarge', megabytes: number) =>
+ agentSessionFailureSentence(
+ { kind: 'attachmentInvalid', attachment: { reason, limit: megabytes * 1024 * 1024 } },
+ 'rejection',
+ {},
+ sayAgentSessionFailureTranslated
+ )
+ expect(sentence('tooLarge', 5)).toBe(
+ "Une image de ce message dépasse 5 Mo, le message n'a donc pas été envoyé."
+ )
+ expect(sentence('totalTooLarge', 20)).toBe(
+ "Les images de ce message dépassent 20 Mo au total, le message n'a donc pas été envoyé."
+ )
+ })
+
+ it('shows a host sentence with no fact beside it as written', async () => {
+ await i18n.changeLanguage('fr')
+ expect(
+ agentSessionWriteNoticeText(
+ structuredAgentSessionRejectionParts('Claude does not support .bmp images', 'send')
+ )
+ ).toBe('Claude does not support .bmp images')
+ })
+
+ it('keeps Japanese-only characters out of the Chinese words', () => {
+ const chat = zh.components['native-chat']
+ for (const words of [...Object.values(chat.failureWords), chat.state.error.keepsTrying]) {
+ expect(words).not.toMatch(JAPANESE_ONLY)
+ }
+ })
+})
diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts
new file mode 100644
index 000000000000..bfe2c4dca3bc
--- /dev/null
+++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts
@@ -0,0 +1,228 @@
+// Desktop words for a failure fact: each piece of the shared sentence translated whole, with the
+// shared English as its fallback, so the host's row and desktop's notice never say it differently.
+
+import { translate } from '@/i18n/i18n'
+import {
+ AGENT_SESSION_FAILURE_COPY as COPY,
+ type AgentSessionFailureCopyId,
+ type AgentSessionFailureCopyValues,
+ type AgentSessionFailureSay
+} from '../../../../shared/agent-session-failure-copy'
+
+// The pieces a refusal notice says too keep the notice's keys, so each has one translation.
+const PIECES: Record string> =
+ {
+ theAgent: () => translate('components.native-chat.failureWords.theAgent', COPY.theAgent),
+ providerStartFailed: (values) =>
+ translate(
+ 'components.native-chat.failureWords.providerStartFailed',
+ COPY.providerStartFailed,
+ values
+ ),
+ runCommandAgain: (values) =>
+ translate(
+ 'components.native-chat.failureWords.runCommandAgain',
+ COPY.runCommandAgain,
+ values
+ ),
+ sendToTryAgain: () =>
+ translate('components.native-chat.failureWords.sendToTryAgain', COPY.sendToTryAgain),
+ couldNotStart: (values) =>
+ translate('components.native-chat.failureWords.couldNotStart', COPY.couldNotStart, values),
+ couldNotRestart: (values) =>
+ translate(
+ 'components.native-chat.failureWords.couldNotRestart',
+ COPY.couldNotRestart,
+ values
+ ),
+ terminalAgentHoldsChat: () =>
+ translate(
+ 'components.native-chat.writeNotice.terminalAgentHoldsChat',
+ COPY.terminalAgentHoldsChat
+ ),
+ quitTerminalAgent: () =>
+ translate('components.native-chat.writeNotice.quitTerminalAgent', COPY.quitTerminalAgent),
+ startNewChat: () =>
+ translate('components.native-chat.writeNotice.startNewChat', COPY.startNewChat),
+ notSignedIn: (values) =>
+ translate('components.native-chat.failureWords.notSignedIn', COPY.notSignedIn, values),
+ signInFirst: () =>
+ translate('components.native-chat.failureWords.signInFirst', COPY.signInFirst),
+ signInThenRunCommand: (values) =>
+ translate(
+ 'components.native-chat.failureWords.signInThenRunCommand',
+ COPY.signInThenRunCommand,
+ values
+ ),
+ signInThenSend: () =>
+ translate('components.native-chat.failureWords.signInThenSend', COPY.signInThenSend),
+ historyTooLarge: () =>
+ translate('components.native-chat.failureWords.historyTooLarge', COPY.historyTooLarge),
+ managedAccountEnvOverride: () =>
+ translate(
+ 'components.native-chat.failureWords.managedAccountEnvOverride',
+ COPY.managedAccountEnvOverride
+ ),
+ accountSwitchInProgress: () =>
+ translate(
+ 'components.native-chat.failureWords.accountSwitchInProgress',
+ COPY.accountSwitchInProgress
+ ),
+ managedAccountUnsupported: () =>
+ translate(
+ 'components.native-chat.failureWords.managedAccountUnsupported',
+ COPY.managedAccountUnsupported
+ ),
+ chooseClaudeAccount: () =>
+ translate(
+ 'components.native-chat.failureWords.chooseClaudeAccount',
+ COPY.chooseClaudeAccount
+ ),
+ chooseClaudeAccountThenRunCommand: (values) =>
+ translate(
+ 'components.native-chat.failureWords.chooseClaudeAccountThenRunCommand',
+ COPY.chooseClaudeAccountThenRunCommand,
+ values
+ ),
+ chooseClaudeAccountThenSend: () =>
+ translate(
+ 'components.native-chat.failureWords.chooseClaudeAccountThenSend',
+ COPY.chooseClaudeAccountThenSend
+ ),
+ providerExitedRow: (values) =>
+ translate(
+ 'components.native-chat.failureWords.providerExitedRow',
+ COPY.providerExitedRow,
+ values
+ ),
+ providerExitedRejection: (values) =>
+ translate(
+ 'components.native-chat.failureWords.providerExitedRejection',
+ COPY.providerExitedRejection,
+ values
+ ),
+ providerRejected: () =>
+ translate('components.native-chat.failureWords.providerRejected', COPY.providerRejected),
+ providerRejectedQuoted: (values) =>
+ translate(
+ 'components.native-chat.failureWords.providerRejectedQuoted',
+ COPY.providerRejectedQuoted,
+ values
+ ),
+ attachmentEmpty: () =>
+ translate('components.native-chat.failureWords.attachmentEmpty', COPY.attachmentEmpty),
+ attachmentTooLarge: () =>
+ translate('components.native-chat.failureWords.attachmentTooLarge', COPY.attachmentTooLarge),
+ attachmentLargerThan: (values) =>
+ translate(
+ 'components.native-chat.failureWords.attachmentLargerThan',
+ COPY.attachmentLargerThan,
+ values
+ ),
+ attachmentTooMany: () =>
+ translate('components.native-chat.failureWords.attachmentTooMany', COPY.attachmentTooMany),
+ attachmentAtMost: (values) =>
+ translate(
+ 'components.native-chat.failureWords.attachmentAtMost',
+ COPY.attachmentAtMost,
+ values
+ ),
+ attachmentTotalTooLarge: () =>
+ translate(
+ 'components.native-chat.failureWords.attachmentTotalTooLarge',
+ COPY.attachmentTotalTooLarge
+ ),
+ attachmentTotalMoreThan: (values) =>
+ translate(
+ 'components.native-chat.failureWords.attachmentTotalMoreThan',
+ COPY.attachmentTotalMoreThan,
+ values
+ ),
+ attachmentUnsupportedType: (values) =>
+ translate(
+ 'components.native-chat.failureWords.attachmentUnsupportedType',
+ COPY.attachmentUnsupportedType,
+ values
+ ),
+ attachmentNotAFile: () =>
+ translate('components.native-chat.failureWords.attachmentNotAFile', COPY.attachmentNotAFile),
+ attachmentNoSource: () =>
+ translate('components.native-chat.failureWords.attachmentNoSource', COPY.attachmentNoSource),
+ attachmentInvalid: () =>
+ translate('components.native-chat.failureWords.attachmentInvalid', COPY.attachmentInvalid),
+ attachmentUnreadable: () =>
+ translate(
+ 'components.native-chat.failureWords.attachmentUnreadable',
+ COPY.attachmentUnreadable
+ ),
+ emptyMessage: () =>
+ translate('components.native-chat.failureWords.emptyMessage', COPY.emptyMessage),
+ queueFull: () => translate('components.native-chat.failureWords.queueFull', COPY.queueFull),
+ writeFailed: () =>
+ translate('components.native-chat.failureWords.writeFailed', COPY.writeFailed),
+ cancelled: () => translate('components.native-chat.failureWords.cancelled', COPY.cancelled),
+ chatClosed: () => translate('components.native-chat.failureWords.chatClosed', COPY.chatClosed),
+ hostRestarted: () =>
+ translate('components.native-chat.failureWords.hostRestarted', COPY.hostRestarted),
+ notDelivered: () =>
+ translate('components.native-chat.failureWords.notDelivered', COPY.notDelivered),
+ notDeliveredSendAgain: () =>
+ translate(
+ 'components.native-chat.failureWords.notDeliveredSendAgain',
+ COPY.notDeliveredSendAgain
+ ),
+ commandRefused: () =>
+ translate('components.native-chat.failureWords.commandRefused', COPY.commandRefused),
+ commandRefusedTryAgain: () =>
+ translate(
+ 'components.native-chat.failureWords.commandRefusedTryAgain',
+ COPY.commandRefusedTryAgain
+ ),
+ compactionFailed: () =>
+ translate('components.native-chat.failureWords.compactionFailed', COPY.compactionFailed),
+ compactionFailedQuoted: (values) =>
+ translate(
+ 'components.native-chat.failureWords.compactionFailedQuoted',
+ COPY.compactionFailedQuoted,
+ values
+ ),
+ compactionUnconfirmed: () =>
+ translate(
+ 'components.native-chat.failureWords.compactionUnconfirmed',
+ COPY.compactionUnconfirmed
+ ),
+ cancelUnconfirmed: () =>
+ translate('components.native-chat.failureWords.cancelUnconfirmed', COPY.cancelUnconfirmed),
+ stopRefused: (values) =>
+ translate('components.native-chat.failureWords.stopRefused', COPY.stopRefused, values),
+ stopRefusedQuoted: (values) =>
+ translate(
+ 'components.native-chat.failureWords.stopRefusedQuoted',
+ COPY.stopRefusedQuoted,
+ values
+ ),
+ noTurnToStop: (values) =>
+ translate('components.native-chat.failureWords.noTurnToStop', COPY.noTurnToStop, values),
+ answerUnconfirmed: () =>
+ translate('components.native-chat.failureWords.answerUnconfirmed', COPY.answerUnconfirmed),
+ hostFault: () => translate('components.native-chat.failureWords.hostFault', COPY.hostFault),
+ hostFaultTryAgain: () =>
+ translate('components.native-chat.failureWords.hostFaultTryAgain', COPY.hostFaultTryAgain),
+ hostStopped: (values) =>
+ translate('components.native-chat.failureWords.hostStopped', COPY.hostStopped, values),
+ providerRateLimited: (values) =>
+ translate(
+ 'components.native-chat.failureWords.providerRateLimited',
+ COPY.providerRateLimited,
+ values
+ ),
+ providerRetrying: (values) =>
+ translate(
+ 'components.native-chat.failureWords.providerRetrying',
+ COPY.providerRetrying,
+ values
+ )
+ }
+
+export const sayAgentSessionFailureTranslated: AgentSessionFailureSay = (id, values = {}) =>
+ PIECES[id](values)
diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.test.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.test.ts
index 260c910d01e2..e5e2c9f7a337 100644
--- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.test.ts
+++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.test.ts
@@ -1,9 +1,15 @@
import { afterEach, describe, expect, it } from 'vitest'
-import { i18n } from '@/i18n/i18n'
+import { i18n, setRendererPluginLanguagePacks } from '@/i18n/i18n'
import en from '@/i18n/locales/en.json'
+import fr from '@/i18n/locales/fr.json'
+import ja from '@/i18n/locales/ja.json'
+import ko from '@/i18n/locales/ko.json'
+import zh from '@/i18n/locales/zh.json'
+import { pluginLanguageResourceId } from '../../../../shared/plugins/plugin-language-pack-artifact'
import { agentSessionWriteNoticeEnglish } from '../../../../shared/agent-session-refusal-notice'
import {
AGENT_SESSION_WRITE_NOTICE_COPY,
+ type AgentSessionWriteNoticePart,
type AgentSessionWriteNoticeSentence
} from '../../../../shared/agent-session-write-notice-copy'
import { agentSessionWriteNoticeText } from './agent-session-write-notice-text'
@@ -13,6 +19,7 @@ const SENTENCES = Object.keys(AGENT_SESSION_WRITE_NOTICE_COPY).filter(
)
afterEach(async () => {
+ setRendererPluginLanguagePacks([])
await i18n.changeLanguage('en')
})
@@ -38,4 +45,62 @@ describe('desktop words for a write that did not happen', () => {
agentSessionWriteNoticeText([{ text: 'Claude messages support at most 20 images' }])
).toBe('Claude messages support at most 20 images')
})
+
+ it('runs sentences on in Japanese and Chinese, and spaces them in other languages', async () => {
+ // Two sentences from the notice, and two the failure words join inside their own sentence.
+ const parts: AgentSessionWriteNoticePart[] = [
+ 'notDoneSend',
+ { failure: { kind: 'startFailed' }, surface: 'rejection', context: { agentName: 'Claude' } }
+ ]
+ for (const [language, catalog, gap] of [
+ ['ja', ja, ''],
+ ['zh', zh, ''],
+ ['en', en, ' '],
+ ['fr', fr, ' '],
+ ['ko', ko, ' ']
+ ] as const) {
+ await i18n.changeLanguage(language)
+ const chat = catalog.components['native-chat']
+ expect(agentSessionWriteNoticeText(parts)).toBe(
+ [
+ chat.writeNotice.notDoneSend,
+ chat.failureWords.couldNotStart.replace('{{agent}}', 'Claude'),
+ chat.failureWords.sendToTryAgain
+ ].join(gap)
+ )
+ }
+ })
+
+ it("runs on after a plugin language's full stop, and keeps the space after English it left", async () => {
+ const id = 'plugin:example.chinese/zh-TW' as const
+ setRendererPluginLanguagePacks([
+ {
+ id,
+ resourceLanguage: pluginLanguageResourceId(id),
+ pluginKey: 'example.chinese',
+ locale: 'zh-TW',
+ catalog: {
+ components: {
+ 'native-chat': {
+ writeNotice: {
+ restartFailed: '智能體無法重新啟動。',
+ notDoneSend: '您的訊息未傳送。'
+ }
+ }
+ }
+ }
+ }
+ ])
+ await i18n.changeLanguage(pluginLanguageResourceId(id))
+ expect(agentSessionWriteNoticeText(['restartFailed', 'notDoneSend'])).toBe(
+ '智能體無法重新啟動。您的訊息未傳送。'
+ )
+ // The pack predates the failure words, so they fall back to English.
+ expect(
+ agentSessionWriteNoticeText([
+ 'notDoneSend',
+ { failure: { kind: 'startFailed' }, surface: 'rejection', context: { agentName: 'Claude' } }
+ ])
+ ).toBe("您的訊息未傳送。Claude couldn't start. Send your message to try again.")
+ })
})
diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts
index 14d265256d2d..e584429ddfbf 100644
--- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts
+++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts
@@ -2,6 +2,7 @@
// shared English as its fallback so desktop and mobile never say it differently.
import { translate } from '@/i18n/i18n'
+import { agentSessionFailureSentence } from '../../../../shared/agent-session-failure-words'
import { agentSessionWriteNoticeParts } from '../../../../shared/agent-session-refusal-notice'
import {
AGENT_SESSION_WRITE_NOTICE_COPY as COPY,
@@ -12,6 +13,8 @@ import type {
AgentSessionWriteFailure,
AgentSessionWriteKind
} from '../../../../shared/agent-session-write-failure'
+import { joinSentences } from '../../../../shared/sentence-joining'
+import { sayAgentSessionFailureTranslated } from './agent-session-failure-words-text'
const SENTENCES: Record string> = {
notDoneReadHistory: () =>
@@ -120,7 +123,20 @@ const SENTENCES: Record string> = {
}
export function agentSessionWriteNoticeText(parts: readonly AgentSessionWriteNoticePart[]): string {
- return parts.map((part) => (typeof part === 'string' ? SENTENCES[part]() : part.text)).join(' ')
+ return joinSentences(
+ parts.map((part) =>
+ typeof part === 'string'
+ ? SENTENCES[part]()
+ : 'text' in part
+ ? part.text
+ : agentSessionFailureSentence(
+ part.failure,
+ part.surface,
+ part.context,
+ sayAgentSessionFailureTranslated
+ )
+ )
+ )
}
export function agentSessionWriteFailureText(
diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts
index 6ccc98b45267..b50122f5108c 100644
--- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts
+++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts
@@ -6,6 +6,7 @@ import type {
} from '../../../../shared/agent-session-journal-types'
import {
createStructuredAgentSessionOutboxEntry,
+ structuredAgentSessionRejectedFailure,
type StructuredAgentSessionOutboxEntry
} from '../../../../shared/structured-agent-session-outbox'
import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words'
@@ -331,6 +332,52 @@ describe('the notice on each message that did not go through', () => {
})
})
+ it("shows the host's sentence when this build cannot read all of a rejection's fact", () => {
+ const reason = "Claude couldn't start. Start a new chat to continue."
+ const copiedReason = 'An image on this message uses a newer check.'
+ // As a newer host sends them: a known code with a new reason, and a new attachment reason.
+ const newerStart = JSON.parse(
+ '{ "kind": "startFailed", "refusal": { "code": "agent_session_conflict", "details": { "reason": "newerReason" } } }'
+ )
+ const newerAttachment = JSON.parse(
+ '{ "kind": "attachmentInvalid", "attachment": { "reason": "newerReason" } }'
+ )
+ expect(
+ texts(
+ [
+ entry('recorded', {
+ state: 'rejected',
+ lastFailure: structuredAgentSessionRejectedFailure({ reason, rejection: newerStart })
+ }),
+ entry('copied', {
+ state: 'rejected',
+ lastFailure: structuredAgentSessionRejectedFailure({
+ reason: copiedReason,
+ rejection: newerAttachment
+ })
+ })
+ ],
+ null,
+ [
+ {
+ clientMessageId: 'recorded',
+ fence: 1,
+ payloadFingerprint: 'fingerprint',
+ dispatchState: 'rejected',
+ providerItemId: null,
+ reason,
+ rejection: newerStart,
+ submittedAt: 1,
+ resolvedAt: 1
+ }
+ ]
+ )
+ ).toEqual({
+ [agentJournalSubmissionKey('recorded')]: reason,
+ [agentJournalSubmissionKey('copied')]: copiedReason
+ })
+ })
+
it('says nothing on a message that is only waiting its turn or on its way', () => {
expect(texts([entry('queued'), entry('sending', { state: 'dispatching' })])).toEqual({})
})
diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts
index cccc3d7de3d3..7cf515cc75a2 100644
--- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts
+++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts
@@ -13,6 +13,7 @@
import {
readAgentSessionFailureFact,
+ readWholeAgentSessionFailureFact,
type AgentSessionFailureFact
} from '../../../../shared/agent-session-failure'
import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key'
@@ -66,6 +67,18 @@ export function sameAgentSessionFailureFact(
)
}
+/** Whether a loaded start-failure row already states this failure. Matching is identity, not
+ * wording: what this build can read is enough. */
+export function agentSessionFailureStatedByStartRow(
+ failure: unknown,
+ startFailures: readonly AgentSessionFailureFact[]
+): boolean {
+ const fact = readAgentSessionFailureFact(failure)
+ return (
+ fact !== undefined && startFailures.some((stated) => sameAgentSessionFailureFact(stated, fact))
+ )
+}
+
function deliveryNoticeText(
entry: StructuredAgentSessionOutboxEntry,
context: AgentSessionFailureWordsContext,
@@ -87,16 +100,18 @@ function deliveryNoticeText(
'Message was not sent.'
)
}
- const fact = readAgentSessionFailureFact(recorded?.rejection)
if (
entry.state === 'rejected' &&
- fact &&
- startFailures.some((stated) => sameAgentSessionFailureFact(stated, fact))
+ agentSessionFailureStatedByStartRow(recorded?.rejection, startFailures)
) {
return agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send'))
}
return agentSessionWriteNoticeText(
- structuredAgentSessionAttemptFailureParts(entry.lastFailure, context, fact)
+ structuredAgentSessionAttemptFailureParts(
+ entry.lastFailure,
+ context,
+ readWholeAgentSessionFailureFact(recorded?.rejection)
+ )
)
}
diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-failure-notice.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-failure-notice.ts
index 8dee55de82e6..256303c4514e 100644
--- a/src/renderer/src/components/native-chat/structured-agent-session-read-failure-notice.ts
+++ b/src/renderer/src/components/native-chat/structured-agent-session-read-failure-notice.ts
@@ -5,6 +5,7 @@ import {
agentSessionReadHistoryRefusalParts,
agentSessionWriteNotDoneParts
} from '../../../../shared/agent-session-refusal-notice'
+import { agentSessionNoticeSaysThisChatUnread } from '../../../../shared/agent-session-write-notice-copy'
import type { AgentSessionRefusalReference } from '../../../../shared/agent-session-wire-refusals'
import { isFinalAgentSessionReadRefusal } from '../../../../shared/structured-agent-session-read-refusal'
import { agentSessionWriteNoticeText } from './agent-session-write-notice-text'
@@ -15,6 +16,8 @@ export type StructuredAgentSessionReadFailureNotice = {
named: boolean
/** Nothing the read retries gets past it, so the pane no longer says it keeps trying. */
final: boolean
+ /** The words already say this chat's history didn't load, so the pane need not say it again. */
+ saysUnread: boolean
}
export function structuredAgentSessionReadFailureNotice(
@@ -27,6 +30,7 @@ export function structuredAgentSessionReadFailureNotice(
return {
text: agentSessionWriteNoticeText(parts),
named: refusal?.details?.reason !== undefined,
- final: isFinalAgentSessionReadRefusal(refusal)
+ final: isFinalAgentSessionReadRefusal(refusal),
+ saysUnread: agentSessionNoticeSaysThisChatUnread(parts)
}
}
diff --git a/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts b/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts
new file mode 100644
index 000000000000..498e9d4fc011
--- /dev/null
+++ b/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts
@@ -0,0 +1,178 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import { i18n } from '@/i18n/i18n'
+import type { AgentSessionConversationCommandResult } from '../../../../shared/agent-session-conversation-command'
+import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure'
+import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words'
+import { TUI_AGENT_DISPLAY_NAMES } from '../../../../shared/tui-agent-display-names'
+import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label'
+import { sendStructuredConversationCommand } from './structured-conversation-command-send'
+
+const START_FAILED: AgentSessionFailureFact = { kind: 'startFailed' }
+const COMPACTION_FAILED: AgentSessionFailureFact = {
+ kind: 'compactionFailed',
+ detail: { text: 'Context is too short', audience: 'person' }
+}
+
+// The facts a command whose conversation did not start can carry: a /clear's new one, or the
+// start a /compact waited on.
+const START_FACTS: AgentSessionFailureFact[] = [
+ START_FAILED,
+ { kind: 'restartFailed' },
+ { kind: 'startFailed', refusal: { code: 'structured_agent_session_unsupported' } },
+ {
+ kind: 'startFailed',
+ refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } }
+ },
+ { kind: 'providerStartFailed' },
+ { kind: 'notSignedIn' },
+ { kind: 'historyTooLarge' },
+ { kind: 'managedAccountUnsupported' },
+ { kind: 'managedAccountEnvOverride' },
+ { kind: 'accountSwitchInProgress' }
+]
+const COMPACT_FACTS: AgentSessionFailureFact[] = [
+ COMPACTION_FAILED,
+ { kind: 'commandRefused' },
+ { kind: 'compactionFailed' },
+ { kind: 'compactionUnconfirmed' }
+]
+
+// What the host returns: its English, worded with the context it uses, beside the fact.
+function hostResult(
+ command: 'clear' | 'compact',
+ failure: AgentSessionFailureFact,
+ provider: 'claude' | 'codex' = 'claude'
+): AgentSessionConversationCommandResult {
+ const words = agentSessionFailureWords(failure, {
+ agentName: TUI_AGENT_DISPLAY_NAMES[provider],
+ command,
+ surface: 'row'
+ })
+ return { command, state: 'completed', error: words.text, failure: words.failure }
+}
+
+async function sent(
+ result: AgentSessionConversationCommandResult,
+ provider: 'claude' | 'codex' = 'claude'
+) {
+ return sendStructuredConversationCommand({
+ command: result.command,
+ agentName: structuredAgentLabel(provider),
+ pending: { current: false },
+ blocked: false,
+ startFailures: () => [],
+ send: async () => ({ kind: 'done', value: result })
+ })
+}
+
+afterEach(async () => {
+ await i18n.changeLanguage('en')
+})
+
+describe('the line under the composer after a conversation command failed', () => {
+ it('says in English exactly what the host wrote, for every failure a command reports', async () => {
+ for (const provider of ['claude', 'codex'] as const) {
+ for (const result of [
+ ...START_FACTS.map((fact) => hostResult('clear', fact, provider)),
+ ...START_FACTS.map((fact) => hostResult('compact', fact, provider)),
+ ...COMPACT_FACTS.map((fact) => hostResult('compact', fact, provider))
+ ]) {
+ expect(await sent(result, provider)).toEqual({ accepted: false, error: result.error })
+ }
+ }
+ // The host's own tests pin these sentences.
+ expect((await sent(hostResult('clear', START_FAILED, 'codex'), 'codex')).error).toBe(
+ "Codex couldn't start. Run /clear again."
+ )
+ expect(
+ (await sent(hostResult('compact', { kind: 'restartFailed' }, 'codex'), 'codex')).error
+ ).toBe("Codex couldn't restart. Run /compact again.")
+ expect((await sent(hostResult('compact', { kind: 'notSignedIn' }), 'claude')).error).toBe(
+ 'Claude is not signed in for the selected account. Sign in, then run /compact again.'
+ )
+ expect((await sent(hostResult('clear', START_FACTS[2], 'codex'), 'codex')).error).toBe(
+ "Codex couldn't start. Start a new chat to continue."
+ )
+ expect((await sent(hostResult('clear', { kind: 'notSignedIn' }, 'codex'), 'codex')).error).toBe(
+ 'Codex is not signed in for the selected account. Sign in, then run /clear again.'
+ )
+ })
+
+ it("says it in the reader's language", async () => {
+ await i18n.changeLanguage('fr')
+ expect((await sent(hostResult('clear', START_FAILED))).error).toBe(
+ "Claude n'a pas pu démarrer. Relancez /clear."
+ )
+ expect((await sent(hostResult('compact', COMPACTION_FAILED))).error).toBe(
+ 'La compaction a échoué : Context is too short.'
+ )
+ expect((await sent(hostResult('compact', { kind: 'restartFailed' }))).error).toBe(
+ "Claude n'a pas pu redémarrer. Relancez /compact."
+ )
+ expect((await sent(hostResult('compact', { kind: 'managedAccountUnsupported' }))).error).toBe(
+ "Tant qu'un compte Claude est ajouté dans WSL, les chats Claude nécessitent un compte Claude Windows. Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis relancez /compact."
+ )
+ await i18n.changeLanguage('ja')
+ expect((await sent(hostResult('clear', START_FAILED))).error).toBe(
+ 'Claude を起動できませんでした。/clear をもう一度実行してください。'
+ )
+ expect((await sent(hostResult('compact', { kind: 'notSignedIn' }))).error).toBe(
+ 'Claude は選択したアカウントでサインインしていません。サインインしてから、/compact をもう一度実行してください。'
+ )
+ })
+
+ it("shows the host's sentence as written when this build cannot read all of its fact", async () => {
+ await i18n.changeLanguage('fr')
+ const error = "Claude couldn't start. Start a new chat to continue."
+ // As a newer host sends them: a refusal code, a refusal reason, and a part this build doesn't know.
+ for (const failure of [
+ JSON.parse('{ "kind": "startFailed", "refusal": { "code": "agent_session_newer_refusal" } }'),
+ JSON.parse(
+ '{ "kind": "startFailed", "refusal": { "code": "agent_session_conflict", "details": { "reason": "newerReason" } } }'
+ ),
+ JSON.parse('{ "kind": "startFailed", "newerPart": { "reason": "unresumable" } }')
+ ]) {
+ expect(await sent({ command: 'clear', state: 'completed', error, failure })).toEqual({
+ accepted: false,
+ error
+ })
+ }
+ expect((await sent(hostResult('clear', START_FACTS[2]))).error).toBe(
+ "Claude n'a pas pu démarrer. Démarrez un nouveau chat pour continuer."
+ )
+ })
+
+ it("shows the host's sentence as written for a command this build doesn't know", async () => {
+ await i18n.changeLanguage('fr')
+ const error = "Claude couldn't start. Run /rewind again."
+ // A newer host's command, with a fact this build reads whole and a loaded row stating it.
+ const result: AgentSessionConversationCommandResult = JSON.parse(
+ `{ "command": "rewind", "state": "completed", "error": ${JSON.stringify(error)}, "failure": { "kind": "startFailed" } }`
+ )
+ expect(
+ await sendStructuredConversationCommand({
+ command: 'compact',
+ agentName: 'Claude',
+ pending: { current: false },
+ blocked: false,
+ startFailures: () => [START_FAILED],
+ send: async () => ({ kind: 'done', value: result })
+ })
+ ).toEqual({ accepted: false, error })
+ })
+
+ it("shows an older host's sentence as written when it sent no fact", async () => {
+ await i18n.changeLanguage('fr')
+ expect(
+ await sent({
+ command: 'clear',
+ state: 'completed',
+ error: "Claude couldn't start. Run /clear again."
+ })
+ ).toEqual({ accepted: false, error: "Claude couldn't start. Run /clear again." })
+ expect(await sent({ command: 'compact', state: 'completed' })).toEqual({
+ accepted: true,
+ error: null
+ })
+ })
+})
diff --git a/src/renderer/src/components/native-chat/structured-conversation-command-send.ts b/src/renderer/src/components/native-chat/structured-conversation-command-send.ts
index 51575e4ae03a..23c1fa3b93c7 100644
--- a/src/renderer/src/components/native-chat/structured-conversation-command-send.ts
+++ b/src/renderer/src/components/native-chat/structured-conversation-command-send.ts
@@ -1,14 +1,26 @@
-import type {
- AgentSessionConversationCommand,
- AgentSessionConversationCommandResult
+import {
+ isAgentSessionConversationCommand,
+ type AgentSessionConversationCommand,
+ type AgentSessionConversationCommandResult
} from '../../../../shared/agent-session-conversation-command'
+import {
+ readWholeAgentSessionFailureFact,
+ type AgentSessionFailureFact
+} from '../../../../shared/agent-session-failure'
+import { agentSessionFailureSentence } from '../../../../shared/agent-session-failure-words'
import { translate } from '@/i18n/i18n'
+import { sayAgentSessionFailureTranslated } from './agent-session-failure-words-text'
+import { agentSessionFailureStatedByStartRow } from './structured-agent-session-delivery-notices'
import type { StructuredAgentSessionWriteOutcome } from './use-structured-agent-session-mutate'
export async function sendStructuredConversationCommand(input: {
command: AgentSessionConversationCommand
+ /** The chat's agent, as a failed command names it. */
+ agentName: string
pending: { current: boolean }
blocked: boolean
+ /** What the chat's loaded start-failure rows state, read when the reply lands. */
+ startFailures: () => readonly AgentSessionFailureFact[]
send: (
command: AgentSessionConversationCommand
) => Promise>
@@ -28,23 +40,49 @@ export async function sendStructuredConversationCommand(input: {
if (outcome.kind === 'not-done') {
return { accepted: false, error: outcome.notice }
}
- const result = outcome.kind === 'done' ? outcome.value : null
- return {
- accepted: result?.state === 'completed' && !result.error,
- error:
- result?.error ??
- (result
- ? null
- : translate(
- 'components.native-chat.conversationCommand.unconfirmed',
- 'Conversation operation was not confirmed.'
- ))
+ // The pane stopped waiting on this reply (closed, left the chat, or sent a newer command).
+ if (outcome.kind === 'dropped') {
+ return { accepted: false, error: null }
+ }
+ const { value } = outcome
+ // The chat's own start failed and its loaded row already says why, as for a message that start
+ // rejected. A /clear's failed start is its new chat's, whose row this pane never shows, and a
+ // command this build doesn't know may be either, so its host's words are shown.
+ if (
+ isAgentSessionConversationCommand(value.command) &&
+ value.command !== 'clear' &&
+ agentSessionFailureStatedByStartRow(value.failure, input.startFailures())
+ ) {
+ return { accepted: false, error: null }
}
+ const error = conversationCommandFailureText(value, input.agentName)
+ return { accepted: value.state === 'completed' && !error, error }
} finally {
input.pending.current = false
}
}
+/** The host's sentence in the reader's language, from the fact beside it; with no fact (an older
+ * host), one this build can't read whole, or a command it doesn't know, the sentence as written. */
+function conversationCommandFailureText(
+ result: AgentSessionConversationCommandResult,
+ agentName: string
+): string | null {
+ const fact = isAgentSessionConversationCommand(result.command)
+ ? readWholeAgentSessionFailureFact(result.failure)
+ : undefined
+ if (!fact) {
+ return result.error ?? null
+ }
+ // As the host words it: naming the chat's agent and the command a failed start was for.
+ return agentSessionFailureSentence(
+ fact,
+ 'row',
+ { agentName, command: result.command },
+ sayAgentSessionFailureTranslated
+ )
+}
+
export function isUnconfirmedConversationCommand(method: string, value: unknown): boolean {
return (
method === 'agentSession.conversationCommand' &&
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx
new file mode 100644
index 000000000000..8700678c030e
--- /dev/null
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx
@@ -0,0 +1,330 @@
+// @vitest-environment happy-dom
+
+import { act, renderHook } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } 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'
+
+const mocks = vi.hoisted(() => ({ call: vi.fn(), toastError: vi.fn() }))
+let fence = 3
+let items: AgentJournalRenderItem[] = []
+
+vi.mock('sonner', () => ({ toast: { error: mocks.toastError, message: vi.fn() } }))
+
+vi.mock('@/runtime/structured-agent-session-client', () => ({
+ callStructuredAgentSession: mocks.call
+}))
+
+vi.mock('./use-structured-agent-session-read', () => ({
+ useStructuredAgentSessionRead: () => ({
+ state: {
+ fence,
+ items,
+ submissions: [],
+ status: 'ready',
+ error: null,
+ hasOlder: false,
+ handoff: null
+ },
+ loadingOlder: false,
+ loadOlder: vi.fn()
+ })
+}))
+
+vi.mock('./use-structured-agent-session-outbox', () => ({
+ structuredSessionOperationId: () => 'operation-1',
+ useStructuredAgentSessionOutbox: () => ({
+ outbox: [],
+ blockedClientMessageId: null,
+ error: null,
+ send: vi.fn(),
+ retry: vi.fn()
+ })
+}))
+
+import { i18n } from '@/i18n/i18n'
+import { useStructuredAgentSession } from './use-structured-agent-session'
+import { useStructuredAgentSessionMutate } from './use-structured-agent-session-mutate'
+
+type Pane = { sessionId: string; transportEnabled: boolean }
+
+const START_FAILED: AgentSessionFailureFact = { kind: 'startFailed' }
+const RESTART_FAILED: AgentSessionFailureFact = { kind: 'restartFailed' }
+const NOT_SIGNED_IN: AgentSessionFailureFact = { kind: 'notSignedIn' }
+
+function startFailureRow(fact: AgentSessionFailureFact): AgentJournalRenderItem {
+ return {
+ itemId: agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('start-1')),
+ revision: 1,
+ sequence: 1,
+ observedAt: 1,
+ body: {
+ kind: 'status',
+ tone: 'error',
+ ...agentSessionFailureWords(fact, { agentName: 'Codex', surface: 'row' })
+ }
+ }
+}
+
+function commandReply(
+ command: 'clear' | 'compact',
+ failure?: AgentSessionFailureFact
+): { ok: true; replayed: false; fence: number; value: unknown } {
+ const words = failure
+ ? agentSessionFailureWords(failure, { agentName: 'Codex', command, surface: 'row' })
+ : undefined
+ return {
+ ok: true,
+ replayed: false,
+ fence: 3,
+ value: {
+ command,
+ state: 'completed',
+ ...(words ? { error: words.text, failure: words.failure } : {})
+ }
+ }
+}
+
+// Holds every write open until the test answers it, so the fence can move in between.
+function heldWrites(): (reply: unknown) => void {
+ let answer: (value: unknown) => void = () => {}
+ mocks.call.mockImplementation((_target: unknown, method: string) =>
+ method === 'agentSession.conversationCommand' || method === 'agentSession.cancel'
+ ? new Promise((resolve) => {
+ answer = resolve
+ })
+ : Promise.resolve(null)
+ )
+ return (reply) => answer(reply)
+}
+
+function renderPane() {
+ return renderHook(
+ (pane: Pane) =>
+ useStructuredAgentSession({
+ sessionId: pane.sessionId,
+ agent: 'codex',
+ target: { kind: 'local' },
+ isVisible: true,
+ transportEnabled: pane.transportEnabled
+ }),
+ { initialProps: { sessionId: 'session-1', transportEnabled: true } }
+ )
+}
+
+// The fence moves while the command is in flight, as a start during the command does, and then
+// the command's reply lands. The reply still answers what this pane asked.
+async function commandAcrossFenceMove(
+ command: 'clear' | 'compact',
+ reply: unknown,
+ pane: Pane = { sessionId: 'session-1', transportEnabled: true },
+ rowsAfterStart: AgentJournalRenderItem[] = items
+): Promise<{ accepted: boolean; error: string | null }> {
+ const answer = heldWrites()
+ const { result, rerender } = renderPane()
+ let sent: Promise<{ accepted: boolean; error: string | null }> = Promise.resolve({
+ accepted: true,
+ error: null
+ })
+ act(() => {
+ sent = result.current.runConversationCommand(command)
+ })
+ fence = 5
+ items = rowsAfterStart
+ rerender(pane)
+ await act(async () => {
+ answer(reply)
+ await sent
+ })
+ return sent
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ fence = 3
+ items = []
+})
+
+afterEach(async () => {
+ await i18n.changeLanguage('en')
+})
+
+describe('a conversation command whose reply lands after the fence moved', () => {
+ it("shows, in the reader's language, why a start during the command failed", async () => {
+ await i18n.changeLanguage('fr')
+ // A /clear's start is its new chat's; a /compact's restarts this one.
+ for (const [command, failure, error] of [
+ ['clear', START_FAILED, "Codex n'a pas pu démarrer. Relancez /clear."],
+ ['compact', RESTART_FAILED, "Codex n'a pas pu redémarrer. Relancez /compact."]
+ ] as const) {
+ fence = 3
+ expect(await commandAcrossFenceMove(command, commandReply(command, failure))).toEqual({
+ accepted: false,
+ error
+ })
+ }
+ })
+
+ it('clears the draft for a /clear that completed', async () => {
+ expect(await commandAcrossFenceMove('clear', commandReply('clear'))).toEqual({
+ accepted: true,
+ error: null
+ })
+ })
+
+ it("says a /clear's failure even when an equal start row is loaded: that row is not the /clear's", async () => {
+ await i18n.changeLanguage('fr')
+ items = [startFailureRow(NOT_SIGNED_IN)]
+ const outcome = await commandAcrossFenceMove('clear', commandReply('clear', NOT_SIGNED_IN))
+ expect(outcome.accepted).toBe(false)
+ expect(outcome.error).toMatch(/^Codex n'est pas connecté/)
+ expect(outcome.error).toContain('/clear')
+ })
+
+ it('clears the draft for a /compact that started', async () => {
+ expect(await commandAcrossFenceMove('compact', commandReply('compact'))).toEqual({
+ accepted: true,
+ error: null
+ })
+ })
+
+ it("says nothing for a /compact whose start failed, when that start's row is loaded", async () => {
+ // The row arrives with the new fence, after the command was sent.
+ expect(
+ await commandAcrossFenceMove('compact', commandReply('compact', RESTART_FAILED), undefined, [
+ startFailureRow(RESTART_FAILED)
+ ])
+ ).toEqual({ accepted: false, error: null })
+ })
+
+ it("says why a /compact's start failed while its row is not loaded", async () => {
+ expect(
+ await commandAcrossFenceMove('compact', commandReply('compact', RESTART_FAILED))
+ ).toEqual({ accepted: false, error: "Codex couldn't restart. Run /compact again." })
+ })
+
+ it("shows the host's refusal of a command that arrives after the fence moved", async () => {
+ const outcome = await commandAcrossFenceMove('compact', {
+ ok: false,
+ refusal: {
+ code: 'agent_session_conflict',
+ message: 'Orca log text.',
+ retryable: false
+ }
+ })
+ expect(outcome).toEqual({ accepted: false, error: "The command didn't run." })
+ })
+
+ it('keeps the reply of a /clear that stopped the running agent: done clears the draft, failed says why', async () => {
+ // A live runtime at fence 3; stopping it for the new chat moves the fence during the request.
+ expect(await commandAcrossFenceMove('clear', commandReply('clear'))).toEqual({
+ accepted: true,
+ error: null
+ })
+ fence = 3
+ expect(await commandAcrossFenceMove('clear', commandReply('clear', NOT_SIGNED_IN))).toEqual({
+ accepted: false,
+ error: expect.stringMatching(/^Codex is not signed in.*\/clear/)
+ })
+ })
+
+ it('drops a reply the pane stopped waiting on: it left the chat and came back', async () => {
+ const answer = heldWrites()
+ const { result, rerender } = renderPane()
+ let sent: Promise<{ accepted: boolean; error: string | null }> = Promise.resolve({
+ accepted: true,
+ error: null
+ })
+ act(() => {
+ sent = result.current.runConversationCommand('clear')
+ })
+ fence = 5
+ rerender({ sessionId: 'session-2', transportEnabled: true })
+ rerender({ sessionId: 'session-1', transportEnabled: true })
+ await act(async () => {
+ answer(commandReply('clear', NOT_SIGNED_IN))
+ await sent
+ })
+ expect(await sent).toEqual({ accepted: false, error: null })
+ })
+
+ it('drops a reply the pane stopped waiting on: a newer command replaced it', async () => {
+ const answers: ((reply: unknown) => void)[] = []
+ mocks.call.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ answers.push(resolve)
+ })
+ )
+ const stateRef: { current: { fence: number | null } } = { current: { fence: 3 } }
+ const { result } = renderHook(() =>
+ useStructuredAgentSessionMutate({
+ sessionId: 'session-1',
+ target: { kind: 'local' },
+ stateRef
+ })
+ )
+ const command = (): Promise =>
+ result.current.write('agentSession.conversationCommand', 'agentSession.conversationCommand', {
+ command: 'clear'
+ })
+ const older = command()
+ const newer = command()
+ stateRef.current.fence = 5
+ await act(async () => {
+ answers[0](commandReply('clear', NOT_SIGNED_IN))
+ answers[1](commandReply('clear'))
+ await Promise.all([older, newer])
+ })
+ expect(await older).toEqual({ kind: 'dropped' })
+ expect(await newer).toEqual({ kind: 'done', value: commandReply('clear').value })
+ })
+
+ it('drops a reply once the pane has moved to another chat or closed', async () => {
+ for (const pane of [
+ { sessionId: 'session-2', transportEnabled: true },
+ { sessionId: 'session-1', transportEnabled: false }
+ ]) {
+ fence = 3
+ expect(
+ await commandAcrossFenceMove('clear', commandReply('clear', NOT_SIGNED_IN), pane)
+ ).toEqual({ accepted: false, error: null })
+ }
+ })
+})
+
+describe('any other write across a fence move', () => {
+ it('drops the result: it answered for the runtime this pane replaced', async () => {
+ const answer = heldWrites()
+ const { result, rerender } = renderPane()
+ let stopped: Promise = Promise.resolve('unset')
+ act(() => {
+ stopped = result.current.cancel('turn-1')
+ })
+ fence = 5
+ rerender({ sessionId: 'session-1', transportEnabled: true })
+ await act(async () => {
+ answer({ ok: true, replayed: false, fence: 3, value: { cancelled: true } })
+ await stopped
+ })
+ expect(await stopped).toBeNull()
+ expect(mocks.toastError).not.toHaveBeenCalled()
+ })
+
+ it('keeps a result the same fence answers', async () => {
+ const answer = heldWrites()
+ const { result } = renderPane()
+ let stopped: Promise = Promise.resolve('unset')
+ act(() => {
+ stopped = result.current.cancel('turn-1')
+ })
+ await act(async () => {
+ answer({ ok: true, replayed: false, fence: 3, value: { cancelled: true } })
+ await stopped
+ })
+ expect(await stopped).toEqual({ cancelled: true })
+ })
+})
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 57b7acdaf093..a9ea417cda69 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
@@ -4,8 +4,10 @@
// the same request reuses it and the host upserts one row instead of two (a
// payload naming no target gets a new one on every call), and
// every result is discarded unless the runtime fence it was issued against is
-// still the current one. A write that did not happen is reported once, in the
-// person's words, by the caller that knows where to say it; nothing latches.
+// still the current one; the reply to the conversation command a pane still
+// waits on is kept, since that command may move the fence itself. A write that
+// did not happen is reported once, in the person's words, by the caller that
+// knows where to say it; nothing latches.
import { useCallback, useEffect, useRef } from 'react'
import { toast } from 'sonner'
@@ -60,10 +62,14 @@ export function useStructuredAgentSessionMutate(args: {
const { enabled = true, sessionId, stateRef, target } = args
const operationIds = useRef(new Map())
const enabledRef = useRef(enabled)
+ // The conversation command this pane still waits on; only its reply may outlive a fence move. A
+ // newer command replaces it, and disabling the pane forgets it.
+ const commandInFlight = useRef(null)
useEffect(() => {
// Why: update the gate after commit so render stays free of ref mutations.
enabledRef.current = enabled
- }, [enabled])
+ commandInFlight.current = null
+ }, [enabled, sessionId])
const write = useCallback(
async (
@@ -76,6 +82,21 @@ export function useStructuredAgentSessionMutate(args: {
return { kind: 'dropped' }
}
const targetFence = stateRef.current.fence
+ const request = {}
+ if (fingerprintMethod === 'agentSession.conversationCommand') {
+ commandInFlight.current = request
+ }
+ // Whether this call's answer is still for what the pane shows. Another fence's result is about
+ // a runtime the pane replaced; a conversation command may itself start or stop the agent,
+ // moving the fence under its own reply, so that reply is kept while this pane still waits on
+ // it. Called once, when the call settles.
+ const settlesHere = (): boolean => {
+ const waitedOn = commandInFlight.current === request
+ if (waitedOn) {
+ commandInFlight.current = null
+ }
+ return enabledRef.current && (stateRef.current.fence === targetFence || waitedOn)
+ }
const key = `${sessionId}:${fingerprintMethod}:${JSON.stringify(fields)}`
// A write naming no target acts on whatever is in flight when the host reaches it, so every
// press is its own write; one naming its target keeps its id for a retry to replay.
@@ -103,7 +124,7 @@ export function useStructuredAgentSessionMutate(args: {
...fields
})
} catch (error) {
- return enabledRef.current && stateRef.current.fence === targetFence
+ return settlesHere()
? {
kind: 'not-done',
notice: agentSessionWriteFailureText(
@@ -126,7 +147,7 @@ export function useStructuredAgentSessionMutate(args: {
) {
operationIds.current.delete(key)
}
- return enabledRef.current && stateRef.current.fence === targetFence
+ return settlesHere()
? {
kind: 'not-done',
notice: agentSessionWriteFailureText(
@@ -136,7 +157,7 @@ export function useStructuredAgentSessionMutate(args: {
}
: { kind: 'dropped' }
}
- if (!enabledRef.current || stateRef.current.fence !== targetFence) {
+ if (!settlesHere()) {
return { kind: 'dropped' }
}
if (!conversationCommands.isUnconfirmedConversationCommand(fingerprintMethod, result.value)) {
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-transport.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-transport.ts
index f0c3eaa59a83..e42dca314a24 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-transport.ts
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-transport.ts
@@ -38,5 +38,6 @@ export function useStructuredAgentSessionTransport(args: {
useEffect(() => {
stateRef.current = read.state
}, [read.state])
- return { ...read, ...mutation, providerVisible }
+ // `stateRef`: the read state now, for a reply that lands after the render that sent it.
+ return { ...read, ...mutation, providerVisible, stateRef }
}
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 db9f70e7ba94..4815c0a944ea 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
@@ -43,6 +43,7 @@ vi.mock('./use-structured-agent-session-outbox', () => ({
import { useStructuredAgentSession } from './use-structured-agent-session'
import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result'
+import { i18n } from '@/i18n/i18n'
const LOCAL_TARGET = { kind: 'local' } as const
const OPTIONS = { models: [], current: {} }
@@ -262,4 +263,40 @@ describe('a chat write the host refused', () => {
"The agent is still starting. The agent wasn't stopped. Wait for the agent to finish starting."
)
})
+
+ it("words a failed /clear the host ran with the chat's own agent, in the reader's language", async () => {
+ mocks.call.mockImplementation((_target, method) =>
+ method === 'agentSession.options'
+ ? Promise.resolve(OPTIONS)
+ : Promise.resolve({
+ ok: true,
+ value: {
+ command: 'clear',
+ state: 'completed',
+ error: "Codex couldn't start. Run /clear again.",
+ failure: { kind: 'startFailed' }
+ }
+ })
+ )
+ const { result } = renderHook(() =>
+ useStructuredAgentSession({
+ sessionId: 'session-1',
+ target: LOCAL_TARGET,
+ agent: 'codex',
+ isVisible: true
+ })
+ )
+
+ await i18n.changeLanguage('fr')
+ try {
+ await act(async () => {
+ await expect(result.current.runConversationCommand('clear')).resolves.toEqual({
+ accepted: false,
+ error: "Codex n'a pas pu démarrer. Relancez /clear."
+ })
+ })
+ } finally {
+ await i18n.changeLanguage('en')
+ }
+ })
})
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 41d56744b313..e4d18eaed2ee 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
@@ -8,6 +8,7 @@ import type {
} from '../../../../shared/agent-session-conversation-command'
import type { AgentType } from '../../../../shared/agent-status-types'
import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
+import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label'
import {
supportsStructuredAgentSessionPromptCancel,
supportsStructuredAgentSessionQuestionAnswers
@@ -35,6 +36,7 @@ import { useStructuredAgentSessionContextUsage } from './use-structured-agent-se
import { useStructuredAgentSessionRailOutline } from './use-structured-agent-session-rail-outline'
import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages'
import { outboxOutsideQueuedCards } from './structured-agent-session-queued-cards'
+import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-delivery-notices'
import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership'
export type { StructuredPromptItem } from './structured-agent-session-message-projection'
@@ -67,13 +69,21 @@ export function useStructuredAgentSession(args: {
target,
transportEnabled = true
} = args
- const { state, loadingOlder, olderHistoryGeneration, loadOlder, mutate, write, providerVisible } =
- useStructuredAgentSessionTransport({
- sessionId,
- target,
- isVisible,
- enabled: transportEnabled
- })
+ const {
+ state,
+ stateRef,
+ loadingOlder,
+ olderHistoryGeneration,
+ loadOlder,
+ mutate,
+ write,
+ providerVisible
+ } = useStructuredAgentSessionTransport({
+ sessionId,
+ target,
+ isVisible,
+ enabled: transportEnabled
+ })
const commandPending = useRef(false)
const transportState = useStructuredAgentSessionTransportState(state, transportEnabled)
const {
@@ -186,6 +196,7 @@ export function useStructuredAgentSession(args: {
runConversationCommand: (command: AgentSessionConversationCommand) =>
structuredConversationCommands.sendStructuredConversationCommand({
command,
+ agentName: structuredAgentLabel(agent === 'codex' ? 'codex' : 'claude'),
pending: commandPending,
blocked: Boolean(
transportState.turnId ||
@@ -193,6 +204,7 @@ export function useStructuredAgentSession(args: {
transportState.backgroundTasks.isMonitoring ||
outbox.length
),
+ startFailures: () => structuredAgentSessionStartFailureFacts(stateRef.current.items),
send: (command) =>
write(
'agentSession.conversationCommand',
diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json
index 4b6ac35c6922..3c57af388225 100644
--- a/src/renderer/src/i18n/en-runtime-required.json
+++ b/src/renderer/src/i18n/en-runtime-required.json
@@ -2715,6 +2715,64 @@
"effort": "Effort",
"imageUnsupported": "Image paste is not supported for this agent."
},
+ "failureWords": {
+ "accountSwitchInProgress": "A Claude account switch is in progress. Try again after it finishes.",
+ "answerUnconfirmed": "Your answer was recorded but the agent did not confirm it.",
+ "attachmentAtMost": "{{agent}} accepts at most {{limit}} images in one message, so this message was not sent.",
+ "attachmentEmpty": "An image on this message is empty, so the message was not sent.",
+ "attachmentInvalid": "An attachment on this message can't be sent to the agent.",
+ "attachmentLargerThan": "An image on this message is larger than {{size}} MB, so the message was not sent.",
+ "attachmentNoSource": "An image on this message has no file to send, so the message was not sent.",
+ "attachmentNotAFile": "An image on this message isn't a file, so the message was not sent.",
+ "attachmentTooLarge": "An image on this message is too large, so the message was not sent.",
+ "attachmentTooMany": "This message has too many images, so it was not sent.",
+ "attachmentTotalMoreThan": "The images on this message add up to more than {{size}} MB, so the message was not sent.",
+ "attachmentTotalTooLarge": "The images on this message are too large together, so the message was not sent.",
+ "attachmentUnreadable": "An attachment on this message couldn't be read, so the message was not sent.",
+ "attachmentUnsupportedType": "{{agent}} accepts only PNG, JPEG, GIF, and WebP images, so this message was not sent.",
+ "cancelUnconfirmed": "Cancellation was not confirmed.",
+ "cancelled": "This message was withdrawn before the agent started it.",
+ "chatClosed": "The chat closed before this message was sent.",
+ "chooseClaudeAccount": "Choose or add one in Claude Accounts settings.",
+ "chooseClaudeAccountThenRunCommand": "Choose or add one in Claude Accounts settings, then run /{{command}} again.",
+ "chooseClaudeAccountThenSend": "Choose or add one in Claude Accounts settings, then send your message again.",
+ "commandRefused": "This command didn't run.",
+ "commandRefusedTryAgain": "This command didn't run. Try it again.",
+ "compactionFailed": "Compaction failed.",
+ "compactionFailedQuoted": "Compaction failed: {{detail}}.",
+ "compactionUnconfirmed": "Compaction completion is unconfirmed.",
+ "couldNotRestart": "{{agent}} couldn't restart.",
+ "couldNotStart": "{{agent}} couldn't start.",
+ "emptyMessage": "This message is empty, so it was not sent.",
+ "historyTooLarge": "This conversation's history is too large to restore here.",
+ "hostFault": "Orca ran into a problem, so this didn't go through.",
+ "hostFaultTryAgain": "Orca ran into a problem, so this didn't go through. Try again.",
+ "hostRestarted": "Orca restarted before this message was sent.",
+ "hostStopped": "{{agent}} never finished starting, so Orca stopped it.",
+ "managedAccountEnvOverride": "This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.",
+ "managedAccountUnsupported": "While a Claude account is added in WSL, Claude chats need a Windows Claude account.",
+ "noTurnToStop": "{{agent}} had no turn running to stop.",
+ "notDelivered": "This message was not delivered.",
+ "notDeliveredSendAgain": "This message was not delivered. Send it again to continue.",
+ "notSignedIn": "{{agent}} is not signed in for the selected account.",
+ "providerExitedRejection": "{{agent}} stopped before this message was sent.",
+ "providerExitedRow": "{{agent}} stopped while this response was in progress. You can continue in this conversation.",
+ "providerRateLimited": "{{agent}} is rate-limited and retrying.",
+ "providerRejected": "The provider did not accept this message.",
+ "providerRejectedQuoted": "The provider did not accept this message: {{detail}}.",
+ "providerRetrying": "{{agent}} hit a temporary problem and is retrying.",
+ "providerStartFailed": "{{agent}} stopped before it finished starting.",
+ "queueFull": "Too many messages were waiting for the agent, so this one was not sent.",
+ "runCommandAgain": "Run /{{command}} again.",
+ "sendToTryAgain": "Send your message to try again.",
+ "signInFirst": "Sign in first.",
+ "signInThenRunCommand": "Sign in, then run /{{command}} again.",
+ "signInThenSend": "Sign in, then send your message again.",
+ "stopRefused": "{{agent}} didn't stop.",
+ "stopRefusedQuoted": "{{agent}} didn't stop: {{detail}}.",
+ "theAgent": "The agent",
+ "writeFailed": "Orca couldn't hand this message to the agent, so it was not sent."
+ },
"question": {
"other": "Other…"
},
@@ -2724,6 +2782,7 @@
"title": "Start a chat with {{value0}}"
},
"error": {
+ "keepsTrying": "Orca keeps trying to load it.",
"retryingSubtitle": "The transcript could not be read. Orca keeps trying to load it.",
"subtitle": "The transcript could not be read. Toggle back to the terminal to keep working.",
"title": "Could not load conversation"
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 25a3f2aaabf5..69264dc34789 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -17466,6 +17466,64 @@
"empty": "No users found"
},
"native-chat": {
+ "failureWords": {
+ "theAgent": "The agent",
+ "providerStartFailed": "{{agent}} stopped before it finished starting.",
+ "runCommandAgain": "Run /{{command}} again.",
+ "sendToTryAgain": "Send your message to try again.",
+ "couldNotStart": "{{agent}} couldn't start.",
+ "couldNotRestart": "{{agent}} couldn't restart.",
+ "notSignedIn": "{{agent}} is not signed in for the selected account.",
+ "signInFirst": "Sign in first.",
+ "signInThenRunCommand": "Sign in, then run /{{command}} again.",
+ "signInThenSend": "Sign in, then send your message again.",
+ "historyTooLarge": "This conversation's history is too large to restore here.",
+ "managedAccountEnvOverride": "This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.",
+ "accountSwitchInProgress": "A Claude account switch is in progress. Try again after it finishes.",
+ "managedAccountUnsupported": "While a Claude account is added in WSL, Claude chats need a Windows Claude account.",
+ "chooseClaudeAccount": "Choose or add one in Claude Accounts settings.",
+ "chooseClaudeAccountThenRunCommand": "Choose or add one in Claude Accounts settings, then run /{{command}} again.",
+ "chooseClaudeAccountThenSend": "Choose or add one in Claude Accounts settings, then send your message again.",
+ "providerExitedRow": "{{agent}} stopped while this response was in progress. You can continue in this conversation.",
+ "providerExitedRejection": "{{agent}} stopped before this message was sent.",
+ "providerRejected": "The provider did not accept this message.",
+ "providerRejectedQuoted": "The provider did not accept this message: {{detail}}.",
+ "attachmentEmpty": "An image on this message is empty, so the message was not sent.",
+ "attachmentTooLarge": "An image on this message is too large, so the message was not sent.",
+ "attachmentLargerThan": "An image on this message is larger than {{size}} MB, so the message was not sent.",
+ "attachmentTooMany": "This message has too many images, so it was not sent.",
+ "attachmentAtMost": "{{agent}} accepts at most {{limit}} images in one message, so this message was not sent.",
+ "attachmentTotalTooLarge": "The images on this message are too large together, so the message was not sent.",
+ "attachmentTotalMoreThan": "The images on this message add up to more than {{size}} MB, so the message was not sent.",
+ "attachmentUnsupportedType": "{{agent}} accepts only PNG, JPEG, GIF, and WebP images, so this message was not sent.",
+ "attachmentNotAFile": "An image on this message isn't a file, so the message was not sent.",
+ "attachmentNoSource": "An image on this message has no file to send, so the message was not sent.",
+ "attachmentInvalid": "An attachment on this message can't be sent to the agent.",
+ "attachmentUnreadable": "An attachment on this message couldn't be read, so the message was not sent.",
+ "emptyMessage": "This message is empty, so it was not sent.",
+ "queueFull": "Too many messages were waiting for the agent, so this one was not sent.",
+ "writeFailed": "Orca couldn't hand this message to the agent, so it was not sent.",
+ "cancelled": "This message was withdrawn before the agent started it.",
+ "chatClosed": "The chat closed before this message was sent.",
+ "hostRestarted": "Orca restarted before this message was sent.",
+ "notDelivered": "This message was not delivered.",
+ "notDeliveredSendAgain": "This message was not delivered. Send it again to continue.",
+ "commandRefused": "This command didn't run.",
+ "commandRefusedTryAgain": "This command didn't run. Try it again.",
+ "compactionFailed": "Compaction failed.",
+ "compactionFailedQuoted": "Compaction failed: {{detail}}.",
+ "compactionUnconfirmed": "Compaction completion is unconfirmed.",
+ "cancelUnconfirmed": "Cancellation was not confirmed.",
+ "stopRefused": "{{agent}} didn't stop.",
+ "stopRefusedQuoted": "{{agent}} didn't stop: {{detail}}.",
+ "noTurnToStop": "{{agent}} had no turn running to stop.",
+ "answerUnconfirmed": "Your answer was recorded but the agent did not confirm it.",
+ "hostFault": "Orca ran into a problem, so this didn't go through.",
+ "hostFaultTryAgain": "Orca ran into a problem, so this didn't go through. Try again.",
+ "hostStopped": "{{agent}} never finished starting, so Orca stopped it.",
+ "providerRateLimited": "{{agent}} is rate-limited and retrying.",
+ "providerRetrying": "{{agent}} hit a temporary problem and is retrying."
+ },
"writeNotice": {
"notDoneReadHistory": "This chat's history couldn't be loaded.",
"notDoneSend": "Your message was not sent.",
@@ -17753,7 +17811,8 @@
"error": {
"title": "Could not load conversation",
"subtitle": "The transcript could not be read. Toggle back to the terminal to keep working.",
- "retryingSubtitle": "The transcript could not be read. Orca keeps trying to load it."
+ "retryingSubtitle": "The transcript could not be read. Orca keeps trying to load it.",
+ "keepsTrying": "Orca keeps trying to load it."
},
"reconnecting": "Reconnecting to this chat…",
"pairHost": "Pair a host to view agent chat history.",
@@ -17831,8 +17890,7 @@
"unnamed": "Subagent"
},
"conversationCommand": {
- "pendingWork": "Wait for pending work and messages to finish before using this command.",
- "unconfirmed": "Conversation operation was not confirmed."
+ "pendingWork": "Wait for pending work and messages to finish before using this command."
},
"drop": {
"title": "Drop to attach to this chat",
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 731806faa3bb..c28987a4d819 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -14904,6 +14904,64 @@
},
"components": {
"native-chat": {
+ "failureWords": {
+ "theAgent": "El agente",
+ "providerStartFailed": "{{agent}} se detuvo antes de terminar de iniciarse.",
+ "runCommandAgain": "Vuelva a ejecutar /{{command}}.",
+ "sendToTryAgain": "Envíe su mensaje para volver a intentarlo.",
+ "couldNotStart": "{{agent}} no pudo iniciarse.",
+ "couldNotRestart": "{{agent}} no pudo reiniciarse.",
+ "notSignedIn": "{{agent}} no tiene la sesión iniciada con la cuenta seleccionada.",
+ "signInFirst": "Inicie sesión primero.",
+ "signInThenRunCommand": "Inicie sesión y vuelva a ejecutar /{{command}}.",
+ "signInThenSend": "Inicie sesión y vuelva a enviar su mensaje.",
+ "historyTooLarge": "El historial de esta conversación es demasiado grande para restaurarlo aquí.",
+ "managedAccountEnvOverride": "Este inicio de Claude define sus propias variables de inicio de sesión de Anthropic. Quítelas para usar una cuenta de Claude administrada.",
+ "accountSwitchInProgress": "Hay un cambio de cuenta de Claude en curso. Vuelva a intentarlo cuando termine.",
+ "managedAccountUnsupported": "Mientras haya una cuenta de Claude agregada en WSL, los chats de Claude necesitan una cuenta de Claude de Windows.",
+ "chooseClaudeAccount": "Elija o agregue una en la configuración de Cuentas de Claude.",
+ "chooseClaudeAccountThenRunCommand": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a ejecutar /{{command}}.",
+ "chooseClaudeAccountThenSend": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a enviar su mensaje.",
+ "providerExitedRow": "{{agent}} se detuvo mientras esta respuesta estaba en curso. Puede continuar en esta conversación.",
+ "providerExitedRejection": "{{agent}} se detuvo antes de que se enviara este mensaje.",
+ "providerRejected": "El proveedor no aceptó este mensaje.",
+ "providerRejectedQuoted": "El proveedor no aceptó este mensaje: {{detail}}.",
+ "attachmentEmpty": "Una imagen de este mensaje está vacía, así que el mensaje no se envió.",
+ "attachmentTooLarge": "Una imagen de este mensaje es demasiado grande, así que el mensaje no se envió.",
+ "attachmentLargerThan": "Una imagen de este mensaje supera los {{size}} MB, así que el mensaje no se envió.",
+ "attachmentTooMany": "Este mensaje tiene demasiadas imágenes, así que no se envió.",
+ "attachmentAtMost": "{{agent}} acepta como máximo {{limit}} imágenes por mensaje, así que este mensaje no se envió.",
+ "attachmentTotalTooLarge": "Las imágenes de este mensaje son demasiado grandes en conjunto, así que el mensaje no se envió.",
+ "attachmentTotalMoreThan": "Las imágenes de este mensaje suman más de {{size}} MB, así que el mensaje no se envió.",
+ "attachmentUnsupportedType": "{{agent}} solo acepta imágenes PNG, JPEG, GIF y WebP, así que este mensaje no se envió.",
+ "attachmentNotAFile": "Una imagen de este mensaje no es un archivo, así que el mensaje no se envió.",
+ "attachmentNoSource": "Una imagen de este mensaje no tiene ningún archivo que enviar, así que el mensaje no se envió.",
+ "attachmentInvalid": "Un archivo adjunto de este mensaje no se puede enviar al agente.",
+ "attachmentUnreadable": "No se pudo leer un archivo adjunto de este mensaje, así que el mensaje no se envió.",
+ "emptyMessage": "Este mensaje está vacío, así que no se envió.",
+ "queueFull": "Había demasiados mensajes esperando al agente, así que este no se envió.",
+ "writeFailed": "Orca no pudo entregar este mensaje al agente, así que no se envió.",
+ "cancelled": "Este mensaje se retiró antes de que el agente empezara a procesarlo.",
+ "chatClosed": "El chat se cerró antes de que se enviara este mensaje.",
+ "hostRestarted": "Orca se reinició antes de que se enviara este mensaje.",
+ "notDelivered": "Este mensaje no se entregó.",
+ "notDeliveredSendAgain": "Este mensaje no se entregó. Vuelva a enviarlo para continuar.",
+ "commandRefused": "Este comando no se ejecutó.",
+ "commandRefusedTryAgain": "Este comando no se ejecutó. Vuelva a intentarlo.",
+ "compactionFailed": "La compactación falló.",
+ "compactionFailedQuoted": "La compactación falló: {{detail}}.",
+ "compactionUnconfirmed": "No se confirmó que la compactación haya terminado.",
+ "cancelUnconfirmed": "No se confirmó la cancelación.",
+ "stopRefused": "{{agent}} no se detuvo.",
+ "stopRefusedQuoted": "{{agent}} no se detuvo: {{detail}}.",
+ "noTurnToStop": "{{agent}} no tenía ningún turno en curso que detener.",
+ "answerUnconfirmed": "Su respuesta se registró, pero el agente no la confirmó.",
+ "hostFault": "Orca tuvo un problema, así que esto no se completó.",
+ "hostFaultTryAgain": "Orca tuvo un problema, así que esto no se completó. Vuelva a intentarlo.",
+ "hostStopped": "{{agent}} no terminó de iniciarse, así que Orca lo detuvo.",
+ "providerRateLimited": "{{agent}} alcanzó un límite de solicitudes y está reintentando.",
+ "providerRetrying": "{{agent}} tuvo un problema temporal y está reintentando."
+ },
"writeNotice": {
"notDoneReadHistory": "No se pudo cargar el historial de este chat.",
"notDoneSend": "Su mensaje no se envió.",
@@ -15035,7 +15093,8 @@
"error": {
"title": "No se pudo cargar la conversación",
"subtitle": "No se pudo leer la transcripción. Vuelva a la terminal para seguir trabajando.",
- "retryingSubtitle": "No se pudo leer la transcripción. Orca sigue intentando cargarla."
+ "retryingSubtitle": "No se pudo leer la transcripción. Orca sigue intentando cargarla.",
+ "keepsTrying": "Orca sigue intentando cargarlo."
},
"reconnecting": "Reconectando con este chat…",
"pairHost": "Empareje un host para ver el historial de chat del agente.",
diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json
index 65acfdf49e92..69deaa9ff669 100644
--- a/src/renderer/src/i18n/locales/fr.json
+++ b/src/renderer/src/i18n/locales/fr.json
@@ -17378,6 +17378,64 @@
},
"components": {
"native-chat": {
+ "failureWords": {
+ "theAgent": "L'agent",
+ "providerStartFailed": "{{agent}} s'est arrêté avant d'avoir fini de démarrer.",
+ "runCommandAgain": "Relancez /{{command}}.",
+ "sendToTryAgain": "Envoyez votre message pour réessayer.",
+ "couldNotStart": "{{agent}} n'a pas pu démarrer.",
+ "couldNotRestart": "{{agent}} n'a pas pu redémarrer.",
+ "notSignedIn": "{{agent}} n'est pas connecté avec le compte sélectionné.",
+ "signInFirst": "Connectez-vous d'abord.",
+ "signInThenRunCommand": "Connectez-vous, puis relancez /{{command}}.",
+ "signInThenSend": "Connectez-vous, puis renvoyez votre message.",
+ "historyTooLarge": "L'historique de cette conversation est trop volumineux pour être restauré ici.",
+ "managedAccountEnvOverride": "Ce lancement de Claude définit ses propres variables de connexion Anthropic. Supprimez-les pour utiliser un compte Claude géré.",
+ "accountSwitchInProgress": "Un changement de compte Claude est en cours. Réessayez une fois qu'il sera terminé.",
+ "managedAccountUnsupported": "Tant qu'un compte Claude est ajouté dans WSL, les chats Claude nécessitent un compte Claude Windows.",
+ "chooseClaudeAccount": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude.",
+ "chooseClaudeAccountThenRunCommand": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis relancez /{{command}}.",
+ "chooseClaudeAccountThenSend": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis renvoyez votre message.",
+ "providerExitedRow": "{{agent}} s'est arrêté pendant que cette réponse était en cours. Vous pouvez continuer dans cette conversation.",
+ "providerExitedRejection": "{{agent}} s'est arrêté avant l'envoi de ce message.",
+ "providerRejected": "Le fournisseur n'a pas accepté ce message.",
+ "providerRejectedQuoted": "Le fournisseur n'a pas accepté ce message : {{detail}}.",
+ "attachmentEmpty": "Une image de ce message est vide, le message n'a donc pas été envoyé.",
+ "attachmentTooLarge": "Une image de ce message est trop volumineuse, le message n'a donc pas été envoyé.",
+ "attachmentLargerThan": "Une image de ce message dépasse {{size}} Mo, le message n'a donc pas été envoyé.",
+ "attachmentTooMany": "Ce message contient trop d'images, il n'a donc pas été envoyé.",
+ "attachmentAtMost": "{{agent}} accepte au maximum {{limit}} images par message, ce message n'a donc pas été envoyé.",
+ "attachmentTotalTooLarge": "Les images de ce message sont trop volumineuses au total, le message n'a donc pas été envoyé.",
+ "attachmentTotalMoreThan": "Les images de ce message dépassent {{size}} Mo au total, le message n'a donc pas été envoyé.",
+ "attachmentUnsupportedType": "{{agent}} n'accepte que les images PNG, JPEG, GIF et WebP, ce message n'a donc pas été envoyé.",
+ "attachmentNotAFile": "Une image de ce message n'est pas un fichier, le message n'a donc pas été envoyé.",
+ "attachmentNoSource": "Une image de ce message n'a aucun fichier à envoyer, le message n'a donc pas été envoyé.",
+ "attachmentInvalid": "Une pièce jointe de ce message ne peut pas être envoyée à l'agent.",
+ "attachmentUnreadable": "Une pièce jointe de ce message n'a pas pu être lue, le message n'a donc pas été envoyé.",
+ "emptyMessage": "Ce message est vide, il n'a donc pas été envoyé.",
+ "queueFull": "Trop de messages attendaient l'agent, celui-ci n'a donc pas été envoyé.",
+ "writeFailed": "Orca n'a pas pu transmettre ce message à l'agent, il n'a donc pas été envoyé.",
+ "cancelled": "Ce message a été retiré avant que l'agent ne commence à le traiter.",
+ "chatClosed": "Le chat s'est fermé avant l'envoi de ce message.",
+ "hostRestarted": "Orca a redémarré avant l'envoi de ce message.",
+ "notDelivered": "Ce message n'a pas été remis.",
+ "notDeliveredSendAgain": "Ce message n'a pas été remis. Renvoyez-le pour continuer.",
+ "commandRefused": "Cette commande n'a pas été exécutée.",
+ "commandRefusedTryAgain": "Cette commande n'a pas été exécutée. Réessayez.",
+ "compactionFailed": "La compaction a échoué.",
+ "compactionFailedQuoted": "La compaction a échoué : {{detail}}.",
+ "compactionUnconfirmed": "La fin de la compaction n'est pas confirmée.",
+ "cancelUnconfirmed": "L'annulation n'a pas été confirmée.",
+ "stopRefused": "{{agent}} ne s'est pas arrêté.",
+ "stopRefusedQuoted": "{{agent}} ne s'est pas arrêté : {{detail}}.",
+ "noTurnToStop": "{{agent}} n'avait aucun tour en cours à arrêter.",
+ "answerUnconfirmed": "Votre réponse a été enregistrée, mais l'agent ne l'a pas confirmée.",
+ "hostFault": "Orca a rencontré un problème, l'opération n'a donc pas abouti.",
+ "hostFaultTryAgain": "Orca a rencontré un problème, l'opération n'a donc pas abouti. Réessayez.",
+ "hostStopped": "{{agent}} n'a jamais fini de démarrer, Orca l'a donc arrêté.",
+ "providerRateLimited": "{{agent}} est limité en débit et réessaie.",
+ "providerRetrying": "{{agent}} a rencontré un problème temporaire et réessaie."
+ },
"writeNotice": {
"notDoneReadHistory": "L'historique de ce chat n'a pas pu être chargé.",
"notDoneSend": "Votre message n'a pas été envoyé.",
@@ -17588,7 +17646,8 @@
"error": {
"title": "Impossible de charger la conversation",
"subtitle": "La transcription n'a pas pu être lue. Rebasculez sur le terminal pour continuer à travailler.",
- "retryingSubtitle": "La transcription n'a pas pu être lue. Orca continue d'essayer de la charger."
+ "retryingSubtitle": "La transcription n'a pas pu être lue. Orca continue d'essayer de la charger.",
+ "keepsTrying": "Orca continue d'essayer de le charger."
},
"reconnecting": "Reconnexion à ce chat…",
"pairHost": "Associez un hôte pour consulter l'historique de chat des agents.",
@@ -17743,8 +17802,7 @@
"unnamed": "Sous-agent"
},
"conversationCommand": {
- "pendingWork": "Attendez la fin du travail et des messages en attente avant d'utiliser cette commande.",
- "unconfirmed": "L'opération de conversation n'a pas été confirmée."
+ "pendingWork": "Attendez la fin du travail et des messages en attente avant d'utiliser cette commande."
},
"drop": {
"title": "Déposer pour joindre à ce chat",
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 85491478e07d..d7de27a0aef0 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -17314,6 +17314,64 @@
}
},
"native-chat": {
+ "failureWords": {
+ "theAgent": "Agent",
+ "providerStartFailed": "{{agent}} は起動が完了する前に停止しました。",
+ "runCommandAgain": "/{{command}} をもう一度実行してください。",
+ "sendToTryAgain": "もう一度試すには、メッセージを送信してください。",
+ "couldNotStart": "{{agent}} を起動できませんでした。",
+ "couldNotRestart": "{{agent}} を再起動できませんでした。",
+ "notSignedIn": "{{agent}} は選択したアカウントでサインインしていません。",
+ "signInFirst": "先にサインインしてください。",
+ "signInThenRunCommand": "サインインしてから、/{{command}} をもう一度実行してください。",
+ "signInThenSend": "サインインしてから、メッセージをもう一度送信してください。",
+ "historyTooLarge": "この会話の履歴は大きすぎるため、ここでは復元できません。",
+ "managedAccountEnvOverride": "この Claude の起動では独自の Anthropic サインイン変数が設定されています。管理対象の Claude アカウントを使用するには、それらを削除してください。",
+ "accountSwitchInProgress": "Claude アカウントの切り替え中です。完了してからもう一度お試しください。",
+ "managedAccountUnsupported": "WSL に Claude アカウントが追加されている間、Claude チャットには Windows の Claude アカウントが必要です。",
+ "chooseClaudeAccount": "Claude アカウントの設定で選択または追加してください。",
+ "chooseClaudeAccountThenRunCommand": "Claude アカウントの設定で選択または追加してから、/{{command}} をもう一度実行してください。",
+ "chooseClaudeAccountThenSend": "Claude アカウントの設定で選択または追加してから、メッセージをもう一度送信してください。",
+ "providerExitedRow": "この応答の途中で {{agent}} が停止しました。この会話で続けることができます。",
+ "providerExitedRejection": "このメッセージが送信される前に {{agent}} が停止しました。",
+ "providerRejected": "プロバイダーはこのメッセージを受け付けませんでした。",
+ "providerRejectedQuoted": "プロバイダーはこのメッセージを受け付けませんでした: {{detail}}。",
+ "attachmentEmpty": "このメッセージの画像が空のため、メッセージは送信されませんでした。",
+ "attachmentTooLarge": "このメッセージの画像が大きすぎるため、メッセージは送信されませんでした。",
+ "attachmentLargerThan": "このメッセージの画像が {{size}} MB を超えているため、メッセージは送信されませんでした。",
+ "attachmentTooMany": "このメッセージには画像が多すぎるため、送信されませんでした。",
+ "attachmentAtMost": "{{agent}} が 1 つのメッセージで受け付ける画像は最大 {{limit}} 枚のため、このメッセージは送信されませんでした。",
+ "attachmentTotalTooLarge": "このメッセージの画像の合計サイズが大きすぎるため、メッセージは送信されませんでした。",
+ "attachmentTotalMoreThan": "このメッセージの画像の合計が {{size}} MB を超えているため、メッセージは送信されませんでした。",
+ "attachmentUnsupportedType": "{{agent}} が受け付けるのは PNG、JPEG、GIF、WebP 画像のみのため、このメッセージは送信されませんでした。",
+ "attachmentNotAFile": "このメッセージの画像がファイルではないため、メッセージは送信されませんでした。",
+ "attachmentNoSource": "このメッセージの画像に送信するファイルがないため、メッセージは送信されませんでした。",
+ "attachmentInvalid": "このメッセージの添付ファイルは Agent に送信できません。",
+ "attachmentUnreadable": "このメッセージの添付ファイルを読み取れなかったため、メッセージは送信されませんでした。",
+ "emptyMessage": "このメッセージは空のため、送信されませんでした。",
+ "queueFull": "Agent を待っているメッセージが多すぎたため、このメッセージは送信されませんでした。",
+ "writeFailed": "Orca はこのメッセージを Agent に渡せなかったため、送信されませんでした。",
+ "cancelled": "このメッセージは Agent が処理を始める前に取り消されました。",
+ "chatClosed": "このメッセージが送信される前にチャットが閉じられました。",
+ "hostRestarted": "このメッセージが送信される前に Orca が再起動しました。",
+ "notDelivered": "このメッセージは配信されませんでした。",
+ "notDeliveredSendAgain": "このメッセージは配信されませんでした。続けるには、もう一度送信してください。",
+ "commandRefused": "このコマンドは実行されませんでした。",
+ "commandRefusedTryAgain": "このコマンドは実行されませんでした。もう一度お試しください。",
+ "compactionFailed": "圧縮に失敗しました。",
+ "compactionFailedQuoted": "圧縮に失敗しました: {{detail}}。",
+ "compactionUnconfirmed": "圧縮の完了は確認されていません。",
+ "cancelUnconfirmed": "キャンセルは確認されませんでした。",
+ "stopRefused": "{{agent}} は停止しませんでした。",
+ "stopRefusedQuoted": "{{agent}} は停止しませんでした: {{detail}}。",
+ "noTurnToStop": "{{agent}} には停止できる実行中のターンがありませんでした。",
+ "answerUnconfirmed": "回答は記録されましたが、Agent は確認しませんでした。",
+ "hostFault": "Orca で問題が発生したため、この操作は完了しませんでした。",
+ "hostFaultTryAgain": "Orca で問題が発生したため、この操作は完了しませんでした。もう一度お試しください。",
+ "hostStopped": "{{agent}} の起動が完了しなかったため、Orca が停止しました。",
+ "providerRateLimited": "{{agent}} はレート制限を受けているため、再試行しています。",
+ "providerRetrying": "{{agent}} で一時的な問題が発生したため、再試行しています。"
+ },
"writeNotice": {
"notDoneReadHistory": "このチャットの履歴を読み込めませんでした。",
"notDoneSend": "メッセージは送信されませんでした。",
@@ -17524,7 +17582,8 @@
"error": {
"title": "会話を読み込めませんでした",
"subtitle": "トランスクリプトを読み取ることができませんでした。作業を続けるには、ターミナルに切り替えてください。",
- "retryingSubtitle": "トランスクリプトを読み取ることができませんでした。Orca は読み込みを再試行し続けます。"
+ "retryingSubtitle": "トランスクリプトを読み取ることができませんでした。Orca は読み込みを再試行し続けます。",
+ "keepsTrying": "Orca は読み込みを再試行し続けます。"
},
"reconnecting": "このチャットに再接続しています…",
"pairHost": "ホストをペアリングして、Agent のチャット履歴を表示します。",
@@ -17679,8 +17738,7 @@
"unnamed": "サブ Agent"
},
"conversationCommand": {
- "pendingWork": "このコマンドを使用する前に、保留中の作業とメッセージが完了するまで待ってください。",
- "unconfirmed": "会話動作は確認できておりません。"
+ "pendingWork": "このコマンドを使用する前に、保留中の作業とメッセージが完了するまで待ってください。"
},
"drop": {
"title": "ドロップしてこのチャットに添付します",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 180ed46c24fa..3e19e9a23ac3 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -17314,6 +17314,64 @@
}
},
"native-chat": {
+ "failureWords": {
+ "theAgent": "에이전트",
+ "providerStartFailed": "{{agent}}이(가) 시작을 마치기 전에 중지되었습니다.",
+ "runCommandAgain": "/{{command}}을(를) 다시 실행하세요.",
+ "sendToTryAgain": "다시 시도하려면 메시지를 보내세요.",
+ "couldNotStart": "{{agent}}을(를) 시작하지 못했습니다.",
+ "couldNotRestart": "{{agent}}을(를) 다시 시작하지 못했습니다.",
+ "notSignedIn": "{{agent}}이(가) 선택한 계정으로 로그인되어 있지 않습니다.",
+ "signInFirst": "먼저 로그인하세요.",
+ "signInThenRunCommand": "로그인한 다음 /{{command}}을(를) 다시 실행하세요.",
+ "signInThenSend": "로그인한 다음 메시지를 다시 보내세요.",
+ "historyTooLarge": "이 대화의 기록이 너무 커서 여기에서 복원할 수 없습니다.",
+ "managedAccountEnvOverride": "이 Claude 실행은 자체 Anthropic 로그인 변수를 설정합니다. 관리되는 Claude 계정을 사용하려면 해당 변수를 제거하세요.",
+ "accountSwitchInProgress": "Claude 계정 전환이 진행 중입니다. 완료된 후 다시 시도하세요.",
+ "managedAccountUnsupported": "WSL에 Claude 계정이 추가되어 있는 동안에는 Claude 채팅에 Windows Claude 계정이 필요합니다.",
+ "chooseClaudeAccount": "Claude 계정 설정에서 선택하거나 추가하세요.",
+ "chooseClaudeAccountThenRunCommand": "Claude 계정 설정에서 선택하거나 추가한 다음 /{{command}}을(를) 다시 실행하세요.",
+ "chooseClaudeAccountThenSend": "Claude 계정 설정에서 선택하거나 추가한 다음 메시지를 다시 보내세요.",
+ "providerExitedRow": "이 응답이 진행되는 동안 {{agent}}이(가) 중지되었습니다. 이 대화에서 계속할 수 있습니다.",
+ "providerExitedRejection": "이 메시지가 전송되기 전에 {{agent}}이(가) 중지되었습니다.",
+ "providerRejected": "공급자가 이 메시지를 받아들이지 않았습니다.",
+ "providerRejectedQuoted": "공급자가 이 메시지를 받아들이지 않았습니다: {{detail}}.",
+ "attachmentEmpty": "이 메시지의 이미지가 비어 있어 메시지가 전송되지 않았습니다.",
+ "attachmentTooLarge": "이 메시지의 이미지가 너무 커서 메시지가 전송되지 않았습니다.",
+ "attachmentLargerThan": "이 메시지의 이미지가 {{size}}MB보다 커서 메시지가 전송되지 않았습니다.",
+ "attachmentTooMany": "이 메시지에 이미지가 너무 많아 전송되지 않았습니다.",
+ "attachmentAtMost": "{{agent}}은(는) 메시지 하나에 이미지를 최대 {{limit}}개까지 받을 수 있어 이 메시지가 전송되지 않았습니다.",
+ "attachmentTotalTooLarge": "이 메시지의 이미지 전체 크기가 너무 커서 메시지가 전송되지 않았습니다.",
+ "attachmentTotalMoreThan": "이 메시지의 이미지 합계가 {{size}}MB를 넘어 메시지가 전송되지 않았습니다.",
+ "attachmentUnsupportedType": "{{agent}}은(는) PNG, JPEG, GIF, WebP 이미지만 받을 수 있어 이 메시지가 전송되지 않았습니다.",
+ "attachmentNotAFile": "이 메시지의 이미지가 파일이 아니어서 메시지가 전송되지 않았습니다.",
+ "attachmentNoSource": "이 메시지의 이미지에 보낼 파일이 없어 메시지가 전송되지 않았습니다.",
+ "attachmentInvalid": "이 메시지의 첨부 파일은 에이전트에 보낼 수 없습니다.",
+ "attachmentUnreadable": "이 메시지의 첨부 파일을 읽을 수 없어 메시지가 전송되지 않았습니다.",
+ "emptyMessage": "이 메시지가 비어 있어 전송되지 않았습니다.",
+ "queueFull": "에이전트를 기다리는 메시지가 너무 많아 이 메시지는 전송되지 않았습니다.",
+ "writeFailed": "Orca가 이 메시지를 에이전트에 전달하지 못해 전송되지 않았습니다.",
+ "cancelled": "이 메시지는 에이전트가 처리를 시작하기 전에 철회되었습니다.",
+ "chatClosed": "이 메시지가 전송되기 전에 채팅이 닫혔습니다.",
+ "hostRestarted": "이 메시지가 전송되기 전에 Orca가 다시 시작되었습니다.",
+ "notDelivered": "이 메시지는 전달되지 않았습니다.",
+ "notDeliveredSendAgain": "이 메시지는 전달되지 않았습니다. 계속하려면 다시 보내세요.",
+ "commandRefused": "이 명령이 실행되지 않았습니다.",
+ "commandRefusedTryAgain": "이 명령이 실행되지 않았습니다. 다시 시도하세요.",
+ "compactionFailed": "압축에 실패했습니다.",
+ "compactionFailedQuoted": "압축에 실패했습니다: {{detail}}.",
+ "compactionUnconfirmed": "압축 완료가 확인되지 않았습니다.",
+ "cancelUnconfirmed": "취소가 확인되지 않았습니다.",
+ "stopRefused": "{{agent}}이(가) 중지되지 않았습니다.",
+ "stopRefusedQuoted": "{{agent}}이(가) 중지되지 않았습니다: {{detail}}.",
+ "noTurnToStop": "{{agent}}에 중지할 실행 중인 턴이 없었습니다.",
+ "answerUnconfirmed": "응답이 기록되었지만 에이전트가 확인하지 않았습니다.",
+ "hostFault": "Orca에 문제가 발생하여 이 작업이 완료되지 않았습니다.",
+ "hostFaultTryAgain": "Orca에 문제가 발생하여 이 작업이 완료되지 않았습니다. 다시 시도하세요.",
+ "hostStopped": "{{agent}}이(가) 시작을 마치지 못해 Orca가 중지했습니다.",
+ "providerRateLimited": "{{agent}}이(가) 속도 제한에 걸려 다시 시도하고 있습니다.",
+ "providerRetrying": "{{agent}}에 일시적인 문제가 발생하여 다시 시도하고 있습니다."
+ },
"writeNotice": {
"notDoneReadHistory": "이 채팅의 기록을 불러오지 못했습니다.",
"notDoneSend": "메시지가 전송되지 않았습니다.",
@@ -17524,7 +17582,8 @@
"error": {
"title": "대화를 로드할 수 없습니다.",
"subtitle": "스크립트를 읽을 수 없습니다. 계속 작업하려면 terminal로 다시 전환하세요.",
- "retryingSubtitle": "스크립트를 읽을 수 없습니다. Orca가 계속 다시 불러오기를 시도합니다."
+ "retryingSubtitle": "스크립트를 읽을 수 없습니다. Orca가 계속 다시 불러오기를 시도합니다.",
+ "keepsTrying": "Orca가 계속 다시 불러오기를 시도합니다."
},
"reconnecting": "이 채팅에 다시 연결하는 중…",
"pairHost": "호스트를 페어링하여 에이전트 채팅 기록을 확인하세요.",
@@ -17679,8 +17738,7 @@
"unnamed": "하위 에이전트"
},
"conversationCommand": {
- "pendingWork": "이 명령을 사용하기 전에 보류 중인 작업과 메시지가 완료될 때까지 기다리십시오.",
- "unconfirmed": "대화 동작이 확인되지 않았습니다."
+ "pendingWork": "이 명령을 사용하기 전에 보류 중인 작업과 메시지가 완료될 때까지 기다리십시오."
},
"drop": {
"title": "이 채팅에 첨부하려면 드롭하세요.",
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 42d4d19309b3..6473e8cba697 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -17279,6 +17279,64 @@
},
"components": {
"native-chat": {
+ "failureWords": {
+ "theAgent": "智能体",
+ "providerStartFailed": "{{agent}} 在完成启动前已停止。",
+ "runCommandAgain": "请重新运行 /{{command}}。",
+ "sendToTryAgain": "请发送您的消息以重试。",
+ "couldNotStart": "{{agent}} 无法启动。",
+ "couldNotRestart": "{{agent}} 无法重新启动。",
+ "notSignedIn": "{{agent}} 未使用所选账户登录。",
+ "signInFirst": "请先登录。",
+ "signInThenRunCommand": "请登录,然后重新运行 /{{command}}。",
+ "signInThenSend": "请登录,然后重新发送您的消息。",
+ "historyTooLarge": "此对话的历史记录过大,无法在此恢复。",
+ "managedAccountEnvOverride": "此次 Claude 启动设置了自己的 Anthropic 登录变量。请移除这些变量以使用受管理的 Claude 账户。",
+ "accountSwitchInProgress": "正在切换 Claude 账户。请在切换完成后重试。",
+ "managedAccountUnsupported": "当 WSL 中添加了 Claude 账户时,Claude 聊天需要 Windows Claude 账户。",
+ "chooseClaudeAccount": "请在 Claude 账户设置中选择或添加一个。",
+ "chooseClaudeAccountThenRunCommand": "请在 Claude 账户设置中选择或添加一个,然后重新运行 /{{command}}。",
+ "chooseClaudeAccountThenSend": "请在 Claude 账户设置中选择或添加一个,然后重新发送您的消息。",
+ "providerExitedRow": "{{agent}} 在生成此回复时停止了。您可以在此对话中继续。",
+ "providerExitedRejection": "{{agent}} 在发送此消息前已停止。",
+ "providerRejected": "提供商未接受此消息。",
+ "providerRejectedQuoted": "提供商未接受此消息:{{detail}}。",
+ "attachmentEmpty": "此消息中的一张图片为空,因此消息未发送。",
+ "attachmentTooLarge": "此消息中的一张图片过大,因此消息未发送。",
+ "attachmentLargerThan": "此消息中的一张图片超过 {{size}} MB,因此消息未发送。",
+ "attachmentTooMany": "此消息包含的图片过多,因此未发送。",
+ "attachmentAtMost": "{{agent}} 每条消息最多接受 {{limit}} 张图片,因此此消息未发送。",
+ "attachmentTotalTooLarge": "此消息中的图片总大小过大,因此消息未发送。",
+ "attachmentTotalMoreThan": "此消息中的图片总计超过 {{size}} MB,因此消息未发送。",
+ "attachmentUnsupportedType": "{{agent}} 仅接受 PNG、JPEG、GIF 和 WebP 图片,因此此消息未发送。",
+ "attachmentNotAFile": "此消息中的一张图片不是文件,因此消息未发送。",
+ "attachmentNoSource": "此消息中的一张图片没有可发送的文件,因此消息未发送。",
+ "attachmentInvalid": "此消息中的附件无法发送给智能体。",
+ "attachmentUnreadable": "无法读取此消息中的附件,因此消息未发送。",
+ "emptyMessage": "此消息为空,因此未发送。",
+ "queueFull": "等待智能体处理的消息过多,因此此消息未发送。",
+ "writeFailed": "Orca 无法将此消息交给智能体,因此消息未发送。",
+ "cancelled": "此消息在智能体开始处理前已撤回。",
+ "chatClosed": "此消息发送前聊天已关闭。",
+ "hostRestarted": "此消息发送前 Orca 已重新启动。",
+ "notDelivered": "此消息未送达。",
+ "notDeliveredSendAgain": "此消息未送达。请重新发送以继续。",
+ "commandRefused": "此命令未运行。",
+ "commandRefusedTryAgain": "此命令未运行。请重试。",
+ "compactionFailed": "压缩失败。",
+ "compactionFailedQuoted": "压缩失败:{{detail}}。",
+ "compactionUnconfirmed": "未确认压缩是否完成。",
+ "cancelUnconfirmed": "未确认取消。",
+ "stopRefused": "{{agent}} 未停止。",
+ "stopRefusedQuoted": "{{agent}} 未停止:{{detail}}。",
+ "noTurnToStop": "{{agent}} 没有正在运行的轮次可停止。",
+ "answerUnconfirmed": "您的回答已记录,但智能体未确认。",
+ "hostFault": "Orca 遇到问题,因此此操作未完成。",
+ "hostFaultTryAgain": "Orca 遇到问题,因此此操作未完成。请重试。",
+ "hostStopped": "{{agent}} 始终未完成启动,因此 Orca 已将其停止。",
+ "providerRateLimited": "{{agent}} 已被限流,正在重试。",
+ "providerRetrying": "{{agent}} 遇到临时问题,正在重试。"
+ },
"writeNotice": {
"notDoneReadHistory": "无法加载此聊天的历史记录。",
"notDoneSend": "您的消息未发送。",
@@ -17489,7 +17547,8 @@
"error": {
"title": "无法加载对话",
"subtitle": "无法读取记录。请切回终端继续工作。",
- "retryingSubtitle": "无法读取记录。Orca 会继续尝试加载。"
+ "retryingSubtitle": "无法读取记录。Orca 会继续尝试加载。",
+ "keepsTrying": "Orca 会继续尝试加载。"
},
"reconnecting": "正在重新连接此聊天…",
"pairHost": "配对主机以查看智能体聊天记录。",
@@ -17644,8 +17703,7 @@
"unnamed": "子代理"
},
"conversationCommand": {
- "pendingWork": "在使用此命令之前,请等待挂起的工作和消息完成。",
- "unconfirmed": "对话操作未确认。"
+ "pendingWork": "在使用此命令之前,请等待挂起的工作和消息完成。"
},
"drop": {
"title": "拖放以附加到此聊天",
diff --git a/src/shared/agent-session-conversation-command.ts b/src/shared/agent-session-conversation-command.ts
index 8b5ecb6ccf04..2aba1790ad84 100644
--- a/src/shared/agent-session-conversation-command.ts
+++ b/src/shared/agent-session-conversation-command.ts
@@ -23,6 +23,13 @@ export type AgentSessionConversationCommandRecord = AgentSessionConversationComm
phase: 'prepared' | 'committed'
}
+/** A newer host may answer with a command this build doesn't know. */
+export function isAgentSessionConversationCommand(
+ value: unknown
+): value is AgentSessionConversationCommand {
+ return value === 'clear' || value === 'compact'
+}
+
export function isAgentSessionConversationCommandResult(
value: unknown
): value is AgentSessionConversationCommandResult {
@@ -31,7 +38,7 @@ export function isAgentSessionConversationCommandResult(
}
const row = value as AgentSessionConversationCommandResult
return (
- (row.command === 'clear' || row.command === 'compact') &&
+ isAgentSessionConversationCommand(row.command) &&
(row.state === 'completed' || row.state === 'unknown') &&
(row.replacementSessionId === undefined ||
(typeof row.replacementSessionId === 'string' &&
diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts
new file mode 100644
index 000000000000..9f15a9f7e34a
--- /dev/null
+++ b/src/shared/agent-session-failure-copy.ts
@@ -0,0 +1,110 @@
+// The pieces every failure sentence is made of, in English. The host fills them in as they are;
+// desktop translates each piece whole with this as its fallback, so the two never differ.
+
+/** Sentences a refusal notice shows too, so a chat says them one way. */
+export const TERMINAL_AGENT_HOLDS_CHAT = 'This chat is still open in a terminal agent.'
+export const QUIT_TERMINAL_AGENT = 'Quit that agent to continue the chat here.'
+export const START_NEW_CHAT = 'Start a new chat to continue.'
+
+/** Every piece a failure sentence is made of, whole so desktop can translate each on its own.
+ * `{{agent}}` is the agent's name or `theAgent`, `{{command}}` a conversation command's name; the
+ * host fills them in English. */
+export const AGENT_SESSION_FAILURE_COPY = {
+ theAgent: 'The agent',
+ providerStartFailed: '{{agent}} stopped before it finished starting.',
+ runCommandAgain: 'Run /{{command}} again.',
+ sendToTryAgain: 'Send your message to try again.',
+ couldNotStart: "{{agent}} couldn't start.",
+ couldNotRestart: "{{agent}} couldn't restart.",
+ terminalAgentHoldsChat: TERMINAL_AGENT_HOLDS_CHAT,
+ quitTerminalAgent: QUIT_TERMINAL_AGENT,
+ startNewChat: START_NEW_CHAT,
+ notSignedIn: '{{agent}} is not signed in for the selected account.',
+ signInFirst: 'Sign in first.',
+ signInThenRunCommand: 'Sign in, then run /{{command}} again.',
+ signInThenSend: 'Sign in, then send your message again.',
+ historyTooLarge: "This conversation's history is too large to restore here.",
+ managedAccountEnvOverride:
+ 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.',
+ accountSwitchInProgress: 'A Claude account switch is in progress. Try again after it finishes.',
+ managedAccountUnsupported:
+ 'While a Claude account is added in WSL, Claude chats need a Windows Claude account.',
+ chooseClaudeAccount: 'Choose or add one in Claude Accounts settings.',
+ chooseClaudeAccountThenRunCommand:
+ 'Choose or add one in Claude Accounts settings, then run /{{command}} again.',
+ chooseClaudeAccountThenSend:
+ 'Choose or add one in Claude Accounts settings, then send your message again.',
+ providerExitedRow:
+ '{{agent}} stopped while this response was in progress. You can continue in this conversation.',
+ providerExitedRejection: '{{agent}} stopped before this message was sent.',
+ providerRejected: 'The provider did not accept this message.',
+ providerRejectedQuoted: 'The provider did not accept this message: {{detail}}.',
+ attachmentEmpty: 'An image on this message is empty, so the message was not sent.',
+ attachmentTooLarge: 'An image on this message is too large, so the message was not sent.',
+ attachmentLargerThan:
+ 'An image on this message is larger than {{size}} MB, so the message was not sent.',
+ attachmentTooMany: 'This message has too many images, so it was not sent.',
+ attachmentAtMost:
+ '{{agent}} accepts at most {{limit}} images in one message, so this message was not sent.',
+ attachmentTotalTooLarge:
+ 'The images on this message are too large together, so the message was not sent.',
+ attachmentTotalMoreThan:
+ 'The images on this message add up to more than {{size}} MB, so the message was not sent.',
+ attachmentUnsupportedType:
+ '{{agent}} accepts only PNG, JPEG, GIF, and WebP images, so this message was not sent.',
+ attachmentNotAFile: "An image on this message isn't a file, so the message was not sent.",
+ attachmentNoSource: 'An image on this message has no file to send, so the message was not sent.',
+ attachmentInvalid: "An attachment on this message can't be sent to the agent.",
+ attachmentUnreadable:
+ "An attachment on this message couldn't be read, so the message was not sent.",
+ emptyMessage: 'This message is empty, so it was not sent.',
+ queueFull: 'Too many messages were waiting for the agent, so this one was not sent.',
+ writeFailed: "Orca couldn't hand this message to the agent, so it was not sent.",
+ cancelled: 'This message was withdrawn before the agent started it.',
+ chatClosed: 'The chat closed before this message was sent.',
+ hostRestarted: 'Orca restarted before this message was sent.',
+ notDelivered: 'This message was not delivered.',
+ notDeliveredSendAgain: 'This message was not delivered. Send it again to continue.',
+ commandRefused: "This command didn't run.",
+ commandRefusedTryAgain: "This command didn't run. Try it again.",
+ compactionFailed: 'Compaction failed.',
+ compactionFailedQuoted: 'Compaction failed: {{detail}}.',
+ compactionUnconfirmed: 'Compaction completion is unconfirmed.',
+ cancelUnconfirmed: 'Cancellation was not confirmed.',
+ stopRefused: "{{agent}} didn't stop.",
+ stopRefusedQuoted: "{{agent}} didn't stop: {{detail}}.",
+ noTurnToStop: '{{agent}} had no turn running to stop.',
+ answerUnconfirmed: 'Your answer was recorded but the agent did not confirm it.',
+ hostFault: "Orca ran into a problem, so this didn't go through.",
+ hostFaultTryAgain: "Orca ran into a problem, so this didn't go through. Try again.",
+ hostStopped: '{{agent}} never finished starting, so Orca stopped it.',
+ providerRateLimited: '{{agent}} is rate-limited and retrying.',
+ providerRetrying: '{{agent}} hit a temporary problem and is retrying.'
+} as const
+
+export type AgentSessionFailureCopyId = keyof typeof AGENT_SESSION_FAILURE_COPY
+
+/** What a piece's `{{name}}` placeholders stand for. */
+export type AgentSessionFailureCopyValues = {
+ agent?: string
+ command?: string
+ detail?: string
+ limit?: string
+ size?: string
+}
+
+/** One piece in the reader's language, placeholders filled. */
+export type AgentSessionFailureSay = (
+ id: AgentSessionFailureCopyId,
+ values?: AgentSessionFailureCopyValues
+) => string
+
+/** The host's words, and any surface without translations. */
+export const sayAgentSessionFailureEnglish: AgentSessionFailureSay = (id, values = {}) => {
+ const filled = new Map(Object.entries(values))
+ // One pass over the template, so a provider's words are never read as a placeholder.
+ return AGENT_SESSION_FAILURE_COPY[id].replace(
+ /\{\{(\w+)\}\}/g,
+ (placeholder, name: string) => filled.get(name) ?? placeholder
+ )
+}
diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts
index 2b334cf9629f..8310387a6dec 100644
--- a/src/shared/agent-session-failure-words.ts
+++ b/src/shared/agent-session-failure-words.ts
@@ -3,8 +3,9 @@
// A row's `text`, a rejected message's `reason` and a conversation command's `error` are what
// every released client prints as they are, so the host writes them here, from the fact, and
// nowhere else: never Orca's own error text, a refusal's message, or a probe's evidence. A
-// provider's words reach the sentence only when the provider wrote them for a person. The table
-// is also the English default for a client that chooses its own copy from the fact.
+// provider's words reach the sentence only when the provider wrote them for a person. Each sentence
+// is built from the pieces in `agent-session-failure-copy`, which desktop translates to word the
+// same fact in the reader's language.
import {
isSubmissionRejectionFact,
@@ -17,7 +18,14 @@ import {
type SubmissionRejectionKind
} from './agent-session-failure'
import type { AgentSessionConversationCommand } from './agent-session-conversation-command'
+import {
+ sayAgentSessionFailureEnglish,
+ type AgentSessionFailureCopyId,
+ type AgentSessionFailureCopyValues,
+ type AgentSessionFailureSay
+} from './agent-session-failure-copy'
import type { AgentSessionWireRefusalCode } from './agent-session-wire-refusals'
+import { joinSentences } from './sentence-joining'
import {
DISPATCH_REJECTED_CANCELLED,
DISPATCH_REJECTED_CODEX_QUEUE_FULL,
@@ -82,11 +90,6 @@ export const START_REFUSAL_RESUMABLE: Record string
-function quotingPersonDetail(lead: string, detail: ProviderDiagnostic | undefined): string {
+function agent(say: AgentSessionFailureSay, { agentName }: AgentSessionFailureWordsContext) {
+ return { agent: agentName ?? say('theAgent') }
+}
+
+function quotingPersonDetail(
+ say: AgentSessionFailureSay,
+ lead: AgentSessionFailureCopyId,
+ quotedLead: AgentSessionFailureCopyId,
+ detail: ProviderDiagnostic | undefined,
+ values: AgentSessionFailureCopyValues = {}
+): string {
const quoted =
detail?.audience === 'person'
? detail.text
@@ -105,132 +119,150 @@ function quotingPersonDetail(lead: string, detail: ProviderDiagnostic | undefine
.trim()
.replace(/[.\s]+$/, '')
: ''
- return quoted ? `${lead}: ${quoted}.` : `${lead}.`
-}
-
-/** What to do once the start can work, for a sentence that ends in it. */
-function retryStep({ command }: AgentSessionFailureWordsContext): string {
- return command ? `run /${command} again` : 'send your message again'
+ return quoted ? say(quotedLead, { ...values, detail: quoted }) : say(lead, values)
}
/** The next step after a start or restart that failed: the command, or the message, again. */
-function startRetry({ command, retryControl }: AgentSessionFailureWordsContext): string {
+function startRetry(
+ say: AgentSessionFailureSay,
+ { command, retryControl }: AgentSessionFailureWordsContext
+): string[] {
if (retryControl) {
- return ''
+ return []
}
- return command ? ` Run /${command} again.` : ' Send your message to try again.'
+ return [command ? say('runCommandAgain', { command }) : say('sendToTryAgain')]
}
-function couldNot(verb: string): Sentence {
- return (context, fact) => {
- const { agentName } = context
- const failed = `${agentName ?? 'The agent'} couldn't ${verb}.`
+function couldNot(verb: 'couldNotStart' | 'couldNotRestart'): Sentence {
+ return (context, fact, _surface, say) => {
+ const failed = say(verb, agent(say, context))
// Only a terminal agent an older build recorded holds a claim; quitting it frees the chat.
if (fact.refusal?.details?.reason === 'claimConflicted') {
- return `${failed} ${TERMINAL_AGENT_HOLDS_CHAT} ${QUIT_TERMINAL_AGENT}`
+ return joinSentences([failed, say('terminalAgentHoldsChat'), say('quitTerminalAgent')])
}
const code = fact.refusal?.code
- return code && !START_REFUSAL_RESUMABLE[code]
- ? `${failed} ${START_NEW_CHAT}`
- : `${failed}${startRetry(context)}`
+ return joinSentences(
+ code && !START_REFUSAL_RESUMABLE[code]
+ ? [failed, say('startNewChat')]
+ : [failed, ...startRetry(say, context)]
+ )
}
}
+// The number only; each language's sentence carries its own unit.
function megabytes(bytes: number): string {
- return `${Math.round((bytes / BYTES_PER_MB) * 10) / 10} MB`
+ return String(Math.round((bytes / BYTES_PER_MB) * 10) / 10)
}
-const NOT_SENT = 'so the message was not sent.'
-
const ATTACHMENT_SENTENCES = {
- empty: () => `An image on this message is empty, ${NOT_SENT}`,
- tooLarge: (_, { limit }) =>
- limit
- ? `An image on this message is larger than ${megabytes(limit)}, ${NOT_SENT}`
- : `An image on this message is too large, ${NOT_SENT}`,
- tooMany: ({ agentName }, { limit }) =>
+ empty: (say) => say('attachmentEmpty'),
+ tooLarge: (say, _, { limit }) =>
+ limit ? say('attachmentLargerThan', { size: megabytes(limit) }) : say('attachmentTooLarge'),
+ tooMany: (say, context, { limit }) =>
limit
- ? `${agentName ?? 'The agent'} accepts at most ${limit} images in one message, so this message was not sent.`
- : 'This message has too many images, so it was not sent.',
- totalTooLarge: (_, { limit }) =>
+ ? say('attachmentAtMost', { ...agent(say, context), limit: String(limit) })
+ : say('attachmentTooMany'),
+ totalTooLarge: (say, _, { limit }) =>
limit
- ? `The images on this message add up to more than ${megabytes(limit)}, ${NOT_SENT}`
- : `The images on this message are too large together, ${NOT_SENT}`,
- unsupportedType: ({ agentName }) =>
- `${agentName ?? 'The agent'} accepts only PNG, JPEG, GIF, and WebP images, so this message was not sent.`,
- notAFile: () => `An image on this message isn't a file, ${NOT_SENT}`,
- noSource: () => `An image on this message has no file to send, ${NOT_SENT}`
+ ? say('attachmentTotalMoreThan', { size: megabytes(limit) })
+ : say('attachmentTotalTooLarge'),
+ unsupportedType: (say, context) => say('attachmentUnsupportedType', agent(say, context)),
+ notAFile: (say) => say('attachmentNotAFile'),
+ noSource: (say) => say('attachmentNoSource')
} satisfies Record<
AgentSessionAttachmentProblemReason,
- (context: AgentSessionFailureWordsContext, problem: AgentSessionAttachmentProblem) => string
+ (
+ say: AgentSessionFailureSay,
+ context: AgentSessionFailureWordsContext,
+ problem: AgentSessionAttachmentProblem
+ ) => string
>
const FAILURE_SENTENCES = {
- providerStartFailed: (context) =>
- `${context.agentName ?? 'The agent'} stopped before it finished starting.${startRetry(context)}`,
- startFailed: couldNot('start'),
+ providerStartFailed: (context, _fact, _surface, say) =>
+ joinSentences([say('providerStartFailed', agent(say, context)), ...startRetry(say, context)]),
+ startFailed: couldNot('couldNotStart'),
// Beside a Retry the resend is the button, but signing in is still a step to take first.
- notSignedIn: (context) =>
- `${context.agentName ?? 'The agent'} is not signed in for the selected account. ${context.retryControl ? 'Sign in first.' : `Sign in, then ${retryStep(context)}.`}`,
- historyTooLarge: () =>
- `This conversation's history is too large to restore here. ${START_NEW_CHAT}`,
- managedAccountEnvOverride: () =>
- 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.',
- accountSwitchInProgress: () =>
- 'A Claude account switch is in progress. Try again after it finishes.',
- managedAccountUnsupported: (context) =>
- `While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings${context.retryControl ? '' : `, then ${retryStep(context)}`}.`,
- providerExited: ({ agentName }, _, surface) =>
- surface === 'row'
- ? `${agentName ?? 'The agent'} stopped while this response was in progress. You can continue in this conversation.`
- : `${agentName ?? 'The agent'} stopped before this message was sent.`,
- restartFailed: couldNot('restart'),
- providerRejected: (_, fact) =>
- quotingPersonDetail('The provider did not accept this message', fact.detail),
- attachmentInvalid: (context, fact) =>
+ notSignedIn: (context, _fact, _surface, say) =>
+ joinSentences([
+ say('notSignedIn', agent(say, context)),
+ context.retryControl
+ ? say('signInFirst')
+ : context.command
+ ? say('signInThenRunCommand', { command: context.command })
+ : say('signInThenSend')
+ ]),
+ historyTooLarge: (_context, _fact, _surface, say) =>
+ joinSentences([say('historyTooLarge'), say('startNewChat')]),
+ managedAccountEnvOverride: (_context, _fact, _surface, say) => say('managedAccountEnvOverride'),
+ accountSwitchInProgress: (_context, _fact, _surface, say) => say('accountSwitchInProgress'),
+ managedAccountUnsupported: (context, _fact, _surface, say) =>
+ joinSentences([
+ say('managedAccountUnsupported'),
+ context.retryControl
+ ? say('chooseClaudeAccount')
+ : context.command
+ ? say('chooseClaudeAccountThenRunCommand', { command: context.command })
+ : say('chooseClaudeAccountThenSend')
+ ]),
+ providerExited: (context, _fact, surface, say) =>
+ say(surface === 'row' ? 'providerExitedRow' : 'providerExitedRejection', agent(say, context)),
+ restartFailed: couldNot('couldNotRestart'),
+ providerRejected: (_context, fact, _surface, say) =>
+ quotingPersonDetail(say, 'providerRejected', 'providerRejectedQuoted', fact.detail),
+ attachmentInvalid: (context, fact, _surface, say) =>
fact.attachment
- ? ATTACHMENT_SENTENCES[fact.attachment.reason](context, fact.attachment)
- : "An attachment on this message can't be sent to the agent.",
- attachmentUnreadable: () => `An attachment on this message couldn't be read, ${NOT_SENT}`,
- emptyMessage: () => 'This message is empty, so it was not sent.',
- queueFull: () => 'Too many messages were waiting for the agent, so this one was not sent.',
- writeFailed: () => "Orca couldn't hand this message to the agent, so it was not sent.",
- cancelled: () => 'This message was withdrawn before the agent started it.',
- chatClosed: () => 'The chat closed before this message was sent.',
- hostRestarted: () => 'Orca restarted before this message was sent.',
- notDelivered: ({ retryControl }) =>
- retryControl
- ? 'This message was not delivered.'
- : 'This message was not delivered. Send it again to continue.',
- commandRefused: ({ retryControl }) =>
- `This command didn't run.${retryControl ? '' : ' Try it again.'}`,
- compactionFailed: (_, fact) => quotingPersonDetail('Compaction failed', fact.detail),
- compactionUnconfirmed: () => 'Compaction completion is unconfirmed.',
- cancelUnconfirmed: () => 'Cancellation was not confirmed.',
+ ? ATTACHMENT_SENTENCES[fact.attachment.reason](say, context, fact.attachment)
+ : say('attachmentInvalid'),
+ attachmentUnreadable: (_context, _fact, _surface, say) => say('attachmentUnreadable'),
+ emptyMessage: (_context, _fact, _surface, say) => say('emptyMessage'),
+ queueFull: (_context, _fact, _surface, say) => say('queueFull'),
+ writeFailed: (_context, _fact, _surface, say) => say('writeFailed'),
+ cancelled: (_context, _fact, _surface, say) => say('cancelled'),
+ chatClosed: (_context, _fact, _surface, say) => say('chatClosed'),
+ hostRestarted: (_context, _fact, _surface, say) => say('hostRestarted'),
+ notDelivered: ({ retryControl }, _fact, _surface, say) =>
+ say(retryControl ? 'notDelivered' : 'notDeliveredSendAgain'),
+ commandRefused: ({ retryControl }, _fact, _surface, say) =>
+ say(retryControl ? 'commandRefused' : 'commandRefusedTryAgain'),
+ compactionFailed: (_context, fact, _surface, say) =>
+ quotingPersonDetail(say, 'compactionFailed', 'compactionFailedQuoted', fact.detail),
+ compactionUnconfirmed: (_context, _fact, _surface, say) => say('compactionUnconfirmed'),
+ cancelUnconfirmed: (_context, _fact, _surface, say) => say('cancelUnconfirmed'),
// The agent was reached and declined, so the sentence says that, not that the Stop was lost.
- stopRefused: ({ agentName }, fact) =>
+ stopRefused: (context, fact, _surface, say) =>
fact.detail?.audience === 'person'
- ? quotingPersonDetail(`${agentName ?? 'The agent'} didn't stop`, fact.detail)
- : `${agentName ?? 'The agent'} had no turn running to stop.`,
- answerUnconfirmed: () => 'Your answer was recorded but the agent did not confirm it.',
- hostFault: ({ retryControl }) =>
- `Orca ran into a problem, so this didn't go through.${retryControl ? '' : ' Try again.'}`,
- hostStopped: ({ agentName }) =>
- `${agentName ?? 'The agent'} never finished starting, so Orca stopped it.`,
- providerRetrying: ({ agentName }, { retry }) =>
- retry?.error === 'rate_limit' || retry?.status === 429
- ? `${agentName ?? 'The agent'} is rate-limited and retrying.`
- : `${agentName ?? 'The agent'} hit a temporary problem and is retrying.`
+ ? quotingPersonDetail(
+ say,
+ 'stopRefused',
+ 'stopRefusedQuoted',
+ fact.detail,
+ agent(say, context)
+ )
+ : say('noTurnToStop', agent(say, context)),
+ answerUnconfirmed: (_context, _fact, _surface, say) => say('answerUnconfirmed'),
+ hostFault: ({ retryControl }, _fact, _surface, say) =>
+ say(retryControl ? 'hostFault' : 'hostFaultTryAgain'),
+ hostStopped: (context, _fact, _surface, say) => say('hostStopped', agent(say, context)),
+ providerRetrying: (context, { retry }, _surface, say) =>
+ say(
+ retry?.error === 'rate_limit' || retry?.status === 429
+ ? 'providerRateLimited'
+ : 'providerRetrying',
+ agent(say, context)
+ )
} satisfies Record
/** The sentence a person reads for this fact on this surface; never a marker. */
export function agentSessionFailureSentence(
fact: AgentSessionFailureFact,
surface: AgentSessionFailureSurface,
- context: AgentSessionFailureWordsContext = {}
+ context: AgentSessionFailureWordsContext = {},
+ /** Desktop passes its translations; the host and the phone keep English. */
+ say: AgentSessionFailureSay = sayAgentSessionFailureEnglish
): string {
const sentence: Sentence = FAILURE_SENTENCES[fact.kind]
- return sentence(context, fact, surface)
+ return sentence(context, fact, surface, say)
}
/** The markers released clients hide, for the rejections that had one before rows carried a fact.
diff --git a/src/shared/agent-session-failure.test.ts b/src/shared/agent-session-failure.test.ts
index 4dc12f9bfe8b..48d79c7d7308 100644
--- a/src/shared/agent-session-failure.test.ts
+++ b/src/shared/agent-session-failure.test.ts
@@ -3,9 +3,13 @@ import {
MAX_PROVIDER_DIAGNOSTIC_CHARS,
providerDiagnostic,
providerDiagnosticOf,
+ agentSessionFailureFact,
readAgentSessionFailureFact,
+ readProviderRetry,
+ readWholeAgentSessionFailureFact,
withProviderDiagnostic
} from './agent-session-failure'
+import { AGENT_SESSION_REFUSAL_REASONS } from './agent-session-refusal-details'
describe('provider diagnostics', () => {
it('bounds the text and records nothing for an empty one', () => {
@@ -91,3 +95,47 @@ describe('reading a failure fact', () => {
).toEqual({ kind: 'restartFailed', refusal: { code: 'agent_session_conflict' } })
})
})
+
+describe('reading all of a failure fact', () => {
+ it('reads every part of a fact the host built, as it arrives off the wire', () => {
+ for (const fact of [
+ agentSessionFailureFact('startFailed', {
+ refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } }
+ }),
+ agentSessionFailureFact('providerRejected', {
+ detail: providerDiagnostic('Image type .bmp', 'person')
+ }),
+ agentSessionFailureFact('attachmentInvalid', {
+ attachment: { reason: 'tooLarge', limit: 5 * 1024 * 1024 }
+ }),
+ agentSessionFailureFact('providerRetrying', {
+ retry: readProviderRetry({ error: 'rate_limit', status: 429 })
+ })
+ ]) {
+ expect(readWholeAgentSessionFailureFact(JSON.parse(JSON.stringify(fact)))).toEqual(fact)
+ }
+ // Every reason a refusal can name, on the code that names it.
+ for (const [code, reasons] of Object.entries(AGENT_SESSION_REFUSAL_REASONS)) {
+ for (const reason of reasons) {
+ const fact = { kind: 'startFailed', refusal: { code, details: { reason } } }
+ expect(readWholeAgentSessionFailureFact(fact)).toEqual(fact)
+ }
+ }
+ })
+
+ it('reads nothing when this build would drop any part, however deep', () => {
+ for (const value of [
+ { kind: 'futureKind' },
+ { kind: 'startFailed', refusal: { code: 'agent_session_future_code' } },
+ {
+ kind: 'startFailed',
+ refusal: { code: 'agent_session_conflict', details: { reason: 'futureReason' } }
+ },
+ { kind: 'attachmentInvalid', attachment: { reason: 'futureReason' } },
+ { kind: 'providerRejected', detail: { text: 'x', audience: 'future' } },
+ { kind: 'startFailed', futurePart: {} }
+ ]) {
+ expect(readWholeAgentSessionFailureFact(value)).toBeUndefined()
+ }
+ })
+})
diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts
index 13568348f348..1f8a470588bf 100644
--- a/src/shared/agent-session-failure.ts
+++ b/src/shared/agent-session-failure.ts
@@ -6,6 +6,7 @@
// value the provider wrote — never recovered from a string afterwards, since by then nothing can
// tell a provider's sentence from Orca's.
+import { structuralValuesEqualIgnoringUndefined } from './structural-value-equality'
import {
readAgentSessionRefusalReference,
type AgentSessionRefusalReference
@@ -233,6 +234,16 @@ export function readAgentSessionFailureFact(value: unknown): AgentSessionFailure
})
}
+/** The fact only when this build read all of it. Anything it dropped, such as a newer refusal code
+ * or reason, may change the advice, so a surface that would re-word the fact shows the host's
+ * sentence instead. */
+export function readWholeAgentSessionFailureFact(
+ value: unknown
+): AgentSessionFailureFact | undefined {
+ const fact = readAgentSessionFailureFact(value)
+ return fact && structuralValuesEqualIgnoringUndefined(fact, value) ? fact : undefined
+}
+
/** The provider-authored diagnostic an error carries, set only where it was composed. Follows the
* `cause` chain, since wrappers such as the acquisition errors keep the original as their cause. */
export function providerDiagnosticOf(error: unknown): ProviderDiagnostic | undefined {
diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts
index cdd3639e6a7a..9037e219044a 100644
--- a/src/shared/agent-session-refusal-notice.test.ts
+++ b/src/shared/agent-session-refusal-notice.test.ts
@@ -14,6 +14,7 @@ import {
} from './agent-session-refusal-notice'
import {
AGENT_SESSION_WRITE_NOTICE_COPY,
+ agentSessionNoticeSaysThisChatUnread,
type AgentSessionWriteNoticeSentence
} from './agent-session-write-notice-copy'
import { AGENT_SESSION_REFUSAL_REASONS } from './agent-session-refusal-details'
@@ -393,22 +394,25 @@ describe('the notice for every reason a host names', () => {
})
).toEqual(['historyUnavailable', 'notDoneSend'])
expect(
- send({
- kind: 'refused',
- code: 'agent_session_operation_invalid',
- details: { reason: 'notSignedIn' }
- })
- ).toEqual([
- 'notDoneSend',
- { text: 'Codex is not signed in for the selected account. Sign in first.' }
- ])
+ agentSessionWriteNoticeEnglish(
+ send({
+ kind: 'refused',
+ code: 'agent_session_operation_invalid',
+ details: { reason: 'notSignedIn' }
+ })
+ )
+ ).toBe(
+ 'Your message was not sent. Codex is not signed in for the selected account. Sign in first.'
+ )
expect(
- send({
- kind: 'refused',
- code: 'agent_session_operation_invalid',
- details: { reason: 'providerStartFailed' }
- })
- ).toEqual(['notDoneSend', { text: 'Codex stopped before it finished starting.' }])
+ agentSessionWriteNoticeEnglish(
+ send({
+ kind: 'refused',
+ code: 'agent_session_operation_invalid',
+ details: { reason: 'providerStartFailed' }
+ })
+ )
+ ).toBe('Your message was not sent. Codex stopped before it finished starting.')
})
it('says the write did not happen, once, and never shows the host message', () => {
@@ -438,10 +442,14 @@ describe('the notice for every reason a host names', () => {
code: 'agent_session_operation_invalid',
details: { reason: 'notSignedIn' }
})
+ // The fact, not its English, so desktop words it in the reader's language.
expect(agentSessionWriteNoticeParts(failure, 'send')).toEqual([
'notDoneSend',
- { text: agentSessionFailureSentence({ kind: 'notSignedIn' }, 'rejection') }
+ { failure: { kind: 'notSignedIn' }, surface: 'rejection', context: {} }
])
+ expect(agentSessionWriteNoticeEnglish(agentSessionWriteNoticeParts(failure, 'send'))).toBe(
+ `Your message was not sent. ${agentSessionFailureSentence({ kind: 'notSignedIn' }, 'rejection')}`
+ )
// Its next step is to send the message again, which a Stop cannot do.
expect(agentSessionWriteNoticeParts(failure, 'stop')).toEqual(['notDoneStop'])
})
@@ -572,6 +580,23 @@ describe('a chat whose history the host could not open', () => {
).toEqual(['historyUnreadable'])
})
+ // A pane headed by such words drops its own "didn't load", so each one must count.
+ it("knows which read notices already say this chat's history did not load", () => {
+ for (const failure of [...FAILURES, ...REASONED]) {
+ for (const retryControl of [false, true]) {
+ const parts = agentSessionWriteNoticeParts(failure, 'read-history', { retryControl })
+ // "Chats were saved by a newer Orca" is about every chat, not this one.
+ const notAboutThisRead =
+ mayHaveRun(failure) ||
+ codeOf(failure) === 'structured_agent_session_unsupported' ||
+ parts.includes('savedByNewerOrca')
+ expect(agentSessionNoticeSaysThisChatUnread(parts), JSON.stringify(parts)).toBe(
+ !notAboutThisRead
+ )
+ }
+ }
+ })
+
it('says only that the history did not load for any other read refusal', () => {
expect(agentSessionReadHistoryRefusalParts('agent_session_ownership_unknown')).toEqual([
'notDoneReadHistory'
@@ -594,16 +619,16 @@ describe('agentSessionRefusalCauseParts', () => {
})
).toEqual(['turnActive', 'waitForTurn'])
expect(
- agentSessionRefusalCauseParts({
- kind: 'refused',
- code: 'agent_session_operation_invalid',
- details: { reason: 'historyTooLarge' }
- })
- ).toEqual([
- {
- text: "This conversation's history is too large to restore here. Start a new chat to continue."
- }
- ])
+ agentSessionWriteNoticeEnglish(
+ agentSessionRefusalCauseParts({
+ kind: 'refused',
+ code: 'agent_session_operation_invalid',
+ details: { reason: 'historyTooLarge' }
+ })
+ )
+ ).toBe(
+ "This conversation's history is too large to restore here. Start a new chat to continue."
+ )
})
it('leaves out a step the Retry beside it takes', () => {
@@ -615,33 +640,37 @@ describe('agentSessionRefusalCauseParts', () => {
})
).toEqual(['historyUnavailable'])
expect(
- agentSessionRefusalCauseParts(
- {
+ agentSessionWriteNoticeEnglish(
+ agentSessionRefusalCauseParts(
+ {
+ kind: 'refused',
+ code: 'agent_session_operation_invalid',
+ details: { reason: 'notSignedIn' }
+ },
+ { agentName: 'Claude' }
+ )
+ )
+ ).toBe('Claude is not signed in for the selected account. Sign in first.')
+ expect(
+ agentSessionWriteNoticeEnglish(
+ agentSessionRefusalCauseParts({
kind: 'refused',
code: 'agent_session_operation_invalid',
- details: { reason: 'notSignedIn' }
- },
- { agentName: 'Claude' }
+ details: { reason: 'providerStartFailed' }
+ })
)
- ).toEqual([{ text: 'Claude is not signed in for the selected account. Sign in first.' }])
+ ).toBe('The agent stopped before it finished starting.')
expect(
- agentSessionRefusalCauseParts({
- kind: 'refused',
- code: 'agent_session_operation_invalid',
- details: { reason: 'providerStartFailed' }
- })
- ).toEqual([{ text: 'The agent stopped before it finished starting.' }])
- expect(
- agentSessionRefusalCauseParts({
- kind: 'refused',
- code: 'agent_session_operation_invalid',
- details: { reason: 'managedAccountUnsupported' }
- })
- ).toEqual([
- {
- text: 'While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings.'
- }
- ])
+ agentSessionWriteNoticeEnglish(
+ agentSessionRefusalCauseParts({
+ kind: 'refused',
+ code: 'agent_session_operation_invalid',
+ details: { reason: 'managedAccountUnsupported' }
+ })
+ )
+ ).toBe(
+ 'While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings.'
+ )
})
it('writes the same sentences as before where no Retry stands beside them', () => {
diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts
index 6c2afcfebf70..b2021797bed6 100644
--- a/src/shared/agent-session-refusal-notice.ts
+++ b/src/shared/agent-session-refusal-notice.ts
@@ -17,6 +17,7 @@ import {
} from './agent-session-failure-words'
import type { AgentSessionRefusalReason } from './agent-session-refusal-details'
import {
+ AGENT_SESSION_HISTORY_UNREAD_CAUSES,
AGENT_SESSION_WRITE_NOTICE_COPY,
type AgentSessionWriteNoticePart,
type AgentSessionWriteNoticeSentence
@@ -227,21 +228,13 @@ export function agentSessionRefusalReasonWords(
return reason === undefined ? undefined : byReason?.[reason]
}
-// Each already says the history can't be read here.
-const HISTORY_CAUSES: ReadonlySet = new Set([
- 'historyUnusable',
- 'historyUnavailable',
- 'historyUnreadable',
- 'savedByNewerOrca'
-])
-
/** A cause, and that the request did not happen unless the cause already says so. */
function causeParts(
cause: AgentSessionWriteNoticeSentence,
write: AgentSessionWriteKind
): AgentSessionWriteNoticeSentence[] {
const saysNotDone =
- (write === 'read-history' && HISTORY_CAUSES.has(cause)) ||
+ (write === 'read-history' && AGENT_SESSION_HISTORY_UNREAD_CAUSES.has(cause)) ||
(cause === 'questionChanged' && write === 'answer')
return saysNotDone ? [cause] : [cause, NOT_DONE[write]]
}
@@ -257,9 +250,8 @@ function reasonParts(
return undefined
}
if ('fact' in words) {
- const sentence = agentSessionFailureSentence({ kind: words.fact }, 'rejection', context)
return write === 'send' || write === 'composer-send'
- ? [NOT_DONE[write], { text: sentence }]
+ ? [NOT_DONE[write], { failure: { kind: words.fact }, surface: 'rejection', context }]
: undefined
}
const said = causeParts(words.cause, write)
@@ -343,7 +335,13 @@ export function agentSessionWriteNoticeEnglish(
parts: readonly AgentSessionWriteNoticePart[]
): string {
return parts
- .map((part) => (typeof part === 'string' ? AGENT_SESSION_WRITE_NOTICE_COPY[part] : part.text))
+ .map((part) =>
+ typeof part === 'string'
+ ? AGENT_SESSION_WRITE_NOTICE_COPY[part]
+ : 'text' in part
+ ? part.text
+ : agentSessionFailureSentence(part.failure, part.surface, part.context)
+ )
.join(' ')
}
diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts
index 97bd97cbf811..e5e6d011311b 100644
--- a/src/shared/agent-session-write-notice-copy.ts
+++ b/src/shared/agent-session-write-notice-copy.ts
@@ -1,9 +1,14 @@
// The sentences a chat notice is made of, each whole so desktop can translate it on its own.
+import type { AgentSessionFailureFact } from './agent-session-failure'
import {
QUIT_TERMINAL_AGENT,
START_NEW_CHAT,
TERMINAL_AGENT_HOLDS_CHAT
+} from './agent-session-failure-copy'
+import type {
+ AgentSessionFailureSurface,
+ AgentSessionFailureWordsContext
} from './agent-session-failure-words'
/** Every sentence a notice is made of. Desktop translates each whole sentence with this as its
@@ -62,5 +67,33 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = {
} as const
export type AgentSessionWriteNoticeSentence = keyof typeof AGENT_SESSION_WRITE_NOTICE_COPY
-/** A notice as whole sentences, each translated on its own; `text` is a provider's own words. */
-export type AgentSessionWriteNoticePart = AgentSessionWriteNoticeSentence | { text: string }
+/** A failure fact, worded where it is shown so desktop can say it in the reader's language. */
+export type AgentSessionWriteNoticeFailurePart = {
+ failure: AgentSessionFailureFact
+ surface: AgentSessionFailureSurface
+ context: AgentSessionFailureWordsContext
+}
+/** A notice as whole sentences, each translated on its own; `text` is words someone else wrote: a
+ * provider's, or a host's sentence with no fact beside it. */
+export type AgentSessionWriteNoticePart =
+ | AgentSessionWriteNoticeSentence
+ | { text: string }
+ | AgentSessionWriteNoticeFailurePart
+
+/** Causes that already say the history can't be read here, so no sentence after them says it
+ * again. */
+export const AGENT_SESSION_HISTORY_UNREAD_CAUSES: ReadonlySet =
+ new Set(['historyUnusable', 'historyUnavailable', 'historyUnreadable', 'savedByNewerOrca'])
+
+/** Whether these words already say this chat's history didn't load, so a pane headed by them need
+ * only add that it keeps trying. "Chats were saved by a newer Orca" names no one chat for "it". */
+export function agentSessionNoticeSaysThisChatUnread(
+ parts: readonly AgentSessionWriteNoticePart[]
+): boolean {
+ return parts.some(
+ (part) =>
+ typeof part === 'string' &&
+ part !== 'savedByNewerOrca' &&
+ (part === 'notDoneReadHistory' || AGENT_SESSION_HISTORY_UNREAD_CAUSES.has(part))
+ )
+}
diff --git a/src/shared/native-chat-empty-state.ts b/src/shared/native-chat-empty-state.ts
index 3ef7b1a19025..2957698ae09f 100644
--- a/src/shared/native-chat-empty-state.ts
+++ b/src/shared/native-chat-empty-state.ts
@@ -30,6 +30,9 @@ export const NATIVE_CHAT_EMPTY_STATE_COPY = {
}
} as const satisfies Record
+/** The retrying half alone, under a title that already says the history didn't load. */
+export const NATIVE_CHAT_KEEPS_TRYING_COPY = 'Orca keeps trying to load it.'
+
/** Resolve the empty-state copy with the agent label substituted for `{{value0}}`.
* For platforms without an i18n layer (mobile). */
export function formatNativeChatEmptyStateCopy(
diff --git a/src/shared/sentence-joining.ts b/src/shared/sentence-joining.ts
new file mode 100644
index 000000000000..bbb97f974b06
--- /dev/null
+++ b/src/shared/sentence-joining.ts
@@ -0,0 +1,12 @@
+// Japanese and Chinese run on after a full-width terminator; any other sentence takes a space.
+const RUNS_ON_AFTER = /[。!?]$/
+
+/** Whole sentences as one passage. Each gap follows the sentence before it, not the UI language, so
+ * a sentence a language pack left in English keeps its space beside translated ones. */
+export function joinSentences(sentences: readonly string[]): string {
+ return sentences
+ .map((sentence, index) =>
+ index === 0 || RUNS_ON_AFTER.test(sentences[index - 1]) ? sentence : ` ${sentence}`
+ )
+ .join('')
+}
diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts
index 3095a3a0b948..3c2b6834114c 100644
--- a/src/shared/structured-agent-session-outbox.ts
+++ b/src/shared/structured-agent-session-outbox.ts
@@ -1,4 +1,5 @@
-import { readAgentSessionFailureFact, type AgentSessionFailureFact } from './agent-session-failure'
+import type { AgentSessionFailureFact } from './agent-session-failure'
+import { readWholeAgentSessionFailureFact } from './agent-session-failure'
import type { AgentJournalMessageItem, AgentJournalSubmission } from './agent-session-journal-types'
import {
parseAgentSessionWriteFailure,
@@ -58,13 +59,13 @@ export type StructuredAgentSessionAttemptFailure =
/** The host recorded the message and the provider turned it down, with the provider's reason. */
| { kind: 'rejected'; reason: string | null; rejection?: StructuredAgentSessionRejectionFact }
-/** The failure a rejected submission leaves on its message. A fact this build cannot place is
+/** The failure a rejected submission leaves on its message. A fact this build cannot read whole is
* dropped, leaving the reason. */
export function structuredAgentSessionRejectedFailure(submission: {
reason: string | null
rejection?: unknown
}): Extract {
- const fact = readAgentSessionFailureFact(submission.rejection)
+ const fact = readWholeAgentSessionFailureFact(submission.rejection)
return {
kind: 'rejected',
reason: submission.reason,
diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts
index 5ba2b6db96c9..885af6581adc 100644
--- a/src/shared/structured-agent-session-send-disposition.ts
+++ b/src/shared/structured-agent-session-send-disposition.ts
@@ -20,10 +20,7 @@ import {
type AgentSessionWriteRefusal
} from './agent-session-write-failure'
import type { AgentSessionFailureFact } from './agent-session-failure'
-import {
- agentSessionFailureSentence,
- type AgentSessionFailureWordsContext
-} from './agent-session-failure-words'
+import type { AgentSessionFailureWordsContext } from './agent-session-failure-words'
import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection'
import {
classifyStructuredAgentSessionSendFailure,
@@ -168,7 +165,7 @@ function rejectionFactParts(
}
// A fact this build cannot place proves only that the message did not happen.
return kind
- ? [{ text: agentSessionFailureSentence({ ...fact, kind }, 'rejection', context) }]
+ ? [{ failure: { ...fact, kind }, surface: 'rejection', context }]
: agentSessionWriteNotDoneParts(write)
}