- 将项目、页面和 MCP 客户端统一命名为 upup - 支持多服务商、模型发现、多模态输入和思考等级配置 - 优化对话模型选择和 Toast 通知,新增历史搜索、分页与批量删除 - 修复网页预览、滚动定位和输入闪烁,移除任务轮数与执行时限 - 补充运营与多用户模式规划,增加模型、会话和预览回归测试
526 lines
31 KiB
TypeScript
526 lines
31 KiB
TypeScript
import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from "bun:test";
|
|
import { mkdtempSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
import { Database } from "bun:sqlite";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
import { createApp } from "../src/server/app";
|
|
import { parseProvider, providerRuntime } from "../src/server/provider";
|
|
import { DEEPSEEK_DEFAULTS } from "../src/shared/providers";
|
|
import { Store } from "../src/server/store";
|
|
|
|
const adminKey = "integration-test-admin-key";
|
|
const apiKey = "test-key-never-return-to-browser";
|
|
let upstream: ReturnType<typeof Bun.serve>;
|
|
let mcpServer: ReturnType<typeof Bun.serve>;
|
|
let toolCalls = 0;
|
|
let modelRequests: Record<string, any>[] = [];
|
|
let app: ReturnType<typeof createApp>;
|
|
let directory: string;
|
|
let cookie = "";
|
|
const sse = (events: unknown[]) => new Response(events.map(event => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "Content-Type": "text/event-stream" } });
|
|
|
|
beforeAll(() => {
|
|
upstream = Bun.serve({ port: 0, hostname: "127.0.0.1", async fetch(request) {
|
|
const path = new URL(request.url).pathname;
|
|
if (request.method === "GET" && path === "/v1/models") {
|
|
if (request.headers.get("authorization") !== `Bearer ${apiKey}`) return Response.json({ error: "bad key" }, { status: 401 });
|
|
return Response.json({ models: [
|
|
{ slug: "vision-model", display_name: "Vision model", context_window: 256000, input_modalities: ["text", "image"], supported_reasoning_levels: [{ reasoning_effort: "none" }, { reasoning_effort: "high" }] },
|
|
{ slug: "text-model", input_modalities: ["text"], context_window: 128000 },
|
|
] });
|
|
}
|
|
const input = await request.json() as Record<string, any>;
|
|
modelRequests.push(input);
|
|
if (input.model === "fail-model") return Response.json({ error: { message: `invalid key ${apiKey}`, type: "authentication_error" } }, { status: 401 });
|
|
if (input.model === "slow-model") {
|
|
let timer: ReturnType<typeof setInterval>;
|
|
return new Response(new ReadableStream({
|
|
start(output) { timer = setInterval(() => output.enqueue(new TextEncoder().encode(": ping\n\n")), 100); },
|
|
cancel() { clearInterval(timer); },
|
|
}), { headers: { "Content-Type": "text/event-stream" } });
|
|
}
|
|
if (path === "/v1/chat/completions") {
|
|
const last = input.messages.at(-1);
|
|
const wantsTool = input.messages.some((m: any) => m.role === "user" && JSON.stringify(m.content).includes("检查登录"));
|
|
const completedTools = input.messages.filter((message: any) => message.role === "tool").length;
|
|
const call = input.model === "many-turns" ? completedTools < 14 : wantsTool && last?.role !== "tool";
|
|
const delta = call ? { tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "check_login_status", arguments: "{}" } }] } : { content: wantsTool ? "已检查小红书登录。" : "OK" };
|
|
return sse([
|
|
{ id: "chat_1", object: "chat.completion.chunk", created: 1, model: input.model, choices: [{ index: 0, delta, finish_reason: null }] },
|
|
{ id: "chat_1", object: "chat.completion.chunk", created: 1, model: input.model, choices: [{ index: 0, delta: {}, finish_reason: call ? "tool_calls" : "stop" }], usage: { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 } },
|
|
]);
|
|
}
|
|
if (path === "/v1/responses") {
|
|
const item = { type: "message", id: "msg_1", role: "assistant", status: "completed", content: [{ type: "output_text", text: "OK", annotations: [] }] };
|
|
return sse([
|
|
{ type: "response.created", response: { id: "resp_1", status: "in_progress" } },
|
|
{ type: "response.output_item.added", output_index: 0, item: { ...item, content: [] } },
|
|
{ type: "response.output_text.delta", output_index: 0, content_index: 0, item_id: "msg_1", delta: "OK" },
|
|
{ type: "response.output_item.done", output_index: 0, item },
|
|
{ type: "response.completed", response: { id: "resp_1", status: "completed", output: [item], usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 } } },
|
|
]);
|
|
}
|
|
if (path === "/v1/messages") {
|
|
const events = [
|
|
{ type: "message_start", message: { id: "msg_2", type: "message", role: "assistant", model: input.model, content: [], stop_reason: null, usage: { input_tokens: 10, output_tokens: 0 } } },
|
|
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
|
|
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "OK" } },
|
|
{ type: "content_block_stop", index: 0 },
|
|
{ type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 2 } },
|
|
{ type: "message_stop" },
|
|
];
|
|
return new Response(events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "Content-Type": "text/event-stream" } });
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
} });
|
|
mcpServer = Bun.serve({ port: 0, hostname: "127.0.0.1", async fetch(request) {
|
|
const server = new McpServer({ name: "test-xhs", version: "1.0.0" });
|
|
server.registerTool("check_login_status", { description: "检查登录状态", inputSchema: {} }, async () => {
|
|
toolCalls++;
|
|
return { content: [{ type: "text", text: "已登录 test-account" }] };
|
|
});
|
|
server.registerTool("get_login_qrcode", { description: "获取二维码", inputSchema: {} }, async () => ({ content: [{ type: "text", text: "测试二维码" }] }));
|
|
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
await server.connect(transport);
|
|
try { return await transport.handleRequest(request); }
|
|
finally { await server.close(); }
|
|
} });
|
|
});
|
|
afterAll(async () => { await upstream.stop(true); await mcpServer.stop(true); });
|
|
beforeEach(() => {
|
|
directory = mkdtempSync(join(tmpdir(), "xhs-agent-test-"));
|
|
app = createApp({ dataDir: directory, adminKey, mcpUrl: `${mcpServer.url}mcp` });
|
|
cookie = "";
|
|
toolCalls = 0;
|
|
modelRequests = [];
|
|
});
|
|
afterEach(async () => { await app.close(); rmSync(directory, { recursive: true, force: true }); });
|
|
|
|
async function request(path: string, method = "GET", input?: unknown, extraHeaders: Record<string, string> = {}) {
|
|
const response = await app.fetch(new Request(`http://localhost:3000/api${path}`, {
|
|
method, headers: { cookie, ...(input ? { "content-type": "application/json" } : {}), ...extraHeaders }, body: input ? JSON.stringify(input) : undefined,
|
|
}));
|
|
const session = response.headers.get("set-cookie");
|
|
if (session) cookie = session.split(";")[0]!;
|
|
return response;
|
|
}
|
|
async function setup() {
|
|
const response = await request("/auth/login", "POST", { key: adminKey });
|
|
expect(response.status).toBe(200);
|
|
}
|
|
async function provider(protocol = "openai-completions", model = "test-model", baseUrl = `${upstream.url}v1`) {
|
|
return request("/provider", "PUT", { name: "test", protocol, model, baseUrl, apiKey, contextWindow: 128000, maxTokens: 4096 });
|
|
}
|
|
|
|
test("single admin Key login, CSRF and session persistence", async () => {
|
|
expect((await request("/provider")).status).toBe(401);
|
|
expect((await request("/auth/login", "POST", { key: "incorrect-key" })).status).toBe(401);
|
|
expect((await request("/auth/login", "POST", { key: adminKey }, { origin: "https://evil.example" })).status).toBe(403);
|
|
await setup();
|
|
expect(cookie).toContain("session=");
|
|
expect((await request("/auth/setup", "POST", { key: adminKey })).status).toBe(404);
|
|
expect(await (await request("/auth/me")).json()).toEqual({ admin: { id: 1 } });
|
|
expect((await request("/auth/me")).status).toBe(200);
|
|
expect((await request("/auth/logout", "POST", {}, { origin: "https://evil.example" })).status).toBe(403);
|
|
await app.close();
|
|
app = createApp({ dataDir: directory, mcpUrl: `${mcpServer.url}mcp` });
|
|
expect((await request("/auth/me")).status).toBe(200);
|
|
await request("/auth/logout", "POST");
|
|
expect((await request("/auth/me")).status).toBe(401);
|
|
expect((await request("/auth/login", "POST", { key: "incorrect-key" })).status).toBe(401);
|
|
const login = await request("/auth/login", "POST", { key: adminKey });
|
|
expect(login.status).toBe(200);
|
|
expect(login.headers.get("set-cookie")).toContain("HttpOnly; SameSite=Strict");
|
|
});
|
|
|
|
test("admin can replace the single Key, invalidating old Key and other sessions", async () => {
|
|
await setup();
|
|
await provider();
|
|
const previousSession = cookie;
|
|
await setup();
|
|
const newKey = "replacement-admin-key";
|
|
expect((await request("/auth/key", "PUT", { key: newKey })).status).toBe(200);
|
|
expect((await request("/auth/me")).status).toBe(200);
|
|
expect((await request("/auth/me", "GET", undefined, { cookie: previousSession })).status).toBe(401);
|
|
expect((await request("/auth/login", "POST", { key: adminKey })).status).toBe(401);
|
|
expect((await request("/auth/login", "POST", { key: newKey })).status).toBe(200);
|
|
expect(readFileSync(join(directory, "admin.key"), "utf8")).toBe(newKey);
|
|
await app.close();
|
|
// The original bootstrap setting must not undo a change made in the Web UI.
|
|
app = createApp({ dataDir: directory, adminKey, mcpUrl: `${mcpServer.url}mcp` });
|
|
expect((await request("/auth/login", "POST", { key: adminKey })).status).toBe(401);
|
|
expect((await request("/auth/login", "POST", { key: newKey })).status).toBe(200);
|
|
expect((await (await request("/provider")).json()).provider.name).toBe("test");
|
|
});
|
|
|
|
test("login works through a real Vite proxy and rejects unrelated origins", async () => {
|
|
const { createServer } = await import("vite");
|
|
await app.close();
|
|
app = createApp({ dataDir: directory, allowedOrigins: ["http://127.0.0.1:5173", "http://localhost:5173"], mcpUrl: `${mcpServer.url}mcp` });
|
|
const backend = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: app.fetch });
|
|
const proxy = await createServer({ configFile: false, root: directory, optimizeDeps: { noDiscovery: true, entries: [] }, server: {
|
|
host: "127.0.0.1", port: 0, proxy: { "/api": backend.url.origin },
|
|
watch: null,
|
|
} });
|
|
try {
|
|
await proxy.listen();
|
|
const address = proxy.httpServer!.address() as { port: number };
|
|
for (const origin of ["http://127.0.0.1:5173", "http://localhost:5173", "https://evil.example"]) {
|
|
const response = await fetch(`http://127.0.0.1:${address.port}/api/auth/login`, {
|
|
method: "POST", headers: { "Content-Type": "application/json", Origin: origin, "Sec-Fetch-Site": "same-origin" }, body: JSON.stringify({ key: adminKey }),
|
|
});
|
|
expect(response.status).toBe(origin === "https://evil.example" ? 403 : 200);
|
|
await response.text();
|
|
}
|
|
} finally { await proxy.close(); await backend.stop(true); }
|
|
});
|
|
|
|
test("provider validates endpoints and encrypts secrets without returning them", async () => {
|
|
await setup();
|
|
expect((await provider("openai-responses", "model", `${upstream.url}v1/responses`)).status).toBe(400);
|
|
const saved = await provider();
|
|
expect(saved.status).toBe(200);
|
|
expect(await saved.text()).not.toContain(apiKey);
|
|
expect(await (await request("/provider")).text()).not.toContain(apiKey);
|
|
expect(readFileSync(join(directory, "agent.sqlite")).includes(Buffer.from(apiKey))).toBe(false);
|
|
expect(readFileSync(join(directory, "agent.sqlite-wal")).includes(Buffer.from(apiKey))).toBe(false);
|
|
const keep = await request("/provider", "PUT", { name: "test", protocol: "openai-completions", baseUrl: `${upstream.url}v1`, model: "test-model", apiKey: "" });
|
|
expect((await keep.json()).provider.hasApiKey).toBe(true);
|
|
});
|
|
|
|
test("all three provider protocols execute through Pi against real HTTP streams", async () => {
|
|
await setup();
|
|
for (const protocol of ["openai-completions", "openai-responses", "anthropic-messages"]) {
|
|
expect((await provider(protocol)).status).toBe(200);
|
|
const response = await request("/provider/test", "POST");
|
|
const result = await response.json();
|
|
expect(result).toEqual({ ok: true, text: "OK" });
|
|
expect(response.status).toBe(200);
|
|
}
|
|
});
|
|
|
|
test("DeepSeek defaults need only a Key and never reuse another provider's Key", async () => {
|
|
await setup();
|
|
await provider();
|
|
expect((await request("/provider", "PUT", { preset: "deepseek", apiKey: "" })).status).toBe(400);
|
|
const response = await request("/provider", "PUT", { apiKey });
|
|
expect(response.status).toBe(200);
|
|
const { provider: saved } = await response.json();
|
|
expect(saved).toEqual({ ...DEEPSEEK_DEFAULTS, hasApiKey: true });
|
|
expect(saved.apiKey).toBeUndefined();
|
|
expect((await request("/provider", "PUT", { preset: "deepseek", apiKey: "" })).status).toBe(200);
|
|
expect((await request("/provider", "PUT", { preset: "unknown", apiKey })).status).toBe(400);
|
|
});
|
|
|
|
test("DeepSeek runtime sends the correct model and tool-compatible request parameters", async () => {
|
|
const config = parseProvider({ preset: "deepseek", apiKey }, null);
|
|
const { models, model } = providerRuntime(config);
|
|
let target = "";
|
|
let authenticated = false;
|
|
const localFetch = Object.assign(async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
const request = new Request(input, init);
|
|
target = request.url;
|
|
authenticated = request.headers.get("authorization") === `Bearer ${apiKey}`;
|
|
return fetch(new URL("/v1/chat/completions", upstream.url), { method: request.method, headers: request.headers, body: await request.text() });
|
|
}, { preconnect: fetch.preconnect });
|
|
const response = await models.completeSimple(model, { messages: [{ role: "user", content: "hello", timestamp: Date.now() }] }, { fetch: localFetch, maxTokens: 32 });
|
|
expect(response.stopReason).toBe("stop");
|
|
expect(target).toBe("https://api.deepseek.com/chat/completions");
|
|
expect(authenticated).toBe(true);
|
|
expect(modelRequests[0]!.model).toBe("deepseek-flash");
|
|
expect(modelRequests[0]!.thinking).toEqual({ type: "disabled" });
|
|
expect(modelRequests[0]!.max_tokens).toBe(32);
|
|
const { generationOptions } = providerRuntime(config);
|
|
await models.completeSimple(model, { messages: [{ role: "user", content: "hello", timestamp: Date.now() }] }, { ...generationOptions, fetch: localFetch });
|
|
expect(modelRequests[1]!.max_tokens).toBeUndefined();
|
|
expect(modelRequests[1]!.max_completion_tokens).toBeUndefined();
|
|
const thinking = providerRuntime({ ...config, vision: true, thinkingLevel: "max", thinkingLevels: ["off", "low", "high", "max"] });
|
|
await thinking.models.completeSimple(thinking.model, { messages: [{ role: "user", content: "hello", timestamp: Date.now() }] }, { ...thinking.generationOptions, fetch: localFetch });
|
|
expect(modelRequests[2]!.thinking).toEqual({ type: "enabled" });
|
|
expect(modelRequests[2]!.reasoning_effort).toBe("max");
|
|
});
|
|
|
|
test("legacy DeepSeek preset output caps migrate to server defaults without changing custom caps", () => {
|
|
const store = new Store(join(directory, "provider-migration"), adminKey);
|
|
store.saveProvider(1, { ...DEEPSEEK_DEFAULTS, maxTokens: 4096, apiKey });
|
|
expect(store.provider(1)!.maxTokens).toBeNull();
|
|
expect(store.provider(1)!.apiKey).toBe(apiKey);
|
|
store.saveProvider(1, { ...DEEPSEEK_DEFAULTS, maxTokens: 16000, apiKey });
|
|
expect(store.provider(1)!.maxTokens).toBe(16000);
|
|
store.close();
|
|
});
|
|
|
|
test("provider catalogs preserve legacy configs and store multiple providers with independent keys", async () => {
|
|
await setup();
|
|
await request("/provider", "PUT", { preset: "deepseek", apiKey });
|
|
const migrated = await (await request("/providers")).json();
|
|
expect(migrated.providers.length).toBe(1);
|
|
expect(migrated.providers[0].models.find((model: any) => model.id === "deepseek-flash").vision).toBe(true);
|
|
expect(migrated.default.modelId).toBe("deepseek-flash");
|
|
const created = await request("/providers", "POST", { kind: "cpa", name: "Local CPA", baseUrl: upstream.url.origin, apiKey });
|
|
expect(created.status).toBe(200);
|
|
const catalog = await created.json();
|
|
expect(catalog.providers.length).toBe(2);
|
|
expect(JSON.stringify(catalog)).not.toContain(apiKey);
|
|
const cpa = catalog.providers[1];
|
|
expect(cpa.baseUrl).toBe(`${upstream.url.origin}/v1`);
|
|
expect(cpa.models.map((model: any) => model.id)).toEqual(["vision-model", "text-model"]);
|
|
expect(cpa.models[0].thinkingLevels).toEqual(["off", "high"]);
|
|
expect(cpa.models[0].contextWindow).toBe(256000);
|
|
const discovery = await (await request("/providers/discover", "POST", { kind: "cpa", name: "Second CPA", baseUrl: upstream.url.origin, apiKey })).json();
|
|
const fromDiscovery = await (await request("/providers", "POST", { kind: "cpa", name: "Second CPA", baseUrl: upstream.url.origin, apiKey, models: discovery.models })).json();
|
|
expect(fromDiscovery.providers[2].models[0].thinkingLevels).toEqual(["off", "high"]);
|
|
expect((await request(`/providers/${fromDiscovery.providers[2].id}`, "DELETE")).status).toBe(200);
|
|
const defaults = { providerId: cpa.id, modelId: "vision-model", thinkingLevel: "high" };
|
|
expect((await request("/models/default", "PUT", defaults)).status).toBe(200);
|
|
const active = (await (await request("/provider")).json()).provider;
|
|
expect(active.model).toBe("vision-model");
|
|
expect(active.thinkingLevel).toBe("high");
|
|
expect(active.vision).toBe(true);
|
|
expect((await request("/models/default", "PUT", { ...defaults, thinkingLevel: "max" })).status).toBe(400);
|
|
expect((await request(`/providers/${cpa.id}`, "DELETE")).status).toBe(409);
|
|
const wrongHost = await request("/providers/discover", "POST", { ...cpa, baseUrl: `${upstream.url.origin}/other`, apiKey: "" });
|
|
expect(wrongHost.status).toBe(400);
|
|
await app.close();
|
|
app = createApp({ dataDir: directory, mcpUrl: `${mcpServer.url}mcp` });
|
|
const restored = await (await request("/providers")).json();
|
|
expect(restored.default).toEqual(defaults);
|
|
expect(restored.providers.length).toBe(2);
|
|
expect(readFileSync(join(directory, "agent.sqlite-wal")).includes(Buffer.from(apiKey))).toBe(false);
|
|
});
|
|
|
|
test("chat model selection sends real image blocks and thinking effort without changing defaults", async () => {
|
|
await setup();
|
|
const response = await request("/providers", "POST", { kind: "cpa", name: "CPA", baseUrl: upstream.url.origin, apiKey });
|
|
const catalog = await response.json();
|
|
const cpa = catalog.providers[0];
|
|
const selection = { providerId: cpa.id, modelId: "vision-model", thinkingLevel: "high" };
|
|
const image = { mimeType: "image/png", data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1kAAAAASUVORK5CYII=" };
|
|
const run = await request("/agent/run", "POST", { prompt: "识别图片", selection, images: [image] });
|
|
expect(run.status).toBe(200);
|
|
expect(await run.text()).toContain('"type":"done"');
|
|
const payload = modelRequests.at(-1)!;
|
|
expect(JSON.stringify(payload.input)).toContain("input_image");
|
|
expect(JSON.stringify(payload.input)).toContain(image.data);
|
|
expect(payload.reasoning.effort).toBe("high");
|
|
expect(payload.max_output_tokens).toBeUndefined();
|
|
expect((await (await request("/providers")).json()).default.thinkingLevel).toBe("off");
|
|
const history = (await (await request("/agent/messages")).json()).messages;
|
|
expect(history[0].content.some((block: any) => block.type === "image")).toBe(true);
|
|
expect((await request("/agent/run", "POST", { prompt: "识别图片", selection: { ...selection, modelId: "text-model", thinkingLevel: "off" }, images: [image] })).status).toBe(400);
|
|
expect((await request("/agent/run", "POST", { prompt: "识别图片", selection, images: [{ ...image, data: "bm90LWFuLWltYWdl" }] })).status).toBe(400);
|
|
});
|
|
|
|
test("Pi runs MCP tools, streams events, and restores persisted conversation", async () => {
|
|
await setup();
|
|
await provider();
|
|
const status = await (await request("/mcp")).json();
|
|
expect(status.connected).toBe(true);
|
|
expect(status.tools.some((tool: any) => tool.name === "check_login_status")).toBe(true);
|
|
const response = await request("/agent/run", "POST", { prompt: "检查登录" });
|
|
expect(response.headers.get("content-type")).toBe("text/event-stream");
|
|
const events = await response.text();
|
|
expect(events).toContain('"type":"tool_execution_end"');
|
|
expect(events).toContain('"type":"done"');
|
|
expect(events).not.toContain('"type":"error"');
|
|
expect(events).toContain("已检查小红书登录");
|
|
expect(toolCalls).toBe(1);
|
|
expect(modelRequests[0]!.tools.some((tool: any) => tool.function.name === "check_login_status")).toBe(true);
|
|
const history = (await (await request("/agent/messages")).json()).messages;
|
|
expect(history.some((message: any) => message.role === "toolResult")).toBe(true);
|
|
await app.close();
|
|
app = createApp({ dataDir: directory, mcpUrl: `${mcpServer.url}mcp` });
|
|
expect((await (await request("/agent/messages")).json()).messages).toEqual(history);
|
|
expect((await request("/agent/messages", "DELETE")).status).toBe(200);
|
|
expect((await (await request("/agent/messages")).json()).messages).toEqual([]);
|
|
});
|
|
|
|
test("agent tasks can complete beyond twelve model calls", async () => {
|
|
await setup();
|
|
await provider("openai-completions", "many-turns");
|
|
const run = await request("/agent/run", "POST", { prompt: "依次检查登录状态,直到任务完成" });
|
|
const events = await run.text();
|
|
expect(events).toContain('"type":"done"');
|
|
expect(events).not.toContain('"type":"error"');
|
|
expect(toolCalls).toBe(14);
|
|
expect(modelRequests.length).toBe(15);
|
|
});
|
|
|
|
test("new conversations preserve history, can be selected, and have isolated model context", async () => {
|
|
await setup();
|
|
await provider();
|
|
const first = (await (await request("/conversations")).json()).active;
|
|
await (await request("/agent/run", "POST", { prompt: "第一段会话", conversationId: first.id })).text();
|
|
const history = (await (await request(`/conversations/${first.id}`)).json()).conversation.messages;
|
|
expect(history.length).toBe(2);
|
|
const second = (await (await request("/conversations", "POST")).json()).conversation;
|
|
expect(second.id).not.toBe(first.id);
|
|
expect(second.messages).toEqual([]);
|
|
expect((await (await request(`/conversations/${first.id}`)).json()).conversation.messages).toEqual(history);
|
|
// Creating another blank conversation does not litter history with empty entries.
|
|
expect((await (await request("/conversations", "POST")).json()).conversation.id).toBe(second.id);
|
|
await (await request("/agent/run", "POST", { prompt: "第二段会话", conversationId: second.id })).text();
|
|
expect(JSON.stringify(modelRequests.at(-1)!.messages)).not.toContain("第一段会话");
|
|
const selected = await request("/conversations/active", "PUT", { id: first.id });
|
|
expect((await selected.json()).conversation.messages).toEqual(history);
|
|
const list = await (await request("/conversations")).json();
|
|
expect(list.conversations.length).toBe(2);
|
|
expect(list.conversations.find((item: any) => item.id === first.id).title).toBe("第一段会话");
|
|
await app.close();
|
|
app = createApp({ dataDir: directory, mcpUrl: `${mcpServer.url}mcp` });
|
|
const restored = await (await request("/conversations")).json();
|
|
expect(restored.active.id).toBe(first.id);
|
|
expect(restored.active.messages).toEqual(history);
|
|
expect(restored.conversations.length).toBe(2);
|
|
expect((await request("/conversations/active", "PUT", { id: "missing" })).status).toBe(404);
|
|
expect((await request("/agent/run", "POST", { prompt: "hello", conversationId: "missing" })).status).toBe(404);
|
|
});
|
|
|
|
test("deleting conversations preserves other history and replaces the last deleted conversation", async () => {
|
|
await setup();
|
|
await provider();
|
|
const first = (await (await request("/conversations")).json()).active;
|
|
await (await request("/agent/run", "POST", { prompt: "保留的会话", conversationId: first.id })).text();
|
|
const history = (await (await request(`/conversations/${first.id}`)).json()).conversation.messages;
|
|
const second = (await (await request("/conversations", "POST")).json()).conversation;
|
|
await (await request("/agent/run", "POST", { prompt: "删除的会话", conversationId: second.id })).text();
|
|
await request("/conversations/active", "PUT", { id: first.id });
|
|
expect((await request(`/conversations/${second.id}`, "DELETE", undefined, { origin: "https://evil.example" })).status).toBe(403);
|
|
const inactive = await (await request(`/conversations/${second.id}`, "DELETE")).json();
|
|
expect(inactive.active.id).toBe(first.id);
|
|
expect(inactive.active.messages).toEqual(history);
|
|
expect(inactive.conversations.map((item: any) => item.id)).toEqual([first.id]);
|
|
expect((await request(`/conversations/${second.id}`)).status).toBe(404);
|
|
expect((await request(`/conversations/${second.id}`, "DELETE")).status).toBe(404);
|
|
|
|
const third = (await (await request("/conversations", "POST")).json()).conversation;
|
|
const active = await (await request(`/conversations/${third.id}`, "DELETE")).json();
|
|
expect(active.active.id).toBe(first.id);
|
|
expect(active.active.messages).toEqual(history);
|
|
const last = await (await request(`/conversations/${first.id}`, "DELETE")).json();
|
|
expect(last.active.id).not.toBe(first.id);
|
|
expect(last.active.messages).toEqual([]);
|
|
expect(last.conversations.length).toBe(1);
|
|
await app.close();
|
|
app = createApp({ dataDir: directory, mcpUrl: `${mcpServer.url}mcp` });
|
|
const restored = await (await request("/conversations")).json();
|
|
expect(restored.active.id).toBe(last.active.id);
|
|
expect(restored.conversations.length).toBe(1);
|
|
expect((await request(`/conversations/${first.id}`)).status).toBe(404);
|
|
});
|
|
|
|
test("history searches message text, paginates, and deletes selections atomically", async () => {
|
|
await setup();
|
|
const store = new Store(directory);
|
|
const ids: string[] = [];
|
|
try {
|
|
for (let index = 0; index < 24; index++) {
|
|
const conversation = store.createConversation(1, [
|
|
{ role: "user", content: `历史 ${index}`, timestamp: 1000 + index },
|
|
{ role: "user", content: [{ type: "text", text: index === 7 ? "正文匹配 100%_真实文本" : `其他消息 ${index}` }, { type: "image", mimeType: "image/png", data: "image-only-secret" }], timestamp: 2000 + index },
|
|
]);
|
|
ids.push(conversation.id);
|
|
}
|
|
} finally { store.close(); }
|
|
const first = await (await request("/conversations/history")).json();
|
|
expect(first.total).toBe(25);
|
|
expect(first.items.length).toBe(20);
|
|
expect(first.items[0].title).toBe("新对话");
|
|
const second = await (await request("/conversations/history?page=2")).json();
|
|
expect(second.items.length).toBe(5);
|
|
expect(new Set([...first.items, ...second.items].map(item => item.id)).size).toBe(25);
|
|
const matched = await (await request(`/conversations/history?q=${encodeURIComponent("100%_真实文本")}`)).json();
|
|
expect(matched.total).toBe(1);
|
|
expect(matched.items[0].id).toBe(ids[7]);
|
|
expect(matched.items[0].preview).toContain("正文匹配 100%_真实文本");
|
|
expect(JSON.stringify(matched)).not.toContain("image-only-secret");
|
|
expect((await (await request("/conversations/history?q=image-only-secret")).json()).total).toBe(0);
|
|
const oldest = await (await request("/conversations/history?sort=oldest")).json();
|
|
expect(oldest.items[0].id).toBe(ids[0]);
|
|
expect((await request("/conversations/history?page=0")).status).toBe(400);
|
|
expect((await request("/conversations/delete", "POST", { ids: [] })).status).toBe(400);
|
|
expect((await request("/conversations/delete", "POST", { ids: [ids[0], "missing"] })).status).toBe(404);
|
|
expect((await request(`/conversations/${ids[0]}`)).status).toBe(200);
|
|
expect((await request("/conversations/delete", "POST", { ids: [ids[0]] }, { origin: "https://evil.example" })).status).toBe(403);
|
|
const deleted = await (await request("/conversations/delete", "POST", { ids: [ids[0], ids[1], ids[0], ids[23]] })).json();
|
|
expect(deleted.deleted).toBe(3);
|
|
expect(deleted.conversations.length).toBe(22);
|
|
expect(deleted.active.id).not.toBe(ids[23]);
|
|
expect((await request(`/conversations/${ids[0]}`)).status).toBe(404);
|
|
const all = await (await request("/conversations/delete", "POST", { ids: deleted.conversations.map((item: any) => item.id) })).json();
|
|
expect(all.conversations.length).toBe(1);
|
|
expect(all.active.messages).toEqual([]);
|
|
});
|
|
|
|
test("legacy conversation migration preserves messages and does not duplicate them on restart", () => {
|
|
const legacyDirectory = join(directory, "legacy");
|
|
mkdirSync(legacyDirectory);
|
|
const db = new Database(join(legacyDirectory, "agent.sqlite"), { create: true });
|
|
const messages = [{ role: "user" as const, content: "旧会话", timestamp: 1000 }];
|
|
db.exec("CREATE TABLE conversations(user_id INTEGER PRIMARY KEY,messages TEXT NOT NULL)");
|
|
db.query("INSERT INTO conversations VALUES (1,?)").run(JSON.stringify(messages));
|
|
db.close();
|
|
let store = new Store(legacyDirectory, adminKey);
|
|
expect(store.messages(1)).toEqual(messages);
|
|
expect(store.conversations(1).length).toBe(1);
|
|
expect(store.db.query<{ messages: string }, []>("SELECT messages FROM conversations WHERE user_id=1").get()!.messages).toBe(JSON.stringify(messages));
|
|
const originalId = store.currentConversation(1).id;
|
|
store.close();
|
|
store = new Store(legacyDirectory);
|
|
expect(store.currentConversation(1).id).toBe(originalId);
|
|
expect(store.messages(1)).toEqual(messages);
|
|
expect(store.conversations(1).length).toBe(1);
|
|
store.close();
|
|
});
|
|
|
|
test("upstream errors remain errors and credentials are redacted", async () => {
|
|
await setup();
|
|
await provider("openai-completions", "fail-model");
|
|
const tested = await request("/provider/test", "POST");
|
|
expect(tested.status).toBe(502);
|
|
expect(await tested.text()).not.toContain(apiKey);
|
|
const run = await request("/agent/run", "POST", { prompt: "hello" });
|
|
const events = await run.text();
|
|
expect(events).toContain('"type":"error"');
|
|
expect(events).not.toContain('"type":"done"');
|
|
expect(events).not.toContain(apiKey);
|
|
expect(await (await request("/agent/messages")).text()).not.toContain(apiKey);
|
|
});
|
|
|
|
test("MCP failure is reported while basic model conversation remains usable", async () => {
|
|
await setup();
|
|
await provider();
|
|
await app.close();
|
|
app = createApp({ dataDir: directory, mcpUrl: `${upstream.url}not-mcp` });
|
|
expect((await (await request("/mcp")).json()).connected).toBe(false);
|
|
const response = await request("/agent/run", "POST", { prompt: "hello" });
|
|
const events = await response.text();
|
|
expect(events).toContain('"connected":false');
|
|
expect(events).toContain('"type":"done"');
|
|
});
|
|
|
|
test("running tasks reject concurrent changes and can be stopped", async () => {
|
|
await setup();
|
|
await provider("openai-completions", "slow-model");
|
|
const response = await request("/agent/run", "POST", { prompt: "slow" });
|
|
const events = response.text();
|
|
expect((await request("/agent/run", "POST", { prompt: "second" })).status).toBe(409);
|
|
expect((await request("/agent/messages", "DELETE")).status).toBe(409);
|
|
expect((await request("/conversations", "POST")).status).toBe(409);
|
|
expect((await request("/conversations/active", "PUT", { id: "missing" })).status).toBe(409);
|
|
const current = (await (await request("/conversations")).json()).active;
|
|
expect((await request(`/conversations/${current.id}`, "DELETE")).status).toBe(409);
|
|
expect((await request("/conversations/delete", "POST", { ids: [current.id] })).status).toBe(409);
|
|
expect((await provider()).status).toBe(409);
|
|
for (let index = 0; index < 100 && modelRequests.length === 0; index++) await Bun.sleep(10);
|
|
expect(modelRequests.length).toBe(1);
|
|
expect(JSON.stringify((await (await request("/agent/messages")).json()).messages)).toContain("slow");
|
|
expect((await request("/agent/stop", "POST")).status).toBe(200);
|
|
const output = await events;
|
|
expect(output).toContain('"type":"error"');
|
|
expect(output).not.toContain('"type":"done"');
|
|
expect((await request("/agent/messages", "DELETE")).status).toBe(200);
|
|
});
|