Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 3 additions & 26 deletions apps/web/src/agents-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@
// this file carries no roster/detail data model of its own.

import { WorkflowDefinitionResponse, WorkflowRunResponse, paginatedSchema } from "@intx/types";
import { type } from "arktype";
import type { ArkErrors } from "arktype";
import { useMutation, useQueryClient } from "@tanstack/react-query";

import { ApiQueryError } from "@/lib/api-query";
import { deployAgentSource, type DeployedAgent, type NewAgentInput } from "./agent-deploy";
import { apiRequest, type Validator } from "./api";
import { workbenchKeys } from "./chat-path";
import { tenantKeys } from "./query-client";

Expand All @@ -21,30 +20,8 @@ const InstancesPage = paginatedSchema(WorkflowRunResponse);
// not a raised limit here.
const PAGE_LIMIT = 100;

type Validator<T> = (data: unknown) => T | ArkErrors;

async function getJSON<T>(path: string, schema: Validator<T>): Promise<T> {
let response: Response;
try {
response = await fetch(path, { headers: { accept: "application/json" } });
} catch (cause) {
throw new ApiQueryError(
cause instanceof Error ? cause.message : String(cause),
undefined,
path,
);
}
if (response.status === 401) {
throw new ApiQueryError("Not signed in.", 401, path);
}
if (!response.ok) {
throw new ApiQueryError(`The server answered ${response.status}.`, response.status, path);
}
const parsed = schema(await response.json().catch(() => undefined));
if (parsed instanceof type.errors) {
throw new ApiQueryError(`Unexpected response shape: ${parsed.summary}`, undefined, path);
}
return parsed;
function getJSON<T>(path: string, schema: Validator<T>): Promise<T> {
return apiRequest(path, schema, undefined, ApiQueryError);
}

export function listAgentDefinitions(tenantId: string): Promise<readonly AgentDefinition[]> {
Expand Down
219 changes: 219 additions & 0 deletions apps/web/src/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { afterEach, describe, expect, test } from "bun:test";
import { type } from "arktype";

import { ApiQueryError, UnauthenticatedError } from "@/lib/api-query";
import {
apiQueryOptions,
apiRequest,
fetchArtifactDetail,
fetchTenantDetail,
readErrorEnvelope,
requestVoid,
} from "./api";
import { ChatApiError } from "./chat/threads-api";
import { shouldRetryQuery, isAuthInvalidError } from "./query-client";
import { SettingsApiError, getAccount } from "./settings/api";
import { CredentialsApiError, deleteCredential } from "./settings/credentials-api";
import { InferenceSettingsApiError, deleteOwnOffering } from "./settings/inference/api";
import { TenancyApiError, removePrincipal } from "./settings/tenancy-api";

const ItemSchema = type({ id: "string", count: "number" });

function stubFetch(status: number, body?: unknown): typeof fetch {
return (async (_input: string | URL | Request, _init?: RequestInit) => {
return new Response(body === undefined ? "" : JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
}

const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});

describe("Error hierarchy — all API errors extend ApiQueryError", () => {
test("domain API error classes are instanceof both their own class and ApiQueryError", () => {
const errors = [
new SettingsApiError("settings fail", 500),
new TenancyApiError("tenancy fail", 404),
new CredentialsApiError("cred fail", 403),
new InferenceSettingsApiError("inference fail", 401),
new ChatApiError("chat fail", 502),
];

for (const error of errors) {
expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(ApiQueryError);
}

expect(errors[0]).toBeInstanceOf(SettingsApiError);
expect(errors[1]).toBeInstanceOf(TenancyApiError);
expect(errors[2]).toBeInstanceOf(CredentialsApiError);
expect(errors[3]).toBeInstanceOf(InferenceSettingsApiError);
expect(errors[4]).toBeInstanceOf(ChatApiError);
});

test("shouldRetryQuery and isAuthInvalidError recognize domain error subclasses via ApiQueryError", () => {
const notFound = new TenancyApiError("missing", 404);
expect(shouldRetryQuery(0, notFound)).toBe(false);

const serverErr = new CredentialsApiError("boom", 500);
expect(shouldRetryQuery(0, serverErr)).toBe(true);
expect(shouldRetryQuery(3, serverErr)).toBe(false);

const unauth = new InferenceSettingsApiError("expired", 401);
expect(isAuthInvalidError(unauth)).toBe(true);
expect(isAuthInvalidError(notFound)).toBe(false);
});
});

describe("readErrorEnvelope", () => {
test("extracts userMessage from cardinal error envelope", () => {
const msg = readErrorEnvelope(
403,
{ error: { code: "forbidden", userMessage: "Access denied.", refId: "ref-1" } },
"saving settings",
);
expect(msg).toBe("Access denied.");
});

test("extracts message from stock hub error envelope when userMessage is absent", () => {
const msg = readErrorEnvelope(
400,
{ error: { code: "bad_request", message: "Invalid skill name." } },
"creating skill",
);
expect(msg).toBe("Invalid skill name.");
});

test("falls back to path-free sentence when body is not a recognized envelope", () => {
expect(readErrorEnvelope(502, { unexpected: true }, "loading data")).toBe(
"The server answered 502 while loading data.",
);
expect(readErrorEnvelope(502, undefined)).toBe("The server answered 502.");
});
});

describe("apiRequest and requestVoid", () => {
test("parses valid response through ArkType schema", async () => {
const value = await apiRequest(
"/api/items/1",
ItemSchema,
"loading item",
ApiQueryError,
undefined,
stubFetch(200, { id: "item-1", count: 42 }),
);
expect(value).toEqual({ id: "item-1", count: 42 });
});

test("rejects with statused ErrorCtor on non-2xx and surfaces envelope userMessage", async () => {
const error = await apiRequest(
"/api/items/1",
ItemSchema,
"loading item",
CredentialsApiError,
undefined,
stubFetch(403, {
error: { code: "forbidden", userMessage: "No permission", refId: "r-1" },
}),
).catch((cause: unknown) => cause);

expect(error).toBeInstanceOf(CredentialsApiError);
expect(error).toBeInstanceOf(ApiQueryError);
expect((error as CredentialsApiError).status).toBe(403);
expect((error as CredentialsApiError).message).toBe("No permission");
});

test("rejects when response body fails ArkType schema validation", async () => {
const error = await apiRequest(
"/api/items/1",
ItemSchema,
"loading item",
SettingsApiError,
undefined,
stubFetch(200, { id: "item-1", count: "not-a-number" }),
).catch((cause: unknown) => cause);

expect(error).toBeInstanceOf(SettingsApiError);
expect(error).toBeInstanceOf(ApiQueryError);
expect((error as SettingsApiError).message).toContain("Unexpected response shape");
});

test("requestVoid resolves only on 204 and rejects 200/201 or network failures", async () => {
await expect(
requestVoid(
"/api/items/1",
"deleting item",
CredentialsApiError,
{ method: "DELETE" },
stubFetch(204),
),
).resolves.toBeUndefined();

for (const status of [200, 201]) {
const drift = await requestVoid(
"/api/items/1",
"deleting item",
TenancyApiError,
{ method: "DELETE" },
stubFetch(status, { ok: true }),
).catch((cause: unknown) => cause);
expect(drift).toBeInstanceOf(TenancyApiError);
expect(drift).toBeInstanceOf(ApiQueryError);
expect((drift as TenancyApiError).status).toBe(status);
expect((drift as TenancyApiError).message).toContain("204");
}

const networkErr = await requestVoid(
"/api/items/1",
"deleting item",
TenancyApiError,
{ method: "DELETE" },
(async () => {
throw new TypeError("fetch failed");
}) as unknown as typeof fetch,
).catch((cause: unknown) => cause);
expect(networkErr).toBeInstanceOf(TenancyApiError);
expect((networkErr as TenancyApiError).status).toBeUndefined();
});

test("apiQueryOptions maps 401 to UnauthenticatedError and non-ok to ApiQueryError", async () => {
globalThis.fetch = stubFetch(401, {});
const opts = apiQueryOptions("/api/me", ItemSchema);
const unauth = await opts.queryFn().catch((cause: unknown) => cause);
expect(unauth).toBeInstanceOf(UnauthenticatedError);

globalThis.fetch = stubFetch(503, {});
const serverErr = await opts.queryFn().catch((cause: unknown) => cause);
expect(serverErr).toBeInstanceOf(ApiQueryError);
expect((serverErr as ApiQueryError).status).toBe(503);
});

test("settings, tenancy, credentials, inference, and api helpers preserve error contracts", async () => {
globalThis.fetch = stubFetch(401, {});
await expect(getAccount()).rejects.toBeInstanceOf(UnauthenticatedError);
await expect(removePrincipal("t1", "p1")).rejects.toBeInstanceOf(UnauthenticatedError);
await expect(fetchTenantDetail("t1")).rejects.toBeInstanceOf(UnauthenticatedError);
await expect(fetchArtifactDetail("t1", "a1")).rejects.toBeInstanceOf(UnauthenticatedError);

const inf401 = await deleteOwnOffering(
"t1",
"o1",
stubFetch(401, {
error: { code: "unauthorized", userMessage: "Sign in again", refId: "r" },
}),
).catch((cause: unknown) => cause);
expect(inf401).toBeInstanceOf(InferenceSettingsApiError);
expect(inf401).toBeInstanceOf(ApiQueryError);
expect((inf401 as InferenceSettingsApiError).status).toBe(401);
expect((inf401 as InferenceSettingsApiError).message).toBe("Sign in again");

globalThis.fetch = stubFetch(500, {});
const credErr = await deleteCredential("t1", "c1").catch((cause: unknown) => cause);
expect(credErr).toBeInstanceOf(CredentialsApiError);
expect(credErr).toBeInstanceOf(ApiQueryError);
});
});
Loading
Loading