Files
fathah--hermes-desktop/tests/hermes-cli-session-id.test.ts
2026-07-13 13:36:53 +08:00

681 lines
21 KiB
TypeScript

import { EventEmitter } from "events";
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { mkdirSync, rmSync } from "fs";
const {
spawned,
TEST_HOME,
TEST_REPO,
healthStatuses,
apiRequests,
apiRequestErrors,
requestEvents,
modelConfig,
profileEnv,
} = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const path = require("path");
// eslint-disable-next-line @typescript-eslint/no-require-imports
const os = require("os");
return {
spawned: [] as Array<
EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
killed: boolean;
kill: ReturnType<typeof vi.fn>;
unref: ReturnType<typeof vi.fn>;
spawnArgs?: string[];
spawnOptions?: { env?: Record<string, string> };
}
>,
TEST_HOME: path.join(os.tmpdir(), `hermes-cli-session-test-${Date.now()}`),
TEST_REPO: path.join(os.tmpdir(), `hermes-cli-session-repo-${Date.now()}`),
healthStatuses: [] as number[],
apiRequests: [] as Array<{
body: string;
headers: Record<string, string>;
}>,
apiRequestErrors: [] as string[],
requestEvents: [] as string[],
modelConfig: {
model: "test-model",
provider: "openrouter",
baseUrl: "",
},
profileEnv: {} as Record<string, string>,
};
});
vi.mock("http", () => ({
default: {
request: (
_url: string,
_options: Record<string, unknown>,
cb?: (res: {
statusCode: number;
headers?: Record<string, string>;
resume?: () => void;
on?: (event: string, handler: (...args: unknown[]) => void) => void;
}) => void,
) => {
let body = "";
const handlers = new Map<string, (...args: unknown[]) => void>();
const req = {
write: (chunk: string | Buffer) => {
body += chunk.toString();
},
end: () => {
if (_url.endsWith("/health")) {
requestEvents.push("health");
cb?.({
statusCode: healthStatuses.shift() ?? 503,
resume: () => {},
});
return;
}
if (_url.endsWith("/v1/chat/completions")) {
requestEvents.push("chat");
const requestError = apiRequestErrors.shift();
if (requestError === "HANG") {
return;
}
if (requestError === "HANG_ACCEPTED") {
apiRequests.push({
body,
headers: (_options.headers as Record<string, string>) || {},
});
return;
}
if (requestError === "TIMEOUT_ACCEPTED") {
apiRequests.push({
body,
headers: (_options.headers as Record<string, string>) || {},
});
queueMicrotask(() => {
handlers.get("timeout")?.();
});
return;
}
if (requestError?.startsWith("STATUS:")) {
const [, status = "500", message = "API error"] =
requestError.split(":");
apiRequests.push({
body,
headers: (_options.headers as Record<string, string>) || {},
});
const res = new EventEmitter() as EventEmitter & {
statusCode: number;
headers: Record<string, string>;
};
res.statusCode = Number(status);
res.headers = {};
cb?.(res);
queueMicrotask(() => {
res.emit(
"data",
Buffer.from(JSON.stringify({ error: { message } })),
);
res.emit("end");
});
return;
}
if (requestError?.startsWith("STREAM_ERROR:")) {
const message = requestError.slice("STREAM_ERROR:".length);
apiRequests.push({
body,
headers: (_options.headers as Record<string, string>) || {},
});
const res = new EventEmitter() as EventEmitter & {
statusCode: number;
headers: Record<string, string>;
};
res.statusCode = 200;
res.headers = { "x-hermes-session-id": "desk-cold-gateway" };
cb?.(res);
queueMicrotask(() => {
res.emit(
"data",
Buffer.from(
'data: {"choices":[{"delta":{"content":"Partial"}}]}\n\n',
),
);
res.emit("error", new Error(message));
});
return;
}
if (requestError) {
queueMicrotask(() => {
handlers.get("error")?.(new Error(requestError));
});
return;
}
apiRequests.push({
body,
headers: (_options.headers as Record<string, string>) || {},
});
const res = new EventEmitter() as EventEmitter & {
statusCode: number;
headers: Record<string, string>;
};
res.statusCode = 200;
res.headers = { "x-hermes-session-id": "desk-cold-gateway" };
cb?.(res);
queueMicrotask(() => {
res.emit(
"data",
Buffer.from(
'data: {"choices":[{"delta":{"content":"Hi from API"}}]}\n\n',
),
);
res.emit("data", Buffer.from("data: [DONE]\n\n"));
res.emit("end");
});
}
},
on: (event: string, handler: (...args: unknown[]) => void) => {
handlers.set(event, handler);
return req;
},
destroy: () => {
handlers.get("error")?.(new Error("destroyed"));
},
};
return req;
},
},
}));
vi.mock("https", () => ({
default: {
request: () => ({
write: () => {},
end: () => {},
on: () => {},
destroy: () => {},
}),
},
}));
vi.mock("child_process", () => ({
default: {
spawn: vi.fn((_cmd: string, args?: string[], options?: unknown) => {
const proc = Object.assign(new EventEmitter(), {
stdout: new EventEmitter(),
stderr: new EventEmitter(),
killed: false,
kill: vi.fn(),
unref: vi.fn(),
spawnArgs: args,
spawnOptions: options,
});
proc.stderr.pipe = vi.fn();
spawned.push(proc);
return proc;
}),
},
spawn: vi.fn((_cmd: string, args?: string[], options?: unknown) => {
const proc = Object.assign(new EventEmitter(), {
stdout: new EventEmitter(),
stderr: new EventEmitter(),
killed: false,
kill: vi.fn(),
unref: vi.fn(),
spawnArgs: args,
spawnOptions: options,
});
proc.stderr.pipe = vi.fn();
spawned.push(proc);
return proc;
}),
}));
vi.mock("../src/main/installer", () => ({
HERMES_HOME: TEST_HOME,
HERMES_PYTHON: process.execPath,
HERMES_REPO: TEST_REPO,
hermesCliArgs: (extra?: string[]) => ["/dev/null", ...(extra || [])],
getEnhancedPath: () => process.env.PATH || "",
}));
vi.mock("../src/main/config", () => ({
getModelConfig: () => modelConfig,
getConfigValue: () => "",
readEnv: () => profileEnv,
getApiServerKey: () => "",
getConnectionConfig: () => ({ mode: "local" as const }),
}));
vi.mock("../src/main/ssh-tunnel", () => ({
getSshTunnelUrl: () => null,
isSshTunnelActive: () => false,
isSshTunnelHealthy: () => Promise.resolve(false),
startSshTunnel: () => Promise.resolve(),
}));
vi.mock("../src/main/utils", () => ({
stripAnsi: (s: string) => s,
pidIsAliveAs: () => false,
getActiveProfileNameSync: () => "default",
normalizeProfileName: (p?: string) =>
p === undefined || p === "" || p === "default" ? undefined : p,
profileHome: () => TEST_HOME,
profilePaths: () => ({
home: TEST_HOME,
envFile: `${TEST_HOME}/.env`,
configFile: `${TEST_HOME}/config.yaml`,
}),
}));
vi.mock("../src/main/models", () => ({
readModels: () => [],
}));
vi.mock("../src/main/process-options", () => ({
HIDDEN_SUBPROCESS_OPTIONS: {},
}));
import {
sendMessage,
startGateway,
stopGateway,
stopHealthPolling,
} from "../src/main/hermes";
describe("CLI fallback session id propagation", () => {
beforeEach(() => {
healthStatuses.length = 0;
apiRequests.length = 0;
apiRequestErrors.length = 0;
requestEvents.length = 0;
modelConfig.model = "test-model";
modelConfig.provider = "openrouter";
modelConfig.baseUrl = "";
for (const key of Object.keys(profileEnv)) {
delete profileEnv[key];
}
rmSync(TEST_REPO, { recursive: true, force: true });
});
afterEach(() => {
stopGateway(undefined, true);
stopHealthPolling();
spawned.length = 0;
});
it("captures the quiet CLI session id from stderr so the next desktop turn can resume it", async () => {
const done = new Promise<string | undefined>((resolve) => {
sendMessage("hi", {
onChunk: () => {},
onDone: resolve,
onError: () => {},
}).then(() => {
const proc = spawned[0];
proc.stdout.emit("data", Buffer.from("Hi there"));
proc.stderr.emit(
"data",
Buffer.from("\nsession_id: 20260527_143413_10df4c\n"),
);
proc.emit("close", 0);
});
});
await expect(done).resolves.toBe("20260527_143413_10df4c");
});
it("runs AIML API through the CLI custom provider bridge", async () => {
modelConfig.model = "gpt-4o-mini";
modelConfig.provider = "aimlapi";
modelConfig.baseUrl = "https://api.aimlapi.com/v1";
profileEnv.AIMLAPI_API_KEY = "sk-aiml-test";
const done = new Promise<string | undefined>((resolve) => {
sendMessage("hi", {
onChunk: () => {},
onDone: resolve,
onError: () => {},
}).then(() => {
const proc = spawned[0];
proc.stdout.emit("data", Buffer.from("Hi there"));
proc.emit("close", 0);
});
});
await expect(done).resolves.toBeUndefined();
const proc = spawned[0];
expect(proc.spawnArgs).toEqual(
expect.arrayContaining(["-m", "gpt-4o-mini", "--provider", "custom"]),
);
expect(proc.spawnOptions?.env).toMatchObject({
AIMLAPI_API_KEY: "sk-aiml-test",
OPENAI_API_KEY: "sk-aiml-test",
OPENAI_BASE_URL: "https://api.aimlapi.com/v1",
CUSTOM_BASE_URL: "https://api.aimlapi.com/v1",
HERMES_INFERENCE_PROVIDER: "custom",
});
});
it("continues a CLI-created timestamp session over the API instead of minting a desk id", async () => {
const cliSessionId = "20260527_143413_10df4c";
const firstDone = new Promise<string | undefined>((resolve) => {
sendMessage("hi", {
onChunk: () => {},
onDone: resolve,
onError: () => {},
}).then(() => {
const proc = spawned[0];
proc.stdout.emit("data", Buffer.from("Hi there"));
proc.stderr.emit(
"data",
Buffer.from(`\nsession_id: ${cliSessionId}\n`),
);
proc.emit("close", 0);
});
});
await expect(firstDone).resolves.toBe(cliSessionId);
healthStatuses.push(200);
await expect(
new Promise<string | undefined>((resolve, reject) => {
sendMessage(
"what time is it?",
{
onChunk: () => {},
onDone: resolve,
onError: reject,
},
undefined,
cliSessionId,
).catch(reject);
}),
).resolves.toBe("desk-cold-gateway");
expect(apiRequests).toHaveLength(1);
expect(apiRequests[0].headers["X-Hermes-Session-Id"]).toBe(cliSessionId);
expect(JSON.parse(apiRequests[0].body)).toMatchObject({
session_id: cliSessionId,
messages: [{ role: "user", content: "what time is it?" }],
stream: true,
});
});
it("uses a healthy running gateway API instead of falling back to CLI", async () => {
mkdirSync(TEST_REPO, { recursive: true });
healthStatuses.push(200);
expect(startGateway()).toBe(true);
expect(spawned).toHaveLength(1);
const chunks: string[] = [];
const done = new Promise<string | undefined>((resolve, reject) => {
sendMessage("hi", {
onChunk: (chunk) => chunks.push(chunk),
onDone: resolve,
onError: reject,
}).catch(reject);
});
await expect(done).resolves.toBe("desk-cold-gateway");
expect(chunks.join("")).toBe("Hi from API");
expect(spawned).toHaveLength(1);
expect(apiRequests).toHaveLength(1);
expect(JSON.parse(apiRequests[0].body)).toMatchObject({
messages: [{ role: "user", content: "hi" }],
stream: true,
});
});
it("recovers a stopped local gateway before sending via the API", async () => {
mkdirSync(TEST_REPO, { recursive: true });
healthStatuses.push(503, 503, 200);
const chunks: string[] = [];
const done = new Promise<string | undefined>((resolve, reject) => {
sendMessage("hi after update", {
onChunk: (chunk) => chunks.push(chunk),
onDone: resolve,
onError: reject,
}).catch(reject);
});
await expect(done).resolves.toBe("desk-cold-gateway");
expect(chunks.join("")).toBe("Hi from API");
expect(spawned).toHaveLength(1);
expect(apiRequests).toHaveLength(1);
expect(JSON.parse(apiRequests[0].body)).toMatchObject({
messages: [{ role: "user", content: "hi after update" }],
stream: true,
});
});
it("restarts a tracked but unhealthy local gateway before sending via the API", async () => {
mkdirSync(TEST_REPO, { recursive: true });
expect(startGateway()).toBe(true);
expect(spawned).toHaveLength(1);
healthStatuses.push(503, 503, 503, 200);
const chunks: string[] = [];
const done = new Promise<string | undefined>((resolve, reject) => {
sendMessage("hi after stale gateway", {
onChunk: (chunk) => chunks.push(chunk),
onDone: resolve,
onError: reject,
}).catch(reject);
});
await expect(done).resolves.toBe("desk-cold-gateway");
expect(chunks.join("")).toBe("Hi from API");
expect(spawned).toHaveLength(2);
expect(apiRequests).toHaveLength(1);
expect(JSON.parse(apiRequests[0].body)).toMatchObject({
messages: [{ role: "user", content: "hi after stale gateway" }],
stream: true,
});
});
it("recovers after a stale ready cache without slowing the normal API send path", async () => {
mkdirSync(TEST_REPO, { recursive: true });
healthStatuses.push(200);
await expect(
new Promise<string | undefined>((resolve, reject) => {
sendMessage("warmup", {
onChunk: () => {},
onDone: resolve,
onError: reject,
}).catch(reject);
}),
).resolves.toBe("desk-cold-gateway");
expect(apiRequests).toHaveLength(1);
expect(requestEvents).toEqual(["health", "chat"]);
apiRequestErrors.push("connect ECONNREFUSED 127.0.0.1:8765");
healthStatuses.push(503, 200);
const secondSendStart = requestEvents.length;
const chunks: string[] = [];
await expect(
new Promise<string | undefined>((resolve, reject) => {
sendMessage("hi after restart", {
onChunk: (chunk) => chunks.push(chunk),
onDone: resolve,
onError: reject,
}).catch(reject);
}),
).resolves.toBe("desk-cold-gateway");
expect(chunks.join("")).toBe("Hi from API");
expect(spawned).toHaveLength(1);
expect(apiRequests).toHaveLength(2);
expect(requestEvents[secondSendStart]).toBe("chat");
expect(requestEvents.at(-1)).toBe("chat");
expect(JSON.parse(apiRequests[1].body)).toMatchObject({
messages: [{ role: "user", content: "hi after restart" }],
stream: true,
});
});
it("retries a reset local API socket once after gateway recovery", async () => {
mkdirSync(TEST_REPO, { recursive: true });
healthStatuses.push(200);
await expect(
new Promise<string | undefined>((resolve, reject) => {
sendMessage("warmup", {
onChunk: () => {},
onDone: resolve,
onError: reject,
}).catch(reject);
}),
).resolves.toBe("desk-cold-gateway");
apiRequestErrors.push("read ECONNRESET");
healthStatuses.push(503, 200);
const chunks: string[] = [];
await expect(
new Promise<string | undefined>((resolve, reject) => {
sendMessage("hi after reset", {
onChunk: (chunk) => chunks.push(chunk),
onDone: resolve,
onError: reject,
}).catch(reject);
}),
).resolves.toBe("desk-cold-gateway");
expect(chunks.join("")).toBe("Hi from API");
expect(apiRequests).toHaveLength(2);
expect(JSON.parse(apiRequests[1].body)).toMatchObject({
messages: [{ role: "user", content: "hi after reset" }],
stream: true,
});
});
it("preserves API response errors when recovery succeeds", async () => {
mkdirSync(TEST_REPO, { recursive: true });
healthStatuses.push(200);
await expect(
new Promise<string | undefined>((resolve, reject) => {
sendMessage("warmup", {
onChunk: () => {},
onDone: resolve,
onError: reject,
}).catch(reject);
}),
).resolves.toBe("desk-cold-gateway");
apiRequestErrors.push("STATUS:401:Authentication failed");
healthStatuses.push(503, 200);
const startedSessions: string[] = [];
await expect(
new Promise<string | undefined>((resolve, reject) => {
sendMessage("bad key", {
onChunk: () => {},
onDone: resolve,
onSessionStarted: (sessionId) => startedSessions.push(sessionId),
onError: reject,
}).catch(reject);
}),
).rejects.toThrow("Authentication failed");
expect(startedSessions).toEqual([]);
expect(apiRequests).toHaveLength(2);
expect(JSON.parse(apiRequests[1].body)).toMatchObject({
messages: [{ role: "user", content: "bad key" }],
stream: true,
});
});
it("reports a mid-stream API disconnect without replaying partial output", async () => {
mkdirSync(TEST_REPO, { recursive: true });
healthStatuses.push(200);
await expect(
new Promise<string | undefined>((resolve, reject) => {
sendMessage("warmup", {
onChunk: () => {},
onDone: resolve,
onError: reject,
}).catch(reject);
}),
).resolves.toBe("desk-cold-gateway");
apiRequestErrors.push("STREAM_ERROR:read ECONNRESET");
healthStatuses.push(503, 200);
const secondSendStart = requestEvents.length;
const chunks: string[] = [];
await expect(
new Promise<string | undefined>((resolve, reject) => {
sendMessage("partial stream", {
onChunk: (chunk) => chunks.push(chunk),
onDone: resolve,
onError: reject,
}).catch(reject);
}),
).rejects.toThrow("Stream error: read ECONNRESET");
expect(chunks.join("")).toBe("Partial");
expect(apiRequests).toHaveLength(2);
expect(requestEvents[secondSendStart]).toBe("chat");
await vi.waitFor(() => {
expect(spawned).toHaveLength(1);
expect(healthStatuses).toHaveLength(0);
expect(requestEvents.slice(secondSendStart + 1)).toContain("health");
});
expect(JSON.parse(apiRequests[1].body)).toMatchObject({
messages: [{ role: "user", content: "partial stream" }],
stream: true,
});
});
it("recovers an accepted timed-out request without replaying the user message", async () => {
mkdirSync(TEST_REPO, { recursive: true });
healthStatuses.push(200);
await expect(
new Promise<string | undefined>((resolve, reject) => {
sendMessage("warmup", {
onChunk: () => {},
onDone: resolve,
onError: reject,
}).catch(reject);
}),
).resolves.toBe("desk-cold-gateway");
expect(requestEvents).toEqual(["health", "chat"]);
apiRequestErrors.push("TIMEOUT_ACCEPTED");
healthStatuses.push(503, 503, 200);
const secondSendStart = requestEvents.length;
const chunks: string[] = [];
await expect(
new Promise<string | undefined>((resolve, reject) => {
sendMessage("hi after hung gateway", {
onChunk: (chunk) => chunks.push(chunk),
onDone: resolve,
onError: reject,
}).catch(reject);
}),
).rejects.toThrow(
"API request timed out. Check the SSH tunnel and remote Hermes gateway.",
);
expect(chunks).toEqual([]);
expect(spawned).toHaveLength(1);
expect(apiRequests).toHaveLength(2);
expect(requestEvents[secondSendStart]).toBe("chat");
expect(requestEvents.slice(secondSendStart + 1)).toContain("health");
expect(JSON.parse(apiRequests[1].body)).toMatchObject({
messages: [{ role: "user", content: "hi after hung gateway" }],
stream: true,
});
});
});