fix(mobile): preserve prompt refusal during draft clear

This commit is contained in:
Brennan Benson
2026-08-05 00:01:32 -07:00
parent 60de278ff2
commit 484bdd425b
4 changed files with 57 additions and 12 deletions
@@ -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()
})
})
+10 -4
View File
@@ -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<MobileNativeChatSendOutcome, 'unknown'>
/** 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<boolean> {
}): Promise<MobileNativeChatClearOutcome> {
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'
}
}
@@ -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.
@@ -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'
}
}