mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 08:02:43 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
+71
-1
@@ -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 } })
|
||||
|
||||
+29
-3
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user