import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { getSlashCommands, parseCommand } from "./commands.js"; import { createBackspaceDeduper, isIgnorableTuiStopError, resolveCtrlCAction, resolveFinalAssistantText, resolveGatewayDisconnectState, resolveInitialTuiAgentId, resolveTuiSessionKey, stopTuiSafely, } from "./tui.js"; describe("resolveFinalAssistantText", () => { it("falls back to streamed text when final text is empty", () => { expect(resolveFinalAssistantText({ finalText: "", streamedText: "Hello" })).toBe("Hello"); }); it("prefers the final text when present", () => { expect( resolveFinalAssistantText({ finalText: "All done", streamedText: "partial", }), ).toBe("All done"); }); it("falls back to formatted error text when final and streamed text are empty", () => { expect( resolveFinalAssistantText({ finalText: "", streamedText: "", errorMessage: '401 {"error":{"message":"Missing scopes: model.request"}}', }), ).toContain("HTTP 401"); }); }); describe("tui slash commands", () => { it("treats /elev as an alias for /elevated", () => { expect(parseCommand("/elev on")).toEqual({ name: "elevated", args: "on" }); }); it("normalizes alias case", () => { expect(parseCommand("/ELEV off")).toEqual({ name: "elevated", args: "off", }); }); it("includes gateway text commands", () => { const commands = getSlashCommands({}); expect(commands.some((command) => command.name === "context")).toBe(true); expect(commands.some((command) => command.name === "commands")).toBe(true); }); }); describe("resolveTuiSessionKey", () => { it("uses global only as the default when scope is global", () => { expect( resolveTuiSessionKey({ raw: "", sessionScope: "global", currentAgentId: "main", sessionMainKey: "agent:main:main", }), ).toBe("global"); expect( resolveTuiSessionKey({ raw: "test123", sessionScope: "global", currentAgentId: "main", sessionMainKey: "agent:main:main", }), ).toBe("agent:main:test123"); }); it("keeps explicit agent-prefixed keys unchanged", () => { expect( resolveTuiSessionKey({ raw: "agent:ops:incident", sessionScope: "global", currentAgentId: "main", sessionMainKey: "agent:main:main", }), ).toBe("agent:ops:incident"); }); it("lowercases session keys with uppercase characters", () => { // Uppercase in agent-prefixed form expect( resolveTuiSessionKey({ raw: "agent:main:Test1", sessionScope: "global", currentAgentId: "main", sessionMainKey: "agent:main:main", }), ).toBe("agent:main:test1"); // Uppercase in bare form (prefixed by currentAgentId) expect( resolveTuiSessionKey({ raw: "Test1", sessionScope: "global", currentAgentId: "main", sessionMainKey: "agent:main:main", }), ).toBe("agent:main:test1"); }); }); describe("resolveInitialTuiAgentId", () => { const cfg: OpenClawConfig = { agents: { list: [ { id: "main", workspace: "/tmp/openclaw" }, { id: "ops", workspace: "/tmp/openclaw/projects/ops" }, ], }, }; it("infers agent from cwd when session is not agent-prefixed", () => { expect( resolveInitialTuiAgentId({ cfg, fallbackAgentId: "main", initialSessionInput: "", cwd: "/tmp/openclaw/projects/ops/src", }), ).toBe("ops"); }); it("keeps explicit agent prefix from --session", () => { expect( resolveInitialTuiAgentId({ cfg, fallbackAgentId: "main", initialSessionInput: "agent:main:incident", cwd: "/tmp/openclaw/projects/ops/src", }), ).toBe("main"); }); it("falls back when cwd has no matching workspace", () => { expect( resolveInitialTuiAgentId({ cfg, fallbackAgentId: "main", initialSessionInput: "", cwd: "/var/tmp/unrelated", }), ).toBe("main"); }); }); describe("resolveGatewayDisconnectState", () => { it("returns pairing recovery guidance when disconnect reason requires pairing", () => { const state = resolveGatewayDisconnectState("gateway closed (1008): pairing required"); expect(state.connectionStatus).toContain("pairing required"); expect(state.activityStatus).toBe("pairing required: run openclaw devices list"); expect(state.pairingHint).toContain("openclaw devices list"); }); it("falls back to idle for generic disconnect reasons", () => { const state = resolveGatewayDisconnectState("network timeout"); expect(state.connectionStatus).toBe("gateway disconnected: network timeout"); expect(state.activityStatus).toBe("idle"); expect(state.pairingHint).toBeUndefined(); }); }); describe("createBackspaceDeduper", () => { function createTimedDedupe(start = 1000) { let now = start; const dedupe = createBackspaceDeduper({ dedupeWindowMs: 8, now: () => now, }); return { dedupe, advance: (deltaMs: number) => { now += deltaMs; }, }; } it("suppresses duplicate backspace events within the dedupe window", () => { const { dedupe, advance } = createTimedDedupe(); expect(dedupe("\x7f")).toBe("\x7f"); advance(1); expect(dedupe("\x08")).toBe(""); }); it("preserves backspace events outside the dedupe window", () => { const { dedupe, advance } = createTimedDedupe(); expect(dedupe("\x7f")).toBe("\x7f"); advance(10); expect(dedupe("\x7f")).toBe("\x7f"); }); it("never suppresses non-backspace keys", () => { const dedupe = createBackspaceDeduper(); expect(dedupe("a")).toBe("a"); expect(dedupe("\x1b[A")).toBe("\x1b[A"); }); }); describe("resolveCtrlCAction", () => { it("clears input and arms exit on first ctrl+c when editor has text", () => { expect(resolveCtrlCAction({ hasInput: true, now: 2000, lastCtrlCAt: 0 })).toEqual({ action: "clear", nextLastCtrlCAt: 2000, }); }); it("exits on second ctrl+c within the exit window", () => { expect(resolveCtrlCAction({ hasInput: false, now: 2800, lastCtrlCAt: 2000 })).toEqual({ action: "exit", nextLastCtrlCAt: 2000, }); }); it("shows warning when exit window has elapsed", () => { expect(resolveCtrlCAction({ hasInput: false, now: 3501, lastCtrlCAt: 2000 })).toEqual({ action: "warn", nextLastCtrlCAt: 3501, }); }); }); describe("TUI shutdown safety", () => { it("treats setRawMode EBADF errors as ignorable", () => { expect(isIgnorableTuiStopError(new Error("setRawMode EBADF"))).toBe(true); expect( isIgnorableTuiStopError({ code: "EBADF", syscall: "setRawMode", }), ).toBe(true); }); it("does not ignore unrelated stop errors", () => { expect(isIgnorableTuiStopError(new Error("something else failed"))).toBe(false); expect(isIgnorableTuiStopError({ code: "EIO", syscall: "write" })).toBe(false); }); it("swallows only ignorable stop errors", () => { expect(() => { stopTuiSafely(() => { throw new Error("setRawMode EBADF"); }); }).not.toThrow(); }); it("rethrows non-ignorable stop errors", () => { expect(() => { stopTuiSafely(() => { throw new Error("boom"); }); }).toThrow("boom"); }); });