feat(profiles): User Profiles v1 — SDK methods + docs (#7340)
Co-authored-by: Pratik <10096516+pratikgajjar@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Pratik
Claude Opus 4.8
parent
0cddc36d52
commit
ea9bbcabed
@@ -23,6 +23,14 @@ export type {
|
||||
FeedbackPayload,
|
||||
CreateMemoryExportPayload,
|
||||
GetMemoryExportPayload,
|
||||
ProfileEntityType,
|
||||
ProfileStatus,
|
||||
ProfileResponse,
|
||||
ProfileJobResponse,
|
||||
ProfileJobStatus,
|
||||
ProfileSettings,
|
||||
ProfileSettingsResponse,
|
||||
EntityProfileSettings,
|
||||
} from "./mem0.types";
|
||||
|
||||
// Re-export enums as values (not type-only)
|
||||
|
||||
+203
-3
@@ -1,4 +1,5 @@
|
||||
import axios from "axios";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import {
|
||||
AllUsers,
|
||||
PaginatedMemories,
|
||||
@@ -20,6 +21,12 @@ import {
|
||||
FeedbackPayload,
|
||||
CreateMemoryExportPayload,
|
||||
GetMemoryExportPayload,
|
||||
ProfileEntityType,
|
||||
ProfileResponse,
|
||||
ProfileJobResponse,
|
||||
ProfileJobStatus,
|
||||
ProfileSettings,
|
||||
ProfileSettingsResponse,
|
||||
} from "./mem0.types";
|
||||
import {
|
||||
captureClientEvent,
|
||||
@@ -92,6 +99,9 @@ interface ClientIdentity {
|
||||
}
|
||||
|
||||
// Shares one ping per (host, api key) across clients; FIFO-capped.
|
||||
// One collection for every generation; the operation is a body field.
|
||||
const PROFILE_JOBS_PATH = "/v2/profiles/jobs/";
|
||||
|
||||
const IDENTITY_CACHE_MAX_DEFAULT = 50;
|
||||
const identityByCredentials = new Map<string, Promise<ClientIdentity>>();
|
||||
|
||||
@@ -305,7 +315,8 @@ export default class MemoryClient {
|
||||
});
|
||||
}
|
||||
|
||||
async _fetchWithErrorHandling(url: string, options: any): Promise<any> {
|
||||
/** Fetch with no key conversion, for payloads carrying user-controlled property names. */
|
||||
async _fetchRawJson(url: string, options: any): Promise<any> {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
@@ -318,8 +329,11 @@ export default class MemoryClient {
|
||||
const errorData = await response.text();
|
||||
throw createExceptionFromResponse(response.status, errorData);
|
||||
}
|
||||
const jsonResponse = await response.json();
|
||||
return snakeToCamelKeys(jsonResponse);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async _fetchWithErrorHandling(url: string, options: any): Promise<any> {
|
||||
return snakeToCamelKeys(await this._fetchRawJson(url, options));
|
||||
}
|
||||
|
||||
_preparePayload(
|
||||
@@ -816,6 +830,192 @@ export default class MemoryClient {
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the memory profile for a single user.
|
||||
*
|
||||
* Branch on `status`, not on an empty `profile`: generation is asynchronous,
|
||||
* so a known user without a profile yet is a normal response.
|
||||
*/
|
||||
async getProfile(data: { entityId: string }): Promise<ProfileResponse> {
|
||||
this._captureEvent("get_profile", []);
|
||||
await this._awaitIdentity();
|
||||
|
||||
const response = await this._fetchWithErrorHandling(
|
||||
`${this.host}/v2/entities/user/${encodeURIComponent(data.entityId)}/profile/`,
|
||||
{
|
||||
headers: this.headers,
|
||||
},
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate or refresh the profile for one user, now.
|
||||
*
|
||||
* Profiles are otherwise built once a user crosses an internal message
|
||||
* threshold, so a new user has none for its first few memories. Returns as
|
||||
* soon as the work is queued: poll {@link getProfile} and branch on `status`.
|
||||
*
|
||||
* Pass `idempotencyKey` and reuse it to retry a lost request without starting
|
||||
* (and being billed for) a second job.
|
||||
*/
|
||||
async generateProfile(data: {
|
||||
entityId: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<ProfileJobResponse> {
|
||||
this._captureEvent("generate_profile", []);
|
||||
await this._awaitIdentity();
|
||||
|
||||
const response = await this._fetchWithErrorHandling(
|
||||
`${this.host}${PROFILE_JOBS_PATH}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...this.headers,
|
||||
"Idempotency-Key": data.idempotencyKey ?? uuidv7(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
operation: "trigger",
|
||||
entity_type: "user",
|
||||
entity_id: data.entityId,
|
||||
}),
|
||||
},
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
/** Get the profile settings for the current project. */
|
||||
async getProfileSettings(): Promise<ProfileSettingsResponse> {
|
||||
this._captureEvent("get_profile_settings", []);
|
||||
await this._awaitIdentity();
|
||||
|
||||
const raw = await this._fetchRawJson(`${this.host}/v2/profiles/settings/`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
return this._settingsWithVerbatimSchema(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* The envelope keys are ours; the schema's property names are the customer's.
|
||||
*
|
||||
* Each entity type carries its own schema, so every one has to be restored
|
||||
* from the raw body — otherwise camel-casing rewrites the customer's field
|
||||
* names and a profile comes back under keys they never chose.
|
||||
*/
|
||||
private _settingsWithVerbatimSchema(raw: any): ProfileSettingsResponse {
|
||||
const settings = snakeToCamelKeys(raw) as ProfileSettingsResponse;
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return settings;
|
||||
}
|
||||
|
||||
const rawEntities = raw.entities;
|
||||
if (rawEntities && typeof rawEntities === "object") {
|
||||
for (const [entityType, entitySettings] of Object.entries(rawEntities)) {
|
||||
if (
|
||||
entitySettings &&
|
||||
typeof entitySettings === "object" &&
|
||||
"schema" in entitySettings &&
|
||||
settings.entities?.[entityType as ProfileEntityType]
|
||||
) {
|
||||
settings.entities[entityType as ProfileEntityType]!.schema = (
|
||||
entitySettings as Record<string, any>
|
||||
).schema;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update profile settings. Only the fields you pass are written.
|
||||
*
|
||||
* `schema` and `customInstructions` are per user and are nested under
|
||||
* `entities` for the API; only `enabled` is project-wide. Sending them flat
|
||||
* is rejected with `Unsupported settings`.
|
||||
*/
|
||||
async updateProfileSettings(
|
||||
settings: ProfileSettings,
|
||||
): Promise<ProfileSettingsResponse> {
|
||||
const payloadKeys = Object.keys(settings || {});
|
||||
this._captureEvent("update_profile_settings", [payloadKeys]);
|
||||
await this._awaitIdentity();
|
||||
|
||||
const { schema, customInstructions, enabled } = settings || {};
|
||||
|
||||
const body: Record<string, any> = {};
|
||||
if (enabled !== undefined) {
|
||||
body.enabled = enabled;
|
||||
}
|
||||
|
||||
const entitySettings: Record<string, any> = {};
|
||||
// The schema's property names are the customer's and must reach the API verbatim.
|
||||
if (schema !== undefined) {
|
||||
entitySettings.schema = schema;
|
||||
}
|
||||
if (customInstructions !== undefined) {
|
||||
entitySettings.custom_instructions = customInstructions;
|
||||
}
|
||||
if (Object.keys(entitySettings).length > 0) {
|
||||
body.entities = { user: entitySettings };
|
||||
}
|
||||
|
||||
const raw = await this._fetchRawJson(`${this.host}/v2/profiles/settings/`, {
|
||||
method: "POST",
|
||||
headers: this.headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return this._settingsWithVerbatimSchema(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate profiles for a few real users, to check a schema.
|
||||
*
|
||||
* Real generations against real memories, and the results are kept: the
|
||||
* profiles are written to those users and count toward usage.
|
||||
*/
|
||||
async sampleProfiles(data?: {
|
||||
limit?: number;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<ProfileJobResponse> {
|
||||
this._captureEvent("sample_profiles", []);
|
||||
await this._awaitIdentity();
|
||||
|
||||
const response = await this._fetchWithErrorHandling(
|
||||
`${this.host}${PROFILE_JOBS_PATH}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...this.headers,
|
||||
"Idempotency-Key": data?.idempotencyKey ?? uuidv7(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
operation: "sample",
|
||||
// Required: the API refuses a job that does not name an entity kind.
|
||||
entity_type: "user",
|
||||
...this._prepareParams({ limit: data?.limit }),
|
||||
}),
|
||||
},
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one generation job. Accepts the `statusUrl` from a create call, or a
|
||||
* bare job id. Prefer `statusUrl` so a route change needs no client update.
|
||||
*/
|
||||
async getProfileJob(jobIdOrStatusUrl: string): Promise<ProfileJobStatus> {
|
||||
await this._awaitIdentity();
|
||||
|
||||
const path = jobIdOrStatusUrl.startsWith("/")
|
||||
? jobIdOrStatusUrl
|
||||
: `${PROFILE_JOBS_PATH}${jobIdOrStatusUrl}/`;
|
||||
return this._fetchWithErrorHandling(`${this.host}${path}`, {
|
||||
method: "GET",
|
||||
headers: this.headers,
|
||||
});
|
||||
}
|
||||
|
||||
async createMemoryExport(
|
||||
data: CreateMemoryExportPayload,
|
||||
): Promise<{ message: string; id: string }> {
|
||||
|
||||
@@ -239,3 +239,98 @@ export interface GetMemoryExportPayload {
|
||||
filters?: Record<string, any>;
|
||||
memoryExportId?: string;
|
||||
}
|
||||
|
||||
// ─── Profile Types ──────────────────────────────────────────
|
||||
|
||||
/** The entity kind that carries a profile. */
|
||||
export type ProfileEntityType = "user";
|
||||
|
||||
/**
|
||||
* `succeeded` is the only state in which `profile` is guaranteed to hold content.
|
||||
*
|
||||
* These are values, not keys, so the client does not camel-case them: the wire
|
||||
* spelling is what a comparison has to match.
|
||||
*/
|
||||
export type ProfileStatus =
|
||||
| "succeeded"
|
||||
| "pending"
|
||||
| "failed"
|
||||
| "not_enabled"
|
||||
| "insufficient_data";
|
||||
|
||||
export interface ProfileResponse {
|
||||
/** Shaped by the project's schema; keys are not camel-cased. */
|
||||
profile: Record<string, any>;
|
||||
status: ProfileStatus;
|
||||
entityType: ProfileEntityType;
|
||||
entityId: string;
|
||||
updatedAt: string | null;
|
||||
generationCount: number;
|
||||
/** The last generation's failure reason, when `status` is `failed`. */
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
/** Every accepted generation. `statusUrl` is the server's own poll path. */
|
||||
export interface ProfileJobResponse {
|
||||
jobId: string;
|
||||
status: string;
|
||||
statusUrl: string;
|
||||
operation: string;
|
||||
entityType: ProfileEntityType;
|
||||
/** Entities reserved against usage for this job. */
|
||||
entityCountReserved?: number;
|
||||
/** Null when the job has no associated event. */
|
||||
eventId?: string | null;
|
||||
replayed?: boolean;
|
||||
/** Sample runs only: how many entities were picked. */
|
||||
sampled?: number;
|
||||
/** Sample runs only: the entity ids picked. Read each one with `getProfile`. */
|
||||
entityIds?: string[];
|
||||
}
|
||||
|
||||
/** The settings to write. `schema` and `customInstructions` apply to user profiles. */
|
||||
export interface ProfileSettings {
|
||||
/** Turn profile generation on or off. Project-wide. */
|
||||
enabled?: boolean;
|
||||
/** JSON Schema for the profile. Every property needs a `description`. */
|
||||
schema?: Record<string, any> | null;
|
||||
customInstructions?: string | null;
|
||||
}
|
||||
|
||||
/** One entity type's stored configuration. */
|
||||
export interface EntityProfileSettings {
|
||||
/** The customer's JSON Schema, with its property names verbatim. */
|
||||
schema?: Record<string, any> | null;
|
||||
customInstructions?: string | null;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings as stored. `schema` and `customInstructions` are per entity
|
||||
* type and nest under `entities`; only `enabled` is project-wide.
|
||||
*/
|
||||
export interface ProfileSettingsResponse {
|
||||
enabled?: boolean;
|
||||
entities?: Partial<Record<ProfileEntityType, EntityProfileSettings>>;
|
||||
capabilities?: Record<string, any>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/** `GET /v2/profiles/jobs/{id}/`. The job nests under `job`. */
|
||||
export interface ProfileJobStatus {
|
||||
job: {
|
||||
id: string;
|
||||
operation: string;
|
||||
entityType: ProfileEntityType;
|
||||
status: string;
|
||||
/** Null until `enumerationComplete`. */
|
||||
total: number | null;
|
||||
enumerationComplete: boolean;
|
||||
/** succeeded + failed + skipped. */
|
||||
completed: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* MemoryClient unit tests — profiles.
|
||||
* Verifies request construction and the verbatim round-trip of user-controlled
|
||||
* profile/schema keys, not mock response echo.
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import type { ProfileStatus } from "../mem0.types";
|
||||
import { TEST_API_KEY } from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
findFetchCall,
|
||||
getFetchBody,
|
||||
installConsoleSuppression,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
describe("MemoryClient - getProfile()", () => {
|
||||
test("reads the v2 entity route and keeps profile keys verbatim", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/entities/user/alice/profile/", {
|
||||
status: 200,
|
||||
body: {
|
||||
// Customer schema keys: camel-casing these would break the schema they wrote.
|
||||
profile: {
|
||||
favorite_topics: ["hiking"],
|
||||
work_style: { preferred_hours: "mornings" },
|
||||
},
|
||||
status: "succeeded",
|
||||
entity_type: "user",
|
||||
entity_id: "alice",
|
||||
updated_at: "2026-02-08T00:00:00Z",
|
||||
generation_count: 3,
|
||||
},
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const result = await client.getProfile({ entityId: "alice" });
|
||||
|
||||
const call = findFetchCall(mock, "/v2/entities/user/alice/profile/");
|
||||
expect(call).toBeDefined();
|
||||
|
||||
expect(result.profile).toEqual({
|
||||
favorite_topics: ["hiking"],
|
||||
work_style: { preferred_hours: "mornings" },
|
||||
});
|
||||
expect(result.entityType).toBe("user");
|
||||
expect(result.entityId).toBe("alice");
|
||||
expect(result.generationCount).toBe(3);
|
||||
expect(result.status).toBe("succeeded");
|
||||
});
|
||||
|
||||
test("status keeps its wire spelling", async () => {
|
||||
// A status is a VALUE, not a key, so the client does not camel-case it.
|
||||
// Declaring the union as `insufficientData` made tsc reject the comparison
|
||||
// that works and accept one that can never be true.
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/entities/user/bob/profile/", {
|
||||
status: 200,
|
||||
body: {
|
||||
profile: {},
|
||||
status: "insufficient_data",
|
||||
entity_type: "user",
|
||||
entity_id: "bob",
|
||||
},
|
||||
});
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const result = await client.getProfile({ entityId: "bob" });
|
||||
|
||||
expect(result.status).toBe("insufficient_data");
|
||||
// Assignable without a cast: the declared union must contain the wire value.
|
||||
const status: ProfileStatus = result.status;
|
||||
expect(status).not.toBe("insufficientData");
|
||||
});
|
||||
|
||||
test("scopes to user and encodes the entity id", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/entities/user/", {
|
||||
status: 200,
|
||||
body: { profile: {}, status: "pending", entity_type: "user" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.getProfile({ entityId: "a/b" });
|
||||
|
||||
const call = findFetchCall(mock, "/v2/entities/user/a%2Fb/profile/");
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryClient - generateProfile()", () => {
|
||||
test("sends operation trigger with the entity to the jobs collection", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/profiles/jobs/", {
|
||||
status: 202,
|
||||
body: {
|
||||
job_id: "00000000-0000-7000-8000-000000000001",
|
||||
status: "QUEUED",
|
||||
status_url: "/v2/profiles/jobs/00000000-0000-7000-8000-000000000001/",
|
||||
operation: "trigger",
|
||||
entity_type: "user",
|
||||
entity_count_reserved: 1,
|
||||
event_id: "00000000-0000-7000-8000-000000000002",
|
||||
replayed: false,
|
||||
},
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const result = await client.generateProfile({ entityId: "alice" });
|
||||
|
||||
const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST");
|
||||
expect(call).toBeDefined();
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.operation).toBe("trigger");
|
||||
expect(body.entity_type).toBe("user");
|
||||
expect(body.entity_id).toBe("alice");
|
||||
// A generated Idempotency-Key is always sent so the create can be retried safely.
|
||||
const headers = call![1].headers as Record<string, string>;
|
||||
expect(headers["Idempotency-Key"]).toBeTruthy();
|
||||
expect(result.jobId).toBe("00000000-0000-7000-8000-000000000001");
|
||||
expect(result.statusUrl).toBe(
|
||||
"/v2/profiles/jobs/00000000-0000-7000-8000-000000000001/",
|
||||
);
|
||||
expect(result.replayed).toBe(false);
|
||||
});
|
||||
|
||||
test("reuses a caller-supplied idempotency key", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/profiles/jobs/", {
|
||||
status: 202,
|
||||
body: {
|
||||
job_id: "j1",
|
||||
status: "QUEUED",
|
||||
status_url: "/v2/profiles/jobs/j1/",
|
||||
operation: "trigger",
|
||||
entity_type: "user",
|
||||
},
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.generateProfile({
|
||||
entityId: "alice",
|
||||
idempotencyKey: "retry-key-123",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST");
|
||||
const headers = call![1].headers as Record<string, string>;
|
||||
expect(headers["Idempotency-Key"]).toBe("retry-key-123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryClient - profile settings", () => {
|
||||
test("sends schema property names verbatim and returns them unchanged", async () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
favorite_topics: {
|
||||
type: "array",
|
||||
description: "Topics the user returns to",
|
||||
items: { type: "string" },
|
||||
},
|
||||
workStyle: {
|
||||
type: "string",
|
||||
description: "How the user prefers to work",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/profiles/settings/", {
|
||||
status: 200,
|
||||
body: {
|
||||
enabled: true,
|
||||
entities: {
|
||||
user: {
|
||||
schema,
|
||||
custom_instructions: "Focus on durable preferences",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const result = await client.updateProfileSettings({
|
||||
enabled: true,
|
||||
schema,
|
||||
customInstructions: "Focus on durable preferences",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v2/profiles/settings/", "POST");
|
||||
expect(call).toBeDefined();
|
||||
const body = getFetchBody(call!);
|
||||
|
||||
// Mixed casing goes out exactly as written, nested under the entity type.
|
||||
// Exact, not a subset match: the customer's keys must go out unchanged.
|
||||
expect(body.entities).toEqual({
|
||||
user: { schema, custom_instructions: "Focus on durable preferences" },
|
||||
});
|
||||
expect(body.enabled).toBe(true);
|
||||
// A flat schema is rejected by the API with "Unsupported settings".
|
||||
expect("schema" in body).toBe(false);
|
||||
|
||||
// And the customer's property names survive the round trip.
|
||||
expect(result.entities?.user?.schema).toEqual(schema);
|
||||
expect(result.entities?.user?.customInstructions).toBe(
|
||||
"Focus on durable preferences",
|
||||
);
|
||||
});
|
||||
|
||||
test("scopes entity-level settings under user", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/profiles/settings/", {
|
||||
status: 200,
|
||||
body: { enabled: true },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.updateProfileSettings({
|
||||
schema: { type: "object", properties: {} },
|
||||
});
|
||||
|
||||
const body = getFetchBody(
|
||||
findFetchCall(mock, "/v2/profiles/settings/", "POST")!,
|
||||
);
|
||||
expect(Object.keys(body.entities as object)).toEqual(["user"]);
|
||||
});
|
||||
|
||||
test("omits fields the caller did not set", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/profiles/settings/", {
|
||||
status: 200,
|
||||
body: {
|
||||
enabled: false,
|
||||
entities: { user: { schema: null, custom_instructions: null } },
|
||||
},
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.updateProfileSettings({ enabled: false });
|
||||
|
||||
const call = findFetchCall(mock, "/v2/profiles/settings/", "POST");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.enabled).toBe(false);
|
||||
// Nothing entity-scoped was passed, so no entities key is sent at all.
|
||||
expect("entities" in body).toBe(false);
|
||||
expect("schema" in body).toBe(false);
|
||||
expect("custom_instructions" in body).toBe(false);
|
||||
});
|
||||
|
||||
test("getProfileSettings reads the v2 route", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/profiles/settings/", {
|
||||
status: 200,
|
||||
body: {
|
||||
enabled: true,
|
||||
entities: {
|
||||
user: {
|
||||
schema: { properties: { favorite_topics: { type: "array" } } },
|
||||
custom_instructions: null,
|
||||
},
|
||||
},
|
||||
capabilities: { full_rebuild: false },
|
||||
},
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const result = await client.getProfileSettings();
|
||||
|
||||
expect(findFetchCall(mock, "/v2/profiles/settings/")).toBeDefined();
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.entities?.user?.schema).toEqual({
|
||||
properties: { favorite_topics: { type: "array" } },
|
||||
});
|
||||
expect(result.capabilities?.fullRebuild).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryClient - sampleProfiles()", () => {
|
||||
test("sampleProfiles omits limit when unset", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/profiles/jobs/", {
|
||||
status: 202,
|
||||
body: {
|
||||
job_id: "job_1",
|
||||
status: "QUEUED",
|
||||
status_url: "/v2/profiles/jobs/job_1/",
|
||||
operation: "sample",
|
||||
entity_type: "user",
|
||||
sampled: 5,
|
||||
results: [],
|
||||
},
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.sampleProfiles();
|
||||
|
||||
const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST");
|
||||
// Every job names an entity kind: the API refuses one that does not.
|
||||
expect(getFetchBody(call!)).toEqual({
|
||||
operation: "sample",
|
||||
entity_type: "user",
|
||||
});
|
||||
});
|
||||
|
||||
test("sampleProfiles passes an explicit limit", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/profiles/jobs/", {
|
||||
status: 202,
|
||||
body: {
|
||||
job_id: "job_2",
|
||||
status: "QUEUED",
|
||||
status_url: "/v2/profiles/jobs/job_2/",
|
||||
operation: "sample",
|
||||
entity_type: "user",
|
||||
sampled: 3,
|
||||
results: [],
|
||||
},
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const result = await client.sampleProfiles({ limit: 3 });
|
||||
|
||||
const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST");
|
||||
expect(getFetchBody(call!).operation).toBe("sample");
|
||||
expect(getFetchBody(call!).limit).toBe(3);
|
||||
expect(getFetchBody(call!).entity_type).toBe("user");
|
||||
expect(result.sampled).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,9 @@ const OPAQUE_VALUE_KEYS = new Set([
|
||||
// (see issue #5738; same class as `metadata`/`structuredDataSchema`).
|
||||
"customCategories",
|
||||
"custom_categories",
|
||||
// A profile's keys come from the customer's own JSON Schema. The schema itself
|
||||
// is handled in `updateProfileSettings`, so that `schema` is not opaque globally.
|
||||
"profile",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user