fix(mobile): keep main's malformed-reply message and re-anchor two mutants

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
This commit is contained in:
Jinwoo-H
2026-09-15 17:07:29 -04:00
parent c4cd7c253c
commit 43c8df5f26
3 changed files with 26 additions and 11 deletions
+6 -1
View File
@@ -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;
@@ -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
@@ -28,7 +28,8 @@ const mutants: Record<string, Mutation> = {
'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