mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
fix(mobile): two known main bugs the RPC migration preserved
A malformed host `error` and a null settings result both reach a property read that throws.
Both are deliberate behaviour changes; the goldens move in the follow-up commit.
`hostReplyErrorTextOrFallback` passed a truthy non-string through under a `string` annotation.
Its one caller is the in-band `git.commit` failure, and every consumer of that text is display or
prompt copy: `use-mobile-create-pr-runner` and `PrSidebarCreateEmptyState` record it as a commit
failure, `use-mobile-commit-failure-recovery` hands it to `summarizeCommitFailure`, which starts
with `raw.slice(...).replace(...)`. So no consumer needs the value, and the decision is the
fallback rather than `String(value)` — the relay handler declares
`commit(): Promise<{ success: boolean; error?: string }>`, so a non-string is a malformed reply,
and `generatedCommitMessageReader` in the same domain already reads a non-string host error as
absent. The parameter stays `unknown`, which it honestly is, and the `SAFETY` cast is gone.
`useNewWorkspaceRuntimeContext` read settings through `settingsRead`, whose reader preserves
main's `boxed!.settings` throw, so a `null` or absent result threw a TypeError out of the effect —
losing the trusted-hooks publish and the available-provider computation that follow it, not just
the settings. It now uses `optionalSettingsRead`, the operation that already reads a null or
absent result as absent settings, so the reply degrades exactly the way a reply with no `settings`
member does. Reply-side only: same method, same params, same barrier, no wire change.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import { useNewWorkspaceRuntimeContext } from './use-new-workspace-runtime-context'
|
||||
|
||||
type RuntimeContext = ReturnType<typeof useNewWorkspaceRuntimeContext>
|
||||
|
||||
const TRUSTED_HOOKS = { '/repo/orca.yaml': 'sha-1' }
|
||||
|
||||
function reply(result: unknown): RpcResponse {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the test scripts raw host replies, not validated payloads.
|
||||
return { id: 'r', ok: true, result, _meta: { runtimeId: 'runtime-1' } } as RpcResponse
|
||||
}
|
||||
|
||||
/** Every prerequisite answers normally; only the settings result varies. */
|
||||
function clientAnsweringSettingsWith(settingsResult: unknown): RpcClient {
|
||||
const sendRequest = vi.fn(async (method: string) => {
|
||||
switch (method) {
|
||||
case 'settings.get':
|
||||
return reply(settingsResult)
|
||||
case 'ui.get':
|
||||
return reply({ ui: { trustedOrcaHooks: TRUSTED_HOOKS } })
|
||||
case 'preflight.check':
|
||||
return reply({ glab: { installed: false } })
|
||||
default:
|
||||
return reply({ connected: false })
|
||||
}
|
||||
})
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook reaches only sendRequest on the client.
|
||||
return { sendRequest } as unknown as RpcClient
|
||||
}
|
||||
|
||||
describe('useNewWorkspaceRuntimeContext', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
async function mount(settingsResult: unknown): Promise<RuntimeContext> {
|
||||
// One client for the whole mount: the hook keys its effect on client identity.
|
||||
const client = clientAnsweringSettingsWith(settingsResult)
|
||||
let context!: RuntimeContext
|
||||
function Harness(): null {
|
||||
context = useNewWorkspaceRuntimeContext(client, true)
|
||||
return null
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
await act(async () => {})
|
||||
return context
|
||||
}
|
||||
|
||||
it('degrades a null result the way a reply without a settings member degrades', async () => {
|
||||
const absent = await mount({})
|
||||
const absentState = {
|
||||
runtimeSettings: absent.runtimeSettings,
|
||||
trustedOrcaHooks: absent.trustedOrcaHooks,
|
||||
availableProviders: absent.availableProviders
|
||||
}
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
|
||||
const nullResult = await mount(null)
|
||||
expect({
|
||||
runtimeSettings: nullResult.runtimeSettings,
|
||||
trustedOrcaHooks: nullResult.trustedOrcaHooks,
|
||||
availableProviders: nullResult.availableProviders
|
||||
}).toEqual(absentState)
|
||||
// The state the property-read TypeError used to skip on its way out of the effect.
|
||||
expect(absentState).toEqual({
|
||||
runtimeSettings: null,
|
||||
trustedOrcaHooks: TRUSTED_HOOKS,
|
||||
availableProviders: ['github']
|
||||
})
|
||||
})
|
||||
|
||||
it('degrades an absent result the same way', async () => {
|
||||
const context = await mount(undefined)
|
||||
expect(context.runtimeSettings).toBeNull()
|
||||
expect(context.trustedOrcaHooks).toEqual(TRUSTED_HOOKS)
|
||||
expect(context.availableProviders).toEqual(['github'])
|
||||
})
|
||||
|
||||
it('publishes the settings a host does send', async () => {
|
||||
const context = await mount({
|
||||
settings: { defaultTuiAgent: 'codex', visibleTaskProviders: ['github', 'linear'] }
|
||||
})
|
||||
expect(context.runtimeSettings).toEqual({
|
||||
defaultTuiAgent: 'codex',
|
||||
visibleTaskProviders: ['github', 'linear']
|
||||
})
|
||||
expect(context.availableProviders).toEqual(['github'])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { settingsRead } from '../transport/settings-read-operations'
|
||||
import { optionalSettingsRead } from '../transport/settings-read-operations'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
@@ -40,7 +40,7 @@ export function useNewWorkspaceRuntimeContext(
|
||||
client.sendRequest('linear.status')
|
||||
])
|
||||
const [settingsRes, uiRes] = await Promise.allSettled([
|
||||
settingsRead.request(client),
|
||||
optionalSettingsRead.request(client),
|
||||
client.sendRequest('ui.get')
|
||||
])
|
||||
if (stale) {
|
||||
@@ -48,7 +48,9 @@ export function useNewWorkspaceRuntimeContext(
|
||||
}
|
||||
|
||||
const settingsResult =
|
||||
settingsRes.status === 'fulfilled' ? settingsRead.interpret(settingsRes.value) : null
|
||||
settingsRes.status === 'fulfilled'
|
||||
? optionalSettingsRead.interpret(settingsRes.value)
|
||||
: null
|
||||
const settingsValue = settingsResult?.accepted
|
||||
? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary.
|
||||
(settingsResult.value as NewWorktreeRuntimeSettings & { visibleTaskProviders?: unknown })
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { summarizeCommitFailure } from '../../../src/shared/source-control-commit-failure'
|
||||
import { hostReplyErrorTextOrFallback, refusedRpcMessageOrFallback } from './rpc-refusal-message'
|
||||
|
||||
describe('refusedRpcMessageOrFallback', () => {
|
||||
it('falls back for a message-less refusal and for a non-Error throw', () => {
|
||||
const messageless = new Error('cleared below')
|
||||
messageless.message = ''
|
||||
expect(refusedRpcMessageOrFallback(new Error('refused'), 'Commit failed')).toBe('refused')
|
||||
expect(refusedRpcMessageOrFallback(messageless, 'Commit failed')).toBe('Commit failed')
|
||||
expect(refusedRpcMessageOrFallback('refused', 'Commit failed')).toBe('Commit failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hostReplyErrorTextOrFallback', () => {
|
||||
it('keeps a non-empty host string', () => {
|
||||
expect(hostReplyErrorTextOrFallback('nothing staged', 'Commit failed')).toBe('nothing staged')
|
||||
})
|
||||
|
||||
it('falls back for an absent, null or empty host error', () => {
|
||||
expect(hostReplyErrorTextOrFallback(undefined, 'Commit failed')).toBe('Commit failed')
|
||||
expect(hostReplyErrorTextOrFallback(null, 'Commit failed')).toBe('Commit failed')
|
||||
expect(hostReplyErrorTextOrFallback('', 'Commit failed')).toBe('Commit failed')
|
||||
})
|
||||
|
||||
it('falls back for a truthy non-string, which the host contract does not allow', () => {
|
||||
expect(hostReplyErrorTextOrFallback({ message: 'inner refused' }, 'Commit failed')).toBe(
|
||||
'Commit failed'
|
||||
)
|
||||
expect(hostReplyErrorTextOrFallback(['a'], 'Commit failed')).toBe('Commit failed')
|
||||
expect(hostReplyErrorTextOrFallback(7, 'Commit failed')).toBe('Commit failed')
|
||||
expect(hostReplyErrorTextOrFallback(true, 'Commit failed')).toBe('Commit failed')
|
||||
})
|
||||
|
||||
// The consumer that main's pass-through broke: `.slice` on an object, `.replace` on an array.
|
||||
it('yields text the commit-failure summarizer can read', () => {
|
||||
for (const malformed of [{ message: 'inner refused' }, ['inner refused'], 7]) {
|
||||
expect(summarizeCommitFailure(hostReplyErrorTextOrFallback(malformed, 'Commit failed'))).toBe(
|
||||
'Commit failed'
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,6 @@
|
||||
/**
|
||||
* A refused operation's message, or the screen's own copy when the host sent none.
|
||||
*
|
||||
* Call sites spelled this as `response.error?.message || fallback`. Once the refusal arrives as
|
||||
* the acceptance policy's thrown Error, the `||` has to live somewhere — and it must not also
|
||||
* cover a transport rejection, whose message main surfaced verbatim, empty string included. So
|
||||
* a migrated call site keeps two catches where it had two paths, and only the refusal one calls
|
||||
* this.
|
||||
* A refused operation's message, or the screen's own copy when the host sent none. Only the
|
||||
* refusal catch may call this: a transport rejection's message is surfaced verbatim, empty included.
|
||||
*/
|
||||
export function refusedRpcMessageOrFallback(error: unknown, fallback: string): string {
|
||||
return (error instanceof Error ? error.message : '') || fallback
|
||||
@@ -14,12 +9,10 @@ export function refusedRpcMessageOrFallback(error: unknown, fallback: string): s
|
||||
/**
|
||||
* An error a host reported inside an accepted reply, or the screen's copy when it sent none.
|
||||
*
|
||||
* Exactly `result?.error || fallback`, including for a truthy non-string: main passed that value
|
||||
* through under a `string` annotation, and a downstream `.replace` then threw. Stringifying it
|
||||
* here would be an improvement, but an unannounced one inside a migration whose contract is that
|
||||
* no behaviour changes — so the pass-through stays and the latent throw is ticketed separately.
|
||||
* The host contract declares `error` as a string, so a non-string is a malformed reply and reads
|
||||
* as absent — every consumer is display or prompt text, and main's pass-through made
|
||||
* `summarizeCommitFailure` throw on `.slice`.
|
||||
*/
|
||||
export function hostReplyErrorTextOrFallback(value: unknown, fallback: string): string {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reproduces main's own annotation of an unvalidated host field.
|
||||
return ((value as string | undefined) || fallback) as string
|
||||
return (typeof value === 'string' ? value : '') || fallback
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export const settingsRead = bindDeferredRpcOperation(
|
||||
})
|
||||
)
|
||||
|
||||
/** History resume and repo labels historically tolerate an absent or null result. */
|
||||
/** A null or absent result reads as absent settings instead of throwing the property read. */
|
||||
export const optionalSettingsRead = bindDeferredRpcOperation(
|
||||
defineRpcOperation({
|
||||
name: 'settings.optional-member-or-skip',
|
||||
|
||||
Reference in New Issue
Block a user