Skip to content
78 changes: 70 additions & 8 deletions mobile/src/session/MobileNativeChatBackgroundTasks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,23 @@ function view(id: string, overrides: Partial<AgentChildWorkView> = {}): AgentChi
}

type Props = Parameters<typeof MobileNativeChatBackgroundTasks>[0]
type StopResult = Awaited<ReturnType<Props['tasks']['stop']>>

const REJECTED: StopResult = { status: 'rejected' }
const UNCONFIRMED: StopResult = { status: 'unknown' }
const STOPPED: StopResult = { status: 'accepted', value: { cancelled: true }, sameFence: true }
const NOTHING_STOPPED: StopResult = {
status: 'accepted',
value: { cancelled: false },
sameFence: true
}

function tasksFor(
state: AgentSessionBackgroundTaskState | null,
options: {
streamLive?: boolean
hostClockOffsetMs?: number
stop?: (taskId?: string) => Promise<unknown>
stop?: Props['tasks']['stop']
} = {}
): Props['tasks'] {
return {
Expand All @@ -65,7 +75,7 @@ function tasksFor(
options.hostClockOffsetMs ?? 0
),
sessionKey: 'session-a',
stop: options.stop ?? vi.fn(async () => undefined)
stop: options.stop ?? vi.fn(async () => REJECTED)
}
}

Expand Down Expand Up @@ -264,8 +274,8 @@ describe('MobileNativeChatBackgroundTasks', () => {
})

