diff --git a/apps/mobile/src/lib/ai.ts b/apps/mobile/src/lib/ai.ts index a8a354e..3be936d 100644 --- a/apps/mobile/src/lib/ai.ts +++ b/apps/mobile/src/lib/ai.ts @@ -14,28 +14,92 @@ export interface ChatMessage { text: string; } -/** Endpoint override (e.g. from Expo config). Empty = offline echo mode. */ -export const AI_ENDPOINT = process.env.EXPO_PUBLIC_AI_ENDPOINT ?? ''; - -export async function sendChat(history: ChatMessage[]): Promise { - const last = history[history.length - 1]?.text ?? ''; +export const CHAT_TIMEOUT_MS = 30000; +export type ChatFailure = + | 'timeout' + | 'cancelled' + | 'network' + | 'http' + | 'invalid'; +export class ChatError extends Error { + constructor(readonly kind: ChatFailure) { + super(kind); + this.name = 'ChatError'; + } +} - if (!AI_ENDPOINT) { - // Offline placeholder until the provider is wired. - await new Promise((r) => setTimeout(r, 350)); - return { - id: `a-${Date.now()}`, - role: 'assistant', - text: `You said: “${last}”. (Offline stub — set EXPO_PUBLIC_AI_ENDPOINT to reach a model.)`, +function offlineDelay(signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const abort = () => { + clearTimeout(timer); + reject(new ChatError('cancelled')); }; - } + const timer = setTimeout(() => { + signal.removeEventListener('abort', abort); + resolve(); + }, 350); + signal.addEventListener('abort', abort, { once: true }); + if (signal.aborted) abort(); + }); +} - const res = await fetch(`${AI_ENDPOINT}/chat`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ messages: history.map(({ role, text }) => ({ role, text })) }), +/** One attempt. Cancellation cannot guarantee that the remote service stopped. */ +export async function sendChat(history: readonly ChatMessage[], options: {signal?: AbortSignal} = {}): Promise { + if (options.signal?.aborted) throw new ChatError('cancelled'); + const controller = new AbortController(); + let timedOut = false; + const timer = setTimeout(() => { timedOut = true; controller.abort(); }, CHAT_TIMEOUT_MS); + const forwardAbort = () => controller.abort(); + options.signal?.addEventListener('abort', forwardAbort, {once:true}); + let rejectAbort: () => void = () => {}; + const aborted = new Promise((_, reject) => { + rejectAbort = () => reject(new ChatError(timedOut ? 'timeout' : 'cancelled')); + controller.signal.addEventListener('abort', rejectAbort, {once:true}); }); - if (!res.ok) throw new Error(`AI request failed (${res.status})`); - const data = (await res.json()) as { text?: string }; - return { id: `a-${Date.now()}`, role: 'assistant', text: data.text ?? '(empty response)' }; + const work = async () => { + const endpoint = process.env.EXPO_PUBLIC_AI_ENDPOINT ?? ''; + if (!endpoint) { + await offlineDelay(controller.signal); + const last = history[history.length - 1]?.text ?? ''; + return `You said: “${last}”. (Offline stub — set EXPO_PUBLIC_AI_ENDPOINT to reach a model.)`; + } + const response = await fetch(`${endpoint.replace(/\/+$/, '')}/chat`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + signal: controller.signal, + body: JSON.stringify({ + messages: history.map(({ role, text }) => ({ role, text })), + }), + }); + if (!response.ok) { + void response.body?.cancel().catch(() => {}); + throw new ChatError('http'); + } + let data: unknown; + try { + data = await response.json(); + } catch { + throw new ChatError('invalid'); + } + if ( + !data || + typeof data !== 'object' || + !('text' in data) || + typeof data.text !== 'string' || + !data.text.trim() + ) + throw new ChatError('invalid'); + return data.text; + }; + try { + return await Promise.race([work(), aborted]); + } catch (error) { + if (controller.signal.aborted) + throw new ChatError(timedOut ? 'timeout' : 'cancelled'); + throw error instanceof ChatError ? error : new ChatError('network'); + } finally { + clearTimeout(timer); + options.signal?.removeEventListener('abort', forwardAbort); + controller.signal.removeEventListener('abort', rejectAbort); + } } diff --git a/apps/mobile/src/screens/ChatScreen.tsx b/apps/mobile/src/screens/ChatScreen.tsx index 5a89a9f..43ae82b 100644 --- a/apps/mobile/src/screens/ChatScreen.tsx +++ b/apps/mobile/src/screens/ChatScreen.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { FlatList, KeyboardAvoidingView, @@ -10,40 +10,119 @@ import { View, } from 'react-native'; import { theme } from '../theme'; -import { sendChat, type ChatMessage } from '../lib/ai'; +import { + CHAT_TIMEOUT_MS, + ChatError, + sendChat, + type ChatFailure, + type ChatMessage, +} from '../lib/ai'; + +const INITIAL: ChatMessage[] = [ + { + id: 'sys-0', + role: 'assistant', + text: 'Hi — I’m your TronBrowser agent. Ask me anything.', + }, +]; +type Turn = + | { message: ChatMessage; state: 'pending' } + | { message: ChatMessage; state: 'failed'; reason: ChatFailure }; +const FAILURE_COPY: Record = { + 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.", + http: 'The assistant service returned an error.', + invalid: "The assistant sent a reply this app couldn't read.", +}; /** * AI chat tab (PRD §Mobile). Wired to `../lib/ai`, which is the single seam * where the real model provider / `@tronbrowser/ai-core` gets plugged in. */ export function ChatScreen() { - const [messages, setMessages] = useState([ - { id: 'sys-0', role: 'assistant', text: 'Hi — I’m your TronBrowser agent. Ask me anything.' }, - ]); + const [messages, setMessages] = useState(INITIAL); const [draft, setDraft] = useState(''); - const [busy, setBusy] = useState(false); + const [turn, setTurn] = useState(null); + const [confirmClear, setConfirmClear] = useState(false); + const history = useRef(INITIAL); + const generation = useRef(0); + const nextId = useRef(0); + const pending = useRef<{ + controller: AbortController; + message: ChatMessage; + } | null>(null); + const busy = turn?.state === 'pending'; const listRef = useRef>(null); - const submit = useCallback(async () => { - const text = draft.trim(); - if (!text || busy) return; - const userMsg: ChatMessage = { id: `u-${Date.now()}`, role: 'user', text }; - setMessages((m) => [...m, userMsg]); - setDraft(''); - setBusy(true); + const run = useCallback(async (message: ChatMessage) => { + if (pending.current) return; + const controller = new AbortController(); + const attempt = ++generation.current; + pending.current = { controller, message }; + setTurn({ message, state: 'pending' }); try { - const reply = await sendChat([...messages, userMsg]); - setMessages((m) => [...m, reply]); - } catch (err) { - setMessages((m) => [ - ...m, - { id: `e-${Date.now()}`, role: 'assistant', text: `Error: ${(err as Error).message}` }, - ]); + const text = await sendChat([...history.current, message], { + signal: controller.signal, + }); + if (attempt !== generation.current) return; + history.current = [ + ...history.current, + message, + { id: `a-${++nextId.current}`, role: 'assistant', text }, + ]; + setMessages(history.current); + setTurn(null); + } catch (error) { + if (attempt !== generation.current) return; + setTurn({ + message, + state: 'failed', + reason: error instanceof ChatError ? error.kind : 'network', + }); } finally { - setBusy(false); - requestAnimationFrame(() => listRef.current?.scrollToEnd({ animated: true })); + if (pending.current?.controller === controller) pending.current = null; } - }, [draft, busy, messages]); + }, []); + + const submit = () => { + const text = draft.trim(); + if (!text || pending.current) return; + setDraft(''); + void run({ id: `u-${++nextId.current}`, role: 'user', text }); + }; + const stop = () => { + const request = pending.current; + if (!request) { + setTurn((current) => + current?.state === 'pending' + ? { message: current.message, state: 'failed', reason: 'cancelled' } + : current, + ); + return; + } + generation.current++; + pending.current = null; + request.controller.abort(); + setTurn({ message: request.message, state: 'failed', reason: 'cancelled' }); + }; + const clear = () => { + generation.current++; + pending.current?.controller.abort(); + pending.current = null; + history.current = INITIAL; + setMessages(INITIAL); + setTurn(null); + setConfirmClear(false); + }; + useEffect( + () => () => { + generation.current++; + pending.current?.controller.abort(); + pending.current = null; + }, + [], + ); return ( + + {confirmClear ? ( + <> + + Confirm clear + + setConfirmClear(false)} + style={styles.action} + > + Cancel + + + ) : ( + setConfirmClear(true)} + style={styles.action} + > + Clear conversation + + )} + + listRef.current?.scrollToEnd({ animated: true }) + } keyExtractor={(m) => m.id} contentContainerStyle={styles.list} renderItem={({ item }) => ( @@ -67,21 +181,47 @@ export function ChatScreen() { )} /> + {turn && ( + + + {turn.state === 'pending' + ? 'Waiting for reply...' + : FAILURE_COPY[turn.reason]} + + { + void run(turn.message); + } + } + > + {busy ? 'Stop' : 'Retry'} + + + )} Send @@ -93,6 +233,21 @@ export function ChatScreen() { const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: theme.bg }, list: { padding: 12, gap: 8 }, + actions: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'flex-end', + paddingHorizontal: 8, + }, + action: { minHeight: 44, justifyContent: 'center', paddingHorizontal: 12 }, + actionText: { color: theme.accent, fontWeight: '600' }, + status: { + paddingHorizontal: 12, + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + statusText: { color: theme.textDim, flex: 1, flexShrink: 1 }, bubble: { maxWidth: '85%', padding: 10, borderRadius: 12 }, userBubble: { alignSelf: 'flex-end', backgroundColor: theme.accentDim }, aiBubble: { alignSelf: 'flex-start', backgroundColor: theme.surfaceAlt }, diff --git a/apps/mobile/test/CHAT_RECOVERY.md b/apps/mobile/test/CHAT_RECOVERY.md new file mode 100644 index 0000000..1e9dd3c --- /dev/null +++ b/apps/mobile/test/CHAT_RECOVERY.md @@ -0,0 +1,26 @@ +# Chat recovery validation + +The companion chat makes one request per explicit Send or Retry. A 30-second +deadline covers both response headers and JSON body. Stop, Clear and actual +unmount abort the request and invalidate late completions. Switching tabs does +not unmount chat. Aborting locally does not prove that a remote service stopped +processing the prompt; a manual retry can incur another server-side request. + +Only completed user/assistant pairs enter future history. Failed prompts remain +retryable without inserting error messages or duplicate prompts. Sending a new +prompt replaces the failed turn. Clear requires confirmation and preserves the +composer draft. History remains in memory, not persistent storage. + +`test/ai.test.ts` covers transport and deadlines; `test/chat-recovery.test.tsx` +uses the real screen and app tab lifecycle with a deferred transport stub. +Neither proves native device behavior or the real AI backend contract. Without +`EXPO_PUBLIC_AI_ENDPOINT`, the existing explicitly labeled offline echo remains. + +`test/ai-http.test.ts` also runs real Node fetch against a loopback HTTP server: +headers/body deadlines, cancellation and socket closure, explicit retry, UTF-8, +connection reset and sanitized failures. These tests deliberately wait for the +real 30-second deadline. They do not contact a real AI service or exercise the +React Native networking implementation. + +Before release, exercise keyboard/layout, Stop/Retry/Clear and slow/offline +requests on the native target. JS export verifies bundling, not native runtime. diff --git a/apps/mobile/test/ai-http.test.ts b/apps/mobile/test/ai-http.test.ts new file mode 100644 index 0000000..9f89df6 --- /dev/null +++ b/apps/mobile/test/ai-http.test.ts @@ -0,0 +1,174 @@ +import { createServer, type ServerResponse } from 'node:http'; +import { once } from 'node:events'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CHAT_TIMEOUT_MS, sendChat } from '../src/lib/ai'; + +const history = [{ id: 'local-only', role: 'user' as const, text: 'hello' }]; +afterEach(() => vi.unstubAllEnvs()); + +async function expectClosed(closed: Promise) { + expect(closed).toBeDefined(); + let timer: ReturnType | undefined; + try { + await Promise.race([ + closed, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error('Response still open after client returned')), + 2000, + ); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +async function withServer( + handler: (response: ServerResponse) => void, + check: ( + requests: { method?: string; path?: string; body: string }[], + ) => Promise, +) { + const requests: { method?: string; path?: string; body: string }[] = []; + const server = createServer(async (request, response) => { + try { + request.setEncoding('utf8'); + let body = ''; + for await (const chunk of request) body += String(chunk); + requests.push({ method: request.method, path: request.url, body }); + handler(response); + } catch { + response.destroy(); + } + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('No test port'); + vi.stubEnv('EXPO_PUBLIC_AI_ENDPOINT', `http://127.0.0.1:${address.port}/`); + try { + await check(requests); + } finally { + server.closeAllConnections(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } +} + +describe('chat using real loopback HTTP and AbortController', () => { + it('sends one POST with public conversation fields and decodes UTF-8', async () => { + await withServer( + (response) => { + response.setHeader('content-type', 'application/json'); + const bytes = Buffer.from( + JSON.stringify({ text: '\u0111\u00e3 nh\u1eadn' }), + ); + for (const byte of bytes) response.write(Buffer.from([byte])); + response.end(); + }, + async (requests) => { + expect(await sendChat(history)).toBe('\u0111\u00e3 nh\u1eadn'); + expect(requests).toEqual([ + { + method: 'POST', + path: '/chat', + body: JSON.stringify({ + messages: [{ role: 'user', text: 'hello' }], + }), + }, + ]); + }, + ); + }); + + it.each(['headers', 'body'] as const)( + 'times out stalled %s over a real socket, without retry', + async (part) => { + let closed!: Promise; + await withServer( + (response) => { + closed = once(response, 'close'); + if (part === 'body') { + response.setHeader('content-type', 'application/json'); + response.write('{"text":"'); + } + }, + async (requests) => { + const started = Date.now(); + await expect(sendChat(history)).rejects.toMatchObject({ + kind: 'timeout', + message: 'timeout', + }); + expect(Date.now() - started).toBeGreaterThanOrEqual( + CHAT_TIMEOUT_MS - 100, + ); + await expectClosed(closed); + expect(requests).toHaveLength(1); + }, + ); + }, + CHAT_TIMEOUT_MS + 5000, + ); + + it('cancels an in-flight request, closes the connection and permits an explicit next attempt', async () => { + const controller = new AbortController(); + let count = 0; + let closed!: Promise; + await withServer( + (response) => { + if (++count === 1) { + closed = once(response, 'close'); + response.write('{"text":"unfinished'); + controller.abort(); + } else response.end(JSON.stringify({ text: 'second attempt' })); + }, + async (requests) => { + await expect( + sendChat(history, { signal: controller.signal }), + ).rejects.toMatchObject({ kind: 'cancelled' }); + await expectClosed(closed); + expect(requests).toHaveLength(1); + expect(await sendChat(history)).toBe('second attempt'); + expect(requests).toHaveLength(2); + }, + ); + }); + + it.each([ + { code: 500, body: 'PRIVATE_REMOTE_ERROR', kind: 'http' }, + { code: 200, body: '{"text":', kind: 'invalid' }, + { code: 200, body: '{"text":" "}', kind: 'invalid' }, + ])( + 'sanitizes $code/$kind responses without retry', + async ({ code, body, kind }) => { + await withServer( + (response) => { + response.writeHead(code, { 'content-type': 'application/json' }); + response.end(body); + }, + async (requests) => { + await expect(sendChat(history)).rejects.toMatchObject({ + kind, + message: kind, + }); + expect(requests).toHaveLength(1); + }, + ); + }, + ); + + it('handles a connection reset without retrying the prompt', async () => { + await withServer( + (response) => response.destroy(), + async (requests) => { + await expect(sendChat(history)).rejects.toMatchObject({ + kind: 'network', + message: 'network', + }); + expect(requests).toHaveLength(1); + }, + ); + }); +}); diff --git a/apps/mobile/test/ai.test.ts b/apps/mobile/test/ai.test.ts new file mode 100644 index 0000000..bb42aa9 --- /dev/null +++ b/apps/mobile/test/ai.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CHAT_TIMEOUT_MS, sendChat } from '../src/lib/ai'; +const history = [{ id: 'u-1', role: 'user' as const, text: 'hello' }]; +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); +describe('one bounded chat attempt', () => { + it('posts only conversation fields and returns valid text', async () => { + vi.stubEnv('EXPO_PUBLIC_AI_ENDPOINT', 'https://ai.example/'); + const fetcher = vi.fn( + async () => new Response(JSON.stringify({ text: 'reply' })), + ); + vi.stubGlobal('fetch', fetcher); + expect(await sendChat(history)).toBe('reply'); + expect(fetcher).toHaveBeenCalledWith( + 'https://ai.example/chat', + expect.objectContaining({ + body: JSON.stringify({ messages: [{ role: 'user', text: 'hello' }] }), + signal: expect.any(AbortSignal), + }), + ); + }); + it.each(['headers', 'body'])( + 'enforces a deadline through stalled %s', + async (part) => { + vi.useFakeTimers(); + vi.stubEnv('EXPO_PUBLIC_AI_ENDPOINT', 'https://ai.example'); + const fetcher = vi.fn(() => + part === 'headers' + ? new Promise(() => {}) + : Promise.resolve({ + ok: true, + json: () => new Promise(() => {}), + } as Response), + ); + vi.stubGlobal('fetch', fetcher); + const result = expect(sendChat(history)).rejects.toMatchObject({ + kind: 'timeout', + }); + await vi.advanceTimersByTimeAsync(CHAT_TIMEOUT_MS); + await result; + expect(vi.getTimerCount()).toBe(0); + expect(fetcher).toHaveBeenCalledTimes(1); + }, + ); + it.each([null, {}, [], { text: 3 }, { text: ' ' }, 'string'])( + 'rejects malformed response %j', + async (value) => { + vi.stubEnv('EXPO_PUBLIC_AI_ENDPOINT', 'https://ai.example'); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify(value))), + ); + await expect(sendChat(history)).rejects.toMatchObject({ + kind: 'invalid', + }); + }, + ); + it('never displays remote errors or retries automatically', async () => { + vi.stubEnv('EXPO_PUBLIC_AI_ENDPOINT', 'https://ai.example'); + const fetcher = vi.fn(async () => new Response('SECRET', { status: 500 })); + vi.stubGlobal('fetch', fetcher); + await expect(sendChat(history)).rejects.toMatchObject({ message: 'http' }); + expect(fetcher).toHaveBeenCalledOnce(); + fetcher.mockRejectedValue(new Error('SECRET')); + await expect(sendChat(history)).rejects.toMatchObject({ + message: 'network', + }); + }); + it('cancels before and during fetch even if transport ignores abort', async () => { + vi.useFakeTimers(); + vi.stubEnv('EXPO_PUBLIC_AI_ENDPOINT', 'https://ai.example'); + const fetcher = vi.fn(() => new Promise(() => {})); + vi.stubGlobal('fetch', fetcher); + const stopped = new AbortController(); + stopped.abort(); + await expect( + sendChat(history, { signal: stopped.signal }), + ).rejects.toMatchObject({ kind: 'cancelled' }); + expect(fetcher).not.toHaveBeenCalled(); + const active = new AbortController(); + const result = expect( + sendChat(history, { signal: active.signal }), + ).rejects.toMatchObject({ kind: 'cancelled' }); + active.abort(); + await result; + expect(vi.getTimerCount()).toBe(0); + }); + it('keeps the offline stub honest and cancels its delay without timers', async () => { + vi.useFakeTimers(); + vi.stubEnv('EXPO_PUBLIC_AI_ENDPOINT', ''); + const result = sendChat(history); + await vi.advanceTimersByTimeAsync(350); + expect(await result).toContain('Offline stub'); + expect(vi.getTimerCount()).toBe(0); + const controller = new AbortController(); + const cancelled = expect( + sendChat(history, { signal: controller.signal }), + ).rejects.toMatchObject({ kind: 'cancelled' }); + controller.abort(); + await cancelled; + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/apps/mobile/test/chat-recovery.test.tsx b/apps/mobile/test/chat-recovery.test.tsx new file mode 100644 index 0000000..016490e --- /dev/null +++ b/apps/mobile/test/chat-recovery.test.tsx @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ReactTestInstance } from 'react-test-renderer'; +import { ChatScreen } from '../src/screens/ChatScreen'; +import App from '../App'; +import { ChatError, type ChatMessage } from '../src/lib/ai'; +import { + actAsync, + fire, + hosts, + renderScreen, + switchTab, + textContents, +} from './harness'; +const calls = vi.hoisted( + () => + [] as { + history: ChatMessage[]; + signal: AbortSignal; + resolve: (text: string) => void; + reject: (error: unknown) => void; + }[], +); +vi.mock('../src/lib/ai', async (original) => ({ + ...(await original()), + sendChat: vi.fn( + (history: ChatMessage[], { signal }: { signal: AbortSignal }) => + new Promise((resolve, reject) => + calls.push({ history, signal, resolve, reject }), + ), + ), +})); +beforeEach(() => { + calls.length = 0; +}); +const button = (root: ReactTestInstance, label: string) => + hosts(root, 'TouchableOpacity').find( + (n) => n.props.accessibilityLabel === label, + )!; +const input = (root: ReactTestInstance) => + hosts(root, 'TextInput').find( + (n) => n.props.accessibilityLabel === 'Chat message', + )!; +async function send(root: ReactTestInstance, text = 'hello') { + await fire(input(root), 'onChangeText', text); + await fire(button(root, 'Send message'), 'onPress'); +} +describe('chat recovery without hidden replay', () => { + it('shows a timeout as a failed turn without retrying automatically', async () => { + const { root } = await renderScreen(); + await send(root); + await actAsync(() => calls[0]!.reject(new ChatError('timeout'))); + expect(textContents(root).join(' ')).toContain( + 'No reply within 30 seconds.', + ); + expect(button(root, 'Retry message')).toBeDefined(); + expect(calls).toHaveLength(1); + }); + it('cancelling Clear leaves pending work and the conversation intact', async () => { + const { root } = await renderScreen(); + await send(root); + await fire(button(root, 'Clear conversation'), 'onPress'); + await fire(button(root, 'Cancel clear conversation'), 'onPress'); + expect(calls[0]!.signal.aborted).toBe(false); + expect(textContents(root)).toContain('hello'); + await actAsync(() => calls[0]!.resolve('reply')); + expect(textContents(root)).toContain('reply'); + }); + it('blocks same-frame duplicate sends and commits each prompt exactly once', async () => { + const { root } = await renderScreen(); + await fire(input(root), 'onChangeText', 'hello'); + const submit = button(root, 'Send message').props.onPress; + await actAsync(() => { + submit(); + submit(); + }); + expect(calls).toHaveLength(1); + await actAsync(() => calls[0]!.resolve('world')); + expect(textContents(root).filter((t) => t === 'hello')).toHaveLength(1); + expect(textContents(root)).toContain('world'); + }); + it('keeps failed prompts retryable, never sends errors, preserves a newer draft', async () => { + const { root } = await renderScreen(); + await send(root); + await fire(input(root), 'onChangeText', 'next draft'); + await actAsync(() => calls[0]!.reject(new Error('PRIVATE_SERVER_ERROR'))); + expect(textContents(root).join(' ')).not.toContain('PRIVATE_SERVER_ERROR'); + expect(input(root).props.value).toBe('next draft'); + await fire(button(root, 'Retry message'), 'onPress'); + expect(calls[1]!.history).toEqual(calls[0]!.history); + await actAsync(() => calls[1]!.resolve('done')); + expect(textContents(root).filter((t) => t === 'hello')).toHaveLength(1); + expect(input(root).props.value).toBe('next draft'); + await fire(button(root, 'Send message'), 'onPress'); + expect(calls[2]!.history.map((m) => m.text).slice(-3)).toEqual([ + 'hello', + 'done', + 'next draft', + ]); + }); + it('a new prompt replaces an unsuccessful turn, not its error in history', async () => { + const { root } = await renderScreen(); + await send(root); + await actAsync(() => calls[0]!.reject(new Error('secret'))); + await send(root, 'other'); + expect(calls[1]!.history.map((m) => m.text).slice(1)).toEqual(['other']); + }); + it('Stop wins against a resolved microtask and an old result cannot replace a retry', async () => { + const { root } = await renderScreen(); + await send(root); + await actAsync(() => { + calls[0]!.resolve('late'); + button(root, 'Stop response').props.onPress(); + }); + expect(calls[0]!.signal.aborted).toBe(true); + expect(textContents(root)).not.toContain('late'); + await fire(button(root, 'Retry message'), 'onPress'); + await actAsync(() => calls[1]!.resolve('new')); + expect(textContents(root)).toContain('new'); + }); + it('confirmable clear invalidates work, keeps the draft and ignores late output', async () => { + const { root } = await renderScreen(); + await send(root); + await fire(input(root), 'onChangeText', 'keep me'); + await fire(button(root, 'Clear conversation'), 'onPress'); + expect(calls[0]!.signal.aborted).toBe(false); + await fire(button(root, 'Confirm clear conversation'), 'onPress'); + expect(calls[0]!.signal.aborted).toBe(true); + await actAsync(() => calls[0]!.resolve('stale')); + expect(textContents(root)).not.toContain('stale'); + expect(textContents(root)).not.toContain('hello'); + expect(input(root).props.value).toBe('keep me'); + }); + it('aborts on true unmount but not when changing tabs', async () => { + const app = await renderScreen(); + await switchTab(app.root, 'Chat'); + await send(app.root); + await switchTab(app.root, 'Browse'); + expect(calls[0]!.signal.aborted).toBe(false); + await actAsync(() => calls[0]!.resolve('background reply')); + await switchTab(app.root, 'Chat'); + expect(textContents(app.root)).toContain('background reply'); + await send(app.root, 'pending'); + await actAsync(() => app.unmount()); + expect(calls[1]!.signal.aborted).toBe(true); + await actAsync(() => calls[1]!.resolve('after unmount')); + }); +});