Skip to content

Commit 6fa1827

Browse files
os-zhuangclaude
andauthored
fix(runtime): the /ai/agents degraded fallback answers in the declared envelope (#4053) (#4124)
* 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 * fix(runtime): re-point #4058's new /ai/agents assertion at the enveloped body Semantic conflict, not a textual one. #4058 step 2 (#4086) landed on main while this branch was open and added `http-dispatcher.test.ts`'s "a stub slot 404s per route and keeps the /ai/agents empty list", which asserts the body is exactly `{ agents: [] }` — the shape #4053 enveloped one commit earlier. The two merge cleanly and the test fails, which is why CI caught it and the merge did not. The courtesy that test pins is unchanged: a stub-occupied AI slot still answers an empty list rather than a fault. The list just travels under `data` now, and the assertion says so — plus that no `agents` key survives at the top level, so a revert to the bare body fails here too. Also swept the repo for any other reader of the old shape. Three hits, all correct: the two negative assertions above, and `client/src/index.ts`'s `body?.agents` — which is exactly what the relocation variant keeps working. Merged main in rather than rebasing, per the branch's history so far. Verified against the full suite the way Test Core runs it — `turbo run test --filter='!@objectstack/dogfood'`: 129/129 packages, runtime 924. Two worktree-staleness traps on the way, both reading as code failures: - `plugin-auth` could not resolve `hono` — the merge moved the lockfile and I had not reinstalled. `pnpm install --frozen-lockfile` fixed it. - `runtime` failed under turbo but passed standalone — the gitignored `.objectstack/data/memory-driver.json` accumulating rows again. Neither is CI-visible; CI installs clean and checks out fresh. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CYbS3kS8xzsHNXFTzp4e2z * fix(tooling): drop the wildcard ledger's entry for the mount #4112 removed NOT part of this branch's work — `main` is red on it and every open PR fails the same check, so this unblocks rather than waits. `origin/main` @ 857a6cf fails `check:wildcard-fallthrough` on its own, verified in a detached worktree with no branch involved: ✗ packages/adapters/hono/src/index.ts:all `${prefix}/storage/*` DECLARED but not found by the scan. Semantic conflict between two commits that both landed, not a bug in the guard. #4112 retired that mount outright — the adapter now carries a comment where `app.all(prefix + '/storage/*')` was, because the handler spoke a storage contract that does not exist and the wildcard claimed the whole `/storage` subtree. #4116's MOUNTS ledger (#4122) was written against a tree that still had it and landed after the removal, so the entry was stale on arrival. Git merged both cleanly because they touch different lines; the guard is what noticed, which is it working on day one. Dropping the entry is the whole fix — there is nothing left to ratchet when the mount it names does not exist. Its `${prefix}/auth/*` sibling is untouched and still correctly ratcheted to #4117. After: 6 yielding / 1 ratcheted / 5 exempt (12 namespace-claiming mounts). Flagged on #4122 as well, so whoever owns it can take it back if they would rather land it themselves. Also enumerated the ESLint job properly this time — it runs ELEVEN checks, not the six I had been running (`doc-authoring`, `authz-resolver`, `release-notes` and `node-version` were the ones I was missing, plus `pnpm lint` itself). All eleven pass, and `pnpm lint` is clean once `.cache/` — the objectui console build artifact an earlier `.objectui-sha` bump left in this worktree — is removed. Those 61 "errors" were entirely that directory; CI checks out fresh and never had them. Full suite: 129/129 packages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CYbS3kS8xzsHNXFTzp4e2z --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1caf30c commit 6fa1827

6 files changed

Lines changed: 163 additions & 7 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
@@ -643,7 +643,15 @@ describe('HttpDispatcher extracted domains (PR-7: auth/ai)', () => {
643643
it('/ai/agents returns an empty list (not 404) when no AI service is configured', async () => {
644644
const result = await makeDispatcher().dispatch('GET', '/ai/agents', undefined, {}, {} as any);
645645
expect(result.response?.status).toBe(200);
646-
expect(result.response?.body?.agents).toEqual([]);
646+
// #4053: in the declared envelope now, with `AiAgentsResponseSchema`'s
647+
// `{ agents }` RELOCATED under `data` rather than flattened to the bare
648+
// array. `unwrapResponse` returns `data`, so `client.ai.agents.list()`
649+
// reads `.agents` off it and keeps working against cloud's still-
650+
// unenveloped `service-ai` too — which is what lets the two surfaces
651+
// convert independently instead of in lockstep.
652+
expect(envelopeViolations(result.response?.body), JSON.stringify(result.response?.body)).toEqual([]);
653+
expect(result.response?.body?.data?.agents).toEqual([]);
654+
expect(result.response?.body?.agents).toBeUndefined();
647655
});
648656

649657
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
@@ -50,8 +50,19 @@ export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string,
5050
// service-ai is a Cloud/Enterprise package) into console error-log
5151
// spam on every page. An empty list conveys the same information
5252
// without looking like a fault. Every other /ai/* route still 404s.
53+
//
54+
// The body is the declared envelope (#4053). `data` carries
55+
// `AiAgentsResponseSchema`'s `{ agents }` — a RELOCATION of the declared
56+
// payload under `data`, the way `SettingsNamespacePayload` moved in #3843,
57+
// not a flatten to the bare array. That distinction is load-bearing here:
58+
// `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,
62+
// which `useAiSurfaceEnabled` turns into "hide the entire AI surface", and
63+
// which is indistinguishable from the legitimate seat-less/CE state.
5364
if (method === 'GET' && subPath === '/ai/agents') {
54-
return { handled: true, response: { status: 200, body: { agents: [] } } };
65+
return { handled: true, response: deps.success({ agents: [] }) };
5566
}
5667
// [#3842] Was a hand-rolled envelope with the status in `code`. It has
5768
// no header or shape of its own, so it is simply the shared exit now.

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2624,7 +2624,14 @@ describe('HttpDispatcher', () => {
26242624
const agents = await dispatcher.handleAI('/ai/agents', 'GET', undefined, {}, { request: {} });
26252625
expect(agents.handled).toBe(true);
26262626
expect(agents.response?.status).toBe(200);
2627-
expect(agents.response?.body).toEqual({ agents: [] });
2627+
// #4053 enveloped this body while #4058 was in flight. The courtesy
2628+
// this test pins is unchanged — an empty list rather than a fault —
2629+
// it just travels under `data` now. `AiAgentsResponseSchema`'s
2630+
// `{ agents }` is RELOCATED, not flattened to the bare array, so
2631+
// `client.ai.agents.list()` still reads `.agents` off what
2632+
// `unwrapResponse` returns.
2633+
expect(agents.response?.body).toEqual({ success: true, data: { agents: [] }, meta: undefined });
2634+
expect(agents.response?.body?.agents).toBeUndefined();
26282635
});
26292636

26302637
// Discovery must say exactly what the domains do — one predicate feeds

scripts/check-route-envelope.mjs

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

235-
// Kind 4 — real drift, tracked.
235+
// Kind 2 — the `{ agents: [] }` fallback moved onto `deps.success` in #4053;
236+
// what remains is the passthrough of the AI service's own result.
236237
'ai.ts': {
237-
handBuilt: 2,
238-
ratchet: '#4053',
239-
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 []',
238+
handBuilt: 1,
239+
note: "passthrough of the AI service result (status and body are the service's, streaming included)",
240240
},
241241
};
242242

0 commit comments

Comments
 (0)