From 484bdd425becf9978222714dccc9e9fc2b25c2eb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:01:32 -0700 Subject: [PATCH] fix(mobile): preserve prompt refusal during draft clear --- .../session/mobile-native-chat-send.test.ts | 25 ++++++++++++++++--- mobile/src/session/mobile-native-chat-send.ts | 14 ++++++++--- ...se-mobile-native-chat-message-send.test.ts | 20 +++++++++++++-- .../use-mobile-native-chat-message-send.ts | 10 +++++--- 4 files changed, 57 insertions(+), 12 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-send.test.ts b/mobile/src/session/mobile-native-chat-send.test.ts index ff42d38940a..6a5490a1e03 100644 --- a/mobile/src/session/mobile-native-chat-send.test.ts +++ b/mobile/src/session/mobile-native-chat-send.test.ts @@ -340,7 +340,7 @@ describe('clearMobileNativeChatInput', () => { const clearInput = buildAgentTuiClearInputForText('Linked Linear issue: ABC-123\nhttps://x') await expect( clearMobileNativeChatInput({ client, terminal: 'term', clearInput }) - ).resolves.toBe(true) + ).resolves.toBe('accepted') expect(params(client)).toMatchObject({ text: clearInput, enter: false }) }) @@ -353,7 +353,26 @@ describe('clearMobileNativeChatInput', () => { }) await expect( clearMobileNativeChatInput({ client, terminal: 'term', clearInput: '\x15' }) - ).resolves.toBe(false) + ).resolves.toBe('rejected') + }) + + it('preserves an interactive prompt rejection from a guarded clear', async () => { + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: false, refusedReason: 'interactive-prompt' } }, + _meta: { runtimeId: 'runtime' } + }) + + await expect( + clearMobileNativeChatInput({ + client, + terminal: 'term', + clearInput: '\x15', + guardInteractivePrompt: true + }) + ).resolves.toBe('interactive-prompt') + expect(params(client)).toMatchObject({ nativeChat: true }) }) it('refuses to start an underfunded clear rather than half-clearing', async () => { @@ -365,7 +384,7 @@ describe('clearMobileNativeChatInput', () => { clearInput: '\x15', deadline: Date.now() + 10 }) - ).resolves.toBe(false) + ).resolves.toBe('rejected') expect(client.sendRequest).not.toHaveBeenCalled() }) }) diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index 1b6c0a03002..28c3f247cf2 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -40,6 +40,7 @@ type MobileNativeChatSendArgs = { * desktop (ack loss after a write, or a cutover that cannot tell whether the * frame was written) — callers must not present it as a definite send failure. */ export type MobileNativeChatSendOutcome = 'accepted' | 'rejected' | 'unknown' | 'interactive-prompt' +export type MobileNativeChatClearOutcome = Exclude /** Without an explicit timeout `sendRequest` waits for reconnect indefinitely, and * the composer holds `sending` (send arrow dimmed, no error) for as long as it @@ -119,11 +120,11 @@ export async function clearMobileNativeChatInput(args: { guardInteractivePrompt?: boolean mobileClient?: MobileTerminalClient deadline?: number -}): Promise { +}): Promise { const timeoutMs = args.deadline === undefined ? MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS : args.deadline - Date.now() if (timeoutMs < MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS) { - return false + return 'rejected' } try { const response = await args.client.sendRequest( @@ -137,9 +138,14 @@ export async function clearMobileNativeChatInput(args: { }, { timeoutMs, budgetSpansConnect: true } ) - return isTerminalSendRpcAccepted(response) + if (isTerminalSendRpcAccepted(response)) { + return 'accepted' + } + return getTerminalSendRpcRefusedReason(response) === 'interactive-prompt' + ? 'interactive-prompt' + : 'rejected' } catch { // A failed clear must not send the body on top of an uncleared line. - return false + return 'rejected' } } diff --git a/mobile/src/session/use-mobile-native-chat-message-send.test.ts b/mobile/src/session/use-mobile-native-chat-message-send.test.ts index 1d480c362ee..a0473b73489 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.test.ts @@ -90,7 +90,7 @@ describe('useMobileNativeChatMessageSend', () => { sendWithOutcome.mockReset() sendWithOutcome.mockResolvedValue('accepted') clearInputWrite.mockReset() - clearInputWrite.mockResolvedValue(true) + clearInputWrite.mockResolvedValue('accepted') acceptSend.mockReset() holdUnconfirmedSend.mockReset() restoreRejectedDraft.mockReset() @@ -131,7 +131,7 @@ describe('useMobileNativeChatMessageSend', () => { it('aborts without sending the body when the clear is rejected', async () => { // Sending on top of an uncleared line is exactly the concatenation bug. - clearInputWrite.mockResolvedValue(false) + clearInputWrite.mockResolvedValue('rejected') mount(() => ({ text: DRAFT, createdAt: 1 })) let result: boolean | undefined await act(async () => { @@ -141,6 +141,22 @@ describe('useMobileNativeChatMessageSend', () => { expect(sendWithOutcome).not.toHaveBeenCalled() }) + it('reports an interactive prompt rejected while clearing a seeded launch draft', async () => { + clearInputWrite.mockResolvedValue('interactive-prompt') + mount(() => ({ text: DRAFT, createdAt: 1 })) + + let result: boolean | undefined + await act(async () => { + result = await api!.send('hello') + }) + + expect(result).toBe(false) + expect(restoreRejectedDraft).toHaveBeenCalledTimes(1) + expect(sendWithOutcome).not.toHaveBeenCalled() + expect(acceptSend).not.toHaveBeenCalled() + expect(onSendError).toHaveBeenCalledWith('Finish the terminal prompt before sending') + }) + it('drops the body write\u2019s own Ctrl+U prefix once the dedicated clear ran', async () => { // A Ctrl+U written immediately before body text in the SAME write arrives as // a literal control character, so it would head the received message. diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts index 8fb5e47f2a6..bd06d598c7a 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -135,7 +135,7 @@ export function useMobileNativeChatMessageSend(args: { // than pasting on top of an uncleared line. const seededLaunchDraft = readSeededLaunchDraftSeed() if (seededLaunchDraft && !images?.length) { - const cleared = await clearMobileNativeChatInput({ + const clearOutcome = await clearMobileNativeChatInput({ client, terminal: handle, clearInput: buildAgentTuiClearInputForText(seededLaunchDraft.text), @@ -145,11 +145,15 @@ export function useMobileNativeChatMessageSend(args: { ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } : {}) }) - if (!cleared) { + if (clearOutcome !== 'accepted') { if (syncComposer) { restoreRejectedDraft(origin, text) } - onSendError('Message not sent') + onSendError( + clearOutcome === 'interactive-prompt' + ? 'Finish the terminal prompt before sending' + : 'Message not sent' + ) return 'rejected' } }