diff --git a/mobile/src/session/MobileStructuredAgentSessionView.tsx b/mobile/src/session/MobileStructuredAgentSessionView.tsx index 042f63ddd6c..eed68f68ce7 100644 --- a/mobile/src/session/MobileStructuredAgentSessionView.tsx +++ b/mobile/src/session/MobileStructuredAgentSessionView.tsx @@ -322,7 +322,8 @@ function MobileStructuredHandoffBanner(props: { ) : ( [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')} > Open agent TUI diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts index 56f7a39c1ed..d5bc04b8f9a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts @@ -95,6 +95,10 @@ function submit(params: AgentSessionHandoffRequest) { return coordinator.request('client-1', params) } +async function expectAccepted(params: AgentSessionHandoffRequest): Promise { + expect(await submit(params)).toMatchObject({ ok: true }) +} + function process(spawnToken: string, pid: number) { return { hostId: 'local', @@ -351,9 +355,7 @@ describe('structured session ownership handoff', () => { 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() @@ -666,9 +668,7 @@ describe('structured session ownership handoff', () => { 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({ @@ -683,9 +683,9 @@ describe('structured session ownership handoff', () => { 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) }) @@ -741,12 +741,17 @@ describe('structured session ownership handoff', () => { 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') @@ -798,9 +803,7 @@ describe('structured session ownership handoff', () => { 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', @@ -813,9 +816,9 @@ describe('structured session ownership handoff', () => { 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() }) @@ -824,9 +827,7 @@ describe('structured session ownership handoff', () => { 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({ @@ -847,9 +848,9 @@ describe('structured session ownership handoff', () => { 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) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.test.tsx new file mode 100644 index 00000000000..b0fc96bb475 --- /dev/null +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.test.tsx @@ -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( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Open agent TUI' })) + + expect(onRequest).toHaveBeenCalledWith('to-tui', 'after-turn') + }) +}) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx index 30bb0103552..367cd422822 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx @@ -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')}