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
21 changes: 21 additions & 0 deletions apps/mobile/docs/chat-limits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Chat request and response limits

The mobile chat adapter accepts at most 65536 UTF-8 bytes of serialized request
JSON and at most 65536 bytes of response JSON. Limits include field names,
history, escaping and multibyte characters, not just the latest prompt.
Exactly the limit is accepted; larger messages fail with an explicit size error.
History is never silently truncated. Failed text remains visible; clear history
requires confirmation. Oversized requests do not offer an identical retry.

Responses with an excessive declared Content-Length are rejected before reading.
Stream-capable transports also count actual bytes and cancel oversized bodies,
including those without or with misleading length headers. Buffered React Native
fetch is checked after text decoding, before JSON parsing. It may already have
downloaded the body into native memory: this is not a native download memory cap.
The first cancel, timeout or detected oversize event remains the reported cause.

Tests cover Unicode size boundaries, offline request rejection, body cancellation,
late fetch responses, terminal-event ordering, retained UI state, and real
loopback HTTP (including stalled headers/body and unterminated oversized data).
Local Android/iOS bundle export checks compilation, not device runtime or a real
provider. Device transport behavior remains a release verification item.
72 changes: 63 additions & 9 deletions apps/mobile/src/lib/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ export interface ChatMessage {
}

