Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion mobile/src/session/mobile-existing-agent-launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import {
PROMPTED_AGENT_LAUNCH_TIMEOUT_MS,
launchAgentInExistingWorkspace,
reserveMobileAgentLaunch,
supportsMobileExistingAgentLaunch
supportsMobileExistingAgentLaunch,
AGENT_LAUNCH_TAB_CLOSED_MESSAGE
} from './mobile-existing-agent-launch'

// A connected client whose only behaviour is the scripted `sendRequest`.
Expand Down Expand Up @@ -115,6 +116,18 @@ describe('reserveMobileAgentLaunch', () => {
})
})

it('asks for the new tab after the one the user is on', async () => {
const { client, sendRequest } = scriptedClient(launched({}))
await launch(client, {
placement: { afterTabId: 'tab-current' },
mintOperationId: () => `1790000000000-${'b'.repeat(32)}`
})

expect(requestParam(sendRequest.mock.calls[0]![1], 'placement')).toEqual({
afterTabId: 'tab-current'
})
})

it('mints a pane the host adopts even where the runtime has no crypto.randomUUID', () => {
const native = Object.getOwnPropertyDescriptor(globalThis.crypto, 'randomUUID')
Object.defineProperty(globalThis.crypto, 'randomUUID', { value: undefined, configurable: true })
Expand Down Expand Up @@ -237,6 +250,14 @@ describe('launchAgentInExistingWorkspace', () => {
})
})

it('reads a closed tab as the user stopping the launch, not as the launch failing', async () => {
const { client } = scriptedClient(refused('agent_launch_tab_closed'))
await expect(launch(client)).resolves.toEqual({
kind: 'tab-closed',
message: AGENT_LAUNCH_TAB_CLOSED_MESSAGE
})
})

