Skip to content

Commit 5fac840

Browse files
authored
Merge pull request #7559 from elizaOS/wip/zod-typed-routes-round-5
feat(shared): zod-typed routes round 5 — create + presence + run steering
2 parents 10a0d4a + 2e12080 commit 5fac840

6 files changed

Lines changed: 345 additions & 73 deletions

File tree

packages/agent/src/api/apps-routes.ts

Lines changed: 50 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,15 @@ import {
1111
type AppSessionActionResult,
1212
createGeneratedAppHeroSvg,
1313
hasAppInterface,
14+
PostCreateAppRequestSchema,
1415
PostInstallAppRequestSchema,
1516
PostLaunchAppRequestSchema,
1617
PostLoadFromDirectoryRequestSchema,
18+
PostOverlayPresenceRequestSchema,
1719
PostRelaunchAppRequestSchema,
1820
PostReplaceFavoritesRequestSchema,
21+
PostRunControlRequestSchema,
22+
PostRunMessageRequestSchema,
1923
PostStopAppRequestSchema,
2024
PutAppPermissionsRequestSchema,
2125
PutFavoriteAppRequestSchema,
@@ -491,27 +495,6 @@ function parseCapturedBody(body: string): Record<string, unknown> | null {
491495
}
492496
}
493497

494-
function readSteeringContent(
495-
body: Record<string, unknown> | null,
496-
): string | null {
497-
const content =
498-
typeof body?.content === "string"
499-
? body.content
500-
: typeof body?.message === "string"
501-
? body.message
502-
: null;
503-
const trimmed = content?.trim() ?? "";
504-
return trimmed.length > 0 ? trimmed : null;
505-
}
506-
507-
function readSteeringAction(
508-
body: Record<string, unknown> | null,
509-
): "pause" | "resume" | null {
510-
const action = typeof body?.action === "string" ? body.action.trim() : "";
511-
if (action === "pause" || action === "resume") return action;
512-
return null;
513-
}
514-
515498
function isAppRunSummary(value: unknown): value is AppRunSummary {
516499
return (
517500
typeof value === "object" &&
@@ -816,7 +799,7 @@ export async function handleAppsRoutes(
816799

817800
if (method === "PUT") {
818801
const rawBody = await readJsonBody<Record<string, unknown>>(req, res);
819-
if (rawBody === null || rawBody === undefined) return true;
802+
if (rawBody === null) return true;
820803
const parsed = PutFavoriteAppRequestSchema.safeParse(rawBody);
821804
if (!parsed.success) {
822805
const issue = parsed.error.issues[0];
@@ -845,7 +828,7 @@ export async function handleAppsRoutes(
845828
return true;
846829
}
847830
const rawBody = await readJsonBody<Record<string, unknown>>(req, res);
848-
if (rawBody === null || rawBody === undefined) return true;
831+
if (rawBody === null) return true;
849832
const parsed = PostReplaceFavoritesRequestSchema.safeParse(rawBody);
850833
if (!parsed.success) {
851834
const issue = parsed.error.issues[0];
@@ -871,11 +854,20 @@ export async function handleAppsRoutes(
871854

872855
// Dashboard heartbeat for overlay apps (companion, etc.) — no AppManager run.
873856
if (method === "POST" && pathname === "/api/apps/overlay-presence") {
874-
const body = await readJsonBody<{ appName?: string | null }>(req, res);
875-
if (!body) return true;
876-
const raw = body.appName;
877-
const appName =
878-
typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : null;
857+
const rawBody = await readJsonBody<Record<string, unknown>>(req, res);
858+
if (rawBody === null) return true;
859+
const parsed = PostOverlayPresenceRequestSchema.safeParse(rawBody);
860+
if (!parsed.success) {
861+
const issue = parsed.error.issues[0];
862+
const issuePath = issue?.path?.join(".") ?? "<root>";
863+
error(
864+
res,
865+
`Invalid request body at ${issuePath}: ${issue?.message ?? "validation failed"}`,
866+
400,
867+
);
868+
return true;
869+
}
870+
const { appName } = parsed.data;
879871
setOverlayAppPresence(appName);
880872
json(res, { ok: true, appName });
881873
return true;
@@ -946,29 +938,18 @@ export async function handleAppsRoutes(
946938
return true;
947939
}
948940

949-
const body =
950-
subroute === "message"
951-
? await readJsonBody<{ content?: string }>(req, res)
952-
: await readJsonBody<{ action?: "pause" | "resume" }>(req, res);
953-
if (!body) return true;
954-
955-
const normalizedBody =
941+
const rawBody = await readJsonBody<Record<string, unknown>>(req, res);
942+
if (rawBody === null) return true;
943+
const parsed =
956944
subroute === "message"
957-
? {
958-
content: readSteeringContent(body),
959-
}
960-
: {
961-
action: readSteeringAction(body),
962-
};
963-
if (
964-
(subroute === "message" && !normalizedBody.content) ||
965-
(subroute === "control" && !normalizedBody.action)
966-
) {
945+
? PostRunMessageRequestSchema.safeParse(rawBody)
946+
: PostRunControlRequestSchema.safeParse(rawBody);
947+
if (!parsed.success) {
948+
const issue = parsed.error.issues[0];
949+
const issuePath = issue?.path?.join(".") ?? "<root>";
967950
error(
968951
res,
969-
subroute === "message"
970-
? "content is required"
971-
: "action must be pause or resume",
952+
`Invalid request body at ${issuePath}: ${issue?.message ?? "validation failed"}`,
972953
400,
973954
);
974955
return true;
@@ -978,7 +959,7 @@ export async function handleAppsRoutes(
978959
ctx,
979960
run,
980961
subroute,
981-
normalizedBody as Record<string, unknown>,
962+
parsed.data as Record<string, unknown>,
982963
);
983964
if (!result) {
984965
error(res, "Run steering failed", 500);
@@ -1024,7 +1005,7 @@ export async function handleAppsRoutes(
10241005
if (method === "POST" && pathname === "/api/apps/launch") {
10251006
try {
10261007
const rawBody = await readJsonBody<Record<string, unknown>>(req, res);
1027-
if (rawBody === null || rawBody === undefined) return true;
1008+
if (rawBody === null) return true;
10281009
const parsed = PostLaunchAppRequestSchema.safeParse(rawBody);
10291010
if (!parsed.success) {
10301011
const issue = parsed.error.issues[0];
@@ -1053,7 +1034,7 @@ export async function handleAppsRoutes(
10531034
if (method === "POST" && pathname === "/api/apps/install") {
10541035
try {
10551036
const rawBody = await readJsonBody<Record<string, unknown>>(req, res);
1056-
if (rawBody === null || rawBody === undefined) return true;
1037+
if (rawBody === null) return true;
10571038
const parsed = PostInstallAppRequestSchema.safeParse(rawBody);
10581039
if (!parsed.success) {
10591040
const issue = parsed.error.issues[0];
@@ -1117,7 +1098,7 @@ export async function handleAppsRoutes(
11171098

11181099
if (method === "POST" && pathname === "/api/apps/stop") {
11191100
const rawBody = await readJsonBody<Record<string, unknown>>(req, res);
1120-
if (rawBody === null || rawBody === undefined) return true;
1101+
if (rawBody === null) return true;
11211102
const parsed = PostStopAppRequestSchema.safeParse(rawBody);
11221103
if (!parsed.success) {
11231104
const issue = parsed.error.issues[0];
@@ -1227,7 +1208,7 @@ export async function handleAppsRoutes(
12271208

12281209
if (method === "POST" && pathname === "/api/apps/relaunch") {
12291210
const rawBody = await readJsonBody<Record<string, unknown>>(req, res);
1230-
if (rawBody === null || rawBody === undefined) return true;
1211+
if (rawBody === null) return true;
12311212
const parsed = PostRelaunchAppRequestSchema.safeParse(rawBody);
12321213
if (!parsed.success) {
12331214
const issue = parsed.error.issues[0];
@@ -1363,7 +1344,7 @@ export async function handleAppsRoutes(
13631344
// the zod schema in @elizaos/shared so the wire shape is the
13641345
// single source of truth (see contracts/app-permissions-routes.ts).
13651346
const rawBody = await readJsonBody<Record<string, unknown>>(req, res);
1366-
if (rawBody === null || rawBody === undefined) return true;
1347+
if (rawBody === null) return true;
13671348
const parsed = PutAppPermissionsRequestSchema.safeParse(rawBody);
13681349
if (!parsed.success) {
13691350
const issue = parsed.error.issues[0];
@@ -1395,7 +1376,7 @@ export async function handleAppsRoutes(
13951376
// The schema handles the required check, the absolute-path check,
13961377
// and rejects extra unknown fields via .strict().
13971378
const rawBody = await readJsonBody<Record<string, unknown>>(req, res);
1398-
if (rawBody === null || rawBody === undefined) return true;
1379+
if (rawBody === null) return true;
13991380
const parsed = PostLoadFromDirectoryRequestSchema.safeParse(rawBody);
14001381
if (!parsed.success) {
14011382
const issue = parsed.error.issues[0];
@@ -1530,16 +1511,20 @@ export async function handleAppsRoutes(
15301511
}
15311512

15321513
if (method === "POST" && pathname === "/api/apps/create") {
1533-
const body = await readJsonBody<{ intent?: string; editTarget?: string }>(
1534-
req,
1535-
res,
1536-
);
1537-
if (!body) return true;
1538-
const intent = body.intent?.trim() ?? "";
1539-
if (!intent) {
1540-
error(res, "intent is required");
1514+
const rawBody = await readJsonBody<Record<string, unknown>>(req, res);
1515+
if (rawBody === null) return true;
1516+
const parsed = PostCreateAppRequestSchema.safeParse(rawBody);
1517+
if (!parsed.success) {
1518+
const issue = parsed.error.issues[0];
1519+
const issuePath = issue?.path?.join(".") ?? "<root>";
1520+
error(
1521+
res,
1522+
`Invalid request body at ${issuePath}: ${issue?.message ?? "validation failed"}`,
1523+
400,
1524+
);
15411525
return true;
15421526
}
1527+
const { intent, editTarget } = parsed.data;
15431528

15441529
const runtimeWithActions = runtime as {
15451530
actions?: Array<{
@@ -1583,11 +1568,11 @@ export async function handleAppsRoutes(
15831568
parameters: {
15841569
mode: "create",
15851570
intent,
1586-
...(body.editTarget ? { editTarget: body.editTarget } : {}),
1571+
...(editTarget ? { editTarget } : {}),
15871572
},
15881573
mode: "create",
15891574
intent,
1590-
...(body.editTarget ? { editTarget: body.editTarget } : {}),
1575+
...(editTarget ? { editTarget } : {}),
15911576
},
15921577
callback,
15931578
)) as { success?: boolean; text?: string; data?: unknown } | undefined;

packages/shared/src/contracts/apps-lifecycle-routes.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { describe, expect, it } from "vitest";
22
import {
3+
PostCreateAppRequestSchema,
34
PostInstallAppRequestSchema,
45
PostLaunchAppRequestSchema,
6+
PostOverlayPresenceRequestSchema,
57
PostRelaunchAppRequestSchema,
68
PostStopAppRequestSchema,
79
} from "./apps-lifecycle-routes.js";
@@ -166,3 +168,102 @@ describe("PostRelaunchAppRequestSchema", () => {
166168
).toThrow();
167169
});
168170
});
171+
172+
describe("PostCreateAppRequestSchema", () => {
173+
it("accepts intent only", () => {
174+
const parsed = PostCreateAppRequestSchema.parse({ intent: "make a todo" });
175+
expect(parsed).toEqual({ intent: "make a todo" });
176+
});
177+
178+
it("accepts intent + editTarget", () => {
179+
const parsed = PostCreateAppRequestSchema.parse({
180+
intent: "tweak the colour",
181+
editTarget: "companion",
182+
});
183+
expect(parsed).toEqual({
184+
intent: "tweak the colour",
185+
editTarget: "companion",
186+
});
187+
});
188+
189+
it("trims intent and editTarget", () => {
190+
const parsed = PostCreateAppRequestSchema.parse({
191+
intent: " build me an app ",
192+
editTarget: " companion ",
193+
});
194+
expect(parsed).toEqual({
195+
intent: "build me an app",
196+
editTarget: "companion",
197+
});
198+
});
199+
200+
it("rejects missing intent", () => {
201+
expect(() => PostCreateAppRequestSchema.parse({})).toThrow();
202+
});
203+
204+
it("rejects empty intent", () => {
205+
expect(() => PostCreateAppRequestSchema.parse({ intent: "" })).toThrow();
206+
});
207+
208+
it("rejects empty editTarget (use omission)", () => {
209+
expect(() =>
210+
PostCreateAppRequestSchema.parse({ intent: "x", editTarget: "" }),
211+
).toThrow();
212+
});
213+
214+
it("rejects extra fields (strict)", () => {
215+
expect(() =>
216+
PostCreateAppRequestSchema.parse({ intent: "x", scaffold: "v2" }),
217+
).toThrow();
218+
});
219+
});
220+
221+
describe("PostOverlayPresenceRequestSchema", () => {
222+
it("accepts a string appName", () => {
223+
const parsed = PostOverlayPresenceRequestSchema.parse({
224+
appName: "companion",
225+
});
226+
expect(parsed).toEqual({ appName: "companion" });
227+
});
228+
229+
it("accepts explicit null", () => {
230+
const parsed = PostOverlayPresenceRequestSchema.parse({ appName: null });
231+
expect(parsed).toEqual({ appName: null });
232+
});
233+
234+
it("accepts omitted appName as null", () => {
235+
const parsed = PostOverlayPresenceRequestSchema.parse({});
236+
expect(parsed).toEqual({ appName: null });
237+
});
238+
239+
it("collapses empty string to null", () => {
240+
const parsed = PostOverlayPresenceRequestSchema.parse({ appName: "" });
241+
expect(parsed).toEqual({ appName: null });
242+
});
243+
244+
it("collapses whitespace-only string to null", () => {
245+
const parsed = PostOverlayPresenceRequestSchema.parse({
246+
appName: " \t ",
247+
});
248+
expect(parsed).toEqual({ appName: null });
249+
});
250+
251+
it("trims surrounding whitespace", () => {
252+
const parsed = PostOverlayPresenceRequestSchema.parse({
253+
appName: " companion ",
254+
});
255+
expect(parsed).toEqual({ appName: "companion" });
256+
});
257+
258+
it("rejects non-string non-null appName", () => {
259+
expect(() =>
260+
PostOverlayPresenceRequestSchema.parse({ appName: 42 }),
261+
).toThrow();
262+
});
263+
264+
it("rejects extra fields (strict)", () => {
265+
expect(() =>
266+
PostOverlayPresenceRequestSchema.parse({ appName: "x", focus: true }),
267+
).toThrow();
268+
});
269+
});

0 commit comments

Comments
 (0)