|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * `GET /api/v1/ai/agents` — the declaration tied to the wire (#4053). |
| 5 | + * |
| 6 | + * The route has TWO producers in two repos: the framework dispatcher's degraded |
| 7 | + * fallback when no AI service is registered (`runtime/src/domains/ai.ts`, the |
| 8 | + * open-source default — `service-ai` is Cloud/EE) and cloud's `service-ai` |
| 9 | + * `buildAgentRoutes`. Both now answer in the declared envelope, with |
| 10 | + * `AiAgentsResponseSchema` RELOCATED under `data` rather than flattened to the |
| 11 | + * bare array (framework#4124, cloud#929). |
| 12 | + * |
| 13 | + * Each producer pins its own body in its own repo. Neither repo can pin the |
| 14 | + * OTHER one, and `scripts/check-route-envelope.mjs` reaches neither — it scans |
| 15 | + * route modules that write via `res.json(...)`, while the dispatcher domains |
| 16 | + * return `{ status, body }` for a central sender (#4049) and cloud is outside |
| 17 | + * the framework guard entirely. What both producers DO share is this package, |
| 18 | + * which is where the payload is declared. So the agreement is pinned here, once, |
| 19 | + * against the declaration itself. |
| 20 | + * |
| 21 | + * Why bother, when nothing is broken today: the failure this route has is |
| 22 | + * invisible. `useAiSurfaceEnabled` gates the ENTIRE AI surface on |
| 23 | + * `agents.length > 0`, deliberately — the route is access-filtered per caller, |
| 24 | + * making it the only signal that is both edition- and user-aware (ADR-0068). So |
| 25 | + * an empty catalog is a CORRECT answer: |
| 26 | + * |
| 27 | + * seat-less user → AI surface hidden (correct) |
| 28 | + * Community Edition, no service-ai → AI surface hidden (correct) |
| 29 | + * a producer flattening `data` → AI surface hidden (the bug) |
| 30 | + * |
| 31 | + * No error, no 403, no failed request, no log. There is no downstream signal that |
| 32 | + * would catch a producer drifting, which is why the shape is asserted rather than |
| 33 | + * described. |
| 34 | + */ |
| 35 | + |
| 36 | +import { describe, expect, it } from 'vitest'; |
| 37 | +import { envelopeViolations } from './contract.zod'; |
| 38 | +import { AiAgentsResponseSchema } from './protocol.zod'; |
| 39 | + |
| 40 | +const ASK = { |
| 41 | + name: 'ask', label: 'Ask', role: 'assistant', |
| 42 | + capabilities: { authoring: false, canvas: false, debug: false, resume: false }, |
| 43 | +}; |
| 44 | +const BUILD = { |
| 45 | + name: 'build', label: 'Build', role: 'authoring', |
| 46 | + capabilities: { authoring: true, canvas: true, debug: true, resume: true }, |
| 47 | +}; |
| 48 | + |
| 49 | +/** Both halves of the contract: a conformant envelope AND a conformant `data`. */ |
| 50 | +function servesTheDeclaredShape(body: unknown): boolean { |
| 51 | + if (envelopeViolations(body).length > 0) return false; |
| 52 | + return AiAgentsResponseSchema.safeParse((body as { data?: unknown }).data).success; |
| 53 | +} |
| 54 | + |
| 55 | +describe('GET /ai/agents — the body both producers now answer with', () => { |
| 56 | + it('the populated catalog: envelope-conformant, and `data` IS the declared payload', () => { |
| 57 | + const body = { success: true, data: { agents: [ASK, BUILD] } }; |
| 58 | + expect(envelopeViolations(body)).toEqual([]); |
| 59 | + expect(AiAgentsResponseSchema.safeParse(body.data).success).toBe(true); |
| 60 | + }); |
| 61 | + |
| 62 | + it('the empty catalog — the framework fallback, and a seat-less caller', () => { |
| 63 | + // Two different reasons for the same body, both correct. The fallback emits |
| 64 | + // exactly this when no AI service is registered; `service-ai` emits it for a |
| 65 | + // caller whose permission set reaches no agent (ADR-0049). |
| 66 | + const body = { success: true, data: { agents: [] } }; |
| 67 | + expect(envelopeViolations(body)).toEqual([]); |
| 68 | + expect(AiAgentsResponseSchema.safeParse(body.data).success).toBe(true); |
| 69 | + }); |
| 70 | + |
| 71 | + it('`meta` beside the payload is fine — `deps.success` builds it', () => { |
| 72 | + expect(servesTheDeclaredShape({ success: true, data: { agents: [] }, meta: undefined })).toBe(true); |
| 73 | + expect(servesTheDeclaredShape({ success: true, data: { agents: [] }, meta: { timestamp: 't' } })).toBe(true); |
| 74 | + }); |
| 75 | +}); |
| 76 | + |
| 77 | +describe('GET /ai/agents — the shapes a producer must NOT drift back into', () => { |
| 78 | + it('the FLATTENED conversion — the one that empties the agent list', () => { |
| 79 | + // #3983 set the precedent that `data` carries the payload directly, and |
| 80 | + // following it here would look consistent. It is the wrong reading: |
| 81 | + // `AiAgentsResponseSchema` is a DECLARED payload schema, where share-links' |
| 82 | + // `{ links }` was an ad-hoc wrapper with none — so this is the #3843 |
| 83 | + // relocation, not a reshape. |
| 84 | + // |
| 85 | + // Note WHICH check catches it. The envelope is fine; it is the payload that |
| 86 | + // no longer matches its declaration. A suite leading with `envelopeViolations` |
| 87 | + // alone would pass this body, which is exactly how it would ship. |
| 88 | + const body = { success: true, data: [ASK, BUILD] }; |
| 89 | + expect(envelopeViolations(body)).toEqual([]); |
| 90 | + expect(AiAgentsResponseSchema.safeParse(body.data).success).toBe(false); |
| 91 | + expect(servesTheDeclaredShape(body)).toBe(false); |
| 92 | + }); |
| 93 | + |
| 94 | + it('the pre-#4053 bare body — no flag for `unwrapResponse` to key on', () => { |
| 95 | + const body = { agents: [ASK] }; |
| 96 | + expect(servesTheDeclaredShape(body)).toBe(false); |
| 97 | + expect(envelopeViolations(body)).toContain('success is missing, must be a boolean'); |
| 98 | + }); |
| 99 | + |
| 100 | + it('the duplicate-payload shim — `agents` kept alive beside `data`', () => { |
| 101 | + // The drift #4038 / #4049 removed from `/share-links`: the payload under |
| 102 | + // both spellings, so no consumer ever has to choose and neither dialect |
| 103 | + // dies. Here the mirrored key catches it; the payload itself is valid. |
| 104 | + const body = { success: true, data: { agents: [ASK] }, agents: [ASK] }; |
| 105 | + expect(envelopeViolations(body)).toEqual([ |
| 106 | + 'stray top-level key `agents` — the payload belongs under `data`', |
| 107 | + ]); |
| 108 | + expect(servesTheDeclaredShape(body)).toBe(false); |
| 109 | + }); |
| 110 | + |
| 111 | + it('an agent row without `capabilities` — the field UI affordances branch on', () => { |
| 112 | + // cloud#816 / ADR-0057 "B+": hosts render debug drawer, Live Canvas and |
| 113 | + // resume-vs-fresh BY CAPABILITY. A row missing it is not a row this route |
| 114 | + // returns, so it fails the payload half even inside a clean envelope. |
| 115 | + const body = { success: true, data: { agents: [{ name: 'build', label: 'Build', role: 'authoring' }] } }; |
| 116 | + expect(envelopeViolations(body)).toEqual([]); |
| 117 | + expect(servesTheDeclaredShape(body)).toBe(false); |
| 118 | + }); |
| 119 | +}); |
0 commit comments