diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 4c178b71fd8..ed4330c8f1a 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -18,6 +18,7 @@ import { launchOrcaApp } from './launch' import { addEnvironmentFromPairingCode } from './environments' import { RuntimeClientError } from './types' import { + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, @@ -72,6 +73,7 @@ describe('CLI remote WebSocket transport', () => { expect.objectContaining({ clientCapabilities: [ AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index 6237b8d040d..81de25d66c0 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -11,6 +11,7 @@ import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows' import { TERMINAL_FIT_RESTORE_DEADLINE_MS } from '../../shared/terminal-fit-restore-deadline' import { + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY @@ -82,6 +83,7 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { connectionId: desktopSenders.connectionIdFor(event.sender), clientCapabilities: [ AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY ] @@ -131,6 +133,7 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { connectionId, clientCapabilities: [ AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY ] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts index cc5a7282080..4c2283efb52 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts @@ -1,6 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionBackgroundTaskState } from '../../../../shared/agent-session-wire' -import { AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY } from '../../../../shared/protocol-version' +import { + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY +} from '../../../../shared/protocol-version' import { remoteRuntimeClientCapabilities } from '../../../../shared/remote-runtime-client-capabilities' import type { AgentSessionSubscribeInput } from '../../../native-chat/agent-session-wire/structured-agent-session-subscribers' import { @@ -24,6 +27,21 @@ const CURRENT_CLIENT = { ...STRUCTURED_CLIENT, clientCapabilities: remoteRuntimeClientCapabilities(STRUCTURED_CLIENT.clientCapabilities) } +/** Understands a stopless roster, but predates per-row stoppability. */ +const STOP_ONLY_CLIENT = { + ...STRUCTURED_CLIENT, + clientCapabilities: remoteRuntimeClientCapabilities(STRUCTURED_CLIENT.clientCapabilities).filter( + (capability) => capability !== AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY + ) +} +const MIXED_ROWS: AgentSessionBackgroundTaskState = { + state: 'monitoring', + supportsTaskStop: true, + tasks: [ + { id: 'fore', kind: 'agent', stoppable: false }, + { id: 'back', kind: 'agent' } + ] +} describe('background-task stop capability at the RPC boundary', () => { it('advertises reader support on remote requests and subscriptions', () => { @@ -93,6 +111,58 @@ describe('background-task stop capability at the RPC boundary', () => { } ) + it('advertises row-stop support separately from stop support', () => { + // A client can advertise the stop capability and still predate `stoppable`, + // so the two must not be conflated. + expect(CURRENT_CLIENT.clientCapabilities).toContain( + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY + ) + expect(STOP_ONLY_CLIENT.clientCapabilities).not.toContain( + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY + ) + }) + + it.each([ + ['row-stop reader', () => CURRENT_CLIENT, MIXED_ROWS], + [ + 'stop-only reader', + () => STOP_ONLY_CLIENT, + { state: 'monitoring', tasks: [MIXED_ROWS.tasks[1]] } + ], + ['in-process reader', () => undefined, MIXED_ROWS] + ] as const)('projects unstoppable rows for a %s', async (_label, client, expected) => { + hostCalls.history.mockReturnValue({ + ok: true, + page: { items: [], backgroundTasks: MIXED_ROWS } + }) + expect( + await call('agentSession.history', { sessionId: SESSION, direction: 'tail' }, client()) + ).toMatchObject({ ok: true, result: { page: { backgroundTasks: expected } } }) + }) + + it('hands a reader that predates the field no strip when every row is unstoppable', async () => { + // Its pre-feature view exactly: the host published no foreground rows at all. + const foregroundOnly = { + state: 'monitoring' as const, + supportsTaskStop: true, + tasks: [{ id: 'fore', kind: 'agent' as const, stoppable: false }] + } + hostCalls.history.mockReturnValue({ + ok: true, + page: { items: [], backgroundTasks: foregroundOnly } + }) + expect( + await call( + 'agentSession.history', + { sessionId: SESSION, direction: 'tail' }, + STOP_ONLY_CLIENT + ) + ).toMatchObject({ ok: true, result: { page: { backgroundTasks: null } } }) + expect( + await call('agentSession.history', { sessionId: SESSION, direction: 'tail' }, CURRENT_CLIENT) + ).toMatchObject({ ok: true, result: { page: { backgroundTasks: foregroundOnly } } }) + }) + it('preserves legacy stoppable state for both readers', async () => { const stoppable = { state: 'monitoring', tasks: TASKS.tasks } hostCalls.history.mockReturnValue({ ok: true, page: { items: [], backgroundTasks: stoppable } }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.ts b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.ts index 02afc5569bf..ae69de4a069 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.ts @@ -3,7 +3,10 @@ import type { AgentSessionHistoryResult, AgentSessionSubscribeEvent } from '../../../../shared/agent-session-wire' -import { AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY } from '../../../../shared/protocol-version' +import { + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY +} from '../../../../shared/protocol-version' import type { RpcContext } from '../core' type BackgroundTaskReader = Pick @@ -15,14 +18,37 @@ function supportsReadOnlyTasks(ctx: BackgroundTaskReader): boolean { ) } +function honoursRowStop(ctx: BackgroundTaskReader): boolean { + return ( + ctx.clientKind === undefined || + ctx.clientCapabilities?.includes(AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY) === true + ) +} + +/** A reader that predates `stoppable` draws a per-row stop on every row it is + * handed, and the host cannot honour one on a row marked unstoppable — the + * dead button the field exists to remove. The host publishing such rows at all + * is new, so withholding them hands that reader exactly its pre-feature view; + * a state whose every row is withheld becomes no strip, as it was. */ +function withoutUnstoppableRows( + state: AgentSessionBackgroundTaskState +): AgentSessionBackgroundTaskState | null { + if (!state.tasks?.some((task) => task.stoppable === false)) { + return state + } + const tasks = state.tasks.filter((task) => task.stoppable !== false) + return tasks.length > 0 ? { ...state, tasks } : null +} + function projectState( state: AgentSessionBackgroundTaskState | null | undefined, ctx: BackgroundTaskReader ): AgentSessionBackgroundTaskState | null | undefined { + const rows = !state || honoursRowStop(ctx) ? state : withoutUnstoppableRows(state) // Legacy readers always offer a stop; retain their pre-producer empty strip. - return state?.supportsStopAll === false && !state.supportsTaskStop && !supportsReadOnlyTasks(ctx) + return rows?.supportsStopAll === false && !rows.supportsTaskStop && !supportsReadOnlyTasks(ctx) ? null - : state + : rows } export function projectBackgroundTaskHistory( diff --git a/src/renderer/src/web/web-runtime-connection-frame-router.ts b/src/renderer/src/web/web-runtime-connection-frame-router.ts index fe089be80ef..bb0d51b1380 100644 --- a/src/renderer/src/web/web-runtime-connection-frame-router.ts +++ b/src/renderer/src/web/web-runtime-connection-frame-router.ts @@ -1,6 +1,7 @@ import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import { isKeepaliveFrame } from '../../../shared/runtime-rpc-envelope' import { + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, @@ -58,6 +59,7 @@ export async function routeWebRuntimeConnectionFrame( type: 'e2ee_auth', deviceToken: context.pairingToken, clientCapabilities: [ + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 74317dd28c1..beeb37a5faa 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -166,6 +166,13 @@ export const AGENT_SESSION_REWIND_RUNTIME_CAPABILITY = 'agent-session.rewind.v1' // Readers must understand a monitoring roster with no available stop control. export const AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY = 'agent-session.background-task-stop.v1' as const +// Why: the host now publishes rows for work that is live inside a turn, and such +// a row carries `stoppable: false` because no targeted stop can reach it. A +// reader that predates the field draws a per-row Stop on every row it is given, +// so it must be told apart from one that honours the field — and NOT by the +// stop capability above, which a client can advertise while predating this. +export const AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY = + 'agent-session.background-task-row-stop.v1' as const // Why: adding kimi to RESUMABLE_TUI_AGENTS grows terminal.ensureAgentSession's enum, and an // older host answers the unknown member with invalid_argument — a code the launch fallback does // not retry on — so clients must probe before taking the host-authority path. @@ -273,6 +280,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, AGENT_SESSION_REWIND_RUNTIME_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, GITHUB_MARK_PR_READY_RUNTIME_CAPABILITY, diff --git a/src/shared/remote-runtime-client-capabilities.ts b/src/shared/remote-runtime-client-capabilities.ts index 456f90e25ad..21e6bb145fa 100644 --- a/src/shared/remote-runtime-client-capabilities.ts +++ b/src/shared/remote-runtime-client-capabilities.ts @@ -1,4 +1,5 @@ import { + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, @@ -18,6 +19,7 @@ export function remoteRuntimeClientCapabilities( return Array.from( new Set([ AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,