Files
next-tjwater-agent/tests/routes/responses.test.ts
T
jiang 6a49d2209e feat(agent): unify MCP tools and adopt Responses delivery
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.
2026-10-19 18:46:58 +08:00

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