Files
EvoScientist-WebUI/src/app/api/conversations/[threadId]/runs/route.test.ts
T
m4 fe982f7f95
CI / macos-latest / Node 20 (push) Has been cancelled
CI / ubuntu-latest / Node 20 (push) Has been cancelled
CI / windows-latest / Node 20 (push) Has been cancelled
feat: add workspace isolation and administration UI
2026-07-19 12:17:18 +08:00

257 lines
7.5 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import type { NextRequest } from "next/server";
const mocks = vi.hoisted(() => ({
getActiveDeployment: vi.fn(),
assertCutoverIsIdle: vi.fn(),
assertThreadMatchesScope: vi.fn(),
}));
vi.mock("server-only", () => ({}));
vi.mock("@/lib/server/activeDeployment", () => ({
getActiveDeployment: mocks.getActiveDeployment,
assertCutoverIsIdle: mocks.assertCutoverIsIdle,
CutoverInProgressError: class CutoverInProgressError extends Error {},
}));
vi.mock("@/lib/server/conversationResponse", () => ({
assertThreadMatchesScope: mocks.assertThreadMatchesScope,
sanitizeConversationData: <T>(value: T) => value,
}));
const routes = await import("./route");
const context = { params: Promise.resolve({ threadId: "thread-a" }) };
function legacyDeployment() {
return {
assistantId: "EvoScientist",
isolationMode: "legacy",
scopeRegistry: null,
threadClient: {
threads: { get: vi.fn().mockResolvedValue({ thread_id: "thread-a" }) },
runs: {
create: vi
.fn()
.mockResolvedValue({ run_id: "run-a", status: "pending" }),
get: vi.fn(),
list: vi.fn().mockResolvedValue([]),
},
},
};
}
function scopedDeployment() {
const scope = {
deployment_id: "deployment-a",
scope_id: "00000000-0000-4000-8000-000000000010",
state: "active",
revision: 1,
};
const reserveRun = vi
.fn()
.mockResolvedValueOnce({ run_owner_id: "owner-initial", run_id: null })
.mockResolvedValueOnce({ run_owner_id: "owner-resume", run_id: null });
const bindRun = vi.fn().mockResolvedValue({});
return {
assistantId: "EvoScientist",
isolationMode: "required" as const,
scopeRegistry: {
getByThread: vi.fn().mockResolvedValue(scope),
reserveRun,
bindRun,
transition: vi.fn(),
},
threadClient: {
threads: {
get: vi.fn().mockResolvedValue({
thread_id: "thread-a",
metadata: { workspace_scope_id: scope.scope_id },
}),
update: vi.fn(),
},
runs: {
create: vi
.fn()
.mockResolvedValueOnce({ run_id: "run-initial", status: "pending" })
.mockResolvedValueOnce({ run_id: "run-resume", status: "pending" }),
get: vi.fn(),
list: vi.fn().mockResolvedValue([]),
},
},
};
}
describe("conversation run route", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("keeps legacy BFF runs operational without scope metadata", async () => {
const deployment = legacyDeployment();
mocks.getActiveDeployment.mockResolvedValue(deployment);
const request = new Request(
"http://localhost/api/conversations/thread-a/runs",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
turn_id: "turn-a",
input: { messages: [{ type: "human", content: "hello" }] },
review_mode: "manual",
}),
}
);
const response = await routes.POST(
request as unknown as NextRequest,
context
);
expect(response.status).toBe(201);
expect(await response.json()).toMatchObject({
threadId: "thread-a",
runId: "run-a",
turnId: "turn-a",
});
expect(deployment.threadClient.runs.create).toHaveBeenCalledWith(
"thread-a",
"EvoScientist",
expect.objectContaining({
metadata: expect.not.objectContaining({
workspace_scope_id: expect.anything(),
}),
config: { configurable: { review_mode: "manual" } },
})
);
});
it("continues to fail closed in required mode without a scope service", async () => {
const deployment = {
...legacyDeployment(),
isolationMode: "required" as const,
};
mocks.getActiveDeployment.mockResolvedValue(deployment);
const request = new Request(
"http://localhost/api/conversations/thread-a/runs",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input: { messages: [] } }),
}
);
const response = await routes.POST(
request as unknown as NextRequest,
context
);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
error: "Workspace scope service is unavailable.",
});
});
it("rejects optional-mode runs when the scope service is unavailable", async () => {
const deployment = {
...legacyDeployment(),
isolationMode: "optional" as const,
};
mocks.getActiveDeployment.mockResolvedValue(deployment);
const request = new Request(
"http://localhost/api/conversations/thread-a/runs",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input: { messages: [] } }),
}
);
const response = await routes.POST(
request as unknown as NextRequest,
context
);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
error: "Workspace scope service is unavailable.",
});
expect(deployment.threadClient.runs.create).not.toHaveBeenCalled();
});
it("creates a distinct run request for an approval resume in the same turn", async () => {
const deployment = scopedDeployment();
mocks.getActiveDeployment.mockResolvedValue(deployment);
const turnId = "00000000-0000-4000-8000-000000000001";
const initialRequestId = "00000000-0000-4000-8000-000000000002";
const resumeRequestId = "00000000-0000-4000-8000-000000000003";
const initial = new Request(
"http://localhost/api/conversations/thread-a/runs",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
turn_id: turnId,
run_request_id: initialRequestId,
input: { messages: [{ type: "human", content: "hello" }] },
review_mode: "manual",
}),
}
);
const resume = new Request(
"http://localhost/api/conversations/thread-a/runs",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
turn_id: turnId,
run_request_id: resumeRequestId,
interrupt_key: "interrupt-a",
command: { resume: { decisions: [{ type: "approve" }] } },
review_mode: "manual",
}),
}
);
const initialResponse = await routes.POST(
initial as unknown as NextRequest,
context
);
const resumeResponse = await routes.POST(
resume as unknown as NextRequest,
context
);
expect(initialResponse.status).toBe(201);
expect(resumeResponse.status).toBe(201);
expect(deployment.scopeRegistry.reserveRun).toHaveBeenNthCalledWith(
1,
"00000000-0000-4000-8000-000000000010",
expect.objectContaining({
turnId,
runRequestId: initialRequestId,
interruptKey: null,
})
);
expect(deployment.scopeRegistry.reserveRun).toHaveBeenNthCalledWith(
2,
"00000000-0000-4000-8000-000000000010",
expect.objectContaining({
turnId,
runRequestId: resumeRequestId,
interruptKey: "interrupt-a",
})
);
expect(deployment.scopeRegistry.bindRun).toHaveBeenNthCalledWith(
1,
"00000000-0000-4000-8000-000000000010",
initialRequestId,
"run-initial"
);
expect(deployment.scopeRegistry.bindRun).toHaveBeenNthCalledWith(
2,
"00000000-0000-4000-8000-000000000010",
resumeRequestId,
"run-resume"
);
});
});