From 5c2d2bf6e38775c0e485cfb3511420c07a7ae5e0 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Wed, 12 Aug 2026 02:58:46 -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 086c3b6b8eb..2dde1b492cb 100644
--- a/mobile/src/session/MobileStructuredAgentSessionView.tsx
+++ b/mobile/src/session/MobileStructuredAgentSessionView.tsx
@@ -330,7 +330,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 55ec29de127..3021e327355 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
@@ -86,6 +86,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 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)
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')}