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.
This commit is contained in:
2026-10-19 18:46:58 +08:00
parent f5c3cde2c2
commit 6a49d2209e
164 changed files with 12286 additions and 2368 deletions
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it, mock } from "bun:test";
import express from "express";
import { createAgentMessageQuery, registerAgentMessageBridge } from "../../src/routes/agent-message-routes.js";
import { setRuntimeSessionContext, removeRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
import type { SessionMetadataStore, SessionRecord } from "../../src/sessions/metadataStore.js";
import type { AgentMessageCoordinator } from "../../src/chat/agent-message-coordinator.js";
const task = "81000000-0000-4000-8000-000000000001";
const run = "81000000-0000-4000-8000-000000000002";
const request = "81000000-0000-4000-8000-000000000003";
describe("scoped Agent message bridge", () => {
it("derives sender from runtime, validates arguments, and denies child/review/cross-run writes", async () => {
const records = new Map<string, SessionRecord>();
for (const [id, purpose] of [["main", "ordinary_chat"], ["investigator", "task_execution"], ["review", "ordinary_chat"]] as const) {
const record: SessionRecord = { sessionId: id, actorKey: "owner", projectKey: "project", projectId: task,
createdAt: "now", updatedAt: "now", status: "active", purpose, ...(purpose === "task_execution" ? { taskId: task, runId: run } : {}) };
records.set(id, record);
setRuntimeSessionContext({ ...record, clientSessionId: id, traceId: "test", learningMode: id === "review" ? "review" : "interactive" });
}
const send = mock(async (_session: string, input: unknown) => ({ input, target_session_id: null }));
const reply = mock(async () => ({}));
const read = mock(async () => ({ items: [], next_cursor: null }));
const coordinator = {
client: { send, reply, list: read, get: async () => ({ task_id: task, run_id: request, direction: "to_task" }) },
isActive: () => true, notify: () => {}, deliver: async () => {},
} as unknown as AgentMessageCoordinator;
const app = express(); app.use(express.json());
registerAgentMessageBridge(app, { internalToken: "test-only", messages: coordinator,
sessions: { get: async (_context: unknown, id: string) => records.get(id) ?? null } as unknown as SessionMetadataStore });
const server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server.once("listening", resolve));
const url = `http://127.0.0.1:${(server.address() as { port: number }).port}/internal/agent-messages`;
const call = (operation: string, session: string, input: unknown, token = "test-only") => fetch(`${url}/${operation}`, {
method: "POST", headers: { "Content-Type": "application/json", "x-agent-internal-token": token },
body: JSON.stringify({ session_id: session, input }),
});
const input = { task_id: task, run_id: run, request_id: request, message: "核对压力记录" };
try {
expect((await call("send", "main", input, "wrong")).status).toBe(403);
expect((await call("send", "child", input)).status).toBe(403);
expect((await call("send", "review", input)).status).toBe(403);
expect((await call("send", "investigator", input)).status).toBe(403);
expect((await call("send", "main", { ...input, source_session_id: "someone-else" })).status).toBe(400);
expect((await call("send", "main", input)).status).toBe(200);
expect(send).toHaveBeenCalledTimes(1);
expect(send.mock.calls[0]).toEqual(["main", input]);
expect((await call("reply", "investigator", { message_id: request, request_id: request, message: "回复" })).status).toBe(403);
expect(reply).not.toHaveBeenCalled();
expect((await call("read", "main", {})).status).toBe(404);
const query = createAgentMessageQuery({ sessions: { get: async (_context: unknown, id: string) => records.get(id) ?? null } as unknown as SessionMetadataStore, messages: coordinator });
await query("main", "read", {});
expect(read).toHaveBeenCalledWith("main", "main", undefined, 20);
await expect(query("investigator", "list", {})).rejects.toThrow();
await expect(query("child", "read", {})).rejects.toThrow();
} finally {
for (const id of records.keys()) removeRuntimeSessionContext(id);
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
});
+1 -1
View File
@@ -40,7 +40,7 @@ describe("agent event trigger", () => {
it("keeps event entry and action-tool instructions consistent with report-driven completion", () => {
const prompt = buildAgentTriggerPrompt(agentTriggerContextSchema.parse(trigger));
expect(prompt).toContain("get_operational_task_context");
expect(prompt).toContain("mcp_tjwater_get_task_context");
expect(prompt).toContain("final/insufficient_data");
expect(prompt).toContain("正常结束本轮");
expect(prompt).toContain("权限拒绝和接口错误不能证明数据缺失");
+1
View File
@@ -96,6 +96,7 @@ describe("generateSessionTitle", () => {
},
],
abortSession: async () => undefined,
removeSession: async () => undefined,
} as unknown as OpencodeRuntimeAdapter;
const title = await generateSessionTitle(runtime, {
+76 -8
View File
@@ -19,6 +19,32 @@ const createEventStream = (events: unknown[]) => ({
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
describe("streamPromptResponse", () => {
it("flushes before prompting and resumes the same session when a message wins the completion race", async () => {
const order: string[] = [];
const emitted: string[] = [];
let idleChecks = 0;
const runtime = {
prompt: async (sessionId: string) => { order.push(`prompt:${sessionId}`); },
subscribeEvents: async () => createEventStream([1, 2].flatMap((round) => [
{ type: "message.part.updated", properties: { sessionID: "s", part: {
id: `text-${round}`, type: "text", text: `round ${round}`, sessionID: "s", messageID: `m-${round}`,
} } },
{ type: "session.idle", properties: { sessionID: "s" } },
])),
messages: async () => [],
} as unknown as OpencodeRuntimeAdapter;
const result = await streamPromptResponse({
runtime, sessionId: "s", clientSessionId: "client", message: "test",
beforePrompt: async () => { order.push("flush"); },
beforeIdle: async () => ++idleChecks === 2,
write: (event) => emitted.push(event),
});
expect(result.failed).toBeFalse();
expect(order).toEqual(["flush", "prompt:s", "prompt:s"]);
expect(idleChecks).toBe(2);
expect(emitted.filter((event) => event === "done")).toHaveLength(1);
});
it("keeps the first pending event when the V2 prompt acknowledgement arrives first", async () => {
const emitted: { event: string; data: Record<string, unknown> }[] = [];
const runtime = {
@@ -252,7 +278,7 @@ describe("streamPromptResponse", () => {
properties: {
id: "perm-1",
sessionID: "runtime-session-1",
permission: "web_search",
permission: "websearch",
patterns: ["npm test"],
metadata: { command: "npm test" },
always: ["npm test"],
@@ -286,18 +312,60 @@ describe("streamPromptResponse", () => {
{
requestId: "perm-1",
sessionId: "runtime-session-1",
reply: "always",
reply: "once",
},
]);
expect(events.some((item) => item.event === "permission_request")).toBe(false);
expect(events.find((item) => item.event === "permission_response")?.data).toEqual({
session_id: "client-session-1",
request_id: "perm-1",
reply: "always",
reply: "once",
});
});
it("requires manual Shell approval even in always mode", async () => {
it.each(["permission.asked", "permission.v2.asked"])("returns to manual approval after an automatic round for %s", async (eventType) => {
let remembered = false;
let round = 0;
const replies: string[] = [];
const runtime = {
subscribeEvents: async () => {
round += 1;
return createEventStream([
...(!remembered ? [{
type: eventType,
properties: {
id: `permission-${round}`,
sessionID: "runtime-session-1",
...(eventType === "permission.asked"
? { permission: "webfetch", patterns: ["https://example.test/*"], always: ["https://example.test/*"] }
: { action: "webfetch", resources: ["https://example.test/"], save: ["https://example.test/*"] }),
},
}] : []),
{ type: "session.idle", properties: { sessionID: "runtime-session-1" } },
]);
},
prompt: async () => undefined,
messages: async () => [],
replyPermission: async ({ reply }: { reply: string }) => {
replies.push(reply);
remembered = reply === "always";
},
} as unknown as OpencodeRuntimeAdapter;
const automatic: Array<{ event: string; data: Record<string, unknown> }> = [];
const manual: Array<{ event: string; data: Record<string, unknown> }> = [];
const options = { runtime, sessionId: "runtime-session-1", clientSessionId: "client-session-1", message: "fetch a public reference" };
await streamPromptResponse({ ...options, approvalMode: "always", write: (event, data) => automatic.push({ event, data }) });
await streamPromptResponse({ ...options, approvalMode: "request", write: (event, data) => manual.push({ event, data }) });
expect(replies).toEqual(["once"]);
expect(automatic.find(({ event }) => event === "permission_response")?.data.reply).toBe("once");
expect(manual.find(({ event }) => event === "permission_request")?.data).toMatchObject({
request_id: "permission-2", permission: "webfetch",
});
});
it.each(["shell", "bash", "edit", "mcp_fixture_write"])("automatically approves %s in always mode", async (action) => {
const replies: Array<Record<string, unknown>> = [];
const runtime = {
subscribeEvents: async () =>
@@ -307,7 +375,7 @@ describe("streamPromptResponse", () => {
properties: {
id: "perm-1",
sessionID: "runtime-session-1",
permission: "bash",
permission: action,
patterns: ["npm test"],
metadata: { command: "npm test" },
always: ["npm test"],
@@ -337,9 +405,9 @@ describe("streamPromptResponse", () => {
write: (event, data) => events.push({ event, data }),
});
expect(replies).toEqual([]);
expect(events.some((item) => item.event === "permission_request")).toBe(true);
expect(events.some((item) => item.event === "permission_response")).toBe(false);
expect(replies).toEqual([{ requestId: "perm-1", sessionId: "runtime-session-1", reply: "once" }]);
expect(events.some((item) => item.event === "permission_request")).toBe(false);
expect(events.find((item) => item.event === "permission_response")?.data.reply).toBe("once");
});
it("forwards opencode v2 permission requests as SSE payloads", async () => {
@@ -1,48 +0,0 @@
import { afterEach, describe, expect, it } from "bun:test";
import express from "express";
import { type Server } from "node:http";
import { registerExternalInformationBridge } from "../../src/routes/externalInformationBridge.js";
import { ExternalInformationClient } from "../../src/product/external-information-client.js";
import { bindSessionCredential, releaseSessionCredential } from "../../src/product/credential-refresh.js";
import { removeRuntimeSessionContext, setRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
let agent: Server | undefined;
let backend: Server | undefined;
const session = "external-information-bridge-test";
afterEach(async () => {
for (const server of [agent, backend]) if (server) await new Promise<void>((resolve) => server.close(() => resolve()));
releaseSessionCredential(session); removeRuntimeSessionContext(session);
});
describe("external information bridge", () => {
it("calls search and geocode with the owning session and rejects browser, scope and URL injection", async () => {
let calls = 0;
const product = express(); product.use(express.json());
product.post(["/api/v1/web-searches", "/api/v1/geocoding-requests"], (req, res) => {
calls++;
expect(req.header("Authorization")).toBe("Bearer TEST_ONLY");
expect(req.header("X-Project-Id")).toBe("trusted-project");
res.json(req.path.endsWith("web-searches") ? { data: { results: [req.body.query] } } : { status: "0", location: { lon: "116.4", lat: "39.9" } });
});
backend = product.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => backend!.once("listening", resolve));
const app = express(); app.use(express.json());
registerExternalInformationBridge(app, { internalToken: "TEST_ONLY", client: new ExternalInformationClient(`http://127.0.0.1:${(backend.address() as { port: number }).port}`) });
agent = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => agent!.once("listening", resolve));
const base = `http://127.0.0.1:${(agent.address() as { port: number }).port}`;
const post = (name: string, body: unknown, headers: Record<string, string> = {}) => fetch(`${base}/internal/tools/${name}`, { method: "POST", headers: { "Content-Type": "application/json", "x-agent-internal-token": "TEST_ONLY", ...headers }, body: JSON.stringify(body) });
setRuntimeSessionContext({ sessionId: session, clientSessionId: session, actorKey: "owner", projectKey: "trusted-project", traceId: "test", purpose: "ordinary_chat" });
bindSessionCredential(session, { authorization: "Bearer TEST_ONLY", projectId: "trusted-project" });
expect((await post("web-search", { session_id: session, input: { query: "政策" } })).status).toBe(200);
const coordinates = await post("geocode", { session_id: session, input: { keyword: "北京" } });
expect(await coordinates.json()).toMatchObject({ center: [116.4, 39.9] });
expect((await post("web-search", { session_id: session, input: { query: "test" } }, { Origin: "http://browser" })).status).toBe(403);
expect((await post("web-search", { session_id: "missing", input: { query: "test" } })).status).toBe(403);
expect((await post("web-search", { session_id: session, input: { query: "test", url: "http://untrusted" } })).status).toBe(400);
expect((await post("geocode", { session_id: session, input: { keyword: "北京" }, project_id: "other" })).status).toBe(400);
expect(calls).toBe(2);
releaseSessionCredential(session);
expect((await post("geocode", { session_id: session, input: { keyword: "北京" } })).status).toBe(401);
expect(calls).toBe(2);
});
});
+6 -4
View File
@@ -12,7 +12,8 @@ import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
describe("MCP routes", () => {
it("protects the internal policy bridge against browser and context injection", async () => {
const app = express(); app.use(express.json());
registerMcpPolicyBridge(app, { internalToken: "MCP_TEST_ONLY", runtime: { mcpReadonlyTools: () => ["mcp_fixture_read"] } });
let builds = 0;
registerMcpPolicyBridge(app, { internalToken: "MCP_TEST_ONLY", runtime: { mcpReadonlyTools: () => { builds++; return ["mcp_fixture_read"]; } } });
const server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server.once("listening", resolve));
const url = `http://127.0.0.1:${(server.address() as { port: number }).port}/internal/mcp/session-policy`;
@@ -25,7 +26,8 @@ describe("MCP routes", () => {
expect((await post({ session_id: sessionID }, { "Sec-Fetch-Site": "same-origin" })).status).toBe(403);
expect((await post({ session_id: sessionID, project_id: "other" })).status).toBe(400);
expect((await post({ session_id: "missing" })).status).toBe(404);
expect(await (await post({ session_id: sessionID })).json()).toEqual({ mode: "readonly", readonly_tools: ["mcp_fixture_read"] });
expect(await (await post({ session_id: sessionID })).json()).toEqual({ mode: "readonly", readonly_tools: ["mcp_fixture_read"], query_tools: [] });
expect(builds).toBe(1);
} finally {
removeRuntimeSessionContext(sessionID);
await new Promise<void>((resolve) => server.close(() => resolve()));
@@ -61,10 +63,10 @@ describe("MCP routes", () => {
if (failed) throw new Error("SECRET_URL_AND_TOKEN");
return { configuration_errors: [], servers: [{ name: "mcp_fixture", type: "remote", status: "needs_auth", configured_protocol: "auto", tools: [] }] };
} } });
app.use("/api/v1/agent/chat", router);
app.use("/api/v1", router);
const server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server.once("listening", resolve));
const url = `http://127.0.0.1:${(server.address() as { port: number }).port}/api/v1/agent/chat/session/private-session/mcp`;
const url = `http://127.0.0.1:${(server.address() as { port: number }).port}/api/v1/session/private-session/mcp`;
try {
expect((await originalFetch(url)).status).toBe(401);
const headers = { Authorization: "Bearer OWNER", "X-Project-Id": "project" };
+44
View File
@@ -0,0 +1,44 @@
import { afterEach, expect, test } from "bun:test";
import express from "express";
import type { Server } from "node:http";
import { buildChatRouter } from "../../src/routes/chat.js";
const originalFetch = globalThis.fetch;
let server: Server | undefined;
afterEach(async () => {
globalThis.fetch = originalFetch;
if (server) await new Promise<void>((resolve) => server!.close(() => resolve()));
server = undefined;
});
test("suggestions inherit verified identity and project scope and reject unsupported requests before inference", async () => {
globalThis.fetch = (async (_input, init) => {
const headers = new Headers(init?.headers);
if (headers.get("Authorization") === "Bearer denied") return Response.json({ detail: "denied" }, { status: 403 });
return Response.json({ user_id: headers.get("Authorization") === "Bearer user-a" ? "user-a" : "user-b", project_id: headers.get("X-Project-Id"), network: "supply" });
}) as typeof fetch;
let generations = 0;
const app = express();
app.use(express.json());
app.use("/api/v1", buildChatRouter({
runtime: { generateText: async () => { generations += 1; return '{"suggestions":[{"question":"核对供水监测数据的完整性","activity_id":null}]}'; } },
frontendActionCoordinator: {}, learningOrchestrator: {}, memoryStore: {}, sessionBridge: {}, sessionMetadataStore: {}, sessionTranscriptStore: {}, sessionUiStateStore: {},
} 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 test server address");
const post = (user: string, project = "10000000-0000-4000-8000-000000000001", body: unknown = { activities: [] }) => originalFetch(`http://127.0.0.1:${address.port}/api/v1/suggestions`, {
method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${user}`, "X-Project-Id": project, Connection: "close" }, body: JSON.stringify(body),
});
expect((await post("user-a")).status).toBe(200);
expect((await post("user-a")).status).toBe(200);
expect(generations).toBe(1);
expect((await post("user-b")).status).toBe(200);
expect((await post("user-a", "10000000-0000-4000-8000-000000000002")).status).toBe(200);
expect(generations).toBe(3);
expect((await post("denied")).status).toBe(403);
expect((await post("user-a", undefined, { activities: [], model: "unknown/model" })).status).toBe(400);
expect((await post("user-a", undefined, { activities: [], unexpected: "field" })).status).toBe(400);
expect(generations).toBe(3);
});
+185
View File
@@ -0,0 +1,185 @@
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");
});
+65 -17
View File
@@ -6,7 +6,10 @@ 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 { toActorKey, toProjectKey } from "../../src/utils/fileStore.js";
const originalFetch = globalThis.fetch;
@@ -42,7 +45,9 @@ describe("chat session HTTP isolation", () => {
});
}) as typeof fetch;
const sessionMetadataStore = new SessionMetadataStore(metadataDir);
const workspaces = new SessionWorkspaceStore(join(metadataDir, "workspaces"));
const sessionMetadataStore = new SessionMetadataStore(metadataDir, workspaces);
const sessionUiStateStore = new SessionUiStateStore(join(metadataDir, "old-ui"), workspaces);
await sessionMetadataStore.initialize();
const ownerContext = {
actorKey: toActorKey("product:user-a"),
@@ -54,6 +59,14 @@ describe("chat session HTTP isolation", () => {
...ownerContext,
sessionId: "ordinary-session",
});
await sessionUiStateStore.write({ ...ownerContext, sessionId: "ordinary-session" }, {
sessionId: "ordinary-session", messages: [{ type: "text", text: "owner-only answer" }],
});
const personalContext = { actorKey: ownerContext.actorKey, projectKey: `personal-${ownerContext.actorKey}`, userId: "user-a" };
await sessionMetadataStore.ensure({ ...personalContext, sessionId: "personal-session" });
await sessionUiStateStore.write({ ...personalContext, sessionId: "personal-session" }, {
sessionId: "personal-session", messages: [{ type: "text", text: "personal answer" }],
});
await sessionMetadataStore.ensure({
...ownerContext,
sessionId: "execution-session",
@@ -63,32 +76,64 @@ describe("chat session HTTP isolation", () => {
});
const app = express();
const removed: string[] = [];
let failCleanup = true;
const runtime = {
removeSession: async (sessionId: string) => {
if (failCleanup) throw new Error("native service unavailable");
removed.push(sessionId);
},
} as never;
app.use(express.json());
app.use("/api/v1/agent/chat", buildChatRouter({
frontendActionCoordinator: {} as never,
learningOrchestrator: {} as never,
app.use("/api/v1", buildChatRouter({
frontendActionCoordinator: { cancelSession: () => undefined } as never,
learningOrchestrator: { cancelSession: async () => undefined } as never,
memoryStore: {} as never,
resultReferenceResolver: {} as never,
runtime: {} as never,
sessionBridge: {} as never,
runtime,
sessionBridge: new ChatSessionBridge(runtime),
sessionMetadataStore,
sessionTranscriptStore: {} as never,
sessionUiStateStore: {} as never,
sessionUiStateStore,
}));
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("expected HTTP server address");
const ownedOrdinary = await getJson(address.port, "/api/v1/agent/chat/sessions", "token-a", ownerContext.projectId);
const ownedExecution = await getJson(address.port, "/api/v1/agent/chat/sessions?purpose=task_execution", "token-a", ownerContext.projectId);
const otherUser = await getJson(address.port, "/api/v1/agent/chat/sessions?purpose=task_execution", "token-b", ownerContext.projectId);
const otherProject = await getJson(address.port, "/api/v1/agent/chat/sessions?purpose=task_execution", "token-a", "10000000-0000-4000-8000-000000000002");
const ownedOrdinary = await getJson(address.port, "/api/v1/sessions", "token-a", ownerContext.projectId);
const ownedExecution = await getJson(address.port, "/api/v1/sessions?purpose=task_execution", "token-a", ownerContext.projectId);
const otherUser = await getJson(address.port, "/api/v1/sessions?purpose=task_execution", "token-b", ownerContext.projectId);
const otherProject = await getJson(address.port, "/api/v1/sessions?purpose=task_execution", "token-a", "10000000-0000-4000-8000-000000000002");
expect(ownedOrdinary.body.sessions.map((item: { id: string }) => item.id)).toEqual(["ordinary-session"]);
expect(ownedExecution.body.sessions.map((item: { id: string }) => item.id)).toEqual(["execution-session"]);
expect(otherUser.body.sessions).toEqual([]);
expect(otherProject.body.sessions).toEqual([]);
expect((await getJson(address.port, "/api/v1/session/ordinary-session", "token-a", ownerContext.projectId)).body.messages).toEqual([{ type: "text", text: "owner-only answer" }]);
expect((await getJson(address.port, "/api/v1/session/ordinary-session", "token-b", ownerContext.projectId)).status).toBe(404);
expect((await getJson(address.port, "/api/v1/session/ordinary-session", "token-a", "10000000-0000-4000-8000-000000000002")).status).toBe(404);
expect((await getJson(address.port, "/api/v1/session/ordinary-session", "token-a")).status).toBe(404);
const personalHistory = await getJson(address.port, "/api/v1/sessions", "token-a");
expect(personalHistory.body.sessions.map((session: { id: string }) => session.id)).toEqual(["personal-session"]);
expect((await getJson(address.port, "/api/v1/session/personal-session", "token-a")).body.messages).toEqual([{ type: "text", text: "personal answer" }]);
expect((await getJson(address.port, "/api/v1/session/personal-session", "token-b")).status).toBe(404);
expect((await getJson(address.port, "/api/v1/session/personal-session", "token-a", ownerContext.projectId)).status).toBe(404);
expect((await getJson(address.port, "/api/v1/session", "token-a", undefined, { parent_session_id: "ordinary-session" })).status).toBe(404);
expect((await getJson(address.port, "/api/v1/fork", "token-b", ownerContext.projectId, { session_id: "ordinary-session", keep_message_count: 2 })).status).toBe(404);
expect((await getJson(address.port, "/api/v1/session", "token-b", ownerContext.projectId, { session_id: "ordinary-session" })).status).toBe(404);
expect((await getJson(address.port, "/api/v1/session", "token-b", ownerContext.projectId, { session_id: "unknown-session" })).status).toBe(404);
const path = "/api/v1/session/ordinary-session";
expect((await getJson(address.port, path, "token-b", ownerContext.projectId, undefined, "DELETE")).status).toBe(204);
expect((await getJson(address.port, path, "token-a", undefined, undefined, "DELETE")).status).toBe(204);
expect((await sessionUiStateStore.read({ ...ownerContext, sessionId: "ordinary-session" }))?.messages).toHaveLength(1);
expect((await getJson(address.port, path, "token-a", ownerContext.projectId, undefined, "DELETE")).status).toBe(503);
expect((await getJson(address.port, path, "token-a", ownerContext.projectId)).status).toBe(404);
expect((await getJson(address.port, "/api/v1/sessions", "token-a", ownerContext.projectId)).body.sessions).toEqual([]);
failCleanup = false;
expect((await getJson(address.port, path, "token-a", ownerContext.projectId, undefined, "DELETE")).status).toBe(204);
expect(removed).toEqual(["ordinary-session"]);
expect(await sessionUiStateStore.read({ ...ownerContext, sessionId: "ordinary-session" })).toBeNull();
expect((await getJson(address.port, "/api/v1/session/personal-session", "token-a")).status).toBe(200);
});
});
@@ -96,16 +141,19 @@ const getJson = (
port: number,
path: string,
token: string,
projectId: string,
projectId?: string,
body?: Record<string, unknown>,
method?: string,
) => new Promise<{ status: number; body: Record<string, any> }>((resolve, reject) => {
const request = httpRequest({
hostname: "127.0.0.1",
port,
path,
method: "GET",
method: method ?? (body ? "POST" : "GET"),
headers: {
Authorization: `Bearer ${token}`,
"X-Project-Id": projectId,
...(projectId ? { "X-Project-Id": projectId } : {}),
...(body ? { "Content-Type": "application/json" } : {}),
},
}, (response) => {
const chunks: Buffer[] = [];
@@ -113,10 +161,10 @@ const getJson = (
response.on("end", () => {
resolve({
status: response.statusCode ?? 0,
body: JSON.parse(Buffer.concat(chunks).toString("utf8")),
body: JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"),
});
});
});
request.on("error", reject);
request.end();
request.end(body ? JSON.stringify(body) : undefined);
});
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "bun:test";
import express from "express";
import { registerTaskAgentBridge } from "../../src/routes/task-agent-bridge.js";
import { registerTaskPublicationBridge } from "../../src/routes/task-publication-bridge.js";
import { removeRuntimeSessionContext, setRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
import { TASK_AGENT, TASK_READER_AGENT } from "../../src/runtime/task-agent-policy.js";
describe("task reader bridge", () => {
it("uses parent identity only for reads, revokes on run end and forbids child publication", async () => {
const id = "bridge-task-parent";
setRuntimeSessionContext({ sessionId: id, clientSessionId: id, actorKey: "a", projectKey: "p",
projectId: "project-1", taskId: "task-1", traceId: "test", purpose: "task_execution" });
let active = true;
const app = express(); app.use(express.json());
const reader = registerTaskAgentBridge(app, { internalToken: "TEST_ONLY", hasActiveRun: () => active,
getSession: async (sessionId) => ({ id: sessionId, parentID: sessionId === id ? undefined : id,
agent: sessionId === id ? TASK_AGENT : TASK_READER_AGENT, location: { directory: "/workspace" } }),
});
registerTaskPublicationBridge(app, { internalToken: "TEST_ONLY" });
const server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server.once("listening", resolve));
const base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
const post = (path: string, token = "TEST_ONLY") => fetch(`${base}/internal/operational-task/${path}`, {
method: "POST", headers: { "Content-Type": "application/json", "x-agent-internal-token": token }, body: JSON.stringify({ session_id: "child" }),
});
try {
expect(await (await post("agent-policy")).json()).toEqual({ role: "reader" });
expect((await post("agent-policy", "wrong")).status).toBe(403);
expect((await reader("child"))?.runtimeSessionId).toBe(id);
for (const kind of ["artifacts", "presentation"]) expect((await post(kind)).status).toBe(403);
active = false;
expect(await reader("child")).toBeNull();
expect(await (await post("agent-policy")).json()).toEqual({ role: "denied" });
} finally {
removeRuntimeSessionContext(id);
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
});
@@ -0,0 +1,71 @@
import { describe, expect, it } from "bun:test";
import express from "express";
import type { OperationalTaskClient } from "../../src/product/operational-task-client.js";
import { registerTaskPublicationBridge } from "../../src/routes/task-publication-bridge.js";
import { removeRuntimeSessionContext, setRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
describe("task publication bridge", () => {
it("requires a trusted execution context and injects task identity without browser capabilities", async () => {
const taskId = "50000000-0000-4000-8000-000000000001";
const requestId = "60000000-0000-4000-8000-000000000001";
const contexts = ["task-publication-execution", "task-publication-collaboration", "task-publication-ordinary"];
const published: Array<{ kind: string; args: unknown[] }> = [];
contexts.forEach((sessionId, index) => setRuntimeSessionContext({
sessionId, clientSessionId: `client-${index}`, actorKey: "actor-1", projectKey: "project-1", traceId: "test",
taskId, purpose: ["task_execution", "task_collaboration", "ordinary_chat"][index] as "task_execution",
}));
const app = express();
app.use(express.json());
registerTaskPublicationBridge(app, { internalToken: "internal-test", client: {
publishArtifacts: async (...args: unknown[]) => {
published.push({ kind: "artifacts", args });
return { progress_revision: 1 };
},
publishPresentation: async (...args: unknown[]) => {
published.push({ kind: "presentation", args });
return { recorded: true, event_seq: 8, run_id: "55000000-0000-4000-8000-000000000001", execution_generation: 1 };
},
} as unknown as Pick<OperationalTaskClient, "publishArtifacts" | "publishPresentation"> });
const server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server.once("listening", resolve));
const base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
const post = (kind: string, body: unknown, extraHeaders: Record<string, string> = {}) => fetch(`${base}/internal/operational-task/${kind}`, {
method: "POST", headers: { "Content-Type": "application/json", "x-agent-internal-token": "internal-test", ...extraHeaders },
body: JSON.stringify(body),
});
const progress = { summary: "核对实际资料", diagram: null };
const command = { action: "highlight_features", layer: "pipes", feature_ids: ["P-1"], effect: "pulse" };
try {
const body = { session_id: contexts[0], request_id: requestId, command };
expect((await post("presentation", body, { "x-agent-internal-token": "wrong" })).status).toBe(403);
expect((await post("presentation", body, { Origin: "http://browser.test" })).status).toBe(403);
expect((await post("presentation", { ...body, session_id: "unknown" })).status).toBe(403);
for (const sessionId of contexts.slice(1)) {
expect((await post("presentation", { ...body, session_id: sessionId })).status).toBe(403);
expect((await post("artifacts", { session_id: sessionId, request_id: requestId, progress })).status).toBe(403);
}
expect((await post("presentation", { ...body, run_id: "model-supplied-run" })).status).toBe(400);
expect((await post("presentation", { ...body, command: {
action: "set_overlay", overlay_id: "o1", title: "位置", mode: "geometry", evidence_id: "e1", data: { coordinates: [1, 2] },
} })).status).toBe(400);
expect(published).toHaveLength(0);
const result = await post("presentation", body);
expect(result.status).toBe(200);
expect(await result.json()).toMatchObject({ recorded: true, event_seq: 8 });
expect(published[0]).toEqual({ kind: "presentation", args: [contexts[0], taskId, {
requestId, sourceSessionId: "client-0", command,
}] });
expect((await post("artifacts", { session_id: contexts[0], request_id: requestId, progress })).status).toBe(200);
expect(published[1]?.args).toEqual([contexts[0], taskId, {
requestId, sourceSessionId: "client-0", evidence: undefined, executionPlan: undefined, report: undefined, progress,
}]);
const report = { title: "报告", current_conclusion: "核验结束", conclusion_boundary: "授权数据范围",
stage: "final", outcome: "concluded", stop_reason: null, data_gaps: [] };
expect((await post("artifacts", { session_id: contexts[1], request_id: requestId, report })).status).toBe(200);
} finally {
contexts.forEach(removeRuntimeSessionContext);
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
});