mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
fix(mobile): give an unreadable reply a message a user can read
`RpcIncompatibleReplyError` put `incompatible_reply: <op> (<method>)` in `message`, and `message` is what the screens hand to a toast. Step 7 is the first change that can reach this error at all, so the token would have shipped to users as its own error copy. Fixed at the boundary rather than per site: `message` is now plain copy, and the machine token moved to `code` (`incompatible_reply`) and `name` (`RpcIncompatibleReplyError`), both readable by callers. The cross-bundle fallback in `isRpcIncompatibleReplyError` matched on the old message prefix, so it now matches on `name`, which a foreign copy of the module still carries. No existing test pinned the old text. Two new ones pin the copy, the token and the foreign-copy match. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import type { RpcDecodeIssue } from './rpc-operation-contract'
|
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.
|
// 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
|
// 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
|
// 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.
|
// delivery-unknown nor shaped like the cutover error the retry loops replay on.
|
||||||
export class RpcIncompatibleReplyError extends Error {
|
export class RpcIncompatibleReplyError extends Error {
|
||||||
|
readonly code = RPC_INCOMPATIBLE_REPLY_CODE
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly operationName: string,
|
readonly operationName: string,
|
||||||
readonly method: string,
|
readonly method: string,
|
||||||
readonly issues: readonly RpcDecodeIssue[]
|
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
|
// Why: instanceof can miss across bundle copies, so also match the name a copy still carries,
|
||||||
// isLogicalClientCutoverError.
|
// mirroring isLogicalClientCutoverError.
|
||||||
export function isRpcIncompatibleReplyError(error: unknown): boolean {
|
export function isRpcIncompatibleReplyError(error: unknown): boolean {
|
||||||
return (
|
return (
|
||||||
error instanceof RpcIncompatibleReplyError ||
|
error instanceof RpcIncompatibleReplyError ||
|
||||||
(error instanceof Error && error.message.startsWith(INCOMPATIBLE_REPLY_MESSAGE_PREFIX))
|
(error instanceof Error && error.name === INCOMPATIBLE_REPLY_ERROR_NAME)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from './stable-logical-rpc-client'
|
} from './stable-logical-rpc-client'
|
||||||
import { isRpcDeliveryUnknown, markRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
|
import { isRpcDeliveryUnknown, markRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
|
||||||
import {
|
import {
|
||||||
|
RPC_INCOMPATIBLE_REPLY_CODE,
|
||||||
RpcIncompatibleReplyError,
|
RpcIncompatibleReplyError,
|
||||||
isRpcIncompatibleReplyError
|
isRpcIncompatibleReplyError
|
||||||
} from './rpc-incompatible-reply-error'
|
} from './rpc-incompatible-reply-error'
|
||||||
@@ -320,6 +321,33 @@ describe('an incompatible reply', () => {
|
|||||||
runRpcOperation(connectedSession(incompatible), workspaceListOrNull, {})
|
runRpcOperation(connectedSession(incompatible), workspaceListOrNull, {})
|
||||||
).resolves.toBeNull()
|
).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', () => {
|
describe('a descriptor', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user