it('reports unknown when no answer arrives at all', async () => {
const { client } = scriptedClient({ throws: new Error('Request timed out') })
await expect(launch(client)).resolves.toEqual({
Expand Down
17 changes: 16 additions & 1 deletion mobile/src/session/mobile-existing-agent-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@
* every outcome is unit-testable.
*/

import type { AgentLaunchPrompt, AgentLaunchResult } from '../../../src/shared/agent-launch-intent'
import type {
AgentLaunchPlacement,
AgentLaunchPrompt,
AgentLaunchResult
} from '../../../src/shared/agent-launch-intent'
import { isAgentSessionHandleProvider } from '../../../src/shared/agent-session-provider-handle'
import { AGENT_LAUNCH_TAB_CLOSED_CODE } from '../../../src/shared/agent-launch-tab-closed'
import { makePaneKey } from '../../../src/shared/stable-pane-id'
import { createStructuredAgentSessionId } from '../../../src/shared/structured-agent-session-create'
import type { TuiAgent } from '../../../src/shared/tui-agent'
Expand Down Expand Up @@ -39,6 +44,9 @@ export const AGENT_LAUNCH_UNCONFIRMED_MESSAGE =

// The host started nothing, and the next tap reserves new ids.
export const AGENT_LAUNCH_RESERVATION_TAKEN_MESSAGE = "Couldn't start the agent. Try again."
// Who closed it, and whether the agent had started, are unknown here: the phone or the computer.
export const AGENT_LAUNCH_TAB_CLOSED_MESSAGE =
"The agent's tab was closed, so the agent was stopped."

/**
* The tab a launch will create, named by this device before it asks, so it can land there as soon
Expand Down Expand Up @@ -75,6 +83,8 @@ export type MobileExistingAgentLaunch =
| { kind: 'failed'; message: string }
/** The agent may or may not be running; the caller must not launch again on its own. */
| { kind: 'unknown'; message: string }
/** A user closed the launch's tab, which stopped it; not a failure of the launch. */
| { kind: 'tab-closed'; message: string }

export function supportsMobileExistingAgentLaunch(
hostCapabilities: readonly string[] | null | undefined
Expand All @@ -91,6 +101,7 @@ export async function launchAgentInExistingWorkspace(args: {
prompt?: AgentLaunchPrompt
launchSource?: string
reservation?: MobileAgentLaunchReservation
placement?: AgentLaunchPlacement
// Injected in tests; each call is one new operation, so a later tap never replays this one.
mintOperationId?: () => string
}): Promise<MobileExistingAgentLaunch> {
Expand All @@ -103,6 +114,7 @@ export async function launchAgentInExistingWorkspace(args: {
operationId: (args.mintOperationId ?? structuredSessionOperationId)(),
...(args.prompt ? { prompt: args.prompt } : {}),
...(args.launchSource ? { launchSource: args.launchSource } : {}),
...(args.placement ? { placement: args.placement } : {}),
...(args.reservation
? {
paneKey: makePaneKey(args.reservation.pane.tabId, args.reservation.pane.leafId),
Expand Down Expand Up @@ -159,6 +171,9 @@ function classifyLaunchRefusal(
if (isAgentLaunchReservationTakenRefusal(error)) {
return { kind: 'failed', message: AGENT_LAUNCH_RESERVATION_TAKEN_MESSAGE }
}
if (error.code === AGENT_LAUNCH_TAB_CLOSED_CODE) {
return { kind: 'tab-closed', message: AGENT_LAUNCH_TAB_CLOSED_MESSAGE }
}
const message = error.message?.trim()
return { kind: 'failed', message: message || "Couldn't start the agent." }
}
Expand Down
25 changes: 25 additions & 0 deletions mobile/src/session/new-tab-agent-host-launch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { MobileQuickCommandLaunch } from '../terminal/quick-commands'
import type { MobileSessionTab } from './mobile-session-route-types'
import type { TuiAgent } from '../../../src/shared/tui-agent'
import { AGENT_LAUNCH_PLACEMENT_RUNTIME_CAPABILITY } from '../../../src/shared/agent-launch-runtime-capability'
import type { RpcClient } from '../transport/rpc-client'
import { triggerError, triggerSuccess } from '../platform/haptics'
import {
Expand Down Expand Up @@ -32,6 +33,18 @@ export function launchesThroughHost(options: NewTabAgentLaunchOptions | undefine
return options?.startupCommand === undefined && options?.enter !== false
}

/** The host's id for a session tab: a terminal pane is listed under its parent tab. */
function hostTabIdOf(
tabs: readonly MobileSessionTab[],
sessionTabId: string | null | undefined
): string | undefined {
const tab = sessionTabId ? tabs.find((candidate) => candidate.id === sessionTabId) : undefined
if (!tab) {
return undefined
}
return tab.type === 'terminal' ? tab.parentTabId : tab.id
}

/**
* The "+" menu's agent start through `agent.launch`. The phone lands on the tab when it is listed,
* which for a paste-after-start agent is long before the reply; the reply only reports delivery.
Expand All @@ -45,6 +58,8 @@ export async function launchNewTabAgentThroughHost(args: {
options: NewTabAgentLaunchOptions | undefined
/** Names this launch's pending selection, as it names the "+" lock. */
lock: string
/** The session tab the user is on; the new tab follows it, as the plain "+" terminal does. */
activeSessionTabId?: string | null
pendingSelectionRef: { current: PendingSessionSelection | null }
fetchSessionTabs: () => Promise<void>
getSessionTabs: () => readonly MobileSessionTab[]
Expand All @@ -55,6 +70,10 @@ export async function launchNewTabAgentThroughHost(args: {
const { options, pendingSelectionRef, showToast } = args
const prompt = options?.agentPrompt ?? options?.initialPrompt
const reservation = reserveMobileAgentLaunch(args.agent)
// An older host would drop it anyway; asking only a host that reads it keeps the request as before.
const afterTabId = args.hostCapabilities?.includes(AGENT_LAUNCH_PLACEMENT_RUNTIME_CAPABILITY)
? hostTabIdOf(args.getSessionTabs(), args.activeSessionTabId)
: undefined
// Why: armed before asking, because the reply waits for prompt delivery while the tab is listed
// as soon as it exists. A tab the user picks meanwhile replaces this.
pendingSelectionRef.current = launchedSelection(args.lock, reservation, null)
Expand All @@ -64,6 +83,7 @@ export async function launchNewTabAgentThroughHost(args: {
worktreeId: args.worktreeId,
agent: args.agent,
reservation,
...(afterTabId ? { placement: { afterTabId } } : {}),
...(prompt?.trim() ? { prompt: { text: prompt, delivery: 'submit' as const } } : {}),
...(options?.agentPrompt
? { launchSource: 'quick_command' }
Expand All @@ -79,6 +99,11 @@ export async function launchNewTabAgentThroughHost(args: {
args.reportCreateFailure(launched.message)
return true
}
if (launched.kind === 'tab-closed') {
// Why silent: the tab this "+" opened is gone, which is the answer; the close was a user's own.
pendingSelectionRef.current = withoutUnansweredLaunch(pendingSelectionRef.current, args.lock)
return true
}
if (launched.kind === 'unknown') {
// Why: a listed tab proves the agent started, so only the prompt is in doubt; notes stay unsent.
if (isLaunchedSurfaceListed(args.getSessionTabs(), reservation)) {
Expand Down
18 changes: 18 additions & 0 deletions mobile/src/session/pending-session-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { MobileSessionTab } from './mobile-session-route-types'
import {
LAUNCHED_SELECTION_SNAPSHOT_BUDGET,
isLaunchedSurfaceListed,
launchedSelection,
pendingSelectionHandle,
pendingSelectionTabId,
Expand Down Expand Up @@ -165,3 +166,20 @@ describe('the halves of a pick', () => {
)
})
})

describe('isLaunchedSurfaceListed', () => {
it('reads a listed terminal as a started agent', () => {
expect(isLaunchedSurfaceListed([reservedTerminalTab('term_1')], { pane: PANE })).toBe(true)
})

it('does not read a tab the host showed before its agent existed as a start', () => {
expect(isLaunchedSurfaceListed([reservedTerminalTab(null)], { pane: PANE })).toBe(false)
})

it('still lands the selection on that tab, so the phone shows it at once', () => {
const resolved = resolveLaunchedSelection(launchedSelection('lock', { pane: PANE }, null), [
reservedTerminalTab(null)
])
expect(resolved.landedTabId).toBe(`${PANE.tabId}::${PANE.leafId}`)
})
})
7 changes: 5 additions & 2 deletions mobile/src/session/pending-session-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,13 +135,16 @@ export function resolveLaunchedSelection(
}
}

/** Whether the host lists a launch's tab, which proves the agent started. */
/** Whether the host lists a launch's running surface, which proves the agent started. */
export function isLaunchedSurfaceListed(
tabs: readonly MobileSessionTab[],
surface: Partial<LaunchedSurface>
): boolean {
const known: LaunchedSurface = { pane: null, sessionId: null, handle: null, ...surface }
return tabs.some((tab) => isLaunchedTab(tab, known))
// A host may list a launch's terminal tab before its agent exists; only a terminal proves a start.
return tabs.some(
(tab) => isLaunchedTab(tab, known) && (tab.type !== 'terminal' || tab.terminal !== null)
)
}

function isLaunchedTab(tab: MobileSessionTab, surface: LaunchedSurface): boolean {
Expand Down
1 change: 1 addition & 0 deletions mobile/src/session/pr-ai-triage-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export async function launchAgentWithPrompt(args: {
case 'unsupported':
return { kind: 'not-started', message: AGENT_LAUNCH_UPDATE_REQUIRED_MESSAGE }
case 'failed':
case 'tab-closed':
return { kind: 'not-started', message: launched.message }
case 'unknown':
return { kind: 'unconfirmed', message: launched.message }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export function useMobileSessionTerminalCreateActions(scope: MobileSessionAttach
agent,
options,
lock: clientMutationId,
activeSessionTabId,
pendingSelectionRef,
fetchSessionTabs,
getSessionTabs: () => sessionTabsRef.current,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createElement } from 'react'
import { AGENT_LAUNCH_PLACEMENT_RUNTIME_CAPABILITY } from '../../../src/shared/agent-launch-runtime-capability'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {
Expand All @@ -25,6 +26,7 @@ import { releaseTerminalCreateLock } from './terminal-create-lock'
import { useMobileSessionTerminalCreateActions } from './use-mobile-session-terminal-create-actions'

vi.mock('../platform/haptics', () => ({ triggerSuccess: vi.fn(), triggerError: vi.fn() }))
const { triggerError } = await import('../platform/haptics')

const LAUNCH_CAPABILITIES = [
'agent.launch.v2',
Expand Down Expand Up @@ -198,6 +200,39 @@ describe('the + menu', () => {
expect(state.scheduleDelayedAction).not.toHaveBeenCalled()
})

it('asks a host that places tabs to put the new one after the tab the user is on', async () => {
const { client, sendRequest } = scriptedClient(
launchReply({ kind: 'terminal', handle: 'term_7' })
)
const state = scope(client, [...LAUNCH_CAPABILITIES, AGENT_LAUNCH_PLACEMENT_RUNTIME_CAPABILITY])
state.sessionTabsRef.current = [
{
type: 'terminal',
id: 'existing-tab',
parentTabId: 'host-tab-1',
leafId: 'leaf-1',
title: 'Terminal',
terminal: 'term_1',
isActive: true
}
]

await create_(state, 'claude')

// A terminal pane is listed under its parent tab; that is the id the host places by.
expect(launchParams(sendRequest)).toMatchObject({ placement: { afterTabId: 'host-tab-1' } })
})

it('sends no placement to a host that would not read it', async () => {
const { client, sendRequest } = scriptedClient(
launchReply({ kind: 'terminal', handle: 'term_7' })
)

await create_(scope(client), 'claude')

expect(launchParams(sendRequest)).not.toHaveProperty('placement')
})

it('waits for a chat by its session id, not a predicted tab id', async () => {
const { client } = scriptedClient(
launchReply({ kind: 'structured', sessionId: 'claude_s1', handle: 'h' })
Expand Down Expand Up @@ -323,6 +358,19 @@ describe('the + menu', () => {
expect(methods(sendRequest)).toEqual(['agent.launchReplay', 'session.tabs.createTerminal'])
})

it('says nothing when a user closed the tab the launch opened: the tab going is the answer', async () => {
const { client } = scriptedClient(refusal('agent_launch_tab_closed'))
const state = scope(client)
vi.mocked(triggerError).mockClear()

await create_(state, 'claude')

expect(state.showToast).not.toHaveBeenCalled()
expect(state.setCreateError).not.toHaveBeenCalledWith(expect.stringMatching(/./))
expect(triggerError).not.toHaveBeenCalled()
expect(state.pendingSelectionRef.current).toBeNull()
})

it("shows the host's refusal even when the session already has tabs", async () => {
const { client, sendRequest } = scriptedClient({
id: 'x',
Expand Down
6 changes: 5 additions & 1 deletion mobile/src/tasks/agent-launch-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import {
withoutReservedAgentCreateFields,
type AgentLaunchPlacement,
type AgentLaunchPrompt,
type AgentLaunchResult
} from '../../../src/shared/agent-launch-intent'
Expand Down Expand Up @@ -84,6 +85,7 @@ export function agentLaunchExistingParams(args: {
launchSource?: string
paneKey?: string
sessionId?: string
placement?: AgentLaunchPlacement
}): RpcSendParams<'agent.launchReplay'> {
return {
agent: args.agent,
Expand All @@ -92,7 +94,9 @@ export function agentLaunchExistingParams(args: {
...(args.prompt ? { prompt: args.prompt } : {}),
...(args.launchSource ? { launchSource: args.launchSource } : {}),
...(args.paneKey ? { paneKey: args.paneKey } : {}),
...(args.sessionId ? { sessionId: args.sessionId } : {})
...(args.sessionId ? { sessionId: args.sessionId } : {}),
// An older host drops it and the tab lands in its active group, as before.
...(args.placement ? { placement: args.placement } : {})
}
}

Expand Down
15 changes: 14 additions & 1 deletion mobile/src/transport/mobile-runtime-client-capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import {
STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
} from '../../../src/shared/protocol-version'
import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../src/shared/agent-launch-runtime-capability'
import {
AGENT_LAUNCH_RUNTIME_CAPABILITY,
AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY,
AGENT_LAUNCH_UNSTARTED_TAB_CLIENT_CAPABILITY
} from '../../../src/shared/agent-launch-runtime-capability'
import { MOBILE_RUNTIME_CLIENT_CAPABILITIES } from './mobile-runtime-client-capabilities'

/** Mirrors the host's `parseRuntimeClientCapabilities`, which returns an EMPTY list — silently
Expand Down Expand Up @@ -34,6 +38,15 @@ describe('mobile runtime client capabilities', () => {
expect(MOBILE_RUNTIME_CLIENT_CAPABILITIES).toContain(AGENT_LAUNCH_RUNTIME_CAPABILITY)
})

it('reads an early launch tab as not started and a closed one as stopped', () => {
expect(MOBILE_RUNTIME_CLIENT_CAPABILITIES).toEqual(
expect.arrayContaining([
AGENT_LAUNCH_UNSTARTED_TAB_CLIENT_CAPABILITY,
AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY
])
)
})

it('stays inside the bounds the host parses, which fail closed to no capabilities at all', () => {
expect(MOBILE_RUNTIME_CLIENT_CAPABILITIES.length).toBeLessThanOrEqual(HOST_CAPABILITY_LIMIT)
for (const capability of MOBILE_RUNTIME_CLIENT_CAPABILITIES) {
Expand Down
12 changes: 10 additions & 2 deletions mobile/src/transport/mobile-runtime-client-capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import {
STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
} from '../../../src/shared/protocol-version'
import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../src/shared/agent-launch-runtime-capability'
import {
AGENT_LAUNCH_RUNTIME_CAPABILITY,
AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY,
AGENT_LAUNCH_UNSTARTED_TAB_CLIENT_CAPABILITY
} from '../../../src/shared/agent-launch-runtime-capability'
import { remoteRuntimeClientCapabilities } from '../../../src/shared/remote-runtime-client-capabilities'

export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilities([
Expand All @@ -20,7 +24,11 @@ export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilitie
// Mobile renders either launch outcome — a structured chat or a terminal agent — so it may ask
// the host to pick. Without this the host refuses `agent.launch` and every mobile create with an
// agent stays a PTY.
AGENT_LAUNCH_RUNTIME_CAPABILITY
AGENT_LAUNCH_RUNTIME_CAPABILITY,
// Reads a listed launch tab with no terminal yet as not started, so the host may show it early.
AGENT_LAUNCH_UNSTARTED_TAB_CLIENT_CAPABILITY,
// Reads `agent_launch_tab_closed` (a user closed its tab, which stopped it) as a definite answer.
AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY
])

export const MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD =
Expand Down
Loading
Loading