Files
2026-07-13 13:36:53 +08:00

278 lines
7.4 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ── Shared state for capturing HTTP requests (hoisted before mocks) ──
const { capturedRequests, makeMockRequest } = vi.hoisted(() => {
const capturedRequests: Array<{
url: string;
options: Record<string, unknown>;
body: string;
}> = [];
function makeMockRequest(
url: string,
options: Record<string, unknown>,
): {
write: (body: string) => void;
end: () => void;
on: (event: string, cb: () => void) => void;
destroy: () => void;
} {
return {
write: (body: string) => {
capturedRequests.push({ url, options, body });
},
end: () => {},
on: (_event: string, _cb: () => void) => {},
destroy: () => {},
};
}
return {
capturedRequests,
makeMockRequest,
};
});
// ── Mock Node.js http/https modules ──
vi.mock("http", () => ({
default: {
request: (url: string, options: Record<string, unknown>) =>
makeMockRequest(url, options),
},
}));
vi.mock("https", () => ({
default: {
request: (url: string, options: Record<string, unknown>) =>
makeMockRequest(url, options),
},
}));
// ── Mock project dependencies ──
const { TEST_HOME } = 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 {
TEST_HOME: path.join(os.tmpdir(), `hermes-api-test-${Date.now()}`),
};
});
vi.mock("../src/main/installer", () => ({
HERMES_HOME: TEST_HOME,
HERMES_PYTHON: "/usr/bin/python3",
HERMES_REPO: "/dev/null",
hermesCliArgs: () => ["/dev/null"],
getEnhancedPath: () => process.env.PATH || "",
}));
vi.mock("../src/main/config", () => ({
getModelConfig: () => ({ model: "test-model", provider: "openrouter" }),
getConfigValue: () => "",
readEnv: () => ({}),
getConnectionConfig: () => ({
mode: "remote" as const,
remoteUrl: "http://test-api.example.com",
apiKey: "test-key",
ssh: {
host: "",
port: 22,
username: "",
keyPath: "",
remotePort: 8642,
localPort: 18642,
},
}),
}));
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,
}));
vi.mock("../src/main/models", () => ({
readModels: () => [],
}));
vi.mock("../src/main/process-options", () => ({
HIDDEN_SUBPROCESS_OPTIONS: {},
}));
// ── Import module under test ──
import {
sendMessage,
stopHealthPolling as realStopHealthPolling,
} from "../src/main/hermes";
describe("sendMessageViaApi forwards resumeSessionId", () => {
beforeEach(() => {
capturedRequests.length = 0;
});
afterEach(() => {
realStopHealthPolling();
capturedRequests.length = 0;
});
it("includes session_id in request body when resumeSessionId is provided", async () => {
const testSessionId = "session-abc-123";
await sendMessage(
"hello",
{
onChunk: () => {},
onDone: () => {},
onError: () => {},
},
"default",
testSessionId,
);
const chatRequest = capturedRequests.find((r) =>
r.url.includes("/v1/chat/completions"),
);
expect(chatRequest).toBeDefined();
const parsed = JSON.parse(chatRequest!.body);
expect(parsed.session_id).toBe(testSessionId);
});
it("does not include session_id field when resumeSessionId is absent", async () => {
await sendMessage(
"hello",
{
onChunk: () => {},
onDone: () => {},
onError: () => {},
},
"default",
undefined,
);
const chatRequest = capturedRequests.find((r) =>
r.url.includes("/v1/chat/completions"),
);
expect(chatRequest).toBeDefined();
const parsed = JSON.parse(chatRequest!.body);
expect(parsed).not.toHaveProperty("session_id");
});
it("does not send empty string session_id when resumeSessionId is empty string", async () => {
await sendMessage(
"hello",
{
onChunk: () => {},
onDone: () => {},
onError: () => {},
},
"default",
"",
);
const chatRequest = capturedRequests.find((r) =>
r.url.includes("/v1/chat/completions"),
);
expect(chatRequest).toBeDefined();
const parsed = JSON.parse(chatRequest!.body);
expect(parsed).not.toHaveProperty("session_id");
});
it("sends the X-Hermes-Session-Id request header when resuming", async () => {
const testSessionId = "session-abc-123";
await sendMessage(
"hello",
{
onChunk: () => {},
onDone: () => {},
onError: () => {},
},
"default",
testSessionId,
);
const chatRequest = capturedRequests.find((r) =>
r.url.includes("/v1/chat/completions"),
);
expect(chatRequest).toBeDefined();
const headers = chatRequest!.options.headers as Record<string, string>;
// The gateway resumes an existing session from this request header;
// the session_id body field is ignored. Without it every request
// forks a new server-side session (issue #226).
expect(headers["X-Hermes-Session-Id"]).toBe(testSessionId);
});
it("generates a fresh `desk-`-prefixed X-Hermes-Session-Id when no resumeSessionId is passed", async () => {
// Pin the new-chat session-id behaviour: instead of letting the
// gateway fall back to its `_derive_chat_session_id` fingerprint
// (sha256(system_prompt + first_user_message)[:16]), the desktop
// generates `desk-<ms>-<uuid>` per fresh chat and ships it via the
// header. The fingerprint collides across all chats whose first
// message is the same — see NousResearch/hermes-agent#7484.
await sendMessage(
"hello",
{
onChunk: () => {},
onDone: () => {},
onError: () => {},
},
"default",
undefined,
);
const chatRequest = capturedRequests.find((r) =>
r.url.includes("/v1/chat/completions"),
);
expect(chatRequest).toBeDefined();
const headers = chatRequest!.options.headers as Record<string, string>;
expect(headers).toHaveProperty("X-Hermes-Session-Id");
expect(headers["X-Hermes-Session-Id"]).toMatch(
/^desk-\d{13,}-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
});
it("generates a different X-Hermes-Session-Id on each fresh send (no fingerprint collision)", async () => {
// The same first message twice MUST NOT produce the same session
// id — the whole point of the fix.
await sendMessage(
"Hello there",
{ onChunk: () => {}, onDone: () => {}, onError: () => {} },
"default",
undefined,
);
await sendMessage(
"Hello there",
{ onChunk: () => {}, onDone: () => {}, onError: () => {} },
"default",
undefined,
);
const chatRequests = capturedRequests.filter((r) =>
r.url.includes("/v1/chat/completions"),
);
expect(chatRequests.length).toBeGreaterThanOrEqual(2);
const ids = chatRequests.map(
(r) =>
(r.options.headers as Record<string, string>)["X-Hermes-Session-Id"],
);
expect(ids[0]).toBeTruthy();
expect(ids[1]).toBeTruthy();
expect(ids[0]).not.toBe(ids[1]);
});
});