diff --git a/apps/web/components/task/use-passthrough-terminal.test.ts b/apps/web/components/task/use-passthrough-terminal.test.ts index 03261a1184..002cdf12f5 100644 --- a/apps/web/components/task/use-passthrough-terminal.test.ts +++ b/apps/web/components/task/use-passthrough-terminal.test.ts @@ -3,6 +3,8 @@ import { buildTerminalWsUrl } from "./use-passthrough-terminal"; import { reconnectDelayMs, startReconnectLoop } from "./ws-reconnect"; import type { Terminal } from "@xterm/xterm"; +const WS_BASE_URL = "ws://localhost:38429"; + describe("reconnectDelayMs", () => { it("returns 300ms for attempt 0", () => { expect(reconnectDelayMs(0)).toBe(300); @@ -29,7 +31,7 @@ describe("reconnectDelayMs", () => { describe("buildTerminalWsUrl", () => { it("routes shell terminals by task environment ID", () => { expect( - buildTerminalWsUrl("ws://localhost:38429", { + buildTerminalWsUrl(WS_BASE_URL, { mode: "shell", environmentId: "env-1", terminalId: "terminal with spaces", @@ -39,7 +41,7 @@ describe("buildTerminalWsUrl", () => { it("routes agent terminals by session ID", () => { expect( - buildTerminalWsUrl("ws://localhost:38429", { + buildTerminalWsUrl(WS_BASE_URL, { mode: "agent", sessionId: "session-1", }), @@ -62,11 +64,11 @@ describe("startReconnectLoop", () => { const stop = startReconnectLoop({ environmentId: "env-1", - wsBaseUrl: "ws://localhost:38429", + wsBaseUrl: WS_BASE_URL, mode: "shell", terminalId: "shell-1", label: undefined, - terminal: {} as Terminal, + terminal: { reset: vi.fn() } as unknown as Terminal, fitAndResize: vi.fn(), wsRef: { current: null }, attachAddonRef: { current: null }, @@ -81,4 +83,43 @@ describe("startReconnectLoop", () => { expect(onDisconnected).toHaveBeenCalledTimes(1); stop(); }); + + it("resets xterm before each connection so replayed PTY buffers do not append duplicates", () => { + vi.useFakeTimers(); + + const terminal = { reset: vi.fn() } as unknown as Terminal; + let closes = 0; + const connectWebSocket = vi.fn(({ onSocketClose }) => { + if (closes < 1) { + closes += 1; + onSocketClose({ code: 1006 } as CloseEvent); + } + }); + + const stop = startReconnectLoop({ + environmentId: "env-1", + wsBaseUrl: WS_BASE_URL, + mode: "shell", + terminalId: "shell-1", + label: undefined, + terminal, + fitAndResize: vi.fn(), + wsRef: { current: null }, + attachAddonRef: { current: null }, + onConnected: vi.fn(), + onDisconnected: vi.fn(), + connectWebSocket, + }); + + vi.advanceTimersByTime(150); + + expect(connectWebSocket).toHaveBeenCalledTimes(1); + expect(terminal.reset).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(300); + + expect(connectWebSocket).toHaveBeenCalledTimes(2); + expect(terminal.reset).toHaveBeenCalledTimes(2); + stop(); + }); }); diff --git a/apps/web/components/task/ws-reconnect.ts b/apps/web/components/task/ws-reconnect.ts index bafb0038ff..f930475c18 100644 --- a/apps/web/components/task/ws-reconnect.ts +++ b/apps/web/components/task/ws-reconnect.ts @@ -95,6 +95,10 @@ export function startReconnectLoop({ if (connectTimeout) clearTimeout(connectTimeout); connectTimeout = setTimeout(() => { if (!isMounted) return; + // The backend replays the PTY buffer whenever a terminal WebSocket + // reconnects. Clear the existing xterm contents first so replay replaces + // the visible terminal instead of appending duplicate prompt lines. + terminal.reset(); connectWebSocket({ sessionId, environmentId,