Skip to content

Commit cc6dcb0

Browse files
committed
test(spec,client,runtime): both producers of /ai/agents are enveloped now — relocate the declaration and pin it (#4053)
#4053 asked for three steps plus one relocation. The three landed: the framework's degraded fallback (#4124), cloud's `service-ai` (cloud#929), and the SDK read — which needed no change, and that is the finding rather than an omission. What was left is the relocation the issue named, and a set of records that all still say the conversion is half done. ## The declaration follows the wire `AiAgentsResponseSchema` was declared as the whole `GET /api/v1/ai/agents` body. Since the conversion it is what travels under `data`, so it now says so — the same move `SettingsNamespacePayload` made in #3843, and the reason this was a RELOCATION rather than #3983's flatten is recorded on the declaration instead of only at the two producers that implement it. ## One pin, in the package both producers share `packages/spec/src/api/ai-agents-envelope.test.ts` asserts BOTH halves of the contract — `envelopeViolations` clean AND `data` parsing as the declared payload — with the drifts pinned as negatives: the flattened conversion, the pre-#4053 bare body, an `agents` key mirrored beside `data`, and a row with no `capabilities`. Note which check catches the flatten. The envelope is *fine*; it is the payload that stops matching its declaration. A suite leading with `envelopeViolations` alone passes that body, which is exactly how it would ship. This lives in spec because nothing else can hold it. Each producer pins its own body in its own repo and neither can pin the other's; `scripts/check-route-envelope.mjs` reaches neither — dispatcher domains return `{ status, body }` for a central sender (#4049), and cloud is outside the framework guard entirely. What both producers do share is this package. ## Records that had gone stale Four comments still described cloud as unenveloped and the migration as in flight. On this route that is not cosmetic: the whole reason #4053 exists is that a wrong belief about the shape produces no error, no 403 and no log — just a hidden AI surface, which is also what a seat-less caller and a Community Edition deployment correctly see. A reader who believes the servers are bare is one step from "clean this up". Verified: spec 7174 passed (277 files, 7 new), client 205, runtime 956; `check:route-envelope` 0 ratcheted on both surfaces, self-test included; `check:role-word`, `check:nul-bytes` clean; spec and client tsc clean. No behavior change, so no changeset. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BZaiYNM5igzGk6JiibjShb
1 parent 90c2b15 commit cc6dcb0

8 files changed

Lines changed: 168 additions & 15 deletions

File tree

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
@@ -694,9 +694,9 @@ describe('HttpDispatcher extracted domains (PR-7: auth/ai)', () => {
694694
// #4053: in the declared envelope now, with `AiAgentsResponseSchema`'s
695695
// `{ agents }` RELOCATED under `data` rather than flattened to the bare
696696
// array. `unwrapResponse` returns `data`, so `client.ai.agents.list()`
697-
// reads `.agents` off it and keeps working against cloud's still-
698-
// unenveloped `service-ai` too — which is what lets the two surfaces
699-
// convert independently instead of in lockstep.
697+
// reads `.agents` off it — which is what let this surface convert on its
698+
// own and cloud's `service-ai`, the route's other producer, follow
699+
// independently instead of in lockstep. Both answer this now (cloud#929).
700700
expect(envelopeViolations(result.response?.body), JSON.stringify(result.response?.body)).toEqual([]);
701701
expect(result.response?.body?.data?.agents).toEqual([]);
702702
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
@@ -56,9 +56,10 @@ export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string,
5656
// payload under `data`, the way `SettingsNamespacePayload` moved in #3843,
5757
// not a flatten to the bare array. That distinction is load-bearing here:
5858
// `unwrapResponse` returns `data`, so `client.ai.agents.list()` reads
59-
// `.agents` off it and keeps working, and it keeps working against cloud's
60-
// `service-ai` too while that surface still answers unenveloped. Flattening
61-
// to `data: []` would have made `.agents` `undefined` — an empty catalog,
59+
// `.agents` off it. It is also what let this side convert first and cloud's
60+
// `service-ai` — the route's OTHER producer — follow independently rather
61+
// than in lockstep; both answer this shape now (cloud#929). Flattening to
62+
// `data: []` would have made `.agents` `undefined` — an empty catalog,
6263
// which `useAiSurfaceEnabled` turns into "hide the entire AI surface", and
6364
// which is indistinguishable from the legitimate seat-less/CE state.
6465
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
@@ -2770,7 +2770,8 @@ describe('HttpDispatcher', () => {
27702770
// it just travels under `data` now. `AiAgentsResponseSchema`'s
27712771
// `{ agents }` is RELOCATED, not flattened to the bare array, so
27722772
// `client.ai.agents.list()` still reads `.agents` off what
2773-
// `unwrapResponse` returns.
2773+
// `unwrapResponse` returns. Cloud's `service-ai` — the route's other
2774+
// producer — answers the same shape (cloud#929).
27742775
expect(agents.response?.body).toEqual({ success: true, data: { agents: [] }, meta: undefined });
27752776
expect(agents.response?.body?.agents).toBeUndefined();
27762777
});
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)