Files
upup/tests/app.test.ts
chuan 4bf0617cad feat: 完善 upup 模型配置和历史会话管理
- 将项目、页面和 MCP 客户端统一命名为 upup
- 支持多服务商、模型发现、多模态输入和思考等级配置
- 优化对话模型选择和 Toast 通知,新增历史搜索、分页与批量删除
- 修复网页预览、滚动定位和输入闪烁,移除任务轮数与执行时限
- 补充运营与多用户模式规划,增加模型、会话和预览回归测试
2026-10-25 00:52:29 +08:00

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);
});