Skip to content
Open
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
24 changes: 18 additions & 6 deletions db/services/user-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,25 @@ export async function patchUserProfile(
input: UserProfilePatch
) {
const patch = userProfilePatchSchema.parse(input);
const profile = parseUserProfile({
...(await readUserProfile(scope)),
...patch,
});
if (patch.countryCode) patch.countryCode = patch.countryCode.toUpperCase();
await ensureScope(scope);
await writeUserProfile(scope, profile);
return profile;
const updatedAt = new Date();
// A field patch must not replace values read before another conversation's update.
const [profile] = await db
.insert(userProfiles)
.values({
...emptyUserProfile,
...patch,
updatedAt,
workspaceId: scope.workspaceId,
})
.onConflictDoUpdate({
target: userProfiles.workspaceId,
set: { ...patch, updatedAt },
})
.returning(selection);
if (!profile) throw new Error("Personal Info could not be updated.");
return parseUserProfile(profile);
}

async function writeUserProfile(scope: AccessScope, profile: UserProfile) {
Expand Down
148 changes: 148 additions & 0 deletions tests/agent/profile-concurrency.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { PGlite } from "@electric-sql/pglite";
import { drizzle } from "drizzle-orm/pglite";
import { migrate } from "drizzle-orm/pglite/migrator";
import type { MemoryToolsContext } from "eve/memory";
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import * as Database from "@db";
import * as schema from "@db/schema";
import {
patchUserProfile,
readUserProfile,
replaceUserProfile,
} from "@db/services/user-profile";
import personalInfoMemory from "@agent/memory/personal_info";
import { accessScopeForUser } from "@shared/identity/access-scope";
import { emptyUserProfile } from "@shared/user-profile/schema";
import { toolContextFor } from "@tests/helpers/tool-context";

const client = new PGlite();
const database = drizzle(client, { schema });
const scope = accessScopeForUser("profile-fixture");

beforeAll(async () => {
await migrate(database, { migrationsFolder: "db/migrations" });
// SAFETY: PGlite supplies the real Drizzle query-builder contract used by the service; only the database driver changes.
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Exercise the real migrations and service against an isolated PostgreSQL-compatible database.
vi.spyOn(Database, "db", "get").mockReturnValue(database as never);
}, 20_000);

beforeEach(async () => {
await database.delete(schema.workspaces);
});

afterAll(async () => {
vi.restoreAllMocks();
await client.close();
});

describe("Personal Info field updates", () => {
it("preserves unrelated fields when two conversations update an existing profile", async () => {
await replaceUserProfile(scope, { ...emptyUserProfile, firstName: "Ada" });
const firstTools = await personalInfoMemory.provider.tools(
memoryContext("city-session")
);
const secondTools = await personalInfoMemory.provider.tools(
memoryContext("region-session")
);
if (!firstTools || !secondTools)
throw new Error("Expected the interactive profile update tool.");
await Promise.all([
firstTools.update.execute(
{ city: "London" },
toolContextFor({ callId: "city", sessionId: "city-session" })
),
secondTools.update.execute(
{ region: "Greater London" },
toolContextFor({ callId: "region", sessionId: "region-session" })
),
]);
expect(await readUserProfile(scope)).toMatchObject({
city: "London",
firstName: "Ada",
region: "Greater London",
});
});

it("preserves both fields when concurrent patches create the first profile", async () => {
await Promise.all([
patchUserProfile(scope, { firstName: "Ada" }),
patchUserProfile(scope, { lastName: "Lovelace" }),
]);
expect(await readUserProfile(scope)).toMatchObject({
firstName: "Ada",
lastName: "Lovelace",
});
});

it("normalizes values and removes only an explicitly cleared field", async () => {
await replaceUserProfile(scope, {
...emptyUserProfile,
city: "London",
firstName: "Ada",
});
expect(
await patchUserProfile(scope, { countryCode: " gb ", firstName: null })
).toMatchObject({
city: "London",
countryCode: "GB",
firstName: null,
});
});

it("creates a normalized profile without changing replacement semantics", async () => {
expect(
await patchUserProfile(scope, { firstName: " Ada ", countryCode: "gb" })
).toMatchObject({
countryCode: "GB",
firstName: "Ada",
lastName: null,
});
expect(
await replaceUserProfile(scope, {
...emptyUserProfile,
lastName: "Lovelace",
})
).toMatchObject({
countryCode: null,
firstName: null,
lastName: "Lovelace",
});
});
});

function memoryContext(sessionId: string): MemoryToolsContext {
return {
channel: {},
memory: {
scope: {
key: "profile-key",
namespace: "openinstinct-personal-info-v1",
value: scope.workspaceId,
},
slot: "personal_info",
},
messages: [],
model: null,
session: {
auth: {
current: {
attributes: { workspaceId: scope.workspaceId },
authenticator: "fixture",
principalId: scope.userId,
principalType: "user",
},
initiator: null,
},
id: sessionId,
},
turn: { id: "profile-turn", input: [], sequence: 1 },
};
}