@consensus-tools/universal 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +451 -0
  3. package/dist/__tests__/defaults.test.d.ts +2 -0
  4. package/dist/__tests__/defaults.test.d.ts.map +1 -0
  5. package/dist/__tests__/defaults.test.js +55 -0
  6. package/dist/__tests__/defaults.test.js.map +1 -0
  7. package/dist/__tests__/fail-policy.test.d.ts +2 -0
  8. package/dist/__tests__/fail-policy.test.d.ts.map +1 -0
  9. package/dist/__tests__/fail-policy.test.js +80 -0
  10. package/dist/__tests__/fail-policy.test.js.map +1 -0
  11. package/dist/__tests__/frameworks.test.d.ts +2 -0
  12. package/dist/__tests__/frameworks.test.d.ts.map +1 -0
  13. package/dist/__tests__/frameworks.test.js +86 -0
  14. package/dist/__tests__/frameworks.test.js.map +1 -0
  15. package/dist/__tests__/logger.test.d.ts +2 -0
  16. package/dist/__tests__/logger.test.d.ts.map +1 -0
  17. package/dist/__tests__/logger.test.js +77 -0
  18. package/dist/__tests__/logger.test.js.map +1 -0
  19. package/dist/__tests__/resolve.test.d.ts +2 -0
  20. package/dist/__tests__/resolve.test.d.ts.map +1 -0
  21. package/dist/__tests__/resolve.test.js +71 -0
  22. package/dist/__tests__/resolve.test.js.map +1 -0
  23. package/dist/__tests__/wrap.test.d.ts +2 -0
  24. package/dist/__tests__/wrap.test.d.ts.map +1 -0
  25. package/dist/__tests__/wrap.test.js +90 -0
  26. package/dist/__tests__/wrap.test.js.map +1 -0
  27. package/dist/defaults.d.ts +20 -0
  28. package/dist/defaults.d.ts.map +1 -0
  29. package/dist/defaults.js +48 -0
  30. package/dist/defaults.js.map +1 -0
  31. package/dist/errors.d.ts +23 -0
  32. package/dist/errors.d.ts.map +1 -0
  33. package/dist/errors.js +31 -0
  34. package/dist/errors.js.map +1 -0
  35. package/dist/index.d.ts +38 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +239 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/logger.d.ts +12 -0
  40. package/dist/logger.d.ts.map +1 -0
  41. package/dist/logger.js +55 -0
  42. package/dist/logger.js.map +1 -0
  43. package/dist/resolve.d.ts +9 -0
  44. package/dist/resolve.d.ts.map +1 -0
  45. package/dist/resolve.js +25 -0
  46. package/dist/resolve.js.map +1 -0
  47. package/dist/types.d.ts +35 -0
  48. package/dist/types.d.ts.map +1 -0
  49. package/dist/types.js +2 -0
  50. package/dist/types.js.map +1 -0
  51. package/package.json +82 -0
  52. package/src/__tests__/defaults.test.ts +71 -0
  53. package/src/__tests__/fail-policy.test.ts +107 -0
  54. package/src/__tests__/frameworks.test.ts +106 -0
  55. package/src/__tests__/logger.test.ts +93 -0
  56. package/src/__tests__/resolve.test.ts +80 -0
  57. package/src/__tests__/wrap.test.ts +110 -0
  58. package/src/consensus-llm.test.ts +260 -0
  59. package/src/defaults.ts +124 -0
  60. package/src/errors.ts +35 -0
  61. package/src/index.ts +386 -0
  62. package/src/logger.ts +65 -0
  63. package/src/persona-reviewer-factory.ts +387 -0
  64. package/src/reputation-manager.test.ts +131 -0
  65. package/src/reputation-manager.ts +168 -0
  66. package/src/resolve.ts +30 -0
  67. package/src/risk-tiers.test.ts +36 -0
  68. package/src/risk-tiers.ts +49 -0
  69. package/src/types.ts +127 -0
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ DEFAULTS,
4
+ DEFAULT_GUARD,
5
+ DEFAULT_POLICY,
6
+ DEFAULT_PERSONA_COUNT,
7
+ DEFAULT_PERSONA_TRIO,
8
+ policyToStrategy,
9
+ } from "../defaults.js";
10
+ import { ConfigError } from "../errors.js";
11
+
12
+ describe("DEFAULTS", () => {
13
+ it("provides full defaults matching expected values", () => {
14
+ expect(DEFAULTS.policy).toBe("majority");
15
+ expect(DEFAULTS.guards).toEqual(["agent_action"]);
16
+ expect(DEFAULTS.failPolicy).toBe("closed");
17
+ expect(DEFAULTS.storage).toBe("memory");
18
+ expect(DEFAULTS.logger).toBe(true);
19
+ });
20
+
21
+ it("exports expected guard and policy constants", () => {
22
+ expect(DEFAULT_GUARD).toBe("agent_action");
23
+ expect(DEFAULT_POLICY).toBe("majority");
24
+ expect(DEFAULT_PERSONA_COUNT).toBe(3);
25
+ expect(DEFAULT_PERSONA_TRIO).toEqual(["security", "compliance", "user-impact"]);
26
+ });
27
+
28
+ it("merges partial config with defaults", () => {
29
+ const partial = { policy: "unanimous", failPolicy: "open" as const };
30
+ const merged = { ...DEFAULTS, ...partial };
31
+ expect(merged.policy).toBe("unanimous");
32
+ expect(merged.failPolicy).toBe("open");
33
+ // Untouched defaults remain
34
+ expect(merged.guards).toEqual(["agent_action"]);
35
+ expect(merged.storage).toBe("memory");
36
+ expect(merged.logger).toBe(true);
37
+ });
38
+ });
39
+
40
+ describe("policyToStrategy", () => {
41
+ it("maps 'majority' to { strategy: 'majority' }", () => {
42
+ expect(policyToStrategy("majority")).toEqual({ strategy: "majority" });
43
+ });
44
+
45
+ it("maps 'supermajority' to { strategy: 'threshold', threshold: 0.67 }", () => {
46
+ expect(policyToStrategy("supermajority")).toEqual({ strategy: "threshold", threshold: 0.67 });
47
+ });
48
+
49
+ it("maps 'unanimous' to { strategy: 'unanimous' }", () => {
50
+ expect(policyToStrategy("unanimous")).toEqual({ strategy: "unanimous" });
51
+ });
52
+
53
+ it("maps 'threshold:0.8' to { strategy: 'threshold', threshold: 0.8 }", () => {
54
+ expect(policyToStrategy("threshold:0.8")).toEqual({ strategy: "threshold", threshold: 0.8 });
55
+ });
56
+
57
+ it("throws ConfigError for unknown policy name", () => {
58
+ expect(() => policyToStrategy("bogus")).toThrow(ConfigError);
59
+ expect(() => policyToStrategy("bogus")).toThrow('Unknown policy "bogus"');
60
+ });
61
+
62
+ it("throws ConfigError for invalid threshold value", () => {
63
+ expect(() => policyToStrategy("threshold:abc")).toThrow(ConfigError);
64
+ expect(() => policyToStrategy("threshold:abc")).toThrow("Invalid threshold value");
65
+ });
66
+
67
+ it("throws ConfigError for threshold out of range", () => {
68
+ expect(() => policyToStrategy("threshold:1.5")).toThrow(ConfigError);
69
+ expect(() => policyToStrategy("threshold:-0.1")).toThrow(ConfigError);
70
+ });
71
+ });
@@ -0,0 +1,107 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import type { DecisionResult } from "@consensus-tools/wrapper";
3
+ import { ConsensusBlockedError } from "../errors.js";
4
+
5
+ // Mock the wrapper's consensus function and guards' createGuardTemplate
6
+ const mockWrapped = vi.fn<(...args: unknown[]) => Promise<DecisionResult<unknown>>>();
7
+
8
+ vi.mock("@consensus-tools/wrapper", () => ({
9
+ consensus: vi.fn(() => mockWrapped),
10
+ }));
11
+
12
+ vi.mock("@consensus-tools/guards", () => ({
13
+ createGuardTemplate: vi.fn((_name: string, _config: unknown) => ({
14
+ asReviewer: () => vi.fn(),
15
+ })),
16
+ GUARD_CONFIGS: {
17
+ security: { description: "Security reviewer", rules: () => [] },
18
+ compliance: { description: "Compliance reviewer", rules: () => [] },
19
+ "user-impact": { description: "User-impact reviewer", rules: () => [] },
20
+ },
21
+ DEFAULT_PERSONA_TRIO: ["security", "compliance", "user-impact"],
22
+ }));
23
+
24
+ const { consensus } = await import("../index.js");
25
+
26
+ describe("failPolicy behavior", () => {
27
+ let originalNodeEnv: string | undefined;
28
+
29
+ beforeEach(() => {
30
+ vi.clearAllMocks();
31
+ originalNodeEnv = process.env["NODE_ENV"];
32
+ });
33
+
34
+ afterEach(() => {
35
+ if (originalNodeEnv === undefined) {
36
+ delete process.env["NODE_ENV"];
37
+ } else {
38
+ process.env["NODE_ENV"] = originalNodeEnv;
39
+ }
40
+ });
41
+
42
+ it("failPolicy: 'closed' + error -> ConsensusBlockedError thrown", async () => {
43
+ const fn = vi.fn(async () => "result");
44
+
45
+ mockWrapped.mockRejectedValueOnce(new Error("deliberation crashed"));
46
+
47
+ const wrapped = consensus.wrap(fn, { failPolicy: "closed" });
48
+ await expect(wrapped("myTool", {})).rejects.toThrow(ConsensusBlockedError);
49
+
50
+ // Second call: verify message
51
+ mockWrapped.mockRejectedValueOnce(new Error("deliberation crashed again"));
52
+ const wrapped2 = consensus.wrap(fn, { failPolicy: "closed" });
53
+ await expect(wrapped2("myTool", {})).rejects.toThrow("Consensus deliberation failed");
54
+ });
55
+
56
+ it("failPolicy: 'open' + error -> fn result returned", async () => {
57
+ const fn = vi.fn(async (_name: string, _args: Record<string, unknown>) => "fallback-result");
58
+
59
+ // First call: mockWrapped rejects (deliberation error)
60
+ mockWrapped.mockRejectedValueOnce(new Error("deliberation crashed"));
61
+
62
+ const wrapped = consensus.wrap(fn, { failPolicy: "open" });
63
+ const result = await wrapped("myTool", { key: "val" });
64
+
65
+ // failPolicy 'open' should call fn directly and return its result
66
+ expect(result).toBe("fallback-result");
67
+ expect(fn).toHaveBeenCalledWith("myTool", { key: "val" });
68
+ });
69
+
70
+ it("NODE_ENV=production + failPolicy:'open' -> console.warn emitted", () => {
71
+ process.env["NODE_ENV"] = "production";
72
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
73
+
74
+ const fn = vi.fn(async () => "result");
75
+ consensus.wrap(fn, { failPolicy: "open" });
76
+
77
+ expect(warnSpy).toHaveBeenCalledWith(
78
+ expect.stringContaining("failPolicy 'open' in production"),
79
+ );
80
+ warnSpy.mockRestore();
81
+ });
82
+
83
+ it("NODE_ENV=production + storage:'memory' -> console.warn emitted", () => {
84
+ process.env["NODE_ENV"] = "production";
85
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
86
+
87
+ const fn = vi.fn(async () => "result");
88
+ // storage defaults to 'memory', so no explicit override needed
89
+ consensus.wrap(fn, { storage: "memory" });
90
+
91
+ expect(warnSpy).toHaveBeenCalledWith(
92
+ expect.stringContaining("storage 'memory' in production"),
93
+ );
94
+ warnSpy.mockRestore();
95
+ });
96
+
97
+ it("NODE_ENV=development -> no warnings emitted", () => {
98
+ process.env["NODE_ENV"] = "development";
99
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
100
+
101
+ const fn = vi.fn(async () => "result");
102
+ consensus.wrap(fn, { failPolicy: "open", storage: "memory" });
103
+
104
+ expect(warnSpy).not.toHaveBeenCalled();
105
+ warnSpy.mockRestore();
106
+ });
107
+ });
@@ -0,0 +1,106 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { MissingDependencyError } from "../errors.js";
3
+
4
+ // Mock the wrapper and guards (needed for module load)
5
+ vi.mock("@consensus-tools/wrapper", () => ({
6
+ consensus: vi.fn(() => vi.fn()),
7
+ }));
8
+
9
+ vi.mock("@consensus-tools/guards", () => ({
10
+ createGuardTemplate: vi.fn((_name: string, _config: unknown) => ({
11
+ asReviewer: () => vi.fn(),
12
+ })),
13
+ }));
14
+
15
+ // Mock the optional adapter packages to simulate them not being installed
16
+ vi.mock("@consensus-tools/langchain", () => {
17
+ throw new Error("Cannot find module '@consensus-tools/langchain'");
18
+ });
19
+
20
+ vi.mock("@consensus-tools/ai-sdk", () => {
21
+ throw new Error("Cannot find module '@consensus-tools/ai-sdk'");
22
+ });
23
+
24
+ vi.mock("@consensus-tools/mcp", () => {
25
+ throw new Error("Cannot find module '@consensus-tools/mcp'");
26
+ });
27
+
28
+ const { consensus } = await import("../index.js");
29
+
30
+ describe("framework shortcuts", () => {
31
+ it("consensus.langchain() throws MissingDependencyError when adapter not installed", async () => {
32
+ await expect(consensus.langchain({} as any)).rejects.toThrow(MissingDependencyError);
33
+ await expect(consensus.langchain({} as any)).rejects.toThrow("@consensus-tools/langchain");
34
+ });
35
+
36
+ it("consensus.aiSdk() throws MissingDependencyError when adapter not installed", async () => {
37
+ await expect(consensus.aiSdk(() => {})).rejects.toThrow(MissingDependencyError);
38
+ await expect(consensus.aiSdk(() => {})).rejects.toThrow("@consensus-tools/ai-sdk");
39
+ });
40
+
41
+ it("consensus.mcp() throws MissingDependencyError when adapter not installed", async () => {
42
+ await expect(consensus.mcp()).rejects.toThrow(MissingDependencyError);
43
+ await expect(consensus.mcp()).rejects.toThrow("@consensus-tools/mcp");
44
+ });
45
+ });
46
+
47
+ describe("consensus.langchain() with adapter installed", () => {
48
+ it("returns a handler instance when adapter is available", async () => {
49
+ // Create a mock handler class
50
+ class MockGuardHandler {
51
+ name = "consensus-guard";
52
+ config: Record<string, unknown>;
53
+ constructor(config: Record<string, unknown>) {
54
+ this.config = config;
55
+ }
56
+ }
57
+
58
+ // Temporarily override the langchain mock to return a working module
59
+ const mockMod = {
60
+ ConsensusGuardCallbackHandler: MockGuardHandler,
61
+ };
62
+
63
+ // We need to test with a fresh import; use vi.doMock to override for a scoped import
64
+ vi.doMock("@consensus-tools/langchain", () => mockMod);
65
+
66
+ // Re-import to pick up the new mock
67
+ const { consensus: freshConsensus } = await import("../index.js");
68
+
69
+ const handler = await freshConsensus.langchain(null, { policy: "supermajority", guards: ["security"] });
70
+
71
+ expect(handler).toBeInstanceOf(MockGuardHandler);
72
+ expect((handler as MockGuardHandler).config).toEqual({
73
+ policy: "supermajority",
74
+ guards: ["security"],
75
+ onDecision: undefined,
76
+ });
77
+
78
+ // Restore the original mock (not installed)
79
+ vi.doMock("@consensus-tools/langchain", () => {
80
+ throw new Error("Cannot find module '@consensus-tools/langchain'");
81
+ });
82
+ });
83
+
84
+ it("uses default policy 'majority' when no config provided", async () => {
85
+ class MockGuardHandler {
86
+ name = "consensus-guard";
87
+ config: Record<string, unknown>;
88
+ constructor(config: Record<string, unknown>) {
89
+ this.config = config;
90
+ }
91
+ }
92
+
93
+ vi.doMock("@consensus-tools/langchain", () => ({
94
+ ConsensusGuardCallbackHandler: MockGuardHandler,
95
+ }));
96
+
97
+ const { consensus: freshConsensus } = await import("../index.js");
98
+ const handler = await freshConsensus.langchain(null);
99
+
100
+ expect((handler as MockGuardHandler).config.policy).toBe("majority");
101
+
102
+ vi.doMock("@consensus-tools/langchain", () => {
103
+ throw new Error("Cannot find module '@consensus-tools/langchain'");
104
+ });
105
+ });
106
+ });
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { createLogger } from "../logger.js";
3
+ import type { LogEvent } from "../types.js";
4
+
5
+ describe("createLogger", () => {
6
+ it("emits deliberation.start event via beforeSubmit hook", () => {
7
+ const logFn = vi.fn<(event: LogEvent) => void>();
8
+ const hooks = createLogger({ logger: logFn });
9
+
10
+ hooks.beforeSubmit!(["arg1", "arg2"]);
11
+
12
+ expect(logFn).toHaveBeenCalledOnce();
13
+ const event = logFn.mock.calls[0]![0];
14
+ expect(event.event).toBe("deliberation.start");
15
+ expect(event.data).toEqual({ args: ["arg1", "arg2"] });
16
+ expect(typeof event.timestamp).toBe("number");
17
+ });
18
+
19
+ it("emits deliberation.result event via afterResolve hook", () => {
20
+ const logFn = vi.fn<(event: LogEvent) => void>();
21
+ const hooks = createLogger({ logger: logFn });
22
+
23
+ const mockResult = {
24
+ action: "allow" as const,
25
+ output: "ok",
26
+ scores: [{ score: 0.9, rationale: "safe" }],
27
+ aggregateScore: 0.9,
28
+ attempt: 1,
29
+ };
30
+
31
+ hooks.afterResolve!(mockResult);
32
+
33
+ expect(logFn).toHaveBeenCalledOnce();
34
+ const event = logFn.mock.calls[0]![0];
35
+ expect(event.event).toBe("deliberation.result");
36
+ expect(event.data).toEqual({
37
+ action: "allow",
38
+ aggregateScore: 0.9,
39
+ attempt: 1,
40
+ scoresCount: 1,
41
+ });
42
+ });
43
+
44
+ it("emits deliberation.error-level event via onBlock hook", () => {
45
+ const logFn = vi.fn<(event: LogEvent) => void>();
46
+ const hooks = createLogger({ logger: logFn });
47
+
48
+ const mockResult = {
49
+ action: "block" as const,
50
+ output: null,
51
+ scores: [{ score: 0, rationale: "blocked" }],
52
+ aggregateScore: 0,
53
+ attempt: 1,
54
+ };
55
+
56
+ hooks.onBlock!(mockResult);
57
+
58
+ expect(logFn).toHaveBeenCalledOnce();
59
+ const event = logFn.mock.calls[0]![0];
60
+ expect(event.event).toBe("deliberation.result");
61
+ expect(event.data.action).toBe("block");
62
+ });
63
+
64
+ it("returns empty hooks when logger is false (suppresses all events)", () => {
65
+ const hooks = createLogger({ logger: false });
66
+
67
+ expect(hooks.beforeSubmit).toBeUndefined();
68
+ expect(hooks.afterResolve).toBeUndefined();
69
+ expect(hooks.onBlock).toBeUndefined();
70
+ expect(hooks.onEscalate).toBeUndefined();
71
+ });
72
+
73
+ it("uses custom logger function to receive events", () => {
74
+ const events: LogEvent[] = [];
75
+ const customLogger = (event: LogEvent) => {
76
+ events.push(event);
77
+ };
78
+ const hooks = createLogger({ logger: customLogger });
79
+
80
+ hooks.beforeSubmit!(["hello"]);
81
+ hooks.afterResolve!({
82
+ action: "allow",
83
+ output: "world",
84
+ scores: [],
85
+ aggregateScore: 1.0,
86
+ attempt: 1,
87
+ });
88
+
89
+ expect(events).toHaveLength(2);
90
+ expect(events[0]!.event).toBe("deliberation.start");
91
+ expect(events[1]!.event).toBe("deliberation.result");
92
+ });
93
+ });
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { resolveWrappable } from "../resolve.js";
3
+
4
+ describe("resolveWrappable", () => {
5
+ it("returns the function directly when input is a function", () => {
6
+ const fn = async (name: string, args: Record<string, unknown>) => ({ name, args });
7
+ const result = resolveWrappable(fn);
8
+ expect(result).toBe(fn);
9
+ });
10
+
11
+ it("returns .execute when input has .execute", async () => {
12
+ const obj = {
13
+ state: "bound",
14
+ execute: async function (name: string, args: Record<string, unknown>) {
15
+ return { name, args, state: this.state };
16
+ },
17
+ };
18
+ const resolved = resolveWrappable(obj as any);
19
+ const output = await resolved("test", { a: 1 });
20
+ expect(output).toEqual({ name: "test", args: { a: 1 }, state: "bound" });
21
+ });
22
+
23
+ it("returns .invoke when input has .invoke", async () => {
24
+ const obj = {
25
+ state: "invoked",
26
+ invoke: async function (name: string, args: Record<string, unknown>) {
27
+ return { name, args, state: this.state };
28
+ },
29
+ };
30
+ const resolved = resolveWrappable(obj as any);
31
+ const output = await resolved("test", { b: 2 });
32
+ expect(output).toEqual({ name: "test", args: { b: 2 }, state: "invoked" });
33
+ });
34
+
35
+ it("returns .call when input has .call", async () => {
36
+ const obj = {
37
+ state: "called",
38
+ call: async function (name: string, args: Record<string, unknown>) {
39
+ return { name, args, state: this.state };
40
+ },
41
+ };
42
+ const resolved = resolveWrappable(obj as any);
43
+ const output = await resolved("test", { c: 3 });
44
+ expect(output).toEqual({ name: "test", args: { c: 3 }, state: "called" });
45
+ });
46
+
47
+ it("prefers .execute over .invoke when both exist (resolution order)", async () => {
48
+ const obj = {
49
+ execute: async (_name: string, _args: Record<string, unknown>) => "execute-wins",
50
+ invoke: async (_name: string, _args: Record<string, unknown>) => "invoke-loses",
51
+ };
52
+ const resolved = resolveWrappable(obj as any);
53
+ const output = await resolved("test", {});
54
+ expect(output).toBe("execute-wins");
55
+ });
56
+
57
+ it("throws TypeError for null", () => {
58
+ expect(() => resolveWrappable(null as any)).toThrow(TypeError);
59
+ expect(() => resolveWrappable(null as any)).toThrow("Expected a Wrappable");
60
+ });
61
+
62
+ it("throws TypeError for undefined", () => {
63
+ expect(() => resolveWrappable(undefined as any)).toThrow(TypeError);
64
+ expect(() => resolveWrappable(undefined as any)).toThrow("Expected a Wrappable");
65
+ });
66
+
67
+ it("throws TypeError for object with no matching methods", () => {
68
+ const obj = { foo: () => "bar" };
69
+ expect(() => resolveWrappable(obj as any)).toThrow(TypeError);
70
+ expect(() => resolveWrappable(obj as any)).toThrow("Expected a Wrappable");
71
+ });
72
+
73
+ it("throws TypeError for string primitive", () => {
74
+ expect(() => resolveWrappable("hello" as any)).toThrow(TypeError);
75
+ });
76
+
77
+ it("throws TypeError for number primitive", () => {
78
+ expect(() => resolveWrappable(42 as any)).toThrow(TypeError);
79
+ });
80
+ });
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import type { DecisionResult } from "@consensus-tools/wrapper";
3
+ import { ConsensusBlockedError } from "../errors.js";
4
+
5
+ // Mock the wrapper's consensus function and guards' createGuardTemplate
6
+ const mockWrapped = vi.fn<(...args: unknown[]) => Promise<DecisionResult<unknown>>>();
7
+
8
+ vi.mock("@consensus-tools/wrapper", () => ({
9
+ consensus: vi.fn(() => mockWrapped),
10
+ }));
11
+
12
+ vi.mock("@consensus-tools/guards", () => ({
13
+ createGuardTemplate: vi.fn((_name: string, _config: unknown) => ({
14
+ asReviewer: () => vi.fn(),
15
+ })),
16
+ GUARD_CONFIGS: {
17
+ security: { description: "Security reviewer", rules: () => [] },
18
+ compliance: { description: "Compliance reviewer", rules: () => [] },
19
+ "user-impact": { description: "User-impact reviewer", rules: () => [] },
20
+ },
21
+ DEFAULT_PERSONA_TRIO: ["security", "compliance", "user-impact"],
22
+ }));
23
+
24
+ // Import after mocks are set up
25
+ const { consensus } = await import("../index.js");
26
+
27
+ describe("consensus.wrap()", () => {
28
+ beforeEach(() => {
29
+ vi.clearAllMocks();
30
+ });
31
+
32
+ it("happy path: wrap function, reviewers run, decision returned", async () => {
33
+ const fn = vi.fn(async (_name: string, _args: Record<string, unknown>) => "tool-result");
34
+
35
+ mockWrapped.mockResolvedValueOnce({
36
+ action: "allow",
37
+ output: "tool-result",
38
+ scores: [{ score: 0.9, rationale: "approved" }],
39
+ aggregateScore: 0.9,
40
+ attempt: 1,
41
+ });
42
+
43
+ const wrapped = consensus.wrap(fn);
44
+ const result = await wrapped("myTool", { input: "data" });
45
+
46
+ expect(result).toBe("tool-result");
47
+ expect(mockWrapped).toHaveBeenCalledWith("myTool", { input: "data" });
48
+ });
49
+
50
+ it("wrapped fn returns undefined -> reviewers evaluate undefined", async () => {
51
+ const fn = vi.fn(async () => undefined);
52
+
53
+ mockWrapped.mockResolvedValueOnce({
54
+ action: "allow",
55
+ output: undefined,
56
+ scores: [{ score: 0.8 }],
57
+ aggregateScore: 0.8,
58
+ attempt: 1,
59
+ });
60
+
61
+ const wrapped = consensus.wrap(fn);
62
+ const result = await wrapped("myTool", {});
63
+
64
+ expect(result).toBeUndefined();
65
+ });
66
+
67
+ it("wrapped fn throws synchronously -> failPolicy 'closed' throws ConsensusBlockedError", async () => {
68
+ const fn = vi.fn(() => {
69
+ throw new Error("sync explosion");
70
+ });
71
+
72
+ mockWrapped.mockRejectedValueOnce(new Error("sync explosion"));
73
+
74
+ const wrapped = consensus.wrap(fn, { failPolicy: "closed" });
75
+ await expect(wrapped("myTool", {})).rejects.toThrow(ConsensusBlockedError);
76
+ });
77
+
78
+ it("all reviewers return score=0 -> unanimous block with failPolicy closed", async () => {
79
+ const fn = vi.fn(async () => "result");
80
+
81
+ mockWrapped.mockResolvedValueOnce({
82
+ action: "block",
83
+ output: null,
84
+ scores: [
85
+ { score: 0, rationale: "blocked-1" },
86
+ { score: 0, rationale: "blocked-2" },
87
+ { score: 0, rationale: "blocked-3" },
88
+ ],
89
+ aggregateScore: 0,
90
+ attempt: 1,
91
+ });
92
+
93
+ const wrapped = consensus.wrap(fn, { failPolicy: "closed" });
94
+ await expect(wrapped("dangerousTool", {})).rejects.toThrow(ConsensusBlockedError);
95
+
96
+ // Second call to verify message content
97
+ mockWrapped.mockResolvedValueOnce({
98
+ action: "block",
99
+ output: null,
100
+ scores: [
101
+ { score: 0, rationale: "blocked-1" },
102
+ { score: 0, rationale: "blocked-2" },
103
+ { score: 0, rationale: "blocked-3" },
104
+ ],
105
+ aggregateScore: 0,
106
+ attempt: 1,
107
+ });
108
+ await expect(wrapped("dangerousTool", {})).rejects.toThrow(/Consensus block/);
109
+ });
110
+ });