Skip to content

Commit 469b3d4

Browse files
authored
test(spec,client,runtime): both producers of /ai/agents are enveloped now — relocate the declaration and pin it (#4053) (#4238)
Closes out #4053. Both producers converted (#4124, cloud#929); the SDK needed no change because the conversion RELOCATED the declared payload under `data` rather than flattening it. - spec: `AiAgentsResponseSchema` now describes the `data` payload, not the whole body — the #3843 relocation, recorded on the declaration itself. - spec: new `ai-agents-envelope.test.ts` pins both halves of the contract (envelope conformance AND `data` matching the declared payload), with the flatten, the bare body, the mirrored `agents` key and a capabilities-less row as negatives. It lives here because neither producer's repo can pin the other's and `check-route-envelope.mjs` reaches neither. - runtime/client: four comments that still described cloud as unenveloped and the migration as in flight. Empty changeset — releases nothing.
1 parent bdb4e8d commit 469b3d4

9 files changed

Lines changed: 172 additions & 15 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
test(spec,client,runtime): relocate `AiAgentsResponseSchema` onto the `data` payload it now describes, and pin `GET /ai/agents` against its declaration (#4053). Both producers of the route — the framework's degraded fallback (#4124) and cloud's `service-ai` (cloud#929) — converted onto the declared envelope; this closes out the declaration half and corrects four comments that still described the migration as in flight. Comments, a doc-block and one new test file; releases nothing.

packages/client/src/ai-agents-envelope.test.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
* `client.ai.agents.list()` against both shapes of `GET /api/v1/ai/agents` (#4053).
55
*
66
* This route has two producers — the framework dispatcher's degraded fallback when
7-
* no AI service is registered, and cloud's `service-ai` — and it is mid-migration
8-
* onto the declared envelope. The framework side converted first; cloud's has not
9-
* yet, so both shapes are live in the fleet and the SDK has to read either.
7+
* no AI service is registered, and cloud's `service-ai`. Both answer in the declared
8+
* envelope now: the framework side converted first (#4124) and cloud followed on its
9+
* own schedule (cloud#929). The unenveloped shape is still read here because an SDK
10+
* outlives the server it is pointed at — a current client talking to a deployment
11+
* from before the conversion is ordinary, not hypothetical.
1012
*
11-
* That is only true because the conversion RELOCATES the declared payload under
13+
* That independence was only possible because the conversion RELOCATES the payload under
1214
* `data` (`data: { agents }`) rather than flattening it to the bare array
1315
* (`data: [...]`). The distinction is the whole reason this file exists:
1416
*
@@ -46,14 +48,14 @@ const ASK = { name: 'ask', label: 'Ask', role: 'assistant' };
4648
const BUILD = { name: 'build', label: 'Build', role: 'assistant' };
4749

4850
describe('client.ai.agents.list — both producers', () => {
49-
it('reads the UNENVELOPED body (cloud service-ai, pre-#4053)', async () => {
51+
it('reads the UNENVELOPED body — a pre-#4053 server this client still has to talk to', async () => {
5052
const { client, fetchMock } = clientAnswering({ agents: [ASK, BUILD] });
5153
const agents = await client.ai.agents.list();
5254
expect(String(fetchMock.mock.calls[0][0])).toBe('http://localhost:3000/api/v1/ai/agents');
5355
expect(agents.map((a: any) => a.name)).toEqual(['ask', 'build']);
5456
});
5557

56-
it('reads the ENVELOPED body with `data: { agents }` (the framework fallback, #4053)', async () => {
58+
it('reads the ENVELOPED body with `data: { agents }` — what both producers serve (#4053)', async () => {
5759
const { client } = clientAnswering({ success: true, data: { agents: [ASK, BUILD] } });
5860
const agents = await client.ai.agents.list();
5961
expect(agents.map((a: any) => a.name)).toEqual(['ask', 'build']);

packages/client/src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3798,6 +3798,16 @@ export class ObjectStackClient {
37983798
* Agents the CALLER may chat with — the route filters by the caller's
37993799
* permissions (ADR-0049), so an empty list is a legitimate answer for a
38003800
* seat-less user rather than an error to retry.
3801+
*
3802+
* The `.agents` read below survived #4053 unchanged, and that is the
3803+
* conclusion rather than an oversight. `AiAgentsResponseSchema` moved
3804+
* under the envelope's `data` as a RELOCATION (#3843's precedent), so
3805+
* `unwrapResponse` hands back `{ agents }` from an enveloped producer and
3806+
* the same object from a pre-conversion one. Had the route flattened to
3807+
* `data: [...]` (#3983's precedent) this line would read `undefined` and
3808+
* answer `[]` — an empty catalog, which is what the console reads as "this
3809+
* caller has no AI", so the regression would have been invisible.
3810+
* `ai-agents-envelope.test.ts` pins both readings.
38013811
*/
38023812
list: async (): Promise<AiAgentSummary[]> => {
38033813
const route = this.getRoute('ai');

packages/runtime/src/domain-handler-registry.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -698,9 +698,9 @@ describe('HttpDispatcher extracted domains (PR-7: auth/ai)', () => {
698698
// #4053: in the declared envelope now, with `AiAgentsResponseSchema`'s
699699
// `{ agents }` RELOCATED under `data` rather than flattened to the bare
700700
// array. `unwrapResponse` returns `data`, so `client.ai.agents.list()`
701-
// reads `.agents` off it and keeps working against cloud's still-
702-
// unenveloped `service-ai` too — which is what lets the two surfaces
703-
// convert independently instead of in lockstep.
701+
// reads `.agents` off it — which is what let this surface convert on its
702+
// own and cloud's `service-ai`, the route's other producer, follow
703+
// independently instead of in lockstep. Both answer this now (cloud#929).
704704
expect(envelopeViolations(result.response?.body), JSON.stringify(result.response?.body)).toEqual([]);
705705
expect(result.response?.body?.data?.agents).toEqual([]);
706706
expect(result.response?.body?.agents).toBeUndefined();

packages/runtime/src/domains/ai.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,10 @@ export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string,
5757
// payload under `data`, the way `SettingsNamespacePayload` moved in #3843,
5858
// not a flatten to the bare array. That distinction is load-bearing here:
5959
// `unwrapResponse` returns `data`, so `client.ai.agents.list()` reads
60-
// `.agents` off it and keeps working, and it keeps working against cloud's
61-
// `service-ai` too while that surface still answers unenveloped. Flattening
62-
// to `data: []` would have made `.agents` `undefined` — an empty catalog,
60+
// `.agents` off it. It is also what let this side convert first and cloud's
61+
// `service-ai` — the route's OTHER producer — follow independently rather
62+
// than in lockstep; both answer this shape now (cloud#929). Flattening to
63+
// `data: []` would have made `.agents` `undefined` — an empty catalog,
6364
// which `useAiSurfaceEnabled` turns into "hide the entire AI surface", and
6465
// which is indistinguishable from the legitimate seat-less/CE state.
6566
if (method === 'GET' && subPath === '/ai/agents') {

packages/runtime/src/http-dispatcher.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2785,7 +2785,8 @@ describe('HttpDispatcher', () => {
27852785
// it just travels under `data` now. `AiAgentsResponseSchema`'s
27862786
// `{ agents }` is RELOCATED, not flattened to the bare array, so
27872787
// `client.ai.agents.list()` still reads `.agents` off what
2788-
// `unwrapResponse` returns.
2788+
// `unwrapResponse` returns. Cloud's `service-ai` — the route's other
2789+
// producer — answers the same shape (cloud#929).
27892790
expect(agents.response?.body).toEqual({ success: true, data: { agents: [] }, meta: undefined });
27902791
expect(agents.response?.body?.agents).toBeUndefined();
27912792
});
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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+
});

packages/spec/src/api/envelope-violations.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,11 @@ describe('envelopeViolations — what the schema MISSES', () => {
8888
});
8989

9090
it('a payload left at the top level beside the flag', () => {
91-
// The shape `GET /ai/agents` still answers in, minus the flag (#4053).
91+
// The shim `GET /ai/agents` would have grown had it kept `agents` alive
92+
// beside `data` through the conversion. It did not — both producers moved
93+
// the payload rather than mirroring it (#4053) — and this is the check that
94+
// would have said so. See `ai-agents-envelope.test.ts` for that route's own
95+
// pins, including the half this predicate deliberately does not cover.
9296
const body = { success: true, data: [], agents: [] };
9397
expect(parses(body)).toBe(true);
9498
expect(conformant(body)).toBe(false);

packages/spec/src/api/protocol.zod.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1069,7 +1069,23 @@ export const AiAgentSummarySchema = lazySchema(() => z.object({
10691069
}));
10701070

10711071
/**
1072-
* `GET /api/v1/ai/agents`.
1072+
* `GET /api/v1/ai/agents` — the **`data` payload**, not the whole body.
1073+
*
1074+
* The body is the platform envelope (`BaseResponseSchema`), and this schema is
1075+
* what travels under `data`: `{ success: true, data: { agents: [...] } }`. It
1076+
* was declared as the whole body until #4053 converted the route, and moving it
1077+
* under `data` was a RELOCATION — the payload is unchanged, exactly as
1078+
* `SettingsNamespacePayload` moved in #3843, not a flatten to the bare array the
1079+
* way #3983 reshaped `/share-links`.
1080+
*
1081+
* That distinction is load-bearing, which is why it is recorded on the
1082+
* declaration rather than only at the two producers. `unwrapResponse` returns
1083+
* `data`, so `client.ai.agents.list()` reads `.agents` off this object; flattening
1084+
* to `data: [...]` would make that read `undefined` → an empty catalog, which
1085+
* `useAiSurfaceEnabled` turns into "hide the entire AI surface" — the same thing a
1086+
* seat-less caller and a Community-Edition deployment correctly see, with no
1087+
* error, no 403 and no log to tell them apart. `ai-agents-envelope.test.ts` pins
1088+
* both halves.
10731089
*
10741090
* ACCESS-AWARE: the route returns only agents the CALLER may chat with
10751091
* (ADR-0049), so an empty list is a legitimate answer for a seat-less user —

0 commit comments

Comments
 (0)