fix: negotiate keyboard support for host-authoritative agent launches

This commit is contained in:
Neil
2026-09-15 21:41:38 -07:00
parent 8936204c0b
commit 9b8bf094f3
11 changed files with 183 additions and 7 deletions
+2 -2
View File
@@ -2,9 +2,9 @@
OMP's ProcessTerminal sends `CSI ? u` and then a DA1 sentinel before selecting its keyboard encoding. A fresh desktop terminal already advertises Kitty support, but a direct New Tab launch can query before that renderer owns replies. Previously startup ingress answered only OSC color queries.
The renderer now supplies optional `terminalKittyKeyboardProtocol: true` from its actual xterm `vtExtensions.kittyKeyboard` setting. The existing local/SSH/paired spawn route places it in startup ingress as optional `kittyKeyboardProtocol`. Missing or false capability leaves behavior unchanged, including panes that deliberately withhold Kitty on native Windows. Old peers ignore these optional fields; the ingress version and stream opcodes do not change. New clients with old hosts retain the old renderer fallback.
The renderer now supplies optional `terminalKittyKeyboardProtocol: true` from its actual xterm `vtExtensions.kittyKeyboard` setting. The existing local/SSH/paired spawn route places it in startup ingress as optional `kittyKeyboardProtocol`. Missing or false capability leaves behavior unchanged, including panes that deliberately withhold Kitty on native Windows. The ingress version and stream opcodes do not change. Terminal creation accepts additive fields, but host-authoritative `terminal.createAgentSession` and `terminal.ensureAgentSession` use strict schemas. Clients send the keyboard flag on those methods only after the host advertises `agent-session.keyboard.v1`. The negotiated payload stays fixed across launch retries; old hosts receive the original payload and retain the renderer fallback. New hosts accept older clients that omit the flag.
Source ingress answers only the exact first `CSI ? u` before its deadline/renderer handoff. It uses the existing mode tracker for preceding flag pushes and the existing reply-delivery echo guard. Its transformed source span consumes the query once, while the following DA1 and Kitty mode-setting bytes retain their sequence ranges and reach the renderer. Color and Kitty authority end independently: answering both colors does not end Kitty handling, and ConPTY's persistent color ownership does not retain Kitty ownership after handoff.
Source ingress answers only the exact first `CSI ? u` before its deadline/renderer handoff. It uses the existing mode tracker for preceding flag pushes and the existing reply-delivery echo guard. Its transformed source span consumes the query once, while the following DA1 and Kitty mode-setting bytes retain their sequence ranges and reach the renderer. Keyboard intent does not require theme colors. Color and Kitty authority end independently: answering both colors does not end Kitty handling, and ConPTY's persistent color ownership does not retain Kitty ownership after handoff.
Run the actual OMP protocol smoke with a read-only reference checkout:
@@ -1,3 +1,7 @@
import {
CreateAgentSessionParams,
EnsureAgentSessionParams
} from '../../shared/rpc-contract/agent-session-params'
import { describe, expect, it, vi } from 'vitest'
import type {
RuntimeCreateAgentSessionRequest,
@@ -111,6 +115,40 @@ async function fenceRemoteAgentSessionSpawn(runtime: OrcaRuntimeService) {
}
describe('agent-session create operation ledger', () => {
it.each([true, false, undefined])(
'forwards renderer keyboard support on create and resume: %s',
async (terminalKittyKeyboardProtocol) => {
const runtime = createRuntime()
const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal())
await runtime.createAgentSession(
CreateAgentSessionParams.parse(request(operationId(), { terminalKittyKeyboardProtocol }))
)
await runtime.ensureAgentSession(
EnsureAgentSessionParams.parse({
kind: 'explicit',
worktree: 'id:worktree-1',
agent: 'codex',
providerSession: { key: 'session_id', id: 'provider-session-1' },
terminalKittyKeyboardProtocol
})
)
expect(createTerminal).toHaveBeenCalledTimes(2)
for (const call of createTerminal.mock.calls) {
expect(call[1]?.terminalKittyKeyboardProtocol).toBe(terminalKittyKeyboardProtocol)
}
}
)
it('refuses a changed keyboard capability under the same create operation', async () => {
const runtime = createRuntime()
const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal())
const id = operationId()
await runtime.createAgentSession(request(id, { terminalKittyKeyboardProtocol: true }))
await expect(runtime.createAgentSession(request(id))).rejects.toThrow(
'agent_session_operation_conflict'
)
expect(createTerminal).toHaveBeenCalledOnce()
})
it('selects legacy before trust, spawn, or ledger state for an old daemon', async () => {
const provider = {
supportsAgentSessionClaims: vi.fn(() => false),
@@ -66,7 +66,8 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe
request.presentation ?? null,
request.placement?.tabId ?? null,
request.placement?.leafId ?? null,
request.viewMode ?? null
request.viewMode ?? null,
...(request.terminalKittyKeyboardProtocol === true ? ['kitty-keyboard'] : [])
])
)
.digest('base64url')
@@ -142,7 +143,8 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe
request.presentation ?? null,
request.placement?.tabId ?? null,
request.placement?.leafId ?? null,
request.viewMode ?? null
request.viewMode ?? null,
...(request.terminalKittyKeyboardProtocol === true ? ['kitty-keyboard'] : [])
])
)
.digest('base64url')
@@ -213,6 +215,7 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe
env: startup.env,
launchConfig: startup.launchConfig,
launchAgent: request.agent,
terminalKittyKeyboardProtocol: request.terminalKittyKeyboardProtocol,
startupCommandDelivery: startup.startupCommandDelivery,
cwd: startupCwd,
presentation: request.presentation ?? 'background',
@@ -176,6 +176,7 @@ export class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends OrcaRuntim
launchConfig: startup.launchConfig,
startupCommandDelivery: startup.startupCommandDelivery,
launchAgent: request.agent,
terminalKittyKeyboardProtocol: request.terminalKittyKeyboardProtocol,
presentation: request.presentation ?? 'background',
tabId: request.placement?.tabId,
leafId: request.placement?.leafId,
@@ -20,8 +20,57 @@ const { runtimeCall, refreshSessionTabsSnapshot, resetRemoteRuntimeTransport } =
})
describe('createRemoteRuntimePtyTransport', () => {
beforeEach(() => {
it.each([
{ supported: true, resume: false },
{ supported: true, resume: true },
{ supported: false, resume: false },
{ supported: false, resume: true }
])(
'gates keyboard fields for host support $supported, resume $resume',
async ({ supported, resume }) => {
runtimeCall.mockImplementation(async (args: { method?: string }) =>
args.method === 'status.get'
? {
ok: true,
result: {
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 2,
capabilities: [
'agent-session.host-authority.v1',
...(supported ? ['agent-session.keyboard.v1'] : [])
]
}
}
: { ok: true, result: { terminal: { handle: 'terminal-1' } } }
)
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: 'pane:1',
launchAgent: 'codex',
terminalKittyKeyboardProtocol: true,
...(resume
? { resumeProviderSession: { key: 'session_id' as const, id: 'session-1' } }
: {})
})
await transport.connect({ url: '', callbacks: {} })
const method = resume ? 'terminal.ensureAgentSession' : 'terminal.createAgentSession'
const call = runtimeCall.mock.calls.find(([args]) => args.method === method)?.[0]
expect(call).toBeDefined()
if (supported) {
expect(call?.params).toHaveProperty('terminalKittyKeyboardProtocol', true)
} else {
expect(call?.params).not.toHaveProperty('terminalKittyKeyboardProtocol')
}
transport.destroy?.()
}
)
beforeEach(async () => {
resetRemoteRuntimeTransport()
// Charge the cold module transform to setup, not the launch deadline.
await import('./remote-runtime-pty-transport')
})
it('closes a remote terminal created after the pane was destroyed', async () => {
@@ -1,3 +1,4 @@
import { createAgentSessionKeyboardOptions } from '@/runtime/agent-session-keyboard-capability'
/* eslint-disable max-lines -- Why: remote PTY transport keeps lifecycle, JSON fallback, and binary stream wiring together so reconnect/destroy ordering stays testable as one behavior surface. */
import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope'
import {
@@ -406,6 +407,7 @@ export function createRemoteRuntimePtyTransport(
// Why: reconnect retries must replay one host operation instead of creating
// another fresh agent when the first response was lost.
const agentCreateOperation = createAgentSessionCreateOperation()
const agentKeyboardOptions = createAgentSessionKeyboardOptions(terminalKittyKeyboardProtocol)
const outputProcessor = createPtyOutputProcessor({
onTitleChange,
onBell,
@@ -2250,8 +2252,9 @@ export function createRemoteRuntimePtyTransport(
createEnvironmentId,
connectLifecycleEpoch
)
const hostAuthorityCreate = () =>
createWithUnknownOutcomeRecovery(
const hostAuthorityCreate = async () => {
const keyboardOptions = await agentKeyboardOptions(createEnvironmentId)
return createWithUnknownOutcomeRecovery(
'agent-session',
(timeoutMs) =>
resumeProviderSessionToSend
@@ -2260,6 +2263,7 @@ export function createRemoteRuntimePtyTransport(
'terminal.ensureAgentSession',
{
kind: 'explicit',
...keyboardOptions,
worktree: toRuntimeTerminalWorktreeSelector(worktreeId),
agent: launchAgentToSend!,
providerSession: resumeProviderSessionToSend,
@@ -2280,6 +2284,7 @@ export function createRemoteRuntimePtyTransport(
'terminal.createAgentSession',
withAgentSessionCreateOperationId(
{
...keyboardOptions,
worktree: toRuntimeTerminalWorktreeSelector(worktreeId),
agent: launchAgentToSend!,
...(agentPrompt ? { prompt: agentPrompt } : {}),
@@ -2300,6 +2305,7 @@ export function createRemoteRuntimePtyTransport(
createEnvironmentId,
connectLifecycleEpoch
)
}
const resumeHostAuthorityCapability = resumeProviderSessionToSend
? agentResumeHostAuthorityCapability(launchAgentToSend)
: undefined
@@ -0,0 +1,42 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { createAgentSessionKeyboardOptions } from './agent-session-keyboard-capability'
import { runtimeEnvironmentSupportsCapability } from './runtime-rpc-client'
vi.mock('./runtime-rpc-client', () => ({ runtimeEnvironmentSupportsCapability: vi.fn() }))
const probe = vi.mocked(runtimeEnvironmentSupportsCapability)
beforeEach(() => {
probe.mockReset()
})
it.each([true, false])(
'negotiates once and freezes the payload across reconnects: %s',
async (supported) => {
probe.mockResolvedValueOnce(supported).mockResolvedValue(!supported)
const resolve = createAgentSessionKeyboardOptions(true)
const first = resolve('env-1')
expect(resolve('env-1')).toBe(first)
expect(await first).toEqual(supported ? { terminalKittyKeyboardProtocol: true } : {})
expect(await resolve('env-1')).toEqual(await first)
expect(probe).toHaveBeenCalledExactlyOnceWith('env-1', 'agent-session.keyboard.v1')
}
)
it.each([undefined, false])(
'does not probe or advertise disabled renderer support: %s',
async (enabled) => {
expect(await createAgentSessionKeyboardOptions(enabled)('env-1')).toEqual({})
expect(probe).not.toHaveBeenCalled()
}
)
it('keeps the legacy payload after an unavailable read-only capability probe', async () => {
probe.mockRejectedValueOnce(new Error('disconnected')).mockResolvedValue(true)
const resolve = createAgentSessionKeyboardOptions(true)
expect(await resolve('env-1')).toEqual({})
expect(await resolve('env-1')).toEqual({})
expect(probe).toHaveBeenCalledOnce()
})
it('preserves an incompatible runtime refusal instead of degrading the launch', async () => {
const error = Object.assign(new Error('update required'), { code: 'runtime_compat_block' })
probe.mockRejectedValue(error)
await expect(createAgentSessionKeyboardOptions(true)('env-1')).rejects.toBe(error)
})
@@ -0,0 +1,30 @@
import { AGENT_SESSION_KEYBOARD_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
import { runtimeEnvironmentSupportsCapability } from './runtime-rpc-client'
import { isRuntimeCompatBlockError } from './runtime-protocol-compat'
type KeyboardOptions = { terminalKittyKeyboardProtocol?: true }
export function createAgentSessionKeyboardOptions(enabled: boolean | undefined) {
let negotiated: Promise<KeyboardOptions> | undefined
return (environmentId: string): Promise<KeyboardOptions> => {
// A replay must keep its original payload even after a host upgrade or reconnect.
negotiated ??= (async () => {
if (enabled !== true) {
return {}
}
try {
const supported = await runtimeEnvironmentSupportsCapability(
environmentId,
AGENT_SESSION_KEYBOARD_RUNTIME_CAPABILITY
)
return supported ? { terminalKittyKeyboardProtocol: true as const } : {}
} catch (error) {
if (isRuntimeCompatBlockError(error)) {
throw error
}
return {}
}
})()
return negotiated
}
}
@@ -110,6 +110,7 @@ export type RuntimeEnsureAgentSessionRequest =
agent: ResumableTuiAgent
providerSession: AgentProviderSessionMetadata
ompResumeFilePath?: string
terminalKittyKeyboardProtocol?: boolean
/** Explicit client override. Omission keeps launch defaults host-owned. */
agentArgs?: string | null
launchPreferences?: AgentLaunchPreferences
@@ -124,6 +125,7 @@ export type RuntimeEnsureAgentSessionResult = {
export type RuntimeCreateAgentSessionRequest = {
clientOperationId: string
terminalKittyKeyboardProtocol?: boolean
worktree: string
agent: TuiAgent
prompt?: string
+3
View File
@@ -132,6 +132,8 @@ export const AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY =
export { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update'
export const AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY =
'agent-session.host-authority.v1' as const
// Older launch schemas reject unknown fields; advertise before clients send keyboard support.
export const AGENT_SESSION_KEYBOARD_RUNTIME_CAPABILITY = 'agent-session.keyboard.v1' as const
export const AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY =
'agent-session.omp-resume-path.v1' as const
// Why: structured sessions are journal-backed, not PTY-backed, so an incapable client must not
@@ -286,6 +288,7 @@ export const RUNTIME_CAPABILITIES = [
REMOTE_SERVER_UPDATE_CAPABILITY,
AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY,
AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY,
AGENT_SESSION_KEYBOARD_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY,
@@ -126,6 +126,7 @@ export const ExplicitEnsure = z
agent: z.enum(RESUMABLE_TUI_AGENTS),
providerSession: ProviderSession,
ompResumeFilePath: OmpResumeFilePath.optional(),
terminalKittyKeyboardProtocol: z.boolean().optional(),
agentArgs: AgentArgs.optional(),
launchPreferences: LaunchPreferences.optional(),
presentation: Presentation.optional(),
@@ -154,6 +155,7 @@ export const EnsureAgentSessionParams: z.ZodType<RuntimeEnsureAgentSessionReques
export const CreateAgentSessionParams: z.ZodType<RuntimeCreateAgentSessionRequest> = z
.object({
terminalKittyKeyboardProtocol: z.boolean().optional(),
clientOperationId: z
.string()
.refine(