Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ import { agentLaunchPaneNoticeText } from './agent-launch-pane-notice-text'
*/
export function AgentLaunchPaneNotice({
refusal,
agentId,
tabId
}: {
refusal: AgentLaunchPaneOutcome
agentId?: string | null
tabId: string
}): React.JSX.Element {
const prompt = agentLaunchPanePrompt(tabId)
Expand All @@ -22,7 +24,7 @@ export function AgentLaunchPaneNotice({
data-agent-launch-pane-notice={refusal.kind}
className="pointer-events-auto absolute inset-x-3 bottom-3 z-50 flex items-start justify-between gap-3 rounded-md border border-border bg-popover px-3.5 py-2.5 text-xs text-popover-foreground"
>
<span className="min-w-0">{agentLaunchPaneNoticeText(refusal)}</span>
<span className="min-w-0">{agentLaunchPaneNoticeText(refusal, agentId)}</span>
{prompt ? (
<Button
variant="outline"
Expand All @@ -42,11 +44,13 @@ export function AgentLaunchPaneNotice({
/** The notice in the active pane, when that pane's error is a launch's. */
export function AgentLaunchPaneNoticePortal({
refusal,
agentId,
isActive,
pane,
tabId
}: {
refusal: AgentLaunchPaneOutcome | null
agentId?: string | null
isActive: boolean
pane: { id: number; container: HTMLElement } | null | undefined
tabId: string
Expand All @@ -55,7 +59,7 @@ export function AgentLaunchPaneNoticePortal({
return null
}
return createPortal(
<AgentLaunchPaneNotice refusal={refusal} tabId={tabId} />,
<AgentLaunchPaneNotice refusal={refusal} agentId={agentId} tabId={tabId} />,
pane.container,
`agent-launch-pane-notice-${pane.id}`
)
Expand Down
106 changes: 106 additions & 0 deletions src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ vi.mock('@/lib/client-environment-info', () => ({
}))

import { i18n } from '@/i18n/i18n'
import { appendPaneTerminalError, terminalErrorForPane } from './terminal-error-accumulation'

import {
TerminalErrorToast,
Expand Down Expand Up @@ -69,6 +70,111 @@ describe('isSshReconnectOwnedTerminalError', () => {
})

describe('humanizeTerminalError', () => {
it('bounds startup row work linearly through accumulated toast renders at N and 10N', () => {
const tokenMatches = vi.spyOn(String.prototype, 'match')
const translations = vi.spyOn(i18n, 't')
try {
for (const kind of ['startup', 'mixed', 'unmatched'] as const) {
let view: ReturnType<typeof render> | undefined
for (const rowCount of [2, 20]) {
const rows = Array.from({ length: rowCount }, (_, index) =>
kind === 'startup' || (kind === 'mixed' && index % 2 === 0)
? `Attempt ${index}: agent_session_exited_during_start`
: `Paste failed ${index}.`
)
const midpoint = rowCount / 2
const errors = appendPaneTerminalError(
appendPaneTerminalError({}, 1, rows.slice(0, midpoint).join('\n')),
1,
rows.slice(midpoint).join('\n')
)
const error = terminalErrorForPane(null, errors, 1)
expect(error).toBe(rows.join('\n'))
if (error === null) {
throw new Error('Accumulated error missing')
}
tokenMatches.mockClear()
translations.mockClear()
const toast = React.createElement(TerminalErrorToast, {
error,
agentId: 'codex',
paneOnClient: false,
onDismiss: vi.fn()
})
if (view) {
view.rerender(toast)
} else {
view = render(toast)
}

const tokenChecks = tokenMatches.mock.calls.filter(
([pattern]) =>
pattern instanceof RegExp && pattern.source.includes('agent_(?:session|launch)_')
).length
const startupTranslations = translations.mock.calls.filter(
([key]) =>
typeof key === 'string' &&
key.startsWith('auto.components.terminal.pane.AgentLaunchPaneNotice.')
).length
const matchingRows = kind === 'startup' ? rowCount : kind === 'mixed' ? midpoint : 0
// Classification and adaptation each visit a row at most once; known exits use two templates.
expect(tokenChecks).toBeGreaterThanOrEqual(rowCount)
expect(tokenChecks).toBeLessThanOrEqual(2 * rowCount)
if (kind === 'startup') {
expect(tokenChecks).toBe(2 * rowCount)
}
expect(startupTranslations).toBe(2 * matchingRows)
expect(
view.container.textContent?.match(/Codex exited while starting\./g) ?? []
).toHaveLength(matchingRows)
expect(view.container.querySelector('a') !== null).toBe(kind !== 'startup')
expect(view.container.textContent).not.toContain('agent_session_exited_during_start')
}
view?.unmount()
}
} finally {
tokenMatches.mockRestore()
translations.mockRestore()
}
})

it.each([false, true])(
'explains a known exit and retains reporting for independent errors: %s',
(mixed) => {
const code = 'agent_session_exited_during_start'
const footer = '---\nOrca: 1.4.232\nOS: win32 10.0 (x64)\nShell: cmd.exe'
const view = render(
React.createElement(TerminalErrorToast, {
error: `Error invoking remote method 'pty:spawn': Error: ${code}${mixed ? '\nPaste failed.' : ''}\n\n${footer}`,
agentId: 'codex',
paneOnClient: false,
onDismiss: vi.fn()
})
)
const text = view.container.textContent ?? ''
expect(text).toContain(
'Codex exited while starting. Run it in a terminal in this folder to see why, then try again.'
)
expect(text).not.toContain(code)
if (mixed) {
expect(text).toContain('Paste failed.')
}
expect(view.container.querySelectorAll('a')).toHaveLength(mixed ? 1 : 0)
}
)

it.each([
'agent_session_operation_unknown',
'agent_session_claim_unavailable',
'agent_launch_future_failure'
])('preserves uncertainty for a startup string without proof of failure: %s', (code) => {
const text = humanizeTerminalError(
`Error invoking remote method 'pty:spawn': Error: ${code}`,
'codex'
)
expect(text).toBe("Couldn't confirm Codex started. Check its terminal before trying again.")
})

it('replaces the pane-owner-unverified code with actionable copy', () => {
const humanized = humanizeTerminalError('terminal_pane_owner_unverified')
expect(humanized).not.toContain('terminal_pane_owner_unverified')
Expand Down
12 changes: 9 additions & 3 deletions src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
isFolderWorkspacePathError
} from '@/lib/folder-workspace-path-status'
import { Button } from '@/components/ui/button'
import { agentLaunchErrorText, isAgentLaunchOnlyError } from './agent-launch-pane-notice-text'
import {
hasClientEnvironmentFooter,
stripClientEnvironmentFooter
Expand Down Expand Up @@ -153,11 +154,13 @@ function isSshHostStateTerminalError(error: string): boolean {
}

/** Swaps raw daemon-boundary codes for copy a user can act on. */
export function humanizeTerminalError(error: string): string {
export function humanizeTerminalError(error: string, agentId?: string | null): string {
if (isSshSignInRequiredTerminalError(error)) {
return describeSshSignInRequiredTerminalError(error)
}
let humanized = humanizeFolderWorkspacePathErrors(localizeTerminalSpawnHints(error))
let humanized = humanizeFolderWorkspacePathErrors(
localizeTerminalSpawnHints(agentLaunchErrorText(error, agentId) ?? error)
)
if (humanized.includes(PANE_OWNER_UNVERIFIED_MARKER)) {
const explanation = isPaneOwnerUnverifiedError(humanized)
? translate(
Expand Down Expand Up @@ -220,13 +223,15 @@ export function humanizeTerminalError(error: string): string {

export function TerminalErrorToast({
error,
agentId,
paneOnClient = true,
onDismiss,
onRestartDaemon,
onRetry,
onOpenNewTerminal
}: {
error: string
agentId?: string | null
/** False for a pane on an SSH or remote host, or one whose host is not yet known. */
paneOnClient?: boolean
onDismiss: () => void
Expand All @@ -245,9 +250,10 @@ export function TerminalErrorToast({
!ssh &&
!paneOwnerUnverified &&
!showDaemonRestart &&
!isAgentLaunchOnlyError(error) &&
!isExplainedTerminalError(error) &&
!isFolderWorkspacePathOnlyError(error)
const humanizedError = humanizeTerminalError(error)
const humanizedError = humanizeTerminalError(error, agentId)
// Why: the toast appends its own linked request, so the host's plain-text one would repeat it.
const displayError = showIssueLink
? withoutTerminalSpawnIssueRequest(humanizedError)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export function TerminalPaneSurface({
controller: TerminalPaneController
}): React.JSX.Element {
const {
activeAgentId,
activePane,
activePaneCanContinueInNewSession,
activePaneCanResumeInChat,
Expand Down Expand Up @@ -188,6 +189,7 @@ export function TerminalPaneSurface({
<TerminalPaneCodexRestartPortals controller={controller} />
<AgentLaunchPaneNoticePortal
refusal={visibleLaunchRefusal}
agentId={activeAgentId}
isActive={isActive}
pane={activePane}
tabId={tabId}
Expand All @@ -198,6 +200,7 @@ export function TerminalPaneSurface({
? createPortal(
<TerminalErrorToast
error={visibleTerminalError}
agentId={activeAgentId}
paneOnClient={paneOnClient}
onDismiss={dismissTerminalError}
onRestartDaemon={() => daemonActions.setPending('restart')}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { translate } from '@/i18n/i18n'
import type { AgentLaunchPaneOutcome } from '../../../../shared/agent-launch-pane-verdict'
import { stripClientEnvironmentFooter } from '../../../../shared/client-environment-info'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import { tuiAgentDisplayName } from '../../../../shared/tui-agent-display-names'

/** What the pane shows, if its tab keeps a final launch outcome for this leaf. */
export function agentLaunchPaneOutcomeForLeaf(
Expand All @@ -12,39 +14,94 @@ export function agentLaunchPaneOutcomeForLeaf(
}

/** The recorded reason in words a user can act on; null when it names nothing they can fix. */
function notStartedReason(code: string): string | null {
function notStartedReason(code: string, agent: string): string | null {
if (code.includes('ENOENT')) {
return translate(
'auto.components.terminal.pane.AgentLaunchPaneNotice.commandNotFound',
"Its command wasn't found on this machine."
"{{agent}} couldn't start because its command wasn't found on this machine.",
{ agent }
)
}
if (code === 'agent_session_exited_during_start') {
return translate(
'auto.components.terminal.pane.AgentLaunchPaneNotice.exitedDuringStart',
'It exited while starting.'
'{{agent}} exited while starting.',
{ agent }
)
}
if (code === 'worktree_not_found') {
return translate(
'auto.components.terminal.pane.AgentLaunchPaneNotice.workspaceGone',
'Its workspace is no longer available.'
"{{agent}} couldn't start because its workspace is no longer available.",
{ agent }
)
}
return null
}

export function agentLaunchPaneNoticeText(refusal: AgentLaunchPaneOutcome): string {
export function agentLaunchPaneNoticeText(
refusal: AgentLaunchPaneOutcome,
agentId?: string | null
): string {
const agent =
tuiAgentDisplayName(agentId ?? '') ??
translate('auto.components.terminal.pane.AgentLaunchPaneNotice.agent', 'The agent')
if (refusal.kind === 'unconfirmed') {
return translate(
'auto.components.terminal.pane.AgentLaunchPaneNotice.unconfirmed',
"Couldn't confirm the agent started."
"Couldn't confirm {{agent}} started. Check its terminal before trying again.",
{ agent }
)
}
const lead = translate(
'auto.components.terminal.pane.AgentLaunchPaneNotice.notStarted',
"The agent couldn't start."
)
const reason = notStartedReason(refusal.code)
return reason ? `${lead} ${reason}` : lead
const reason =
notStartedReason(refusal.code, agent) ??
translate(
'auto.components.terminal.pane.AgentLaunchPaneNotice.notStarted',
"{{agent}} couldn't start.",
{ agent }
)
const action =
refusal.code === 'worktree_not_found'
? translate(
'auto.components.terminal.pane.AgentLaunchPaneNotice.restoreWorkspace',
'Restore the workspace folder or open an available workspace, then try again.'
)
: translate(
'auto.components.terminal.pane.AgentLaunchPaneNotice.tryInTerminal',
'Run it in a terminal in this folder to see why, then try again.'
)
return `${reason} ${action}`
}

function startupCode(line: string): string | undefined {
return line.match(/\b(?:agent_(?:session|launch)_[a-z0-9_]+|worktree_not_found)\b/)?.[0]
}

export function isAgentLaunchOnlyError(error: string): boolean {
const lines = stripClientEnvironmentFooter(error)
.split('\n')
.filter((line) => line.trim().length > 0)
return lines.length > 0 && lines.every((line) => startupCode(line) !== undefined)
}

/** Older hosts send startup reasons as strings, sometimes wrapped in an IPC error. */
export function agentLaunchErrorText(error: string, agentId?: string | null): string | null {
let found = false
const text = error
.split('\n')
.map((line) => {
const code = startupCode(line)
Comment thread
brennanb2025 marked this conversation as resolved.
if (!code) {
return line
}
found = true
// Only these producer contracts prove failure without an attempt's typed host outcome.
const outcome: AgentLaunchPaneOutcome =
code === 'agent_session_exited_during_start' || code === 'worktree_not_found'
? { kind: 'not-started', code }
: { kind: 'unconfirmed' }
return agentLaunchPaneNoticeText(outcome, agentId)
})
.join('\n')
return found ? text : null
}
Loading
Loading