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:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user