mirror of
https://github.com/stablyai/orca.git
synced 2026-10-06 00:02:43 +00:00
fix(mobile): re-hold structured chat after reconnect
This commit is contained in:
@@ -31,11 +31,13 @@ export async function callAgentSession<TResult>(
|
||||
client: RpcClient,
|
||||
method: string,
|
||||
params: unknown,
|
||||
timeoutMs = STRUCTURED_SEND_TIMEOUT_MS
|
||||
timeoutMs = STRUCTURED_SEND_TIMEOUT_MS,
|
||||
options?: { failWhenDisconnected?: boolean }
|
||||
): Promise<TResult> {
|
||||
const response = await client.sendRequest(method, params, {
|
||||
timeoutMs,
|
||||
budgetSpansConnect: true
|
||||
budgetSpansConnect: true,
|
||||
...(options?.failWhenDisconnected ? { failWhenDisconnected: true } : {})
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
|
||||
@@ -93,6 +93,7 @@ export function useMobileNativeChatController(args: {
|
||||
client,
|
||||
sessionId: activeChatStructured ? activeChatSessionId : null,
|
||||
enabled: showNativeChat,
|
||||
connected: connState === 'connected',
|
||||
agent: activeChatStructured ? activeChatAgent : null,
|
||||
onSendError
|
||||
})
|
||||
@@ -159,8 +160,6 @@ export function useMobileNativeChatController(args: {
|
||||
const nativeChatTranscriptSettled =
|
||||
nativeChatSession.status === 'ready' ||
|
||||
(nativeChatSession.status === 'error' && nativeChatSession.messages.length > 0)
|
||||
const nativeChatAskObservable =
|
||||
showNativeChat && (nativeChatDetectedAsk != null || nativeChatTranscriptSettled)
|
||||
const {
|
||||
askKey: nativeChatAskKey,
|
||||
showAsk: showNativeChatAsk,
|
||||
@@ -170,7 +169,7 @@ export function useMobileNativeChatController(args: {
|
||||
detectedAsk: nativeChatDetectedAsk,
|
||||
scopeKey: activeSessionTabId,
|
||||
sessionKey: activeChatSessionId,
|
||||
observing: nativeChatAskObservable
|
||||
observing: showNativeChat && (nativeChatDetectedAsk != null || nativeChatTranscriptSettled)
|
||||
})
|
||||
|
||||
// Every chat write gates on both: the lease proves the input floor is ours, and
|
||||
|
||||
@@ -201,15 +201,18 @@ describe('useMobileStructuredAgentSession', () => {
|
||||
|
||||
function Harness({
|
||||
sessionId = 'session-1',
|
||||
agent = 'codex'
|
||||
agent = 'codex',
|
||||
connected = true
|
||||
}: {
|
||||
sessionId?: string | null
|
||||
agent?: string | null
|
||||
connected?: boolean
|
||||
}): null {
|
||||
hook = useMobileStructuredAgentSession({
|
||||
client,
|
||||
sessionId,
|
||||
enabled: true,
|
||||
connected,
|
||||
agent,
|
||||
onSendError
|
||||
} as never)
|
||||
@@ -254,6 +257,47 @@ describe('useMobileStructuredAgentSession', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('re-holds after a reconnect that outlives the host release grace', async () => {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness, { connected: true }))
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
sendRequest.mock.calls.filter(([method]) => method === 'agentSession.hold')
|
||||
).toHaveLength(1)
|
||||
)
|
||||
await vi.waitFor(() => expect(subscribe).toHaveBeenCalledTimes(1))
|
||||
|
||||
// A transport loss retires the connection-scoped hold; after the host's 15s grace
|
||||
// it may evict the provider child. Reconnect must acquire before replaying the stream.
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Harness, { connected: false }))
|
||||
})
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1)
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Harness, { connected: true }))
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
sendRequest.mock.calls.filter(([method]) => method === 'agentSession.hold')
|
||||
).toHaveLength(2)
|
||||
)
|
||||
await vi.waitFor(() => expect(subscribe).toHaveBeenCalledTimes(2))
|
||||
const holdOrders = sendRequest.mock.calls
|
||||
.map((call, index) =>
|
||||
call[0] === 'agentSession.hold' ? sendRequest.mock.invocationCallOrder[index] : null
|
||||
)
|
||||
.filter((order): order is number => order !== null)
|
||||
const subscribeOrders = subscribe.mock.invocationCallOrder
|
||||
const secondHoldOrder = holdOrders[1]
|
||||
const secondSubscribeOrder = subscribeOrders[1]
|
||||
if (secondHoldOrder === undefined || secondSubscribeOrder === undefined) {
|
||||
throw new Error('reconnect calls were not recorded')
|
||||
}
|
||||
expect(secondHoldOrder).toBeLessThan(secondSubscribeOrder)
|
||||
})
|
||||
|
||||
it('sends with the shared structured mutation envelope after the stream fence lands', async () => {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
|
||||
@@ -65,15 +65,18 @@ export function useMobileStructuredAgentSession(args: {
|
||||
client: RpcClient | null
|
||||
sessionId: string | null
|
||||
enabled: boolean
|
||||
/** Live transport only; gates the connection-scoped hold, nothing else. */
|
||||
connected: boolean
|
||||
agent: string | null
|
||||
onSendError: (message: string) => void
|
||||
}): StructuredMobileSession {
|
||||
const { agent, client, sessionId, enabled, onSendError } = args
|
||||
const { agent, client, connected, sessionId, enabled, onSendError } = args
|
||||
const operationIdsRef = useRef(new Map<string, string>())
|
||||
const { state, stateRef, loadingOlder, loadEarlier } = useMobileStructuredAgentState({
|
||||
client,
|
||||
sessionId,
|
||||
enabled
|
||||
enabled,
|
||||
connected
|
||||
})
|
||||
|
||||
const mutate = useCallback(
|
||||
|
||||
@@ -27,16 +27,19 @@ export function useMobileStructuredAgentState(args: {
|
||||
client: RpcClient | null
|
||||
sessionId: string | null
|
||||
enabled: boolean
|
||||
/** Live transport only; gates the connection-scoped hold, nothing else. */
|
||||
connected: boolean
|
||||
}): {
|
||||
state: StructuredAgentSessionState
|
||||
stateRef: { readonly current: StructuredAgentSessionState }
|
||||
loadingOlder: boolean
|
||||
loadEarlier: () => void
|
||||
} {
|
||||
const { client, enabled, sessionId } = args
|
||||
const { client, connected, enabled, sessionId } = args
|
||||
const [state, setState] = useState<StructuredAgentSessionState>(EMPTY_STRUCTURED_AGENT_SESSION)
|
||||
const [loadingOlder, setLoadingOlder] = useState(false)
|
||||
const stateRef = useRef(state)
|
||||
const sessionIdentityRef = useRef<{ client: RpcClient; sessionId: string } | null>(null)
|
||||
useLayoutEffect(() => {
|
||||
stateRef.current = state
|
||||
}, [state])
|
||||
@@ -46,42 +49,80 @@ export function useMobileStructuredAgentState(args: {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !sessionId || !enabled) {
|
||||
return
|
||||
const sessionChanged =
|
||||
client !== null &&
|
||||
sessionId !== null &&
|
||||
(sessionIdentityRef.current?.client !== client ||
|
||||
sessionIdentityRef.current?.sessionId !== sessionId)
|
||||
if (client && sessionId) {
|
||||
sessionIdentityRef.current = { client, sessionId }
|
||||
}
|
||||
const holderId = structuredAgentSessionHolderId('mobile-chat')
|
||||
const held = callAgentSession(client, 'agentSession.hold', {
|
||||
sessionId,
|
||||
holderId
|
||||
}).catch(() => undefined)
|
||||
return () => {
|
||||
void held.then(() =>
|
||||
callAgentSession(client, 'agentSession.release', {
|
||||
sessionId,
|
||||
holderId
|
||||
}).catch(() => undefined)
|
||||
)
|
||||
}
|
||||
}, [client, enabled, sessionId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !sessionId || !enabled) {
|
||||
sessionIdentityRef.current = null
|
||||
setState(EMPTY_STRUCTURED_AGENT_SESSION)
|
||||
setLoadingOlder(false)
|
||||
return
|
||||
}
|
||||
if (sessionChanged) {
|
||||
// Never show one structured tab's transcript under another tab.
|
||||
setState(EMPTY_STRUCTURED_AGENT_SESSION)
|
||||
}
|
||||
if (!connected) {
|
||||
// The connection-scoped hold and stream are retired in the prior cleanup;
|
||||
// retain the last transcript until the transport comes back.
|
||||
return
|
||||
}
|
||||
apply({ type: 'loading' })
|
||||
const unsubscribe = client.subscribe('agentSession.subscribe', { sessionId }, (raw) => {
|
||||
if (typeof raw === 'object' && raw !== null && (raw as { type?: unknown }).type === 'error') {
|
||||
apply({ type: 'error', message: String((raw as { message?: unknown }).message ?? '') })
|
||||
return
|
||||
}
|
||||
if (isSubscribeEvent(raw)) {
|
||||
apply({ type: 'event', event: raw })
|
||||
}
|
||||
const holderId = structuredAgentSessionHolderId('mobile-chat')
|
||||
let cancelled = false
|
||||
let unsubscribe = (): void => {}
|
||||
const held = callAgentSession(client, 'agentSession.hold', {
|
||||
sessionId,
|
||||
holderId
|
||||
})
|
||||
return unsubscribe
|
||||
}, [apply, client, enabled, sessionId])
|
||||
void held
|
||||
.then(() => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
unsubscribe = client.subscribe('agentSession.subscribe', { sessionId }, (raw) => {
|
||||
if (
|
||||
typeof raw === 'object' &&
|
||||
raw !== null &&
|
||||
(raw as { type?: unknown }).type === 'error'
|
||||
) {
|
||||
apply({ type: 'error', message: String((raw as { message?: unknown }).message ?? '') })
|
||||
return
|
||||
}
|
||||
if (isSubscribeEvent(raw)) {
|
||||
apply({ type: 'event', event: raw })
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!cancelled) {
|
||||
apply({ type: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
unsubscribe()
|
||||
void held
|
||||
.then(() =>
|
||||
callAgentSession(
|
||||
client,
|
||||
'agentSession.release',
|
||||
{
|
||||
sessionId,
|
||||
holderId
|
||||
},
|
||||
undefined,
|
||||
{ failWhenDisconnected: true }
|
||||
).catch(() => undefined)
|
||||
)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
}, [apply, client, connected, enabled, sessionId])
|
||||
|
||||
const loadEarlier = useCallback(() => {
|
||||
const current = stateRef.current
|
||||
|
||||
Reference in New Issue
Block a user