fix(agent-session): withhold unstoppable rows from readers that predate them

Rule 3 of remote-wire-compatibility: changing what the host publishes reaches
old clients with no wire change. The Claude host published no foreground rows
before this feature; it does now, and a client that cannot read `stoppable`
draws a per-row Stop on every one of them — Claude always sets
`supportsTaskStop` — which filters to the backgrounded ids, stops nothing, and
returns a result no renderer inspects. That is the dead button `stoppable` was
added to remove, reappearing across a version skew.

Negotiate it. A client can advertise the existing background-task-stop
capability and still predate `stoppable`, so this needs its own constant.
Readers that do not advertise it get unstoppable rows dropped, and a state whose
every row is dropped becomes no strip — exactly their pre-feature view.

RUNTIME_PROTOCOL_VERSION is not bumped: this adds an optional field and a new
negotiated capability, and changes no existing field's meaning, which is the
explicit do-not-bump case in protocol-version.ts.
This commit is contained in:
Merge Sim
2026-09-09 13:48:28 -07:00
parent 3fc5381b43
commit fa3225cd05
7 changed files with 117 additions and 4 deletions
@@ -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,
+3
View File
@@ -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
]
@@ -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 } })
@@ -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<RpcContext, 'clientKind' | 'clientCapabilities'>
@@ -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(
@@ -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,
+8
View File
@@ -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,
@@ -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,