From 43c8df5f26b93cbcda123e7e8329601849aebb06 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 15 Sep 2026 17:07:29 -0400 Subject: [PATCH] fix(mobile): keep main's malformed-reply message and re-anchor two mutants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings the reply matrices surfaced that the pilot scenarios could not. The clipboard upload's `uploadId` read goes back to the raw result. A success carrying no result throws a destructuring TypeError there, and V8 puts the destructured expression's source text in the message — which the composer shows. Reading the slot off the interpreted payload rewrote that sentence for every user who hits a malformed reply, on three families' `result-absent` and `result-null` partitions. The cast is the one main made, kept for the message alone. `race` and `new-tab-refusal-order` both anchored in text the migration rewrote, so each matched zero sites. Re-anchored at their new homes; the defect each injects is unchanged, and `probe-hole-witness.test.ts` still shows the probe killing the reorder while every pre-probe scenario survives it. `native-chat-send-delivery-unknown` is added as this domain's own mutant: dropping the delivery-unknown arm of a chat send makes an ack lost after the frame was written read as a definite rejection, which invites the user to send the same message twice. A second candidate — swapping `terminal.list` from skip to throw — survives every golden and is not registered: the inventory hook wraps its whole read in `catch { return false }`, so a refusal and a throw leave the same strip. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- mobile/src/session/mobile-clipboard-image.ts | 7 ++++- .../mutants/operation-mutations.ts | 27 ++++++++++++------- .../mutants/pilot-mutants.test.ts | 3 ++- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/mobile/src/session/mobile-clipboard-image.ts b/mobile/src/session/mobile-clipboard-image.ts index 5eae5293d85..7551ca55725 100644 --- a/mobile/src/session/mobile-clipboard-image.ts +++ b/mobile/src/session/mobile-clipboard-image.ts @@ -146,7 +146,12 @@ async function uploadMobileClipboardImageTransaction( throw new Error(startResponse.error.message) } - const { uploadId } = clipboardImageUploadStart.interpret(startResponse) as { uploadId: string } + // Why the raw result rather than the interpretation: a success carrying no result throws a + // TypeError here, and V8 puts the destructured expression's source text in its message — which + // the composer then shows. Reading the slot off the accepted payload would rewrite that sentence + // for every user who hits a malformed reply, which is the one change this migration must not make. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the same cast main made, kept so the thrown message is the same one. + const { uploadId } = startResponse.result as { uploadId: string } try { for ( let offset = 0; diff --git a/mobile/src/test-support/rpc-recording/mutants/operation-mutations.ts b/mobile/src/test-support/rpc-recording/mutants/operation-mutations.ts index 1b52c817536..27dbcca49ea 100644 --- a/mobile/src/test-support/rpc-recording/mutants/operation-mutations.ts +++ b/mobile/src/test-support/rpc-recording/mutants/operation-mutations.ts @@ -7,10 +7,21 @@ import type { OperationMutation } from '../operation-module-loader' */ export const OPERATION_MUTATIONS = { // Loses the generation comparison, so a stale workspace response poisons the search cache. + // Drops the delivery-unknown arm of a native-chat send, so an ack lost after the frame was + // written reads as a definite rejection and invites the user to send the same message twice. + 'native-chat-send-delivery-unknown': { + file: 'mobile-native-chat-send.ts', + before: ` return isRpcDeliveryUnknown(error) || isLogicalClientCutoverError(error) + ? 'unknown' + : 'rejected'`, + after: ` return isLogicalClientCutoverError(error) ? 'unknown' : 'rejected'` + }, + // Re-anchored where the operation migration moved the acceptance read; the defect it injects — + // a stale workspace response poisoning the search cache — is unchanged. race: { file: 'use-mobile-native-chat-file-search.ts', - before: '!response.ok || generationRef.current !== generation', - after: '!response.ok' + before: '!accepted.accepted || generationRef.current !== generation', + after: '!accepted.accepted' }, // Accepts a null result envelope instead of rejecting it. The guard is repeated for three // mutations in this file; the anchor carries the message so only the recorded one is edited. @@ -108,15 +119,13 @@ export const OPERATION_MUTATIONS = { }, // Checks the sibling's refusal before the operation's own, so a correlated refusal reports the // sibling. Invisible to every scenario whose sibling succeeds or rejects at the transport. + // Re-anchored where the operation migration moved both reads; the reorder it injects — the + // detection refusal deciding the error before the settings read is interpreted — is unchanged. 'new-tab-refusal-order': { file: 'mobile-new-tab-agent-loader.ts', - before: ` const readSettings = newTabSettingsRead.interpret(settingsResponse) - if (!detectedResponse.ok) { - throw new Error((detectedResponse as RpcFailure).error.message) - }`, - after: ` if (!detectedResponse.ok) { - throw new Error((detectedResponse as RpcFailure).error.message) - } + before: ` const readSettings = newTabSettingsRead.interpret(settingsResponse)`, + after: ` const detected0 = detectedAgents.interpret(detectedAgents.reply) + void detected0 const readSettings = newTabSettingsRead.interpret(settingsResponse)` }, // Publishes an unaccepted read, blanking settings a refusal should have left alone. Invisible diff --git a/mobile/src/test-support/rpc-recording/mutants/pilot-mutants.test.ts b/mobile/src/test-support/rpc-recording/mutants/pilot-mutants.test.ts index acd9840e4f0..8d65addd858 100644 --- a/mobile/src/test-support/rpc-recording/mutants/pilot-mutants.test.ts +++ b/mobile/src/test-support/rpc-recording/mutants/pilot-mutants.test.ts @@ -28,7 +28,8 @@ const mutants: Record = { 'settings-task-hydration-fulfilled': 'task-hydration-envelope', 'settings-task-write': 'task-preferences-optimistic', 'settings-workspace-submit-fulfilled': 'workspace-submit-envelope', - 'settings-task-workspace-fulfilled': 'task-workspace-envelope' + 'settings-task-workspace-fulfilled': 'task-workspace-envelope', + 'native-chat-write-delivery-unknown': 'native-chat-send-delivery-unknown' } /** * The archived tree's visible state, pinned per seed: b1 serves the poisoned empty inventory, b2