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 @@ -228,7 +228,10 @@ describe('ChildSessionSheet recovery', () => {
const list = host(renderer.root, 'FlashList');
await sheet.sync({ sessionError: 'Runtime failure' });
expect(textValues(renderer.root)).toEqual(
expect.arrayContaining(['Connection failed. Please retry in a moment.', 'Runtime failure'])
expect.arrayContaining([
'Connection failed. Please retry in a moment.',
i18n.t('agentChat.messageFailure.assistantFailed'),
])
);
sheet.fetchPage.mockResolvedValueOnce(historyPage([], 'older-cursor'));
await sheet.retry();
Expand All @@ -240,13 +243,13 @@ describe('ChildSessionSheet recovery', () => {
const older = Promise.withResolvers<SessionSnapshotPageOutcome | null>();
sheet.fetchPage.mockReturnValueOnce(older.promise);
await sheet.retry();
expect(textValues(renderer.root)).toContain('Runtime failure');
expect(textValues(renderer.root)).toContain(i18n.t('agentChat.messageFailure.assistantFailed'));
expect(textValues(renderer.root)).toContain(i18n.t('agentChat.olderMessages.couldNotLoad'));
expect(renderer.root.findAllByType(QueryError)).toHaveLength(0);
older.resolve(historyPage([makeAssistantMessage('m0', 'Older recovered row')]));
await sheet.settle();
expect(textValues(list)).toEqual(['Older recovered row', 'child text']);
expect(textValues(renderer.root)).toContain('Runtime failure');
expect(textValues(renderer.root)).toContain(i18n.t('agentChat.messageFailure.assistantFailed'));
expect(textValues(renderer.root)).not.toContain('Retry');
expect(host(renderer.root, 'FlashList')).toBe(list);
});
Expand Down Expand Up @@ -299,6 +302,8 @@ describe('ChildSessionSheet recovery', () => {
...buildProps({ getChildMessages: () => [], hydrationState: readyState }),
sessionError: 'Runtime failure',
});
// The sheet's own failure screen (child-session-sheet.tsx:219-223) is not
// the transcript status slot, so it still names the child's runtime error.
expect(textValues(renderer.root)).toContain('Runtime failure');
expect(textValues(renderer.root)).not.toContain('Retry');
});
Expand Down
54 changes: 54 additions & 0 deletions apps/mobile/src/components/agents/message-bubble.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,60 @@ describe('MessageBubble user text join', () => {
);
});

describe('MessageBubble assistant treatment', () => {
// The user treatment (Bubble side="user": right-aligned accent tile) is
// reserved for a user-role info. An assistant message at any point of its
// life — before the first token, mid-stream, completed — and a message whose
// role is missing or unknown must never take it.
async function findUserBubble(tree: unknown): Promise<{ side: unknown } | null> {
const { Bubble: MockBubble } = await import('@/components/ui/bubble');
const element = findElementByTypeFn(tree, MockBubble);
return element ? { side: element.props.side } : null;
}

it('renders streaming assistant text through the part renderer, never the user bubble', async () => {
const { PartRenderer: MockPartRenderer } = await import('./part-renderer');
const message = assistantMessage('m-treatment-streaming');
message.parts = [
{
id: 'm-treatment-streaming-text',
sessionID: 'ses_1',
messageID: 'm-treatment-streaming',
type: 'text',
text: 'AI Gateway abuse classification is an external, fail-mostly-open integration',
},
] as typeof message.parts;

const tree = await renderBubble(message);
expect(await findUserBubble(tree)).toBeNull();
expect(findElementByTypeFn(tree, MockPartRenderer)).not.toBeNull();
});

it('renders an assistant message with no content yet without the user bubble', async () => {
// Before the first token the assistant row has no visible parts; the
// transcript drops it, and the bubble itself must not render user ink.
const tree = await renderBubble(assistantMessage('m-treatment-empty'));
expect(await findUserBubble(tree)).toBeNull();
});

it('renders a message with an undefined role without the user bubble', async () => {
// A payload quirk must never fall back to the user treatment: the missing
// role renders the assistant path (no bubble), never Bubble side="user".
const message = assistantMessage('m-treatment-unknown-role');
(message.info as { role?: string }).role = undefined;

const tree = await renderBubble(message);
expect(await findUserBubble(tree)).toBeNull();
});

it('reserves Bubble side="user" for a user-role message', async () => {
const tree = await renderBubble(userMessage('m-treatment-user'));
const userBubble = await findUserBubble(tree);
expect(userBubble).not.toBeNull();
expect(userBubble?.side).toBe('user');
});
});

