Files
Hy-Memory/memhub/apps/cli/src/interactive-model-setup.test.ts
T
61b5ef30a9 refactor(memhub): remove HyPermission and trust embedded SDK (#20)
* refactor(memhub): trust embedded SDK native stores

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(memhub): remove model content permission UX

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 17:13:08 +08:00

503 lines
16 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { CoreReader } from "./core-client.js";
import type { CoreOperations } from "./core-operations.js";
import type { WizardPrompter } from "./init-wizard.js";
import { runInteractiveModelSetup } from "./interactive-model-setup.js";
const PROFILE_ID = "22222222-2222-4222-8222-222222222222";
function profile(active = false) {
return {
profile_id: PROFILE_ID,
name: "DeepSeek",
provider_kind: "openai_compatible",
base_url: "https://api.deepseek.com",
model: "deepseek-flash",
embedding_model: "bge-small-zh-v1.5",
embedding_provider_kind: "local_onnx",
embedding_base_url: null,
embedding_dimensions: 512,
has_api_key: true,
credential_state: "available",
timeout_seconds: 90,
max_concurrency: 4,
context_window: null,
disable_thinking: true,
capabilities: {
endpoint_reachable: true,
model_available: true,
structured_output: true,
embedding_available: true,
},
revision: 1,
is_active: active,
created_at: "2026-10-07T00:00:00Z",
updated_at: "2026-10-07T00:00:00Z",
} as const;
}
function prompter(lines: string[], secret = "sk-do-not-print") {
let index = 0;
const written: string[] = [];
return {
written,
value: {
async ask() {
const value = lines[index++];
if (value === undefined)
throw new Error(
`script exhausted after ${index - 1}: ${written.join(" | ")}`,
);
return value;
},
async askSecret() {
return secret;
},
write(line: string) {
written.push(line);
},
} satisfies WizardPrompter,
};
}
function clients(options: { failedProbes?: number } = {}) {
let failedProbes = options.failedProbes ?? 0;
const reader = vi.fn(async (path: string) => {
if (path === "/api/v1/model-profiles") {
return { state: "ok", data: [profile(true)] };
}
if (path.startsWith("/api/v1/model-probe-operations/")) {
return {
state: "ok",
data: {
schema_version: 1,
operation_id: path.split("/").at(-1),
profile_id: null,
expected_profile_revision: null,
state: "not_found",
result: null,
code: null,
stale: null,
},
};
}
return { state: "not_found", code: "http_404" };
});
type PostResult =
| { state: "committed"; data: unknown }
| { state: "rejected"; code: string; http_status: number };
const post = vi.fn(
async (
path: string,
body: Record<string, unknown>,
): Promise<PostResult> => {
if (path === "/api/v1/model-profiles") {
return { state: "committed" as const, data: profile(false) };
}
if (path.endsWith("/probe")) {
const probeOk = failedProbes === 0;
failedProbes = Math.max(0, failedProbes - 1);
return {
state: "committed" as const,
data: {
schema_version: 1,
operation_id: body.operation_id,
profile_id: PROFILE_ID,
expected_profile_revision: 1,
state: "completed",
result: {
profile_id: PROFILE_ID,
profile_revision: 1,
input_profile_revision: 1,
stages: [
{
stage: "model",
ok: probeOk,
code: probeOk ? "ok" : "unauthorized",
latency_ms: 1,
embedding_dimensions: null,
},
],
capabilities: {
...profile().capabilities,
model_available: probeOk,
},
},
code: null,
stale: false,
},
};
}
if (path.endsWith("/activate")) {
return { state: "committed" as const, data: profile(true) };
}
return {
state: "rejected" as const,
code: "unexpected_path",
http_status: 404,
};
},
);
const remove = vi.fn().mockResolvedValue({
state: "committed" as const,
data: { removed: true },
});
return {
reader: reader as unknown as CoreReader,
operations: { post, delete: remove } as CoreOperations & {
delete: typeof remove;
},
post,
remove,
};
}
describe("interactive model setup", () => {
it("uses App defaults and keeps the key out of output", async () => {
const prompt = prompter(["", "", ""]);
const api = clients();
const result = await runInteractiveModelSetup(prompt.value, api, {
dataDir: "/private/tmp/memhub-interactive-test/data",
hostHome: "/private/tmp/memhub-interactive-test/home",
installHosts: false,
});
expect(result).toMatchObject({ state: "configured" });
expect(api.post).toHaveBeenCalledWith(
"/api/v1/model-profiles",
expect.objectContaining({
name: "DeepSeek",
base_url: "https://api.deepseek.com",
model: "deepseek-flash",
api_key: "sk-do-not-print",
embedding_provider_kind: "local_onnx",
}),
);
expect(api.post).not.toHaveBeenCalledWith(
"/api/v1/model-content-permission",
expect.anything(),
);
expect(prompt.written.join("\n")).not.toContain("sk-do-not-print");
});
it("can skip model setup without writing anything", async () => {
const prompt = prompter(["0"]);
const api = clients();
await expect(
runInteractiveModelSetup(prompt.value, api, {
dataDir: "/private/tmp/memhub-interactive-test/data",
hostHome: "/private/tmp/memhub-interactive-test/home",
installHosts: false,
}),
).resolves.toEqual({ state: "skipped" });
expect(api.post).not.toHaveBeenCalled();
});
it("removes an unverified profile before skipping after a failed probe", async () => {
const prompt = prompter(["", "", "", "0"]);
const api = clients();
api.post.mockImplementation(
async (path: string, body: Record<string, unknown>) => {
if (path === "/api/v1/model-profiles") {
return { state: "committed" as const, data: profile(false) };
}
if (path.endsWith("/probe")) {
return {
state: "committed" as const,
data: {
schema_version: 1,
operation_id: body.operation_id,
profile_id: PROFILE_ID,
expected_profile_revision: 1,
state: "completed",
result: {
profile_id: PROFILE_ID,
profile_revision: 1,
input_profile_revision: 1,
stages: [
{
stage: "authentication",
ok: false,
code: "unauthorized",
latency_ms: 1,
embedding_dimensions: null,
},
],
capabilities: {
...profile().capabilities,
model_available: false,
},
},
code: null,
stale: false,
},
};
}
return {
state: "rejected" as const,
code: "unexpected_path",
http_status: 404,
};
},
);
await expect(
runInteractiveModelSetup(prompt.value, api, {
dataDir: "/private/tmp/memhub-interactive-test/data",
hostHome: "/private/tmp/memhub-interactive-test/home",
installHosts: false,
}),
).resolves.toEqual({ state: "skipped" });
expect(api.remove).toHaveBeenCalledWith(
`/api/v1/model-profiles/${PROFILE_ID}`,
);
expect(prompt.written.join("\n")).not.toContain("sk-do-not-print");
});
it("returns to provider selection before any write", async () => {
const prompt = prompter(["", "back", "0"]);
const api = clients();
await expect(
runInteractiveModelSetup(prompt.value, api, {
dataDir: "/private/tmp/memhub-interactive-test/data",
hostHome: "/private/tmp/memhub-interactive-test/home",
installHosts: false,
}),
).resolves.toEqual({ state: "skipped" });
expect(api.post).not.toHaveBeenCalled();
});
it("cleans a failed probe and lets the user re-enter the model settings", async () => {
const prompt = prompter(["", "", "", "", "", "", ""]);
const api = clients({ failedProbes: 1 });
const result = await runInteractiveModelSetup(prompt.value, api, {
dataDir: "/private/tmp/memhub-interactive-test/data",
hostHome: "/private/tmp/memhub-interactive-test/home",
installHosts: false,
});
expect(result).toMatchObject({ state: "configured" });
expect(api.remove).toHaveBeenCalledTimes(1);
expect(
api.post.mock.calls.filter(([path]) => path === "/api/v1/model-profiles"),
).toHaveLength(2);
expect(prompt.written.join("\n")).not.toContain("sk-do-not-print");
});
it("leaves no profile when the user interrupts during prompts", async () => {
let asked = 0;
const prompt = prompter(["", "", ""]);
prompt.value.ask = async () => {
asked += 1;
if (asked === 3) throw new Error("interrupted");
return "";
};
const api = clients();
await expect(
runInteractiveModelSetup(prompt.value, api, {
dataDir: "/private/tmp/memhub-interactive-test/data",
hostHome: "/private/tmp/memhub-interactive-test/home",
installHosts: false,
}),
).rejects.toThrow("interrupted");
expect(api.post).not.toHaveBeenCalled();
expect(api.remove).not.toHaveBeenCalled();
});
it("installs selected detected hosts through preview and fingerprint apply", async () => {
const prompt = prompter(["", "", "", "", ""]);
const api = clients();
const read = vi
.fn()
.mockResolvedValueOnce({
schema_version: 1,
product: "memhub",
command: "preview",
host_id: "codex",
operation: "previewInstall",
result: { fingerprint: "a".repeat(64), files: [] },
})
.mockResolvedValueOnce({
schema_version: 1,
product: "memhub",
command: "apply",
host_id: "codex",
operation: "install",
result: { installed: true },
});
const result = await runInteractiveModelSetup(prompt.value, api, {
dataDir: "/private/tmp/memhub-interactive-test/data",
hostHome: "/private/tmp/memhub-interactive-test/home",
installHosts: true,
hostApi: {
list: vi.fn().mockResolvedValue({
schema_version: 1,
product: "memhub",
command: "list",
hosts: [{ host_id: "codex", label: "Codex", present: true }],
}),
read,
},
});
expect(result).toMatchObject({
state: "configured",
installed_hosts: ["codex"],
});
expect(read).toHaveBeenNthCalledWith(
2,
"/private/tmp/memhub-interactive-test/data",
"codex",
"install",
["a".repeat(64)],
);
});
it("lists and installs only hosts that are actually present", async () => {
const prompt = prompter(["", "", "", "", ""]);
const api = clients();
const read = vi
.fn()
.mockResolvedValueOnce({
schema_version: 1,
product: "memhub",
command: "preview",
host_id: "cursor",
operation: "previewInstall",
result: { fingerprint: "b".repeat(64), files: [] },
})
.mockResolvedValueOnce({
schema_version: 1,
product: "memhub",
command: "apply",
host_id: "cursor",
operation: "install",
result: { installed: true },
});
const result = await runInteractiveModelSetup(prompt.value, api, {
dataDir: "/private/tmp/memhub-interactive-test/data",
hostHome: "/private/tmp/memhub-interactive-test/home",
installHosts: true,
hostApi: {
list: vi.fn().mockResolvedValue({
schema_version: 1,
product: "memhub",
command: "list",
hosts: [
{ host_id: "cursor", label: "Cursor", present: true },
{ host_id: "opencode", label: "OpenCode", present: false },
],
}),
read,
},
});
expect(result).toMatchObject({ installed_hosts: ["cursor"] });
expect(prompt.written.join("\n")).toContain("Cursor");
expect(prompt.written.join("\n")).not.toContain("OpenCode");
expect(read).toHaveBeenCalledTimes(2);
expect(read.mock.calls.every((call) => call[1] === "cursor")).toBe(true);
});
it("completes the wizard and keeps onboarding reachable when one host install fails", async () => {
const prompt = prompter(["", "", "", "", ""]);
const api = clients();
const read = vi
.fn()
// Codex previews and installs fine.
.mockResolvedValueOnce({
schema_version: 1,
product: "memhub",
command: "preview",
host_id: "codex",
operation: "previewInstall",
result: { fingerprint: "a".repeat(64), files: [] },
})
.mockResolvedValueOnce({
schema_version: 1,
product: "memhub",
command: "apply",
host_id: "codex",
operation: "install",
result: { installed: true },
})
// OpenClaw is listed by `host list` but rejected by the connection
// client, so the install throws instead of returning a receipt.
.mockRejectedValueOnce(new Error("connection_client_invalid_input"));
const result = await runInteractiveModelSetup(prompt.value, api, {
dataDir: "/private/tmp/memhub-interactive-test/data",
hostHome: "/private/tmp/memhub-interactive-test/home",
installHosts: true,
hostApi: {
list: vi.fn().mockResolvedValue({
schema_version: 1,
product: "memhub",
command: "list",
hosts: [
{ host_id: "codex", label: "Codex", present: true },
{ host_id: "openclaw", label: "OpenClaw", present: true },
],
}),
read,
},
});
// The wizard must finish: the other host is still installed and the
// failure is summarised for the user instead of aborting the run. OpenClaw
// is App-only, so the summary points the user at the App instead of
// calling the install a failure.
expect(result).toMatchObject({
state: "configured",
installed_hosts: ["codex"],
});
const summary = prompt.written.join("\n");
expect(summary).toContain("请在 App 里接入:OpenClaw");
expect(summary).not.toContain("接入失败:OpenClaw");
expect(read).toHaveBeenCalledTimes(3);
});
it("tells the user to install App-only hosts in the App instead of calling them failed", async () => {
const prompt = prompter(["", "", "", "", ""]);
const api = clients();
const read = vi
.fn()
// The connection client refuses OpenClaw before any plan is produced,
// so the wizard sees the same `connection_client_*` throw as before.
.mockRejectedValueOnce(new Error("connection_client_invalid_input"));
const result = await runInteractiveModelSetup(prompt.value, api, {
dataDir: "/private/tmp/memhub-interactive-test/data",
hostHome: "/private/tmp/memhub-interactive-test/home",
installHosts: true,
hostApi: {
list: vi.fn().mockResolvedValue({
schema_version: 1,
product: "memhub",
command: "list",
hosts: [{ host_id: "openclaw", label: "OpenClaw", present: true }],
}),
read,
},
});
// The wizard still finishes; the only host the user picked is the one
// only the App can install, so nothing counts as "installed".
expect(result).toMatchObject({
state: "configured",
installed_hosts: [],
});
const summary = prompt.written.join("\n");
expect(summary).toContain("请在 App 里接入:OpenClaw");
expect(summary).not.toContain("接入失败:OpenClaw");
});
});