diff --git a/apps/server/src/terminal/Layers/BunPTY.ts b/apps/server/src/terminal/Layers/BunPTY.ts index f0aab813c7c..593fa4e5644 100644 --- a/apps/server/src/terminal/Layers/BunPTY.ts +++ b/apps/server/src/terminal/Layers/BunPTY.ts @@ -1,6 +1,11 @@ import { Effect, Layer } from "effect"; import { PtyAdapter } from "../Services/PTY.ts"; -import type { PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY.ts"; +import { + PtySpawnError, + type PtyAdapterShape, + type PtyExitEvent, + type PtyProcess, +} from "../Services/PTY.ts"; class BunPtyProcess implements PtyProcess { private readonly dataListeners = new Set<(data: string) => void>(); @@ -99,22 +104,30 @@ export const layer = Layer.effect( } return { spawn: (input) => - Effect.sync(() => { - let processHandle: BunPtyProcess | null = null; - const command = [input.shell, ...(input.args ?? [])]; - const subprocess = Bun.spawn(command, { - cwd: input.cwd, - env: input.env, - terminal: { - cols: input.cols, - rows: input.rows, - data: (_terminal, data) => { - processHandle?.emitData(data); + Effect.try({ + try: () => { + let processHandle: BunPtyProcess | null = null; + const command = [input.shell, ...(input.args ?? [])]; + const subprocess = Bun.spawn(command, { + cwd: input.cwd, + env: input.env, + terminal: { + cols: input.cols, + rows: input.rows, + data: (_terminal, data) => { + processHandle?.emitData(data); + }, }, - }, - }); - processHandle = new BunPtyProcess(subprocess); - return processHandle; + }); + processHandle = new BunPtyProcess(subprocess); + return processHandle as PtyProcess; + }, + catch: (cause) => + new PtySpawnError({ + adapter: "bun-pty", + message: cause instanceof Error ? cause.message : "Failed to spawn PTY process", + cause, + }), }), } satisfies PtyAdapterShape; }), diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 6c71e5eb334..9a3d001f066 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -46,6 +46,7 @@ import { type ThreadTerminalGroup, } from "../types"; import { readEnvironmentApi } from "~/environmentApi"; +import { extractErrorMessage } from "~/lib/errorMessage"; import { readLocalApi } from "~/localApi"; import { selectTerminalEventEntries, useTerminalStateStore } from "../terminalStateStore"; @@ -517,10 +518,7 @@ export function TerminalViewport({ void api.terminal .write({ threadId, terminalId, data }) .catch((err) => - writeSystemMessage( - terminal, - err instanceof Error ? err.message : "Terminal write failed", - ), + writeSystemMessage(terminal, extractErrorMessage(err, "Terminal write failed")), ); }); @@ -702,10 +700,7 @@ export function TerminalViewport({ } } catch (err) { if (disposed) return; - writeSystemMessage( - terminal, - err instanceof Error ? err.message : "Failed to open terminal", - ); + writeSystemMessage(terminal, extractErrorMessage(err, "Failed to open terminal")); } }; diff --git a/apps/web/src/lib/errorMessage.test.ts b/apps/web/src/lib/errorMessage.test.ts new file mode 100644 index 00000000000..de5379db2ed --- /dev/null +++ b/apps/web/src/lib/errorMessage.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { extractErrorMessage } from "./errorMessage"; + +describe("extractErrorMessage", () => { + const fallback = "fallback message"; + + it("returns the message of a regular Error", () => { + const err = new Error("boom"); + expect(extractErrorMessage(err, fallback)).toBe("boom"); + }); + + it("returns fallback when Error has empty message", () => { + const err = new Error(""); + expect(extractErrorMessage(err, fallback)).toBe(fallback); + }); + + it("returns fallback when Error message is whitespace", () => { + const err = new Error(" "); + expect(extractErrorMessage(err, fallback)).toBe(fallback); + }); + + it("extracts message from a non-Error object with a message string", () => { + const err = { message: "RPC defect: failed to spawn" }; + expect(extractErrorMessage(err, fallback)).toBe("RPC defect: failed to spawn"); + }); + + it("formats objects carrying a _tag with the serialized payload", () => { + const err = { _tag: "TerminalCwdError", cwd: "/tmp/missing", reason: "notFound" }; + expect(extractErrorMessage(err, fallback)).toContain("TerminalCwdError"); + expect(extractErrorMessage(err, fallback)).toContain("/tmp/missing"); + }); + + it("prefers object.message over _tag formatting", () => { + const err = { _tag: "Foo", message: "explicit message" }; + expect(extractErrorMessage(err, fallback)).toBe("explicit message"); + }); + + it("returns string rejections directly", () => { + expect(extractErrorMessage("a literal string defect", fallback)).toBe( + "a literal string defect", + ); + }); + + it("returns fallback for null", () => { + expect(extractErrorMessage(null, fallback)).toBe(fallback); + }); + + it("returns fallback for undefined", () => { + expect(extractErrorMessage(undefined, fallback)).toBe(fallback); + }); + + it("returns fallback for numeric rejections", () => { + expect(extractErrorMessage(42, fallback)).toBe(fallback); + }); + + it("survives circular objects without throwing", () => { + const err: { _tag: string; self?: unknown } = { _tag: "Circular" }; + err.self = err; + expect(extractErrorMessage(err, fallback)).toBe("Circular"); + }); +}); diff --git a/apps/web/src/lib/errorMessage.ts b/apps/web/src/lib/errorMessage.ts new file mode 100644 index 00000000000..e35781458e1 --- /dev/null +++ b/apps/web/src/lib/errorMessage.ts @@ -0,0 +1,35 @@ +export function extractErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error) { + const message = error.message; + if (typeof message === "string" && message.trim().length > 0) { + return message; + } + } + + if (typeof error === "object" && error !== null) { + const messageCandidate = (error as { message?: unknown }).message; + if (typeof messageCandidate === "string" && messageCandidate.trim().length > 0) { + return messageCandidate; + } + + const tagCandidate = (error as { _tag?: unknown })._tag; + if (typeof tagCandidate === "string" && tagCandidate.length > 0) { + const serialized = safeStringify(error); + return serialized ? `${tagCandidate}: ${serialized}` : tagCandidate; + } + } + + if (typeof error === "string" && error.trim().length > 0) { + return error; + } + + return fallback; +} + +function safeStringify(value: unknown): string | null { + try { + return JSON.stringify(value); + } catch { + return null; + } +} diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index 3feae674924..6ab692dad6b 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -49,6 +49,36 @@ describe("TerminalOpenInput", () => { ).toBe(false); }); + it("accepts maximum cols and rows", () => { + expect( + decodes(TerminalOpenInput, { + threadId: "thread-1", + cwd: "/tmp/project", + cols: 1000, + rows: 500, + }), + ).toBe(true); + }); + + it("rejects cols and rows above maximum", () => { + expect( + decodes(TerminalOpenInput, { + threadId: "thread-1", + cwd: "/tmp/project", + cols: 1001, + rows: 24, + }), + ).toBe(false); + expect( + decodes(TerminalOpenInput, { + threadId: "thread-1", + cwd: "/tmp/project", + cols: 80, + rows: 501, + }), + ).toBe(false); + }); + it("defaults terminalId when missing", () => { const parsed = decodeSync(TerminalOpenInput, { threadId: "thread-1", diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index 21bd74a0999..6ac1135db0d 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -5,10 +5,10 @@ export const DEFAULT_TERMINAL_ID = "default"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; const TerminalColsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(20)).check( - Schema.isLessThanOrEqualTo(400), + Schema.isLessThanOrEqualTo(1000), ); const TerminalRowsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(5)).check( - Schema.isLessThanOrEqualTo(200), + Schema.isLessThanOrEqualTo(500), ); const TerminalIdSchema = TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(128)); const TerminalEnvKeySchema = Schema.String.check(