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:
@@ -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()));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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("权限拒绝和接口错误不能证明数据缺失");
|
||||
|
||||
@@ -96,6 +96,7 @@ describe("generateSessionTitle", () => {
|
||||
},
|
||||
],
|
||||
abortSession: async () => undefined,
|
||||
removeSession: async () => undefined,
|
||||
} as unknown as OpencodeRuntimeAdapter;
|
||||
|
||||
const title = await generateSessionTitle(runtime, {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user