/* Unit tests for gate covering its documented public contract and regression-sensitive edge cases. Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only. RFC-0290: initial conformance corpus. RFC-0291: add rate limiting, identity, freshness, MCP size cap tests. RFC-1113: 2026-07-28 stateless era — _meta version on every request, server/discover, initialize → UnsupportedProtocolVersionError, header consistency. RFC-1113: align agent discovery surface with RFC 9727 and MCP 2026-07-28 stateless era */ import { test, expect } from "vitest"; import { createAgentGate, type AgentGatePorts, __resetLimiterCache } from "../index.ts"; import { __resetMcpLimiterCache } from "../mcp/handler.ts"; import { createFixedWindowLimiter } from "../limits.ts"; import { buildAgentSurfaceManifest, type ActionReceipt, type ManifestDeprecation, } from "@warpgogol/werkstatt-shared/agent"; import type { CapabilityRecord } from "@warpgogol/werkstatt-shared/ontology"; const LEAD_SUBMIT: CapabilityRecord = { id: "lead.submit", version: 2, kind: "action", sideEffect: "write", title: { de: "Anfrage senden", en: "Submit an inquiry" }, description: { de: "Übermittelt eine Anfrage.", en: "Delivers an inquiry." }, input: { type: "object", required: ["message"], additionalProperties: false, properties: { message: { type: "string", minLength: 10, maxLength: 4000 } }, }, output: { type: "object", required: ["receiptId", "status", "duplicate", "submittedAt"], additionalProperties: false, properties: { receiptId: { type: "string", format: "uuid" }, status: { type: "string" }, duplicate: { type: "boolean" }, submittedAt: { type: "string" }, }, }, integration: { eventKind: "lead", source: "agent" }, requires: { entitlements: [], sections: ["send-message"] }, humanEquivalent: { sectionType: "send-message" }, limits: { perMinutePerIp: 10, maxPayloadBytes: 16384 }, }; /** * RFC-1112/RFC-1169: side-effect-free preview capability (no integration, no * humanEquivalent) with the v2 readiness envelope and a nextAction linkage. */ const LEAD_PREPARE: CapabilityRecord = { id: "lead.prepare", version: 2, kind: "action", sideEffect: "none", title: { de: "Anfrage prüfen", en: "Preview an inquiry" }, description: { de: "Prüft eine Anfrage.", en: "Validates an inquiry." }, input: { type: "object", required: ["message"], additionalProperties: false, properties: { message: { type: "string", minLength: 10, maxLength: 4000 } }, }, output: { type: "object", required: ["ready"], additionalProperties: false, properties: { ready: { type: "boolean" }, draftId: { type: "string" }, }, }, nextAction: { capability: "lead.submit", sideEffect: "Creates a project inquiry and dispatches it to the site owner.", }, requires: { entitlements: [], sections: [] }, limits: { perMinutePerIp: 10, maxPayloadBytes: 16384 }, }; /** Manifest exposing only the preview capability — used by all readiness tests. */ function makePreviewManifest() { return buildAgentSurfaceManifest({ generatedAt: "2026-01-01T00:00:00.000Z", site: "test-bundle", baseUrl: "https://test.example", languages: { default: "de", supported: ["de", "en"] }, knowledge: [], actions: [ { id: "lead.prepare", url: "/api/agent/v1/actions/lead.prepare", title: LEAD_PREPARE.title, inputSchemaRef: "#/components/schemas/lead.prepare-input", entitlement: "agent.actions", }, ], }); } function makeManifest(withAction: boolean) { return buildAgentSurfaceManifest({ generatedAt: "2026-01-01T00:00:00.000Z", site: "test-bundle", baseUrl: "https://test.example", languages: { default: "de", supported: ["de", "en"] }, knowledge: [ { domain: "offer", url: "/api/agent/v1/offer.json", schema: "gogol.agent.knowledge/offer@1" }, ], actions: withAction ? [ { id: "lead.submit", url: "/api/agent/v1/actions/lead.submit", title: LEAD_SUBMIT.title, inputSchemaRef: "#/components/schemas/lead.submit-input", entitlement: "agent.actions", }, ] : [], }); } function makeFakePorts(overrides: Partial = {}): AgentGatePorts { return { knowledge: { read: async (path: string) => (path === "/api/agent/v1/offer.json" ? '{"data":{}}' : null), }, dispatch: { send: async (event) => ({ receiptId: event.eventId, status: "accepted" as const, duplicate: false, submittedAt: "2026-07-05T00:00:00.000Z", }), }, now: () => new Date("2026-07-05T00:00:00.000Z"), createRateLimiter: (maxPerWindow) => createFixedWindowLimiter(60, maxPerWindow), ...overrides, }; } /** RFC-1113: the _meta key + version every modern request declares. */ const PROTOCOL_VERSION_META = { "io.modelcontextprotocol/protocolVersion": "2026-07-28" }; function rpc(method: string, params?: unknown, id: string | number = 1) { const p = (params ?? {}) as Record; const meta = { ...PROTOCOL_VERSION_META, ...((p._meta as Record) ?? {}) }; return new Request("https://test.example/api/agent/mcp", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id, method, params: { ...p, _meta: meta } }), }); } /** Raw request without the injected _meta version — for version-gate tests. */ function rawRpc(body: unknown, headers: Record = {}) { return new Request("https://test.example/api/agent/mcp", { method: "POST", headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(body), }); } async function rpcResult(res: Response) { expect(res.status).toBe(200); const body = (await res.json()) as { result?: unknown; error?: { code: number; message: string; data?: unknown }; }; return body; } test("server/discover: returns supportedVersions + capabilities + serverInfo", async () => { const manifest = makeManifest(false); const gate = createAgentGate(manifest, [], makeFakePorts()); const body = await rpcResult(await gate.handleMcp(rpc("server/discover"))); expect(body.result).toEqual({ resultType: "complete", supportedVersions: ["2026-07-28"], capabilities: { tools: {}, resources: {} }, _meta: { "io.modelcontextprotocol/serverInfo": { name: "test-bundle agent surface", version: "2.0.0", }, }, }); }); test("server/discover: answers even without a declared protocol version", async () => { const gate = createAgentGate(makeManifest(false), [], makeFakePorts()); const res = await gate.handleMcp(rawRpc({ jsonrpc: "2.0", id: 1, method: "server/discover" })); expect(res.status).toBe(200); const body = (await res.json()) as { result: { supportedVersions: string[] } }; expect(body.result.supportedVersions).toEqual(["2026-07-28"]); }); test("initialize: legacy handshake returns UnsupportedProtocolVersionError naming supported versions", async () => { const manifest = makeManifest(false); const gate = createAgentGate(manifest, [], makeFakePorts()); const res = await gate.handleMcp( rawRpc({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18" }, }), ); expect(res.status).toBe(400); const body = (await res.json()) as { error: { code: number; data: { supported: string[]; requested: string } }; }; expect(body.error.code).toBe(-32022); expect(body.error.data.supported).toEqual(["2026-07-28"]); expect(body.error.data.requested).toBe("2025-06-18"); }); test("version gate: missing _meta protocol version → -32022 + HTTP 400", async () => { const gate = createAgentGate(makeManifest(false), [], makeFakePorts()); const res = await gate.handleMcp(rawRpc({ jsonrpc: "2.0", id: 1, method: "ping", params: {} })); expect(res.status).toBe(400); const body = (await res.json()) as { error: { code: number; data: { supported: string[]; requested: null } }; }; expect(body.error.code).toBe(-32022); expect(body.error.data.supported).toEqual(["2026-07-28"]); expect(body.error.data.requested).toBeNull(); }); test("version gate: unsupported _meta protocol version → -32022 + HTTP 400", async () => { const gate = createAgentGate(makeManifest(false), [], makeFakePorts()); const res = await gate.handleMcp( rawRpc({ jsonrpc: "2.0", id: 1, method: "tools/list", params: { _meta: { "io.modelcontextprotocol/protocolVersion": "2025-11-25" } }, }), ); expect(res.status).toBe(400); const body = (await res.json()) as { error: { code: number; data: { supported: string[]; requested: string } }; }; expect(body.error.code).toBe(-32022); expect(body.error.data.requested).toBe("2025-11-25"); }); test("version gate: MCP-Protocol-Version header matching _meta → request proceeds", async () => { const gate = createAgentGate(makeManifest(false), [], makeFakePorts()); const res = await gate.handleMcp( rawRpc( { jsonrpc: "2.0", id: 1, method: "ping", params: { _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }, { "MCP-Protocol-Version": "2026-07-28" }, ), ); expect(res.status).toBe(200); }); test("version gate: MCP-Protocol-Version header mismatching _meta → 400", async () => { const gate = createAgentGate(makeManifest(false), [], makeFakePorts()); const res = await gate.handleMcp( rawRpc( { jsonrpc: "2.0", id: 1, method: "ping", params: { _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, }, { "MCP-Protocol-Version": "2025-11-25" }, ), ); expect(res.status).toBe(400); const body = (await res.json()) as { error: { code: number } }; expect(body.error.code).toBe(-32600); }); test("ping: returns an empty object", async () => { const gate = createAgentGate(makeManifest(false), [], makeFakePorts()); const body = await rpcResult(await gate.handleMcp(rpc("ping"))); expect(body.result).toEqual({}); }); test("tools/list: knowledge-only manifest lists exactly one tool", async () => { const gate = createAgentGate(makeManifest(false), [], makeFakePorts()); const body = await rpcResult(await gate.handleMcp(rpc("tools/list"))); const tools = (body.result as { tools: Array<{ name: string }> }).tools; expect(tools.map((t) => t.name)).toEqual(["knowledge.offer.get"]); }); test("tools/list: with an active action, lists both tools with the action's real input schema", async () => { const gate = createAgentGate(makeManifest(true), [LEAD_SUBMIT], makeFakePorts()); const body = await rpcResult(await gate.handleMcp(rpc("tools/list"))); const tools = (body.result as { tools: Array<{ name: string; inputSchema: unknown }> }).tools; expect(tools.map((t) => t.name)).toEqual(["knowledge.offer.get", "action.lead.submit"]); expect(tools[1]!.inputSchema).toEqual(LEAD_SUBMIT.input); }); test("tools/call knowledge: returns the file contents as text", async () => { const gate = createAgentGate(makeManifest(false), [], makeFakePorts()); const body = await rpcResult( await gate.handleMcp(rpc("tools/call", { name: "knowledge.offer.get" })), ); expect(body.result).toEqual({ content: [{ type: "text", text: '{"data":{}}' }] }); }); test("tools/call action: happy path dispatches and returns an ActionReceipt", async () => { const gate = createAgentGate(makeManifest(true), [LEAD_SUBMIT], makeFakePorts()); const body = await rpcResult( await gate.handleMcp( rpc("tools/call", { name: "action.lead.submit", arguments: { message: "hello world!" } }), ), ); const content = (body.result as { content: Array<{ type: string; text: string }> }).content; const parsed = JSON.parse(content[0]!.text) as { receiptId: string; status: string; duplicate: boolean; submittedAt: string; }; expect(parsed.status).toBe("accepted"); expect(parsed.duplicate).toBe(false); expect(parsed.receiptId).toMatch(/^[0-9a-f-]{36}$/); expect(parsed.submittedAt).toBe("2026-07-05T00:00:00.000Z"); }); test("tools/call action: invalid arguments return -32602 with field errors", async () => { const gate = createAgentGate(makeManifest(true), [LEAD_SUBMIT], makeFakePorts()); const res = await gate.handleMcp( rpc("tools/call", { name: "action.lead.submit", arguments: { message: "short" } }), ); const body = (await res.json()) as { error: { code: number; data: { errors: unknown[] } } }; expect(body.error.code).toBe(-32602); expect(body.error.data.errors.length > 0).toBeTruthy(); }); test("tools/call action: dispatch failure returns a retryable server error", async () => { const gate = createAgentGate( makeManifest(true), [LEAD_SUBMIT], makeFakePorts({ dispatch: { send: async () => { throw new Error("boom"); }, }, }), ); const res = await gate.handleMcp( rpc("tools/call", { name: "action.lead.submit", arguments: { message: "hello world!" } }), ); const body = (await res.json()) as { error: { code: number; data: { retryable: boolean } } }; expect(body.error.code).toBe(-32000); expect(body.error.data.retryable).toBe(true); }); test("tools/call: unknown tool name returns -32602", async () => { const gate = createAgentGate(makeManifest(false), [], makeFakePorts()); const res = await gate.handleMcp(rpc("tools/call", { name: "action.does-not-exist" })); const body = (await res.json()) as { error: { code: number } }; expect(body.error.code).toBe(-32602); }); test("resources/list: one resource per knowledge ref with an absolute uri", async () => { const gate = createAgentGate(makeManifest(false), [], makeFakePorts()); const body = await rpcResult(await gate.handleMcp(rpc("resources/list"))); expect(body.result).toEqual({ resources: [ { uri: "https://test.example/api/agent/v1/offer.json", name: "offer", mimeType: "application/json", }, ], }); }); test("resources/read: reads the matching knowledge file by absolute uri", async () => { const gate = createAgentGate(makeManifest(false), [], makeFakePorts()); const body = await rpcResult( await gate.handleMcp( rpc("resources/read", { uri: "https://test.example/api/agent/v1/offer.json" }), ), ); expect(body.result).toEqual({ contents: [ { uri: "https://test.example/api/agent/v1/offer.json", mimeType: "application/json", text: '{"data":{}}', }, ], }); }); test("unknown method returns -32601 method-not-found", async () => { const gate = createAgentGate(makeManifest(false), [], makeFakePorts()); const res = await gate.handleMcp(rpc("prompts/list")); const body = (await res.json()) as { error: { code: number } }; expect(body.error.code).toBe(-32601); }); test("batch requests (array body) are rejected with -32600 invalid-request", async () => { const gate = createAgentGate(makeManifest(false), [], makeFakePorts()); const res = await gate.handleMcp( new Request("https://test.example/api/agent/mcp", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify([{ jsonrpc: "2.0", id: 1, method: "ping" }]), }), ); const body = (await res.json()) as { error: { code: number } }; expect(res.status).toBe(200); expect(body.error.code).toBe(-32600); }); test("GET is rejected with 405", async () => { const gate = createAgentGate(makeManifest(false), [], makeFakePorts()); const res = await gate.handleMcp( new Request("https://test.example/api/agent/mcp", { method: "GET" }), ); expect(res.status).toBe(405); }); // --------------------------------------------------------------------------- // Direct HTTP action route // --------------------------------------------------------------------------- test("handleAction: happy path returns 200 with an ActionReceipt", async () => { const gate = createAgentGate(makeManifest(true), [LEAD_SUBMIT], makeFakePorts()); const res = await gate.handleAction( "lead.submit", new Request("https://test.example/api/agent/actions/lead.submit", { method: "POST", body: JSON.stringify({ message: "hello world!" }), }), ); expect(res.status).toBe(200); const body = (await res.json()) as { receiptId: string; status: string; duplicate: boolean; submittedAt: string; }; expect(body.status).toBe("accepted"); expect(body.duplicate).toBe(false); expect(body.receiptId).toMatch(/^[0-9a-f-]{36}$/); }); test("handleAction: unknown capability id returns 404", async () => { const gate = createAgentGate(makeManifest(false), [], makeFakePorts()); const res = await gate.handleAction( "does.not.exist", new Request("https://test.example/api/agent/actions/does.not.exist", { method: "POST", body: "{}", }), ); expect(res.status).toBe(404); }); test("handleAction: schema violation returns 422 problem+json", async () => { const gate = createAgentGate(makeManifest(true), [LEAD_SUBMIT], makeFakePorts()); const res = await gate.handleAction( "lead.submit", new Request("https://test.example/api/agent/actions/lead.submit", { method: "POST", body: JSON.stringify({ message: "short" }), }), ); expect(res.status).toBe(422); expect(res.headers.get("content-type")).toBe("application/problem+json"); const body = (await res.json()) as { code: string; retryable: boolean; errors: unknown[] }; expect(body.code).toBe("schema-violation"); expect(body.retryable).toBe(false); expect(body.errors.length > 0).toBeTruthy(); }); test("handleAction: oversized payload returns 413", async () => { const gate = createAgentGate(makeManifest(true), [LEAD_SUBMIT], makeFakePorts()); const res = await gate.handleAction( "lead.submit", new Request("https://test.example/api/agent/actions/lead.submit", { method: "POST", body: JSON.stringify({ message: "x".repeat(20000) }), }), ); expect(res.status).toBe(413); }); test("handleAction: dispatch failure returns 502 problem+json", async () => { const gate = createAgentGate( makeManifest(true), [LEAD_SUBMIT], makeFakePorts({ dispatch: { send: async () => { throw new Error("boom"); }, }, }), ); const res = await gate.handleAction( "lead.submit", new Request("https://test.example/api/agent/actions/lead.submit", { method: "POST", body: JSON.stringify({ message: "hello world!" }), }), ); expect(res.status).toBe(502); const body = (await res.json()) as { code: string; retryable: boolean }; expect(body.code).toBe("dispatch-failed"); expect(body.retryable).toBe(true); }); // --------------------------------------------------------------------------- // RFC-0291: rate limiting, identity passthrough, freshness, MCP size cap // --------------------------------------------------------------------------- const RATE_LIMITED_CAP: CapabilityRecord = { ...LEAD_SUBMIT, limits: { perMinutePerIp: 2, maxPayloadBytes: 16384 }, }; test("handleAction: per-IP rate limit returns 429 with Retry-After after exceeding threshold", async () => { __resetLimiterCache(); const gate = createAgentGate(makeManifest(true), [RATE_LIMITED_CAP], makeFakePorts()); const req = () => new Request("https://test.example/api/agent/actions/lead.submit", { method: "POST", headers: { "CF-Connecting-IP": "1.2.3.4" }, body: JSON.stringify({ message: "hello world!" }), }); const r1 = await gate.handleAction("lead.submit", req()); expect(r1.status).toBe(200); const r2 = await gate.handleAction("lead.submit", req()); expect(r2.status).toBe(200); const r3 = await gate.handleAction("lead.submit", req()); expect(r3.status).toBe(429); expect(r3.headers.get("Retry-After")).toBeTruthy(); const body = (await r3.json()) as { code: string; retryAfterSeconds: number }; expect(body.code).toBe("rate-limited"); expect(body.retryAfterSeconds).toBeGreaterThan(0); }); test("handleAction: different IPs get separate rate limit buckets", async () => { __resetLimiterCache(); const gate = createAgentGate(makeManifest(true), [RATE_LIMITED_CAP], makeFakePorts()); const req = (ip: string) => new Request("https://test.example/api/agent/actions/lead.submit", { method: "POST", headers: { "CF-Connecting-IP": ip }, body: JSON.stringify({ message: "hello world!" }), }); expect((await gate.handleAction("lead.submit", req("1.1.1.1"))).status).toBe(200); expect((await gate.handleAction("lead.submit", req("1.1.1.1"))).status).toBe(200); // Different IP — should not be affected by the first IP's bucket expect((await gate.handleAction("lead.submit", req("2.2.2.2"))).status).toBe(200); }); test("handleAction: identity headers are passed through as payload._agentIdentity", async () => { __resetLimiterCache(); let capturedPayload: Record | undefined; const gate = createAgentGate( makeManifest(true), [LEAD_SUBMIT], makeFakePorts({ dispatch: { send: async (event) => { capturedPayload = event.payload; return { receiptId: event.eventId, status: "accepted" as const, duplicate: false, submittedAt: "2026-07-05T00:00:00.000Z", }; }, }, }), ); await gate.handleAction( "lead.submit", new Request("https://test.example/api/agent/actions/lead.submit", { method: "POST", headers: { "Signature-Agent": "test-agent/1.0", "User-Agent": "bot/2.0", }, body: JSON.stringify({ message: "hello world!" }), }), ); expect(capturedPayload).toBeDefined(); expect(capturedPayload!._agentIdentity).toEqual({ "Signature-Agent": "test-agent/1.0", "User-Agent": "bot/2.0", }); }); test("handleAction: identity headers are capped at 1KB total", async () => { __resetLimiterCache(); let capturedPayload: Record | undefined; const gate = createAgentGate( makeManifest(true), [LEAD_SUBMIT], makeFakePorts({ dispatch: { send: async (event) => { capturedPayload = event.payload; return { receiptId: event.eventId, status: "accepted" as const, duplicate: false, submittedAt: "2026-07-05T00:00:00.000Z", }; }, }, }), ); const hugeValue = "x".repeat(2000); await gate.handleAction( "lead.submit", new Request("https://test.example/api/agent/actions/lead.submit", { method: "POST", headers: { "User-Agent": hugeValue }, body: JSON.stringify({ message: "hello world!" }), }), ); expect(capturedPayload).toBeDefined(); const identity = capturedPayload!._agentIdentity as Record; expect(identity["User-Agent"].length).toBeLessThanOrEqual(1024); }); test("tools/call knowledge: freshness is present in _meta when envelope carries it", async () => { const gate = createAgentGate( makeManifest(false), [], makeFakePorts({ knowledge: { read: async () => JSON.stringify({ domain: "offer", schema: "gogol.agent.knowledge/offer@1", contentHash: "abc123", freshness: { lastVerified: "2026-07-01", source: "ckl" }, data: {}, }), }, }), ); const body = await rpcResult( await gate.handleMcp(rpc("tools/call", { name: "knowledge.offer.get" })), ); const result = body.result as { _meta?: { "gogol.dev/freshness"?: unknown } }; expect(result._meta).toBeDefined(); expect(result._meta!["gogol.dev/freshness"]).toEqual({ lastVerified: "2026-07-01", source: "ckl", }); }); test("resources/read: freshness is present in _meta when envelope carries it", async () => { const gate = createAgentGate( makeManifest(false), [], makeFakePorts({ knowledge: { read: async () => JSON.stringify({ domain: "offer", schema: "gogol.agent.knowledge/offer@1", contentHash: "abc123", freshness: { lastVerified: "2026-07-01", source: "ckl" }, data: {}, }), }, }), ); const body = await rpcResult( await gate.handleMcp( rpc("resources/read", { uri: "https://test.example/api/agent/v1/offer.json" }), ), ); const result = body.result as { _meta?: { "gogol.dev/freshness"?: unknown } }; expect(result._meta).toBeDefined(); expect(result._meta!["gogol.dev/freshness"]).toEqual({ lastVerified: "2026-07-01", source: "ckl", }); }); test("tools/call action: MCP path enforces per-IP rate limit", async () => { __resetMcpLimiterCache(); __resetLimiterCache(); const gate = createAgentGate(makeManifest(true), [RATE_LIMITED_CAP], makeFakePorts()); const req = (ip: string) => new Request("https://test.example/api/agent/mcp", { method: "POST", headers: { "content-type": "application/json", "CF-Connecting-IP": ip }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "action.lead.submit", arguments: { message: "hello world!" }, _meta: PROTOCOL_VERSION_META, }, }), }); expect((await gate.handleMcp(req("1.1.1.1"))).status).toBe(200); expect((await gate.handleMcp(req("1.1.1.1"))).status).toBe(200); const r3 = await gate.handleMcp(req("1.1.1.1")); const body = (await r3.json()) as { error: { code: number; data: { retryable: boolean; retryAfterSeconds: number } }; }; expect(body.error.code).toBe(-32000); expect(body.error.data.retryable).toBe(true); expect(body.error.data.retryAfterSeconds).toBeGreaterThan(0); }); test("tools/call action: MCP path enforces payload size cap", async () => { const gate = createAgentGate(makeManifest(true), [LEAD_SUBMIT], makeFakePorts()); const res = await gate.handleMcp( rpc("tools/call", { name: "action.lead.submit", arguments: { message: "x".repeat(20000) }, }), ); const body = (await res.json()) as { error: { code: number; data: { retryable: boolean } } }; expect(body.error.code).toBe(-32000); expect(body.error.data.retryable).toBe(false); }); // --------------------------------------------------------------------------- // RFC-1112: idempotency, receipts, preview capabilities // --------------------------------------------------------------------------- /** In-memory receipt store fake for the idempotency port. */ function makeMemoryStore() { const map = new Map(); return { map, get: async (key: string) => map.get(key) ?? null, put: async (key: string, bodyHash: string, receipt: ActionReceipt) => { map.set(key, { receipt, bodyHash }); }, }; } test("handleAction: Idempotency-Key replay returns the stored receipt as duplicate", async () => { __resetLimiterCache(); const store = makeMemoryStore(); let dispatchCount = 0; const gate = createAgentGate( makeManifest(true), [LEAD_SUBMIT], makeFakePorts({ idempotency: store, dispatch: { send: async (event) => { dispatchCount += 1; return { receiptId: event.eventId, status: "accepted" as const, duplicate: false, submittedAt: "2026-07-05T00:00:00.000Z", }; }, }, }), ); const req = () => new Request("https://test.example/api/agent/actions/lead.submit", { method: "POST", headers: { "Idempotency-Key": "key-1" }, body: JSON.stringify({ message: "hello world!" }), }); const r1 = await gate.handleAction("lead.submit", req()); expect(r1.status).toBe(200); const first = (await r1.json()) as { receiptId: string; duplicate: boolean }; expect(first.duplicate).toBe(false); const r2 = await gate.handleAction("lead.submit", req()); expect(r2.status).toBe(200); const second = (await r2.json()) as { receiptId: string; status: string; duplicate: boolean }; expect(second.receiptId).toBe(first.receiptId); expect(second.status).toBe("duplicate"); expect(second.duplicate).toBe(true); expect(dispatchCount).toBe(1); }); test("handleAction: same Idempotency-Key with a different body returns 409", async () => { __resetLimiterCache(); const store = makeMemoryStore(); const gate = createAgentGate( makeManifest(true), [LEAD_SUBMIT], makeFakePorts({ idempotency: store }), ); const req = (message: string) => new Request("https://test.example/api/agent/actions/lead.submit", { method: "POST", headers: { "Idempotency-Key": "key-2" }, body: JSON.stringify({ message }), }); await gate.handleAction("lead.submit", req("hello world!")); const res = await gate.handleAction("lead.submit", req("a different message")); expect(res.status).toBe(409); const body = (await res.json()) as { code: string; retryable: boolean }; expect(body.code).toBe("idempotency-key-reuse"); expect(body.retryable).toBe(false); }); test("handleAction: Idempotency-Key derives a deterministic eventId", async () => { __resetLimiterCache(); const eventIds: string[] = []; const gate = createAgentGate( makeManifest(true), [LEAD_SUBMIT], makeFakePorts({ dispatch: { send: async (event) => { eventIds.push(event.eventId); return { receiptId: event.eventId, status: "accepted" as const, duplicate: false, submittedAt: "2026-07-05T00:00:00.000Z", }; }, }, }), ); const req = (key: string) => new Request("https://test.example/api/agent/actions/lead.submit", { method: "POST", headers: { "Idempotency-Key": key }, body: JSON.stringify({ message: "hello world!" }), }); await gate.handleAction("lead.submit", req("same-key")); await gate.handleAction("lead.submit", req("same-key")); await gate.handleAction("lead.submit", req("other-key")); // No store → each request dispatches, but same key → same eventId. expect(eventIds[0]).toBe(eventIds[1]); expect(eventIds[0]).not.toBe(eventIds[2]); expect(eventIds[0]).toMatch(/^[0-9a-f-]{36}$/); }); test("handleAction: no idempotency port → X-Agent-Idempotency: disabled", async () => { __resetLimiterCache(); const gate = createAgentGate(makeManifest(true), [LEAD_SUBMIT], makeFakePorts()); const res = await gate.handleAction( "lead.submit", new Request("https://test.example/api/agent/actions/lead.submit", { method: "POST", headers: { "Idempotency-Key": "key-3" }, body: JSON.stringify({ message: "hello world!" }), }), ); expect(res.headers.get("X-Agent-Idempotency")).toBe("disabled"); }); test("handleAction: sideEffect none capability returns the preview envelope without dispatching", async () => { __resetLimiterCache(); let dispatched = false; const gate = createAgentGate( makePreviewManifest(), [LEAD_PREPARE], makeFakePorts({ dispatch: { send: async () => { dispatched = true; throw new Error("must not be called"); }, }, }), ); const res = await gate.handleAction( "lead.prepare", new Request("https://test.example/api/agent/actions/lead.prepare", { method: "POST", body: JSON.stringify({ message: "hello world!" }), }), ); expect(res.status).toBe(200); const body = (await res.json()) as { ready: boolean; draftId: string; summary: Record; nextAction: string; sideEffect: string; }; // RFC-1169 AC-2/AC-6: ready envelope — draftId hash, echoed summary, catalog linkage. expect(body.ready).toBe(true); expect(body.draftId).toMatch(/^sha256:[0-9a-f]{64}$/); expect(body.summary).toEqual({ message: "hello world!" }); expect(body.nextAction).toBe("lead.submit"); expect(body.sideEffect).toBe("Creates a project inquiry and dispatches it to the site owner."); expect(dispatched).toBe(false); }); test("handleAction: preview with missing required fields returns 200 ready:false (never 422, no dispatch, no receipt)", async () => { __resetLimiterCache(); const store = makeMemoryStore(); let dispatched = false; const gate = createAgentGate( makePreviewManifest(), [LEAD_PREPARE], makeFakePorts({ idempotency: store, dispatch: { send: async () => { dispatched = true; throw new Error("must not be called"); }, }, }), ); const res = await gate.handleAction( "lead.prepare", new Request("https://test.example/api/agent/actions/lead.prepare", { method: "POST", headers: { "Idempotency-Key": "prev-1" }, body: JSON.stringify({}), }), ); // RFC-1169 AC-1/AC-5/AC-6: validation failure on the preview path is a // readiness answer, not a problem — and produces no receipt or dispatch. expect(res.status).toBe(200); const body = (await res.json()) as { ready: boolean; missing: string[]; problems: Array<{ field: string; code: string; detail: string }>; }; expect(body.ready).toBe(false); expect(body.missing).toEqual(["message"]); expect(body.problems).toEqual([]); expect(dispatched).toBe(false); expect(await store.get("prev-1")).toBeNull(); }); test("handleAction: preview with a non-required violation reports it in problems, not missing", async () => { __resetLimiterCache(); const gate = createAgentGate(makePreviewManifest(), [LEAD_PREPARE], makeFakePorts()); const res = await gate.handleAction( "lead.prepare", new Request("https://test.example/api/agent/actions/lead.prepare", { method: "POST", body: JSON.stringify({ message: "short" }), }), ); expect(res.status).toBe(200); const body = (await res.json()) as { ready: boolean; missing: string[]; problems: Array<{ field: string; code: string; detail: string }>; }; expect(body.ready).toBe(false); expect(body.missing).toEqual([]); expect(body.problems).toEqual([ { field: "message", code: "MIN_LENGTH", detail: "must be at least 10 characters" }, ]); }); test("tools/call action: preview capability returns the readiness envelope (transport parity)", async () => { __resetMcpLimiterCache(); __resetLimiterCache(); const gate = createAgentGate(makePreviewManifest(), [LEAD_PREPARE], makeFakePorts()); const body = await rpcResult( await gate.handleMcp( rpc("tools/call", { name: "action.lead.prepare", arguments: { message: "hello world!" } }), ), ); const content = (body.result as { content: Array<{ type: string; text: string }> }).content; const parsed = JSON.parse(content[0]!.text) as { ready: boolean; draftId: string; summary: Record; nextAction: string; }; // RFC-1169 AC-9: identical envelope fields on MCP — parity test pins the whole shape. expect(parsed.ready).toBe(true); expect(parsed.draftId).toMatch(/^sha256:[0-9a-f]{64}$/); expect(parsed.summary).toEqual({ message: "hello world!" }); expect(parsed.nextAction).toBe("lead.submit"); }); test("tools/call action: preview with missing fields returns the ready:false envelope (not -32602)", async () => { __resetMcpLimiterCache(); __resetLimiterCache(); const gate = createAgentGate(makePreviewManifest(), [LEAD_PREPARE], makeFakePorts()); const body = await rpcResult( await gate.handleMcp(rpc("tools/call", { name: "action.lead.prepare", arguments: {} })), ); const content = (body.result as { content: Array<{ type: string; text: string }> }).content; const parsed = JSON.parse(content[0]!.text) as { ready: boolean; missing: string[] }; expect(parsed.ready).toBe(false); expect(parsed.missing).toEqual(["message"]); }); test("tools/call action: _meta idempotency key replays via MCP", async () => { __resetMcpLimiterCache(); __resetLimiterCache(); const store = makeMemoryStore(); let dispatchCount = 0; const gate = createAgentGate( makeManifest(true), [LEAD_SUBMIT], makeFakePorts({ idempotency: store, dispatch: { send: async (event) => { dispatchCount += 1; return { receiptId: event.eventId, status: "accepted" as const, duplicate: false, submittedAt: "2026-07-05T00:00:00.000Z", }; }, }, }), ); const call = () => rpc("tools/call", { name: "action.lead.submit", arguments: { message: "hello world!" }, _meta: { "gogol.dev/idempotencyKey": "mcp-key-1" }, }); const b1 = await rpcResult(await gate.handleMcp(call())); const c1 = (b1.result as { content: Array<{ text: string }> }).content; const first = JSON.parse(c1[0]!.text) as { receiptId: string; duplicate: boolean }; expect(first.duplicate).toBe(false); const b2 = await rpcResult(await gate.handleMcp(call())); const c2 = (b2.result as { content: Array<{ text: string }> }).content; const second = JSON.parse(c2[0]!.text) as { receiptId: string; duplicate: boolean }; expect(second.receiptId).toBe(first.receiptId); expect(second.duplicate).toBe(true); expect(dispatchCount).toBe(1); }); // --------------------------------------------------------------------------- // RFC-1171: deprecation headers on the unversioned alias path // --------------------------------------------------------------------------- function makeDeprecatedManifest(deprecateAt: string, sunsetAt: string) { return buildAgentSurfaceManifest({ generatedAt: "2026-01-01T00:00:00.000Z", site: "test-bundle", baseUrl: "https://test.example", languages: { default: "de", supported: ["de", "en"] }, actions: [ { id: "lead.submit", url: "/api/agent/v1/actions/lead.submit", title: LEAD_SUBMIT.title, inputSchemaRef: "#/components/schemas/lead.submit-input", entitlement: "agent.actions", }, ], // Caller-provided entry on the alias path dedupes the injected constant. deprecations: [ { path: "/api/agent/actions/{id}", successor: "/api/agent/v1/actions/{id}", reason: "Actions moved into the versioned namespace (RFC-1171).", deprecateAt, sunsetAt, }, ] satisfies ManifestDeprecation[], }); } test("handleAction: alias path answers with Deprecation + Sunset + successor-version Link once deprecateAt passed", async () => { const gate = createAgentGate( makeDeprecatedManifest("2020-01-01", "2099-01-01"), [LEAD_SUBMIT], makeFakePorts(), ); const res = await gate.handleAction( "lead.submit", new Request("https://test.example/api/agent/actions/lead.submit", { method: "POST", body: JSON.stringify({ message: "hello world!" }), }), ); expect(res.status).toBe(200); expect(res.headers.get("Deprecation")).toBe("@1577836800"); expect(res.headers.get("Sunset")).toBe(new Date("2099-01-01").toUTCString()); expect(res.headers.get("Link")).toBe( '; rel="successor-version"', ); }); test("handleAction: canonical v1 path carries no deprecation headers", async () => { const gate = createAgentGate( makeDeprecatedManifest("2020-01-01", "2099-01-01"), [LEAD_SUBMIT], makeFakePorts(), ); const res = await gate.handleAction( "lead.submit", new Request("https://test.example/api/agent/v1/actions/lead.submit", { method: "POST", body: JSON.stringify({ message: "hello world!" }), }), ); expect(res.status).toBe(200); expect(res.headers.get("Deprecation")).toBeNull(); expect(res.headers.get("Sunset")).toBeNull(); expect(res.headers.get("Link")).toBeNull(); }); test("handleAction: alias headers are suppressed before deprecateAt", async () => { const gate = createAgentGate( makeDeprecatedManifest("2099-01-01", "2099-06-01"), [LEAD_SUBMIT], makeFakePorts(), ); const res = await gate.handleAction( "lead.submit", new Request("https://test.example/api/agent/actions/lead.submit", { method: "POST", body: JSON.stringify({ message: "hello world!" }), }), ); expect(res.status).toBe(200); expect(res.headers.get("Deprecation")).toBeNull(); expect(res.headers.get("Sunset")).toBeNull(); });