diff --git a/mobile/src/transport/rpc-incompatible-reply-error.ts b/mobile/src/transport/rpc-incompatible-reply-error.ts index 026732ca3b5..dd42b42a814 100644 --- a/mobile/src/transport/rpc-incompatible-reply-error.ts +++ b/mobile/src/transport/rpc-incompatible-reply-error.ts @@ -1,6 +1,8 @@ import type { RpcDecodeIssue } from './rpc-operation-contract' -const INCOMPATIBLE_REPLY_MESSAGE_PREFIX = 'incompatible_reply: ' +/** The machine token. `message` is user-facing copy, so callers branch on this or on `name`. */ +export const RPC_INCOMPATIBLE_REPLY_CODE = 'incompatible_reply' +const INCOMPATIBLE_REPLY_ERROR_NAME = 'RpcIncompatibleReplyError' // Why: a reply the operation's reader cannot read says nothing about what the host did. // On a mutation it is NOT evidence the mutation failed and authorizes no retry — only a @@ -8,20 +10,24 @@ const INCOMPATIBLE_REPLY_MESSAGE_PREFIX = 'incompatible_reply: ' // tasks/worktree-create-retry.ts). So this error is deliberately neither marked // delivery-unknown nor shaped like the cutover error the retry loops replay on. export class RpcIncompatibleReplyError extends Error { + readonly code = RPC_INCOMPATIBLE_REPLY_CODE + constructor( readonly operationName: string, readonly method: string, readonly issues: readonly RpcDecodeIssue[] ) { - super(`${INCOMPATIBLE_REPLY_MESSAGE_PREFIX}${operationName} (${method})`) + // Why plain copy: this message reaches toasts and screen error text unchanged. + super(`The host sent a reply this app could not read (${method})`) + this.name = INCOMPATIBLE_REPLY_ERROR_NAME } } -// Why: instanceof can miss across bundle copies, so also match by message, mirroring -// isLogicalClientCutoverError. +// Why: instanceof can miss across bundle copies, so also match the name a copy still carries, +// mirroring isLogicalClientCutoverError. export function isRpcIncompatibleReplyError(error: unknown): boolean { return ( error instanceof RpcIncompatibleReplyError || - (error instanceof Error && error.message.startsWith(INCOMPATIBLE_REPLY_MESSAGE_PREFIX)) + (error instanceof Error && error.name === INCOMPATIBLE_REPLY_ERROR_NAME) ) } diff --git a/mobile/src/transport/rpc-operation.test.ts b/mobile/src/transport/rpc-operation.test.ts index 91542b48528..a05170cb2a8 100644 --- a/mobile/src/transport/rpc-operation.test.ts +++ b/mobile/src/transport/rpc-operation.test.ts @@ -7,6 +7,7 @@ import { } from './stable-logical-rpc-client' import { isRpcDeliveryUnknown, markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' import { + RPC_INCOMPATIBLE_REPLY_CODE, RpcIncompatibleReplyError, isRpcIncompatibleReplyError } from './rpc-incompatible-reply-error' @@ -320,6 +321,33 @@ describe('an incompatible reply', () => { runRpcOperation(connectedSession(incompatible), workspaceListOrNull, {}) ).resolves.toBeNull() }) + + // `message` reaches toasts and screen copy, so the machine token lives on `code`/`name` instead. + it('carries readable copy in the message and the token on code and name', async () => { + const caught = await runRpcOperation( + connectedSession(incompatible), + workspaceListOrThrow, + {} + ).catch((thrown: unknown) => thrown) + + expect((caught as Error).message).toBe( + 'The host sent a reply this app could not read (worktree.ps)' + ) + expect((caught as Error).message).not.toContain('incompatible_reply') + expect((caught as RpcIncompatibleReplyError).code).toBe(RPC_INCOMPATIBLE_REPLY_CODE) + expect((caught as Error).name).toBe('RpcIncompatibleReplyError') + }) + + // A second bundle copy of this module fails `instanceof`, so the name is the fallback. + it('recognizes a foreign copy of the error by name', () => { + const foreign = Object.assign(new Error('anything at all'), { + name: 'RpcIncompatibleReplyError' + }) + + expect(foreign instanceof RpcIncompatibleReplyError).toBe(false) + expect(isRpcIncompatibleReplyError(foreign)).toBe(true) + expect(isRpcIncompatibleReplyError(new Error('incompatible_reply: x (y)'))).toBe(false) + }) }) describe('a descriptor', () => {