diff --git a/config/scripts/structured-agent-session-start-failure-row-render.test.mjs b/config/scripts/structured-agent-session-start-failure-row-render.test.mjs new file mode 100644 index 000000000000..4f28a6cb747f --- /dev/null +++ b/config/scripts/structured-agent-session-start-failure-row-render.test.mjs @@ -0,0 +1,74 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { createElement } from 'react' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../src/shared/agent-session-journal-item-key.ts' +import { projectStructuredItemsToNativeChat } from '../../src/shared/structured-agent-session-projection.ts' +import { structuredAgentSessionStartFailure } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts' +import { structuredAgentSessionStartFailureRow } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts' +import { MessageRow } from '../../src/renderer/src/components/native-chat/NativeChatMessageRow.tsx' +import { i18n } from '../../src/renderer/src/i18n/i18n.ts' + +afterEach(async () => { + cleanup() + await i18n.changeLanguage('en') +}) + +describe('startup sign-in rows from host to renderer', () => { + it.each([ + ['Grok', 'Connectez-vous à Grok avec `grok login` sur l’ordinateur qui exécute ce chat.'], + [ + 'Pi', + 'Connectez-vous à Pi en exécutant `pi` puis en utilisant `/login` sur l’ordinateur qui exécute ce chat.' + ], + [ + 'OpenCode', + 'Connectez-vous à OpenCode avec `opencode auth login` sur l’ordinateur qui exécute ce chat.' + ], + ['OMP', 'Connectez-vous à OMP.'] + ])( + 'translates an actual %s startup row with its diagnostic and resend advice', + async (agentName, guidance) => { + await i18n.changeLanguage('fr') + const detail = 'Provider diagnostic: {{agent}} must remain literal.' + const startup = structuredAgentSessionStartFailure( + { + failure: { + kind: 'notSignedIn', + account: 'system', + detail: { text: detail, audience: 'person' } + } + }, + { agentName } + ) + const mutation = structuredAgentSessionStartFailureRow('generation', startup) + if (mutation.kind !== 'item' || mutation.body.kind !== 'status') { + throw new Error('Expected the startup writer to produce a status row') + } + expect(mutation.body.text).toContain('Then send your message again.') + const [message] = projectStructuredItemsToNativeChat([ + { + itemId: agentJournalItemKey(mutation.identity), + sequence: 1, + revision: 1, + observedAt: 1, + body: mutation.body, + turnScope: mutation.turnScope + } + ]) + render( + createElement(MessageRow, { + message, + agentName, + expandSignal: false, + onScrollMessageToTop: vi.fn() + }) + ) + expect( + screen.getByText(`${guidance} ${detail} Puis envoyez à nouveau votre message.`) + ).toBeInTheDocument() + expect(screen.queryByText(mutation.body.text)).toBeNull() + } + ) +}) diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 66485a110158..ee5701d1159f 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -3,6 +3,11 @@ import { SQLITE_DATABASE_RUNTIME_INCLUDE } from './vitest-sqlite-database-runtim // Real SQLite fixtures keep Node publication and close semantics. export const SQLITE_RUNTIME_INCLUDE = [ ...SQLITE_DATABASE_RUNTIME_INCLUDE, + 'src/main/acp/acp-structured-sign-in.test.ts', + 'src/main/acp/acp-structured-auth.test.ts', + 'src/main/acp/acp-timeline-turn-failures.test.ts', + 'src/main/acp/acp-structured-session-adapter.test.ts', + 'src/main/pi/rpc-turn-races.test.ts', 'src/main/active-view-persistence-boundary.test.ts', 'src/main/automations/automation-dispatch-host-fence.test.ts', 'src/main/automations/automation-owner-fencing.test.ts', @@ -125,6 +130,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts', diff --git a/mobile/src/session/mobile-dictation-refused-start-routing.test.tsx b/mobile/src/session/mobile-dictation-refused-start-routing.test.tsx index 05decb191a55..8b89129550da 100644 --- a/mobile/src/session/mobile-dictation-refused-start-routing.test.tsx +++ b/mobile/src/session/mobile-dictation-refused-start-routing.test.tsx @@ -9,6 +9,7 @@ import { createElement, useRef } from 'react' import { act, create } from 'react-test-renderer' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createFakeRpcClient, type FakeRpcClient } from '../mobile-web-shell/bridge-host-test-fakes' +import type * as NativeChatSendError from './use-mobile-native-chat-send-error' const seen = vi.hoisted(() => ({ toasts: new Array(), @@ -39,7 +40,8 @@ vi.mock('../platform/haptics', () => ({ // The native-chat surface this hook composes with. None of it is on the mic's path, and reaching // the real modules would pull the Expo runtime in behind them. -vi.mock('./use-mobile-native-chat-send-error', () => ({ +vi.mock('./use-mobile-native-chat-send-error', async (importOriginal) => ({ + ...(await importOriginal()), useMobileNativeChatSendError: () => ({ message: null, show: () => {}, @@ -62,6 +64,7 @@ vi.mock('./use-mobile-native-chat-input-lease', () => ({ })) vi.mock('./use-mobile-native-chat-controller', () => ({ useMobileNativeChatController: () => ({ + nativeChatSession: { messages: [] }, toggleTabChatView: () => {}, showNativeChat: false, showNativeChatRef: { current: false }, diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index 01e8900c0e57..4d96759d92fc 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -53,7 +53,9 @@ export type MobileNativeChatPendingItem = { baselineTailMessageId?: string | null } -export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] { +export function foldMobileNativeChatMessages( + messages: readonly NativeChatMessage[] +): NativeChatMessage[] { // The conversation only: a subagent's rows are that subagent's, and mobile shows // each spawn as its roster row (one line per child) rather than the child's own rows. // Normalize first (desktop assembler parity): image marker turns fold into diff --git a/mobile/src/session/mobile-structured-agent-session-launch.test.ts b/mobile/src/session/mobile-structured-agent-session-launch.test.ts index 2deb3042ca21..c5cd130f660e 100644 --- a/mobile/src/session/mobile-structured-agent-session-launch.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-launch.test.ts @@ -37,6 +37,26 @@ const acceptedCreateResult = { const acceptedCreate = { ok: true, result: acceptedCreateResult } describe('mobile structured agent-session launch', () => { + it.each([false, true])( + 'keeps the no-message startup fallback from the host (nested refusal %s)', + async (nested) => { + const message = "Codex isn't signed in. Run `codex login`. Credentials expired." + const refusal = { + code: 'agent_session_operation_invalid', + message, + details: { reason: 'notSignedIn' } + } + const client = clientReturning( + { ok: true, result: { supported: true } }, + nested ? { ok: true, result: { ok: false, refusal } } : { ok: false, error: refusal } + ) + const result = await createMobileStructuredAgentSession(client, 'workspace-1', 'codex') + expect(result).toEqual({ kind: 'unknown', message }) + expect( + result.kind !== 'created' && result.kind !== 'unsupported' && result.message + ).not.toContain('send your message again') + } + ) it('creates through the structured agent-session intent after support is confirmed', async () => { const client = clientReturning({ ok: true, result: { supported: true } }, acceptedCreate) diff --git a/mobile/src/session/mobile-structured-agent-session-send.ts b/mobile/src/session/mobile-structured-agent-session-send.ts index 41c6e109a30f..fcba60c16485 100644 --- a/mobile/src/session/mobile-structured-agent-session-send.ts +++ b/mobile/src/session/mobile-structured-agent-session-send.ts @@ -10,6 +10,7 @@ import { timeoutForDeadline } from './mobile-structured-agent-session-rpc' import { mobileStructuredSendDelivery } from './mobile-structured-send-delivery' +import type { MobileNativeChatSendErrorReporter } from './use-mobile-native-chat-send-error' export async function sendMobileStructuredAgentSessionMessage(input: { client: RpcClient @@ -20,7 +21,7 @@ export async function sendMobileStructuredAgentSessionMessage(input: { /** Sent only when the host advertises `agent-session.queued-messages.v1`. */ delivery?: 'queue-if-active' deadline?: number - onError: (message: string) => void + onError: MobileNativeChatSendErrorReporter }): Promise { const timeoutMs = timeoutForDeadline(input.deadline) if (timeoutMs === null) { @@ -42,7 +43,11 @@ export async function sendMobileStructuredAgentSessionMessage(input: { }) const outcome = mobileStructuredSendDelivery(result) if (outcome.error !== null) { - input.onError(outcome.error) + if (outcome.failure) { + input.onError(outcome.error, { failure: outcome.failure }) + } else { + input.onError(outcome.error) + } } return outcome.outcome } diff --git a/mobile/src/session/mobile-structured-composer-command.test.ts b/mobile/src/session/mobile-structured-composer-command.test.ts index 4d62e05da9f2..ec36a48dfe2c 100644 --- a/mobile/src/session/mobile-structured-composer-command.test.ts +++ b/mobile/src/session/mobile-structured-composer-command.test.ts @@ -149,19 +149,19 @@ describe('mobile structured conversation commands', () => { expect(await dispatchMobileStructuredCommand({ ...input, text: '/clear' })).toBe('rejected') expect(input.onError).toHaveBeenLastCalledWith( "The agent is still working. Run /clear when it's done.", - 'working' + { refusedWhile: 'working' } ) input.busy = () => 'prompt' expect(await dispatchMobileStructuredCommand({ ...input, text: '/clear' })).toBe('rejected') expect(input.onError).toHaveBeenLastCalledWith( "Answer the agent's question or approval, then run /clear.", - 'prompt' + { refusedWhile: 'prompt' } ) input.busy = () => 'working' expect(await dispatchMobileStructuredCommand(input)).toBe('rejected') expect(input.onError).toHaveBeenLastCalledWith( "The agent is still working. Run /compact when it's done.", - 'working' + { refusedWhile: 'working' } ) expect(sendRequest).not.toHaveBeenCalled() }) @@ -188,7 +188,7 @@ describe('mobile structured conversation commands', () => { expect(await dispatchMobileStructuredCommand(input)).toBe('rejected') expect(input.onError).toHaveBeenLastCalledWith( "The agent is still working. Run /compact when it's done.", - 'working' + { refusedWhile: 'working' } ) // Ahead of the phone: said as any failure, so it can't go before it is read. input.busy = () => null diff --git a/mobile/src/session/mobile-structured-composer-command.ts b/mobile/src/session/mobile-structured-composer-command.ts index d8012a5555a7..048fb8cc1407 100644 --- a/mobile/src/session/mobile-structured-composer-command.ts +++ b/mobile/src/session/mobile-structured-composer-command.ts @@ -5,7 +5,6 @@ import type { import { dispatchStructuredAgentSessionComposerCommand, isStructuredAgentSessionComposerCommand, - type StructuredAgentSessionCommandRefusalCause, type StructuredAgentSessionComposerOptions } from '../../../src/shared/structured-agent-session-composer' import { structuredAgentSessionCommandHostRefusalCause } from '../../../src/shared/structured-agent-session-command-refusal-cause' @@ -13,6 +12,7 @@ import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-sessio import type { RpcClient } from '../transport/rpc-client' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { requestStructuredAgentSessionMutation } from './mobile-structured-agent-session-rpc' +import type { MobileNativeChatSendErrorReporter } from './use-mobile-native-chat-send-error' /** What the person sees and can do, in the words the desktop uses. */ function busyCommandText( @@ -40,7 +40,7 @@ export async function dispatchMobileStructuredCommand(input: { /** The host holds this command as a card behind work in flight, so nothing here holds it. */ waitsInLine: (command: AgentSessionConversationCommand) => boolean /** `refusedWhile`: what the phone showed the refused command waiting on. */ - onError: (message: string, refusedWhile?: StructuredAgentSessionCommandRefusalCause) => void + onError: MobileNativeChatSendErrorReporter timeoutMs: number }): Promise { if (input.pending.current) { @@ -103,7 +103,10 @@ export async function dispatchMobileStructuredCommand(input: { } }) if (outcome.error) { - input.onError(outcome.error, outcome.refusedWhile) + input.onError( + outcome.error, + outcome.refusedWhile ? { refusedWhile: outcome.refusedWhile } : undefined + ) } return unknown ? 'unknown' : outcome.accepted ? 'accepted' : 'rejected' } 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 1c7d217368e7..ce3eda3c57b2 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -30,6 +30,43 @@ function draft(overrides: Partial & { messageId: stri } describe('mobileQueuedMessageCards', () => { + it.each([ + ['Claude', 'claude auth login'], + ['Codex', 'codex login'], + ['Grok', 'grok login'], + ['OpenCode', 'opencode auth login'], + ['Pi', '/login'], + ['OMP', 'Sign in to OMP.'] + ])('keeps %s identity and its sign-in action on returned cards', (agentName, guidance) => { + const [card] = mobileQueuedMessageCards( + [draft({ messageId: 'auth', ...returnedAs(agentSessionFailureFact('notSignedIn')) })], + [], + { pendingPrompt: false, agentName } + ) + expect(card?.caption).toContain(agentName) + expect(card?.caption).toContain(guidance) + expect(card?.caption).not.toContain('send your message again') + expect(card?.needsAttention).toBe(true) + }) + + it.each(['Claude', 'Codex'])( + 'keeps %s managed guidance unless a row already explains it', + (agentName) => { + const fact = agentSessionFailureFact('notSignedIn', { account: 'managed' }) + const drafts = [draft({ messageId: 'auth', ...returnedAs(fact) })] + const [card] = mobileQueuedMessageCards(drafts, [], { pendingPrompt: false, agentName }) + expect(card?.caption).toBe( + `This ${agentName} account isn't signed in. Sign in again in ${agentName} Accounts settings.` + ) + const [stated] = mobileQueuedMessageCards(drafts, [], { + pendingPrompt: false, + agentName, + statedFailures: [fact] + }) + expect(stated?.caption).toBe('Your message was not sent.') + } + ) + it('marks a /compact card as a command, its text as typed', () => { const [card] = mobileQueuedMessageCards( [ diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index 46d019f132be..42883b4ed4df 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -2,9 +2,16 @@ // 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 { readWholeAgentSessionFailureFact } from '../../../src/shared/agent-session-failure' +import { + readWholeAgentSessionFailureFact, + type AgentSessionFailureFact +} from '../../../src/shared/agent-session-failure' +import { agentSessionFailureStatedByRow } from '../../../src/shared/agent-session-visible-failures' +import { + agentSessionWriteNotDoneParts, + agentSessionWriteNoticeEnglish +} from '../../../src/shared/agent-session-refusal-notice' 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' import { structuredAgentSessionAttemptFailureParts } from '../../../src/shared/structured-agent-session-rejection-words' import { @@ -38,10 +45,15 @@ function queuedMessageBodyText(body: AgentSessionQueuedMessage['body']): string } function returnedCaption( - draft: Pick + draft: Pick, + agentName?: string, + statedFailures: readonly AgentSessionFailureFact[] = [] ): string { const reason = draft.returnedReason ?? null const rejection = draft.returnedRejection + if (agentSessionFailureStatedByRow(rejection, statedFailures)) { + return agentSessionWriteNoticeEnglish(agentSessionWriteNotDoneParts('send')) + } if (dispatchWasWithdrawn({ dispatchState: 'rejected', reason, rejection })) { return 'Stopped before it was sent' } @@ -50,7 +62,7 @@ function returnedCaption( return agentSessionWriteNoticeEnglish( structuredAgentSessionAttemptFailureParts( { kind: 'rejected', reason }, - { retryControl: true }, + { agentName, retryControl: true }, readWholeAgentSessionFailureFact(rejection) ) ) @@ -99,7 +111,13 @@ export function mobileQueuePauseLabel(pause: Pick[], - facts: { pendingPrompt: boolean; queuePaused?: boolean; agentWorking?: boolean } + facts: { + pendingPrompt: boolean + queuePaused?: boolean + agentWorking?: boolean + agentName?: string + statedFailures?: readonly AgentSessionFailureFact[] + } ): MobileQueuedMessageCard[] { if (!queuedMessages || queuedMessages.length === 0) { return [] @@ -120,7 +138,7 @@ export function mobileQueuedMessageCards( const paused = draft.paused === true const caption = draft.state === 'returned' - ? returnedCaption(draft) + ? returnedCaption(draft, facts.agentName, facts.statedFailures) : paused ? pausedCaption( draft.pausedReason, diff --git a/mobile/src/session/mobile-structured-send-delivery.test.ts b/mobile/src/session/mobile-structured-send-delivery.test.ts index ef0e7a520d9a..57b18015f571 100644 --- a/mobile/src/session/mobile-structured-send-delivery.test.ts +++ b/mobile/src/session/mobile-structured-send-delivery.test.ts @@ -14,6 +14,26 @@ function accepted( } describe('mobileStructuredSendDelivery', () => { + it('keeps the typed auth fact beside guidance until the transcript can explain it', () => { + const result = structuredSendResultFixture('rejected', 'Sign in to Grok with `grok login`.') + if (!('submission' in result)) { + throw new Error('expected submission') + } + const fact = { + kind: 'notSignedIn' as const, + detail: { text: 'Key expired.', audience: 'person' as const } + } + result.submission.rejection = fact + expect(mobileStructuredSendDelivery({ status: 'accepted', value: result })).toEqual({ + outcome: 'rejected', + error: 'Sign in to Grok with `grok login`.', + failure: fact + }) + result.submission.rejection = { kind: 'providerRejected' } + expect(mobileStructuredSendDelivery({ status: 'accepted', value: result }).error).toContain( + 'Sign in' + ) + }) it('reports transport and host uncertainty on this send', () => { expect(mobileStructuredSendDelivery({ status: 'unknown' })).toEqual({ outcome: 'unknown', diff --git a/mobile/src/session/mobile-structured-send-delivery.ts b/mobile/src/session/mobile-structured-send-delivery.ts index 744a561cabb1..c4fd5e450f38 100644 --- a/mobile/src/session/mobile-structured-send-delivery.ts +++ b/mobile/src/session/mobile-structured-send-delivery.ts @@ -2,12 +2,17 @@ import type { AgentSessionSendResult } from '../../../src/shared/agent-session-w import { agentSessionRefusalOperationState } from '../../../src/shared/agent-session-refusal-retry' import { structuredAgentSessionRejectionNotice } from '../../../src/shared/structured-agent-session-rejection-words' import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection' +import { + readWholeAgentSessionFailureFact, + type AgentSessionFailureFact +} from '../../../src/shared/agent-session-failure' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import type { StructuredAgentSessionMutationCallResult } from './mobile-structured-agent-session-rpc' export type MobileStructuredSendDelivery = { outcome: MobileNativeChatSendOutcome error: string | null + failure?: AgentSessionFailureFact } export function mobileStructuredSendDelivery( @@ -44,9 +49,11 @@ export function mobileStructuredSendDelivery( // The host transcript already owns this send's stopped row. return { outcome: 'accepted', error: null } } + const failure = readWholeAgentSessionFailureFact(submission.rejection) return { outcome: 'rejected', - error: structuredAgentSessionRejectionNotice(submission.reason, 'composer-send') + error: structuredAgentSessionRejectionNotice(submission.reason, 'composer-send'), + ...(failure?.kind === 'notSignedIn' ? { failure } : {}) } } return { outcome: 'accepted', error: null } diff --git a/mobile/src/session/use-mobile-native-chat-send-error.test.ts b/mobile/src/session/use-mobile-native-chat-send-error.test.ts index c105a0b54373..5faafea45698 100644 --- a/mobile/src/session/use-mobile-native-chat-send-error.test.ts +++ b/mobile/src/session/use-mobile-native-chat-send-error.test.ts @@ -3,8 +3,11 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useMobileNativeChatSendError, + mobileNativeChatSendErrorMessage, type MobileNativeChatCommandRefusalCauses } from './use-mobile-native-chat-send-error' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import * as MobileNativeChatRenderData from './mobile-native-chat-render-data' type HookApi = ReturnType @@ -66,7 +69,7 @@ describe('useMobileNativeChatSendError', () => { const line = "The agent is still working. Run /clear when it's done." await render() await showing({ working: true, prompt: false }) - await act(async () => api().show(line, 'working')) + await act(async () => api().show(line, { refusedWhile: 'working' })) expect(api().message).toBe(line) await showing({ working: true, prompt: false }) expect(api().message).toBe(line) @@ -82,7 +85,9 @@ describe('useMobileNativeChatSendError', () => { await render() await showing({ working: false, prompt: true }) await act(async () => - api().show("Answer the agent's question or approval, then run /clear.", 'prompt') + api().show("Answer the agent's question or approval, then run /clear.", { + refusedWhile: 'prompt' + }) ) await showing({ working: false, prompt: false }) expect(api().message).toBeNull() @@ -93,7 +98,9 @@ describe('useMobileNativeChatSendError', () => { await act(async () => api().show('Message not sent')) await showing({ working: false, prompt: false }) expect(api().message).toBe('Message not sent') - await act(async () => api().show('Background tasks are still running.', 'background')) + await act(async () => + api().show('Background tasks are still running.', { refusedWhile: 'background' }) + ) await showing({ working: false, prompt: false }) expect(api().message).toBe('Background tasks are still running.') }) @@ -109,6 +116,94 @@ describe('useMobileNativeChatSendError', () => { expect(api().message).toBeNull() }) + it('holds the fact with its message, and clears it on another failure or expiry', async () => { + const fact = { kind: 'notSignedIn', account: 'managed' } as const + await render() + await act(async () => api().show('Sign in', { failure: fact })) + expect(api().failure).toEqual(fact) + await act(async () => api().show('Stop failed')) + expect(api().failure).toBeUndefined() + await act(async () => api().show('Sign in', { failure: fact })) + await act(async () => { + vi.advanceTimersByTime(4000) + }) + expect(api().failure).toBeUndefined() + expect(api().message).toBeNull() + }) + + it('dedupes only the same failure and leaves unrelated banners free of transcript scans', () => { + const failure = { + kind: 'notSignedIn', + account: 'managed', + detail: { text: 'Key expired.', audience: 'person' } + } as const + const messages: NativeChatMessage[] = [ + { + id: 'auth', + role: 'system', + timestamp: 1, + source: 'transcript', + blocks: [{ type: 'text', text: 'Host guidance', failure }] + } + ] + const scan = vi.spyOn(MobileNativeChatRenderData, 'foldMobileNativeChatMessages') + expect(mobileNativeChatSendErrorMessage({ message: 'Stop failed' }, messages)).toBe( + 'Stop failed' + ) + expect(scan).not.toHaveBeenCalled() + expect(mobileNativeChatSendErrorMessage({ message: 'Sign in', failure }, messages)).toBe( + 'Your message was not sent.' + ) + expect( + mobileNativeChatSendErrorMessage( + { message: 'Sign in', failure: { ...failure, account: 'system' } }, + messages + ) + ).toBe('Sign in') + expect( + mobileNativeChatSendErrorMessage( + { + message: 'Sign in', + failure: { ...failure, detail: { ...failure.detail, text: 'Other key.' } } + }, + messages + ) + ).toBe('Sign in') + scan.mockRestore() + }) + + it('keeps sign-in guidance when only a hidden child states the failure', () => { + const failure = { kind: 'notSignedIn' } as const + const child: NativeChatMessage = { + id: 'child-auth', + agentId: 'codex-child', + role: 'system', + timestamp: 1, + source: 'transcript', + blocks: [{ type: 'text', text: 'Sign in to Codex', failure }] + } + expect( + mobileNativeChatSendErrorMessage({ message: 'Sign in to Codex', failure }, [child]) + ).toBe('Sign in to Codex') + }) + + it('shortens guidance only while a matching row is visible', () => { + const failure = { kind: 'notSignedIn' } as const + const visible: NativeChatMessage = { + id: 'parent-auth', + role: 'system', + timestamp: 1, + source: 'transcript', + blocks: [{ type: 'text', text: 'Sign in to Codex', failure }] + } + const child = { ...visible, id: 'child-auth', agentId: 'codex-child' } + const error = { message: 'Sign in to Codex', failure } + expect(mobileNativeChatSendErrorMessage(error, [child, visible])).toBe( + 'Your message was not sent.' + ) + expect(mobileNativeChatSendErrorMessage(error, [child])).toBe(error.message) + }) + it('restarts the hold when a second failure lands mid-hold', async () => { await render() await act(async () => api().show('a')) @@ -145,13 +240,19 @@ describe('useMobileNativeChatSendError', () => { it('drops a held failure when the scope changes', async () => { await render('terminal-1') - await act(async () => api().show('a')) + await act(async () => api().show('a', { failure: { kind: 'notSignedIn' } })) expect(api().message).toBe('a') await act(async () => { renderer?.update(createElement(Harness, { scopeKey: 'terminal-2' })) }) expect(api().message).toBeNull() + expect(api().failure).toBeUndefined() + await act(async () => { + renderer?.update(createElement(Harness, { scopeKey: 'terminal-1' })) + }) + expect(api().message).toBeNull() + expect(api().failure).toBeUndefined() }) it('falls back to the toast when the banner is not mounted', async () => { diff --git a/mobile/src/session/use-mobile-native-chat-send-error.ts b/mobile/src/session/use-mobile-native-chat-send-error.ts index d0b7b0427307..02df95dc16ff 100644 --- a/mobile/src/session/use-mobile-native-chat-send-error.ts +++ b/mobile/src/session/use-mobile-native-chat-send-error.ts @@ -1,10 +1,25 @@ import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react' +import { + readWholeAgentSessionFailureFact, + type AgentSessionFailureFact +} from '../../../src/shared/agent-session-failure' +import { sameAgentSessionFailureFact } from '../../../src/shared/agent-session-visible-failures' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { structuredAgentSessionRejectionNotice } from '../../../src/shared/structured-agent-session-rejection-words' +import { foldMobileNativeChatMessages } from './mobile-native-chat-render-data' import type { StructuredAgentSessionCommandRefusalCause } from '../../../src/shared/structured-agent-session-composer' -/** `refusedWhile`: a refused command's cause, which its line is said only while the chat shows. */ +/** What a send error says beyond its text. `refusedWhile`: a refused command's cause, whose line is + * said only while the chat shows it. `failure`: a rejected send's fact, whose guidance steps aside + * once the transcript states the same failure. */ +export type MobileNativeChatSendErrorDetails = { + refusedWhile?: StructuredAgentSessionCommandRefusalCause + failure?: AgentSessionFailureFact +} + export type MobileNativeChatSendErrorReporter = ( message: string, - refusedWhile?: StructuredAgentSessionCommandRefusalCause + details?: MobileNativeChatSendErrorDetails ) => void /** What the phone's chat shows a refused command waiting on. */ @@ -15,6 +30,25 @@ export type MobileNativeChatCommandRefusalCauses = Readonly< const NATIVE_CHAT_SEND_ERROR_HOLD_MS = 4000 const NATIVE_CHAT_SEND_ERROR_TOAST_MS = 1600 +/** A held rejection keeps its guidance until the current transcript states the same failure. */ +export function mobileNativeChatSendErrorMessage( + error: { message: string | null; failure?: AgentSessionFailureFact }, + messages: readonly NativeChatMessage[] +): string | null { + const { failure, message } = error + if (!message || !failure) { + return message + } + const stated = foldMobileNativeChatMessages(messages).some((row) => + row.blocks.some((block) => { + const fact = + block.type === 'text' ? readWholeAgentSessionFailureFact(block.failure) : undefined + return fact !== undefined && sameAgentSessionFailureFact(failure, fact) + }) + ) + return stated ? structuredAgentSessionRejectionNotice(null, 'composer-send') : message +} + /** Holds the newest native-chat send failure for the composer's inline banner. * Why a banner and not the bottom toast: chat failures happen with the keyboard * up, which covers the toast — the surface the user is looking at is the composer. @@ -25,6 +59,7 @@ export function useMobileNativeChatSendError(args: { showToast: (message: string, durationMs?: number) => void }): { message: string | null + failure?: AgentSessionFailureFact show: MobileNativeChatSendErrorReporter clear: () => void /** Called each render with what the chat shows: a refusal whose cause has ended is dropped. */ @@ -32,10 +67,13 @@ export function useMobileNativeChatSendError(args: { /** Set by the route each render; gates banner vs toast. */ bannerMountedRef: MutableRefObject } { - const [message, setMessage] = useState(null) - const [refusedWhile, setRefusedWhile] = useState< - StructuredAgentSessionCommandRefusalCause | undefined - >(undefined) + const [error, setError] = useState< + ({ scopeKey: string | null; message: string } & MobileNativeChatSendErrorDetails) | null + >(null) + if (error && error.scopeKey !== args.scopeKey) { + setError(null) + } + const visibleError = error?.scopeKey === args.scopeKey ? error : null const timerRef = useRef | null>(null) const bannerMountedRef = useRef(false) const showToastRef = useRef(args.showToast) @@ -58,10 +96,10 @@ export function useMobileNativeChatSendError(args: { return } clearTimer() - setMessage(null) + setError(null) }, [clearTimer, scopeKey]) const show = useCallback( - (next: string, cause?: StructuredAgentSessionCommandRefusalCause) => { + (next: string, details?: MobileNativeChatSendErrorDetails) => { // Why: deferred failures can land after the user left chat (banner unmounted) // or moved to another tab, where the banner belongs to a different terminal — // both must fall back to the toast instead of being swallowed or misattributed. @@ -70,19 +108,17 @@ export function useMobileNativeChatSendError(args: { return } clearTimer() - setMessage(next) - setRefusedWhile(cause) + setError({ scopeKey, message: next, ...details }) timerRef.current = setTimeout(() => { timerRef.current = null - setMessage(null) + setError(null) }, NATIVE_CHAT_SEND_ERROR_HOLD_MS) }, [clearTimer, scopeKey] ) - // A held failure describes the scope it was raised on; drop it when that changes. + // A scope change retires its timer; rendering already discards its held failure. useEffect(() => { clearTimer() - setMessage(null) }, [clearTimer, scopeKey]) useEffect( () => () => { @@ -96,10 +132,17 @@ export function useMobileNativeChatSendError(args: { ) const keepWhile = (causes: MobileNativeChatCommandRefusalCauses | null) => { // Dropped, not hidden: the cause coming back later is not what this refusal was about. - if (message !== null && refusedWhile !== undefined && causes?.[refusedWhile] === false) { - setMessage(null) - setRefusedWhile(undefined) + const refusedWhile = visibleError?.refusedWhile + if (refusedWhile !== undefined && causes?.[refusedWhile] === false) { + setError(null) } } - return { message, show, clear, keepWhile, bannerMountedRef } + return { + message: visibleError?.message ?? null, + ...(visibleError?.failure ? { failure: visibleError.failure } : {}), + show, + clear, + keepWhile, + bannerMountedRef + } } diff --git a/mobile/src/session/use-mobile-session-native-chat-dictation.ts b/mobile/src/session/use-mobile-session-native-chat-dictation.ts index 075ee17c19ef..024ab78a86ab 100644 --- a/mobile/src/session/use-mobile-session-native-chat-dictation.ts +++ b/mobile/src/session/use-mobile-session-native-chat-dictation.ts @@ -1,4 +1,4 @@ -import { useEffect, useCallback } from 'react' +import { useEffect, useCallback, useMemo } from 'react' import { useFocusEffect } from 'expo-router' import { useMobileDictation } from '../hooks/use-mobile-dictation' import { triggerError } from '../platform/haptics' @@ -13,7 +13,10 @@ import { import { useMobileNativeChatController } from './use-mobile-native-chat-controller' import { useMobileNativeChatReadability } from './use-mobile-native-chat-readability' import { useMobileNativeChatInputLease } from './use-mobile-native-chat-input-lease' -import { useMobileNativeChatSendError } from './use-mobile-native-chat-send-error' +import { + useMobileNativeChatSendError, + mobileNativeChatSendErrorMessage +} from './use-mobile-native-chat-send-error' import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' import { useMobileSendCompletionGeneration } from './use-mobile-send-completion-generation' import type { MobileSessionFeedbackCapabilitiesModel } from './use-mobile-session-feedback-capabilities' @@ -78,6 +81,18 @@ export function useMobileSessionNativeChatDictation( onSendResolved: nativeChatSendError.clear }) const { toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController + const sendErrorMessage = useMemo( + () => + mobileNativeChatSendErrorMessage( + { message: nativeChatSendError.message, failure: nativeChatSendError.failure }, + nativeChatController.nativeChatSession.messages + ), + [ + nativeChatSendError.message, + nativeChatSendError.failure, + nativeChatController.nativeChatSession.messages + ] + ) nativeChatSendError.bannerMountedRef.current = showNativeChat nativeChatSendError.keepWhile(nativeChatController.nativeChatCommandRefusalCauses) const nativeChatOverlayInputLockReason = @@ -228,7 +243,7 @@ export function useMobileSessionNativeChatDictation( }, [diffComments]) return { nativeChatScopeKey, - nativeChatSendError, + nativeChatSendError: { ...nativeChatSendError, message: sendErrorMessage }, nativeChatTranscriptIsLocalReadable, nativeChatInputLeaseReady, nativeChatInputLeaseReadyRef, diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx index 0268fece2c65..89e99de2a50e 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx @@ -17,6 +17,16 @@ import type { RpcResponse } from '../transport/types' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' +import { agentSessionVisibleFailureFacts } from '../../../src/shared/agent-session-visible-failures' +import type * as AgentSessionVisibleFailures from '../../../src/shared/agent-session-visible-failures' + +vi.mock('../../../src/shared/agent-session-visible-failures', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + agentSessionVisibleFailureFacts: vi.fn(original.agentSessionVisibleFailureFacts) + } +}) import { CAPABLE, LEGACY, @@ -143,6 +153,26 @@ describe('mobile structured queued messages', () => { }) describe('capability-gated delivery', () => { + it('scans failure facts only when a returned card needs them', async () => { + await mountSession(CAPABLE) + act(() => listener?.(snapshotEvent({ runningTurn: true }))) + expect(agentSessionVisibleFailureFacts).not.toHaveBeenCalled() + act(() => + listener?.( + snapshotEvent({ + queuedMessages: [ + queuedDraft({ + messageId: 'returned', + state: 'returned', + returnedRejection: { kind: 'notSignedIn' } + }) + ] + }) + ) + ) + expect(agentSessionVisibleFailureFacts).toHaveBeenCalledTimes(1) + expect(hook!.queued.cards[0]?.caption).toContain('claude auth login') + }) it('sends delivery: queue-if-active — fingerprint included — only on a capable host', async () => { sendRequest.mockImplementation(async (method) => { if (method === 'agentSession.send') { diff --git a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx index 3a6c351c6302..1af770c669c0 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx @@ -9,6 +9,12 @@ import { fieldsOf, ok } from './use-mobile-structured-agent-session-queued.test- import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { structuredSendResultFixture } from './structured-agent-send-result.test-fixture' import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' +import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' +import { + useMobileNativeChatSendError, + mobileNativeChatSendErrorMessage, + type MobileNativeChatSendErrorDetails +} from './use-mobile-native-chat-send-error' const asyncStorage = vi.hoisted(() => ({ getItem: vi.fn(), @@ -56,6 +62,7 @@ function snapshotEvent(): Extract { let renderer: ReactTestRenderer | null = null let hook: ReturnType | null = null + let banner: ReturnType | null = null let listener: ((value: unknown) => void) | null = null let storedOperations: Map const onSendError = vi.fn() @@ -76,7 +83,9 @@ describe('mobile structured send actions', () => { close: () => {} } - function Harness(): null { + function Harness() { + banner = useMobileNativeChatSendError({ scopeKey: 'session-1', showToast: vi.fn() }) + banner.bannerMountedRef.current = true hook = useMobileStructuredAgentSession({ client, sessionId: 'session-1', @@ -87,7 +96,11 @@ describe('mobile structured send actions', () => { hostSupport: null, onSendError }) - return null + return createElement( + 'span', + null, + mobileNativeChatSendErrorMessage(banner, hook.session.messages) + ) } async function mountSession(): Promise { @@ -110,6 +123,9 @@ describe('mobile structured send actions', () => { beforeEach(() => { vi.clearAllMocks() + onSendError.mockImplementation((message: string, details?: MobileNativeChatSendErrorDetails) => + banner?.show(message, details) + ) storedOperations = new Map() asyncStorage.getItem.mockImplementation( @@ -130,6 +146,7 @@ describe('mobile structured send actions', () => { act(() => renderer?.unmount()) renderer = null hook = null + banner = null listener = null }) @@ -180,6 +197,59 @@ describe('mobile structured send actions', () => { expect(calls()).toHaveLength(1) }) + it.each(['reply-before-row', 'row-before-commit'] as const)( + 'states delivery once and leaves auth guidance in the transcript for %s', + async (order) => { + const fact = { + kind: 'notSignedIn', + detail: { text: 'The provider key expired.', audience: 'person' } + } as const + const words = agentSessionFailureWords(fact, { agentName: 'Codex', surface: 'row' }) + const event = snapshotEvent() + event.page.items = [ + { + itemId: 'auth-row', + sequence: 1, + revision: 1, + observedAt: 1, + body: { kind: 'status', tone: 'error', ...words } + } + ] + const value = structuredSendResultFixture('rejected', words.text) + if (!('submission' in value)) { + throw new Error('expected submission') + } + value.submission.rejection = fact + sendRequest.mockImplementation(async (method) => { + if (method !== 'agentSession.send') { + return ok({ models: [], current: {} }) + } + if (order === 'row-before-commit') { + listener?.(event) + } + return ok({ + ok: true, + replayed: false, + fence: 3, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value + }) + }) + await mountSession() + await act(async () => { + expect(await hook!.sendWithOutcome('my message')).toBe('rejected') + }) + expect(onSendError).toHaveBeenCalledExactlyOnceWith(words.text, { failure: fact }) + if (order === 'reply-before-row') { + expect(renderer!.root.findByType('span').children.join('')).toBe(words.text) + act(() => listener?.(event)) + } + expect(renderer!.root.findByType('span').children.join('')).toBe('Your message was not sent.') + expect(JSON.stringify(hook!.session.messages)).toContain(words.text) + expect(onSendError).toHaveBeenCalledTimes(1) + } + ) + it('does not replay an old action after remount', async () => { sendRequest.mockImplementation(async (method) => { if (method === 'agentSession.send') { diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 6ad65cf993b1..fb3c69c51aa6 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -2,7 +2,7 @@ import { useCallback, useMemo, useRef } from 'react' import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' import { withNativeChatCutTurnNotices } from '../../../src/shared/native-chat-cut-turn-notice' -import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names' +import { tuiAgentDisplayName } from '../../../src/shared/tui-agent-display-names' import { isStructuredAgentSessionMainAgentWorking } from '../../../src/shared/structured-agent-session-main-agent-working' import { isFinalAgentSessionReadRefusal } from '../../../src/shared/structured-agent-session-read-refusal' import { @@ -137,7 +137,7 @@ export function useMobileStructuredAgentSession(args: { const transcriptItems = useMemo( () => withNativeChatCutTurnNotices(state.items, { - agentName: TUI_AGENT_DISPLAY_NAMES[agent === 'codex' ? 'codex' : 'claude'] + agentName: agent ? (tuiAgentDisplayName(agent) ?? agent) : undefined }), [agent, state.items] ) @@ -214,6 +214,8 @@ export function useMobileStructuredAgentSession(args: { ) const queued = useMobileStructuredQueuedMessageControls({ sessionKey, + agentName: agent ? (tuiAgentDisplayName(agent) ?? agent) : undefined, + journalItems: state.items, queuedMessages, queuePause, submissions: state.submissions, diff --git a/mobile/src/session/use-mobile-structured-queued-message-controls.ts b/mobile/src/session/use-mobile-structured-queued-message-controls.ts index 74518947e9bd..1a73756841f1 100644 --- a/mobile/src/session/use-mobile-structured-queued-message-controls.ts +++ b/mobile/src/session/use-mobile-structured-queued-message-controls.ts @@ -7,7 +7,11 @@ // the only truth a card action ever needs. import { useCallback, useMemo } from 'react' -import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../src/shared/agent-session-journal-types' +import { agentSessionVisibleFailureFacts } from '../../../src/shared/agent-session-visible-failures' import type { AgentSessionQueuedMessageDeleteResult, AgentSessionQueuedMessagesResumeResult, @@ -46,6 +50,8 @@ export type MobileStructuredQueuedMessageControls = { export function useMobileStructuredQueuedMessageControls(args: { sessionKey: string + agentName?: string + journalItems?: readonly AgentJournalRenderItem[] queuedMessages: MobileQueuedMessageFeed queuePause: MobileQueuePause submissions: readonly AgentJournalSubmission[] @@ -77,9 +83,21 @@ export function useMobileStructuredQueuedMessageControls(args: { mobileQueuedMessageCards(queuedMessages, submissions, { pendingPrompt, agentWorking, + agentName: args.agentName, + statedFailures: queuedMessages?.some((draft) => draft.state === 'returned') + ? agentSessionVisibleFailureFacts(args.journalItems ?? []) + : [], queuePaused: queuePause !== null }), - [agentWorking, pendingPrompt, queuePause, queuedMessages, submissions] + [ + agentWorking, + args.agentName, + args.journalItems, + pendingPrompt, + queuePause, + queuedMessages, + submissions + ] ) const resolved = useCallback( (accepted: boolean): boolean => { diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index 1defc82f7644..678657e0d928 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -55,6 +55,7 @@ export type AcpDialect = { settleRequest?(method: string, params: unknown): AcpRequestSettlement | undefined /** The provider's words in a `session/prompt` error answer, when its message is generic. */ promptErrorDetail?(error: AcpAgentError): string | undefined + authenticationRequired?(error: AcpAgentError): boolean /** The row for a failed turn the provider gave no words for. */ failedTurnText?(stopReason: string): string } diff --git a/src/main/acp/acp-dialects/grok-dialect.ts b/src/main/acp/acp-dialects/grok-dialect.ts index 609a9c313c5e..81d07fa34bc5 100644 --- a/src/main/acp/acp-dialects/grok-dialect.ts +++ b/src/main/acp/acp-dialects/grok-dialect.ts @@ -177,7 +177,10 @@ export const GROK_ACP_DIALECT: AcpDialect = { toolBackgroundTasks: grokToolBackgroundTasks, notification, contextWindow, - promptErrorDetail: (error) => promptErrorDataSchema.safeParse(error.data).data?.message, + promptErrorDetail: (error) => + typeof error.data === 'string' + ? error.data + : promptErrorDataSchema.safeParse(error.data).data?.message, failedTurnText: (stopReason) => stopReason === 'rate_limit' ? 'Grok usage limit reached.' diff --git a/src/main/acp/acp-dialects/omp-dialect.ts b/src/main/acp/acp-dialects/omp-dialect.ts index 80c3fa4ee08c..1714184b35f6 100644 --- a/src/main/acp/acp-dialects/omp-dialect.ts +++ b/src/main/acp/acp-dialects/omp-dialect.ts @@ -8,6 +8,7 @@ import type { AcpDialect } from './acp-dialect' // A zero exit is left out; completed foreground results carry wall time, while service and // background launch results do not establish a process exit. const textBlockSchema = z.looseObject({ type: z.literal('text'), text: z.string() }) +const promptErrorDataSchema = z.looseObject({ details: z.string() }) const toolResultSchema = z.looseObject({ content: z.array(z.unknown()), details: z @@ -91,4 +92,12 @@ function normalizeToolUpdate(update: ToolCallUpdate): ToolCallUpdate { } } -export const OMP_ACP_DIALECT: AcpDialect = { normalizeToolUpdate } +export const OMP_ACP_DIALECT: AcpDialect = { + normalizeToolUpdate, + promptErrorDetail: (error) => promptErrorDataSchema.safeParse(error.data).data?.details, + authenticationRequired: (error) => + error.code === -32603 && + promptErrorDataSchema + .safeParse(error.data) + .data?.details.startsWith('No API key found for ') === true +} diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index c3b6bb9cb367..31d03a5e48ee 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -18,6 +18,7 @@ import { openCodeStoredUserMessagesReader } from '../opencode/opencode-acp-store import type { AcpStoredUserMessagesReader } from './acp-recovery-history' import { isStableCliVersionFrom, isStableCliVersionOnLine } from '../agent-cli-version-probe' import type { TuiAgent } from '../../shared/tui-agent' +import { agentSessionSignInFor } from '../../shared/agent-session-sign-in' import { loadGrokVisualsSkill, loadOmpVisualsSkill, @@ -73,7 +74,7 @@ const GROK_LAUNCH_SPEC: AcpLaunchSpec = { ], env: {}, dialect: GROK_ACP_DIALECT, - loginCommand: ['grok', 'login'], + loginCommand: agentSessionSignInFor('grok')?.loginCommand ?? [], // An API key in Grok's own environment, else the sign-in Grok already cached; never interactive. authMethod: ({ advertised, env }) => env.XAI_API_KEY?.trim() && advertised.includes('xai.api_key') @@ -105,7 +106,7 @@ const OPENCODE_LAUNCH_SPEC: AcpLaunchSpec = { env: { OPENCODE_CLIENT: 'acp', OPENCODE_ENABLE_QUESTION_TOOL: 'false' }, scrubEnvironment: scrubOpenCodeAcpEnvironment, dialect: OPENCODE_ACP_DIALECT, - loginCommand: ['opencode', 'auth', 'login'], + loginCommand: agentSessionSignInFor('opencode')?.loginCommand ?? [], account: openCodeAcpAccountBinding(), installDirectories: ({ homePath }) => [join(homePath, '.opencode', 'bin')], supportsVersion: (version) => @@ -123,8 +124,7 @@ const OMP_LAUNCH_SPEC: AcpLaunchSpec = { args: ({ pluginDir }) => ['acp', ...(pluginDir ? ['--plugin-dir', pluginDir] : [])], env: {}, dialect: OMP_ACP_DIALECT, - // OMP signs in from its own `/login`. - loginCommand: ['omp'], + loginCommand: agentSessionSignInFor('omp')?.loginCommand ?? [], // The directory OMP's terminal chats read too; its default is OMP's own. account: directoryAccountBinding('PI_CODING_AGENT_DIR', (homePath) => join(homePath, '.omp', 'agent') diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 45097b006ee6..cb6d3b330812 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -11,7 +11,6 @@ import type { AgentSessionProviderHandleLink } from '../../shared/agent-session- import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' import { isTuiAgent } from '../../shared/tui-agent-config' import { - AgentSessionAcquisitionRefusal, AgentSessionPreSpawnError, type AgentSessionAcquisition, type StructuredAgentSessionAcquireInput @@ -22,7 +21,8 @@ import { PROVIDER_SPAWN_TOKEN_ENV } from '../provider-process/provider-spawned-process-identity' import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env' -import { AcpAuthRequiredError } from './acp-errors' +import { AcpAgentError } from './acp-errors' +import { acpAuthenticationRequired, acpSignInRequiredRefusal } from './acp-turn-failures' import { ACP_CHILD_ENV_TO_DELETE } from './acp-launch-specs' import { ACP_REOPEN_FAILED, @@ -320,11 +320,8 @@ export async function acquireAcpStructuredSession(input: { } catch (error) { session = null slot.lane?.dispose() - if (error instanceof AcpAuthRequiredError) { - throw new AgentSessionAcquisitionRefusal( - `${spec.agent} reported that it is not signed in: ${error.message}`, - 'notSignedIn' - ) + if (error instanceof AcpAgentError && acpAuthenticationRequired(spec.dialect, error)) { + throw acpSignInRequiredRefusal(spec.agent, spec.dialect, error) } throw error } diff --git a/src/main/acp/acp-structured-sign-in.test.ts b/src/main/acp/acp-structured-sign-in.test.ts new file mode 100644 index 000000000000..06c4b8294d17 --- /dev/null +++ b/src/main/acp/acp-structured-sign-in.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { z } from 'zod' +import { providerDiagnosticOf } from '../../shared/agent-session-failure' +import { providerStartupFailureFact } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' +import { agentSessionFailureSentence } from '../../shared/agent-session-failure-words' +import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { ACP_LAUNCH_SPECS } from './acp-launch-specs' +import { + openAcpAdapterRig, + PROVIDER_SESSION, + sendHello +} from './acp-structured-adapter.test-support' + +const promptMeta = z.object({ _meta: z.object({ promptId: z.string() }).optional() }) +afterEach(closeProviderTimelineRigs) + +describe('ACP sign-out at the first prompt', () => { + it.each(ACP_LAUNCH_SPECS)('classifies $agent before and after acceptance', async (spec) => { + for (const midTurn of [false, true]) { + const detail = 'Credentials have expired; sign in on this host.' + const rig = await openAcpAdapterRig({ + spec, + script: (agent) => + agent.on('session/prompt', (frame) => { + if (midTurn) { + agent.notify('session/update', { + sessionId: PROVIDER_SESSION, + _meta: promptMeta.parse(frame.params)._meta, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'started' } + } + }) + } + agent.fail( + frame, + -32000, + 'Authentication required', + spec.agent === 'grok' ? detail : { message: detail } + ) + }) + }) + await rig.acquire() + await sendHello(rig, 'first') + await rig.settle() + if (midTurn || spec.dialect.injectedPromptIdentity !== true) { + const failures = (await rig.rig.rows()).flatMap(({ body }) => + body.kind === 'status' && body.failure ? [body] : [] + ) + expect(failures).toHaveLength(1) + expect(failures[0]?.failure).toMatchObject({ kind: 'notSignedIn' }) + expect(failures[0]?.text).toContain( + spec.agent === 'omp' ? 'OMP' : spec.loginCommand.join(' ') + ) + if (spec.agent === 'grok') { + expect(failures[0]?.text).toContain(detail) + } + } else { + expect(rig.settled).toContainEqual( + expect.objectContaining({ + clientMessageId: 'first', + state: 'rejected', + rejection: expect.objectContaining({ kind: 'notSignedIn' }) + }) + ) + const rejected = rig.settled.find((item) => 'state' in item && item.state === 'rejected') + expect(rejected && 'reason' in rejected ? rejected.reason : '').toContain( + spec.agent === 'omp' ? 'OMP' : spec.loginCommand.join(' ') + ) + if (spec.agent === 'grok') { + expect(rejected && 'reason' in rejected ? rejected.reason : '').toContain(detail) + } + } + } + }) +}) + +it.each(ACP_LAUNCH_SPECS)( + 'preserves $agent startup auth classification and its detail', + async (spec) => { + const detail = 'Sign-in credentials are missing.' + const rig = await openAcpAdapterRig({ + spec, + script: (agent) => agent.on('session/new', (frame) => agent.fail(frame, -32000, detail)) + }) + const error = await rig.acquire().catch((failure: unknown) => failure) + expect(error).toBeInstanceOf(AgentSessionAcquisitionRefusal) + expect(error).toMatchObject({ reason: 'notSignedIn' }) + expect(providerDiagnosticOf(error)).toEqual({ text: detail, audience: 'person' }) + const fact = providerStartupFailureFact(error) + expect(fact.detail?.text).toBe(detail) + expect(agentSessionFailureSentence(fact, 'row', { agentName: spec.agent })).toContain(detail) + } +) + +it.each([ + [ + 'No API key found for anthropic.\n\nUse /login, set an API key environment variable, or create /host/agent.db', + 'notSignedIn' + ], + ['Rate limit exceeded', undefined], + ['Network connection failed', undefined], + ['No API key found elsewhere', undefined] +] as const)('reads OMP internal-error detail without guessing auth: %s', async (detail, kind) => { + const spec = ACP_LAUNCH_SPECS.find((entry) => entry.agent === 'omp') + if (!spec) { + throw new Error('OMP launch specification missing') + } + const rig = await openAcpAdapterRig({ + spec, + script: (agent) => + agent.on('session/prompt', (frame) => + agent.fail(frame, -32603, 'Internal error', { details: detail }) + ) + }) + await rig.acquire() + await sendHello(rig, 'first') + await rig.settle() + const row = (await rig.rig.rows()).find( + ({ body }) => body.kind === 'status' && body.tone === 'error' + ) + expect(row?.body.kind === 'status' ? row.body.failure?.kind : undefined).toBe(kind) + expect(row?.body.kind === 'status' ? row.body.text : '').toContain(detail) +}) diff --git a/src/main/acp/acp-structured-turns.ts b/src/main/acp/acp-structured-turns.ts index 4bbf773ea51f..b3161cbe46ab 100644 --- a/src/main/acp/acp-structured-turns.ts +++ b/src/main/acp/acp-structured-turns.ts @@ -189,7 +189,10 @@ export class AcpStructuredTurns { const detail = providerDiagnostic(refusal, 'person') this.reject( send.clientMessageId, - agentSessionFailureFact('providerRejected', detail ? { detail } : {}) + agentSessionFailureFact( + lane.translator.authenticationRequired(error) ? 'notSignedIn' : 'providerRejected', + detail ? { detail } : {} + ) ) } else { lane.apply(lane.translator.promptFailed(send.clientMessageId, error, this.deps.now())) diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index 30af2bf56444..5125c7f68de7 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -10,7 +10,11 @@ import { readAcpSessionEvent, type AcpSessionEvent } from './acp-session-events' import { translateAcpRequest } from './acp-timeline-requests' import { acpSessionUpdate } from './acp-session-update' import { AcpToolTimeline } from './acp-tool-timeline' -import { AcpTurnFailures, acpPromptErrorDetail } from './acp-turn-failures' +import { + AcpTurnFailures, + acpAuthenticationRequired, + acpPromptErrorDetail +} from './acp-turn-failures' import { AcpTurnMessages } from './acp-turn-messages' import type { PromptResponse } from './generated/acp-protocol.generated' @@ -88,13 +92,18 @@ export class AcpTimelineTranslator { const detail = error instanceof AcpAgentError ? acpPromptErrorDetail(this.dialect, error) : undefined const ended = this.prompts.last + const notSignedIn = this.authenticationRequired(error) if (this.prompts.current?.clientMessageId !== clientMessageId) { // The provider already ended this turn; its answer may carry the only copy of the reason. return ended?.clientMessageId === clientMessageId && this.failures.has(ended.turn) - ? this.failures.row(ended.turn, detail) + ? this.failures.row(ended.turn, detail, 'error', notSignedIn) : [] } - return this.finishPrompt(clientMessageId, 'error', at, detail) + return this.finishPrompt(clientMessageId, 'error', at, detail, notSignedIn) + } + + authenticationRequired(error: unknown): boolean { + return acpAuthenticationRequired(this.dialect, error) } /** The agent refused the prompt before its turn began: forgets it and answers the agent's reason. @@ -107,14 +116,17 @@ export class AcpTimelineTranslator { clientMessageId: string, stopReason: string, at: number, - failureDetail?: string + failureDetail?: string, + notSignedIn = false ): ProviderTimelineEvent[] { const prompt = this.prompts.current if (prompt?.clientMessageId !== clientMessageId) { return [] } const events = this.start(prompt.turn, at) - events.push(...this.endTurn(prompt.turn, stopReason, at, prompt.durationMs, failureDetail)) + events.push( + ...this.endTurn(prompt.turn, stopReason, at, prompt.durationMs, failureDetail, notSignedIn) + ) return events } @@ -235,9 +247,10 @@ export class AcpTimelineTranslator { stopReason: string, at: number, durationMs: number | undefined, - failureDetail: string | undefined + failureDetail: string | undefined, + notSignedIn = false ): ProviderTimelineEvent[] { - const events = this.failures.ended(turn, stopReason, failureDetail) + const events = this.failures.ended(turn, stopReason, failureDetail, notSignedIn) events.push(acpTurnEnd(turn, stopReason, at, durationMs)) this.end(turn) if (this.prompts.current?.turn === turn) { diff --git a/src/main/acp/acp-turn-failures.ts b/src/main/acp/acp-turn-failures.ts index f4266b1704b4..f5f5f5dd463a 100644 --- a/src/main/acp/acp-turn-failures.ts +++ b/src/main/acp/acp-turn-failures.ts @@ -1,8 +1,14 @@ -import { providerDiagnostic } from '../../shared/agent-session-failure' +import { + agentSessionFailureFact, + providerDiagnostic, + withProviderDiagnostic +} from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' import { BoundedMap } from '../../shared/bounded-map' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpDialect } from './acp-dialects/acp-dialect' -import type { AcpAgentError } from './acp-errors' +import { AcpAgentError, AcpAuthRequiredError } from './acp-errors' +import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' /** Ends the provider failed, rather than ones it chose (a refusal, a token limit). */ const FAILED_STOP_REASONS = ['error', 'rate_limit'] @@ -16,13 +22,36 @@ export function acpPromptErrorDetail(dialect: AcpDialect, error: AcpAgentError): return dialect.promptErrorDetail?.(error) ?? error.message } +export function acpAuthenticationRequired(dialect: AcpDialect, error: unknown): boolean { + return ( + error instanceof AcpAuthRequiredError || + (error instanceof AcpAgentError && dialect.authenticationRequired?.(error) === true) + ) +} + +export function acpSignInRequiredRefusal( + agent: string, + dialect: AcpDialect, + error: AcpAgentError +): AgentSessionAcquisitionRefusal { + return withProviderDiagnostic( + new AgentSessionAcquisitionRefusal( + `${agent} reported that it is not signed in: ${error.message}`, + 'notSignedIn' + ), + providerDiagnostic(acpPromptErrorDetail(dialect, error), 'person') + ) +} + /** One error row per failed turn, in the provider's own words, as a Codex turn-ending error reads: * the message was accepted and the turn ran, so it is no refusal. Providers send that reason several * times (beside the end, after it, in the prompt's error answer), so a later copy only adds a * reason the row still lacks. */ export class AcpTurnFailures { /** The reason each failed turn's row holds; '' for none yet. */ - private readonly rows = new BoundedMap({ maxEntries: 128 }) + private readonly rows = new BoundedMap({ + maxEntries: 128 + }) constructor( private readonly sessionId: string, @@ -35,17 +64,37 @@ export class AcpTurnFailures { } /** The row an end writes, if it failed. */ - ended(turn: string, stopReason: string, text: string | undefined): ProviderTimelineEvent[] { - return acpStopReasonFailed(stopReason) ? this.row(turn, text, stopReason) : [] + ended( + turn: string, + stopReason: string, + text: string | undefined, + notSignedIn = false + ): ProviderTimelineEvent[] { + return acpStopReasonFailed(stopReason) ? this.row(turn, text, stopReason, notSignedIn) : [] } - row(turn: string, text: string | undefined, stopReason = 'error'): ProviderTimelineEvent[] { + row( + turn: string, + text: string | undefined, + stopReason = 'error', + notSignedIn = false + ): ProviderTimelineEvent[] { const written = this.rows.peek(turn) const detail = text === undefined ? undefined : providerDiagnostic(text, 'person') - if (written !== undefined && (written !== '' || !detail)) { + if ( + written !== undefined && + (written.text !== '' || !detail) && + (!notSignedIn || written.notSignedIn) + ) { return [] } - this.rows.set(turn, detail?.text ?? '') + const diagnostic = + detail ?? (written?.text ? providerDiagnostic(written.text, 'person') : undefined) + const authenticationRequired = notSignedIn || written?.notSignedIn === true + this.rows.set(turn, { + text: diagnostic?.text ?? '', + notSignedIn: authenticationRequired + }) return [ { type: 'item.update', @@ -53,10 +102,17 @@ export class AcpTurnFailures { body: { kind: 'status', tone: 'error', - text: - detail?.text ?? - this.dialect.failedTurnText?.(stopReason) ?? - `${this.agentName ?? 'The agent'} ended this turn with an error.` + ...(authenticationRequired + ? agentSessionFailureWords( + agentSessionFailureFact('notSignedIn', { detail: diagnostic }), + { agentName: this.agentName, surface: 'row' } + ) + : { + text: + diagnostic?.text ?? + this.dialect.failedTurnText?.(stopReason) ?? + `${this.agentName ?? 'The agent'} ended this turn with an error.` + }) }, join: { thread: this.sessionId, turn } } diff --git a/src/main/claude/claude-authentication-failures.ts b/src/main/claude/claude-authentication-failures.ts new file mode 100644 index 000000000000..fdf10394e14c --- /dev/null +++ b/src/main/claude/claude-authentication-failures.ts @@ -0,0 +1,57 @@ +import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import { BoundedMap } from '../../shared/bounded-map' +import { claudeRecord, claudeText } from './claude-structured-item-translation' +import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' + +/** Associates the assistant's typed error with its duplicate result, never with error text. */ +export class ClaudeAuthenticationFailures { + private readonly shown = new BoundedMap({ maxEntries: 128 }) + constructor(private readonly account?: () => AgentSessionAccountKind | undefined) {} + + assistant(message: Record) { + if (message.type !== 'assistant' || message.error !== 'authentication_failed') { + return null + } + const content = claudeRecord(message.message)?.content + const text = Array.isArray(content) + ? content + .flatMap((part) => { + const value = claudeRecord(part) + return value?.type === 'text' && typeof value.text === 'string' ? [value.text] : [] + }) + .join('\n') + : '' + const key = this.key(message) + if (key) { + this.shown.set(key, true) + } + return { + kind: 'status' as const, + tone: 'error' as const, + ...agentSessionFailureWords( + agentSessionFailureFact('notSignedIn', { + account: this.account?.(), + detail: providerDiagnostic(text, 'person') + }), + { agentName: 'Claude', provider: 'claude', surface: 'row' } + ) + } + } + + resultAlreadyShown(message: Record): boolean { + const key = this.key(message) + if (!key || !this.shown.has(key)) { + return false + } + this.shown.delete(key) + return message.is_error === true + } + + private key(message: Record): string | null { + const uuid = claudeText(message.user_message_uuid) + return uuid + ? JSON.stringify([message.session_id, message.parent_tool_use_id ?? null, uuid]) + : null + } +} diff --git a/src/main/claude/claude-message-journaling.ts b/src/main/claude/claude-message-journaling.ts index 9d2e00a2e67f..bc7cb347ca00 100644 --- a/src/main/claude/claude-message-journaling.ts +++ b/src/main/claude/claude-message-journaling.ts @@ -33,6 +33,7 @@ import type { ClaudeProvisionalRowCorrections } from './claude-provisional-row-c import type { ClaudeSubagentRoster } from './claude-subagent-roster' import { claudeTurnOpenedBySendEcho, type ClaudeTurnSource } from './claude-turn-opening' import type { ClaudeOpenTurn } from './claude-open-turn' +import type { ClaudeAuthenticationFailures } from './claude-authentication-failures' export type ClaudeMessageJournalContext = { sink: StructuredAgentSessionEventSink @@ -50,6 +51,7 @@ export type ClaudeMessageJournalContext = { /** The session's open turn. Sole owner of turn identity and of the reopen * latch; this module asks it rather than tracking a copy. */ turn: ClaudeOpenTurn + authenticationFailures: ClaudeAuthenticationFailures } export function journalClaudeMessage( @@ -90,7 +92,7 @@ export function journalClaudeMessage( : envelope.parentToolUseId const stamp = ctx.corrections.stampFor(producerRef) const outputEnvelope = claudeOutputEnvelope(envelope) - const body = claudeMessageBody(outputEnvelope) + const body = ctx.authenticationFailures.assistant(message) ?? claudeMessageBody(outputEnvelope) const identity = (body && envelope.role === 'assistant' ? ctx.streamedBlocks.reconcile(envelope)?.identity diff --git a/src/main/claude/claude-result-journaling.ts b/src/main/claude/claude-result-journaling.ts index 1473886b9eac..c971b2333c63 100644 --- a/src/main/claude/claude-result-journaling.ts +++ b/src/main/claude/claude-result-journaling.ts @@ -25,6 +25,7 @@ export type ClaudeResultJournalContext = Pick< | 'providerFallback' | 'corrections' | 'turn' + | 'authenticationFailures' > & { prompts: ClaudeJournalPrompts context: ClaudeContextFacts @@ -40,7 +41,8 @@ export function journalClaudeResult( corrections, turn, prompts, - context + context, + authenticationFailures }: ClaudeResultJournalContext, message: Record, observedAt: number @@ -75,7 +77,8 @@ export function journalClaudeResult( } const kind = claudeProviderFrameKind(message) const failure = claudeResultFailure(message, leftToStop) - if (failure || !isSettledClaudeResultKind(kind)) { + const alreadyShown = authenticationFailures.resultAlreadyShown(message) + if ((failure || !isSettledClaudeResultKind(kind)) && !alreadyShown) { providerFallback.append( kind, message, diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index e2932e0d3468..370299171378 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -5,7 +5,6 @@ import { claudeStreamingMessageBody, type ClaudeToolUse } from './claude-structured-item-translation' -import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' import { claudeProviderFrameActivity } from '../native-chat/agent-session-wire/provider-frame-activity' import { claudeProviderFrameKind, @@ -35,6 +34,8 @@ import { ClaudeJournalPrompts } from './claude-structured-journal-prompts' import { claudeChildToolQueries } from './claude-child-tool-queries' import { journalClaudeMessage, type ClaudeMessageJournalContext } from './claude-message-journaling' import { journalClaudeResult, type ClaudeResultJournalContext } from './claude-result-journaling' +import { ClaudeAuthenticationFailures } from './claude-authentication-failures' +import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' export type { ClaudeJournalTranslator } from './claude-journal-translator-contract' @@ -45,22 +46,7 @@ export type ClaudeJournalTranslatorDeps = { schedule?: AgentSessionDeltaCoalescerDeps['schedule'] fallbackIdPrefix?: string onBackgroundTaskJournalFailure?: (error: Error) => void -} - -export function createClaudeSessionJournalTranslator( - sink: StructuredAgentSessionEventSink | undefined, - prompts: ClaudePromptRegistry, - fallbackIdPrefix: string, - onBackgroundTaskJournalFailure?: (error: Error) => void -): ClaudeJournalTranslator | null { - return sink - ? createClaudeJournalTranslator({ - sink, - fallbackIdPrefix, - ...(onBackgroundTaskJournalFailure ? { onBackgroundTaskJournalFailure } : {}), - bindPromptItemId: (itemId, promptKey) => prompts.bindJournalItemId(itemId, promptKey) - }) - : null + account?: () => AgentSessionAccountKind | undefined } export function createClaudeJournalTranslator( @@ -184,7 +170,8 @@ export function createClaudeJournalTranslator( backgroundTasks, providerFallback, corrections, - turn + turn, + authenticationFailures: new ClaudeAuthenticationFailures(deps.account) } const resultContext: ClaudeResultJournalContext = { ...messageContext, prompts, context } diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index c10b8d626075..2290ba53e965 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -20,7 +20,6 @@ import { claudeConfigDirEnvPatch } from './claude-config-dir-pin' import { CLAUDE_SPAWN_TOKEN_ENV, claudeProcessIdentity } from './claude-structured-owner-identity' import { ClaudePromptRegistry } from './claude-structured-prompt-replies' import { adoptClaudeStructuredSpawnOptions } from './claude-structured-spawn-options' -import { createClaudeSessionJournalTranslator } from './claude-structured-journal-translation' import { observeClaudeFastModeFacts } from './claude-structured-session-options' import { readClaudeStartupFacts, @@ -45,7 +44,7 @@ import { resolveClaudeAcquisitionLaunch } from './claude-structured-acquisition- import { claudeAcquireCatalogAccess } from './claude-structured-acquire-catalog' import { bindClaudeConnectionJournalControls, - createClaudeJournalFailureHandler + createClaudeSessionJournalTranslator } from './claude-structured-session-journal-control' export async function acquireClaudeSession({ @@ -82,12 +81,12 @@ export async function acquireClaudeSession({ // Frames are admitted only after launch resolution proves the provider session // this acquisition owns. Keep the check ahead of every stateful consumer. const initProof = createClaudeInitProof() - const translator = createClaudeSessionJournalTranslator( - input.events, - prompts, - String(input.fence), - createClaudeJournalFailureHandler({ attempt, initProof, callbacks, sessionId }) - ) + const translator = createClaudeSessionJournalTranslator(input.events, String(input.fence), { + attempt, + initProof, + callbacks, + sessionId + }) const onMessage = (message: Record): void => { const init = readClaudeInit(message) @@ -170,6 +169,7 @@ export async function acquireClaudeSession({ attempt }) expectedProviderSessionId = launch.providerSessionId + attempt.account = launch.account observedLeafUuid = launch.resumeLeafUuid const open = deps.openConnection ?? openClaudeStreamJsonConnection const connection = await withAgentSessionCreatePhase('spawn', input.recordPhase, () => diff --git a/src/main/claude/claude-structured-session-journal-control.ts b/src/main/claude/claude-structured-session-journal-control.ts index 796e75c35f95..02dd74f31b0a 100644 --- a/src/main/claude/claude-structured-session-journal-control.ts +++ b/src/main/claude/claude-structured-session-journal-control.ts @@ -5,12 +5,31 @@ import { import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import type { ClaudeInitProof } from './claude-structured-init-proof' import type { ClaudeAcquisitionAttempt, ClaudeAcquireCallbacks } from './claude-structured-session-state' +export function createClaudeSessionJournalTranslator( + sink: StructuredAgentSessionEventSink | undefined, + fallbackIdPrefix: string, + failure: Parameters[0] +): ClaudeJournalTranslator | null { + const { attempt } = failure + return sink + ? createClaudeJournalTranslator({ + sink, + account: () => attempt.account, + fallbackIdPrefix, + onBackgroundTaskJournalFailure: createClaudeJournalFailureHandler(failure), + bindPromptItemId: (itemId, promptKey) => + attempt.prompts.bindJournalItemId(itemId, promptKey) + }) + : null +} + export function createClaudeJournalFailureHandler(input: { attempt: ClaudeAcquisitionAttempt initProof: ClaudeInitProof diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 096a3a4c6724..61ad3a645511 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -254,6 +254,8 @@ export type ClaudeAcquisitionAttempt = { exitProven: boolean finished: Promise finish: () => void + /** The resolved launch's account, read by the translator to word a sign-in failure. */ + account?: ClaudeStructuredLaunch['account'] } export function createClaudeAcquisitionAttempt( diff --git a/src/main/claude/claude-structured-sign-in.test.ts b/src/main/claude/claude-structured-sign-in.test.ts new file mode 100644 index 000000000000..fcc8314eb95b --- /dev/null +++ b/src/main/claude/claude-structured-sign-in.test.ts @@ -0,0 +1,73 @@ +import { readFile } from 'node:fs/promises' +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +const entry = z.looseObject({ kind: z.string() }) +const capturedFrame = z.object({ at: z.number(), frame: z.record(z.string(), z.unknown()) }) + +async function replay(account: 'system' | 'managed', removeTypedError = false) { + const bodies: AgentJournalItemBody[] = [] + const translator = createClaudeJournalTranslator({ + account: () => account, + sink: { + appendItem: (_identity, body) => bodies.push(body), + appendTombstone: () => {}, + publish: () => {} + } + }) + const capture = await readFile( + new URL('./__fixtures__/claude-lifecycle-capture-auth-failed.jsonl', import.meta.url), + 'utf8' + ) + for (const line of capture.trim().split('\n')) { + const value: unknown = JSON.parse(line) + if (entry.parse(value).kind !== 'frame') { + continue + } + const { at, frame } = capturedFrame.parse(value) + if (removeTypedError) { + delete frame.error + } + translator.handle({ type: 'message', sessionId: 'session', observedAt: at, message: frame }) + } + translator.dispose() + return bodies +} + +describe('captured Claude authentication failure', () => { + it.each(['system', 'managed'] as const)( + 'shows one failure with %s sign-in advice and Claude detail', + async (account) => { + const bodies = await replay(account) + const failures = bodies.flatMap((body) => + body.kind === 'status' && body.failure ? [body] : [] + ) + expect(failures).toHaveLength(1) + expect(failures[0]?.failure).toMatchObject({ + kind: 'notSignedIn', + account, + detail: { text: 'Not logged in · Please run /login', audience: 'person' } + }) + expect(failures[0]?.text).toContain( + account === 'managed' ? 'Sign in again in Claude Accounts settings.' : 'claude auth login' + ) + expect( + bodies.filter((body) => body.kind === 'message' && body.role === 'assistant') + ).toHaveLength(0) + expect( + bodies.flatMap((body) => + body.kind === 'status' && body.text.includes('Not logged in') ? [body] : [] + ) + ).toHaveLength(1) + } + ) + it('does not infer authentication from result text without the typed assistant error', async () => { + expect( + (await replay('system', true)).some( + (body) => body.kind === 'status' && body.failure?.kind === 'notSignedIn' + ) + ).toBe(false) + }) +}) diff --git a/src/main/codex/codex-authentication-failure.ts b/src/main/codex/codex-authentication-failure.ts new file mode 100644 index 000000000000..90c556bcf074 --- /dev/null +++ b/src/main/codex/codex-authentication-failure.ts @@ -0,0 +1,34 @@ +import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure' +import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' +import { + readCodexErrorAdditionalDetails, + readCodexErrorInfo, + readCodexErrorMessage +} from './codex-structured-thread-facts' + +const HTTP_ERROR_KINDS = [ + 'httpConnectionFailed', + 'responseStreamConnectionFailed', + 'responseStreamDisconnected', + 'responseTooManyFailedAttempts' +] + +/** Only the protocol's unauthorized variant or an HTTP error carrying 401 proves sign-out. */ +export function codexAuthenticationFailure(payload: unknown, account?: AgentSessionAccountKind) { + const info = readCodexErrorInfo(payload) + if ( + info?.error !== 'unauthorized' && + !(info?.status === 401 && HTTP_ERROR_KINDS.includes(info.error)) + ) { + return null + } + const message = readCodexErrorMessage(payload) + const additional = readCodexErrorAdditionalDetails(payload) + const text = [message, additional && additional !== message ? additional : null] + .filter((part): part is string => part !== null) + .join('\n') + return agentSessionFailureFact('notSignedIn', { + account, + detail: providerDiagnostic(text, 'person') + }) +} diff --git a/src/main/codex/codex-structured-dispatch-echo.ts b/src/main/codex/codex-structured-dispatch-echo.ts index 78c0b8c867ab..b582ba8715b1 100644 --- a/src/main/codex/codex-structured-dispatch-echo.ts +++ b/src/main/codex/codex-structured-dispatch-echo.ts @@ -15,7 +15,7 @@ export const MAX_CODEX_RECORDED_TURN_ENDS = 64 export type CodexTurnEnd = | { status: 'completed' } | { status: 'interrupted' } - | { status: 'failed'; detail?: ProviderDiagnostic } + | { status: 'failed'; detail?: ProviderDiagnostic; notSignedIn?: true } export type CodexDispatchRequestOrigin = { requestedAt: number diff --git a/src/main/codex/codex-structured-journal-contracts.ts b/src/main/codex/codex-structured-journal-contracts.ts index b79ac93c3fc3..3093148470a0 100644 --- a/src/main/codex/codex-structured-journal-contracts.ts +++ b/src/main/codex/codex-structured-journal-contracts.ts @@ -1,4 +1,5 @@ import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' import type { CodexDispatchRequestOrigin } from './codex-structured-dispatch-echo' import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' @@ -20,6 +21,7 @@ export type CodexActiveJournalItem = { } export type CodexJournalTranslatorDeps = { + account?: () => AgentSessionAccountKind | undefined sink: StructuredAgentSessionEventSink /** Names this connection in frame-row identities, so a later connection never revises its rows. */ acquisitionId?: string diff --git a/src/main/codex/codex-structured-journal-generic-frames.ts b/src/main/codex/codex-structured-journal-generic-frames.ts index 74cb932c08e4..5cdacc2b9786 100644 --- a/src/main/codex/codex-structured-journal-generic-frames.ts +++ b/src/main/codex/codex-structured-journal-generic-frames.ts @@ -18,13 +18,15 @@ import { MAX_CODEX_GENERIC_TURN_BUCKETS, MAX_CODEX_WORDLESS_ROWS_PER_TURN } from './codex-structured-journal-limits' -import { readCodexTurnId } from './codex-structured-thread-facts' +import { readCodexTurnId, readCodexErrorWillRetry } from './codex-structured-thread-facts' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { CODEX_PLAN_UPDATED_FRAME_KIND, isWordlessProviderFrame } from '../../shared/native-chat-provider-frame-summary' import type { CodexRowAttribution } from './codex-subagent-linkage' +import { codexAuthenticationFailure } from './codex-authentication-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' const OVERFLOW_BUCKET = '__codex-generic-overflow__' /** A turn's rows by whether a client draws them; each kind has its own cap. */ @@ -74,7 +76,7 @@ export class CodexJournalGenericFrames { constructor( private readonly deps: Pick< CodexJournalTranslatorDeps, - 'sink' | 'schedule' | 'coalesceMs' | 'acquisitionId' + 'sink' | 'schedule' | 'coalesceMs' | 'acquisitionId' | 'account' > & { attributionFor: CodexRowAttribution }, @@ -99,7 +101,25 @@ export class CodexJournalGenericFrames { ) // A frame the classifier declines is deliberately not journaled, which is success. // Failing admission here force-closes the provider through the retry queue. - return translated ? this.appendFrameRow(threadId, payload, translated) : CODEX_JOURNAL_ADMITTED + const auth = + kind === 'notification:error' && !readCodexErrorWillRetry(payload) + ? codexAuthenticationFailure(payload, this.deps.account?.()) + : null + return translated + ? this.appendFrameRow(threadId, payload, { + ...translated, + body: auth + ? { + ...translated.body, + ...agentSessionFailureWords(auth, { + provider: 'codex', + agentName: 'Codex', + surface: 'row' + }) + } + : translated.body + }) + : CODEX_JOURNAL_ADMITTED } /** One frame's own row, under the identity every frame row gets, however it is worded. */ diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 5171e92a1a0f..54cc082c67a1 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -1,3 +1,4 @@ +import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' import { CODEX_STRUCTURED_HANDLE_NAMESPACE, isAgentSessionProviderHandleInNamespace @@ -15,7 +16,7 @@ import { import { CodexBackgroundTaskTracker, codexChildWorkSink } from './codex-background-task-tracker' import { CodexSubagentExecutions } from './codex-subagent-executions' import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' -import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import { createCodexSessionJournalTranslator } from './codex-structured-session-journal' import { openCodexAppServerConnection } from './codex-app-server-connection' import { codexProviderHandleLink, @@ -84,30 +85,20 @@ export async function acquireCodexStructuredSession(input: { : null const subagentExecutions = new CodexSubagentExecutions() const dispatchEchoes = createCodexDispatchEchoes() + let account: AgentSessionAccountKind | undefined // Minted before the translator, which names this connection's frame rows with it. const acquisitionGeneration = mintCodexAcquisitionGeneration(deps) - const translator = acquireInput.events - ? createCodexJournalTranslator({ - sink: acquireInput.events, - sessionId, - acquisitionId: acquisitionGeneration, - ...(deps.now ? { now: deps.now } : {}), - primaryThreadId: () => primaryThreadId, - onPrimaryThreadStoppedRunning: () => deps.onPrimaryThreadStoppedRunning?.({ sessionId }), - dispatchRequestOrigin: (clientMessageId) => dispatchEchoes.requestOrigin(clientMessageId), - subagentExecutions, - bindPromptItemId: (journalItemId, threadId, promptKey, turnId) => - acquisition.prompts.bindJournalItemId(journalItemId, threadId, promptKey, turnId), - clearPromptTurn: (threadId, turnId) => acquisition.prompts.clearTurn(threadId, turnId), - onUserMessageEcho: (clientMessageId, providerIdentity) => { - // Only a send THIS session admitted; an echo from history restore or - // another client names no submission of ours to settle. - if (dispatchEchoes.settle(clientMessageId)) { - deps.onDispatchSettledLate?.({ sessionId, clientMessageId, providerIdentity }) - } - } - }) - : null + const translator = createCodexSessionJournalTranslator({ + sink: acquireInput.events, + account: () => account, + sessionId, + acquisitionId: acquisitionGeneration, + deps, + primaryThreadId: () => primaryThreadId, + dispatchEchoes, + subagentExecutions, + prompts: acquisition.prompts + }) const open = deps.openConnection ?? openCodexAppServerConnection const spawnIdentity = codexSpawnedProcessIdentity(acquireInput, deps.readProcessStartTime) try { @@ -135,6 +126,7 @@ export async function acquireCodexStructuredSession(input: { throw new AgentSessionPreSpawnError(error) }) acquisitions.assertCurrent(sessionId, attempt) + account = launch.codexHome ? deps.resolveAccountKind?.(launch.codexHome) : undefined const connection = await open( { command: launch.command, @@ -241,6 +233,7 @@ export async function acquireCodexStructuredSession(input: { // Where this session's child work goes: the host's records, after each frame is journaled. const sink = codexChildWorkSink(sessionId, deps) const session: CodexSession = { + account, connection, ...codexSessionLifecycle(acquireInput.fence, acquired.acquisitionGeneration as string), threadId: opened.threadId, diff --git a/src/main/codex/codex-structured-session-journal.ts b/src/main/codex/codex-structured-session-journal.ts new file mode 100644 index 000000000000..aef103b4c2a7 --- /dev/null +++ b/src/main/codex/codex-structured-session-journal.ts @@ -0,0 +1,45 @@ +import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import type { CodexSubagentExecutions } from './codex-subagent-executions' +import type { + CodexAcquisitionAttempt, + CodexStructuredSessionAdapterDeps +} from './codex-structured-session-state' + +export function createCodexSessionJournalTranslator(input: { + sink: StructuredAgentSessionEventSink | undefined + account: () => AgentSessionAccountKind | undefined + sessionId: string + acquisitionId: string + deps: CodexStructuredSessionAdapterDeps + primaryThreadId: () => string | null + dispatchEchoes: CodexDispatchEchoes + subagentExecutions: CodexSubagentExecutions + prompts: CodexAcquisitionAttempt['window']['prompts'] +}) { + const { sink, account, sessionId, acquisitionId, deps, dispatchEchoes, prompts } = input + return sink + ? createCodexJournalTranslator({ + sink, + account, + sessionId, + acquisitionId, + ...(deps.now ? { now: deps.now } : {}), + primaryThreadId: input.primaryThreadId, + onPrimaryThreadStoppedRunning: () => deps.onPrimaryThreadStoppedRunning?.({ sessionId }), + dispatchRequestOrigin: (clientMessageId) => dispatchEchoes.requestOrigin(clientMessageId), + subagentExecutions: input.subagentExecutions, + bindPromptItemId: (journalItemId, threadId, promptKey, turnId) => + prompts.bindJournalItemId(journalItemId, threadId, promptKey, turnId), + clearPromptTurn: (threadId, turnId) => prompts.clearTurn(threadId, turnId), + onUserMessageEcho: (clientMessageId, providerIdentity) => { + // History echoes do not settle a send this session never admitted. + if (dispatchEchoes.settle(clientMessageId)) { + deps.onDispatchSettledLate?.({ sessionId, clientMessageId, providerIdentity }) + } + } + }) + : null +} diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index fc2c505e9fb5..62347780f853 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -1,4 +1,5 @@ import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' import type { AgentJournalAnsweredTurnIdentity, AgentJournalItemIdentity, @@ -86,6 +87,7 @@ export type CodexStructuredSessionEvent = | { type: 'ended'; sessionId: string; reason: string; observedAt?: number } export type CodexStructuredSessionAdapterDeps = { + resolveAccountKind?: (home: string) => AgentSessionAccountKind | undefined resolveLaunch: (input: { identity: AgentSessionJournalIdentity }) => Promise @@ -119,6 +121,7 @@ export type CodexStructuredSessionAdapterDeps = { } export type CodexSession = { + account?: AgentSessionAccountKind connection: CodexAppServerConnection ended: boolean /** First observed child exit survives rejected settlement admission. */ diff --git a/src/main/codex/codex-structured-sign-in.test.ts b/src/main/codex/codex-structured-sign-in.test.ts new file mode 100644 index 000000000000..d14ddf3a1058 --- /dev/null +++ b/src/main/codex/codex-structured-sign-in.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import { codexAuthenticationFailure } from './codex-authentication-failure' +import { codexTurnEndRejection, readCodexTurnEnd } from './codex-structured-turn-end-settlement' + +const errors = [ + 'unauthorized', + ...[ + 'httpConnectionFailed', + 'responseStreamConnectionFailed', + 'responseStreamDisconnected', + 'responseTooManyFailedAttempts' + ].map((kind) => ({ [kind]: { httpStatusCode: 401 } })) +] +const detail = 'The session token has expired.' + +describe('Codex protocol authentication failures', () => { + it.each(errors)( + 'classifies the exact protocol kind %j on failure rows and rejected sends', + (codexErrorInfo) => { + const error = { + codexErrorInfo, + message: detail, + additionalDetails: 'Sign in on the execution host.' + } + const bodies: AgentJournalItemBody[] = [] + const translator = createCodexJournalTranslator({ + primaryThreadId: () => 'thread', + account: () => 'managed', + sink: { + appendItem: (_identity, body) => bodies.push(body), + appendTombstone: () => {}, + publish: () => {} + } + }) + translator.handle({ + type: 'notification', + sessionId: 'session', + threadId: 'thread', + method: 'error', + params: { threadId: 'thread', turnId: 'turn', error, willRetry: false } + }) + const row = bodies.find((body) => body.kind === 'status') + expect(row).toMatchObject({ + failure: { kind: 'notSignedIn', account: 'managed', detail: { audience: 'person' } } + }) + expect(row && 'text' in row ? row.text : '').toContain(detail) + expect(row && 'text' in row ? row.text : '').toContain( + 'Sign in again in Codex Accounts settings.' + ) + const end = readCodexTurnEnd('turn/completed', { + turn: { id: 'turn', status: 'failed', error } + }) + expect(end && codexTurnEndRejection(end, 'system')).toMatchObject({ + rejection: { kind: 'notSignedIn', account: 'system' } + }) + expect(end && codexTurnEndRejection(end, 'system')?.reason).toContain('codex login') + translator.dispose() + } + ) + it.each([ + 'other', + 'unauthorized request', + { other: { httpStatusCode: 401 } }, + { httpConnectionFailed: { httpStatusCode: 403 } }, + { httpConnectionFailed: { httpStatusCode: 500 } } + ])('does not guess sign-out from unrelated failures %j', (codexErrorInfo) => { + expect( + codexAuthenticationFailure({ + error: { codexErrorInfo, message: '401 unauthorized authentication required' } + }) + ).toBeNull() + }) + it('keeps a retrying 401 as a retry, without asking the person to sign in yet', () => { + const bodies: AgentJournalItemBody[] = [] + const translator = createCodexJournalTranslator({ + sink: { + appendItem: (_identity, body) => bodies.push(body), + appendTombstone: () => {}, + publish: () => {} + } + }) + translator.handle({ + type: 'notification', + sessionId: 'session', + threadId: 'thread', + method: 'error', + params: { error: { codexErrorInfo: 'unauthorized', message: detail }, willRetry: true } + }) + expect(bodies).toContainEqual( + expect.objectContaining({ failure: expect.objectContaining({ kind: 'providerRetrying' }) }) + ) + translator.dispose() + }) +}) diff --git a/src/main/codex/codex-structured-turn-end-settlement.test.ts b/src/main/codex/codex-structured-turn-end-settlement.test.ts index 2b6542a43336..776ce7263103 100644 --- a/src/main/codex/codex-structured-turn-end-settlement.test.ts +++ b/src/main/codex/codex-structured-turn-end-settlement.test.ts @@ -409,3 +409,25 @@ describe('a send bound to a turn', () => { expect(echoes.size).toBe(1) }) }) + +it('settles the first un-echoed unauthorized turn as signed out, with Codex detail', async () => { + const rig = await turnEndRig() + await rig.sendAndOpen('auth-first') + const error = { codexErrorInfo: 'unauthorized', message: 'The authentication token has expired.' } + const notify = rig.codex.connections[0]?.handlers.onNotification + notify?.('error', { threadId: CODEX_TEST_THREAD_ID, turnId: 'turn-1', willRetry: false, error }) + notify?.('turn/completed', { + threadId: CODEX_TEST_THREAD_ID, + turn: { id: 'turn-1', status: 'failed', error } + }) + expect(rig.settlements).toEqual([ + expect.objectContaining({ + clientMessageId: 'auth-first', + state: 'rejected', + rejection: { kind: 'notSignedIn', detail: { text: error.message, audience: 'person' } } + }) + ]) + expect( + rig.settlements[0] && 'reason' in rig.settlements[0] ? rig.settlements[0].reason : '' + ).toContain('codex login') +}) diff --git a/src/main/codex/codex-structured-turn-end-settlement.ts b/src/main/codex/codex-structured-turn-end-settlement.ts index 44416ed9b84d..cc1a62d05615 100644 --- a/src/main/codex/codex-structured-turn-end-settlement.ts +++ b/src/main/codex/codex-structured-turn-end-settlement.ts @@ -30,16 +30,21 @@ import { readCodexTurnStatus } from './codex-structured-thread-facts' import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' +import { codexAuthenticationFailure } from './codex-authentication-failure' /** A message Codex rejected, in the words that name Codex and its legacy markers. */ export function codexDispatchRejection( - failure: SubmissionRejectionFact + failure: SubmissionRejectionFact, + account?: CodexSession['account'] ): AgentJournalDispatchRejection { - return agentSessionFailureWords(failure, { - surface: 'rejection', - agentName: TUI_AGENT_DISPLAY_NAMES.codex, - provider: 'codex' - }) + return agentSessionFailureWords( + { ...failure, ...(account ? { account } : {}) }, + { + surface: 'rejection', + agentName: TUI_AGENT_DISPLAY_NAMES.codex, + provider: 'codex' + } + ) } export type CodexTurnEndSettlement = { @@ -72,7 +77,11 @@ function unopenedTurnFailure( const message = readCodexJournalRecord(readCodexJournalRecord(params).error).message const detail = typeof message === 'string' && message ? providerDiagnostic(message, 'person') : undefined - return { status: 'failed', ...(detail ? { detail } : {}) } + const auth = codexAuthenticationFailure(params) + return { + status: 'failed', + ...(auth ? { notSignedIn: true, detail: auth.detail } : detail ? { detail } : {}) + } } /** The end a primary-thread notification reports for its turn, or null for any other frame. */ @@ -85,20 +94,32 @@ export function readCodexTurnEnd(method: string, params: unknown): CodexTurnEnd return { status: 'interrupted' } } if (status === 'failed') { + const auth = codexAuthenticationFailure({ + error: readCodexJournalRecord(readCodexJournalRecord(params).turn).error + }) const detail = errorDetail(params) - return { status: 'failed', ...(detail ? { detail } : {}) } + return { + status: 'failed', + ...(auth ? { notSignedIn: true, detail: auth.detail } : detail ? { detail } : {}) + } } return { status: 'completed' } } /** How an ended turn settles a send it never echoed; null leaves the send to its echo. */ -export function codexTurnEndRejection(end: CodexTurnEnd): AgentJournalDispatchRejection | null { +export function codexTurnEndRejection( + end: CodexTurnEnd, + account?: CodexSession['account'] +): AgentJournalDispatchRejection | null { if (end.status === 'interrupted') { return agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) } if (end.status === 'failed') { return codexDispatchRejection( - agentSessionFailureFact('providerRejected', end.detail ? { detail: end.detail } : {}) + agentSessionFailureFact(end.notSignedIn ? 'notSignedIn' : 'providerRejected', { + ...(end.detail ? { detail: end.detail } : {}), + ...(account ? { account } : {}) + }) ) } return null @@ -118,7 +139,7 @@ export function noteCodexTurnOpened( /** Settles the sends bound to the turn this admitted notification ended. */ export function settleCodexSendsInEndedTurn( - session: Pick, + session: Pick, frame: { sessionId: string; method: string; params: unknown }, settle: (settlement: CodexTurnEndSettlement) => void ): void { @@ -134,7 +155,7 @@ export function settleCodexSendsInEndedTurn( ) { return } - const rejection = codexTurnEndRejection(end) + const rejection = codexTurnEndRejection(end, session.account) for (const { clientMessageId, via } of session.dispatchEchoes.endTurn( session.threadId, turnId, diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 85a4007d2f7b..99aced214f60 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -1,3 +1,4 @@ +import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' import type { AgentJournalMessageItem, @@ -53,6 +54,7 @@ export function isCodexTurnOptionKey(key: string): boolean { /** The session state one turn needs. */ export type CodexTurnHost = { + account?: AgentSessionAccountKind connection: Pick threadId: string options: Map @@ -230,7 +232,7 @@ export async function dispatchCodexTurn( answer.via ) : null - const rejection = endedFirst ? codexTurnEndRejection(endedFirst) : null + const rejection = endedFirst ? codexTurnEndRejection(endedFirst, session.account) : null return rejection && answer.turnId ? { state: 'rejected', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts index 1b62ddf43625..82188da92860 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts @@ -30,6 +30,7 @@ import { import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import { providerExecutableMissing } from '../../provider-process/provider-executable-missing' import { argumentProblemOf } from '../structured-agent-arguments-error' +import { providerDiagnosticOf } from '../../../shared/agent-session-failure' /** Who a failed acquisition's sentence names, and whether it was the session's first start. */ export type FailedAcquisitionWording = { @@ -49,6 +50,7 @@ function failedAcquisitionWireRefusal( const { reason } = structuredAgentSessionStartFailure( { refusal, + diagnostic: providerDiagnosticOf(error), argumentProblem: argumentProblemOf(error), ...(wording.newSession ? { newSession: true as const } : {}) }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts index 190910496680..c7f41c8d163c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts @@ -21,6 +21,7 @@ import { structuredAgentSessionStartFailure, withObservedProviderExit } from './structured-agent-session-failure-text' +import { structuredAgentSessionStartFailureRow } from './structured-agent-session-start-failure-row' /** What Orca's own error text looks like: a code, a marker, a uuid, a path, an exception. */ const ORCA_INTERNAL = @@ -106,12 +107,64 @@ describe('structuredAgentSessionStartFailure', () => { expect(structuredAgentSessionStartFailure({ error: refusal }, { agentName: 'Claude' })).toEqual( { reason: - "Claude isn't signed in. Run `claude` and sign in with /login, or choose an account in Claude Accounts settings.", + "Claude isn't signed in. Run `claude auth login`, or choose an account in Claude Accounts settings.", rejection: { kind: 'notSignedIn' } } ) }) + it('keeps a person-facing ACP diagnostic through both typed startup paths', () => { + const detail = providerDiagnostic( + 'Grok needs a configured API key. Literal {{agent}}.', + 'person' + ) + const error = withProviderDiagnostic( + new AgentSessionAcquisitionRefusal('signed out', 'notSignedIn'), + detail + ) + const direct = structuredAgentSessionStartFailure({ error }, { agentName: 'Grok' }) + const refused = structuredAgentSessionStartFailure( + { + refusal: refuse('agent_session_operation_invalid', { reason: 'notSignedIn' }, 'log'), + diagnostic: detail + }, + { agentName: 'Grok' } + ) + expect(direct.rejection).toEqual({ kind: 'notSignedIn', detail }) + expect(refused).toEqual(direct) + expect(structuredAgentSessionStartFailureRow('generation', direct)).toMatchObject({ + body: { kind: 'status', failure: { kind: 'notSignedIn', detail }, text: direct.reason } + }) + expect(direct.reason).toContain('Grok needs a configured API key. Literal {{agent}}.') + }) + + it.each(['Grok', 'Pi', 'OMP', 'OpenCode'])( + 'never asks to resend a message when %s fails before one was submitted', + (agentName) => { + const refusal = refuse('agent_session_operation_invalid', { reason: 'notSignedIn' }, 'log') + const startup = structuredAgentSessionStartFailure( + { refusal, newSession: true }, + { agentName } + ) + expect(startup.reason).toContain(`Sign in to ${agentName}`) + expect(startup.reason).not.toContain('send your message again') + const rejected = structuredAgentSessionStartFailure({ refusal }, { agentName }) + expect(rejected.reason).toContain('Then send your message again.') + } + ) + + it('keeps direct-create managed account guidance free of resend instructions', () => { + const refusal = refuse( + 'agent_session_operation_invalid', + { reason: 'managedAccountUnsupported' }, + 'log' + ) + expect( + structuredAgentSessionStartFailure({ refusal, newSession: true }, { agentName: 'Claude' }) + .reason + ).not.toContain('send your message again') + }) + it.each(['managed', 'system'] as const)( 'keeps the %s account in every start-failure fact', (account) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index fde97b1b09a9..ea7837278cbf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -74,10 +74,10 @@ export function providerStartupFailureFact(cause?: unknown): SubmissionRejection cause instanceof AgentSessionAcquisitionRefusal ? cause.reason : undefined ) if (typed) { - return agentSessionFailureFact( - typed, - cause instanceof AgentSessionAcquisitionRefusal ? { account: cause.account } : {} - ) + return agentSessionFailureFact(typed, { + ...(cause instanceof AgentSessionAcquisitionRefusal ? { account: cause.account } : {}), + detail: providerDiagnosticOf(cause) + }) } if (providerExecutableMissing(cause)) { return agentSessionFailureFact('cliMissing') @@ -109,12 +109,12 @@ function refusedStartFailureFact( const reason = refusal.details?.reason const typed = typedStartRefusal(reason) if (typed) { - return agentSessionFailureFact( - typed, - refusal.code === 'agent_session_operation_invalid' + return agentSessionFailureFact(typed, { + ...(refusal.code === 'agent_session_operation_invalid' ? { account: refusal.details?.account } - : {} - ) + : {}), + detail: diagnostic + }) } if (reason === 'providerStartFailed') { return agentSessionFailureFact('providerStartFailed', { detail: diagnostic }) @@ -182,6 +182,7 @@ export function structuredAgentSessionStartFailure( ): StructuredAgentSessionStartFailureWords { return agentSessionFailureWords(structuredAgentSessionStartFailureFact(cause), { ...context, + ...('refusal' in cause && cause.newSession ? { messageSubmitted: false } : {}), surface: 'rejection' }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 9abd58aa0218..2df29121596d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -22,6 +22,7 @@ import type { import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import { recoverStructuredRewind } from './structured-rewind-recovery' +import { retireSignedOutStructuredAgentSessionChild } from './structured-agent-session-signed-out-child' export type StructuredAgentSessionConversationDelivery = { loop: StructuredAgentSessionDeliveryLoop @@ -56,6 +57,8 @@ export function createStructuredAgentSessionConversationDelivery(input: { sessionId: string, startedFor: string ) => Promise + /** Stops a child that reported it is not signed in, so the start after it reads a new login. */ + stopSignedOutAgent: (sessionId: string) => Promise clientDelivery: Pick< StructuredAgentSessionClientDelivery, 'publishRestored' | 'readChildWork' | 'readStopping' | 'publishStatus' @@ -68,7 +71,25 @@ export function createStructuredAgentSessionConversationDelivery(input: { agents: deps.agents, serialize: input.serialize, trackStart: input.trackStart, - ensureProviderChild: input.ensureProviderChild, + ensureProviderChild: async (sessionId, startedFor) => { + // A send waiting on a person's Stop is not handed over yet; the step after the Stop decides. + const session = input.clientDelivery.readStopping(sessionId) + ? undefined + : sessions.get(sessionId) + await retireSignedOutStructuredAgentSessionChild(sessionId, session, { + work: { + childWork: () => input.clientDelivery.readChildWork(sessionId), + hasOpenDispatch: () => { + const record = deps.store.getRecord(sessionId) + return record !== null && deps.hasOpenDispatch?.(record) === true + }, + providerHoldsDispatch: () => deps.adapter.holdsDispatch?.(sessionId) === true + }, + stopAgent: input.stopSignedOutAgent, + logger: deps.logger + }) + return input.ensureProviderChild(sessionId, startedFor) + }, conversationFence: (sessionId) => structuredAgentSessionConversationFence(deps.store, sessionId), holdClosed: async (sessionId, which) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 64ed2f26d5fa..1a08095a82ec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -124,6 +124,8 @@ export class StructuredAgentSessionHost { trackStart: (start) => this.tasks.trackAttach(start), ensureProviderChild: (sessionId, startedFor) => agentStart.ensureStructuredAgentSessionAgent(this.attachContext(), sessionId, startedFor), + // Put to rest like an idle agent: the stop writes nothing, and the next start is new. + stopSignedOutAgent: (id) => this.lifetime.stopAgent(id, { cause: 'evict', resting: true }), clientDelivery: this.clientDelivery }) this.restore = reveal.createStructuredAgentSessionHostRestore(deps, { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index 1b67f259ed95..1fd7fc937568 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -9,12 +9,11 @@ // Owed work is derived on every tick, never stored, so there is nothing to disagree with it. import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-liveness' -import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' -import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' export const STRUCTURED_AGENT_SESSION_IDLE_SWEEP_INTERVAL_MS = 5 * 60_000 export const STRUCTURED_AGENT_SESSION_IDLE_MS = 30 * 60_000 @@ -43,15 +42,35 @@ export type StructuredAgentSessionIdleSweepDeps = { idleMs?: number } -/** A prompt the user has not answered. A subagent can raise one the lead turn cannot see. */ -export function hasPendingStructuredAgentSessionPrompt( - items: readonly AgentJournalRenderItem[] +/** The host's reads of what a running child still serves besides its journal. */ +export type StructuredAgentSessionChildWorkReads = { + childWork: () => readonly AgentChildWorkView[] | undefined + hasOpenDispatch: () => boolean + providerHoldsDispatch: () => boolean +} + +/** Work the running child owes apart from queued sends: a turn, a child agent, an open + * orchestration dispatch, a send the provider holds, or a prompt nobody answered. Whatever owes + * any of it keeps its child; it builds no snapshot. */ +export function structuredAgentSessionChildHasOpenWork( + journal: Pick, + reads: StructuredAgentSessionChildWorkReads ): boolean { - return items.some( - (item) => - (item.body.kind === 'approval' || item.body.kind === 'question') && - item.body.resolution.state === 'pending' - ) + if ( + journal.activeTurnId() !== null || + agentChildWorkLiveness(reads.childWork()) !== null || + reads.hasOpenDispatch() || + reads.providerHoldsDispatch() + ) { + return true + } + let pendingPrompt = false + journal.visitItems((_itemId, _sequence, body) => { + // A subagent can raise a prompt the lead turn cannot see. + pendingPrompt ||= + (body.kind === 'approval' || body.kind === 'question') && body.resolution.state === 'pending' + }) + return pendingPrompt } export class StructuredAgentSessionIdleSweep { @@ -139,14 +158,13 @@ export class StructuredAgentSessionIdleSweep { /** Work the running child still owes. Scoped to the child: with none, nothing here can pin the * handle, and a leftover prompt or turn row is only history. */ private owesWork(sessionId: string, session: StructuredAgentSessionHostSession): boolean { - const items = session.journal.snapshot().items return ( - activeStructuredAgentSessionTurnId(items) !== null || this.queuedOrDelivering(sessionId, session) || - agentChildWorkLiveness(this.deps.childWork(sessionId)) !== null || - this.deps.hasOpenDispatch(sessionId) || - this.deps.providerHoldsDispatch(sessionId) || - hasPendingStructuredAgentSessionPrompt(items) + structuredAgentSessionChildHasOpenWork(session.journal, { + childWork: () => this.deps.childWork(sessionId), + hasOpenDispatch: () => this.deps.hasOpenDispatch(sessionId), + providerHoldsDispatch: () => this.deps.providerHoldsDispatch(sessionId) + }) ) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts index 24451889ec60..ed6c35a43a95 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -156,7 +156,7 @@ describe('a create that fails before any process spawns', () => { 'a Claude account added in WSL', 'structured Claude is not offered under the active managed Claude account', 'managedAccountUnsupported', - 'While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings, then send your message again.' + 'While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings.' ], [ 'a floating chat whose launch folder is gone', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts index ad05fce28900..d90613cd4e29 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts @@ -20,6 +20,7 @@ import { } from './structured-agent-session-failed-create-refusal' import { withObservedProviderExit } from './structured-agent-session-failure-text' import { withMissingProviderExecutable } from '../../provider-process/provider-executable-missing' +import { providerDiagnostic, withProviderDiagnostic } from '../../../shared/agent-session-failure' import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' const CLAUDE_CREATE = { @@ -36,6 +37,22 @@ function replay(outcome: Parameters[0][ } describe('a ledger replay names the details its first answer did', () => { + it('keeps ACP startup detail and direct-create guidance in the first reply and replay', () => { + const error = withProviderDiagnostic( + new AgentSessionAcquisitionRefusal('signed out', 'notSignedIn'), + providerDiagnostic('Grok needs its API key.', 'person') + ) + const wording = { + record: { ...agentSessionRecordFixture(), provider: 'grok' as const }, + newSession: true + } + const first = failedAcquisitionRefusal(error, wording) + const replayed = replay(failedAcquisitionSettlement(error, wording).outcome) + expect(first?.refusal.message).toBe( + 'Sign in to Grok with `grok login` on the computer running this chat. Grok needs its API key.' + ) + expect(replayed).toMatchObject({ refusal: { message: first?.refusal.message } }) + }) it.each([ [new AgentSessionAcquisitionRefusal('not signed in', 'notSignedIn'), 'notSignedIn'], [ @@ -77,7 +94,9 @@ describe('a ledger replay names the details its first answer did', () => { refusal: { details: first?.refusal.details, message: first?.refusal.message } }) expect(first?.refusal.message).toContain( - account === 'system' ? 'Run `claude`' : 'Sign in again in Claude Accounts settings.' + account === 'system' + ? 'Run `claude auth login`' + : 'Sign in again in Claude Accounts settings.' ) } ) @@ -110,7 +129,7 @@ describe('a ledger replay names the details its first answer did', () => { 'Claude is not signed in for the selected account. Sign in with the Claude CLI for this CLAUDE_CONFIG_DIR, then retry.', 'notSignedIn' ), - "Claude isn't signed in. Run `claude` and sign in with /login, or choose an account in Claude Accounts settings." + "Claude isn't signed in. Run `claude auth login`, or choose an account in Claude Accounts settings." ], [ AgentSessionAcquisitionRefusal.historyTooLarge( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts new file mode 100644 index 000000000000..30eec72920a6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts @@ -0,0 +1,248 @@ +// A child that reported it is not signed in is replaced before the next send, so a sign-in made +// since reaches a new child; an agent that read its login once at start would fail again. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { + acceptedDispatch, + createRestTestRig, + foundRestTestChat, + REST_TEST_CALLER as CALLER, + REST_TEST_SESSION as SESSION, + restTestSend, + type RestTestRig +} from './structured-agent-session-rest-test-rig' +import { + retireSignedOutStructuredAgentSessionChild, + structuredAgentSessionChildReportedSignedOut +} from './structured-agent-session-signed-out-child' +import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' +import type { + AgentJournalItemBody, + AgentJournalTurnLifecycle +} from '../../../shared/agent-session-journal-types' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import type { JournalItemLinkageVisitor } from '../agent-session-journal/journal-store-contracts' +import type { StructuredAgentSessionChildWorkReads } from './structured-agent-session-idle-sweep' + +describe('a send after the agent said it is not signed in', () => { + let rig: RestTestRig + + beforeEach(async () => { + rig = await createRestTestRig() + }) + + afterEach(async () => { + await rig.dispose() + }) + + const fence = () => rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 1 + + it('goes to a new agent, and the new agent is kept for the sends after it', async () => { + await foundRestTestChat(rig) + rig.adapter.dispatch.mockImplementationOnce(async () => ({ + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('notSignedIn'), { + surface: 'rejection' + }) + })) + expect((await rig.host.send(CALLER, restTestSend('signed out', fence()))).ok).toBe(true) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) + expect(rig.adapter.acquire).toHaveBeenCalledTimes(1) + rig.adapter.closeSession.mockClear() + + expect((await rig.host.send(CALLER, restTestSend('signed in now', fence()))).ok).toBe(true) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(3)) + expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) + expect(rig.adapter.acquire).toHaveBeenCalledTimes(2) + + // The earlier child's report does not follow the new one. + expect((await rig.host.send(CALLER, restTestSend('and again', fence()))).ok).toBe(true) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(4)) + expect(rig.adapter.acquire).toHaveBeenCalledTimes(2) + }) + + it('keeps the agent after any other rejection', async () => { + await foundRestTestChat(rig) + rig.adapter.dispatch.mockImplementationOnce(async () => ({ + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { + surface: 'rejection' + }) + })) + expect((await rig.host.send(CALLER, restTestSend('refused', fence()))).ok).toBe(true) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) + rig.adapter.dispatch.mockImplementation(async () => acceptedDispatch()) + + expect((await rig.host.send(CALLER, restTestSend('next', fence()))).ok).toBe(true) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(3)) + expect(rig.adapter.acquire).toHaveBeenCalledTimes(1) + }) +}) + +type Row = { itemId: string; fence: number; agentId?: string; body: AgentJournalItemBody } + +const statusRow = ( + fence: number, + kind: 'notSignedIn' | 'providerExited', + agentId?: string +): Row => ({ + itemId: `status-${fence}-${kind}-${agentId ?? 'root'}`, + fence, + ...(agentId ? { agentId } : {}), + body: { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'row' }) + } +}) +const rejected = (fence: number) => ({ + fence, + dispatchState: 'rejected' as const, + rejection: agentSessionFailureFact('notSignedIn') +}) +const childAt = ( + overrides: Partial = {} +): StructuredAgentSessionProviderChild => ({ + generation: 'generation-2', + fence: 2, + phase: 'ready', + ...overrides +}) +const conversation = ( + input: { + child?: StructuredAgentSessionProviderChild | null + items?: Row[] + submissions?: ReturnType[] + activeTurnId?: string + newestTurnEndedAt?: number + } = {} +) => { + const items = input.items ?? [] + return { + child: input.child === undefined ? childAt() : input.child, + journal: { + submissions: () => input.submissions ?? [], + visitItems: (visit: (itemId: string, sequence: number, body: AgentJournalItemBody) => void) => + items.forEach((item, index) => visit(item.itemId, index, item.body)), + visitItemsWithLinkage: (visit: JournalItemLinkageVisitor) => + items.forEach((item, index) => + visit(item.itemId, index, item.body, item.agentId ? { agentId: item.agentId } : {}) + ), + itemFence: (itemId: string) => items.find((item) => item.itemId === itemId)?.fence, + activeTurnId: () => input.activeTurnId ?? null, + newestTurn: (): AgentJournalTurnLifecycle | null => + input.newestTurnEndedAt === undefined + ? null + : { turnId: 'turn-0', state: 'completed', completedAt: input.newestTurnEndedAt } + } + } +} + +describe('structuredAgentSessionChildReportedSignedOut', () => { + const reported = (input: Parameters[0]) => + structuredAgentSessionChildReportedSignedOut(conversation(input)) + + it('reads a not-signed-in row the running child wrote', () => { + expect(reported({ items: [statusRow(2, 'notSignedIn')] })).toBe(true) + }) + + it('ignores one an earlier child or a subagent wrote, and any other failure', () => { + expect(reported({ items: [statusRow(1, 'notSignedIn')] })).toBe(false) + expect(reported({ items: [statusRow(2, 'notSignedIn', 'subagent-1')] })).toBe(false) + expect(reported({ items: [statusRow(2, 'providerExited')] })).toBe(false) + }) + + it('reads a send rejected as not signed in at this child, not at an earlier one', () => { + expect(reported({ submissions: [rejected(2)] })).toBe(true) + expect(reported({ submissions: [rejected(1)] })).toBe(false) + }) + + it('leaves a child that is starting, closing, or absent', () => { + const items = [statusRow(2, 'notSignedIn')] + expect(reported({ items, child: childAt({ phase: 'starting' }) })).toBe(false) + expect(reported({ items, child: null })).toBe(false) + }) +}) + +describe('retireSignedOutStructuredAgentSessionChild', () => { + const liveChildWork: AgentChildWorkView = { + id: 'subagent-1', + kind: 'agent', + state: 'working', + membership: 'live', + firstObservedAt: 0, + observedAt: 0, + stoppable: false, + invocation: { invocationId: 'spawn-subagent-1', generation: 1 } + } + const idle: StructuredAgentSessionChildWorkReads = { + childWork: () => [], + hasOpenDispatch: () => false, + providerHoldsDispatch: () => false + } + const signedOut = { submissions: [rejected(2)] } + const retire = async ( + input: Parameters[0], + work: Partial = {}, + stopAgent = vi.fn(async () => undefined) + ) => { + const warn = vi.fn() + await retireSignedOutStructuredAgentSessionChild('session', conversation(input), { + work: { ...idle, ...work }, + stopAgent, + logger: { warn, error: vi.fn() } + }) + return { stopAgent, warn } + } + + it('stops a signed-out child that owes nothing', async () => { + expect((await retire(signedOut)).stopAgent).toHaveBeenCalledWith('session') + }) + + it('keeps it while it owes work the idle sweep also protects', async () => { + // A pending prompt is the shared check's too; the idle sweep's tests cover that case. + for (const kept of [ + await retire({ ...signedOut, activeTurnId: 'turn-1' }), + await retire(signedOut, { childWork: () => [liveChildWork] }), + await retire(signedOut, { hasOpenDispatch: () => true }), + await retire(signedOut, { providerHoldsDispatch: () => true }) + ]) { + expect(kept.stopAgent).not.toHaveBeenCalled() + } + }) + + it('keeps it while a result that settled after the last turn may still wake it', async () => { + const settled: AgentChildWorkView = { + ...liveChildWork, + state: 'done', + membership: 'settled', + outcome: 'succeeded', + settledAt: 200 + } + const after = await retire( + { ...signedOut, newestTurnEndedAt: 100 }, + { childWork: () => [settled] } + ) + expect(after.stopAgent).not.toHaveBeenCalled() + const before = await retire( + { ...signedOut, newestTurnEndedAt: 300 }, + { childWork: () => [settled] } + ) + expect(before.stopAgent).toHaveBeenCalledWith('session') + }) + + it('logs a stop that fails and lets the send go on', async () => { + const { warn } = await retire( + signedOut, + {}, + vi.fn(async () => { + throw new Error('exit not proven') + }) + ) + expect(warn).toHaveBeenCalledWith( + 'replacing a signed-out agent failed', + expect.objectContaining({ sessionId: 'session' }) + ) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.ts new file mode 100644 index 000000000000..92f64a8f3a46 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.ts @@ -0,0 +1,100 @@ +// A child that reported its agent is not signed in is replaced before the next send. Some agents +// read their saved login only when they start, so a sign-in made since reaches a new child only. + +import { readWholeAgentSessionFailureFact } from '../../../shared/agent-session-failure' +import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { + structuredAgentSessionChildHasOpenWork, + type StructuredAgentSessionChildWorkReads +} from './structured-agent-session-idle-sweep' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' + +/** What the check reads of a conversation; every read skips building a snapshot. */ +type SignedOutReading = Pick & { + journal: Pick< + AgentSessionJournal, + 'visitItems' | 'visitItemsWithLinkage' | 'itemFence' | 'activeTurnId' | 'newestTurn' + > & { + submissions(): readonly Pick[] + } +} + +const signedOut = (failure: unknown): boolean => + readWholeAgentSessionFailureFact(failure)?.kind === 'notSignedIn' + +/** The running child itself said it is not signed in: a send it rejected, or a row it wrote. Each + * child holds its own fence, so matching it scopes both to this child; a new one has nothing. */ +export function structuredAgentSessionChildReportedSignedOut(session: SignedOutReading): boolean { + const { child, journal } = session + if (!child || child.phase === 'starting' || child.close) { + return false + } + if ( + journal + .submissions() + .some( + (submission) => + submission.dispatchState === 'rejected' && + submission.fence === child.fence && + signedOut(submission.rejection) + ) + ) { + return true + } + // The session's own agent only: a subagent's sign-in failure says nothing about its parent. + let found = false + journal.visitItemsWithLinkage((itemId, _sequence, body, attribution) => { + found ||= + body.kind === 'status' && + isRootAgentJournalItem(attribution) && + signedOut(body.failure) && + journal.itemFence(itemId) === child.fence + }) + return found +} + +/** Child work that settled after the lead's newest turn ended: the lead may still owe the turn + * that reads its result, which journals nothing until it opens. The idle sweep's window covers + * this; a restart before the next send has none, so it waits for that turn. */ +function owesWakeUp( + journal: Pick, + childWork: readonly AgentChildWorkView[] | undefined +): boolean { + const ended = journal.newestTurn()?.completedAt + return (childWork ?? []).some( + (work) => work.settledAt !== undefined && (ended === undefined || work.settledAt > ended) + ) +} + +/** For a caller inside the session's serialize, before it hands the next send to the child. Work + * the child still owes keeps it, as it keeps an idle one from the sweep. A stop that fails leaves + * the close begun, which the start joins. */ +export async function retireSignedOutStructuredAgentSessionChild( + sessionId: string, + session: SignedOutReading | undefined, + deps: { + work: StructuredAgentSessionChildWorkReads + stopAgent: (sessionId: string) => Promise + logger: StructuredAgentSessionLogger + } +): Promise { + if ( + !session || + !structuredAgentSessionChildReportedSignedOut(session) || + structuredAgentSessionChildHasOpenWork(session.journal, deps.work) || + owesWakeUp(session.journal, deps.work.childWork()) + ) { + return + } + await deps.stopAgent(sessionId).catch((error: unknown) => + deps.logger.warn('replacing a signed-out agent failed', { + scope: 'signed-out-child', + sessionId, + error + }) + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index dbc52b9fc35f..42b9b5e4b111 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -94,7 +94,7 @@ describe('a provider that ends before it finished starting', () => { identity: { provider: 'orca', clientMessageId: `start-failure:${GENERATION}` }, body: { kind: 'status', - text: "Claude isn't signed in. Run `claude` and sign in with /login, or choose an account in Claude Accounts settings.", + text: "Claude isn't signed in. Run `claude auth login`, or choose an account in Claude Accounts settings.", tone: 'error', failure: { kind: 'notSignedIn' } } diff --git a/src/main/pi/rpc-capture-replay.test.ts b/src/main/pi/rpc-capture-replay.test.ts index a216583e8218..1e19cb4da083 100644 --- a/src/main/pi/rpc-capture-replay.test.ts +++ b/src/main/pi/rpc-capture-replay.test.ts @@ -4,6 +4,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' import { JsonlRpcTimelineLane } from '../jsonl-rpc/timeline-lane' import { PiRpcTurns } from './rpc-turns' +import { piRpcFailureFact } from './rpc-prompt-delivery' +import { agentSessionFailureSentence } from '../../shared/agent-session-failure-words' import { piRpcDialogPresentation } from './rpc-extension-dialogs' import { closeProviderTimelineRigs, @@ -22,6 +24,23 @@ function capture(name: string) { }) } const disposals: (() => void)[] = [] + +it('separates resend advice from the captured Pi 1.0.4 missing-key detail', () => { + const reply = capture('signed-out').find( + ({ dir, frame }) => dir === 'out' && frame.command === 'prompt' && frame.success === false + )?.frame + if (typeof reply?.error !== 'string') { + throw new Error('The signed-out capture must contain a prompt error') + } + expect(reply.error.endsWith('docs/models.md')).toBe(true) + const fact = piRpcFailureFact(reply.error) + expect(fact.kind).toBe('notSignedIn') + expect(fact.detail?.text).toBe(reply.error) + expect(agentSessionFailureSentence(fact, 'rejection', { agentName: 'Pi' })).toContain( + `${reply.error}. Then send your message again.` + ) +}) + afterEach(async () => { disposals.splice(0).forEach((dispose) => dispose()) await closeProviderTimelineRigs() diff --git a/src/main/pi/rpc-prompt-delivery.test.ts b/src/main/pi/rpc-prompt-delivery.test.ts index 471f9ecf0bc0..e59ebe4c9961 100644 --- a/src/main/pi/rpc-prompt-delivery.test.ts +++ b/src/main/pi/rpc-prompt-delivery.test.ts @@ -123,3 +123,31 @@ describe('Pi pending prompt delivery bounds and ownership', () => { expect(vi.getTimerCount()).toBe(0) }) }) + +it('retains Pi provider detail with sign-in guidance after bounded authentication retries', async () => { + vi.useFakeTimers() + const h = rig() + await h.delivery.submit('signed-out', 1, { type: 'prompt', message: 'hello' }) + for (let attempt = 0; attempt < 9; attempt += 1) { + h.delivery.reply({ + type: 'response', + command: 'prompt', + success: false, + error: 'No API key found for anthropic' + }) + await vi.advanceTimersByTimeAsync(250) + } + expect(h.settled).toHaveBeenCalledExactlyOnceWith( + 'signed-out', + expect.objectContaining({ + state: 'rejected', + rejection: { + kind: 'notSignedIn', + detail: { text: 'No API key found for anthropic', audience: 'person' } + } + }) + ) + expect(h.settled.mock.calls[0]?.[1].reason).toContain('No API key found for anthropic') + expect(h.settled.mock.calls[0]?.[1].reason).toContain('`pi`') + expect(h.settled.mock.calls[0]?.[1].reason).toContain('`/login`') +}) diff --git a/src/main/pi/rpc-prompt-delivery.ts b/src/main/pi/rpc-prompt-delivery.ts index eef0f51c0bfd..d58905092266 100644 --- a/src/main/pi/rpc-prompt-delivery.ts +++ b/src/main/pi/rpc-prompt-delivery.ts @@ -24,6 +24,13 @@ type DeliveryDeps = { } /** Idless prompt acknowledgements are FIFO and may wait indefinitely on extension dialogs. */ +export function piRpcFailureFact(error: string) { + return agentSessionFailureFact( + error.startsWith('No API key found for ') ? 'notSignedIn' : 'providerRejected', + { detail: providerDiagnostic(error, 'person') } + ) +} + export class PiRpcPromptDelivery { private readonly acknowledgements: Submission[] = [] private readonly waiting: Submission[] = [] @@ -91,11 +98,8 @@ export class PiRpcPromptDelivery { throw new Error('Pi prompt reply has no request') } if (!reply.success) { - if ( - !submission.accepted && - reply.error?.startsWith('No API key found for ') && - submission.retries++ < 8 - ) { + const fact = piRpcFailureFact(reply.error ?? 'Pi rejected the prompt') + if (!submission.accepted && fact.kind === 'notSignedIn' && submission.retries++ < 8) { this.removeWaiting(submission) const timer = setTimeout(() => { this.retries.delete(timer) @@ -120,10 +124,6 @@ export class PiRpcPromptDelivery { return } this.remove(submission) - const fact = agentSessionFailureFact( - reply.error?.startsWith('No API key found for ') ? 'notSignedIn' : 'providerRejected', - { detail: providerDiagnostic(reply.error ?? 'Pi rejected the prompt', 'person') } - ) this.deps.settled(submission.id, { state: 'rejected', ...agentSessionFailureWords(fact, { agentName: 'Pi', surface: 'rejection' }) diff --git a/src/main/pi/rpc-turn-races.test.ts b/src/main/pi/rpc-turn-races.test.ts index 9a17d1d6e17c..3f88013f60a8 100644 --- a/src/main/pi/rpc-turn-races.test.ts +++ b/src/main/pi/rpc-turn-races.test.ts @@ -65,6 +65,35 @@ async function setup(request = vi.fn(async (_command: string): Promise } describe('Pi turn settlement races', () => { + it.each([ + ['No API key found for anthropic', 'notSignedIn'], + ['No API key found elsewhere', 'providerRejected'], + ['Rate limit exceeded', 'providerRejected'], + ['Network connection failed', 'providerRejected'] + ] as const)('keeps accepted-turn failure classification: %s', async (detail, kind) => { + const h = await setup() + await h.start() + h.turns.receive({ + type: 'message_end', + message: { + role: 'assistant', + content: [], + stopReason: 'error', + errorMessage: detail + } + }) + h.turns.receive({ type: 'agent_settled' }) + await h.flush() + const errors = (await h.rig.rows()).flatMap(({ body }) => + body.kind === 'status' && body.failure ? [body] : [] + ) + expect(errors).toHaveLength(1) + expect(errors[0]?.failure).toMatchObject({ kind, detail: { text: detail, audience: 'person' } }) + expect(errors[0]?.text).toContain(detail) + if (kind === 'notSignedIn') { + expect(errors[0]?.text).toContain('`/login`') + } + }) it('chooses steering after asynchronous dispatch admission finishes', async () => { const h = await setup() await h.turns.submit('first', 1, { type: 'prompt', message: 'first' }) diff --git a/src/main/pi/rpc-turns.ts b/src/main/pi/rpc-turns.ts index 3bb0ff959d2d..34cd3e7bd230 100644 --- a/src/main/pi/rpc-turns.ts +++ b/src/main/pi/rpc-turns.ts @@ -1,4 +1,3 @@ -import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' import type { AgentSessionDispatchOutcome, @@ -9,7 +8,7 @@ import type { JsonlRpcRecord } from '../jsonl-rpc/peer' import type { JsonlRpcTimelineLane } from '../jsonl-rpc/timeline-lane' import { PiRpcContextUsage } from './rpc-context-usage' import { PiRpcMessages } from './rpc-messages' -import { PiRpcPromptDelivery } from './rpc-prompt-delivery' +import { PiRpcPromptDelivery, piRpcFailureFact } from './rpc-prompt-delivery' import { PiRpcIdleCheck } from './rpc-idle-check' import { piRpcRetryActivity } from './rpc-retry-activity' import { piRpcMessageSchema } from './rpc-protocol' @@ -263,9 +262,7 @@ export class PiRpcTurns { : 'success' const events: ProviderTimelineEvent[] = [] if (outcome === 'failure' && this.failure) { - const fact = agentSessionFailureFact('providerRejected', { - detail: providerDiagnostic(this.failure, 'person') - }) + const fact = piRpcFailureFact(this.failure) events.push({ type: 'item.close', item: `error:${this.active}`, diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index b3acbb0548d7..bc44b74a8a16 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -429,7 +429,7 @@ describe('a structured Claude session over agentSession.*', () => { // The adapter typed the refusal, so the row names the situation rather than quoting Orca. expect(guidance?.body).toMatchObject({ kind: 'status', - text: "Claude isn't signed in. Run `claude` and sign in with /login, or choose an account in Claude Accounts settings.", + text: "Claude isn't signed in. Run `claude auth login`, or choose an account in Claude Accounts settings.", failure: { kind: 'notSignedIn', account: 'system' } }) expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index deed5bc01341..90949ec72184 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -127,6 +127,7 @@ function nativeChatVisualsFor(deps: StructuredAgentSessionRuntimeDeps): { function createCodexAdapter(context: StructuredAgentAdapterContext): StructuredAgentRuntimeAdapter { const { deps, store, followUps, host } = context return new CodexStructuredSessionAdapter({ + resolveAccountKind: deps.resolveCodexAccountKind, resolveLaunch: createCodexStructuredLaunchResolver({ store, resolveWorkspacePath: deps.resolveWorkspacePath, diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 599a21b010a0..163331d10ea0 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -60,6 +60,7 @@ import { isStructuredAgentSessionThinking } from '../../../../shared/structured- import type { NativeChatSettledTurns } from '../../../../shared/native-chat-turn-status' import type { NativeChatDiffReveal, NativeChatDiffTarget } from './native-chat-turn-diffs' import { useNativeChatTurnDiffs } from './use-native-chat-turn-diffs' +import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' /** The turn is blocked on the reader. `shown`: the pane draws the prompt itself, as a card; * `unshown`: it cannot (the prompt is only in the agent's terminal). */ @@ -330,6 +331,7 @@ export function NativeChatMessageList({ const rowContext = useMemo( () => ({ + agentName: structuredAgentLabel(session.agent), expandSignal, revealedDiff, taskListPredecessors, @@ -344,6 +346,7 @@ export function NativeChatMessageList({ onRevealDiff: revealDiff }), [ + session.agent, allowFileUriLinks, expandSignal, expandedTurnIds, diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index 5bfdc76d76c1..c627a3ab4dc3 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -83,6 +83,7 @@ function UserMessageMeta({ type MessageRowProps = { message: NativeChatMessage + agentName?: string previousTodoWrite?: NativeChatToolCallBlock previousUpdatePlan?: NativeChatToolCallBlock revealedDiff?: NativeChatDiffReveal @@ -117,6 +118,7 @@ type MessageRowProps = { * keep their block identity, so only the changed row re-renders. */ export const MessageRow = memo(function MessageRow({ message, + agentName, previousTodoWrite, previousUpdatePlan, revealedDiff, @@ -191,6 +193,7 @@ export const MessageRow = memo(function MessageRow({
diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx index db62bc280a7e..d412b58f18a3 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx @@ -1,16 +1,22 @@ // @vitest-environment happy-dom import '@testing-library/jest-dom/vitest' -import { cleanup, render, screen } from '@testing-library/react' +import { act, cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection' import type { AgentJournalStatusItem } from '../../../../shared/agent-session-journal-types' import { MessageRow } from './NativeChatMessageRow' +import { NativeChatNoticeRow } from './NativeChatNoticeRow' +import { i18n } from '@/i18n/i18n' +import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' import { NativeChatOrcaStopContext, type NativeChatOrcaStopView } from './native-chat-orca-stop-context' -afterEach(cleanup) +afterEach(async () => { + cleanup() + await i18n.changeLanguage('en') +}) function orcaStopView( hostLabel: string | null, @@ -22,7 +28,8 @@ function orcaStopView( function renderStatus( body: AgentJournalStatusItem, hostLabel: string | null = null, - continueAvailable = false + continueAvailable = false, + agentName?: string ) { const [message] = projectStructuredItemsToNativeChat([ { @@ -37,7 +44,12 @@ function renderStatus( const view = orcaStopView(hostLabel, continueAvailable) return render( - + ) } @@ -122,6 +134,136 @@ describe('the row an Orca stop leaves', () => { }) describe('notice rows', () => { + it('keeps unmatched host auth wording and its next step', async () => { + await i18n.changeLanguage('fr') + const text = 'Use the host-specific sign-in page, then run /compact again.' + render( + + ) + expect(screen.getByText(text)).toBeInTheDocument() + }) + + it.each(['providerStartFailed', 'notSignedIn'] as const)( + 'keeps the host /compact retry instruction for %s', + (kind) => { + const words = agentSessionFailureWords( + { kind }, + { agentName: 'Grok', command: 'compact', surface: 'row' } + ) + renderStatus({ kind: 'status', tone: 'error', ...words }, null, false, 'Grok') + expect(screen.getByText(words.text)).toBeInTheDocument() + expect(screen.getByText(/Run \/compact again\./)).toBeInTheDocument() + expect(screen.queryByText(/send your message again/i)).toBeNull() + } + ) + it('updates a mounted auth row when the reader changes language', async () => { + renderStatus( + { + kind: 'status', + tone: 'error', + ...agentSessionFailureWords({ kind: 'notSignedIn' }, { agentName: 'Grok', surface: 'row' }) + }, + null, + false, + 'Grok' + ) + expect( + screen.getByText('Sign in to Grok with `grok login` on the computer running this chat.') + ).toBeInTheDocument() + await act(() => i18n.changeLanguage('fr')) + expect( + screen.getByText( + 'Connectez-vous à Grok avec `grok login` sur l’ordinateur qui exécute ce chat.' + ) + ).toBeInTheDocument() + }) + it.each([ + ['fr', 'Grok', 'Connectez-vous à Grok avec `grok login` sur l’ordinateur qui exécute ce chat.'], + [ + 'fr', + 'Pi', + 'Connectez-vous à Pi en exécutant `pi` puis en utilisant `/login` sur l’ordinateur qui exécute ce chat.' + ], + ['fr', 'OMP', 'Connectez-vous à OMP.'], + [ + 'ja', + 'Grok', + 'このチャットを実行しているコンピューターで `grok login` を使って Grok にサインインしてください。' + ], + [ + 'ja', + 'Pi', + 'このチャットを実行しているコンピューターで `pi` を起動し、`/login` を使って Pi にサインインしてください。' + ], + ['ja', 'OMP', 'OMP にサインインしてください。'] + ])( + 'rewords known %s auth facts for %s and keeps literal diagnostics', + async (locale, agentName, guidance) => { + await i18n.changeLanguage(locale) + const detail = 'Provider diagnostic: {{agent}} must remain literal.' + const words = agentSessionFailureWords( + { kind: 'notSignedIn', detail: { text: detail, audience: 'person' } }, + { agentName, surface: 'row' } + ) + renderStatus( + { + kind: 'status', + ...words, + tone: 'error' + }, + null, + false, + agentName + ) + expect( + screen.getByText(`${guidance}${locale === 'ja' ? '' : ' '}${detail}`) + ).toBeInTheDocument() + expect(screen.queryByText(words.text)).toBeNull() + } + ) + + it('rewords managed-account facts without leaking system login instructions', async () => { + await i18n.changeLanguage('fr') + renderStatus( + { + kind: 'status', + tone: 'error', + ...agentSessionFailureWords( + { kind: 'notSignedIn', account: 'managed' }, + { agentName: 'Claude', surface: 'row' } + ) + }, + null, + false, + 'Claude' + ) + expect( + screen.getByText( + 'Ce compte Claude n’est pas connecté. Reconnectez-vous dans les paramètres des Comptes Claude.' + ) + ).toBeInTheDocument() + expect(screen.queryByText(/claude auth login/)).toBeNull() + }) + + it.each([{ kind: 'futureFailure' }, { kind: 'notSignedIn', account: 'futureAccount' }])( + 'keeps the host fallback for facts this client cannot fully understand (%j)', + async (failure) => { + await i18n.changeLanguage('ja') + const newerRow = { + kind: 'status', + tone: 'error', + text: 'Future host guidance', + failure + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: simulates an older client receiving a newer host's unknown failure fact. + const row = newerRow as unknown as AgentJournalStatusItem + renderStatus(row, null, false, 'Grok') + expect(screen.getByText('Future host guidance')).toBeInTheDocument() + } + ) it('renders compaction as a centered separator', () => { renderStatus({ kind: 'status', text: 'Context compacted', presentation: 'compaction' }) expect(screen.getByRole('separator', { name: 'Context compacted' })).toHaveClass( diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx index 1bd21e9ec013..2aa52449dd30 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx @@ -1,4 +1,5 @@ import { AlertCircle, AlertTriangle, Info } from 'lucide-react' +import { useTranslation } from 'react-i18next' import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' import { NativeChatMarkdown } from './NativeChatMarkdown' import { NativeChatCodeBlock } from './NativeChatCodeBlock' @@ -15,6 +16,9 @@ import { useNativeChatOrcaStopView } from './native-chat-orca-stop-context' import { nativeChatOrcaStopRowText } from './native-chat-orca-stop-words' import { AGENT_SESSION_ORCA_STOP_PRESENTATION } from '../../../../shared/agent-session-orca-stop' import { ProviderFrameRow } from './NativeChatTranscriptChrome' +import { readWholeAgentSessionFailureFact } from '../../../../shared/agent-session-failure' +import { agentSessionFailureSentence } from '../../../../shared/agent-session-failure-words' +import { sayAgentSessionFailureTranslated } from './agent-session-failure-words-text' const HOST_STATUS_WORDS: Record string> = { 'history-repaired': () => @@ -31,13 +35,16 @@ const HOST_STATUS_WORDS: Record string export function NativeChatNoticeRow({ block, + agentName, onLinkClick, allowFileUriLinks = false }: { block: NativeChatTextBlock + agentName?: string onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean }): React.JSX.Element { + useTranslation() const orcaStopView = useNativeChatOrcaStopView() if (block.presentation === 'compaction') { const label = translate('components.native-chat.notices.compaction', 'Context compacted') @@ -96,9 +103,24 @@ export function NativeChatNoticeRow({ const { orcaStop } = block const { hostLabel, continueAvailable } = orcaStopView const named = orcaStop !== undefined && hostLabel !== null + const failure = readWholeAgentSessionFailureFact(block.failure) + // Only reword auth text fully described by its fact; host text may also carry command advice. + const authSurface = + failure?.kind === 'notSignedIn' + ? (['row', 'rejection'] as const).find( + (surface) => block.text === agentSessionFailureSentence(failure, surface, { agentName }) + ) + : undefined const text = named ? nativeChatOrcaStopRowText(orcaStop.cause, hostLabel, { continueAvailable }) - : block.text + : failure && authSurface + ? agentSessionFailureSentence( + failure, + authSurface, + { agentName }, + sayAgentSessionFailureTranslated + ) + : block.text const tone = named || block.presentation === AGENT_SESSION_ORCA_STOP_PRESENTATION ? 'notice' : block.tone const Icon = diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index 920e76d4c50c..0b83b794b055 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -28,13 +28,23 @@ import { type QueuedMessageCard } from './structured-agent-session-queued-cards' import { queuedCardSenderLine } from './native-chat-agent-message-sender-label' +import { agentSessionFailureStatedByStartRow } from './structured-agent-session-delivery-notices' +import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' +import { agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice' /** The visible caption under the text; the default waiting hold needs none. */ -export function queuedMessageCardCaption(card: QueuedMessageCard): string | null { +export function queuedMessageCardCaption( + card: QueuedMessageCard, + agentName?: string, + statedFailures: readonly AgentSessionFailureFact[] = [] +): string | null { switch (card.hold) { case 'returned': { // Read exactly as a rejected submission: the typed fact decides, the reason is the fallback. const reason = card.returnedReason ?? null + if (agentSessionFailureStatedByStartRow(card.returnedRejection, statedFailures)) { + return agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send')) + } // A consumed draft whose submission a Stop withdrew is not a failure of the // message — say what happened rather than "not sent". if ( @@ -50,7 +60,7 @@ export function queuedMessageCardCaption(card: QueuedMessageCard): string | null structuredAgentSessionAttemptFailureParts( { kind: 'rejected', reason }, // The card's own Send is the retry, so the words leave out sending again. - { retryControl: true }, + { agentName, retryControl: true }, readWholeAgentSessionFailureFact(card.returnedRejection) ) ) @@ -116,6 +126,8 @@ export function queuedMessageCardSendNow(card: QueuedMessageCard): { export function NativeChatQueuedMessageCard({ card, + agentName, + statedFailures, showsSteerShortcut, steerHeld = false, onSteer, @@ -124,6 +136,8 @@ export function NativeChatQueuedMessageCard({ onTurnOffQueueing }: { card: QueuedMessageCard + agentName?: string + statedFailures?: readonly AgentSessionFailureFact[] /** Only the newest card answers Cmd/Ctrl+Enter; only it may show the chord. */ showsSteerShortcut: boolean /** The chat reads Stopping: the card waits for the stop (`NativeChatQueuedMessageList`). */ @@ -134,7 +148,7 @@ export function NativeChatQueuedMessageCard({ /** Absent when the host does not queue sends, so there is nothing to turn off. */ onTurnOffQueueing?: () => void }): React.JSX.Element { - const caption = queuedMessageCardCaption(card) + const caption = queuedMessageCardCaption(card, agentName, statedFailures) const returned = card.state === 'returned' const sendNow = queuedMessageCardSendNow(card) const isMac = isMacPlatform() diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index b795eeb625f3..99d1e42f007e 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -11,7 +11,6 @@ const mocks = vi.hoisted(() => ({ updateSettings: vi.fn() })) -vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) vi.mock('../../store', () => { const state = { updateSettings: mocks.updateSettings } @@ -31,12 +30,21 @@ import { import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' import type { AgentSessionQueuePause } from '../../../../shared/agent-session-queued-message-wire' +import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' -function renderList(owner: StructuredAgentSessionQueuedMessagesController) { +function renderList( + owner: StructuredAgentSessionQueuedMessagesController, + agentName?: string, + statedFailures?: readonly AgentSessionFailureFact[] +) { // The app root mounts the provider; tests supply the same context. return render( - + ) } @@ -126,6 +134,60 @@ beforeEach(() => { afterEach(cleanup) describe('NativeChatQueuedMessageList', () => { + it.each([ + [ + 'Claude', + "Claude isn't signed in. Run `claude auth login`, or choose an account in Claude Accounts settings." + ], + ['Codex', "Codex isn't signed in. Run `codex login`."], + ['Grok', 'Sign in to Grok with `grok login` on the computer running this chat.'], + [ + 'OpenCode', + 'Sign in to OpenCode with `opencode auth login` on the computer running this chat.' + ], + ['Pi', 'Sign in to Pi by running `pi` and using `/login` on the computer running this chat.'], + ['OMP', 'Sign in to OMP.'] + ])( + 'threads %s identity to returned cards and keeps their Send control', + (agentName, sentence) => { + renderList( + controller([ + card({ + messageId: 'auth', + state: 'returned', + hold: 'returned', + returnedReason: 'Host English', + returnedRejection: { kind: 'notSignedIn' } + }) + ]), + agentName + ) + expect(screen.getByText(sentence)).toBeTruthy() + expect(screen.getByRole('button', { name: 'Send' })).toBeTruthy() + expect(screen.queryByText(/send your message again/)).toBeNull() + } + ) + + it.each(['Claude', 'Codex'])( + 'keeps %s managed guidance and shows only delivery when the row explains auth', + (agentName) => { + const fact: AgentSessionFailureFact = { kind: 'notSignedIn', account: 'managed' } + const owner = controller([ + card({ messageId: 'auth', state: 'returned', hold: 'returned', returnedRejection: fact }) + ]) + const rendered = renderList(owner, agentName) + expect( + screen.getByText( + `This ${agentName} account isn't signed in. Sign in again in ${agentName} Accounts settings.` + ) + ).toBeTruthy() + rendered.unmount() + renderList(owner, agentName, [fact]) + expect(screen.getByText('Your message was not sent.')).toBeTruthy() + expect(screen.queryByText(/account isn't signed in/)).toBeNull() + expect(screen.getByRole('button', { name: 'Send' })).toBeTruthy() + } + ) it('renders only an empty live region when the host holds no drafts', () => { const { container } = renderList(controller([])) expect(screen.queryByRole('list')).toBeNull() diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index 964881a3b4b6..f1e5a0a3130d 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -5,6 +5,7 @@ import { useAppStore } from '../../store' import { translate } from '@/i18n/i18n' import { NativeChatQueuedMessageCard } from './NativeChatQueuedMessageCard' import type { StructuredAgentSessionQueuedMessagesController } from './use-structured-agent-session-queued-messages' +import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' /** * Host-held drafts stacked between the transcript and the composer — never in @@ -13,10 +14,14 @@ import type { StructuredAgentSessionQueuedMessagesController } from './use-struc */ export function NativeChatQueuedMessageList({ controller, + agentName, + statedFailures, steerHeld = false, focusComposer }: { controller: StructuredAgentSessionQueuedMessagesController + agentName?: string + statedFailures?: readonly AgentSessionFailureFact[] /** The chat reads Stopping: no card steers into the turn a Stop is ending. */ steerHeld?: boolean /** Where focus goes once Steer, Edit or Delete takes the focused card away. */ @@ -80,6 +85,8 @@ export function NativeChatQueuedMessageList({ refocusAfter(controller.steer(card.messageId))} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.availability-notice.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.availability-notice.test.tsx index 5c921f9c6257..85adcf18098f 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.availability-notice.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.availability-notice.test.tsx @@ -7,6 +7,16 @@ import type { AgentSessionFailureFact } from '../../../../shared/agent-session-f import { agentJournalItemKey } from '../../../../shared/agent-session-journal-item-key' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import { structuredAgentSessionStartFailureRowIdentity } from '../../../../shared/structured-agent-session-start-failure-row-key' +import { agentSessionVisibleFailureFacts } from '../../../../shared/agent-session-visible-failures' +import type * as AgentSessionVisibleFailures from '../../../../shared/agent-session-visible-failures' + +vi.mock('../../../../shared/agent-session-visible-failures', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + agentSessionVisibleFailureFacts: vi.fn(original.agentSessionVisibleFailureFacts) + } +}) const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() @@ -28,6 +38,7 @@ vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) import { NativeChatStructuredSession } from './NativeChatStructuredSession' +import { TooltipProvider } from '@/components/ui/tooltip' // The host's verdict is a notice that never holds Send: it can be wrong while a send would work. @@ -39,18 +50,22 @@ afterEach(() => { cleanup() localStorage.clear() resetStructuredSessionMocks() + mocks.queuedCards = [] + vi.mocked(agentSessionVisibleFailureFacts).mockClear() }) function pane(agent: 'claude' | 'codex' = 'codex'): React.JSX.Element { return ( - + + + ) } @@ -83,6 +98,44 @@ function turnRow(): AgentJournalRenderItem { } } +it('does no failure-fact scans while a non-Claude chat streams without rejected sends or returned cards', () => { + const { rerender } = render(pane()) + mocks.journalItems = [turnRow()] + rerender(pane()) + expect(agentSessionVisibleFailureFacts).not.toHaveBeenCalled() +}) + +it('shares one failure-fact scan between returned cards and rejected-send notices', () => { + const fact = { kind: 'notSignedIn' } as const + mocks.journalItems = [startFailureRow(fact)] + mocks.queuedCards = [ + { + messageId: 'returned', + position: 1, + text: 'Retry me', + state: 'returned', + hold: 'returned', + returnedRejection: fact + } + ] + mocks.submissions = [ + { + clientMessageId: 'send-1', + fence: 1, + payloadFingerprint: 'fp', + dispatchState: 'rejected', + providerItemId: null, + reason: CODEX_SIGNED_OUT, + rejection: fact, + submittedAt: 1, + resolvedAt: 2 + } + ] + render(pane()) + expect(agentSessionVisibleFailureFacts).toHaveBeenCalledTimes(1) + expect(screen.getAllByText('Your message was not sent.')).toHaveLength(2) +}) + it('says a signed-out Codex above the composer and still sends', () => { mocks.unavailable = { reason: 'notSignedIn', account: 'system' } render(pane()) @@ -102,6 +155,15 @@ it('says a signed-out Codex above the composer and still sends', () => { expect(mocks.send).toHaveBeenCalledWith('hello', []) }) +it('leaves an auth turn row as the explanation when the catalog also says signed out', () => { + mocks.unavailable = { reason: 'notSignedIn', account: 'system' } + const row = startFailureRow({ kind: 'notSignedIn', account: 'system' }) + mocks.journalItems = [turnRow(), { ...row, itemId: 'codex-unauthorized-error', sequence: 3 }] + render(pane()) + expect(screen.queryByText(CODEX_SIGNED_OUT)).toBeNull() + expect(mocks.composerProps?.structuredTransport).not.toHaveProperty('unavailable') +}) + it('stays dismissed for the same verdict, and shows again when it changes or comes back', () => { mocks.unavailable = { reason: 'notSignedIn', account: 'system' } const view = render(pane()) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx index d807275a38dc..6d90aa576ecd 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx @@ -30,6 +30,8 @@ vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard import { NativeChatStructuredSession } from './NativeChatStructuredSession' import { agentSessionRefusalFailure } from '../../../../shared/agent-session-write-failure' +import { structuredLaunchFailure } from '@/lib/structured-agent-session-launch-failure' +import { StructuredAgentSessionCreateRefusalError } from '@/lib/structured-agent-session-launch-errors' const NOT_SIGNED_IN = { kind: 'refused', @@ -39,7 +41,7 @@ const NOT_SIGNED_IN = { // Retry beside it is the resend, so the words keep only the step before it. const NOT_SIGNED_IN_TEXT = "Codex isn't signed in. Run `codex login`." -function sessionView(): React.JSX.Element { +function sessionView(agent: 'codex' | 'grok' = 'codex'): React.JSX.Element { return ( ) } @@ -132,6 +134,23 @@ describe('NativeChatStructuredSession launch lifecycle', () => { expect(screen.queryByText(/agent_session_/)).toBeNull() }) + it('renders the host-composed auth startup diagnostic beside Retry', () => { + const message = 'Sign in to Grok with `grok login`. Provider diagnostic: {{agent}} key expired.' + mocks.launchLifecycle = 'failed' + mocks.launchFailure = + structuredLaunchFailure( + new StructuredAgentSessionCreateRefusalError(message, 'agent_session_operation_invalid', { + code: 'agent_session_operation_invalid', + details: { reason: 'notSignedIn' } + }) + ) ?? null + render(sessionView('grok')) + expect(screen.getByText(`Chat could not be started. ${message}`)).toBeTruthy() + expect(screen.queryByText(/send your message again/i)).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(mocks.retryLaunch).toHaveBeenCalledWith('wt-1', 'session-1') + }) + it("keeps a step the Retry doesn't take, and drops one it does", () => { mocks.launchLifecycle = 'failed' mocks.launchFailure = { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 3149ae3c86a4..89eddaa8e0c6 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -49,9 +49,11 @@ import { useNativeChatStructuredComposerTransport } from './use-native-chat-stru import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner' import { structuredAgentSessionReadFailureNotice } from './structured-agent-session-read-failure-notice' import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent-session-delivery-notices' +import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection' import { useNativeChatHostOutage } from './use-native-chat-host-outage' import { useNativeChatHostOutageNotice } from './use-native-chat-host-outage-notice' import { useNativeChatAvailabilityNotice } from './use-native-chat-availability-notice' +import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts' import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' export function NativeChatStructuredSession( @@ -132,10 +134,20 @@ export function NativeChatStructuredSession( isWorking: controller.isWorking, composer: { clearError: () => reportComposerError(null) } }) + const needsFailureFacts = + submits.queuedMessages.cards.some((card) => card.state === 'returned') || + controller.submissions.some( + (submission) => submission.dispatchState === 'rejected' && !dispatchWasWithdrawn(submission) + ) + const startFailures = useStructuredAgentSessionStartFailureFacts( + controller.journalItems, + needsFailureFacts + ) const deliveryNotices = useStructuredAgentSessionDeliveryNotices({ pending: controller.pending, submissions: controller.submissions, journalItems: controller.journalItems, + startFailures, agentName: agentLabel }) // Nothing reads an unread history, so its pane stays blank beside the Retry line. @@ -280,6 +292,8 @@ export function NativeChatStructuredSession( {/* Host-held drafts, never transcript rows. Above the status area, so running shells and agents sit next to the composer. */} diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx index 8922e5691282..f9d8f529fc19 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx @@ -18,6 +18,7 @@ import { nativeChatRowOffersRewind } from './native-chat-rewind-eligibility' /** Everything a row needs that is the same for every row. Held as one memoized * object so a row's props change only when that row's own slot does. */ export type NativeChatTranscriptRowContext = { + agentName?: string expandSignal: boolean revealedDiff: NativeChatDiffReveal | null taskListPredecessors: ReadonlyMap @@ -101,6 +102,7 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({ ) : ( @@ -20,6 +24,26 @@ export const AVAILABILITY_PIECES: Record< AvailabilityCopyId, (values: AgentSessionFailureCopyValues) => string > = { + agentCommandNotSignedIn: (values) => + translate( + 'components.native-chat.failureWords.agentCommandNotSignedIn', + COPY.agentCommandNotSignedIn, + values + ), + interactiveAgentNotSignedIn: (values) => + translate( + 'components.native-chat.failureWords.interactiveAgentNotSignedIn', + COPY.interactiveAgentNotSignedIn, + values + ), + agentNotSignedIn: (values) => + translate( + 'components.native-chat.failureWords.agentNotSignedIn', + COPY.agentNotSignedIn, + values + ), + thenSendAgain: () => + translate('components.native-chat.failureWords.thenSendAgain', COPY.thenSendAgain), claudeSystemNotSignedIn: (values) => translate( 'components.native-chat.failureWords.claudeSystemNotSignedIn', diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts index a4da39b85fe1..9e4c7ddbcafb 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts @@ -48,6 +48,8 @@ const NOTICE_PIECES: readonly AgentSessionFailureCopyId[] = [ const JAPANESE_ONLY = /[\u3040-\u30ff続読変済図気帰戻検択転権単圧応対発処実証覧関専]/u const VALUES = { agent: 'Claude', + loginCommand: 'agent login', + slashCommand: '/login', command: 'compact', detail: 'Image type .bmp', limit: '20', @@ -87,6 +89,27 @@ afterEach(async () => { }) describe('desktop words for a failure fact', () => { + it.each([ + ['en', 'Then send your message again.'], + ['es', 'Después, envía tu mensaje de nuevo.'], + ['fr', 'Puis envoyez à nouveau votre message.'], + ['ja', 'その後、メッセージをもう一度送信してください。'], + ['ko', '그런 다음 메시지를 다시 보내세요.'], + ['zh', '然后再次发送消息。'] + ])('keeps %s copy while separating an unpunctuated provider detail', async (locale, retry) => { + await i18n.changeLanguage(locale) + const fact = { + kind: 'notSignedIn', + detail: { text: 'See {{agent}} docs/models.md', audience: 'person' } + } as const + const context = { agentName: 'Pi' } + const row = agentSessionFailureSentence(fact, 'row', context, sayAgentSessionFailureTranslated) + expect(row.endsWith(fact.detail.text)).toBe(true) + expect( + agentSessionFailureSentence(fact, 'rejection', context, sayAgentSessionFailureTranslated) + ).toBe(`${row}. ${retry}`) + }) + 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([ @@ -146,7 +169,7 @@ describe('desktop words for a failure fact', () => { agentSessionWriteNoticeParts(refused, 'send', { agentName: 'Claude' }) ) ).toBe( - "Votre message n'a pas été envoyé. Claude n’est pas connecté. Exécutez `claude` et connectez-vous avec /login, ou choisissez un compte dans les paramètres des Comptes Claude." + "Votre message n'a pas été envoyé. Claude n’est pas connecté. Exécutez `claude auth login`, ou choisissez un compte dans les paramètres des Comptes Claude." ) const detail = 'Uses {{agent}} $t(components.native-chat.failureWords.theAgent) &' const rejected = structuredAgentSessionRejectionParts( diff --git a/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts b/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts index b12ae667de84..58c6ada0adc5 100644 --- a/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts +++ b/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts @@ -1,7 +1,7 @@ import { translate } from '@/i18n/i18n' import type { StructuredAgentSessionLaunchLifecycle } from '@/lib/structured-agent-session-launch' import { agentSessionRefusalCauseParts } from '../../../../shared/agent-session-refusal-notice' -import type { AgentSessionWriteRefusal } from '../../../../shared/agent-session-write-failure' +import type { StructuredLaunchFailure } from '@/lib/structured-agent-session-launch-failure' import { joinSentences } from '../../../../shared/sentence-joining' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' import type { @@ -17,8 +17,7 @@ function nativeChatLaunchNotice({ onRetry }: { lifecycle: StructuredAgentSessionLaunchLifecycle | null - /** The host's refusal behind the failed start; its message is never shown. */ - failure?: AgentSessionWriteRefusal | null + failure?: StructuredLaunchFailure | null /** Names the agent in a start failure's words. */ agentLabel?: string onRetry: () => void @@ -38,9 +37,10 @@ function nativeChatLaunchNotice({ ) const cause = lifecycle === 'failed' && failure - ? agentSessionWriteNoticeText( + ? (failure.authStartupMessage ?? + agentSessionWriteNoticeText( agentSessionRefusalCauseParts(failure, agentLabel ? { agentName: agentLabel } : {}) - ) + )) : '' // An argument problem already says the start failed; the generic lead would repeat it. const saysStartFailure = @@ -66,7 +66,7 @@ export function structuredSessionNotices({ }: { launch: { lifecycle: StructuredAgentSessionLaunchLifecycle | null - failure: AgentSessionWriteRefusal | null + failure: StructuredLaunchFailure | null retry: () => void } agentLabel: string 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 64cf71eadfb3..96b1f66efb8f 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts @@ -2,17 +2,12 @@ // the sender's in-memory sends and the host's rows on every render, never stored. import { - readAgentSessionFailureFact, readWholeAgentSessionFailureFact, type AgentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' -import type { - AgentJournalRenderItem, - AgentJournalSubmission -} from '../../../../shared/agent-session-journal-types' +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice' -import { isStructuredAgentSessionStartFailureRow } from '../../../../shared/structured-agent-session-start-failure-row-key' import { structuredAgentSessionRejectionParts } from '../../../../shared/structured-agent-session-rejection-words' import { structuredAgentSessionRejectedShownInPlace } from '../../../../shared/structured-agent-session-message-projection' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' @@ -23,51 +18,12 @@ import type { StructuredAgentSessionPendingSend } from './structured-agent-sessi const STRUCTURED_AGENT_SESSION_DELIVERY_SENDING: NativeChatDeliveryNotice = { sending: true } const NO_COMMANDS: ReadonlySet = new Set() -/** The facts the chat's loaded start-failure rows state. */ -export function structuredAgentSessionStartFailureFacts( - items: readonly AgentJournalRenderItem[] -): AgentSessionFailureFact[] { - const facts: AgentSessionFailureFact[] = [] - for (const item of items) { - if (item.body.kind === 'status' && isStructuredAgentSessionStartFailureRow(item.itemId)) { - const fact = readAgentSessionFailureFact(item.body.failure) - if (fact) { - facts.push(fact) - } - } - } - return facts -} - -/** Whether two facts are one failure: a start's row and the messages it rejected share one. */ -export function sameAgentSessionFailureFact( - a: AgentSessionFailureFact, - b: AgentSessionFailureFact -): boolean { - return ( - a.kind === b.kind && - a.detail?.text === b.detail?.text && - a.detail?.audience === b.detail?.audience && - a.refusal?.code === b.refusal?.code && - a.refusal?.details?.reason === b.refusal?.details?.reason && - a.attachment?.reason === b.attachment?.reason && - a.attachment?.limit === b.attachment?.limit && - a.retry?.error === b.retry?.error && - a.retry?.status === b.retry?.status - ) -} - -/** 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)) - ) -} +export { + agentSessionVisibleFailureFacts as structuredAgentSessionStartFailureFacts, + agentSessionFailureStatedByRow as agentSessionFailureStatedByStartRow, + sameAgentSessionFailureFact +} from '../../../../shared/agent-session-visible-failures' +import { agentSessionFailureStatedByRow as agentSessionFailureStatedByStartRow } from '../../../../shared/agent-session-visible-failures' function hostRejectionNoticeText( submission: AgentJournalSubmission, 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 index c765001325a4..6686498a3ef8 100644 --- 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 @@ -101,7 +101,7 @@ describe('the line under the composer after a conversation command failed', () = (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 isn't signed in. Run `claude` and sign in with /login, or choose an account in Claude Accounts settings. Run /compact again." + "Claude isn't signed in. Run `claude auth login`, or choose an account in Claude Accounts settings. 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." @@ -130,7 +130,7 @@ describe('the line under the composer after a conversation command failed', () = 'Claude を起動できませんでした。/clear をもう一度実行してください。' ) expect((await sent(hostResult('compact', { kind: 'notSignedIn' }))).error).toBe( - 'Claude にサインインしていません。`claude` を実行して /login でサインインするか、設定の Claude アカウントでアカウントを選択してください。/compact をもう一度実行してください。' + 'Claude にサインインしていません。`claude auth login` を実行するか、Claude アカウント設定でアカウントを選択してください。/compact をもう一度実行してください。' ) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-availability-notice.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-availability-notice.test.tsx new file mode 100644 index 000000000000..6efaa5c82634 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-availability-notice.test.tsx @@ -0,0 +1,83 @@ +// @vitest-environment happy-dom +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { useNativeChatAvailabilityNotice } from './use-native-chat-availability-notice' +import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { + structuredAgentSessionDeliveryNotices, + structuredAgentSessionStartFailureFacts +} from './structured-agent-session-delivery-notices' + +afterEach(cleanup) +describe('each agent names its own sign-in method before a send', () => { + it.each([ + ['claude', 'Claude', 'claude auth login'], + ['codex', 'Codex', 'codex login'], + ['grok', 'Grok', 'grok login'], + ['opencode', 'OpenCode', 'opencode auth login'], + ['pi', 'Pi', '/login'], + ['omp', 'OMP', 'Sign in to OMP.'] + ] as const)('uses %s copy without a post-send retry tail', (agent, agentLabel, command) => { + const { result } = renderHook(() => + useNativeChatAvailabilityNotice({ + agent, + agentLabel, + unavailable: { reason: 'notSignedIn' }, + launchFailure: null, + journalItems: [] + }) + ) + expect(result.current?.text).toContain(command) + expect(result.current?.text).toContain(agentLabel) + expect(result.current?.text).not.toContain('send your message again') + }) +}) + +it('keeps one auth explanation while an unechoed message still reads as unsent', () => { + const failure = { + kind: 'notSignedIn', + account: 'system', + detail: { text: 'Expired session token', audience: 'person' } + } as const + const authRow: AgentJournalRenderItem = { + itemId: 'codex-error', + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'status', + tone: 'error', + ...agentSessionFailureWords(failure, { agentName: 'Codex', surface: 'row' }) + } + } + const { result } = renderHook(() => + useNativeChatAvailabilityNotice({ + agent: 'codex', + agentLabel: 'Codex', + unavailable: { reason: 'notSignedIn', account: 'system' }, + launchFailure: null, + journalItems: [authRow] + }) + ) + expect(result.current).toBeNull() + const notices = structuredAgentSessionDeliveryNotices({ + pending: [], + agentName: 'Codex', + startFailures: structuredAgentSessionStartFailureFacts([authRow]), + submissions: [ + { + clientMessageId: 'send', + fence: 1, + payloadFingerprint: 'send', + dispatchState: 'rejected', + providerItemId: null, + reason: 'Host sign-in guidance', + rejection: failure, + submittedAt: 1, + resolvedAt: 2 + } + ] + }) + expect([...notices.values()].map((notice) => notice.text)).toEqual(['Your message was not sent.']) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-availability-notice.ts b/src/renderer/src/components/native-chat/use-native-chat-availability-notice.ts index f42b5b17d248..afdd06ad61ef 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-availability-notice.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-availability-notice.ts @@ -1,8 +1,5 @@ import { useMemo, useState } from 'react' -import { - agentSessionSignInCopyId, - type AgentSessionUnavailable -} from '../../../../shared/agent-session-availability' +import type { AgentSessionUnavailable } from '../../../../shared/agent-session-availability' import { readAgentSessionFailureFact } from '../../../../shared/agent-session-failure' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import type { AgentType } from '../../../../shared/agent-status-types' @@ -10,6 +7,7 @@ import { agentSessionRefusalReasonWords } from '../../../../shared/agent-session import type { AgentSessionWriteRefusal } from '../../../../shared/agent-session-write-failure' import { isStructuredAgentSessionStartFailureRow } from '../../../../shared/structured-agent-session-start-failure-row-key' import { sayAgentSessionFailureTranslated } from './agent-session-failure-words-text' +import { notSignedInSentence } from '../../../../shared/agent-session-availability-sentences' import type { NativeChatComposerNotice } from './native-chat-composer-notice' /** Why the chat's latest start failed, unless a turn has run since. */ @@ -18,11 +16,11 @@ function failedStartReason(items: readonly AgentJournalRenderItem[] | undefined) for (const item of items ?? []) { if (item.body.kind === 'turn') { reason = null - } else if ( - item.body.kind === 'status' && - isStructuredAgentSessionStartFailureRow(item.itemId) - ) { - reason = readAgentSessionFailureFact(item.body.failure)?.kind ?? null + } else if (item.body.kind === 'status') { + const failure = readAgentSessionFailureFact(item.body.failure) + if (isStructuredAgentSessionStartFailureRow(item.itemId) || failure?.kind === 'notSignedIn') { + reason = failure?.kind ?? null + } } } return reason @@ -63,11 +61,10 @@ export function useNativeChatAvailabilityNotice(input: { text: unavailable.reason === 'cliMissing' ? sayAgentSessionFailureTranslated('cliMissing', { agent: input.agentLabel }) - : sayAgentSessionFailureTranslated( - agentSessionSignInCopyId( - input.agent === 'codex' ? 'codex' : 'claude', - unavailable.account - ) + : notSignedInSentence( + { agentName: input.agentLabel }, + { kind: 'notSignedIn', account: unavailable.account }, + sayAgentSessionFailureTranslated ), onDismiss: () => setDismissal({ key, dismissed: true }) } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts index ba2803da4671..701876aa17ba 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts @@ -9,6 +9,7 @@ import { structuredAgentSessionDeliveryNotices } from './structured-agent-sessio import type { StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends' import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts' import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' +import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' const NO_SUBMISSIONS: readonly AgentJournalSubmission[] = [] @@ -18,6 +19,7 @@ export function useStructuredAgentSessionDeliveryNotices(args: { submissions: readonly AgentJournalSubmission[] journalItems: readonly AgentJournalRenderItem[] agentName: string + startFailures?: readonly AgentSessionFailureFact[] }): ReadonlyMap { const { agentName, pending, submissions } = args // Only a chat with a message shown as not sent reads the journal's rows and loaded items, so in a @@ -26,7 +28,11 @@ export function useStructuredAgentSessionDeliveryNotices(args: { (submission) => submission.dispatchState === 'rejected' && !dispatchWasWithdrawn(submission) ) const journalRows = hasRejected ? submissions : NO_SUBMISSIONS - const startFailures = useStructuredAgentSessionStartFailureFacts(args.journalItems, hasRejected) + const loadedFailures = useStructuredAgentSessionStartFailureFacts( + args.journalItems, + hasRejected && args.startFailures === undefined + ) + const startFailures = args.startFailures ?? loadedFailures const commandItemIds = useCommandItemIds(args.journalItems, hasRejected) const notices = useMemo( () => diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index e399a57ee8dc..ef80824aa94b 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2856,6 +2856,8 @@ "failureWords": { "accountSwitchInProgress": "A Claude account switch is in progress. Try again after it finishes.", "agentCommandNotRunnable": "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", + "agentCommandNotSignedIn": "Sign in to {{agent}} with `{{loginCommand}}` on the computer running this chat.", + "agentNotSignedIn": "Sign in to {{agent}}.", "answerUnconfirmed": "Your answer was recorded but the agent did not confirm it.", "argumentsMissingValue": "Saved Arguments need a value for {{option}}.", "argumentsMultipleValues": "Saved Arguments give {{option}} more than one value.", @@ -2881,10 +2883,10 @@ "chooseClaudeAccountThenSend": "Choose or add one in Claude Accounts settings, then send your message again.", "commandAfterAnswer": "Answer the agent's question or approval, then run /{{command}}.", "claudeManagedNotSignedIn": "This Claude account isn't signed in. Sign in again in Claude Accounts settings.", - "claudeSystemNotSignedIn": "Claude isn't signed in. Run `claude` and sign in with /login, or choose an account in Claude Accounts settings.", + "claudeSystemNotSignedIn": "Claude isn't signed in. Run `{{loginCommand}}`, or choose an account in Claude Accounts settings.", "cliMissing": "{{agent}} wasn't found on the computer running this chat. Install it, or check its Command in Settings → Agents.", "codexManagedNotSignedIn": "This Codex account isn't signed in. Sign in again in Codex Accounts settings.", - "codexSystemNotSignedIn": "Codex isn't signed in. Run `codex login`.", + "codexSystemNotSignedIn": "Codex isn't signed in. Run `{{loginCommand}}`.", "commandRefused": "This command didn't run.", "commandRefusedTryAgain": "This command didn't run. Try it again.", "compactionFailed": "Compaction failed.", @@ -2900,13 +2902,14 @@ "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.", + "interactiveAgentNotSignedIn": "Sign in to {{agent}} by running `{{loginCommand}}` and using `{{slashCommand}}` on the computer running this chat.", "launchFolderMissing": "The folder this chat ran in no longer exists. Restore it to continue this chat.", "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.", + "notSignedIn": "{{agent}} is not signed in.", "previousExitUnverifiable": "Couldn't stop {{agent}} from before.", "providerExitedRejection": "{{agent}} stopped before this message was sent.", "providerExitedRow": "{{agent}} stopped while this response was in progress. You can continue in this conversation.", @@ -2931,6 +2934,7 @@ "stopRefused": "{{agent}} didn't stop.", "stopRefusedQuoted": "{{agent}} didn't stop: {{detail}}.", "theAgent": "The agent", + "thenSendAgain": "Then send your message again.", "writeFailed": "Orca couldn't hand this message to the agent, so it was not sent." }, "notices": { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 28ec34622613..140dc56c828b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18008,10 +18008,10 @@ "sendAgainToTryOnceMore": "Send your message again to try once more.", "couldNotStart": "{{agent}} couldn't start.", "couldNotRestart": "{{agent}} couldn't restart.", - "notSignedIn": "{{agent}} is not signed in for the selected account.", - "claudeSystemNotSignedIn": "Claude isn't signed in. Run `claude` and sign in with /login, or choose an account in Claude Accounts settings.", + "notSignedIn": "{{agent}} is not signed in.", + "claudeSystemNotSignedIn": "Claude isn't signed in. Run `{{loginCommand}}`, or choose an account in Claude Accounts settings.", "claudeManagedNotSignedIn": "This Claude account isn't signed in. Sign in again in Claude Accounts settings.", - "codexSystemNotSignedIn": "Codex isn't signed in. Run `codex login`.", + "codexSystemNotSignedIn": "Codex isn't signed in. Run `{{loginCommand}}`.", "codexManagedNotSignedIn": "This Codex account isn't signed in. Sign in again in Codex Accounts settings.", "cliMissing": "{{agent}} wasn't found on the computer running this chat. Install it, or check its Command in Settings → Agents.", "signInFirst": "Sign in first.", @@ -18077,6 +18077,10 @@ "argumentsMultipleValues": "Saved Arguments give {{option}} more than one value.", "argumentsPositionalPrompt": "Saved Arguments include a prompt.", "editSavedArguments": "Edit them in Settings > Agents > Arguments.", + "agentCommandNotSignedIn": "Sign in to {{agent}} with `{{loginCommand}}` on the computer running this chat.", + "interactiveAgentNotSignedIn": "Sign in to {{agent}} by running `{{loginCommand}}` and using `{{slashCommand}}` on the computer running this chat.", + "agentNotSignedIn": "Sign in to {{agent}}.", + "thenSendAgain": "Then send your message again.", "runCommandWhenDone": "Run /{{command}} when it's done.", "commandAfterAnswer": "Answer the agent's question or approval, then run /{{command}}." }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 91b4a99c3ef5..903faac31db7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17787,10 +17787,10 @@ "couldNotStart": "{{agent}} no pudo iniciarse.", "editSavedArguments": "Edítalos en Ajustes > Agentes > Argumentos.", "couldNotRestart": "{{agent}} no pudo reiniciarse.", - "notSignedIn": "{{agent}} no tiene la sesión iniciada con la cuenta seleccionada.", - "claudeSystemNotSignedIn": "Claude no ha iniciado sesión. Ejecuta `claude` e inicia sesión con /login, o elige una cuenta en la configuración de Cuentas de Claude.", + "notSignedIn": "{{agent}} no ha iniciado sesión.", + "claudeSystemNotSignedIn": "Claude no ha iniciado sesión. Ejecuta `{{loginCommand}}` o elige una cuenta en los ajustes de Cuentas de Claude.", "claudeManagedNotSignedIn": "Esta cuenta de Claude no ha iniciado sesión. Vuelve a iniciar sesión en la configuración de Cuentas de Claude.", - "codexSystemNotSignedIn": "Codex no ha iniciado sesión. Ejecuta `codex login`.", + "codexSystemNotSignedIn": "Codex no ha iniciado sesión. Ejecuta `{{loginCommand}}`.", "codexManagedNotSignedIn": "Esta cuenta de Codex no ha iniciado sesión. Vuelve a iniciar sesión en la configuración de Cuentas de Codex.", "cliMissing": "No se encontró {{agent}} en el equipo que ejecuta este chat. Instálelo o revise su Comando en Ajustes → Agentes.", "signInFirst": "Inicie sesión primero.", @@ -17854,6 +17854,11 @@ "providerRetryNumberOf": "Reintento {{attempt}} de {{maxRetries}}.", "providerRetryLastError": "Último error: {{detail}}.", "previousExitUnverifiable": "No se pudo detener la ejecución anterior de {{agent}}.", + "sessionNotRestored": "{{agent}} no pudo reabrir su sesión anterior, así que este chat continúa en una nueva. {{agent}} no recuerda los mensajes anteriores.", + "agentCommandNotSignedIn": "Inicia sesión en {{agent}} con `{{loginCommand}}` en el equipo donde se ejecuta este chat.", + "interactiveAgentNotSignedIn": "Inicia sesión en {{agent}} ejecutando `{{loginCommand}}` y usando `{{slashCommand}}` en el equipo donde se ejecuta este chat.", + "agentNotSignedIn": "Inicia sesión en {{agent}}.", + "thenSendAgain": "Después, envía tu mensaje de nuevo.", "runCommandWhenDone": "Ejecute /{{command}} cuando termine.", "commandAfterAnswer": "Responda a la pregunta o aprobación del agente y luego ejecute /{{command}}.", "sessionNotRestored": "{{agent}} no pudo reabrir su sesión anterior, así que este chat continúa en una nueva. {{agent}} no recuerda los mensajes anteriores." diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index c9b2bb96bb3c..ad5cfc6b400d 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17888,10 +17888,10 @@ "couldNotStart": "{{agent}} n'a pas pu démarrer.", "editSavedArguments": "Modifiez-les dans Paramètres > Agents > Arguments.", "couldNotRestart": "{{agent}} n'a pas pu redémarrer.", - "notSignedIn": "{{agent}} n'est pas connecté avec le compte sélectionné.", - "claudeSystemNotSignedIn": "Claude n’est pas connecté. Exécutez `claude` et connectez-vous avec /login, ou choisissez un compte dans les paramètres des Comptes Claude.", + "notSignedIn": "{{agent}} n’est pas connecté.", + "claudeSystemNotSignedIn": "Claude n’est pas connecté. Exécutez `{{loginCommand}}`, ou choisissez un compte dans les paramètres des Comptes Claude.", "claudeManagedNotSignedIn": "Ce compte Claude n’est pas connecté. Reconnectez-vous dans les paramètres des Comptes Claude.", - "codexSystemNotSignedIn": "Codex n’est pas connecté. Exécutez `codex login`.", + "codexSystemNotSignedIn": "Codex n’est pas connecté. Exécutez `{{loginCommand}}`.", "codexManagedNotSignedIn": "Ce compte Codex n’est pas connecté. Reconnectez-vous dans les paramètres des Comptes Codex.", "cliMissing": "{{agent}} est introuvable sur l’ordinateur qui exécute cette conversation. Installez-le ou vérifiez sa Commande dans Paramètres → Agents.", "signInFirst": "Connectez-vous d'abord.", @@ -17955,6 +17955,11 @@ "providerRetryNumberOf": "Nouvelle tentative {{attempt}} sur {{maxRetries}}.", "providerRetryLastError": "Dernière erreur : {{detail}}.", "previousExitUnverifiable": "Impossible d'arrêter l'exécution précédente de {{agent}}.", + "sessionNotRestored": "{{agent}} n'a pas pu rouvrir sa session précédente, cette discussion continue donc dans une nouvelle. {{agent}} ne se souvient pas des messages précédents.", + "agentCommandNotSignedIn": "Connectez-vous à {{agent}} avec `{{loginCommand}}` sur l’ordinateur qui exécute ce chat.", + "interactiveAgentNotSignedIn": "Connectez-vous à {{agent}} en exécutant `{{loginCommand}}` puis en utilisant `{{slashCommand}}` sur l’ordinateur qui exécute ce chat.", + "agentNotSignedIn": "Connectez-vous à {{agent}}.", + "thenSendAgain": "Puis envoyez à nouveau votre message.", "runCommandWhenDone": "Lancez /{{command}} quand il aura terminé.", "commandAfterAnswer": "Répondez à la question ou à la demande d'approbation de l'agent, puis lancez /{{command}}.", "sessionNotRestored": "{{agent}} n'a pas pu rouvrir sa session précédente, cette discussion continue donc dans une nouvelle. {{agent}} ne se souvient pas des messages précédents." diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 4457a6742d4c..3aa840dfaa01 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17822,10 +17822,10 @@ "couldNotStart": "{{agent}} を起動できませんでした。", "editSavedArguments": "設定 > Agent > 引数で編集してください。", "couldNotRestart": "{{agent}} を再起動できませんでした。", - "notSignedIn": "{{agent}} は選択したアカウントでサインインしていません。", - "claudeSystemNotSignedIn": "Claude にサインインしていません。`claude` を実行して /login でサインインするか、設定の Claude アカウントでアカウントを選択してください。", + "notSignedIn": "{{agent}} にサインインしていません。", + "claudeSystemNotSignedIn": "Claude にサインインしていません。`{{loginCommand}}` を実行するか、Claude アカウント設定でアカウントを選択してください。", "claudeManagedNotSignedIn": "この Claude アカウントにサインインしていません。設定の Claude アカウントで再度サインインしてください。", - "codexSystemNotSignedIn": "Codex にサインインしていません。`codex login` を実行してください。", + "codexSystemNotSignedIn": "Codex にサインインしていません。`{{loginCommand}}` を実行してください。", "codexManagedNotSignedIn": "この Codex アカウントにサインインしていません。設定の Codex アカウントで再度サインインしてください。", "cliMissing": "このチャットを実行しているコンピューターで {{agent}} が見つかりませんでした。インストールするか、[設定] → [Agent] の [コマンド] を確認してください。", "signInFirst": "先にサインインしてください。", @@ -17889,6 +17889,11 @@ "providerRetryNumberOf": "{{maxRetries}} 回中 {{attempt}} 回目の再試行です。", "providerRetryLastError": "直近のエラー: {{detail}}。", "previousExitUnverifiable": "以前の {{agent}} を停止できませんでした。", + "sessionNotRestored": "{{agent}} は以前のセッションを再開できなかったため、このチャットは新しいセッションで続行します。{{agent}} は以前のメッセージを覚えていません。", + "agentCommandNotSignedIn": "このチャットを実行しているコンピューターで `{{loginCommand}}` を使って {{agent}} にサインインしてください。", + "interactiveAgentNotSignedIn": "このチャットを実行しているコンピューターで `{{loginCommand}}` を起動し、`{{slashCommand}}` を使って {{agent}} にサインインしてください。", + "agentNotSignedIn": "{{agent}} にサインインしてください。", + "thenSendAgain": "その後、メッセージをもう一度送信してください。", "runCommandWhenDone": "終わったら /{{command}} を実行してください。", "commandAfterAnswer": "Agent の質問または承認に回答してから、/{{command}} を実行してください。", "sessionNotRestored": "{{agent}} は以前のセッションを再開できなかったため、このチャットは新しいセッションで続行します。{{agent}} は以前のメッセージを覚えていません。" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index f875f908a29a..43a423f16585 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17822,10 +17822,10 @@ "couldNotStart": "{{agent}}을(를) 시작하지 못했습니다.", "editSavedArguments": "설정 > 에이전트 > 인수에서 수정하세요.", "couldNotRestart": "{{agent}}을(를) 다시 시작하지 못했습니다.", - "notSignedIn": "{{agent}}이(가) 선택한 계정으로 로그인되어 있지 않습니다.", - "claudeSystemNotSignedIn": "Claude에 로그인하지 않았습니다. `claude`를 실행하고 /login으로 로그인하거나 Claude 계정 설정에서 계정을 선택하세요.", + "notSignedIn": "{{agent}}에 로그인되어 있지 않습니다.", + "claudeSystemNotSignedIn": "Claude에 로그인되어 있지 않습니다. `{{loginCommand}}`을 실행하거나 Claude 계정 설정에서 계정을 선택하세요.", "claudeManagedNotSignedIn": "이 Claude 계정에 로그인하지 않았습니다. Claude 계정 설정에서 다시 로그인하세요.", - "codexSystemNotSignedIn": "Codex에 로그인하지 않았습니다. `codex login`을 실행하세요.", + "codexSystemNotSignedIn": "Codex에 로그인하지 않았습니다. `{{loginCommand}}`을 실행하세요.", "codexManagedNotSignedIn": "이 Codex 계정에 로그인하지 않았습니다. Codex 계정 설정에서 다시 로그인하세요.", "cliMissing": "이 채팅을 실행하는 컴퓨터에서 {{agent}}를 찾을 수 없습니다. 설치하거나 설정 → 에이전트에서 명령을 확인하세요.", "signInFirst": "먼저 로그인하세요.", @@ -17889,6 +17889,11 @@ "providerRetryNumberOf": "{{maxRetries}}회 중 {{attempt}}번째 재시도입니다.", "providerRetryLastError": "마지막 오류: {{detail}}.", "previousExitUnverifiable": "이전에 실행된 {{agent}}을(를) 중지하지 못했습니다.", + "sessionNotRestored": "{{agent}}이(가) 이전 세션을 다시 열 수 없어 이 채팅은 새 세션에서 계속됩니다. {{agent}}은(는) 이전 메시지를 기억하지 않습니다.", + "agentCommandNotSignedIn": "이 채팅을 실행하는 컴퓨터에서 `{{loginCommand}}`으로 {{agent}}에 로그인하세요.", + "interactiveAgentNotSignedIn": "이 채팅을 실행하는 컴퓨터에서 `{{loginCommand}}`을 실행하고 `{{slashCommand}}`을 사용해 {{agent}}에 로그인하세요.", + "agentNotSignedIn": "{{agent}}에 로그인하세요.", + "thenSendAgain": "그런 다음 메시지를 다시 보내세요.", "runCommandWhenDone": "작업이 끝나면 /{{command}}을(를) 실행하세요.", "commandAfterAnswer": "에이전트의 질문 또는 승인에 응답한 다음 /{{command}}을(를) 실행하세요.", "sessionNotRestored": "{{agent}}이(가) 이전 세션을 다시 열 수 없어 이 채팅은 새 세션에서 계속됩니다. {{agent}}은(는) 이전 메시지를 기억하지 않습니다." diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index f801ed896394..9057858199aa 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17787,10 +17787,10 @@ "couldNotStart": "{{agent}} 无法启动。", "editSavedArguments": "请在设置 > 智能体 > 参数中编辑。", "couldNotRestart": "{{agent}} 无法重新启动。", - "notSignedIn": "{{agent}} 未使用所选账户登录。", - "claudeSystemNotSignedIn": "Claude 尚未登录。运行 `claude` 并使用 /login 登录,或在 Claude 账户设置中选择一个账户。", + "notSignedIn": "{{agent}} 尚未登录。", + "claudeSystemNotSignedIn": "Claude 尚未登录。请运行 `{{loginCommand}}`,或在 Claude 账户设置中选择账户。", "claudeManagedNotSignedIn": "此 Claude 账户尚未登录。请在 Claude 账户设置中重新登录。", - "codexSystemNotSignedIn": "Codex 尚未登录。请运行 `codex login`。", + "codexSystemNotSignedIn": "Codex 尚未登录。请运行 `{{loginCommand}}`。", "codexManagedNotSignedIn": "此 Codex 账户尚未登录。请在 Codex 账户设置中重新登录。", "cliMissing": "在运行此聊天的计算机上找不到 {{agent}}。请安装它,或检查设置 → 智能体中的命令。", "signInFirst": "请先登录。", @@ -17854,6 +17854,11 @@ "providerRetryNumberOf": "第 {{attempt}} 次重试,共 {{maxRetries}} 次。", "providerRetryLastError": "上次错误:{{detail}}。", "previousExitUnverifiable": "无法停止之前的 {{agent}}。", + "sessionNotRestored": "{{agent}} 无法重新打开之前的会话,因此此聊天将在新会话中继续。{{agent}} 不记得之前的消息。", + "agentCommandNotSignedIn": "请在运行此聊天的电脑上使用 `{{loginCommand}}` 登录 {{agent}}。", + "interactiveAgentNotSignedIn": "请在运行此聊天的电脑上启动 `{{loginCommand}}` 并使用 `{{slashCommand}}` 登录 {{agent}}。", + "agentNotSignedIn": "请登录 {{agent}}。", + "thenSendAgain": "然后再次发送消息。", "runCommandWhenDone": "完成后再运行 /{{command}}。", "commandAfterAnswer": "请先回答智能体的问题或审批,然后再运行 /{{command}}。", "sessionNotRestored": "{{agent}} 无法重新打开之前的会话,因此此聊天将在新会话中继续。{{agent}} 不记得之前的消息。" diff --git a/src/renderer/src/i18n/native-chat-locales.test.ts b/src/renderer/src/i18n/native-chat-locales.test.ts index 023498741503..47de5eaaf0f2 100644 --- a/src/renderer/src/i18n/native-chat-locales.test.ts +++ b/src/renderer/src/i18n/native-chat-locales.test.ts @@ -85,11 +85,15 @@ describe('account-aware Send fixes', () => { '%s includes the same fixes for Send and post-send failures', (_locale, catalog) => { const words = catalog.components['native-chat'].failureWords - expect(words.claudeSystemNotSignedIn).toContain('`claude`') - expect(words.claudeSystemNotSignedIn).toContain('/login') - expect(words.codexSystemNotSignedIn).toContain('`codex login`') + expect(words.claudeSystemNotSignedIn).toContain('`{{loginCommand}}`') + expect(words.codexSystemNotSignedIn).toContain('`{{loginCommand}}`') expect(words.claudeManagedNotSignedIn).not.toContain('/login') expect(words.codexManagedNotSignedIn).not.toContain('`codex login`') + expect(words.claudeManagedNotSignedIn).not.toContain('{{loginCommand}}') + expect(words.codexManagedNotSignedIn).not.toContain('{{loginCommand}}') + expect(words.agentCommandNotSignedIn).toContain('{{agent}}') + expect(words.agentCommandNotSignedIn).toContain('`{{loginCommand}}`') + expect(words.interactiveAgentNotSignedIn).toContain('`{{slashCommand}}`') expect(words.cliMissing).toContain('{{agent}}') for (const key of ['claudeManagedNotSignedIn', 'codexManagedNotSignedIn'] as const) { expect(words[key].trim()).not.toBe('') diff --git a/src/renderer/src/lib/structured-agent-session-launch-failure.ts b/src/renderer/src/lib/structured-agent-session-launch-failure.ts index b9ad791a0c44..3bdb2d5c6be3 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-failure.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-failure.ts @@ -1,4 +1,4 @@ -// What a failed chat start's Retry line may say: the host's refusal, never its message. +// The failed start's refusal and host-composed authentication guidance. import { readAgentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' import { @@ -7,12 +7,23 @@ import { type AgentSessionWriteRefusal } from '../../../shared/agent-session-write-failure' -/** The host's refusal behind a failed launch; undefined when the failure carried none (a lost - * reply, a local fault, an older host). Reads a create error's own refusal or an RPC error's. */ -export function structuredLaunchFailure(error: unknown): AgentSessionWriteRefusal | undefined { +export type StructuredLaunchFailure = AgentSessionWriteRefusal & { authStartupMessage?: string } + +/** The host's refusal behind a failed launch; undefined when the failure carried none. */ +export function structuredLaunchFailure(error: unknown): StructuredLaunchFailure | undefined { const refusal = error instanceof Error && 'refusal' in error ? readAgentSessionRefusalReference(error.refusal) : readAgentSessionErrorRefusal(error) - return refusal ? agentSessionRefusalFailure(refusal) : undefined + if (!refusal) { + return undefined + } + const failure = agentSessionRefusalFailure(refusal) + const message = error instanceof Error ? error.message : undefined + return failure.code === 'agent_session_operation_invalid' && + failure.details?.reason === 'notSignedIn' && + message && + message !== failure.code + ? { ...failure, authStartupMessage: message } + : failure } diff --git a/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts b/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts index 248ab71db7bc..fb64780083bd 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts @@ -47,7 +47,7 @@ export function trackLaunchSettlement( } return } - // The host's message is for the log; the chat's Retry line alone says the failure. + // The chat's Retry line owns the failed start's guidance. console.warn('[native-chat] structured launch failed', error) const failure = structuredLaunchFailure(error) if (failure) { diff --git a/src/renderer/src/lib/structured-agent-session-launch-registry.ts b/src/renderer/src/lib/structured-agent-session-launch-registry.ts index 5d4bcfbb196e..076ecf184862 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-registry.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-registry.ts @@ -1,6 +1,6 @@ import { useSyncExternalStore } from 'react' import type { TuiAgent } from '../../../shared/tui-agent' -import type { AgentSessionWriteRefusal } from '../../../shared/agent-session-write-failure' +import type { StructuredLaunchFailure } from './structured-agent-session-launch-failure' import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' import type { ExecutionHostId } from '../../../shared/execution-host' import type { StructuredLaunchRecoveryState } from './structured-agent-session-launch-recovery' @@ -31,7 +31,7 @@ export type StructuredLaunchState = StructuredLaunchRecoveryState & { callers: StructuredLaunchCallerGroup /** The host's refusal behind the last failed attempt, worded beside Retry. Absent when the * failure named none. */ - failure?: AgentSessionWriteRefusal + failure?: StructuredLaunchFailure selection: StructuredLaunchSelection } @@ -178,7 +178,7 @@ export function getStructuredAgentSessionLaunchResumes(sessionId: string): boole export function getStructuredAgentSessionLaunchFailure( worktreeId: string, sessionId: string -): AgentSessionWriteRefusal | null { +): StructuredLaunchFailure | null { const state = getStructuredLaunchStateBySessionId(sessionId) return state && matchesLaunchWorktree(state, worktreeId) && @@ -190,7 +190,7 @@ export function getStructuredAgentSessionLaunchFailure( export function useStructuredAgentSessionLaunchFailure( worktreeId: string, sessionId: string -): AgentSessionWriteRefusal | null { +): StructuredLaunchFailure | null { return useSyncExternalStore( subscribeStructuredAgentLaunchStatus, () => getStructuredAgentSessionLaunchFailure(worktreeId, sessionId), diff --git a/src/shared/agent-session-availability-sentences.ts b/src/shared/agent-session-availability-sentences.ts index b42fe2f68aef..ccf0b409a0de 100644 --- a/src/shared/agent-session-availability-sentences.ts +++ b/src/shared/agent-session-availability-sentences.ts @@ -1,8 +1,14 @@ import { agentSessionSignInCopyId } from './agent-session-availability' import type { AgentSessionFailureFact } from './agent-session-failure' import type { AgentSessionFailureSay } from './agent-session-failure-copy' -import type { AgentSessionFailureWordsContext } from './agent-session-failure-words' +import type { + AgentSessionFailureWordsContext, + AgentSessionFailureSurface +} from './agent-session-failure-words' import { joinSentences } from './sentence-joining' +import { agentSessionSignInFor } from './agent-session-sign-in' + +const SENTENCE_END = /[.!?。!?][\p{Pe}\p{Pf}"']*\s*$/u // The sentences for a start no account or CLI on this host can make: also the chat's notice. @@ -13,29 +19,31 @@ function agent(say: AgentSessionFailureSay, { agentName }: AgentSessionFailureWo export function notSignedInSentence( context: AgentSessionFailureWordsContext, fact: AgentSessionFailureFact, - say: AgentSessionFailureSay + say: AgentSessionFailureSay, + surface: AgentSessionFailureSurface = 'row' ): string { - if (!context.provider && context.agentName !== 'Claude' && context.agentName !== 'Codex') { - return joinSentences([ - say('notSignedIn', agent(say, context)), - context.retryControl - ? say('signInFirst') - : context.command - ? say('signInThenRunCommand', { command: context.command }) - : say('signInThenSend') - ]) - } - const provider = - context.agentName === 'Codex' - ? 'codex' - : context.agentName === 'Claude' - ? 'claude' - : (context.provider ?? 'claude') - const signIn = say(agentSessionSignInCopyId(provider, fact.account)) - // Signing in is the step; a command the start was for still has to be run again after it. - return context.command && !context.retryControl - ? joinSentences([signIn, say('runCommandAgain', { command: context.command })]) - : signIn + const signIn = agentSessionSignInFor(context.agentName ?? context.provider) + const copy = say(signIn ? agentSessionSignInCopyId(signIn.agent, fact.account) : 'notSignedIn', { + ...agent(say, context), + loginCommand: signIn?.loginCommand.join(' '), + slashCommand: signIn && 'slashCommand' in signIn ? signIn.slashCommand : undefined + }) + const next = context.retryControl + ? undefined + : context.command + ? say('runCommandAgain', { command: context.command }) + : surface === 'rejection' && + context.messageSubmitted !== false && + signIn?.agent !== 'claude' && + signIn?.agent !== 'codex' + ? say(signIn ? 'thenSendAgain' : 'signInThenSend') + : undefined + const detail = fact.detail?.audience === 'person' ? fact.detail.text : undefined + const detailSentence = + detail && next && !SENTENCE_END.test(detail) ? `${detail.trimEnd()}.` : detail + return joinSentences( + [copy, detailSentence, next].filter((sentence): sentence is string => sentence !== undefined) + ) } /** A command the start was for is still run again once the CLI is installed. */ diff --git a/src/shared/agent-session-availability.test.ts b/src/shared/agent-session-availability.test.ts index 6961ec23f818..2c81374072d1 100644 --- a/src/shared/agent-session-availability.test.ts +++ b/src/shared/agent-session-availability.test.ts @@ -35,6 +35,11 @@ describe("the host's sign-in and CLI verdict", () => { expect(agentSessionSignInCopyId(provider, account)).toBe(key) expect( agentSessionFailureSentence({ kind: 'notSignedIn', account }, 'rejection', { provider }) - ).toBe(AGENT_SESSION_FAILURE_COPY[key]) + ).toBe( + AGENT_SESSION_FAILURE_COPY[key].replace( + '{{loginCommand}}', + provider === 'claude' ? 'claude auth login' : 'codex login' + ) + ) }) }) diff --git a/src/shared/agent-session-availability.ts b/src/shared/agent-session-availability.ts index 0328492a8c94..78a072d0c254 100644 --- a/src/shared/agent-session-availability.ts +++ b/src/shared/agent-session-availability.ts @@ -1,3 +1,5 @@ +import { agentSessionSignInFor } from './agent-session-sign-in' + export type AgentSessionAccountKind = 'managed' | 'system' /** Why no chat can start under the account a chat runs with, as the host's catalog probe found it. @@ -24,15 +26,19 @@ export function readAgentSessionUnavailable(value: unknown): AgentSessionUnavail } } -export function agentSessionSignInCopyId( - provider: 'claude' | 'codex', - account?: AgentSessionAccountKind -) { - return provider === 'claude' +export function agentSessionSignInCopyId(provider: string, account?: AgentSessionAccountKind) { + const signIn = agentSessionSignInFor(provider) + return signIn?.agent === 'claude' ? account === 'managed' ? 'claudeManagedNotSignedIn' : 'claudeSystemNotSignedIn' - : account === 'managed' - ? 'codexManagedNotSignedIn' - : 'codexSystemNotSignedIn' + : signIn?.agent === 'codex' + ? account === 'managed' + ? 'codexManagedNotSignedIn' + : 'codexSystemNotSignedIn' + : signIn?.agent === 'pi' + ? 'interactiveAgentNotSignedIn' + : signIn?.loginCommand.length + ? 'agentCommandNotSignedIn' + : 'agentNotSignedIn' } diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index 21deeff0e09d..8f17594db219 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -30,19 +30,25 @@ export const AGENT_SESSION_FAILURE_COPY = { terminalAgentHoldsChat: TERMINAL_AGENT_HOLDS_CHAT, quitTerminalAgent: QUIT_TERMINAL_AGENT, startNewChat: START_NEW_CHAT, - notSignedIn: '{{agent}} is not signed in for the selected account.', + notSignedIn: '{{agent}} is not signed in.', claudeSystemNotSignedIn: - "Claude isn't signed in. Run `claude` and sign in with /login, or choose an account in Claude Accounts settings.", + "Claude isn't signed in. Run `{{loginCommand}}`, or choose an account in Claude Accounts settings.", claudeManagedNotSignedIn: "This Claude account isn't signed in. Sign in again in Claude Accounts settings.", - codexSystemNotSignedIn: "Codex isn't signed in. Run `codex login`.", + codexSystemNotSignedIn: "Codex isn't signed in. Run `{{loginCommand}}`.", codexManagedNotSignedIn: "This Codex account isn't signed in. Sign in again in Codex Accounts settings.", + agentCommandNotSignedIn: + 'Sign in to {{agent}} with `{{loginCommand}}` on the computer running this chat.', + interactiveAgentNotSignedIn: + 'Sign in to {{agent}} by running `{{loginCommand}}` and using `{{slashCommand}}` on the computer running this chat.', + agentNotSignedIn: 'Sign in to {{agent}}.', cliMissing: "{{agent}} wasn't found on the computer running this chat. Install it, or check its Command in Settings → Agents.", signInFirst: 'Sign in first.', signInThenRunCommand: 'Sign in, then run /{{command}} again.', signInThenSend: 'Sign in, then send your message again.', + thenSendAgain: '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.', @@ -129,6 +135,8 @@ export type AgentSessionFailureCopyId = keyof typeof AGENT_SESSION_FAILURE_COPY export type AgentSessionFailureCopyValues = { agent?: string command?: string + loginCommand?: string + slashCommand?: string detail?: string option?: string limit?: string diff --git a/src/shared/agent-session-failure-words.test.ts b/src/shared/agent-session-failure-words.test.ts index 557080b2d9ec..bfdc644b8bee 100644 --- a/src/shared/agent-session-failure-words.test.ts +++ b/src/shared/agent-session-failure-words.test.ts @@ -116,7 +116,7 @@ describe('the words written beside a failure fact', () => { command: 'compact' }) expect(compact('notSignedIn')).toBe( - "Claude isn't signed in. Run `claude` and sign in with /login, or choose an account in Claude Accounts settings. Run /compact again." + "Claude isn't signed in. Run `claude auth login`, or choose an account in Claude Accounts settings. Run /compact again." ) expect( agentSessionFailureSentence({ kind: 'notSignedIn', account: 'managed' }, 'rejection', { diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index fbf1a61c07b0..79133b2389b3 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -68,6 +68,8 @@ export type AgentSessionFailureWordsContext = { /** The surface retries for the person — its own Retry beside the words, or a read that reconnects * on its own — so they leave out sending or trying again. */ retryControl?: boolean + /** A direct create has no submitted message to send again. */ + messageSubmitted?: boolean } /** @@ -128,13 +130,17 @@ function quotingPersonDetail( /** The next step after a start or restart that failed: the command, or the message, again. */ function startRetry( say: AgentSessionFailureSay, - { command, retryControl }: AgentSessionFailureWordsContext, + { command, retryControl, messageSubmitted }: AgentSessionFailureWordsContext, sendAgain: 'sendToTryAgain' | 'sendAgainToTryOnceMore' = 'sendToTryAgain' ): string[] { if (retryControl) { return [] } - return [command ? say('runCommandAgain', { command }) : say(sendAgain)] + return [ + command + ? say('runCommandAgain', { command }) + : say(messageSubmitted === false ? 'sendToTryAgain' : sendAgain) + ] } function couldNot(verb: 'couldNotStart' | 'couldNotRestart'): Sentence { @@ -207,7 +213,7 @@ const FAILURE_SENTENCES = { providerStartFailed: (context, _fact, _surface, say) => joinSentences([say('providerStartFailed', agent(say, context)), ...startRetry(say, context)]), startFailed: couldNot('couldNotStart'), - notSignedIn: (context, fact, _surface, say) => notSignedInSentence(context, fact, say), + notSignedIn: (context, fact, surface, say) => notSignedInSentence(context, fact, say, surface), cliMissing: (context, _fact, _surface, say) => cliMissingSentence(context, say), historyTooLarge: (_context, _fact, _surface, say) => joinSentences([say('historyTooLarge'), say('startNewChat')]), @@ -220,7 +226,7 @@ const FAILURE_SENTENCES = { managedAccountUnsupported: (context, _fact, _surface, say) => joinSentences([ say('managedAccountUnsupported'), - context.retryControl + context.retryControl || context.messageSubmitted === false ? say('chooseClaudeAccount') : context.command ? say('chooseClaudeAccountThenRunCommand', { command: context.command }) diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index b1257fb5c17c..aadf9e26fa49 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -725,7 +725,7 @@ describe('agentSessionRefusalCauseParts', () => { ) ) ).toBe( - "Claude isn't signed in. Run `claude` and sign in with /login, or choose an account in Claude Accounts settings." + "Claude isn't signed in. Run `claude auth login`, or choose an account in Claude Accounts settings." ) expect( agentSessionWriteNoticeEnglish( @@ -769,7 +769,7 @@ describe('agentSessionRefusalCauseParts', () => { it('writes the same sentences as before where no Retry stands beside them', () => { expect(agentSessionFailureSentence({ kind: 'notSignedIn' }, 'rejection')).toBe( - 'The agent is not signed in for the selected account. Sign in, then send your message again.' + 'The agent is not signed in. Sign in, then send your message again.' ) }) diff --git a/src/shared/agent-session-sign-in.test.ts b/src/shared/agent-session-sign-in.test.ts new file mode 100644 index 000000000000..ecc5612e73ff --- /dev/null +++ b/src/shared/agent-session-sign-in.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' +import { agentSessionFailureSentence } from './agent-session-failure-words' + +describe('agent-specific sign-in guidance', () => { + it.each([ + ['Missing key', 'Missing key. Then send your message again.'], + ['Missing key.', 'Missing key. Then send your message again.'], + ['Missing key!', 'Missing key! Then send your message again.'], + ['Missing key?', 'Missing key? Then send your message again.'], + ['"Missing key."', '"Missing key." Then send your message again.'], + ['(Missing key.)', '(Missing key.) Then send your message again.'], + ["('Missing key!')", "('Missing key!') Then send your message again."], + ['“Missing key?”', '“Missing key?” Then send your message again.'], + ['Missing key。', 'Missing key。Then send your message again.'], + ['Missing key!', 'Missing key!Then send your message again.'], + ['Missing key?', 'Missing key?Then send your message again.'] + ])('separates guidance after the provider detail %j', (detail, tail) => { + expect( + agentSessionFailureSentence( + { kind: 'notSignedIn', detail: { text: detail, audience: 'person' } }, + 'rejection', + { agentName: 'Pi' } + ) + ).toBe( + `Sign in to Pi by running \`pi\` and using \`/login\` on the computer running this chat. ${tail}` + ) + }) + + it('leaves an unpunctuated detail alone when no guidance follows it', () => { + const fact = { + kind: 'notSignedIn', + detail: { text: 'No API key found for anthropic', audience: 'person' } + } as const + const row = agentSessionFailureSentence(fact, 'row', { agentName: 'Pi' }) + expect(row.endsWith(fact.detail.text)).toBe(true) + expect( + agentSessionFailureSentence(fact, 'rejection', { agentName: 'Pi', retryControl: true }) + ).toBe(row) + }) + + it.each([ + ['Claude', 'claude auth login'], + ['Codex', 'codex login'], + ['Grok', 'grok login'], + ['OpenCode', 'opencode auth login'], + ['Pi', 'pi'], + ['OMP', 'Sign in to OMP.'] + ])( + 'names %s and its verified command without inventing account selection', + (agentName, command) => { + const fact = { + kind: 'notSignedIn', + detail: { text: 'The configured provider has no API key.', audience: 'person' } + } as const + const row = agentSessionFailureSentence(fact, 'row', { agentName }) + const rejection = agentSessionFailureSentence(fact, 'rejection', { agentName }) + expect(row).toContain(command) + expect(rejection).toContain(command) + expect(row).toContain(fact.detail.text) + expect(rejection).toContain(fact.detail.text) + expect(row).not.toContain('send your message again') + expect(row).not.toContain('selected account') + if (agentName === 'Pi') { + expect(row).toContain('`/login`') + } + } + ) + it('keeps diagnostic text literal and keeps log-only detail off the visible sentence', () => { + const detail = 'No API key found for {{agent}} $t(fake.key)' + expect( + agentSessionFailureSentence( + { kind: 'notSignedIn', detail: { text: detail, audience: 'person' } }, + 'rejection', + { agentName: 'Pi' } + ) + ).toContain(detail) + expect( + agentSessionFailureSentence( + { kind: 'notSignedIn', detail: { text: detail, audience: 'log' } }, + 'row', + { agentName: 'Pi' } + ) + ).not.toContain(detail) + }) + it('keeps command retry and managed-account advice', () => { + expect( + agentSessionFailureSentence({ kind: 'notSignedIn', account: 'managed' }, 'row', { + agentName: 'Claude' + }) + ).toBe("This Claude account isn't signed in. Sign in again in Claude Accounts settings.") + expect( + agentSessionFailureSentence({ kind: 'notSignedIn', account: 'managed' }, 'rejection', { + agentName: 'Codex', + command: 'clear' + }) + ).toBe( + "This Codex account isn't signed in. Sign in again in Codex Accounts settings. Run /clear again." + ) + }) +}) diff --git a/src/shared/agent-session-sign-in.ts b/src/shared/agent-session-sign-in.ts new file mode 100644 index 000000000000..1725e298bc8f --- /dev/null +++ b/src/shared/agent-session-sign-in.ts @@ -0,0 +1,16 @@ +import { TUI_AGENT_DISPLAY_NAMES } from './tui-agent-display-names' + +export const AGENT_SESSION_SIGN_IN = [ + { agent: 'claude', loginCommand: ['claude', 'auth', 'login'] }, + { agent: 'codex', loginCommand: ['codex', 'login'] }, + { agent: 'grok', loginCommand: ['grok', 'login'] }, + { agent: 'opencode', loginCommand: ['opencode', 'auth', 'login'] }, + { agent: 'pi', loginCommand: ['pi'], slashCommand: '/login' }, + { agent: 'omp', loginCommand: [] } +] as const + +export function agentSessionSignInFor(agent: string | undefined) { + return AGENT_SESSION_SIGN_IN.find( + (entry) => entry.agent === agent || TUI_AGENT_DISPLAY_NAMES[entry.agent] === agent + ) +} diff --git a/src/shared/agent-session-visible-failures.ts b/src/shared/agent-session-visible-failures.ts new file mode 100644 index 000000000000..32a9cbce25f6 --- /dev/null +++ b/src/shared/agent-session-visible-failures.ts @@ -0,0 +1,55 @@ +import { + readWholeAgentSessionFailureFact, + type AgentSessionFailureFact +} from './agent-session-failure' +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import { isStructuredAgentSessionStartFailureRow } from './structured-agent-session-start-failure-row-key' + +/** Loaded startup causes and authentication explanations already visible in the transcript. */ +export function agentSessionVisibleFailureFacts( + items: readonly AgentJournalRenderItem[] +): AgentSessionFailureFact[] { + const facts: AgentSessionFailureFact[] = [] + for (const item of items) { + if (item.body.kind === 'status') { + const fact = readWholeAgentSessionFailureFact(item.body.failure) + if ( + fact && + (isStructuredAgentSessionStartFailureRow(item.itemId) || fact.kind === 'notSignedIn') + ) { + facts.push(fact) + } + } + } + return facts +} + +/** Whether two facts are one failure: a start's row and the messages it rejected share one. */ +export function sameAgentSessionFailureFact( + a: AgentSessionFailureFact, + b: AgentSessionFailureFact +): boolean { + return ( + a.kind === b.kind && + a.account === b.account && + a.detail?.text === b.detail?.text && + a.detail?.audience === b.detail?.audience && + a.refusal?.code === b.refusal?.code && + a.refusal?.details?.reason === b.refusal?.details?.reason && + a.attachment?.reason === b.attachment?.reason && + a.attachment?.limit === b.attachment?.limit && + a.retry?.error === b.retry?.error && + a.retry?.status === b.retry?.status + ) +} + +/** Whether a loaded row already states this failure, comparing facts rather than wording. */ +export function agentSessionFailureStatedByRow( + failure: unknown, + statedFailures: readonly AgentSessionFailureFact[] +): boolean { + const fact = readWholeAgentSessionFailureFact(failure) + return ( + fact !== undefined && statedFailures.some((stated) => sameAgentSessionFailureFact(stated, fact)) + ) +} diff --git a/src/shared/structured-agent-session-status-block.ts b/src/shared/structured-agent-session-status-block.ts index 080f3c5de53b..de7bad597e1b 100644 --- a/src/shared/structured-agent-session-status-block.ts +++ b/src/shared/structured-agent-session-status-block.ts @@ -1,4 +1,4 @@ -import { readAgentSessionFailureFact } from './agent-session-failure' +import { readWholeAgentSessionFailureFact } from './agent-session-failure' import type { AgentJournalStatusItem, AgentJournalTurnScope } from './agent-session-journal-types' import { readAgentSessionOrcaStop } from './agent-session-orca-stop' import type { NativeChatTextBlock } from './native-chat-types' @@ -8,7 +8,7 @@ export function structuredAgentSessionStatusBlock( body: AgentJournalStatusItem, turnScope?: AgentJournalTurnScope ): NativeChatTextBlock { - const failure = readAgentSessionFailureFact(body.failure) + const failure = readWholeAgentSessionFailureFact(body.failure) const orcaStop = readAgentSessionOrcaStop(body.orcaStop) return { type: 'text',