diff --git a/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts b/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts index 6522fe047b8..b6664c52ce2 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts @@ -2,10 +2,6 @@ import { buildDefaultTerminalOptions } from '@/lib/pane-manager/pane-terminal-op import { createAgentSessionKeyboardOptions } from './agent-session-keyboard-capability' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import type { RuntimeMobileSessionCreateTerminalResult } from '../../../shared/runtime-types' -import type { - RuntimeCreateAgentSessionResult, - RuntimeEnsureAgentSessionResult -} from '../../../shared/agent-session-host-authority' import { toRuntimeExecutionHostId } from '../../../shared/execution-host' import { translate } from '../i18n/i18n' import { useAppStore } from '../store' @@ -43,7 +39,10 @@ import { selectWebRuntimeSessionWorktree, type WebRuntimeSessionWorkspaceSelectionRollback } from './web-runtime-session-workspace-selection' -import { createdTerminalLeafId } from './web-runtime-terminal-identity' +import { + createdTerminalLeafId, + readCreatedAgentTerminalIdentity +} from './web-runtime-terminal-identity' import { settleWebRuntimeTerminalPlacement } from './web-runtime-terminal-placement-settlement' export async function createWebRuntimeSessionTerminalResult( @@ -88,9 +87,8 @@ export async function createWebRuntimeSessionTerminalResult( args.agentArgs !== undefined ? args.agentArgs : args.launchConfig?.agentArgs if (agent) { // Paired panes retain the default keyboard advertisement, including on Windows clients. - const keyboardOptions = createAgentSessionKeyboardOptions( - buildDefaultTerminalOptions().vtExtensions?.kittyKeyboard - ) + const keyboardProtocol = buildDefaultTerminalOptions().vtExtensions?.kittyKeyboard + const keyboardOptions = createAgentSessionKeyboardOptions(keyboardProtocol) let legacyAlreadyPlacedInGroup = false // Why: structured creation cannot yet express afterTabId; keep the exact legacy placement contract until it can. // Why: focus belongs to the paired client; a headless execution host has no renderer to focus. @@ -101,54 +99,60 @@ export async function createWebRuntimeSessionTerminalResult( : args.agentSessionKind === 'resume' ? args.providerSession ? async () => - unwrapRuntimeRpcResult( - (await callEnvironment({ - method: 'terminal.ensureAgentSession', - params: { - ...(await keyboardOptions(environmentId)), - kind: 'explicit', - worktree: toRuntimeWorktreeSelector(args.worktreeId), - agent, - providerSession: args.providerSession!, - ...(args.launchConfig?.ompResumeFilePath - ? { ompResumeFilePath: args.launchConfig.ompResumeFilePath } - : {}), - ...(agentArgsOverride !== undefined ? { agentArgs: agentArgsOverride } : {}), - ...(args.launchPreferences - ? { launchPreferences: args.launchPreferences } - : {}), - presentation: 'background' - }, - timeoutMs: 15_000 - })) as RuntimeRpcResponse - ) - : undefined - : async () => - await createAgentSessionCreateOperation().run(async (clientOperationId) => - unwrapRuntimeRpcResult( - (await callEnvironment({ - method: 'terminal.createAgentSession', - params: withAgentSessionCreateOperationId( - { + readCreatedAgentTerminalIdentity( + unwrapRuntimeRpcResult( + await callEnvironment({ + method: 'terminal.ensureAgentSession', + params: { ...(await keyboardOptions(environmentId)), + kind: 'explicit', worktree: toRuntimeWorktreeSelector(args.worktreeId), agent, - ...(args.prompt ? { prompt: args.prompt } : {}), - ...(args.promptDelivery ? { promptDelivery: args.promptDelivery } : {}), + providerSession: args.providerSession!, + ...(args.launchConfig?.ompResumeFilePath + ? { ompResumeFilePath: args.launchConfig.ompResumeFilePath } + : {}), ...(agentArgsOverride !== undefined ? { agentArgs: agentArgsOverride } : {}), ...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}), - ...(args.cwd ? { startupCwd: args.cwd } : {}), - ...(args.viewMode ? { viewMode: args.viewMode } : {}), presentation: 'background' }, - clientOperationId - ), - timeoutMs: 15_000 - })) as RuntimeRpcResponse + timeoutMs: 15_000 + }) + ) + ) + : undefined + : async () => + await createAgentSessionCreateOperation().run(async (clientOperationId) => + readCreatedAgentTerminalIdentity( + unwrapRuntimeRpcResult( + await callEnvironment({ + method: 'terminal.createAgentSession', + params: withAgentSessionCreateOperationId( + { + ...(await keyboardOptions(environmentId)), + worktree: toRuntimeWorktreeSelector(args.worktreeId), + agent, + ...(args.prompt ? { prompt: args.prompt } : {}), + ...(args.promptDelivery ? { promptDelivery: args.promptDelivery } : {}), + ...(agentArgsOverride !== undefined + ? { agentArgs: agentArgsOverride } + : {}), + ...(args.launchPreferences + ? { launchPreferences: args.launchPreferences } + : {}), + ...(args.cwd ? { startupCwd: args.cwd } : {}), + ...(args.viewMode ? { viewMode: args.viewMode } : {}), + presentation: 'background' + }, + clientOperationId + ), + timeoutMs: 15_000 + }) + ) ) ) const resumeHostAuthorityCapability = diff --git a/src/renderer/src/runtime/web-runtime-terminal-identity.test.ts b/src/renderer/src/runtime/web-runtime-terminal-identity.test.ts new file mode 100644 index 00000000000..58ae954fcc5 --- /dev/null +++ b/src/renderer/src/runtime/web-runtime-terminal-identity.test.ts @@ -0,0 +1,25 @@ +import { expect, it } from 'vitest' +import { readCreatedAgentTerminalIdentity } from './web-runtime-terminal-identity' + +it('reads host coordinates while accepting additive and legacy fields', () => { + expect( + readCreatedAgentTerminalIdentity({ + terminal: { tabId: 'tab', paneKey: 'tab:leaf', future: true }, + disposition: 'created' + }) + ).toEqual({ terminal: { tabId: 'tab', paneKey: 'tab:leaf' } }) + expect(readCreatedAgentTerminalIdentity({ terminal: { paneKey: null } })).toEqual({ + terminal: { tabId: undefined, paneKey: null } + }) +}) + +it.each([ + null, + {}, + { terminal: null }, + { terminal: 'pty' }, + { terminal: { tabId: 12 } }, + { terminal: { paneKey: false } } +])('rejects malformed host coordinates: %j', (value) => { + expect(() => readCreatedAgentTerminalIdentity(value)).toThrow('Host returned') +}) diff --git a/src/renderer/src/runtime/web-runtime-terminal-identity.ts b/src/renderer/src/runtime/web-runtime-terminal-identity.ts index 755eb904c07..66b8db30125 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-identity.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-identity.ts @@ -5,3 +5,25 @@ export function createdTerminalLeafId(terminal: CreatedAgentTerminalIdentity): s const pane = parsePaneKey(terminal.paneKey ?? '') return pane && pane.tabId === terminal.tabId ? pane.leafId : undefined } + +/** Decode only the host terminal coordinates consumed by the paired renderer. */ +export function readCreatedAgentTerminalIdentity(value: unknown): { + terminal: CreatedAgentTerminalIdentity +} { + if (typeof value !== 'object' || value === null || !('terminal' in value)) { + throw new Error('Host returned an invalid agent terminal result') + } + const terminal = value.terminal + if (typeof terminal !== 'object' || terminal === null) { + throw new Error('Host returned an invalid agent terminal identity') + } + const tabId = 'tabId' in terminal ? terminal.tabId : undefined + const paneKey = 'paneKey' in terminal ? terminal.paneKey : undefined + if ( + (tabId !== undefined && typeof tabId !== 'string') || + (paneKey !== undefined && paneKey !== null && typeof paneKey !== 'string') + ) { + throw new Error('Host returned invalid agent terminal coordinates') + } + return { terminal: { tabId, paneKey } } +} diff --git a/tests/e2e/helpers/docker-ssh-relay-processes.ts b/tests/e2e/helpers/docker-ssh-relay-processes.ts index 769b0bd1372..b3252ad9ed5 100644 --- a/tests/e2e/helpers/docker-ssh-relay-processes.ts +++ b/tests/e2e/helpers/docker-ssh-relay-processes.ts @@ -52,7 +52,7 @@ function parseRelayProcessRows(output: string): RelayProcessRow[] { return output.split('\n').map((line) => { const [type, rawPid, rawParentPid, cwd] = line.split('\t') // Why: Number('') is 0, so empty pid/ppid (e.g. vanished /proc status) must - // throw and let expect.poll retry instead of accepting parentPid: 0. + // throw so callers retry the observation instead of accepting parentPid: 0. const pid = Number(rawPid) const parentPid = Number(rawParentPid) if ( diff --git a/tests/e2e/ssh-docker-watcher-isolation.spec.ts b/tests/e2e/ssh-docker-watcher-isolation.spec.ts index c9804f682f2..1501fdf1943 100644 --- a/tests/e2e/ssh-docker-watcher-isolation.spec.ts +++ b/tests/e2e/ssh-docker-watcher-isolation.spec.ts @@ -48,15 +48,11 @@ async function waitForRelayWatcherProcessGroup( target: DockerSshRelayTarget ): Promise { let snapshot: DockerSshRelayProcessSnapshot | null = null - await expect - .poll( - () => { - snapshot = readDockerSshRelayProcessSnapshot(target) - return snapshot !== null - }, - { timeout: 30_000, message: 'remote relay/watcher process group did not appear' } - ) - .toBe(true) + // A process can exit between /proc reads; retry the rejected snapshot, never accept it. + await expect(() => { + snapshot = readDockerSshRelayProcessSnapshot(target) + expect(snapshot, 'remote relay/watcher process group did not appear').not.toBeNull() + }).toPass({ timeout: 30_000 }) if (!snapshot) { throw new Error('remote relay/watcher process group disappeared after polling') } @@ -130,24 +126,17 @@ test.describe('Docker SSH relay watcher isolation', () => { signalDockerSshRelayWatchers(target, beforeCrash) let afterCrash: DockerSshRelayProcessSnapshot | null = null - await expect - .poll( - () => { - afterCrash = readDockerSshRelayProcessSnapshot(target!) - return Boolean( - afterCrash && - afterCrash.relayPid === beforeCrash.relayPid && - afterCrash.watcherPids.every( - (watcherPid) => !beforeCrash.watcherPids.includes(watcherPid) - ) - ) - }, - { - timeout: 30_000, - message: 'watcher children were not replaced under the same relay PID' - } - ) - .toBe(true) + await expect(() => { + afterCrash = readDockerSshRelayProcessSnapshot(target!) + expect( + afterCrash && + afterCrash.relayPid === beforeCrash.relayPid && + afterCrash.watcherPids.every( + (watcherPid) => !beforeCrash.watcherPids.includes(watcherPid) + ), + 'watcher children were not replaced under the same relay PID' + ).toBe(true) + }).toPass({ timeout: 30_000 }) const terminalMarker = `SSH_WATCHER_CRASH_SURVIVED_${Date.now()}` // Why: the shell echoes input before executing it. Encoding keeps the