Skip to content
Open
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
20 changes: 20 additions & 0 deletions apps/mobile/src/components/agents/message-bubble.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,26 @@ describe('MessageBubble failure footer', () => {
expect(findElementByType(tree, 'Button', p => p.accessibilityLabel === 'Retry')).toBeNull();
});

it('states a generic assistant failure once, without a detail line repeating the title', async () => {
const tree = await renderBubbleWithHandlers(
assistantMessageWithError('m-asst-laconic', 'APIError'),
{
onRetryMessage: vi.fn<(message: StoredMessage) => void>(),
}
);
expect(findText(tree, t => t === 'Response failed')).toBe(true);
expect(findText(tree, t => t === 'The response failed.')).toBe(false);
});

it('keeps the classified detail line for a known assistant error', async () => {
const tree = await renderBubbleWithHandlers(
assistantMessageWithError('m-asst-known', 'ProviderAuthError'),
{ onRetryMessage: vi.fn<(message: StoredMessage) => void>() }
);
expect(findText(tree, t => t === 'Response failed')).toBe(true);
expect(findText(tree, t => t === 'The provider rejected the request.')).toBe(true);
});

it('does not render the footer when no handler is supplied', async () => {
const tree = await renderBubbleWithHandlers(userMessage('m-nohandler'), {
deliveryState: { status: 'failed', error: 'nope', reason: 'exhausted' },
Expand Down
4 changes: 3 additions & 1 deletion apps/mobile/src/components/agents/message-bubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,9 @@ function MessageBubbleImpl({
>
{failure.title}
</Text>
<Text className="text-xs text-muted-foreground">{failure.detail}</Text>
{failure.detail !== null ? (
<Text className="text-xs text-muted-foreground">{failure.detail}</Text>
) : null}
<View className="flex-row gap-2">
{failure.canRetry && onRetryMessage ? (
<Button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,11 @@ describe('selectMessageFailure', () => {
expect(result?.canCopy).toBe(false);
});

it('falls back to the generic line for an unknown error name', () => {
it('adds no detail line for an unknown error name, which the title states', () => {
const result = selectMessageFailure({ info: assistantInfo('UnknownError') });
expect(result?.detail).toBe('The response failed.');
expect(result?.detail).not.toContain('RAW_PROVIDER_TEXT');
expect(result?.title).toBe('Response failed');
expect(result?.detail).toBeNull();
expect(JSON.stringify(result)).not.toContain('RAW_PROVIDER_TEXT');
});

it('sets canRetry false only for NON_RETRYABLE_ASSISTANT_ERRORS', () => {
Expand Down
18 changes: 13 additions & 5 deletions apps/mobile/src/components/agents/message-failure-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,13 @@ export const NON_RETRYABLE_ASSISTANT_ERRORS: readonly string[] = [
];

/**
* Fixed, safe copy for a known assistant error name. Unknown names fall back
* to the generic line. Never surfaces `error.data` or provider message text.
* Fixed, safe copy for a known assistant error name. An unknown name has no
* line of its own: the title already states that the response failed, so the
* footer adds no detail rather than repeating the title in a sentence
* (`messageFailure.assistantFailed` is the fixed footer's line, not the
* message row's). Never surfaces `error.data` or provider message text.
*/
function assistantDetail(errorName: string): string {
function assistantDetail(errorName: string): string | null {
switch (errorName) {
case 'ProviderAuthError': {
return i18n.t('agentChat.messageFailure.assistantProviderRejected');
Expand All @@ -40,15 +43,20 @@ function assistantDetail(errorName: string): string {
return i18n.t('agentChat.messageFailure.assistantContextOverflow');
}
default: {
return i18n.t('agentChat.messageFailure.assistantFailed');
return null;
}
}
}

export type MessageFailure = {
kind: 'delivery' | 'assistant';
title: string;
detail: string;
/**
* The explanation line under the title, or `null` when the title alone says
* it (an assistant failure with no classified reason). The footer then shows
* one statement plus the action rather than the same sentence twice.
*/
detail: string | null;
/**
* The untranslated transport text for a failed delivery ("Unauthorized:
* Unauthorized"), for the copy action only — same split as the terminal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import { type MessageDeliveryState, type StoredMessage } from '@kilocode/cloud-agent-sdk';
import {
countInFlightMessages,
lastVisibleMessageFailure,
resolveRetryPrompt,
retryFailedMessage,
} from './session-detail-content-helpers';
Expand Down Expand Up @@ -118,3 +119,131 @@ describe('resolveRetryPrompt', () => {
expect(resolveRetryPrompt(assistant, [assistant])).toBeNull();
});
});

describe('lastVisibleMessageFailure', () => {
function assistantMessageWithText(id: string): StoredMessage {
const message = assistantMessage(id);
// `mergeSessionTranscript` keeps a message only when a part renders content,
// so a row that states a failure needs a part the transcript renders.
message.parts = [
{ id: `${id}-text`, sessionID: 'ses_1', messageID: id, type: 'text', text: 'reply' },
] as typeof message.parts;
return message;
}

function assistantMessageWithError(id: string, errorName: string): StoredMessage {
const message = assistantMessageWithText(id);
(message.info as { error?: { name: string; data: unknown } }).error = {
name: errorName,
data: { message: 'raw' },
};
return message;
}

const noPending = new Map<string, MessageDeliveryState>();
const nothingCanceled = new Map<string, StoredMessage>();

it('returns null when the last row has no failure', () => {
const messages: StoredMessage[] = [userMessage('m1'), assistantMessageWithText('m2')];
expect(
lastVisibleMessageFailure({
displayedMessages: messages,
messages,
pendingMessages: noPending,
canceledQueuedMessages: nothingCanceled,
})
).toBeNull();
});

it('returns the assistant failure the last row renders with a Retry', () => {
const messages: StoredMessage[] = [
userMessage('m1'),
assistantMessageWithError('m2', 'APIError'),
];
const failure = lastVisibleMessageFailure({
displayedMessages: messages,
messages,
pendingMessages: noPending,
canceledQueuedMessages: nothingCanceled,
});
expect(failure?.kind).toBe('assistant');
expect(failure?.title).toBe('Response failed');
expect(failure?.detail).toBeNull();
});

it('returns null for an assistant failure with no preceding user row (no Retry)', () => {
const messages: StoredMessage[] = [assistantMessageWithError('m1', 'APIError')];
expect(
lastVisibleMessageFailure({
displayedMessages: messages,
messages,
pendingMessages: noPending,
canceledQueuedMessages: nothingCanceled,
})
).toBeNull();
});

it('returns the delivery failure the last row renders', () => {
const messages: StoredMessage[] = [userMessage('m1')];
const pendingMessages = new Map<string, MessageDeliveryState>([
['m1', { status: 'failed', error: 'nope', reason: 'exhausted' }],
]);
const failure = lastVisibleMessageFailure({
displayedMessages: messages,
messages,
pendingMessages,
canceledQueuedMessages: nothingCanceled,
});
expect(failure?.kind).toBe('delivery');
expect(failure?.title).toBe('Failed to deliver');
});

it('ignores a cancelled queued row', () => {
const messages: StoredMessage[] = [userMessage('m1')];
const pendingMessages = new Map<string, MessageDeliveryState>([
['m1', { status: 'failed', error: 'nope', reason: 'exhausted' }],
]);
expect(
lastVisibleMessageFailure({
displayedMessages: messages,
messages,
pendingMessages,
canceledQueuedMessages: new Map<string, StoredMessage>([['m1', userMessage('m1')]]),
})
).toBeNull();
});

it('ignores a failure that is not the last row', () => {
const messages: StoredMessage[] = [
userMessage('m1'),
assistantMessageWithError('m2', 'APIError'),
assistantMessageWithText('m3'),
];
expect(
lastVisibleMessageFailure({
displayedMessages: messages,
messages,
pendingMessages: noPending,
canceledQueuedMessages: nothingCanceled,
})
).toBeNull();
});

it('falls back past a message the transcript drops, which states no failure', () => {
// `mergeSessionTranscript` drops an assistant row whose parts render
// nothing (no delivery failure keeps it), so it owns no row and cannot
// state a failure. Treating it as the last row suppressed the footer's own
// line and left the failure with no surface at all.
const dropped = assistantMessageWithError('m2', 'APIError');
dropped.parts = [];
const messages: StoredMessage[] = [userMessage('m1'), dropped];
expect(
lastVisibleMessageFailure({
displayedMessages: messages,
messages,
pendingMessages: noPending,
canceledQueuedMessages: nothingCanceled,
})
).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { type MessageDeliveryState, type StoredMessage } from '@kilocode/cloud-agent-sdk';

import { type MessageFailure, selectMessageFailure } from './message-failure-state';
import { firstHumanText } from './part-types';
import { transcriptRendersMessage } from './session-transcript';

/**
* Counts pending messages that are still in flight. A terminal delivery
Expand Down Expand Up @@ -61,3 +63,69 @@ export function resolveRetryPrompt(
}
return null;
}

type LastVisibleFailureInput = {
/** The rendered list, in order. Its last entry the transcript renders owns
* the row above the footer. */
displayedMessages: readonly StoredMessage[];
/** The full list, for the retry prompt's preceding-user search. */
messages: readonly StoredMessage[];
pendingMessages: ReadonlyMap<string, MessageDeliveryState>;
/** Canceled rows kept locally, keyed by message id. */
canceledQueuedMessages: ReadonlyMap<string, StoredMessage>;
};

/**
* The failure footer the transcript's last message row renders, or `null` when
* that row renders none. The fixed footer's status indicator uses this to tell
* a failure the row already states (which it must not repeat) from a
* session-level error the row does not carry (which only the footer can show).
*
* Mirrors MessageBubble's own gate: a footer needs a failure and a wired
* action. Copy to composer is always wired on a delivery failure, so only an
* assistant failure can be left without one (no preceding user row).
*/
export function lastVisibleMessageFailure({
displayedMessages,
messages,
pendingMessages,
canceledQueuedMessages,
}: LastVisibleFailureInput): MessageFailure | null {
// The transcript drops a message whose parts render nothing (unless its
// delivery failed and its typed footer is the row's surface), so such a
// message owns no row and cannot state a failure. Walk back to the last row
// `mergeSessionTranscript` actually renders: reading `displayedMessages.at(-1)`
// let a dropped failure suppress the footer's own line and leave the failure
// with no surface at all.
const last = lastRenderedMessage(displayedMessages, pendingMessages);
if (last === undefined) {
return null;
}
const deliveryState =
last.info.role === 'user' && !canceledQueuedMessages.has(last.info.id)
? pendingMessages.get(last.info.id)
: undefined;
const failure = selectMessageFailure({ deliveryState, info: last.info });
if (failure === null || failure.kind === 'delivery') {
return failure;
}
return resolveRetryPrompt(last, messages) !== null ? failure : null;
}

/**
* The last message `mergeSessionTranscript` renders, or `undefined` when it
* renders none. A dropped message is invisible, so the row above the footer is
* the one before it.
*/
function lastRenderedMessage(
displayedMessages: readonly StoredMessage[],
pendingMessages: ReadonlyMap<string, MessageDeliveryState>
): StoredMessage | undefined {
for (let i = displayedMessages.length - 1; i >= 0; i -= 1) {
const message = displayedMessages[i];
if (message !== undefined && transcriptRendersMessage(message, pendingMessages)) {
return message;
}
}
return undefined;
}
Loading
Loading