feat(harness): add server-authoritative approvals
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user