From 7c4f94b0e4dde0964e4f8919427e8fbdb948ff79 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Tue, 1 Sep 2026 14:27:01 -0700 Subject: [PATCH] fix(mobile): sequence structured hold before subscribe --- ...e-mobile-structured-agent-session.test.tsx | 28 +++++++ .../use-mobile-structured-agent-state.ts | 82 +++++++++++-------- 2 files changed, 76 insertions(+), 34 deletions(-) diff --git a/mobile/src/session/use-mobile-structured-agent-session.test.tsx b/mobile/src/session/use-mobile-structured-agent-session.test.tsx index 27257f506a6..3165d069223 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session.test.tsx @@ -254,6 +254,34 @@ describe('useMobileStructuredAgentSession', () => { ) }) + it('waits for the structured hold before subscribing', async () => { + let resolveHold: (() => void) | null = null + sendRequest.mockImplementation((method, params) => { + if (method === 'agentSession.hold') { + return new Promise((resolve) => { + resolveHold = () => resolve(ok({ held: true })) + }) + } + return defaultSendRequest(method, params) + }) + + act(() => { + renderer = create(createElement(Harness)) + }) + + await vi.waitFor(() => expect(resolveHold).not.toBeNull()) + expect(subscribe).not.toHaveBeenCalled() + + await act(async () => resolveHold?.()) + await vi.waitFor(() => + expect(subscribe).toHaveBeenCalledWith( + 'agentSession.subscribe', + { sessionId: 'session-1' }, + expect.any(Function) + ) + ) + }) + it('sends with the shared structured mutation envelope after the stream fence lands', async () => { act(() => { renderer = create(createElement(Harness)) diff --git a/mobile/src/session/use-mobile-structured-agent-state.ts b/mobile/src/session/use-mobile-structured-agent-state.ts index c8564e3ea97..e5cfa745f00 100644 --- a/mobile/src/session/use-mobile-structured-agent-state.ts +++ b/mobile/src/session/use-mobile-structured-agent-state.ts @@ -45,31 +45,6 @@ export function useMobileStructuredAgentState(args: { setState((current) => reduceStructuredAgentSession(current, action)) }, []) - useEffect(() => { - if (!client || !sessionId || !enabled) { - return - } - 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 - }, - undefined, - { failWhenDisconnected: true } - ).catch(() => undefined) - ) - } - }, [client, enabled, sessionId]) - useEffect(() => { if (!client || !sessionId || !enabled) { setState(EMPTY_STRUCTURED_AGENT_SESSION) @@ -77,16 +52,55 @@ export function useMobileStructuredAgentState(args: { 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 + 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, enabled, sessionId]) const loadEarlier = useCallback(() => {