Skip to content

Commit 9a13622

Browse files
os-zhuangclaude
andauthored
fix(chatbot): read the agent catalog in the declared envelope too (objectstack#4053) (#2992)
`GET /api/v1/ai/agents` is served by two producers — the framework dispatcher's degraded fallback when no AI service is registered, and cloud's service-ai — and it is one of the last SDK-addressable routes still answering outside the declared `{ success: true, data }` envelope. useAgents read only `{ agents }` and a bare array, so the day either producer converts, the parse misses. That miss is unusually dangerous here, which is why it is worth getting ahead of rather than fixing after. The catalog is not just data: useAiSurfaceEnabled gates the ENTIRE AI surface on `agents.length > 0`, because the route is access-filtered per caller and is therefore the only signal that is both edition- and user-aware (ADR-0068). An empty list is the correct answer for a seat-less user or a Community-Edition deployment with no service-ai — so a parse miss and the legitimate hidden state are indistinguishable: no error, no 403, no log, just the FAB, the top-bar link and the designer's "Ask AI" quietly gone for every user. Nothing downstream would catch it. extractAgentList now folds all four shapes to the same list — bare array, `{ agents }`, `{ success: true, data: [...] }`, `{ success: true, data: { agents } }` — detecting the envelope the way ObjectStackClient.unwrapResponse does (a BOOLEAN `success`), so the two readers cannot disagree about what counts as one. It is exported and pure so the shapes are testable without standing up the hook. No behaviour change against any server shipping today: the shapes that worked before parse identically. This only removes the lockstep requirement, so the server side can convert on its own schedule instead of having to land with a console release — the same consumer-first ordering used for SharedRecordPage in objectstack#3983. Nine tests; reverting to the previous two-shape read fails five of them. Claude-Session: https://claude.ai/code/session_01CYbS3kS8xzsHNXFTzp4e2z Co-authored-by: Claude <noreply@anthropic.com>
1 parent 02aef0c commit 9a13622

3 files changed

Lines changed: 168 additions & 3 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@object-ui/plugin-chatbot': patch
3+
---
4+
5+
**Read the agent catalog in the declared envelope too, before the server converts.**
6+
7+
`GET /api/v1/ai/agents` is served by two producers — the framework dispatcher's
8+
degraded fallback when no AI service is registered, and cloud's `service-ai` — and
9+
it is one of the last SDK-addressable routes still answering outside the platform's
10+
declared `{ success: true, data }` envelope (objectstack#4053). `useAgents` read
11+
only `{ agents }` and a bare array, so the day either producer converts, the parse
12+
would miss.
13+
14+
That miss is unusually dangerous on this particular route, which is why it is worth
15+
getting ahead of rather than fixing after. The catalog is not just data:
16+
`useAiSurfaceEnabled` gates the **entire AI surface** on `agents.length > 0`,
17+
because the route is access-filtered per caller and is therefore the only signal
18+
that is both edition- and user-aware (ADR-0068). An empty list is the correct
19+
answer for a seat-less user or a Community-Edition deployment with no `service-ai`
20+
— so a parse miss and the legitimate hidden state are **indistinguishable**: no
21+
error, no 403, no log, just the FAB, the top-bar link and the designer's "Ask AI"
22+
quietly gone for everyone.
23+
24+
`extractAgentList` now folds all four shapes to the same list — a bare array,
25+
`{ agents }`, `{ success: true, data: [...] }`, and `{ success: true, data:
26+
{ agents } }` — detecting the envelope the way `ObjectStackClient.unwrapResponse`
27+
does (a **boolean** `success`), so the two readers cannot disagree about what
28+
counts as one. Nine tests cover it; reverting to the previous two-shape read fails
29+
five of them.
30+
31+
No behaviour change against any server shipping today: the shapes that worked
32+
before still parse identically. This only removes the lockstep requirement, so the
33+
server side can convert on its own schedule.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `GET /api/v1/ai/agents` shape tolerance (objectstack#4053).
5+
*
6+
* Two producers serve this route — the framework dispatcher's degraded fallback
7+
* when no AI service is registered, and cloud's `service-ai` — and it is
8+
* mid-migration onto the platform's declared `{ success: true, data }` envelope.
9+
*
10+
* Why this file exists rather than a comment: an unrecognised shape here does not
11+
* throw, warn, or log. It yields an empty list, and `useAiSurfaceEnabled` turns an
12+
* empty list into "hide the entire AI surface". That is ALSO the correct behaviour
13+
* for a seat-less user (ADR-0068) or a Community-Edition deployment with no
14+
* `service-ai` — so a parse miss looks exactly like the legitimate hidden state.
15+
* There is no downstream signal that would catch it: no failing request, no 403,
16+
* no visible difference. The tests are the only thing standing between an envelope
17+
* conversion on the server and the AI UI quietly vanishing for every user.
18+
*
19+
* Teaching the reader all three shapes BEFORE any producer converts is what lets
20+
* the server side move on its own schedule instead of landing in lockstep with a
21+
* console release.
22+
*/
23+
24+
import { describe, expect, it } from 'vitest';
25+
import { extractAgentList } from '../useAgents';
26+
27+
const ASK = { name: 'ask', label: 'Ask' };
28+
const BUILD = { name: 'build', label: 'Build' };
29+
30+
describe('extractAgentList — every shape this route answers in', () => {
31+
it('reads `{ agents }` — what both producers send today', () => {
32+
expect(extractAgentList({ agents: [ASK, BUILD] })).toEqual([ASK, BUILD]);
33+
});
34+
35+
it('reads a bare array', () => {
36+
expect(extractAgentList([ASK, BUILD])).toEqual([ASK, BUILD]);
37+
});
38+
39+
it('reads the declared envelope with `data` as the array', () => {
40+
// The shape objectstack#3983 set the precedent for: `data` carries the
41+
// payload directly. This is the variant that silently emptied the list.
42+
expect(extractAgentList({ success: true, data: [ASK, BUILD] })).toEqual([ASK, BUILD]);
43+
});
44+
45+
it('reads the declared envelope with `data: { agents }`', () => {
46+
// The other plausible conversion — relocating the existing payload under
47+
// `data` rather than flattening it. Both must read the same.
48+
expect(extractAgentList({ success: true, data: { agents: [ASK, BUILD] } })).toEqual([ASK, BUILD]);
49+
});
50+
51+
it('agrees across all four shapes — the point of one extractor', () => {
52+
const shapes: unknown[] = [
53+
{ agents: [ASK] },
54+
[ASK],
55+
{ success: true, data: [ASK] },
56+
{ success: true, data: { agents: [ASK] } },
57+
];
58+
for (const s of shapes) expect(extractAgentList(s)).toEqual([ASK]);
59+
});
60+
});
61+
62+
describe('extractAgentList — empty is a real answer, not a fallback', () => {
63+
it('an empty catalog stays empty in every shape', () => {
64+
// A seat-less user gets this legitimately (ADR-0068), so it must not be
65+
// conflated with a miss — but it must not throw either.
66+
expect(extractAgentList({ agents: [] })).toEqual([]);
67+
expect(extractAgentList([])).toEqual([]);
68+
expect(extractAgentList({ success: true, data: [] })).toEqual([]);
69+
expect(extractAgentList({ success: true, data: { agents: [] } })).toEqual([]);
70+
});
71+
72+
it('never throws on a shape it does not recognise', () => {
73+
// Callers gate the whole AI surface on this; a throw would surface as a
74+
// load error rather than a hidden surface, but neither is worth a crash.
75+
for (const junk of [null, undefined, 0, 'nope', {}, { data: null }, { agents: 'no' }]) {
76+
expect(extractAgentList(junk)).toEqual([]);
77+
}
78+
});
79+
});
80+
81+
describe('extractAgentList — envelope detection matches unwrapResponse', () => {
82+
it('keys on a BOOLEAN `success`, as the SDK does', () => {
83+
// `ObjectStackClient.unwrapResponse` treats a body as an envelope iff
84+
// `typeof body.success === 'boolean'`. Diverging from that rule is how two
85+
// readers of one route end up disagreeing about which shape they got.
86+
expect(extractAgentList({ success: true, data: [ASK] })).toEqual([ASK]);
87+
expect(extractAgentList({ success: false, data: [ASK] })).toEqual([ASK]);
88+
});
89+
90+
it('does NOT treat a non-boolean `success` as an envelope', () => {
91+
// A payload that happens to carry a truthy `success` field is data, not an
92+
// envelope — unwrapping it would hide the real agents.
93+
expect(extractAgentList({ success: 'yes', agents: [ASK] })).toEqual([ASK]);
94+
expect(extractAgentList({ success: 1, agents: [ASK] })).toEqual([ASK]);
95+
});
96+
});

packages/plugin-chatbot/src/useAgents.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,43 @@ export function resolveDefaultAgentName(
149149
return agents[0].name;
150150
}
151151

152+
/**
153+
* Pull the agent list out of whatever `GET /api/v1/ai/agents` answered.
154+
*
155+
* This route is served by more than one producer and is mid-migration onto the
156+
* platform's declared `{ success: true, data }` envelope
157+
* (objectstack#4053), so three shapes have to read the same:
158+
*
159+
* [ … ] a bare array
160+
* { agents: [ … ] } today's shape, both producers
161+
* { success: true, data: … } the declared envelope, whose `data`
162+
* may be the array or `{ agents }`
163+
*
164+
* The envelope is detected the way `ObjectStackClient.unwrapResponse` detects
165+
* it — a **boolean** `success` — so the two agree on what counts as one.
166+
*
167+
* Reading the envelope BEFORE any producer emits it is deliberate, and it is the
168+
* whole point of doing this ahead of the conversion. An unrecognised shape here
169+
* does not throw or warn: it yields an empty list, and `useAiSurfaceEnabled`
170+
* turns an empty list into "hide the entire AI surface". That is also the
171+
* CORRECT behaviour for a seat-less user or a Community-Edition deployment with
172+
* no `service-ai` — so a parse miss is indistinguishable from the legitimate
173+
* hidden state, with no error, no 403 and no log to notice it by. Teaching the
174+
* consumer first means the producer can convert on its own schedule instead of
175+
* having to land in lockstep with this file.
176+
*/
177+
export function extractAgentList(payload: unknown): RawAgent[] {
178+
const isEnvelope =
179+
!!payload && typeof payload === 'object' && !Array.isArray(payload) &&
180+
typeof (payload as { success?: unknown }).success === 'boolean';
181+
182+
const body = isEnvelope ? (payload as { data?: unknown }).data : payload;
183+
184+
if (Array.isArray(body)) return body as RawAgent[];
185+
const agents = (body as { agents?: unknown } | null | undefined)?.agents;
186+
return Array.isArray(agents) ? (agents as RawAgent[]) : [];
187+
}
188+
152189
function normalize(raw: RawAgent[]): AgentDescriptor[] {
153190
return raw
154191
.filter((a) => typeof a?.name === 'string' && a.name.length > 0)
@@ -196,9 +233,8 @@ function fetchAgentsCached(
196233
})
197234
.then(async (res) => {
198235
if (!res.ok) throw new Error(`Failed to load agents (${res.status})`);
199-
const payload = (await res.json()) as { agents?: RawAgent[] } | RawAgent[];
200-
const list = Array.isArray(payload) ? payload : payload?.agents ?? [];
201-
return normalize(list);
236+
// Bare array, `{ agents }`, or the declared envelope — see `extractAgentList`.
237+
return normalize(extractAgentList(await res.json()));
202238
})
203239
.then((normalized) => {
204240
agentsCache.set(apiBase, { data: normalized, timestamp: Date.now() });

0 commit comments

Comments
 (0)