Agents web: improve connection stability and terminal reconnection (#312189)

* sessions: improve connection stability and terminal reconnection

- WebSocketClientTransport: fail-fast on send to dead socket with
  force-close and _closeFired guard to prevent double onClose events
- AgentHostPty: add reconnect() method with 10s hydration timeout
  and buffer clear before snapshot replay
- AgentHostTerminalService: add reconnectTerminals() scoped by
  oldClientId, track active ptys with dispose cleanup
- RemoteAgentHostContribution: trigger terminal reconnection on
  clientId change during reconnect
- Telemetry: add socket/close, socket/sendDropped,
  socket/visibilityResumed, terminal/recovery events
- Fix isMeasurement on boolean fields in existing telemetry

* Update src/vs/sessions/common/sessionsTelemetry.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* address review: gate terminal reconnect on clientId change, add reconnect tests

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Osvaldo Ortega
2026-04-23 13:58:31 -07:00
committed by GitHub
parent 89eb050e83
commit dcc7279e0d
6 changed files with 408 additions and 11 deletions

View File

@@ -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 {

View File

@@ -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<SocketCloseEvent, SocketCloseClassification>('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<SendDroppedEvent, SendDroppedClassification>('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<VisibilityResumedEvent, VisibilityResumedClassification>('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<TerminalRecoveryEvent, TerminalRecoveryClassification>('vscodeAgents.terminal/recovery', data);
}

View File

@@ -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);

View File

@@ -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<boolean> {
// 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<void>((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;

View File

@@ -78,6 +78,13 @@ export interface IAgentHostTerminalService {
*/
createTerminalForEntry(address: string, options?: IAgentHostTerminalCreateOptions): Promise<ITerminalInstance | undefined>;
/**
* 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<string, ITerminalInstance>();
/**
* Active AgentHostPty instances with their owning connection clientId,
* keyed by terminal URI string. Used for reconnection scoping.
*/
private readonly _activePtys = new Map<string, { pty: AgentHostPty; clientId: string }>();
private readonly _reviveSequencer = new SequencerByKey<string>();
constructor(
@@ -279,8 +291,9 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe
async createTerminal(connection: IAgentConnection, options?: IAgentHostTerminalCreateOptions): Promise<ITerminalInstance> {
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<ITerminalInstance> {
@@ -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<void>[] = [];
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 };
}
}

View File

@@ -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 = <T>(_kind: StateComponents, _resource: URI): IReference<IAgentSubscription<T>> => {
const onDidChange = new Emitter<TerminalState>();
const onDidApplyAction = new Emitter<ActionEnvelope>();
disposables.add(onDidChange);
disposables.add(onDidApplyAction);
const sub: IAgentSubscription<TerminalState> = {
value: undefined, // never hydrated
verifiedValue: undefined,
onDidChange: onDidChange.event,
onWillApplyAction: Event.None,
onDidApplyAction: onDidApplyAction.event,
};
return {
object: sub as IAgentSubscription<T>,
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);
});
});