fix(native-chat): queue stale idle TUI handoffs

This commit is contained in:
Brennan Benson
2026-08-12 02:58:46 -07:00
parent f5cfa241a1
commit 5c2d2bf6e3
4 changed files with 65 additions and 29 deletions
@@ -330,7 +330,8 @@ function MobileStructuredHandoffBanner(props: {
) : (
<Pressable
style={({ pressed }) => [styles.handoffButton, pressed && styles.pressed]}
onPress={() => void props.onRequest('to-tui', 'now')}
// A submitted turn can reach the host before isWorking updates; after-turn is immediate when idle.
onPress={() => void props.onRequest('to-tui', 'after-turn')}
>
<Text style={styles.handoffButtonText}>Open agent TUI</Text>
</Pressable>
@@ -86,6 +86,10 @@ function submit(params: AgentSessionHandoffRequest) {
return coordinator.request('client-1', params)
}
async function expectAccepted(params: AgentSessionHandoffRequest): Promise<void> {
expect(await submit(params)).toMatchObject({ ok: true })
}
function process(spawnToken: string, pid: number) {
return structuredHandoffTestProcess(NOW, spawnToken, pid)
}
@@ -355,9 +359,7 @@ describe.each(STRUCTURED_HANDOFF_PROVIDER_CASES)(
await appendStatus('running')
expect(await submit(request('to-tui', 'after-turn'))).toMatchObject({ ok: true })
expect(coordinator.status(SESSION).phase).toBe('queued')
expect(
await submit(request('to-tui', 'after-turn', { action: 'cancel-queued' }))
).toMatchObject({ ok: true })
await expectAccepted(request('to-tui', 'after-turn', { action: 'cancel-queued' }))
await appendStatus('completed')
await new Promise((resolve) => setTimeout(resolve, 200))
expect(launchTui).not.toHaveBeenCalled()
@@ -670,9 +672,7 @@ describe.each(STRUCTURED_HANDOFF_PROVIDER_CASES)(
waitForTuiExit.mockRejectedValueOnce(new Error('terminal_handle_stale'))
const retryOperation = operationId()
expect(
await submit(request('to-native', 'now', { operationId: retryOperation }))
).toMatchObject({ ok: true })
await expectAccepted(request('to-native', 'now', { operationId: retryOperation }))
await waitForPhase('failed')
expect(coordinator.status(SESSION)).toMatchObject({
@@ -687,9 +687,9 @@ describe.each(STRUCTURED_HANDOFF_PROVIDER_CASES)(
handoffOperationId: null
})
expect(
await submit(request('to-native', 'now', { action: 'retry', operationId: retryOperation }))
).toMatchObject({ ok: true })
await expectAccepted(
request('to-native', 'now', { action: 'retry', operationId: retryOperation })
)
await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native'))
expect(waitForTuiExit).toHaveBeenCalledTimes(2)
})
@@ -745,12 +745,17 @@ describe.each(STRUCTURED_HANDOFF_PROVIDER_CASES)(
expect(store.getRecord(SESSION)?.lease.runtimeKind).toBe('native')
})
it('keeps a recoverable native owner after repeated transfers and a dead TUI relaunch', async () => {
for (let cycle = 0; cycle < 2; cycle += 1) {
await moveToTui()
expect(await submit(request('to-native', 'after-turn'))).toMatchObject({ ok: true })
await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native'))
}
it('completes a queued second cycle and keeps native recoverable after a dead relaunch', async () => {
await moveToTui()
expect(await submit(request('to-native', 'after-turn'))).toMatchObject({ ok: true })
await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native'))
await appendStatus('running')
expect(await submit(request('to-tui', 'after-turn'))).toMatchObject({ ok: true })
await appendStatus('completed')
await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('tui'))
expect(await submit(request('to-native', 'after-turn'))).toMatchObject({ ok: true })
await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native'))
launchTui.mockImplementationOnce(async ({ fence, spawnToken }) => ({
...makeTuiOwner(fence, spawnToken),
link: link(fence + 1, 'dead-relaunch')
@@ -807,9 +812,7 @@ describe.each(STRUCTURED_HANDOFF_PROVIDER_CASES)(
await moveToTui()
importFailure = new Error('history unavailable')
const retryOperation = operationId()
expect(
await submit(request('to-native', 'after-turn', { operationId: retryOperation }))
).toMatchObject({ ok: true })
await expectAccepted(request('to-native', 'after-turn', { operationId: retryOperation }))
await waitForPhase('failed')
expect(coordinator.status(SESSION)).toMatchObject({
owner: 'tui',
@@ -822,9 +825,9 @@ describe.each(STRUCTURED_HANDOFF_PROVIDER_CASES)(
handoffStage: 'old-owner-stopped'
})
expect(
await submit(request('to-native', 'now', { action: 'retry', operationId: retryOperation }))
).toMatchObject({ ok: true })
await expectAccepted(
request('to-native', 'now', { action: 'retry', operationId: retryOperation })
)
await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native'))
expect(waitForTuiExit).toHaveBeenCalledOnce()
})
@@ -833,9 +836,7 @@ describe.each(STRUCTURED_HANDOFF_PROVIDER_CASES)(
await moveToTui()
nativeAcquireFailure = new Error('native proof unavailable')
const retryOperation = operationId()
expect(
await submit(request('to-native', 'after-turn', { operationId: retryOperation }))
).toMatchObject({ ok: true })
await expectAccepted(request('to-native', 'after-turn', { operationId: retryOperation }))
await waitForPhase('failed')
expect(coordinator.status(SESSION)).toMatchObject({
@@ -856,9 +857,9 @@ describe.each(STRUCTURED_HANDOFF_PROVIDER_CASES)(
phase: 'failed',
operationId: retryOperation
})
expect(
await submit(request('to-native', 'now', { action: 'retry', operationId: retryOperation }))
).toMatchObject({ ok: true })
await expectAccepted(
request('to-native', 'now', { action: 'retry', operationId: retryOperation })
)
await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native'))
expect(waitForTuiExit).toHaveBeenCalledOnce()
expect(acquireNativeCalls).toBe(2)
@@ -0,0 +1,33 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentSessionHandoffStatus } from '../../../../shared/agent-session-wire'
import { StructuredAgentSessionHandoffChrome } from './StructuredAgentSessionHandoffChrome'
const IDLE_NATIVE: AgentSessionHandoffStatus = {
owner: 'native',
direction: null,
phase: 'idle',
stage: null,
operationId: null
}
afterEach(cleanup)
describe('StructuredAgentSessionHandoffChrome', () => {
it('uses queued-safe admission when the native view still appears idle', () => {
const onRequest = vi.fn()
render(
<StructuredAgentSessionHandoffChrome
status={IDLE_NATIVE}
isWorking={false}
onRequest={onRequest}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Open agent TUI' }))
expect(onRequest).toHaveBeenCalledWith('to-tui', 'after-turn')
})
})
@@ -126,7 +126,8 @@ export function StructuredAgentSessionHandoffChrome({
type="button"
variant="ghost"
size="xs"
onClick={() => onRequest('to-tui', 'now')}
// A submitted turn can reach the host before isWorking updates; after-turn is immediate when idle.
onClick={() => onRequest('to-tui', 'after-turn')}
>
{translate('components.native-chat.handoff.openAgentTui', 'Open agent TUI')}
</Button>