it('stops one row by its provider id and holds its button while the Stop is on its way', async () => {
let finish: () => void = () => {}
const stop = vi.fn(() => new Promise<void>((resolve) => (finish = resolve)))
let finish: (result: StopResult) => void = () => {}
const stop = vi.fn(() => new Promise<StopResult>((resolve) => (finish = resolve)))
const mounted = mount(
tasksFor(
{
Expand All @@ -284,13 +294,65 @@ describe('MobileNativeChatBackgroundTasks', () => {
expect(stop).toHaveBeenCalledTimes(1)
expect(stop).toHaveBeenCalledWith('task-a')
expect(stopButtons(mounted)[0]!.props.disabled).toBe(true)
await act(async () => finish())
// Unconfirmed: the row is the verdict, so its Stop is offered again.
await act(async () => finish(UNCONFIRMED))
expect(stopButtons(mounted)[0]!.props.disabled).toBe(false)
})

it('keeps a confirmed Stop held until its row leaves, and offers it again if the row comes back', async () => {
let finish: (result: StopResult) => void = () => {}
const stop = vi.fn(() => new Promise<StopResult>((resolve) => (finish = resolve)))
const running = { state: 'monitoring' as const, supportsTaskStop: true }
const mounted = mount(tasksFor({ ...running, children: [view('a'), view('b')] }, { stop }))
const show = (children: AgentChildWorkView[]) =>
act(() => {
mounted.update(
createElement(MobileNativeChatBackgroundTasks, {
tasks: tasksFor({ ...running, children }, { stop })
})
)
})
expand(mounted)
act(() => stopButtons(mounted)[0]!.props.onPress())
await act(async () => finish(STOPPED))
expect(stopButtons(mounted).map((button) => button.props.disabled)).toEqual([true, false])
show([view('b')])
show([view('a'), view('b')])
expect(stopButtons(mounted).map((button) => button.props.disabled)).toEqual([false, false])
act(() => stopButtons(mounted)[0]!.props.onPress())
await act(async () => finish(NOTHING_STOPPED))
expect(stopButtons(mounted)[0]!.props.disabled).toBe(false)
})

it('stops a backgrounded Codex command by its own id, and offers none on the subagent beside it', () => {
const stop = vi.fn(async () => REJECTED)
const mounted = mount(
tasksFor(
{
state: 'monitoring',
supportsTaskStop: true,
children: [
view('agent', { providerId: 'codex-agent:child-1', stoppable: false }),
view('cmd', {
kind: 'command',
providerId: 'codex-command:exec-1',
description: 'pnpm dev'
})
]
},
{ stop }
)
)
expand(mounted)
const buttons = stopButtons(mounted)
expect(buttons.map((button) => button.props.accessibilityLabel)).toEqual(['Stop pnpm dev'])
act(() => buttons[0]!.props.onPress())
expect(stop).toHaveBeenCalledWith('codex-command:exec-1')
})

it('offers Stop all only to a host with no per-row stop that still accepts one', async () => {
let finish: () => void = () => {}
const stop = vi.fn(() => new Promise<void>((resolve) => (finish = resolve)))
let finish: (result: StopResult) => void = () => {}
const stop = vi.fn(() => new Promise<StopResult>((resolve) => (finish = resolve)))
const fallback = mount(tasksFor({ state: 'monitoring', children: [view('a')] }, { stop }))
expand(fallback)
expect(stopButtons(fallback).map((button) => button.props.accessibilityLabel)).toEqual([
Expand All @@ -305,7 +367,7 @@ describe('MobileNativeChatBackgroundTasks', () => {
expect(stop).toHaveBeenCalledTimes(1)
expect(stop).toHaveBeenCalledWith(undefined)
expect(stopButtons(fallback)[0]!.props.disabled).toBe(true)
await act(async () => finish())
await act(async () => finish(STOPPED))
expect(stopButtons(fallback)[0]!.props.disabled).toBe(false)
const none = mount(
tasksFor({ state: 'monitoring', supportsStopAll: false, children: [view('a')] })
Expand Down
37 changes: 7 additions & 30 deletions mobile/src/session/MobileNativeChatBackgroundTasks.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { memo, useMemo, useRef, useState } from 'react'
import { memo, useMemo, useState } from 'react'
import {
Dimensions,
type LayoutChangeEvent,
Expand Down Expand Up @@ -36,6 +36,10 @@ import { AGENT_WORKING_COLOR, AgentStateDot } from '../components/AgentStateDot'
import { useNow } from '../hooks/use-now'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { MobileStructuredBackgroundTasks } from './use-mobile-structured-background-tasks'
import {
useMobileBackgroundTaskStops,
type MobileBackgroundTaskStopping as Stopping
} from './use-mobile-background-task-stops'
import { mobileAgentChildRowName, mobileAgentChildRowText } from './mobile-agent-child-row-text'

type TaskKind = AgentSessionBackgroundTask['kind']
Expand Down Expand Up @@ -64,9 +68,6 @@ function kindIconColor(kind: TaskKind): string {
return kind === 'monitor' ? AGENT_WORKING_COLOR : colors.textMuted
}

type Stopping = { taskIds: ReadonlySet<string>; all: boolean }
const NOT_STOPPING: Stopping = { taskIds: new Set(), all: false }

function TaskRow({
row,
now,
Expand Down Expand Up @@ -149,14 +150,13 @@ function MobileNativeChatBackgroundTasksImpl({
}: {
tasks: MobileStructuredBackgroundTasks
}): React.JSX.Element | null {
const { view, rowContext, stop } = tasks
const { view, rowContext } = tasks
const [expanded, setExpanded] = useState(false)
// Before the first layout, the window less the strip's own margins, so it opens in its final form.
const [narrow, setNarrow] = useState(
() => Dimensions.get('window').width - 2 * spacing.lg < NARROW_STRIP_WIDTH
)
const [stopping, setStopping] = useState<Stopping>(NOT_STOPPING)
const stoppingRef = useRef(NOT_STOPPING)
const { stopping, onStop } = useMobileBackgroundTaskStops(tasks)
const groups = useMemo(
() =>
view.children !== undefined
Expand All @@ -172,29 +172,6 @@ function MobileNativeChatBackgroundTasksImpl({
const hostNow = now - rowContext.hostClockOffsetMs
const header = backgroundTasksHeaderContent(groups, { narrow, now: hostNow }, say)

const updateStopping = (next: Stopping): void => {
stoppingRef.current = next
setStopping(next)
}
// A press already on its way holds its button; the ref closes the same-frame double tap.
const onStop = (taskId?: string): void => {
const current = stoppingRef.current
if (taskId ? current.taskIds.has(taskId) : current.all) {
return
}
updateStopping({
taskIds: taskId ? new Set([...current.taskIds, taskId]) : current.taskIds,
all: taskId ? current.all : true
})
void stop(taskId).finally(() => {
const latest = stoppingRef.current
const taskIds = new Set(latest.taskIds)
if (taskId) {
taskIds.delete(taskId)
}
updateStopping({ taskIds, all: taskId ? latest.all : false })
})
}
const onLayout = (event: LayoutChangeEvent): void => {
setNarrow(event.nativeEvent.layout.width < NARROW_STRIP_WIDTH)
}
Expand Down
61 changes: 61 additions & 0 deletions mobile/src/session/use-mobile-background-task-stops.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useRef, useState } from 'react'
import { structuredSessionConfirmedStopsStillListed } from '../../../src/shared/structured-session-background-tasks-view'
import type { MobileStructuredBackgroundTasks } from './use-mobile-structured-background-tasks'

export type MobileBackgroundTaskStopping = { taskIds: ReadonlySet<string>; all: boolean }

const NO_TASKS: ReadonlySet<string> = new Set()
const NOT_STOPPING: MobileBackgroundTaskStopping = { taskIds: NO_TASKS, all: false }

/** The strip's Stop presses: which buttons hold, and the press that sends one. */
export function useMobileBackgroundTaskStops(
tasks: Pick<MobileStructuredBackgroundTasks, 'view' | 'stop'>
): { stopping: MobileBackgroundTaskStopping; onStop: (taskId?: string) => void } {
const { view, stop } = tasks
const [stopping, setStopping] = useState<MobileBackgroundTaskStopping>(NOT_STOPPING)
const stoppingRef = useRef(NOT_STOPPING)
// Stops the host confirmed: each row keeps its stopping button until it leaves the strip.
const [confirmed, setConfirmed] = useState<ReadonlySet<string>>(NO_TASKS)
const confirmedListed = structuredSessionConfirmedStopsStillListed(view, confirmed)
if (confirmedListed !== confirmed) {
// A row that left and came back is a task the Stop did not end, so it offers Stop again.
setConfirmed(confirmedListed)
}

const updateStopping = (next: MobileBackgroundTaskStopping): void => {
stoppingRef.current = next
setStopping(next)
}
// A press already on its way holds its button; the ref closes the same-frame double tap.
const onStop = (taskId?: string): void => {
const current = stoppingRef.current
if (taskId ? current.taskIds.has(taskId) : current.all) {
return
}
updateStopping({
taskIds: taskId ? new Set([...current.taskIds, taskId]) : current.taskIds,
all: taskId ? current.all : true
})
void stop(taskId)
.then((result) => {
if (taskId && result.status === 'accepted' && result.value.cancelled) {
setConfirmed((held) => new Set([...held, taskId]))
}
})
.finally(() => {
const latest = stoppingRef.current
const taskIds = new Set(latest.taskIds)
if (taskId) {
taskIds.delete(taskId)
}
updateStopping({ taskIds, all: taskId ? latest.all : false })
})
}

return {
stopping: confirmedListed.size
? { ...stopping, taskIds: new Set([...stopping.taskIds, ...confirmedListed]) }
: stopping,
onStop
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ function tasks(
sessionKey,
view: structuredSessionBackgroundTasksView(state, null),
rowContext: agentChildRowContextForSessionStream(true, 0),
stop: vi.fn(async () => undefined)
stop: vi.fn(async () => ({ status: 'rejected' as const }))
}
}

Expand Down
6 changes: 4 additions & 2 deletions mobile/src/session/use-mobile-structured-background-tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@

import { useCallback, useMemo } from 'react'
import type { AgentChildRowContext } from '../../../src/shared/agent-child-row-model'
import type { AgentSessionCancelResult } from '../../../src/shared/agent-session-wire'
import { agentChildRowContextForSessionStream } from '../../../src/shared/agent-child-row-stream-context'
import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer'
import {
structuredSessionBackgroundTasksView,
type StructuredSessionBackgroundTasksView
} from '../../../src/shared/structured-session-background-tasks-view'
import type { StructuredAgentSessionMutationResult } from './mobile-structured-agent-session-rpc'
import type { MobileStructuredAgentMutate } from './use-mobile-structured-agent-mutation'

export type MobileStructuredBackgroundTasks = {
Expand All @@ -19,7 +21,7 @@ export type MobileStructuredBackgroundTasks = {
* host clock offset also moves the rows' host-stamped clocks onto the phone's. */
rowContext: AgentChildRowContext
/** One child by its provider id, or, with none, every background task the host can stop. */
stop: (taskId?: string) => Promise<unknown>
stop: (taskId?: string) => Promise<StructuredAgentSessionMutationResult<AgentSessionCancelResult>>
}

/** Coarser than one frame's delivery latency, finer than any clock a row shows. */
Expand Down Expand Up @@ -53,7 +55,7 @@ export function useMobileStructuredBackgroundTasks(args: {
)
const stop = useCallback(
(taskId?: string) =>
mutate('agentSession.cancel', 'agentSession.cancel', {
mutate<AgentSessionCancelResult>('agentSession.cancel', 'agentSession.cancel', {
turnId: 'background-tasks',
scope: 'background-tasks',
...(taskId ? { taskId } : {})
Expand Down
17 changes: 14 additions & 3 deletions src/main/acp/acp-structured-child-stop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,20 +35,31 @@ export async function stopAcpChildren(
fence: number,
taskIds: readonly string[],
at: () => number
): Promise<{ cancelled: boolean }> {
): Promise<{ cancelled: boolean; stillRunning?: true }> {
const control = session.spec.dialect.subagentStop
if (!control || !session.subagentStopSupported || session.fence !== fence) {
throw new Error('ACP child cancellation is unavailable')
}
let cancelled = false
let stillRunning = false
for (const id of taskIds) {
const request = control.request(session.lane.translator.providerSessionId, id)
const value = await session.connection.requestExtension(request.method, request.params)
let value: unknown
try {
value = await session.connection.requestExtension(request.method, request.params)
} catch (error) {
// Grok answers an ended or unknown subagent normally, so its refusal means it did nothing; anything else leaves the effect unknown.
if (error instanceof AcpAgentError) {
stillRunning = true
continue
}
throw error
}
const result = control.response(value, id)
if (result.state && result.state !== 'working') {
session.lane.apply(session.lane.translator.reconcileSubagent(id, result.state, at()))
}
cancelled ||= result.cancelled
}
return { cancelled }
return { cancelled, ...(stillRunning ? { stillRunning: true as const } : {}) }
}
10 changes: 7 additions & 3 deletions src/main/acp/acp-structured-child-work.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,9 +358,13 @@ describe('Grok roster evidence through the existing host child store', () => {
}
})

it.each(['rpc-error', 'mismatched-id'])(
// Grok's own refusal means it did nothing; a reply about another subagent proves nothing, so it is unknown.
it.each([
['rpc-error', { ok: false, refusal: { code: 'agent_session_operation_invalid' } }],
['mismatched-id', { ok: false, refusal: { code: 'agent_session_operation_unknown' } }]
] as const)(
'does not settle or cancel the parent after a targeted %s',
async (failure) => {
async (failure, answer) => {
const f = await fixture()
await f.spawn('first')
await f.spawn('sibling')
Expand All @@ -371,7 +375,7 @@ describe('Grok roster evidence through the existing host child store', () => {
f.agent.reply(frame, { result: { subagentId: 'other', cancelled: true } })
}
})
expect(await f.targetedStop('first')).toMatchObject({ ok: true, value: { cancelled: false } })
expect(await f.targetedStop('first')).toMatchObject(answer)
expect(f.childWork.views().every((view) => view.membership === 'live')).toBe(true)
expect(f.agent.frames.filter((frame) => frame.method === 'session/cancel')).toEqual([])
expect(f.rig.child().closed).toBe(false)
Expand Down
Loading
Loading