257 lines
7.5 KiB
TypeScript
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"
|
|
);
|
|
});
|
|
});
|