206 lines
7.2 KiB
TypeScript
206 lines
7.2 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { WebSocketRequestError } from "@/lib/ws/request-error";
|
|
|
|
const getWebSocketClientMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("@/lib/ws/connection", () => ({
|
|
getWebSocketClient: getWebSocketClientMock,
|
|
}));
|
|
|
|
import { QueueAdmissionError, QueueFullError, queueMessage } from "./queue-api";
|
|
|
|
const baseParams = {
|
|
session_id: "session-1",
|
|
session_incarnation_id: "incarnation-1",
|
|
task_id: "task-1",
|
|
content: "queued prompt",
|
|
client_queue_id: "client-queue-1",
|
|
};
|
|
const QUEUE_ADD_ACTION = "message.queue.add";
|
|
const QUEUE_GET_ACTION = "message.queue.get";
|
|
const MESSAGE_LIST_ACTION = "message.list";
|
|
const ATTACHMENT_ID = "file-1";
|
|
const QUEUE_TIMEOUT_ERROR = "WebSocket request timed out";
|
|
|
|
beforeEach(() => {
|
|
getWebSocketClientMock.mockReset();
|
|
});
|
|
|
|
describe("identified queue admission", () => {
|
|
it("forwards an ordinary admission with the ten second budget", async () => {
|
|
const request = vi.fn().mockResolvedValue({ id: baseParams.client_queue_id });
|
|
getWebSocketClientMock.mockReturnValue({ request });
|
|
|
|
await queueMessage(baseParams);
|
|
|
|
expect(request).toHaveBeenCalledWith(QUEUE_ADD_ACTION, baseParams, 10000);
|
|
});
|
|
|
|
it("uses the thirty second budget for attachment admission", async () => {
|
|
const request = vi.fn().mockResolvedValue({ id: baseParams.client_queue_id });
|
|
getWebSocketClientMock.mockReturnValue({ request });
|
|
const params = {
|
|
...baseParams,
|
|
attachments: [{ type: "resource", mime_type: "text/plain", attachment_id: ATTACHMENT_ID }],
|
|
};
|
|
|
|
await queueMessage(params);
|
|
|
|
expect(request).toHaveBeenCalledWith(QUEUE_ADD_ACTION, params, 30000);
|
|
});
|
|
|
|
it("retries an uncertain ordinary admission once with the same budget and payload", async () => {
|
|
let adds = 0;
|
|
const request = vi.fn(async (action: string, ..._args: unknown[]) => {
|
|
if (action === QUEUE_ADD_ACTION) {
|
|
adds++;
|
|
if (adds === 1) throw new Error(QUEUE_TIMEOUT_ERROR);
|
|
return { id: baseParams.client_queue_id };
|
|
}
|
|
if (action === QUEUE_GET_ACTION) return { entries: [], count: 0, max: 10 };
|
|
if (action === MESSAGE_LIST_ACTION) return { messages: [], has_more: false };
|
|
return undefined;
|
|
});
|
|
getWebSocketClientMock.mockReturnValue({
|
|
request,
|
|
getStatus: () => "connected",
|
|
});
|
|
|
|
await expect(queueMessage(baseParams)).resolves.toEqual({ id: baseParams.client_queue_id });
|
|
|
|
const admissions = request.mock.calls.filter(([action]) => action === QUEUE_ADD_ACTION);
|
|
expect(admissions).toEqual([
|
|
[QUEUE_ADD_ACTION, baseParams, 10000],
|
|
[QUEUE_ADD_ACTION, baseParams, 10000],
|
|
]);
|
|
});
|
|
|
|
it("uses the attachment budget for an uncertain retry", async () => {
|
|
const params = {
|
|
...baseParams,
|
|
attachments: [{ type: "resource", mime_type: "text/plain", attachment_id: ATTACHMENT_ID }],
|
|
};
|
|
let adds = 0;
|
|
const request = vi.fn(async (action: string, ..._args: unknown[]) => {
|
|
if (action === QUEUE_ADD_ACTION) {
|
|
adds++;
|
|
if (adds === 1) throw new Error(QUEUE_TIMEOUT_ERROR);
|
|
return { id: baseParams.client_queue_id };
|
|
}
|
|
if (action === QUEUE_GET_ACTION) return { entries: [], count: 0, max: 10 };
|
|
if (action === MESSAGE_LIST_ACTION) return { messages: [], has_more: false };
|
|
return undefined;
|
|
});
|
|
getWebSocketClientMock.mockReturnValue({
|
|
request,
|
|
getStatus: () => "connected",
|
|
});
|
|
|
|
await expect(queueMessage(params)).resolves.toEqual({ id: baseParams.client_queue_id });
|
|
|
|
const admissions = request.mock.calls.filter(([action]) => action === QUEUE_ADD_ACTION);
|
|
expect(admissions[0]?.[2]).toBe(30000);
|
|
expect(admissions[1]?.[2]).toBe(30000);
|
|
});
|
|
|
|
it("does not scan older transcript pages for an ordinary admission", async () => {
|
|
let adds = 0;
|
|
const request = vi.fn(async (action: string, ..._args: unknown[]) => {
|
|
if (action === QUEUE_ADD_ACTION) {
|
|
adds++;
|
|
if (adds === 1) throw new Error(QUEUE_TIMEOUT_ERROR);
|
|
return { id: baseParams.client_queue_id };
|
|
}
|
|
if (action === QUEUE_GET_ACTION) return { entries: [], count: 0, max: 10 };
|
|
if (action === MESSAGE_LIST_ACTION) {
|
|
return { messages: [], has_more: true, cursor: "older-page" };
|
|
}
|
|
return undefined;
|
|
});
|
|
getWebSocketClientMock.mockReturnValue({ request, getStatus: () => "connected" });
|
|
|
|
await queueMessage(baseParams);
|
|
|
|
expect(request).not.toHaveBeenCalledWith(
|
|
MESSAGE_LIST_ACTION,
|
|
expect.objectContaining({ before: "older-page" }),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("post-dispatch queue admission", () => {
|
|
it("reconciles an accepted admission from ordinary transcript provenance", async () => {
|
|
let admissions = 0;
|
|
let transcriptReads = 0;
|
|
const request = vi.fn(async (action: string, ..._args: unknown[]) => {
|
|
if (action === QUEUE_ADD_ACTION) {
|
|
admissions++;
|
|
throw new Error(QUEUE_TIMEOUT_ERROR);
|
|
}
|
|
if (action === QUEUE_GET_ACTION) return { entries: [], count: 0, max: 10 };
|
|
if (action === MESSAGE_LIST_ACTION) {
|
|
transcriptReads++;
|
|
if (transcriptReads < 2) return { messages: [], has_more: false };
|
|
return {
|
|
messages: [
|
|
{
|
|
id: "transcript-1",
|
|
session_id: baseParams.session_id,
|
|
task_id: baseParams.task_id,
|
|
author_type: "user",
|
|
content: baseParams.content,
|
|
type: "message",
|
|
created_at: "2026-09-14T12:00:00Z",
|
|
metadata: { queue_admission_ids: [baseParams.client_queue_id] },
|
|
},
|
|
],
|
|
has_more: false,
|
|
};
|
|
}
|
|
return undefined;
|
|
});
|
|
getWebSocketClientMock.mockReturnValue({ request, getStatus: () => "connected" });
|
|
|
|
await expect(queueMessage(baseParams)).resolves.toMatchObject({
|
|
id: baseParams.client_queue_id,
|
|
content: baseParams.content,
|
|
queued_at: "2026-09-14T12:00:00Z",
|
|
});
|
|
expect(admissions).toBe(2);
|
|
expect(transcriptReads).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("identified queue admission errors", () => {
|
|
it("maps deterministic queue rejection codes to typed errors", async () => {
|
|
const cases = [
|
|
["queue_full", QueueFullError],
|
|
["queue_id_conflict", QueueAdmissionError],
|
|
["queue_session_unavailable", QueueAdmissionError],
|
|
["queue_admission_unavailable", QueueAdmissionError],
|
|
["VALIDATION_ERROR", QueueAdmissionError],
|
|
["NOT_FOUND", QueueAdmissionError],
|
|
] as const;
|
|
for (const [code, errorType] of cases) {
|
|
const request = vi.fn().mockRejectedValue(new WebSocketRequestError("rejected", code));
|
|
getWebSocketClientMock.mockReturnValue({ request });
|
|
|
|
await expect(queueMessage(baseParams)).rejects.toBeInstanceOf(errorType);
|
|
}
|
|
});
|
|
|
|
it("does not retry an explicit rejection", async () => {
|
|
const request = vi
|
|
.fn()
|
|
.mockRejectedValue(new WebSocketRequestError("invalid queue request", "VALIDATION_ERROR"));
|
|
getWebSocketClientMock.mockReturnValue({ request });
|
|
|
|
await expect(queueMessage(baseParams)).rejects.toMatchObject({
|
|
name: "QueueAdmissionError",
|
|
code: "validation",
|
|
});
|
|
expect(request).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|