Skip to content

Commit 4aa9e67

Browse files
committed
fix(runtime): the /ai/agents degraded fallback answers in the declared envelope (#4053)
The last unenveloped SDK-addressable route. The framework's degraded fallback — what an open-source runtime with no service-ai answers — now returns `{ success: true, data: { agents: [] } }` via `deps.success`, and the guard's last ratchet retires with it: 0 ratcheted on both surfaces. ## Why `data: { agents }` and not `data: []` #3983 set the precedent that `data` carries the payload directly, and following it here would have looked consistent. It would also have been wrong, silently. `AiAgentsResponseSchema` is a DECLARED payload schema; share-links' `{ links }` was an ad-hoc wrapper with none. So this is the #3843 relocation — the declared payload moves under `data` unchanged, as SettingsNamespacePayload did — not a reshape. That decides the blast radius. `unwrapResponse` returns `body.data` when a body has a boolean `success` AND a `data` key, so `data: { agents }` keeps `client.ai.agents.list()` reading `.agents` off it, while `data: [...]` would make `.agents` undefined and the method answer `[]`. An empty list is not a visible failure on this route: `useAiSurfaceEnabled` gates the ENTIRE AI surface on `agents.length > 0`, and an empty catalog is the correct answer for a seat-less user (ADR-0068) or a Community-Edition deployment. Broken and legitimate look identical — no error, no 403, no log. ## Consequence: no lockstep Because the SDK reads both shapes identically, each surface converts on its own schedule. Cloud's service-ai still answers unenveloped and keeps working unchanged; objectui already reads all four shapes (objectui#2992). The "three repos in one batch" framing #4053 opened with does not apply to this variant — which is the finding, not a shortcut around it. Five tests in @objectstack/client pin both shapes, including the road not taken: the flattened body asserts [], so the cost of choosing it is recorded rather than rediscovered. The dispatcher's own fallback test now asserts the envelope and that no `agents` key survives at the top level. runtime 914, client 205, rest 505, spec 6987. All ten typecheck-job gates and the six check scripts pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CYbS3kS8xzsHNXFTzp4e2z
1 parent fcb6cfa commit 4aa9e67

5 files changed

Lines changed: 155 additions & 6 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
fix(runtime): the `/ai/agents` degraded fallback answers in the declared envelope (#4053)
6+
7+
`GET /ai/agents` was the last unenveloped SDK-addressable route. The framework's
8+
degraded fallback — what an open-source runtime with no `service-ai` answers —
9+
now returns `{ success: true, data: { agents: [] } }` via `deps.success`, and the
10+
route-envelope guard's last ratchet retires with it: **0 ratcheted on both
11+
surfaces.**
12+
13+
## Why `data: { agents }` and not `data: []`
14+
15+
#3983 set the precedent that `data` carries the payload directly, and following it
16+
here would have looked consistent. It would also have been wrong, and silently so.
17+
18+
`AiAgentsResponseSchema` is a **declared** payload schema; share-links' `{ links }`
19+
was an ad-hoc wrapper with none. So this is the #3843 relocation — the declared
20+
payload moves under `data` unchanged, the way `SettingsNamespacePayload` did —
21+
rather than a reshape.
22+
23+
That distinction decides the blast radius. `unwrapResponse` returns `body.data`
24+
when a body has a boolean `success` **and** a `data` key, so:
25+
26+
| conversion | `client.ai.agents.list()` |
27+
|---|---|
28+
| `data: { agents }` (this one) | reads `.agents` off it — **works** |
29+
| `data: [...]` (flattened) | `.agents` is `undefined`**`[]`** |
30+
31+
An empty list is not a visible failure on this route. `useAiSurfaceEnabled` gates
32+
the entire AI surface on `agents.length > 0`, and an empty catalog is the *correct*
33+
answer for a seat-less user (ADR-0068) or a Community-Edition deployment. The
34+
broken state and the legitimate one are indistinguishable — no error, no 403, no
35+
log.
36+
37+
## Consequence: no lockstep
38+
39+
Because the SDK reads both shapes identically, **each surface converts on its own
40+
schedule**. Cloud's `service-ai` still answers unenveloped and keeps working
41+
unchanged; objectui already reads all four shapes (objectui#2992). The
42+
"three repos in one batch" framing #4053 opened with does not apply to this
43+
variant.
44+
45+
Five tests in `@objectstack/client` pin it, including the road not taken: the
46+
flattened body asserts `[]`, so the cost of choosing it is recorded rather than
47+
rediscovered.
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `client.ai.agents.list()` against both shapes of `GET /api/v1/ai/agents` (#4053).
5+
*
6+
* 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.
10+
*
11+
* That is only true because the conversion RELOCATES the declared payload under
12+
* `data` (`data: { agents }`) rather than flattening it to the bare array
13+
* (`data: [...]`). The distinction is the whole reason this file exists:
14+
*
15+
* • `unwrapResponse` returns `body.data` when a body has a boolean `success`
16+
* AND a `data` key, so `list()` reads `.agents` off `{ agents }` either way.
17+
* • Flattening would make `unwrapResponse` return the array, `.agents` read
18+
* `undefined`, and `list()` answer `[]`.
19+
*
20+
* An empty list is not a visible failure here. `useAiSurfaceEnabled` gates the
21+
* ENTIRE AI surface on `agents.length > 0`, and an empty catalog is the CORRECT
22+
* answer for a seat-less user (ADR-0068) or a Community-Edition deployment with no
23+
* `service-ai`. So the broken state and the legitimate one look identical — no
24+
* error, no 403, no log — which is why the shape is pinned here rather than left
25+
* to be noticed.
26+
*/
27+
28+
import { describe, it, expect, vi } from 'vitest';
29+
import { ObjectStackClient } from './index';
30+
31+
function clientAnswering(body: unknown) {
32+
const fetchMock = vi.fn().mockResolvedValue({
33+
ok: true,
34+
status: 200,
35+
statusText: 'OK',
36+
json: async () => body,
37+
headers: new Headers(),
38+
});
39+
return {
40+
client: new ObjectStackClient({ baseUrl: 'http://localhost:3000', fetch: fetchMock }),
41+
fetchMock,
42+
};
43+
}
44+
45+
const ASK = { name: 'ask', label: 'Ask', role: 'assistant' };
46+
const BUILD = { name: 'build', label: 'Build', role: 'assistant' };
47+
48+
describe('client.ai.agents.list — both producers', () => {
49+
it('reads the UNENVELOPED body (cloud service-ai, pre-#4053)', async () => {
50+
const { client, fetchMock } = clientAnswering({ agents: [ASK, BUILD] });
51+
const agents = await client.ai.agents.list();
52+
expect(String(fetchMock.mock.calls[0][0])).toBe('http://localhost:3000/api/v1/ai/agents');
53+
expect(agents.map((a: any) => a.name)).toEqual(['ask', 'build']);
54+
});
55+
56+
it('reads the ENVELOPED body with `data: { agents }` (the framework fallback, #4053)', async () => {
57+
const { client } = clientAnswering({ success: true, data: { agents: [ASK, BUILD] } });
58+
const agents = await client.ai.agents.list();
59+
expect(agents.map((a: any) => a.name)).toEqual(['ask', 'build']);
60+
});
61+
62+
it('answers identically either way — which is what lets the surfaces convert independently', async () => {
63+
const bare = await clientAnswering({ agents: [ASK] }).client.ai.agents.list();
64+
const enveloped = await clientAnswering({ success: true, data: { agents: [ASK] } }).client.ai.agents.list();
65+
expect(enveloped).toEqual(bare);
66+
});
67+
68+
it('an empty catalog stays empty in both shapes — a seat-less caller answers this legitimately', async () => {
69+
expect(await clientAnswering({ agents: [] }).client.ai.agents.list()).toEqual([]);
70+
expect(await clientAnswering({ success: true, data: { agents: [] } }).client.ai.agents.list()).toEqual([]);
71+
});
72+
73+
it('the FLATTENED variant is why `data` keeps the `{ agents }` wrapper', async () => {
74+
// Pinning the road not taken. #3983 set the precedent that `data` carries
75+
// the payload directly, and following it here would look consistent — but
76+
// `AiAgentsResponseSchema` is a DECLARED payload schema (share-links'
77+
// `{ links }` was an ad-hoc wrapper with none), so the #3843 relocation
78+
// reading applies instead. This asserts the cost of getting it wrong:
79+
// a silently empty agent list, not a parse error.
80+
const { client } = clientAnswering({ success: true, data: [ASK, BUILD] });
81+
expect(await client.ai.agents.list()).toEqual([]);
82+
});
83+
});

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -534,7 +534,15 @@ describe('HttpDispatcher extracted domains (PR-7: auth/ai)', () => {
534534
it('/ai/agents returns an empty list (not 404) when no AI service is configured', async () => {
535535
const result = await makeDispatcher().dispatch('GET', '/ai/agents', undefined, {}, {} as any);
536536
expect(result.response?.status).toBe(200);
537-
expect(result.response?.body?.agents).toEqual([]);
537+
// #4053: in the declared envelope now, with `AiAgentsResponseSchema`'s
538+
// `{ agents }` RELOCATED under `data` rather than flattened to the bare
539+
// array. `unwrapResponse` returns `data`, so `client.ai.agents.list()`
540+
// reads `.agents` off it and keeps working against cloud's still-
541+
// unenveloped `service-ai` too — which is what lets the two surfaces
542+
// convert independently instead of in lockstep.
543+
expect(envelopeViolations(result.response?.body), JSON.stringify(result.response?.body)).toEqual([]);
544+
expect(result.response?.body?.data?.agents).toEqual([]);
545+
expect(result.response?.body?.agents).toBeUndefined();
538546
});
539547

540548
it('/ai routes 404 (service missing) for non-agents paths', async () => {

packages/runtime/src/domains/ai.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,19 @@ export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string,
4343
// service-ai is a Cloud/Enterprise package) into console error-log
4444
// spam on every page. An empty list conveys the same information
4545
// without looking like a fault. Every other /ai/* route still 404s.
46+
//
47+
// The body is the declared envelope (#4053). `data` carries
48+
// `AiAgentsResponseSchema`'s `{ agents }` — a RELOCATION of the declared
49+
// payload under `data`, the way `SettingsNamespacePayload` moved in #3843,
50+
// not a flatten to the bare array. That distinction is load-bearing here:
51+
// `unwrapResponse` returns `data`, so `client.ai.agents.list()` reads
52+
// `.agents` off it and keeps working, and it keeps working against cloud's
53+
// `service-ai` too while that surface still answers unenveloped. Flattening
54+
// to `data: []` would have made `.agents` `undefined` — an empty catalog,
55+
// which `useAiSurfaceEnabled` turns into "hide the entire AI surface", and
56+
// which is indistinguishable from the legitimate seat-less/CE state.
4657
if (method === 'GET' && subPath === '/ai/agents') {
47-
return { handled: true, response: { status: 200, body: { agents: [] } } };
58+
return { handled: true, response: deps.success({ agents: [] }) };
4859
}
4960
// [#3842] Was a hand-rolled envelope with the status in `code`. It has
5061
// no header or shape of its own, so it is simply the shared exit now.

scripts/check-route-envelope.mjs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -229,11 +229,11 @@ const DISPATCHER_DOMAINS = {
229229
note: "bridges better-auth, whose client parses ITS shapes (`{ user, session }`, `{ session: null, user: null }`, `{ success: true }`) — BaseResponseSchema does not govern them",
230230
},
231231

232-
// Kind 4 — real drift, tracked.
232+
// Kind 2 — the `{ agents: [] }` fallback moved onto `deps.success` in #4053;
233+
// what remains is the passthrough of the AI service's own result.
233234
'ai.ts': {
234-
handBuilt: 2,
235-
ratchet: '#4053',
236-
note: 'one passthrough of the AI service result (kind 2); one is the unenveloped `{ agents: [] }` fallback for GET /ai/agents — an SDK-addressable route whose conversion must land with cloud and the SDK together or `ai.agents.list()` silently returns []',
235+
handBuilt: 1,
236+
note: "passthrough of the AI service result (status and body are the service's, streaming included)",
237237
},
238238
};
239239

0 commit comments

Comments
 (0)