125 lines
4.3 KiB
TypeScript
125 lines
4.3 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import * as harness from "../src/index.js";
|
|
|
|
type Resolution = { approvalId: string; status: string };
|
|
type Request = {
|
|
approvalId: string;
|
|
sessionId: string;
|
|
runId: string;
|
|
toolCallId: string;
|
|
toolName: string;
|
|
arguments: unknown;
|
|
riskLevel: "low" | "medium" | "high";
|
|
};
|
|
type Broker = {
|
|
request(input: Request, signal?: AbortSignal): Promise<Resolution>;
|
|
approve(approvalId: string): Resolution;
|
|
deny(approvalId: string): Resolution;
|
|
cancelForSession(sessionId: string): number;
|
|
cancelForRun(runId: string): number;
|
|
};
|
|
|
|
function createBroker(timeoutMs = 1_000): {
|
|
broker: Broker;
|
|
store: { listAfter(sessionId: string): { events: Array<{ type: string; payload: unknown }> } };
|
|
} {
|
|
const Store = Reflect.get(harness, "InMemoryEventStore") as new () => {
|
|
listAfter(sessionId: string): { events: Array<{ type: string; payload: unknown }> };
|
|
};
|
|
const Broker = Reflect.get(harness, "InMemoryApprovalBroker") as unknown as
|
|
| (new (options: Record<string, unknown>) => Broker)
|
|
| undefined;
|
|
expect(Broker, "InMemoryApprovalBroker must be exported").toBeDefined();
|
|
const store = new Store();
|
|
return { broker: new Broker!({ eventStore: store, timeoutMs }), store };
|
|
}
|
|
|
|
function request(overrides: Partial<Request> = {}): Request {
|
|
return {
|
|
approvalId: "ap_1",
|
|
sessionId: "ses_1",
|
|
runId: "run_1",
|
|
toolCallId: "call_1",
|
|
toolName: "save_report_draft",
|
|
arguments: { body: "report", apiKey: "never-log-me" },
|
|
riskLevel: "medium",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe("InMemoryApprovalBroker", () => {
|
|
it("resolves an approved request and treats the same repeated decision as idempotent", async () => {
|
|
const { broker, store } = createBroker();
|
|
const waiting = broker.request(request());
|
|
|
|
const first = broker.approve("ap_1");
|
|
const second = broker.approve("ap_1");
|
|
|
|
await expect(waiting).resolves.toEqual({ approvalId: "ap_1", status: "approved" });
|
|
expect(second).toEqual(first);
|
|
expect(store.listAfter("ses_1").events.map((event) => event.type)).toEqual([
|
|
"approval.required",
|
|
"approval.resolved",
|
|
]);
|
|
expect(JSON.stringify(store.listAfter("ses_1"))).not.toContain("never-log-me");
|
|
});
|
|
|
|
it("rejects a conflicting second decision", async () => {
|
|
const { broker } = createBroker();
|
|
const waiting = broker.request(request());
|
|
broker.approve("ap_1");
|
|
await waiting;
|
|
|
|
expect(() => broker.deny("ap_1")).toThrowError(
|
|
expect.objectContaining({ code: "APPROVAL_ALREADY_RESOLVED" }),
|
|
);
|
|
});
|
|
|
|
it("expires pending approval once when its deadline passes", async () => {
|
|
vi.useFakeTimers();
|
|
const { broker, store } = createBroker(50);
|
|
const waiting = broker.request(request());
|
|
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
|
|
await expect(waiting).resolves.toEqual({ approvalId: "ap_1", status: "expired" });
|
|
expect(store.listAfter("ses_1").events.map((event) => event.type)).toEqual([
|
|
"approval.required",
|
|
"approval.resolved",
|
|
]);
|
|
});
|
|
|
|
it("cancels on abort and removes the abort listener after resolution", async () => {
|
|
const { broker, store } = createBroker();
|
|
const controller = new AbortController();
|
|
const waiting = broker.request(request(), controller.signal);
|
|
|
|
controller.abort();
|
|
|
|
await expect(waiting).resolves.toEqual({ approvalId: "ap_1", status: "cancelled" });
|
|
expect(store.listAfter("ses_1").events).toHaveLength(2);
|
|
controller.abort();
|
|
expect(store.listAfter("ses_1").events).toHaveLength(2);
|
|
});
|
|
|
|
it("cancels all pending approvals for a session or run", async () => {
|
|
const { broker } = createBroker();
|
|
const first = broker.request(request());
|
|
const second = broker.request(request({ approvalId: "ap_2", runId: "run_2", toolCallId: "call_2" }));
|
|
const third = broker.request(request({ approvalId: "ap_3", sessionId: "ses_2", toolCallId: "call_3" }));
|
|
|
|
expect(broker.cancelForRun("run_2")).toBe(1);
|
|
expect(broker.cancelForSession("ses_1")).toBe(1);
|
|
expect(broker.cancelForSession("ses_2")).toBe(1);
|
|
await expect(Promise.all([first, second, third])).resolves.toEqual([
|
|
{ approvalId: "ap_1", status: "cancelled" },
|
|
{ approvalId: "ap_2", status: "cancelled" },
|
|
{ approvalId: "ap_3", status: "cancelled" },
|
|
]);
|
|
});
|
|
});
|