export const CHAT_TIMEOUT_MS = 30000;
export const MAX_CHAT_REQUEST_BYTES = 65536;
export const MAX_CHAT_RESPONSE_BYTES = 65536;
export type ChatFailure =
| 'request_too_large'
| 'response_too_large'
| 'timeout'
| 'cancelled'
| 'network'
Expand Down Expand Up @@ -46,14 +50,23 @@ function offlineDelay(signal: AbortSignal): Promise<void> {
/** One attempt. Cancellation cannot guarantee that the remote service stopped. */
export async function sendChat(history: readonly ChatMessage[], options: {signal?: AbortSignal} = {}): Promise<string> {
if (options.signal?.aborted) throw new ChatError('cancelled');
const body = JSON.stringify({ messages: history.map(({ role, text }) => ({ role, text })) });
if (new TextEncoder().encode(body).byteLength > MAX_CHAT_REQUEST_BYTES)
throw new ChatError('request_too_large');
const controller = new AbortController();
let timedOut = false;
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, CHAT_TIMEOUT_MS);
const forwardAbort = () => controller.abort();
// The first terminating event wins; cancelling transport must not hide a size error.
let failure: ChatFailure = 'cancelled';
const stop = (kind: ChatFailure) => {
if (controller.signal.aborted) return;
failure = kind;
controller.abort();
};
const timer = setTimeout(() => stop('timeout'), CHAT_TIMEOUT_MS);
const forwardAbort = () => stop('cancelled');
options.signal?.addEventListener('abort', forwardAbort, {once:true});
let rejectAbort: () => void = () => {};
const aborted = new Promise<never>((_, reject) => {
rejectAbort = () => reject(new ChatError(timedOut ? 'timeout' : 'cancelled'));
rejectAbort = () => reject(new ChatError(failure));
controller.signal.addEventListener('abort', rejectAbort, {once:true});
});
const work = async () => {
Expand All @@ -67,17 +80,58 @@ export async function sendChat(history: readonly ChatMessage[], options: {signal
method: 'POST',
headers: { 'content-type': 'application/json' },
signal: controller.signal,
body: JSON.stringify({
messages: history.map(({ role, text }) => ({ role, text })),
}),
body,
});
if (controller.signal.aborted) {
void response.body?.cancel().catch(() => {});
throw new ChatError(failure);
}
if (!response.ok) {
void response.body?.cancel().catch(() => {});
throw new ChatError('http');
}
const tooLarge = (): never => {
stop('response_too_large');
throw new ChatError(failure);
};
if (Number(response.headers.get('content-length')) > MAX_CHAT_RESPONSE_BYTES) {
void response.body?.cancel().catch(() => {});
tooLarge();
}
let text: string;
if (response.body?.getReader) {
const reader = response.body.getReader();
const cancel = () => { void reader.cancel().catch(() => {}); };
controller.signal.addEventListener('abort', cancel, { once: true });
try {
const chunks: Uint8Array[] = [];
let bytes = 0;
while (true) {
const chunk = await reader.read();
if (controller.signal.aborted) throw new ChatError(failure);
if (chunk.done) break;
bytes += chunk.value.byteLength;
if (bytes > MAX_CHAT_RESPONSE_BYTES) tooLarge();
chunks.push(chunk.value);
}
const data = new Uint8Array(bytes);
let offset = 0;
for (const chunk of chunks) { data.set(chunk, offset); offset += chunk.byteLength; }
text = new TextDecoder().decode(data);
} finally {
controller.signal.removeEventListener('abort', cancel);
reader.releaseLock();
}
} else {
// RN's buffered fetch may already have downloaded the whole body natively.
// This limits JSON processing/retention, not peak native download memory.
text = await response.text();
if (controller.signal.aborted) throw new ChatError(failure);
if (new TextEncoder().encode(text).byteLength > MAX_CHAT_RESPONSE_BYTES) tooLarge();
}
let data: unknown;
try {
data = await response.json();
data = JSON.parse(text);
} catch {
throw new ChatError('invalid');
}
Expand All @@ -95,7 +149,7 @@ export async function sendChat(history: readonly ChatMessage[], options: {signal
return await Promise.race([work(), aborted]);
} catch (error) {
if (controller.signal.aborted)
throw new ChatError(timedOut ? 'timeout' : 'cancelled');
throw new ChatError(failure);
throw error instanceof ChatError ? error : new ChatError('network');
} finally {
clearTimeout(timer);
Expand Down
6 changes: 4 additions & 2 deletions apps/mobile/src/screens/ChatScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ type Turn =
| { message: ChatMessage; state: 'pending' }
| { message: ChatMessage; state: 'failed'; reason: ChatFailure };
const FAILURE_COPY: Record<ChatFailure, string> = {
request_too_large: 'This message and conversation are too large to send. Your messages are still here. Start a new conversation with Clear, or send a shorter message.',
response_too_large: 'The assistant reply was too large. Your message is still here. Try a shorter request.',
timeout: `No reply within ${CHAT_TIMEOUT_MS / 1000} seconds. The request may still have reached the service.`,
cancelled: 'Stopped. The request may still have reached the service.',
network: "Couldn't reach the assistant.",
Expand Down Expand Up @@ -188,7 +190,7 @@ export function ChatScreen() {
? 'Waiting for reply...'
: FAILURE_COPY[turn.reason]}
</Text>
<TouchableOpacity
{!(turn.state === 'failed' && turn.reason === 'request_too_large') && <TouchableOpacity
accessibilityRole="button"
accessibilityLabel={busy ? 'Stop response' : 'Retry message'}
accessibilityHint={busy ? undefined : 'Sends the message again.'}
Expand All @@ -202,7 +204,7 @@ export function ChatScreen() {
}
>
<Text style={styles.actionText}>{busy ? 'Stop' : 'Retry'}</Text>
</TouchableOpacity>
</TouchableOpacity>}
</View>
)}
<View style={styles.composer}>
Expand Down
11 changes: 11 additions & 0 deletions apps/mobile/test/ai-http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ async function withServer(
}

describe('chat using real loopback HTTP and AbortController', () => {
it('stops a chunked oversized response without awaiting its end or retrying', async () => {
let closed!: Promise<unknown>;
await withServer(response => {
closed = once(response, 'close');
response.write('{"text":"' + 'x'.repeat(65536));
}, async requests => {
await expect(sendChat(history)).rejects.toMatchObject({ kind: 'response_too_large' });
await expectClosed(closed);
expect(requests).toHaveLength(1);
});
});
it('sends one POST with public conversation fields and decodes UTF-8', async () => {
await withServer(
(response) => {
Expand Down
101 changes: 101 additions & 0 deletions apps/mobile/test/ai-size.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { afterEach, expect, it, vi } from 'vitest';
import { CHAT_TIMEOUT_MS, sendChat } from '../src/lib/ai';

const LIMIT = 65536;
const history = [{ id: '1', role: 'user' as const, text: 'hello' }];
afterEach(() => { vi.unstubAllEnvs(); vi.unstubAllGlobals(); vi.useRealTimers(); });
function setup(response: Response) {
vi.stubEnv('EXPO_PUBLIC_AI_ENDPOINT', 'https://ai.example');
const fetcher = vi.fn(async () => response);
vi.stubGlobal('fetch', fetcher);
return fetcher;
}

it.each([-1, 0, 1])('request UTF-8 boundary %+d', async delta => {
const fetcher = setup(new Response('{"text":"ok"}'));
const overhead = JSON.stringify({ messages: [{ role: 'user', text: '' }] }).length;
const text = '\u0111' + 'x'.repeat(LIMIT + delta - overhead - 2);
const input = [{ ...history[0]!, text }];
if (delta > 0) {
await expect(sendChat(input)).rejects.toMatchObject({ kind: 'request_too_large' });
expect(fetcher).not.toHaveBeenCalled();
} else expect(await sendChat(input)).toBe('ok');
expect(input[0]!.text).toBe(text);
});
it.each([-1, 0, 1])('stream response UTF-8 boundary %+d', async delta => {
const text = '\u0111' + 'x'.repeat(LIMIT + delta - 13);
setup(new Response(JSON.stringify({ text })));
if (delta > 0) await expect(sendChat(history)).rejects.toMatchObject({ kind: 'response_too_large' });
else expect(await sendChat(history)).toBe(text);
});
it.each([-1, 0, 1])('RN-like buffered response boundary %+d', async delta => {
const text = 'x'.repeat(LIMIT + delta - 11);
setup({ ok: true, headers: new Headers(), body: null, text: async () => JSON.stringify({ text }) } as Response);
if (delta > 0) await expect(sendChat(history)).rejects.toMatchObject({ kind: 'response_too_large' });
else expect(await sendChat(history)).toBe(text);
});
it.each([undefined, '1', '999999'])('cancels oversize streams with content-length %s', async length => {
const cancel = vi.fn();
const stream = new ReadableStream({ start(c) { c.enqueue(new Uint8Array(LIMIT + 1)); }, cancel });
const response = new Response(stream, { headers: length ? { 'content-length': length } : {} });
setup(response);
await expect(sendChat(history)).rejects.toMatchObject({ kind: 'response_too_large' });
expect(cancel).toHaveBeenCalledOnce();
});
it('cancels a late response from a transport which ignored abort', async () => {
vi.stubEnv('EXPO_PUBLIC_AI_ENDPOINT', 'https://ai.example');
let resolve!: (r: Response) => void;
vi.stubGlobal('fetch', vi.fn(() => new Promise<Response>(r => { resolve = r; })));
const controller = new AbortController();
const result = expect(sendChat(history, { signal: controller.signal })).rejects.toMatchObject({ kind: 'cancelled' });
controller.abort();
await result;
const cancel = vi.fn();
resolve(new Response(new ReadableStream({ cancel })));
await vi.waitFor(() => expect(cancel).toHaveBeenCalledOnce());
});

it('rejects oversized offline history before scheduling a reply', async () => {
vi.useFakeTimers();
vi.stubEnv('EXPO_PUBLIC_AI_ENDPOINT', '');
const fetcher = vi.fn();
vi.stubGlobal('fetch', fetcher);
await expect(sendChat([{ ...history[0]!, text: 'x'.repeat(LIMIT) }]))
.rejects.toMatchObject({ kind: 'request_too_large' });
expect(fetcher).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
});

it('rejects an oversized declared length without reading even a small body', async () => {
const cancel = vi.fn();
setup(new Response(new ReadableStream({
start(c) { c.enqueue(new TextEncoder().encode('{"text":"ok"}')); },
cancel,
}), { headers: { 'content-length': String(LIMIT + 1) } }));
await expect(sendChat(history)).rejects.toMatchObject({ kind: 'response_too_large' });
expect(cancel).toHaveBeenCalledOnce();
});

it.each(['size-first', 'cancel-first', 'timeout-first'])('preserves the first terminal event: %s', async order => {
vi.useFakeTimers();
const external = new AbortController();
let body!: ReadableStreamDefaultController<Uint8Array>;
let cancelReason: unknown;
const cancel = vi.fn(() => {
// An external cancellation during size-triggered transport cleanup must not replace it.
cancelReason = 'cancelled';
external.abort();
});
setup(new Response(new ReadableStream({ start(c) { body = c; }, cancel })));
const result = sendChat(history, { signal: external.signal });
const expected = order === 'size-first' ? 'response_too_large' : order === 'timeout-first' ? 'timeout' : 'cancelled';
const assertion = expect(result).rejects.toMatchObject({ kind: expected });
await Promise.resolve();
if (order === 'size-first') body.enqueue(new Uint8Array(LIMIT + 1));
else if (order === 'cancel-first') external.abort();
else await vi.advanceTimersByTimeAsync(CHAT_TIMEOUT_MS);
await assertion;
expect(cancel).toHaveBeenCalledOnce();
expect(cancelReason).toBe('cancelled');
expect(vi.getTimerCount()).toBe(0);
});
3 changes: 2 additions & 1 deletion apps/mobile/test/ai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ describe('one bounded chat attempt', () => {
? new Promise<Response>(() => {})
: Promise.resolve({
ok: true,
json: () => new Promise(() => {}),
headers: new Headers(),
text: () => new Promise(() => {}),
} as Response),
);
vi.stubGlobal('fetch', fetcher);
Expand Down
16 changes: 16 additions & 0 deletions apps/mobile/test/chat-recovery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,22 @@ async function send(root: ReactTestInstance, text = 'hello') {
await fire(button(root, 'Send message'), 'onPress');
}
describe('chat recovery without hidden replay', () => {
it('retains an oversized failed turn and offers clear instead of an identical retry', async () => {
const { root } = await renderScreen(<ChatScreen />);
await send(root, 'keep this prompt');
await fire(input(root), 'onChangeText', 'shorter draft');
await actAsync(() => calls[0]!.reject(new ChatError('request_too_large')));
expect(textContents(root)).toContain('keep this prompt');
expect(input(root).props.value).toBe('shorter draft');
expect(button(root, 'Retry message')).toBeUndefined();
await fire(button(root, 'Clear conversation'), 'onPress');
await fire(button(root, 'Cancel clear conversation'), 'onPress');
expect(textContents(root)).toContain('keep this prompt');
await fire(button(root, 'Clear conversation'), 'onPress');
await fire(button(root, 'Confirm clear conversation'), 'onPress');
expect(input(root).props.value).toBe('shorter draft');
expect(calls).toHaveLength(1);
});
it('shows a timeout as a failed turn without retrying automatically', async () => {
const { root } = await renderScreen(<ChatScreen />);
await send(root);
Expand Down
Loading