Move project and host queries into native MCP with host-owned OAuth/context, while retaining memory, skills, actions and publication plugins. Add nearby-model queries and explicit chat/task/learning model defaults. Adopt Responses HTTP/SSE and native steer/queue inbox delivery, preserving scoped session ownership, pending input edits and investigation communication. Disable local-provider discovery: Server /health allowed native vLLM to probe /v1/models on port 8000. A native before/after regression prevents recurrence. Validation: 170 focused Bun tests; service/plugin typechecks; native discovery, provider-discovery and input-inbox smoke checks; no paid model inference.
186 lines
11 KiB
TypeScript
186 lines
11 KiB
TypeScript
import { afterEach, beforeEach, expect, it } from "bun:test";
|
|
import express from "express";
|
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
import type { Server } from "node:http";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { buildChatRouter } from "../../src/routes/chat.js";
|
|
import { ChatSessionBridge } from "../../src/chat/sessionBridge.js";
|
|
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
|
|
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
|
|
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
|
|
import { removeRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
|
import { toActorKey, toProjectKey } from "../../src/utils/fileStore.js";
|
|
import type { ResponseInput } from "../../src/chat/response-inputs.js";
|
|
|
|
const http = globalThis.fetch;
|
|
let root: string;
|
|
let server: Server;
|
|
let base: string;
|
|
let fail = false;
|
|
let prompted: string[];
|
|
let pendingInputs: ResponseInput[];
|
|
let aborts: number;
|
|
let gate: Promise<void> | undefined;
|
|
let release: () => void = () => {};
|
|
let bridge: ChatSessionBridge;
|
|
const project = "10000000-0000-4000-8000-000000000001";
|
|
beforeEach(async () => {
|
|
fail = false; prompted = []; pendingInputs = []; aborts = 0; gate = undefined;
|
|
root = await mkdtemp(join(tmpdir(), "tjwater-responses-"));
|
|
globalThis.fetch = (async (_input, init) => Response.json({
|
|
user_id: new Headers(init?.headers).get("authorization") === "Bearer owner" ? "owner" : "other",
|
|
project_id: project, network: "fixture",
|
|
})) as typeof fetch;
|
|
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
|
|
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
|
|
const states = new SessionUiStateStore(join(root, "ui"), workspaces);
|
|
await sessions.initialize();
|
|
const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${project}`), projectId: project, userId: "owner", sessionId: "owned" };
|
|
await sessions.ensure(context);
|
|
await states.write(context, { sessionId: "owned", isTitleManuallyEdited: true, messages: [] });
|
|
const runtime = {
|
|
prompt: async (_id: string, prompt: string) => { prompted.push(prompt); if (fail) throw new Error("fixture execution failed"); },
|
|
messages: async () => [], abortSession: async () => { aborts++; }, waitForSessionIdle: async () => {},
|
|
listPendingUserInputs: async () => pendingInputs.map((input) => ({ ...input })),
|
|
cancelPendingUserInputs: async () => { pendingInputs = []; },
|
|
enqueueUserInput: async (_sessionId: string, input: ResponseInput) => { pendingInputs.push({ ...input }); },
|
|
updatePendingUserInput: async (_sessionId: string, id: string, delivery: "steer" | "queue") => {
|
|
const item = pendingInputs.find((input) => input.id === id);
|
|
if (!item) throw Object.assign(new Error("Consumed"), { status: 409 });
|
|
item.delivery = delivery;
|
|
return { ...item };
|
|
},
|
|
subscribeEvents: async () => ({ async *[Symbol.asyncIterator]() {
|
|
if (gate) await gate;
|
|
yield { type: "message.part.updated", properties: { sessionID: "owned", part: { id: "answer", type: "text", text: "", sessionID: "owned", messageID: "native-answer" } } };
|
|
yield { type: "message.part.delta", properties: { sessionID: "owned", partID: "answer", field: "text", delta: "压力待核验" } };
|
|
for (const item of pendingInputs.splice(0)) {
|
|
yield { type: "session.inbox.delivered", properties: { sessionID: "owned", inboxID: item.id } };
|
|
}
|
|
yield { type: "session.idle", properties: { sessionID: "owned" } };
|
|
} }),
|
|
} as never;
|
|
const app = express(); app.use(express.json());
|
|
bridge = new ChatSessionBridge(runtime);
|
|
app.use("/api/v1", buildChatRouter({
|
|
runtime, sessionBridge: bridge, sessionMetadataStore: sessions, sessionUiStateStore: states,
|
|
sessionTranscriptStore: { getRecentTurns: async () => [] } as never,
|
|
memoryStore: { buildPromptSnapshot: async () => "" } as never,
|
|
learningOrchestrator: { onTurnCompleted: async () => {} } as never,
|
|
frontendActionCoordinator: { cancelSession: () => {} } as never,
|
|
}));
|
|
server = app.listen(0, "127.0.0.1");
|
|
await new Promise<void>((resolve) => server.once("listening", resolve));
|
|
const address = server.address(); if (!address || typeof address === "string") throw new Error("missing address");
|
|
base = `http://127.0.0.1:${address.port}`;
|
|
});
|
|
afterEach(async () => {
|
|
release();
|
|
for (let i = 0; i < 100 && bridge.count(); i++) await Bun.sleep(10);
|
|
globalThis.fetch = http;
|
|
removeRuntimeSessionContext("owned");
|
|
server?.closeAllConnections();
|
|
if (server) await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
await rm(root, { recursive: true, force: true });
|
|
});
|
|
const call = (body: unknown, token = "owner", path = "/api/v1/responses") => http(`${base}${path}`, {
|
|
method: "POST", headers: { Authorization: `Bearer ${token}`, "X-Project-Id": project, "Content-Type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(5000),
|
|
});
|
|
|
|
it("serves Responses SSE through the real HTTP route and preserves the owned conversation", async () => {
|
|
const response = await call({ input: [{ role: "user", content: [{ type: "input_text", text: "核查压力" }] }], conversation: "owned", stream: true });
|
|
const text = await response.text();
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("content-type")).toContain("text/event-stream");
|
|
expect(response.headers.has("x-vercel-ai-ui-message-stream")).toBeFalse();
|
|
expect(text).toContain("event: response.created");
|
|
expect(text).toContain("event: response.output_text.delta");
|
|
expect(text).toContain("event: response.completed");
|
|
expect(text).toContain('"conversation":{"id":"owned"}');
|
|
expect(prompted).toHaveLength(1);
|
|
expect(prompted[0]).toContain("核查压力");
|
|
});
|
|
it("returns a Response object when streaming is omitted", async () => {
|
|
const response = await call({ input: "核查压力", conversation: { id: "owned" } });
|
|
expect(await response.json()).toMatchObject({ object: "response", status: "completed", conversation: { id: "owned" }, output: [{ type: "message", content: [{ type: "output_text", text: "压力待核验" }] }] });
|
|
});
|
|
|
|
it("admits steer/queue input without interrupting, edits pending delivery and persists consumed user messages", async () => {
|
|
gate = new Promise<void>((resolve) => { release = resolve; });
|
|
const initial = await call({ input: "原始问题", conversation: "owned", stream: true });
|
|
const first = `msg_${"1".repeat(32)}`;
|
|
const second = `msg_${"2".repeat(32)}`;
|
|
const baselineAborts = aborts;
|
|
for (const [id, text, delivery] of [[first, "补充压力测点", "steer"], [second, "之后检查流量", "queue"]] as const) {
|
|
const result = await call({ input: text, conversation: "owned", tjwater: { delivery, input_id: id } });
|
|
expect(result.status).toBe(202);
|
|
expect(await result.json()).toMatchObject({ id, text, delivery, status: "pending" });
|
|
}
|
|
expect((await call({ input: "不带方式", conversation: "owned" })).status).toBe(409);
|
|
expect(aborts).toBe(baselineAborts);
|
|
expect(prompted).toHaveLength(1);
|
|
const headers = { Authorization: "Bearer owner", "X-Project-Id": project, "Content-Type": "application/json" };
|
|
const changed = await http(`${base}/api/v1/session/owned/inputs/${second}`, { method: "PATCH", headers, body: JSON.stringify({ delivery: "steer" }) });
|
|
expect(changed.status).toBe(200);
|
|
expect(await changed.json()).toMatchObject({ id: second, delivery: "steer" });
|
|
const list = await http(`${base}/api/v1/session/owned/inputs`, { headers });
|
|
expect((await list.json()).items).toHaveLength(2);
|
|
const foreign = await http(`${base}/api/v1/session/owned/inputs/${first}`, { method: "PATCH", headers: { ...headers, Authorization: "Bearer other" }, body: JSON.stringify({ delivery: "queue" }) });
|
|
expect(foreign.status).toBe(404);
|
|
expect(aborts).toBe(baselineAborts);
|
|
release();
|
|
const output = await initial.text();
|
|
expect(output).toContain("event: response.completed");
|
|
expect(output).toContain("tjwater.user_input");
|
|
const history = await http(`${base}/api/v1/session/owned`, { headers });
|
|
expect(JSON.stringify((await history.json()).messages)).toContain("补充压力测点");
|
|
const consumed = await http(`${base}/api/v1/session/owned/inputs/${second}`, { method: "PATCH", headers, body: JSON.stringify({ delivery: "queue" }) });
|
|
expect(consumed.status).toBe(409);
|
|
});
|
|
it("rejects cross-user conversations, legacy requests and retired URLs before generation", async () => {
|
|
expect((await call({ input: "核查压力", conversation: "owned" }, "other")).status).toBe(404);
|
|
expect((await call({ message: "legacy", session_id: "owned" })).status).toBe(400);
|
|
expect((await call({ input: "核查压力" }, "owner", "/api/v1/agent/chat/stream")).status).toBe(404);
|
|
expect(prompted).toEqual([]);
|
|
});
|
|
it("returns response.failed when the runtime fails", async () => {
|
|
fail = true;
|
|
const response = await call({ input: "核查压力", conversation: "owned", stream: true });
|
|
const text = await response.text();
|
|
expect(text).toContain("event: response.failed");
|
|
expect(text).not.toContain("event: response.completed");
|
|
});
|
|
|
|
it("keeps generating and saving the owned history after the browser disconnects", async () => {
|
|
gate = new Promise<void>((resolve) => { release = resolve; });
|
|
const response = await call({ input: "核查压力", conversation: "owned", stream: true });
|
|
const reader = response.body!.getReader();
|
|
await reader.read();
|
|
await reader.cancel();
|
|
release();
|
|
let loaded: { is_streaming?: boolean; messages?: unknown[] } = {};
|
|
for (let i = 0; i < 50; i++) {
|
|
await Bun.sleep(10);
|
|
const state = await http(`${base}/api/v1/session/owned`, { headers: { Authorization: "Bearer owner", "X-Project-Id": project } });
|
|
loaded = await state.json() as typeof loaded;
|
|
if (!loaded.is_streaming && JSON.stringify(loaded.messages).includes("压力待核验")) break;
|
|
}
|
|
expect(loaded.is_streaming).toBeFalse();
|
|
expect(JSON.stringify(loaded.messages)).toContain("压力待核验");
|
|
});
|
|
|
|
it("marks an explicitly aborted response incomplete and preserves scoped history", async () => {
|
|
gate = new Promise<void>((resolve) => { release = resolve; });
|
|
const response = await call({ input: "核查压力", conversation: "owned", stream: true });
|
|
const queued = await call({ input: "等待后续处理", conversation: "owned", tjwater: { delivery: "queue", input_id: `msg_${"3".repeat(32)}` } });
|
|
expect(queued.status).toBe(202);
|
|
const cancelled = await call({ session_id: "owned" }, "owner", "/api/v1/abort");
|
|
release();
|
|
expect(cancelled.ok).toBeTrue();
|
|
expect(pendingInputs).toEqual([]);
|
|
const text = await response.text();
|
|
expect(text).toContain("event: response.incomplete");
|
|
expect(text).not.toContain("event: response.completed");
|
|
});
|