From 20c2deda29543cd233c9c15f0ae1f7c049cf196c Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Wed, 12 Aug 2026 18:42:39 -0700
Subject: [PATCH] fix(native-chat): queue stale idle TUI handoffs
---
.../MobileStructuredAgentSessionView.tsx | 3 +-
.../structured-agent-session-handoff.test.ts | 55 ++++++++++---------
...ructuredAgentSessionHandoffChrome.test.tsx | 33 +++++++++++
.../StructuredAgentSessionHandoffChrome.tsx | 3 +-
4 files changed, 65 insertions(+), 29 deletions(-)
create mode 100644 src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.test.tsx
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')}