fix(ui): make terminal open failures retryable (#124890)

This commit is contained in:
Peter Steinberger
2026-08-16 16:15:24 -07:00
committed by GitHub
parent 00990506c0
commit b8a95e082b
10 changed files with 349 additions and 26 deletions
@@ -101,7 +101,7 @@ export class TerminalOpenTimeoutError extends Error {
* tab label, uploads, or a replay. Fail here so the panel reports an unusable
* gateway instead of surfacing a downstream TypeError as its only content. */
export class TerminalOpenUnusableSessionError extends Error {
constructor(field: string) {
constructor(readonly field: string) {
super(`terminal session response is missing ${field}`);
this.name = "TerminalOpenUnusableSessionError";
}
@@ -17,6 +17,12 @@ import {
} from "./terminal-panel-upload.ts";
type TerminalDock = Exclude<DockPanelPlacement, "left">;
type TerminalPanelViewportParams = {
activeId: string | null;
connecting: boolean;
error: { text: string; retry?: () => void } | null;
uploadController: TerminalPanelUploadController;
};
export function renderTerminalPanelToolbar(
fullscreen: boolean,
@@ -62,14 +68,23 @@ export function renderTerminalPanelHeader(
</header>`;
}
export function renderTerminalPanelViewport(
activeId: string | null,
connecting: boolean,
errorText: string | null,
uploadController: TerminalPanelUploadController,
): TemplateResult {
export function renderTerminalPanelViewport({
activeId,
connecting,
error,
uploadController,
}: TerminalPanelViewportParams): TemplateResult {
return html`
${errorText ? html`<div class="tp-error" role="alert">${errorText}</div>` : nothing}
${error
? html`<div class="tp-error" role="alert">
<span>${error.text}</span>
${error.retry
? html`<button class="btn btn--sm" type="button" @click=${error.retry}>
${t("common.retry")}
</button>`
: nothing}
</div>`
: nothing}
<wa-tab-panel
id="terminal-tab-panel"
class="tp-viewport"
@@ -87,7 +102,7 @@ export function renderTerminalPanelViewport(
<span>${t("terminal.connecting")}</span>
</div>`
: nothing}
${!activeId && !connecting && !errorText
${!activeId && !connecting && !error
? renderPanelEmptyState({
icon: icons.terminal,
heading: t("chat.sidePanel.terminal"),
@@ -105,7 +120,7 @@ export function terminalOpenErrorText(error: unknown): string {
return t("terminal.connectionTimedOut");
}
if (error instanceof TerminalOpenUnusableSessionError) {
return t("terminal.unavailable");
return t("terminal.unusableSession", { field: error.field });
}
return formatUiError(error);
}
@@ -281,10 +281,11 @@ describe("terminal panel readiness", () => {
panel.available = true;
(panel as unknown as { catalogReadyTimeoutMs: number }).catalogReadyTimeoutMs = 5;
document.body.append(panel);
const catalog = { catalogId: "anthropic", hostId: "node:mac", threadId: "thread" };
panel.handleToggleRequest(
new CustomEvent("openclaw:terminal-toggle", {
detail: { catalog: { catalogId: "anthropic", hostId: "node:mac", threadId: "thread" } },
detail: { catalog },
}),
);
@@ -298,5 +299,15 @@ describe("terminal panel readiness", () => {
params: { sessionId: "catalog-terminal-1" },
});
expect(panel.renderRoot.querySelector(".tabstrip-tab")).toBeNull();
const retry = panel.renderRoot.querySelector<HTMLButtonElement>(".tp-error button");
expect(retry?.textContent?.trim()).toBe("Retry");
retry?.click();
await waitForFast(() => {
expect(requests.filter((request) => request.method === "terminal.open")).toHaveLength(2);
});
expect(requests.findLast((request) => request.method === "terminal.open")?.params).toEqual(
expect.objectContaining({ catalog }),
);
});
});
@@ -23,7 +23,8 @@ import {
type TerminalPanelSessionControllerState,
type TerminalPanelSessionTab,
} from "./terminal-panel-session-types.ts";
import { terminalIntentQueue, type TerminalIntentHost } from "./terminal-pending-actions.ts";
import { TerminalOpenRetry, terminalIntentQueue } from "./terminal-pending-actions.ts";
import type { TerminalIntentHost } from "./terminal-pending-actions.ts";
import {
loadPersistedTerminalSessionIds,
persistLiveTerminalSessions,
@@ -33,8 +34,6 @@ import { TerminalTabReadinessController } from "./terminal-tab-readiness.ts";
import { TerminalTaskQueue } from "./terminal-task-queue.ts";
import { terminalDynamicColors, terminalTheme } from "./terminal-theme.ts";
type TerminalSessionSink = Parameters<TerminalConnection["open"]>[1];
/** Owns gateway PTY sessions and the Ghostty controllers bound to them. */
export class TerminalPanelSessionController
implements ReactiveController, TerminalPanelSessionControllerState
@@ -52,6 +51,7 @@ export class TerminalPanelSessionController
private lifecycleAbortController = new AbortController();
private lifecycleSyncToken = 0;
private tabSequence = 0;
readonly openRetry = new TerminalOpenRetry();
private readonly bootQueue = new TerminalTaskQueue();
private readonly intentHost: TerminalIntentHost;
private readonly readiness: TerminalTabReadinessController<TerminalPanelSessionTab>;
@@ -70,17 +70,14 @@ export class TerminalPanelSessionController
requestUpdate: () => this.host.requestUpdate(),
setBooting: (booting) => this.updateControllerState("booting", booting),
timeoutMs: () => this.host.catalogReadyTimeoutMs,
showTimeout: () => {
this.host.terminalPanelErrorText = t("terminal.refreshRequired");
},
clearTimeout: () => {
this.host.terminalPanelErrorText = null;
},
showTimeout: () => (this.host.terminalPanelErrorText = t("terminal.refreshRequired")),
clearTimeout: () => (this.host.terminalPanelErrorText = null),
};
this.readiness = new TerminalTabReadinessController<TerminalPanelSessionTab>({
timeoutMs: () => this.host.catalogReadyTimeoutMs,
isCurrent: (tab) => this.tabs.includes(tab),
onReady: () => {
this.openRetry.clear();
this.updateControllerState("tabs", [...this.tabs]);
persistLiveTerminalSessions(this.tabs);
},
@@ -318,6 +315,7 @@ export class TerminalPanelSessionController
return false;
}
this.updateControllerState("booting", true);
this.openRetry.clear();
this.host.terminalPanelErrorText = null;
try {
const attached = await this.attachSession(sessionId, operation, agentOwned);
@@ -413,7 +411,7 @@ export class TerminalPanelSessionController
readyTimer: null,
};
tabReference.current = tab;
const sink: TerminalSessionSink = {
const sink: Parameters<TerminalConnection["open"]>[1] = {
// The cancelled guard also protects the buffered-event replay inside
// connection.open/attach from writing to an already-disposed terminal.
onData: (data: string) => {
@@ -520,6 +518,7 @@ export class TerminalPanelSessionController
return false;
}
this.updateControllerState("booting", true);
this.openRetry.remember(catalog, agentId);
this.host.terminalPanelErrorText = null;
// Freeze the selection for this tab; later agent changes affect only new tabs.
const ownerAgentId = agentId ?? undefined;
@@ -561,6 +560,7 @@ export class TerminalPanelSessionController
if (!this.isTerminalOperationCurrent(operation)) {
return false;
}
this.openRetry.clearUnlessRetryable(error);
this.host.terminalPanelErrorText = terminalOpenErrorText(error);
return true;
} finally {
@@ -766,6 +766,7 @@ export class TerminalPanelSessionController
this.lifecycleAbortController.abort();
this.lifecycleAbortController = new AbortController();
this.bootQueue.reset();
this.openRetry.clear();
this.updateControllerState("booting", false);
this.host.terminalPanelUploadController.dispose();
this.host.clearTerminalPanelResizeListeners();
@@ -182,10 +182,23 @@ export const terminalPanelStyles = css`
animation: tp-spin 0.8s linear infinite;
}
.tp-error {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
font-size: 12px;
color: var(--danger, #ff6b6b);
}
.tp-error .btn {
flex: 0 0 auto;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-elevated);
color: var(--text);
padding: 6px 10px;
font: inherit;
}
@keyframes tp-spin {
to {
transform: rotate(360deg);
+18 -5
View File
@@ -378,6 +378,11 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
return this.renderRoot.querySelector(".tp-viewport");
}
private retryTerminalOpen(): void {
this.terminalPanelErrorText = null;
this.terminalSessions.openRetry.run();
}
override render() {
if (
!this.available ||
@@ -400,6 +405,14 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
this.terminalSessions.waitingForRefresh ||
(this.terminalSessions.booting && this.terminalSessions.tabs.length === 0) ||
activeTab?.status === "connecting";
const terminalError = this.terminalPanelErrorText
? {
text: this.terminalPanelErrorText,
retry: this.terminalSessions.openRetry.available
? () => this.retryTerminalOpen()
: undefined,
}
: null;
const sessionPicker = renderTerminalSessionPicker({
open: this.sessionPickerOpen,
loading: this.sessionPickerTask.status === TaskStatus.PENDING,
@@ -443,12 +456,12 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
},
() => void this.terminalSessions.openSession(),
)}
${renderTerminalPanelViewport(
this.terminalSessions.activeId,
${renderTerminalPanelViewport({
activeId: this.terminalSessions.activeId,
connecting,
this.terminalPanelErrorText,
this.terminalPanelUploadController,
)}
error: terminalError,
uploadController: this.terminalPanelUploadController,
})}
</section>
`;
}
@@ -1,4 +1,8 @@
import type { TerminalPanelToggleDetail } from "../panel-toggle-contract.ts";
import {
TerminalOpenTimeoutError,
TerminalOpenUnusableSessionError,
} from "./terminal-connection.ts";
import type {
TerminalPanelAction,
TerminalPanelCatalogReference,
@@ -9,6 +13,44 @@ import {
} from "./terminal-session-storage.ts";
import type { TerminalTaskQueue } from "./terminal-task-queue.ts";
type RetryOpenAction = Extract<TerminalPanelAction, { kind: "catalog" | "open" }>;
/** Retains the exact failed open intent until the operator retries or the tab becomes ready. */
export class TerminalOpenRetry {
private action: RetryOpenAction | null = null;
remember(catalog: TerminalPanelCatalogReference | undefined, agentId: string | null): void {
this.action = catalog ? { kind: "catalog", agentId, catalog } : { kind: "open", agentId };
}
clearUnlessRetryable(error: unknown): void {
if (
!(
error instanceof TerminalOpenTimeoutError ||
error instanceof TerminalOpenUnusableSessionError
)
) {
this.clear();
}
}
clear(): void {
this.action = null;
}
get available(): boolean {
return this.action !== null;
}
run(): void {
const action = this.action;
this.clear();
if (action) {
void terminalIntentQueue.queue(action);
}
}
}
export type TerminalIntentHost = {
bootQueue: Pick<TerminalTaskQueue, "enqueue">;
currentGeneration: () => number;
+119
View File
@@ -0,0 +1,119 @@
import { createHash } from "node:crypto";
import type { Locator, Page } from "playwright";
import { expect, it } from "vitest";
import { waitForControlUiGatewayReady } from "../test-helpers/control-ui-e2e-readiness.ts";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { openChatSidePanelType } from "./chat-side-panel.test-support.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
const suite = createControlUiE2eSuite({
name: "Control UI terminal open",
startServerBeforeBrowser: true,
unavailableMessage: (executablePath) =>
`Playwright Chromium is not installed or cannot start at ${executablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`.`,
});
async function openTerminalSidePanel(page: Page): Promise<Locator> {
await page.goto(`${suite.server.baseUrl}chat`);
await waitForControlUiGatewayReady(page);
await openChatSidePanelType(page, "Terminal");
return page.locator(".sidebar-region__right-runtime openclaw-terminal-panel");
}
async function canvasDigest(canvas: Locator): Promise<string> {
const png = await canvas.screenshot({ animations: "disabled", caret: "hide" });
return createHash("sha256").update(png).digest("hex");
}
async function settleTerminalPaint(page: Page): Promise<void> {
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
}
suite.define(() => {
it("opens against the shared mock and renders echoed terminal output", async () => {
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
const gateway = await installMockGateway(page, {
featureMethods: ["chat.startup", "terminal.open"],
terminalEnabled: true,
});
const panel = await openTerminalSidePanel(page);
await gateway.waitForRequest("terminal.open");
await panel.locator(".tabstrip-tab.is-live").waitFor();
const canvas = panel.locator(".tp-host canvas");
await canvas.waitFor({ state: "visible" });
await settleTerminalPaint(page);
const bannerDigest = await canvasDigest(canvas);
const sentinel = "PROVABLE_MOCK_TERMINAL";
await canvas.click();
await page.keyboard.type(sentinel);
await expect
.poll(async () =>
(await gateway.getRequests("terminal.input"))
.map((request) => (request.params as { data?: string }).data ?? "")
.join(""),
)
.toContain(sentinel);
await expect.poll(() => canvasDigest(canvas)).not.toBe(bannerDigest);
});
});
it("names a missing field and retries the open successfully", async () => {
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
const gateway = await installMockGateway(page, {
featureMethods: ["chat.startup", "terminal.open"],
methodResponses: {
"terminal.open": {
sequence: [
{
agentId: "main",
confined: false,
sessionId: "terminal-missing-cwd",
shell: "/bin/zsh",
},
{
agentId: "main",
confined: false,
cwd: "/workspace/openclaw",
sessionId: "terminal-retry-ready",
shell: "/bin/zsh",
},
],
},
},
terminalEnabled: true,
});
const panel = await openTerminalSidePanel(page);
const error =
"The Gateway returned an unusable terminal session (missing cwd). The Gateway is likely older than this Control UI — update it, then retry.";
await panel.getByText(error, { exact: true }).waitFor();
const retry = panel.getByRole("button", { name: "Retry", exact: true });
await retry.waitFor();
expect(await gateway.getRequests("terminal.open")).toHaveLength(1);
await retry.click();
await expect.poll(async () => (await gateway.getRequests("terminal.open")).length).toBe(2);
await panel.locator(".tabstrip-tab.is-live").waitFor();
const canvas = panel.locator(".tp-host canvas");
await canvas.waitFor({ state: "visible" });
await settleTerminalPaint(page);
const blankDigest = await canvasDigest(canvas);
const retryOutput = "Retry opened terminal\r\n";
await gateway.emitGatewayEvent("terminal.data", {
sessionId: "terminal-retry-ready",
seq: retryOutput.length,
data: retryOutput,
});
await expect.poll(() => canvasDigest(canvas)).not.toBe(blankDigest);
expect(await panel.getByText(error, { exact: true }).count()).toBe(0);
});
});
});
+2
View File
@@ -2014,6 +2014,8 @@ export const en: TranslationMap = {
dockMain: "Fill main content area",
dockMode: "Terminal panel position",
unavailable: "The terminal is not available on this gateway.",
unusableSession:
"The Gateway returned an unusable terminal session (missing {field}). The Gateway is likely older than this Control UI — update it, then retry.",
uploadTooLarge: "File exceeds the 16 MiB terminal upload limit: {file}",
uploadUnsafeCmdPath: "Cannot safely insert an uploaded path containing % or ! into cmd.exe",
uploadUnsupportedShell: "Cannot safely insert an uploaded path into unsupported shell: {shell}",
+107
View File
@@ -948,6 +948,18 @@ function installControlUiMockGateway(
method: string;
match?: Record<string, unknown>;
};
type MockTerminalSession = {
sessionId: string;
agentId: string;
shell: string;
cwd: string;
confined: boolean;
attached: boolean;
owner: "conn";
createdAtMs: number;
buffer: string;
seq: number;
};
type ExposedGateway = {
closeLatest: (code?: number, reason?: string) => void;
deliverLatest: (frame: unknown) => void;
@@ -1006,6 +1018,8 @@ function installControlUiMockGateway(
const methodResponseSequenceIndexes = new Map<string, number>();
const sessionPatches = new Map<string, Record<string, unknown>>();
const createdSessions = new Map<string, Record<string, unknown>>();
const terminalSessions = new Map<string, MockTerminalSession>();
let terminalSessionSequence = 0;
const sessionMessageSubscriptions = new Set<string>();
const sockets: Array<{
readonly readyState: number;
@@ -1883,11 +1897,101 @@ function installControlUiMockGateway(
};
case "sessions.messages.unsubscribe":
return { ok: true };
case "terminal.open": {
const sessionId = `control-ui-mock-terminal-${++terminalSessionSequence}`;
const session: MockTerminalSession = {
sessionId,
agentId:
isRecord(params) && typeof params.agentId === "string"
? params.agentId
: scenario.defaultAgentId,
shell: "/bin/zsh",
cwd: scenario.workspace || "/workspace/openclaw",
confined: false,
attached: true,
owner: "conn",
createdAtMs: Date.now(),
buffer: "",
seq: 0,
};
terminalSessions.set(sessionId, session);
return {
sessionId: session.sessionId,
agentId: session.agentId,
shell: session.shell,
cwd: session.cwd,
confined: session.confined,
};
}
case "terminal.attach": {
const sessionId = isRecord(params) ? params.sessionId : undefined;
const session = typeof sessionId === "string" ? terminalSessions.get(sessionId) : null;
return session
? {
sessionId: session.sessionId,
agentId: session.agentId,
shell: session.shell,
cwd: session.cwd,
confined: session.confined,
buffer: session.buffer,
seq: session.seq,
}
: {};
}
case "terminal.list":
return {
sessions: [...terminalSessions.values()].map(
({ buffer: _buffer, seq: _seq, ...session }) => session,
),
};
case "terminal.input":
case "terminal.resize":
return { ok: true };
case "terminal.close": {
const sessionId = isRecord(params) ? params.sessionId : undefined;
if (typeof sessionId === "string") {
terminalSessions.delete(sessionId);
}
return { ok: true };
}
default:
return {};
}
}
function emitTerminalOutput(
socket: { deliver: (frame: unknown) => void },
method: string,
params: unknown,
response: unknown,
): void {
let data = "";
let session: MockTerminalSession | undefined;
if (
method === "terminal.open" &&
isRecord(response) &&
typeof response.sessionId === "string"
) {
session = terminalSessions.get(response.sessionId);
data = "OpenClaw mock terminal\r\nType anything and the mock Gateway will echo it.\r\n$ ";
} else if (method === "terminal.input" && isRecord(params)) {
session =
typeof params.sessionId === "string" ? terminalSessions.get(params.sessionId) : undefined;
data = typeof params.data === "string" ? params.data : "";
}
if (!session || !data) {
return;
}
session.buffer += data;
session.seq += data.length;
socket.deliver({
event: "terminal.data",
payload: { sessionId: session.sessionId, seq: session.seq, data },
seq: ++seq,
type: "event",
});
}
function shouldDefer(method: string, params: unknown): boolean {
const index = deferredMethods.findIndex(
(candidate) => candidate.method === method && paramsMatch(params, candidate.match),
@@ -2005,6 +2109,9 @@ function installControlUiMockGateway(
? { id, ok: false, error: mockError, type: "res" }
: { id, ok: true, payload, type: "res" },
);
if (!mockError) {
emitTerminalOutput(this, method, frame.params, payload);
}
if (!mockError && method === "connect" && this.readyState === MockWebSocket.OPEN) {
this.tickTimer = window.setInterval(() => {
this.deliver({ event: "tick", payload: {}, seq: ++seq, type: "event" });