diff --git a/src/vs/platform/agentHost/browser/webSocketClientTransport.ts b/src/vs/platform/agentHost/browser/webSocketClientTransport.ts index 6d01fe3e41614..6b0c02128c069 100644 --- a/src/vs/platform/agentHost/browser/webSocketClientTransport.ts +++ b/src/vs/platform/agentHost/browser/webSocketClientTransport.ts @@ -34,6 +34,9 @@ export class WebSocketClientTransport extends Disposable implements IClientTrans private _ws: WebSocket | undefined; private _malformedFrames = 0; + /** Guards against firing onClose more than once. */ + private _closeFired = false; + get isOpen(): boolean { return this._ws?.readyState === WebSocket.OPEN; } @@ -42,6 +45,7 @@ export class WebSocketClientTransport extends Disposable implements IClientTrans private readonly _address: string, private readonly _connectionToken?: string, ) { + // TODO: @osortega remove console.logs super(); } @@ -138,20 +142,44 @@ export class WebSocketClientTransport extends Disposable implements IClientTrans }); ws.addEventListener('close', () => { - this._onClose.fire(); + if (!this._closeFired) { + this._closeFired = true; + this._onClose.fire(); + } }); ws.addEventListener('error', () => { // Error always precedes close - closing is handled in the close handler. - this._onClose.fire(); + // Only fire if close hasn't already been fired (e.g. from send failure). + if (!this._closeFired) { + this._closeFired = true; + this._onClose.fire(); + } }); }); } - send(message: ProtocolMessage | AhpServerNotification | JsonRpcResponse): void { + /** + * Send a message to the remote end. Returns `true` if the message was + * sent, `false` if it was dropped (socket not open). On failure, the + * transport is force-closed so reconnection is triggered immediately + * rather than silently losing messages. + */ + send(message: ProtocolMessage | AhpServerNotification | JsonRpcResponse): boolean { if (this._ws?.readyState === WebSocket.OPEN) { this._ws.send(JSON.stringify(message)); + return true; + } + console.warn( + `[WebSocketClientTransport] Message dropped: readyState=${this._ws?.readyState ?? 'no-socket'}` + ); + // Force-close and fire onClose exactly once to trigger reconnection + this._ws?.close(4001, 'send-on-dead-socket'); + if (!this._closeFired) { + this._closeFired = true; + this._onClose.fire(); } + return false; } override dispose(): void { diff --git a/src/vs/sessions/common/sessionsTelemetry.ts b/src/vs/sessions/common/sessionsTelemetry.ts index ed404d3272de3..04926331fc9b4 100644 --- a/src/vs/sessions/common/sessionsTelemetry.ts +++ b/src/vs/sessions/common/sessionsTelemetry.ts @@ -138,10 +138,10 @@ type TunnelConnectAttemptEvent = { type TunnelConnectAttemptClassification = { owner: 'osortega'; comment: 'Tracks individual agent-host tunnel connect attempts for performance and reliability.'; - isReconnect: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether this attempt was part of a reconnect cycle (true) or an initial connect (false).' }; + isReconnect: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether this attempt was part of a reconnect cycle (true) or an initial connect (false).' }; attempt: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Attempt number within the current connect session (1-based).' }; durationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Duration of this individual attempt in milliseconds.' }; - success: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether this individual attempt succeeded.' }; + success: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether this individual attempt succeeded.' }; errorCategory: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Category of error when the attempt failed (relayConnectionFailed, auth, authExpired, network, other); empty on success.' }; }; @@ -166,10 +166,10 @@ type TunnelConnectResolvedEvent = { type TunnelConnectResolvedClassification = { owner: 'osortega'; comment: 'Tracks overall agent-host tunnel connect session outcomes for reliability.'; - isReconnect: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the resolved session was a reconnect cycle (true) or an initial connect (false).' }; + isReconnect: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the resolved session was a reconnect cycle (true) or an initial connect (false).' }; totalAttempts: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total number of attempts made before resolution.' }; totalDurationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total elapsed time from session start to resolution in milliseconds.' }; - success: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the connect session ultimately succeeded.' }; + success: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the connect session ultimately succeeded.' }; failureReason: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Reason the session terminated without connecting (hostOffline, maxAttemptsReached, auth, authExpired); empty on success.' }; }; @@ -182,3 +182,98 @@ export function logTunnelConnectResolved(telemetryService: ITelemetryService, da failureReason: data.failureReason ?? '', }); } + +// --- Socket lifecycle telemetry --- + +export type SocketCloseTrigger = + | 'server' + | 'sendOnDeadSocket' + | 'visibility' + | 'offline' + | 'malformedFrames' + | 'disposed' + | 'error'; + +type SocketCloseEvent = { + closeCode: number; + wasClean: boolean; + lifetimeMs: number; + messagesSent: number; + messagesReceived: number; + messagesDropped: number; + trigger: string; +}; + +type SocketCloseClassification = { + owner: 'osortega'; + comment: 'Tracks WebSocket close events for agent host connections to measure connection reliability.'; + closeCode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'WebSocket close code.' }; + wasClean: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the close was clean.' }; + lifetimeMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'How long the socket was alive in milliseconds.' }; + messagesSent: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total messages sent.' }; + messagesReceived: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total messages received.' }; + messagesDropped: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total messages dropped due to non-OPEN socket.' }; + trigger: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'What triggered the close (server, sendOnDeadSocket, visibility, offline, malformedFrames, disposed, error).' }; +}; + +export function logSocketClose(telemetryService: ITelemetryService, data: { closeCode: number; wasClean: boolean; lifetimeMs: number; messagesSent: number; messagesReceived: number; messagesDropped: number; trigger: SocketCloseTrigger }): void { + telemetryService.publicLog2('vscodeAgents.socket/close', data); +} + +// --- Send dropped telemetry --- + +type SendDroppedEvent = { + readyState: number; + timeSinceLastReceiveMs: number; + timeSinceLastSendMs: number; +}; + +type SendDroppedClassification = { + owner: 'osortega'; + comment: 'Tracks when a message is silently dropped due to a non-OPEN WebSocket, indicating a zombie socket.'; + readyState: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'WebSocket readyState at drop time (0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED).' }; + timeSinceLastReceiveMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds since last received message.' }; + timeSinceLastSendMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds since last sent message.' }; +}; + +export function logSendDropped(telemetryService: ITelemetryService, data: { readyState: number; timeSinceLastReceiveMs: number; timeSinceLastSendMs: number }): void { + telemetryService.publicLog2('vscodeAgents.socket/sendDropped', data); +} + +// --- Visibility resumed telemetry --- + +type VisibilityResumedEvent = { + hiddenDurationMs: number; + socketAlive: boolean; + forceClosed: boolean; +}; + +type VisibilityResumedClassification = { + owner: 'osortega'; + comment: 'Tracks tab visibility resume events to measure zombie socket detection effectiveness.'; + hiddenDurationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'How long the tab was hidden in milliseconds.' }; + socketAlive: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the socket was alive after zombie detection check.' }; + forceClosed: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the socket was force-closed on resume.' }; +}; + +export function logVisibilityResumed(telemetryService: ITelemetryService, data: { hiddenDurationMs: number; socketAlive: boolean; forceClosed: boolean }): void { + telemetryService.publicLog2('vscodeAgents.socket/visibilityResumed', data); +} + +// --- Terminal recovery telemetry --- + +type TerminalRecoveryEvent = { + recoveredCount: number; + totalCount: number; +}; + +type TerminalRecoveryClassification = { + owner: 'osortega'; + comment: 'Tracks terminal reconnection outcomes after agent host disconnect.'; + recoveredCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Number of terminals successfully reconnected.' }; + totalCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total number of active terminals at reconnect time.' }; +}; + +export function logTerminalRecovery(telemetryService: ITelemetryService, data: { recoveredCount: number; totalCount: number }): void { + telemetryService.publicLog2('vscodeAgents.terminal/recovery', data); +} diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts index 408fe16327d15..6cbf7a107ff17 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -41,6 +41,9 @@ import { createRemoteAgentHarnessDescriptor, RemoteAgentCustomizationItemProvide import { RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvider.js'; import { SyncedCustomizationBundler } from './syncedCustomizationBundler.js'; import { ISSHRemoteAgentHostService } from '../../../../platform/agentHost/common/sshRemoteAgentHost.js'; +import { IAgentHostTerminalService } from '../../../../workbench/contrib/terminal/browser/agentHostTerminalService.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { logTerminalRecovery } from '../../../common/sessionsTelemetry.js'; /** Per-connection state bundle, disposed when a connection is removed. */ class ConnectionState extends Disposable { @@ -99,6 +102,8 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc @ICustomizationHarnessService private readonly _customizationHarnessService: ICustomizationHarnessService, @IStorageService private readonly _storageService: IStorageService, @IAgentPluginService private readonly _agentPluginService: IAgentPluginService, + @IAgentHostTerminalService private readonly _agentHostTerminalService: IAgentHostTerminalService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(); @@ -260,11 +265,34 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc } const existing = this._connections.get(connectionInfo.address); if (existing) { + const nameChanged = existing.name !== connectionInfo.name; + const clientIdChanged = existing.loggedConnection.clientId !== connectionInfo.clientId; + // If the name or clientId changed, tear down and re-register - if (existing.name !== connectionInfo.name || existing.loggedConnection.clientId !== connectionInfo.clientId) { - this._logService.info(`[RemoteAgentHost] Reconnecting contribution for ${connectionInfo.address}: oldClientId=${existing.loggedConnection.clientId}, newClientId=${connectionInfo.clientId}, nameChanged=${existing.name !== connectionInfo.name}`); + if (nameChanged || clientIdChanged) { + this._logService.info(`[RemoteAgentHost] Reconnecting contribution for ${connectionInfo.address}: oldClientId=${existing.loggedConnection.clientId}, newClientId=${connectionInfo.clientId}, nameChanged=${nameChanged}`); + const oldClientId = existing.loggedConnection.clientId; this._connections.deleteAndDispose(connectionInfo.address); this._setupConnection(connectionInfo); + + // Reconnect active terminals only when the backing + // client changed. Name-only updates don't invalidate + // subscriptions and would cause unnecessary buffer + // clear/replay flicker. + if (clientIdChanged) { + const newConnection = this._remoteAgentHostService.getConnection(connectionInfo.address); + if (newConnection) { + this._agentHostTerminalService.reconnectTerminals(newConnection, oldClientId).then( + ({ recovered, total }) => { + if (total > 0) { + this._logService.info(`[RemoteAgentHost] Terminal reconnection: ${recovered}/${total} recovered`); + logTerminalRecovery(this._telemetryService, { recoveredCount: recovered, totalCount: total }); + } + }, + err => this._logService.warn('[RemoteAgentHost] Terminal reconnection failed', err) + ); + } + } } } else { this._setupConnection(connectionInfo); diff --git a/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts b/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts index 30c9e0cee5c3f..0daf8a902e1db 100644 --- a/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts +++ b/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts @@ -120,7 +120,7 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { constructor( id: number, - private readonly _connection: IAgentConnection, + private _connection: IAgentConnection, private readonly _terminalUri: URI, private readonly _options?: IAgentHostPtyOptions, ) { @@ -378,6 +378,84 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { // Not applicable } + /** + * Reconnect this pty to a new agent host connection. Tears down the + * old subscription and re-subscribes with the new connection, replaying + * content from the server-side snapshot. Terminal output during the + * disconnect gap is a stream (not state), so some loss is expected. + * + * @returns `true` if reconnection succeeded, `false` otherwise. + */ + async reconnect(newConnection: IAgentConnection): Promise { + // Clean up old subscription + this._subscriptionDisposables.clear(); + this._subscriptionRef?.dispose(); + this._subscriptionRef = undefined; + + // Swap connection + this._connection = newConnection; + + try { + // Re-subscribe to the terminal state + this._subscriptionRef = this._connection.getSubscription(StateComponents.Terminal, this._terminalUri); + const subscription = this._subscriptionRef.object; + + // Wait for hydration with a timeout — the terminal may no longer + // exist on the server (e.g. agent process restarted). + if (subscription.value === undefined) { + const RECONNECT_HYDRATE_TIMEOUT_MS = 10_000; + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + listener.dispose(); + reject(new Error('Reconnect hydration timed out')); + }, RECONNECT_HYDRATE_TIMEOUT_MS); + const listener = subscription.onDidChange(() => { + clearTimeout(timer); + listener.dispose(); + resolve(); + }); + this._subscriptionDisposables.add(listener); + }); + } + + const state = subscription.value as TerminalState; + + if (state.supportsCommandDetection && !this._supportsCommandDetection) { + this._supportsCommandDetection = true; + this._onSupportsCommandDetection.fire(); + } + + // Clear the terminal buffer before replaying to avoid duplicate + // content. ESC[2J clears the screen, ESC[3J clears scrollback, + // ESC[H moves cursor to home position. + this.handleData('\x1b[2J\x1b[3J\x1b[H'); + this._replayContent(state.content); + + // Update cwd/title if they changed + if (state.cwd) { + this._properties.cwd = state.cwd.toString(); + } + if (state.title) { + this._properties.title = state.title; + } + + // Wire up action listener for streaming updates + this._subscriptionDisposables.add(subscription.onDidApplyAction(envelope => { + this._handleAction(envelope); + })); + + return true; + } catch (err) { + console.warn('[AgentHostPty] Reconnection failed:', err instanceof Error ? err.message : String(err)); + return false; + } + } + + /** The terminal URI this pty is subscribed to. */ + get terminalUri(): URI { + return this._terminalUri; + } + override dispose(): void { this._subscriptionRef?.dispose(); this._subscriptionRef = undefined; diff --git a/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts b/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts index 19a9fba7ab215..6de7432acc951 100644 --- a/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts +++ b/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts @@ -78,6 +78,13 @@ export interface IAgentHostTerminalService { */ createTerminalForEntry(address: string, options?: IAgentHostTerminalCreateOptions): Promise; + /** + * Reconnects all active terminals that belonged to {@link oldClientId} + * to a new agent host connection. Only terminals matching the old + * client are touched — terminals from other hosts are left alone. + */ + reconnectTerminals(newConnection: IAgentConnection, oldClientId: string): Promise<{ recovered: number; total: number }>; + /** * Attaches to an existing server-side terminal by subscribing to its * state without creating a new process. @@ -104,6 +111,11 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe /** Revived terminal instances, keyed by terminal URI string. */ private readonly _revivedInstances = new Map(); + /** + * Active AgentHostPty instances with their owning connection clientId, + * keyed by terminal URI string. Used for reconnection scoping. + */ + private readonly _activePtys = new Map(); private readonly _reviveSequencer = new SequencerByKey(); constructor( @@ -279,8 +291,9 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe async createTerminal(connection: IAgentConnection, options?: IAgentHostTerminalCreateOptions): Promise { const terminalUri = URI.from({ scheme: 'agenthost-terminal', path: `/${generateUuid()}` }); const name = options?.name ?? localize('agentHostTerminal.default', "Agent Host Terminal"); + const key = terminalUri.toString(); - return this._terminalService.createTerminal({ + const instance = await this._terminalService.createTerminal({ config: { customPtyImplementation: (id, cols, rows) => { const pty = new AgentHostPty(id, connection, terminalUri, { @@ -290,6 +303,7 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe if (cols > 0 && rows > 0) { pty.resize(cols, rows); } + this._activePtys.set(key, { pty, clientId: connection.clientId }); return pty; }, name, @@ -298,6 +312,12 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe }, location: options?.location, }); + + this._register(instance.onDisposed(() => { + this._activePtys.delete(key); + })); + + return instance; } async reviveTerminal(connection: IAgentConnection, terminalUri: URI, terminalToolSessionId: string): Promise { @@ -328,6 +348,7 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe commandSource.connect(instance, pty); } + this._activePtys.set(key, { pty, clientId: connection.clientId }); return pty; }, name: localize('agentHostTerminal.tool', "Agent Host Terminal"), @@ -341,8 +362,36 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe instance.store.add(store); this._register(instance.onDisposed(() => { this._revivedInstances.delete(key); + this._activePtys.delete(key); })); return instance; } + + async reconnectTerminals(newConnection: IAgentConnection, oldClientId: string): Promise<{ recovered: number; total: number }> { + // Only reconnect terminals that belonged to the old connection + // identified by oldClientId. In multi-host setups, other hosts' + // terminals are left untouched. + const entries = [...this._activePtys.entries()].filter( + ([, entry]) => entry.clientId === oldClientId + ); + const total = entries.length; + let recovered = 0; + const promises: Promise[] = []; + for (const [key, entry] of entries) { + promises.push( + entry.pty.reconnect(newConnection).then(success => { + if (success) { + recovered++; + // Update the clientId to the new connection + entry.clientId = newConnection.clientId; + } else { + console.warn(`[AgentHostTerminalService] Failed to reconnect terminal: ${key}`); + } + }) + ); + } + await Promise.all(promises); + return { recovered, total }; + } } diff --git a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts index e474f44d569ca..18370f0de2fe7 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts @@ -341,4 +341,123 @@ suite('AgentHostPty', () => { const cwd = await pty.getInitialCwd(); assert.strictEqual(cwd, '/home/user'); }); + + test('reconnect() re-subscribes with new connection and replays content', async () => { + const conn1 = new MockAgentConnection({ content: [{ type: 'unclassified', value: 'old output\n' }] }); + disposables.add(conn1); + const pty = disposables.add(new AgentHostPty(1, conn1, terminalUri)); + + await pty.start(); + + // Create a new connection with different content (simulating server-side changes during disconnect) + const conn2 = new MockAgentConnection({ + content: [{ type: 'unclassified', value: 'old output\nnew output after reconnect\n' }], + cwd: '/home/reconnected', + title: 'Reconnected Terminal', + }); + disposables.add(conn2); + + const dataReceived: string[] = []; + disposables.add(pty.onProcessData!(e => { + dataReceived.push(typeof e === 'string' ? e : e.data); + })); + + const result = await pty.reconnect(conn2); + + assert.strictEqual(result, true, 'reconnect() should succeed'); + // Should have clear sequence + replayed content + assert.ok(dataReceived.some(d => d.includes('\x1b[2J')), 'should clear buffer before replay'); + assert.ok(dataReceived.some(d => d.includes('new output after reconnect')), 'should replay new content'); + + const cwd = await pty.getCwd(); + assert.strictEqual(cwd, '/home/reconnected'); + }); + + test('reconnect() streams new actions from new connection', async () => { + const conn1 = new MockAgentConnection(); + disposables.add(conn1); + const pty = disposables.add(new AgentHostPty(1, conn1, terminalUri)); + await pty.start(); + + const conn2 = new MockAgentConnection(); + disposables.add(conn2); + + const dataReceived: string[] = []; + disposables.add(pty.onProcessData!(e => { + dataReceived.push(typeof e === 'string' ? e : e.data); + })); + + await pty.reconnect(conn2); + dataReceived.length = 0; // clear replay data + + // New actions from conn2 should be received + conn2.fireAction({ type: ActionType.TerminalData, terminal: terminalUri.toString(), data: 'post-reconnect data' }); + + assert.deepStrictEqual(dataReceived, ['post-reconnect data']); + + // Old connection actions should NOT be received + conn1.fireAction({ type: ActionType.TerminalData, terminal: terminalUri.toString(), data: 'stale data' }); + assert.deepStrictEqual(dataReceived, ['post-reconnect data']); + }); + + test('reconnect() times out when subscription never hydrates', async () => { + const conn1 = new MockAgentConnection(); + disposables.add(conn1); + const pty = disposables.add(new AgentHostPty(1, conn1, terminalUri)); + await pty.start(); + + // Create a connection whose subscription never fires onDidChange + const conn2 = new MockAgentConnection(); + disposables.add(conn2); + // Override getSubscription to return a subscription that never hydrates + conn2.getSubscription = (_kind: StateComponents, _resource: URI): IReference> => { + const onDidChange = new Emitter(); + const onDidApplyAction = new Emitter(); + disposables.add(onDidChange); + disposables.add(onDidApplyAction); + const sub: IAgentSubscription = { + value: undefined, // never hydrated + verifiedValue: undefined, + onDidChange: onDidChange.event, + onWillApplyAction: Event.None, + onDidApplyAction: onDidApplyAction.event, + }; + return { + object: sub as IAgentSubscription, + dispose: () => { onDidChange.dispose(); onDidApplyAction.dispose(); }, + }; + }; + + // Suppress the expected console.warn from reconnect failure + const origWarn = console.warn; + console.warn = () => { }; + try { + const result = await pty.reconnect(conn2); + assert.strictEqual(result, false, 'reconnect() should fail on timeout'); + } finally { + console.warn = origWarn; + } + }).timeout(15000); // Allow for the 10s hydration timeout + + test('reconnect() dispatches input to new connection', async () => { + const conn1 = new MockAgentConnection(); + disposables.add(conn1); + const pty = disposables.add(new AgentHostPty(1, conn1, terminalUri)); + await pty.start(); + + const conn2 = new MockAgentConnection(); + disposables.add(conn2); + await pty.reconnect(conn2); + + pty.input('after reconnect'); + await new Promise(resolve => setTimeout(resolve, 10)); + + const inputActions = conn2.dispatchedActions.filter(a => a.type === ActionType.TerminalInput); + assert.strictEqual(inputActions.length, 1); + assert.strictEqual((inputActions[0] as { data: string }).data, 'after reconnect'); + + // conn1 should not have received the input + const oldInputActions = conn1.dispatchedActions.filter(a => a.type === ActionType.TerminalInput); + assert.strictEqual(oldInputActions.length, 0); + }); });