describe('MessageBubble in-bubble text selection context', () => {
it('wraps the assistant parts view in InMessageBubbleContext.Provider with value true', async () => {
const { InMessageBubbleContext } = await import('./bubble-text-selection-context');
Expand Down
22 changes: 20 additions & 2 deletions apps/mobile/src/components/agents/message-details-content.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { type StoredMessage } from '@kilocode/cloud-agent-sdk';
import { type MessageDeliveryState, type StoredMessage } from '@kilocode/cloud-agent-sdk';

import { i18n } from '@/i18n';
import { type SessionModelOption } from '@/lib/hooks/use-session-model-options';
import { dateTimeFormat } from '@/lib/intl-cache';

import { collectCopyableText } from './collect-copyable-text';
import { formatCost } from './context-usage-display';
import { selectMessageFailure } from './message-failure-state';
import { resolveMessageDisplayModel } from './message-model-label';
import { isPartStreaming } from './part-types';
import { friendlyModelName } from './session-model-display';
Expand All @@ -22,25 +23,40 @@ type MessageDetailsContent = {
costLabel: string | null;
tokenRows: MessageDetailsTokenRow[] | null;
copyableText: string | null;
/**
* What the sheet's Copy action copies: the same text plus, on a failed
* delivery, the untranslated transport text. The select-text view renders
* `copyableText`, so the raw string stays behind the copy action only.
*/
copyText: string | null;
canSelectText: boolean;
};

/**
* Pure projection of a StoredMessage into the details-sheet fields.
* Unit-tested for happy / empty visibility rules; the sheet component
* only renders this shape.
*
* When the message's delivery failed, the raw transport text joins the Copy
* payload (the failure footer never renders it): the sheet's Copy action is
* where the untranslated original stays reachable, as with a terminal error.
*/
export function getMessageDetailsContent(
message: StoredMessage,
modelOptions: SessionModelOption[]
modelOptions: SessionModelOption[],
deliveryState?: MessageDeliveryState
): MessageDetailsContent {
const roleLabel =
message.info.role === 'user'
? i18n.t('agentChat.messageDetails.roleUser')
: i18n.t('agentChat.messageDetails.roleAssistant');
const sentTimeLabel = formatMessageSentTime(message.info.time.created);
const copyable = collectCopyableText(message);
const failureCopyDetail =
selectMessageFailure({ deliveryState, info: message.info })?.copyDetail ?? '';
const copyableText = copyable.length > 0 ? copyable : null;
const fullCopy = [copyable, failureCopyDetail].filter(text => text.length > 0).join('\n\n');
const copyText = fullCopy.length > 0 ? fullCopy : null;
const canSelectText =
copyableText !== null &&
!message.parts.some(part => isPartInFlightForSelect(part, message.info.role));
Expand All @@ -53,6 +69,7 @@ export function getMessageDetailsContent(
costLabel: null,
tokenRows: null,
copyableText,
copyText,
canSelectText,
};
}
Expand Down Expand Up @@ -81,6 +98,7 @@ export function getMessageDetailsContent(
]
: null,
copyableText,
copyText,
canSelectText,
};
}
Expand Down
28 changes: 26 additions & 2 deletions apps/mobile/src/components/agents/message-details-sheet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,30 @@ describe('getMessageDetailsContent — canSelectText', () => {
expect(content.copyableText).toBeNull();
expect(content.canSelectText).toBe(false);
});

it('appends the raw transport text to the copy payload only when delivery failed', () => {
// The failure footer never renders transport text; the details Copy action
// is where the untranslated original stays reachable (terminal-error split).
// The select-text view renders `copyableText`, so it never shows the raw
// string either.
const message = storedMessage(userInfo(), [textPart('Continue')]);
const content = getMessageDetailsContent(message, catalogOptions, {
status: 'failed',
error: 'Unauthorized: Unauthorized',
reason: 'exhausted',
});
expect(content.copyText).toBe('Continue\n\nUnauthorized: Unauthorized');
expect(content.copyableText).toBe('Continue');

// Without a failed delivery state the copy payload stays the prompt alone.
const delivered = getMessageDetailsContent(message, catalogOptions);
expect(delivered.copyText).toBe('Continue');

// A queued state is not a failure either.
const queued = getMessageDetailsContent(message, catalogOptions, { status: 'queued' });
expect(queued.copyText).toBe('Continue');
expect(queued.copyableText).toBe('Continue');
});
});

