Merged via squash. Prepared head SHA: 23533e24c4cbb91fd5c3006485c9a5d69bd5c2fb Co-authored-by: arceus77-7 <261276524+arceus77-7@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf
265 lines
7.4 KiB
TypeScript
265 lines
7.4 KiB
TypeScript
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");
|
|
});
|
|
});
|