fix(mobile): keep chat-agent create when the capability check spans a lan-to-relay switch (#33697)

This commit is contained in:
Kelvin Amoaba
2026-10-26 20:25:58 +00:00
committed by GitHub
parent b590977919
commit cf6c70bfce
2 changed files with 64 additions and 29 deletions
@@ -3,6 +3,7 @@ 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 { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
import { createStableLogicalRpcClient } from '../transport/stable-logical-rpc-client'
import { useNewWorktreeRuntimeCapabilities } from './worktree-create-capability'
function connection() {
@@ -113,6 +114,34 @@ describe('capability probe recovery', () => {
expect(current?.tasksSupported).toBe(true)
expect(host.sendRequest).toHaveBeenCalledTimes(2)
})
it('keeps a pending create probe when the same host switches from LAN to relay', async () => {
const lan = new FakeSession('connected')
lan.getLastConnectedAt = () => 1
let dropLan: ((reason: Error) => void) | undefined
lan.sendRequest.mockImplementation(
() =>
new Promise((_resolve, reject) => {
dropLan = reject
})
)
const relay = connection()
relay.sendRequest.mockResolvedValue({
id: 'relay',
ok: true,
result: { capabilities: ['agent.launch.v2', 'worktree.create-idempotency.v1'] },
_meta: { runtimeId: 'test' }
})
const logical = createStableLogicalRpcClient(lan, 'lan')
await render(logical)
const launch = current?.getAgentLaunchSupport()
const idempotency = current?.getWorktreeCreateCutoverSupport()
await act(async () => {
await logical.migrateTo(relay.client, 'relay')
dropLan?.(new Error('socket closed'))
})
await expect(launch).resolves.toEqual({ replay: false })
await expect(idempotency).resolves.toBeTruthy()
})
it.each(['before', 'after'])(
'ignores a prior connection reply resolved %s the current reply',
async (order) => {
+35 -29
View File
@@ -89,37 +89,43 @@ export function useNewWorktreeRuntimeCapabilities(
promise: Promise<NewWorktreeRuntimeCapabilities | null>
} | null>(null)
const getCapabilities = useCallback(async (): Promise<NewWorktreeRuntimeCapabilities | null> => {
const generation = client?.getGeneration?.()
const connectedAt = client?.getLastConnectedAt()
let probe = capabilityProbeRef.current
if (
!probe ||
probe.client !== client ||
probe.generation !== generation ||
probe.connectedAt !== connectedAt
) {
// Queued creates can precede passive effects; share the same status barrier.
probe = {
client,
generation,
connectedAt,
promise: client ? readNewWorktreeRuntimeCapabilities(client) : Promise.resolve(null)
for (;;) {
const generation = client?.getGeneration?.()
const connectedAt = client?.getLastConnectedAt()
let probe = capabilityProbeRef.current
if (
!probe ||
probe.client !== client ||
probe.generation !== generation ||
probe.connectedAt !== connectedAt
) {
// Queued creates can precede passive effects; share the same status barrier.
probe = {
client,
generation,
connectedAt,
promise: client ? readNewWorktreeRuntimeCapabilities(client) : Promise.resolve(null)
}
capabilityProbeRef.current = probe
}
const capabilities = await probe.promise
const current = capabilityProbeRef.current
if (
current === probe &&
client?.getGeneration?.() === generation &&
client?.getLastConnectedAt() === connectedAt
) {
if (!capabilities) {
// Probe failure does not establish missing host support.
capabilityProbeRef.current = null
}
return capabilities
}
// A reconnect or path switch keeps the host; wait for the current connection's answer.
if (current?.client !== client) {
return null
}
capabilityProbeRef.current = probe
}
const capabilities = await probe.promise
if (
capabilityProbeRef.current !== probe ||
client?.getGeneration?.() !== generation ||
client?.getLastConnectedAt() !== connectedAt
) {
return null
}
if (!capabilities && capabilityProbeRef.current === probe) {
// Probe failure does not establish missing host support.
capabilityProbeRef.current = null
}
return capabilities
}, [client])
useEffect(() => {