describe('MessageDetailsSheet copy button wiring (retryable unhappy)', () => {
Expand All @@ -399,10 +423,10 @@ describe('MessageDetailsSheet copy button wiring (retryable unhappy)', () => {
// Sheet onPress wires to this handler (see message-details-sheet.tsx).
const message = storedMessage(userInfo(), [textPart('copy me')]);
const content = getMessageDetailsContent(message, catalogOptions);
expect(content.copyableText).toBe('copy me');
expect(content.copyText).toBe('copy me');

const { handleMessageDetailsCopy } = await import('./message-details-copy');
handleMessageDetailsCopy(content.copyableText);
handleMessageDetailsCopy(content.copyText);

expect(performCopyMock).toHaveBeenCalledWith('copy me');
expect(performCopyMock).toHaveBeenCalledTimes(1);
Expand Down
13 changes: 8 additions & 5 deletions apps/mobile/src/components/agents/message-details-sheet.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type StoredMessage } from '@kilocode/cloud-agent-sdk';
import { type MessageDeliveryState, type StoredMessage } from '@kilocode/cloud-agent-sdk';
import { useMutation } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { Alert, Pressable, ScrollView, View } from 'react-native';
Expand Down Expand Up @@ -31,6 +31,8 @@ type MessageDetailsSheetProps = {
visible: boolean;
message: StoredMessage | null;
modelOptions: SessionModelOption[];
/** The selected message's delivery state; a failed one adds the transport detail to Copy. */
deliveryState?: MessageDeliveryState;
onClose: () => void;
canCancelQueued?: boolean;
isCancelingQueued?: boolean;
Expand All @@ -43,6 +45,7 @@ export function MessageDetailsSheet({
visible,
message,
modelOptions,
deliveryState,
onClose,
canCancelQueued = false,
isCancelingQueued = false,
Expand All @@ -57,8 +60,8 @@ export function MessageDetailsSheet({
const [reportedMessageId, setReportedMessageId] = useState<string | null>(null);
const [selectVisible, setSelectVisible] = useState(false);
const content = useMemo(
() => (message ? getMessageDetailsContent(message, modelOptions) : null),
[message, modelOptions]
() => (message ? getMessageDetailsContent(message, modelOptions, deliveryState) : null),
[message, modelOptions, deliveryState]
);

const reportMutation = useMutation(
Expand Down Expand Up @@ -92,7 +95,7 @@ export function MessageDetailsSheet({
}, [visible]);

const handleCopy = () => {
handleMessageDetailsCopy(content?.copyableText);
handleMessageDetailsCopy(content?.copyText);
};

const handleReport = () => {
Expand Down Expand Up @@ -161,7 +164,7 @@ export function MessageDetailsSheet({

{content ? (
<ScrollView className="flex-1" contentContainerClassName="px-6 pb-6 pt-2">
{content.copyableText ? (
{content.copyText ? (
<View className="mb-6 gap-2">
<Pressable
onPress={handleCopy}
Expand Down
16 changes: 16 additions & 0 deletions apps/mobile/src/components/agents/message-failure-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,18 +74,34 @@ describe('selectMessageFailure', () => {
expect(result?.kind).toBe('delivery');
expect(result?.title).toBe('Failed to deliver');
expect(result?.detail).toBe(detail);
expect(result?.title).not.toContain('RAW_TRANSPORT_TEXT');
expect(result?.detail).not.toContain('RAW_TRANSPORT_TEXT');
// The raw transport text stays reachable behind the copy action only.
expect(result?.copyDetail).toBe('RAW_TRANSPORT_TEXT');
expect(result?.canRetry).toBe(true);
expect(result?.canCopy).toBe(true);
}
});

it('exposes no copy detail when the delivery failure carries no transport text', () => {
const result = selectMessageFailure({
info: userInfo(),
deliveryState: { status: 'failed', error: '', reason: 'exhausted' },
});
expect(result?.copyDetail).toBe('');
});
});

describe('assistant', () => {
it('returns null for an assistant info with no error', () => {
expect(selectMessageFailure({ info: assistantInfoWithoutError() })).toBeNull();
});

it('never carries a copy detail for an assistant row', () => {
const result = selectMessageFailure({ info: assistantInfo('APIError') });
expect(result?.copyDetail).toBe('');
});

it('derives fixed copy from a known error name and never emits provider text', () => {
const result = selectMessageFailure({ info: assistantInfo('ProviderAuthError') });
expect(result).not.toBeNull();
Expand Down
9 changes: 9 additions & 0 deletions apps/mobile/src/components/agents/message-failure-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ export type MessageFailure = {
kind: 'delivery' | 'assistant';
title: string;
detail: string;
/**
* The untranslated transport text for a failed delivery ("Unauthorized:
* Unauthorized"), for the copy action only — same split as the terminal
* error's untranslated original. Never rendered; empty for an assistant
* failure, whose `error.data` is provider text with no diagnostic value.
*/
copyDetail: string;
canRetry: boolean;
canCopy: boolean;
};
Expand All @@ -64,6 +71,7 @@ export function selectMessageFailure(input: {
kind: 'delivery',
title: i18n.t('agentChat.messageFailure.deliveryTitle'),
detail: i18n.t(DELIVERY_DETAIL_KEY_BY_REASON[deliveryState.reason]),
copyDetail: deliveryState.error,
canRetry: true,
canCopy: true,
};
Expand All @@ -75,6 +83,7 @@ export function selectMessageFailure(input: {
kind: 'assistant',
title: i18n.t('agentChat.messageFailure.assistantTitle'),
detail: assistantDetail(errorName),
copyDetail: '',
canRetry: !NON_RETRYABLE_ASSISTANT_ERRORS.includes(errorName),
canCopy: false,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { type MessageDeliveryState, type StoredMessage } from '@kilocode/cloud-a
import {
countInFlightMessages,
resolveRetryPrompt,
retryMessageAndClear,
retryFailedMessage,
} from './session-detail-content-helpers';
import { assistantMessage, userMessage } from './message-bubble-test-utils';

Expand Down Expand Up @@ -33,23 +33,19 @@ describe('countInFlightMessages', () => {
});
});

describe('retryMessageAndClear', () => {
it('clears the failed row when the retry send succeeds', async () => {
describe('retryFailedMessage', () => {
it('re-sends the failed submission', async () => {
const send = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
const clearFailed = vi.fn<() => void>();
await retryMessageAndClear(send, clearFailed);
await retryFailedMessage(send);
expect(send).toHaveBeenCalledTimes(1);
expect(clearFailed).toHaveBeenCalledTimes(1);
});

it('does not clear the failed row when the retry send fails', async () => {
it('swallows a rejected re-send so the failed row keeps its footer', async () => {
const send = vi
.fn<() => Promise<void>>()
.mockRejectedValue(new Error('Failed to send message'));
const clearFailed = vi.fn<() => void>();
await retryMessageAndClear(send, clearFailed);
await expect(retryFailedMessage(send)).resolves.toBeUndefined();
expect(send).toHaveBeenCalledTimes(1);
expect(clearFailed).not.toHaveBeenCalled();
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,17 @@ export function countInFlightMessages(
}

/**
* Re-sends a failed message and clears its failed row only on success. On
* failure the row stays so the user can retry again; the manager has already
* surfaced the failure toast, so the rejection is swallowed here.
* Re-sends a failed submission as a new one. The failed row keeps its typed
* footer and its Retry action: it is the record of a submission that did fail,
* and the re-send materialises its own row with its own delivery state. Clearing
* the original footer on an accepted re-send left the transcript with fewer
* failure footers than failed submissions (the re-send can fail too), so the
* failed row's state is left alone. The manager has already surfaced any failure
* through its own toast, so the rejection is swallowed here.
*/
export async function retryMessageAndClear(
send: () => Promise<void>,
clearFailed: () => void
): Promise<void> {
export async function retryFailedMessage(send: () => Promise<void>): Promise<void> {
try {
await send();
clearFailed();
} catch {
// Swallow: the manager already surfaced the failure toast and the failed
// row stays so the user can retry again.
Expand Down
Loading