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
31 changes: 31 additions & 0 deletions mobile/src/session/MobileNativeChatBackgroundTasks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,37 @@ describe('MobileNativeChatBackgroundTasks', () => {
expect(stop).toHaveBeenCalledWith('codex-command:exec-1')
})

it('stops a Grok background command and the Grok subagent beside it by their own ids', () => {
const stop = vi.fn(async () => REJECTED)
const mounted = mount(
tasksFor(
{
state: 'monitoring',
supportsTaskStop: true,
supportsStopAll: false,
children: [
view('agent', { providerId: 'subagent-1', description: 'Review the diff' }),
view('cmd', {
kind: 'command',
providerId: 'acp-task:01a10366',
description: 'Run the dev server'
})
]
},
{ stop }
)
)
expand(mounted)
const buttons = stopButtons(mounted)
const labels = buttons.map((button) => button.props.accessibilityLabel)
expect(labels).toEqual(
expect.arrayContaining(['Stop Run the dev server', 'Stop Review the diff'])
)
expect(labels).not.toContain('Stop background tasks')
act(() => buttons[labels.indexOf('Stop Run the dev server')]!.props.onPress())
expect(stop).toHaveBeenCalledWith('acp-task:01a10366')
})

it('offers Stop all only to a host with no per-row stop that still accepts one', async () => {
let finish: (result: StopResult) => void = () => {}
const stop = vi.fn(() => new Promise<StopResult>((resolve) => (finish = resolve)))
Expand Down
63 changes: 63 additions & 0 deletions src/main/acp/acp-background-task-child-work.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type {
AgentChildWorkEvidence,
AgentChildWorkEvidenceHandle
} from '../../shared/agent-status-child-work-evidence'
import { isSettledBackgroundTaskState } from '../../shared/native-chat-background-task-row'
import { isBackgroundTaskBlock } from '../../shared/native-chat-types'
import { AGENT_CHILD_WORK_ID_MAX_LENGTH } from '../../shared/agent-status-child-work-value-guards'
import type { AcpTimelineEvent } from './acp-timeline-event'

/** Keeps a task's handle apart from a subagent's, so a Stop knows which route it takes. */
const TASK_ID_PREFIX = 'acp-task:'

function acpBackgroundTaskHandle(taskId: string): AgentChildWorkEvidenceHandle {
return { idKind: 'task_id', id: `${TASK_ID_PREFIX}${taskId}` }
}

/** The agent's own task id behind a child handle, when the handle names a background task. */
export function acpBackgroundTaskStopId(childId: string): string | undefined {
return childId.startsWith(TASK_ID_PREFIX) ? childId.slice(TASK_ID_PREFIX.length) : undefined
}

function backgroundTaskBlocks(event: AcpTimelineEvent) {
return event.type === 'item.update' && 'body' in event && event.body.kind === 'message'
? event.body.blocks.filter(isBackgroundTaskBlock)
: []
}

export function isAcpBackgroundTaskChildWorkEvent(event: AcpTimelineEvent): boolean {
return backgroundTaskBlocks(event).length > 0
}

/** A running task is a live record until it ends, and then its record goes, like a command's. */
export function acpBackgroundTaskChildWork(
event: AcpTimelineEvent,
observedAt: number,
stoppable: boolean
): AgentChildWorkEvidence[] {
return backgroundTaskBlocks(event).flatMap((block): AgentChildWorkEvidence[] => {
const handle = acpBackgroundTaskHandle(block.taskId)
// A handle too long to record could not address the agent's own task either.
if (handle.id.length > AGENT_CHILD_WORK_ID_MAX_LENGTH) {
return []
}
if (isSettledBackgroundTaskState(block.state)) {
return [{ type: 'removed', observedAt, handle }]
}
return [
{
type: 'live',
observedAt,
child: {
handle,
kind: block.kind,
residency: 'background',
state:
block.state === 'monitoring' || block.state === 'waiting' ? block.state : 'working',
...(block.label ? { description: block.label } : {}),
stoppable
}
}
]
})
}
12 changes: 12 additions & 0 deletions src/main/acp/acp-dialects/acp-dialect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,15 @@ export type AcpDialect = {
request(sessionId: string, id: string): { method: string; params: unknown }
response(value: unknown, id: string): { cancelled: boolean; state?: NativeChatSubagentState }
}
backgroundTaskStop?: {
/** Missing required target: proves the route exists without addressing any task. */
probe: { method: string; params: unknown }
/** The agent refusing the route by name: the only answer that hides Stop. */
lacksRoute(error: AcpAgentError): boolean
request(sessionId: string, taskId: string): { method: string; params: unknown }
/** `gone`: the agent no longer runs a task by that id; its own ending may still follow. */
response(value: unknown, taskId: string): 'killed' | 'gone' | 'refused'
}
injectedPromptIdentity?: true
/** A tool update in the shared shape (`rawOutput.stdout`, `rawOutput.exitCode`), read first. */
normalizeToolUpdate?(update: ToolCallUpdate): ToolCallUpdate
Expand Down Expand Up @@ -98,4 +107,7 @@ export type AcpDialect = {
compactionReply?(text: string): AcpCompactionReply | undefined
}

/** Which of the dialect's stop routes this agent process proved it answers. */
export type AcpChildStops = { subagents: boolean; backgroundTasks: boolean }

export const GENERIC_ACP_DIALECT: AcpDialect = {}
29 changes: 29 additions & 0 deletions src/main/acp/acp-dialects/grok-background-task-stop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { z } from 'zod'
import type { AcpDialect } from './acp-dialect'

const response = z.union([
z.object({
error: z.null().optional(),
result: z.object({
taskId: z.string(),
outcome: z.enum(['killed', 'already_exited', 'not_found'])
})
}),
// Grok's envelope for a kill it could not attempt, such as an unknown session.
z.object({ result: z.null(), error: z.string() })
])

export const grokBackgroundTaskStop: NonNullable<AcpDialect['backgroundTaskStop']> = {
probe: { method: '_x.ai/task/kill', params: {} },
lacksRoute: (error) => error.code === -32601,
// `source` is left to Grok's default: its own per-task UI kill.
request: (sessionId, taskId) => ({ method: '_x.ai/task/kill', params: { sessionId, taskId } }),
response: (value, taskId) => {
const parsed = response.safeParse(value)
if (!parsed.success || (parsed.data.result && parsed.data.result.taskId !== taskId)) {
throw new Error('Grok returned an invalid background task kill response')
}
const { result } = parsed.data
return !result ? 'refused' : result.outcome === 'killed' ? 'killed' : 'gone'
}
}
2 changes: 2 additions & 0 deletions src/main/acp/acp-dialects/grok-dialect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { grokBackgroundTaskNotification, grokToolBackgroundTasks } from './grok-
import { grokModelEfforts } from './grok-model-catalog'
import { grokSubagentNotification, grokToolSubagents } from './grok-subagents'
import { grokSubagentStop } from './grok-subagent-stop'
import { grokBackgroundTaskStop } from './grok-background-task-stop'

const tokenCount = z.number().int().nonnegative()
const toolMetaSchema = z.object({ 'x.ai/tool': z.object({ name: z.string().min(1) }) })
Expand Down Expand Up @@ -194,6 +195,7 @@ function notification(

export const GROK_ACP_DIALECT: AcpDialect = {
subagentStop: grokSubagentStop,
backgroundTaskStop: grokBackgroundTaskStop,
injectedPromptIdentity: true,
toolName: (update) => {
const parsed = toolMetaSchema.safeParse(update._meta)
Expand Down
10 changes: 5 additions & 5 deletions src/main/acp/acp-structured-acquire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import type { AcpSessionEvent } from './acp-session-runtime'
import type { AcpStructuredConnection } from './acp-structured-connection'
import { ACP_HANDLE_TRANSPORT, acpAgentName } from './acp-structured-agent-definitions'
import { AcpStructuredLane, acpLaneChildWorkDelivery } from './acp-structured-lane'
import { probeAcpChildStop } from './acp-structured-child-stop'
import { NO_ACP_CHILD_STOPS, probeAcpChildStops } from './acp-structured-child-stop'
import type { AcpStructuredLaunch } from './acp-structured-launch-resolution'
import { AcpStructuredOptions, restoreAcpSessionOptions } from './acp-structured-options'
import { AcpStructuredPrompts } from './acp-structured-prompts'
Expand Down Expand Up @@ -185,7 +185,7 @@ export async function acquireAcpStructuredSession(input: {
deps.readProcessStartTime
)
/** `attaching`: the lane opens inside the attach window, before any frame queued so far. */
let subagentStopSupported = false
let childStops = NO_ACP_CHILD_STOPS
const makeLane = (providerSessionId: string, attaching = false): AcpStructuredLane => {
const lane = new AcpStructuredLane({
sink,
Expand All @@ -196,7 +196,7 @@ export async function acquireAcpStructuredSession(input: {
providerSessionId,
dialect: spec.dialect,
now,
canStopSubagents: () => subagentStopSupported,
childStops: () => childStops,
...acpLaneChildWorkDelivery(deps, sessionId),
logger: deps.logger ?? createStructuredAgentSessionLogger(),
onInputAccepted: (clientMessageId) => session?.turns.accept(clientMessageId),
Expand Down Expand Up @@ -261,7 +261,7 @@ export async function acquireAcpStructuredSession(input: {
started = await connection.start({ cwd: launch.cwd, mcpServers: [], ...auth })
liveLane = makeLane(started.sessionId)
}
subagentStopSupported = await probeAcpChildStop(connection, spec.dialect)
childStops = await probeAcpChildStops(connection, spec.dialect)
options.adoptSession(started.response, started.kind === 'new' ? 'new' : 'loaded')
liveLane.apply(liveLane.translator.contextModels(started.response.models, now()))
const restoreSkipped = await restoreAcpSessionOptions(connection, options, acquire.options)
Expand All @@ -280,7 +280,7 @@ export async function acquireAcpStructuredSession(input: {
fence: acquire.fence,
acquisitionGeneration: generation,
spec,
subagentStopSupported,
childStops,
connection,
lane: liveLane,
prompts,
Expand Down
3 changes: 3 additions & 0 deletions src/main/acp/acp-structured-adapter.test-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ export async function openAcpAdapterRig(
agent.on('_x.ai/subagent/cancel', (frame) =>
agent.fail(frame, -32602, 'Invalid params', 'invalid params: missing field `subagentId`')
)
agent.on('_x.ai/task/kill', (frame) =>
agent.fail(frame, -32602, 'Invalid params', 'invalid params: missing field `sessionId`')
)
agent.on('initialize', (frame) => {
spawned.push('initialize')
agent.reply(frame, {
Expand Down
Loading
Loading