Why: the user profile only knew what onboarding wrote, so people had to repeat themselves,
and semantic recall needed a hosted or separately run memory service. The profile now grows
from everyday conversations, and recall can run on the user's own server with a local
embeddings endpoint (for example llama.cpp). Both stay optional and are tested offline.
What:
- adapter-kit: EmbeddingProvider contract (describe, embed) and the profile.consolidate job
(payload schema and a replace-keyed job factory, one pending job per user).
- adapters: OpenAI-compatible embeddings (POST {baseUrl}/embeddings through the guarded
OpenAI-compatible fetch, batching, bounded responses, clear errors), a deterministic
emulator, an in-process /v1/embeddings server emulator, and a shared conformance suite.
- adapters: "builtin" semantic memory stored in Postgres (semantic_memory_items with Float[]
embeddings, cosine ranking in process, bot/shared/history scopes matching the existing
providers), registered in the memory provider factory and configured through
memory.connectProvider with an embeddings URL, model and optional API key. Private
endpoints require the deployment owner, like the other local memory providers.
- adapters: profile consolidation reads the user's settled app conversations since
UserProfile.consolidatedAt (never messaging channels), asks the main bot's model for strict
JSON facts and an updated summary, drops restatements (embedding cosine when built-in
memory is configured, normalized text otherwise), stores sensitive categories as pending,
and advances the cursor with a compare-and-set. A reconciler hook enqueues it at most daily
per active user; it skips under the scripted runtime and without a usable model.
- db: additive migration 20261002210400_ws4_semantic_memory.
- web: a "Built-in" option in Memory settings; AI data sharing names the embeddings server.
Tests: new embedding conformance, OpenAI-compatible wire, built-in provider and consolidation
unit tests; new Postgres suites for built-in memory (including a real loopback embeddings
server through the memory provider resolver) and for consolidation and its scheduling query,
both added to the integration harness. The settings E2E spec now opens the new form.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
190 lines
6.9 KiB
TypeScript
190 lines
6.9 KiB
TypeScript
import type {
|
|
AgentHomeStore,
|
|
AgentRuntime,
|
|
JobPublisher,
|
|
MessagingSurface,
|
|
SandboxProvider,
|
|
} from "@rakazo/adapter-kit";
|
|
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
|
|
import { createLogger, createTestSink, installLogger } from "@rakazo/logging";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { createBackgroundJobHandlers } from "./background-job-handlers.js";
|
|
import { createRunExecutor } from "./executor.js";
|
|
import { compactHistory } from "./history-compaction.js";
|
|
import { deliverMessagingOutbound, mirrorMessagingOutbound } from "./messaging-delivery.js";
|
|
import { consolidateProfile } from "./profile-consolidation.js";
|
|
import type { EncryptedSecretStore } from "./secrets.js";
|
|
|
|
vi.mock("./history-compaction.js", () => ({ compactHistory: vi.fn(async () => undefined) }));
|
|
vi.mock("./profile-consolidation.js", () => ({
|
|
consolidateProfile: vi.fn(async () => undefined),
|
|
}));
|
|
vi.mock("./messaging-delivery.js", () => ({
|
|
deliverMessagingOutbound: vi.fn(async () => undefined),
|
|
mirrorMessagingOutbound: vi.fn(async () => undefined),
|
|
}));
|
|
|
|
describe("createBackgroundJobHandlers", () => {
|
|
it("delivers directly when shutdown rejects a completed run's mirror job", async () => {
|
|
const enqueueError = new Error("Background job publisher is closing");
|
|
const jobs = {
|
|
enqueue: vi.fn(async () => {
|
|
throw enqueueError;
|
|
}),
|
|
} as unknown as JobPublisher;
|
|
const sink = createTestSink();
|
|
installLogger(createLogger({ service: "rakazo-worker", sinks: [sink] }));
|
|
const handlers = createBackgroundJobHandlers({
|
|
executor: {
|
|
continueRun: vi.fn(async () => undefined),
|
|
} as unknown as ReturnType<typeof createRunExecutor>,
|
|
prisma: {} as unknown as PrismaClient,
|
|
sandbox: {} as unknown as SandboxProvider,
|
|
home: {} as unknown as AgentHomeStore,
|
|
jobs,
|
|
events: {} as unknown as ThreadEvents,
|
|
workerId: "worker-1",
|
|
runtime: {} as unknown as AgentRuntime,
|
|
secretStore: {} as unknown as EncryptedSecretStore,
|
|
memoryProviders: { resolve: vi.fn(async () => null) },
|
|
messaging: {} as unknown as MessagingSurface,
|
|
});
|
|
|
|
await handlers["run.continue"]({ runId: "run-1" });
|
|
|
|
expect(mirrorMessagingOutbound).toHaveBeenCalledWith(
|
|
expect.objectContaining({ prisma: expect.anything(), messaging: expect.anything(), jobs }),
|
|
"run-1",
|
|
);
|
|
expect(deliverMessagingOutbound).toHaveBeenCalledWith(
|
|
expect.objectContaining({ prisma: expect.anything(), messaging: expect.anything(), jobs }),
|
|
{ runId: undefined },
|
|
expect.objectContaining({ operationId: "messaging.deliver:drain" }),
|
|
);
|
|
expect(sink.events.some((event) => event.message === "messaging.deliver enqueue error")).toBe(
|
|
true,
|
|
);
|
|
installLogger(createLogger({ service: "rakazo-worker", level: "off", sinks: [] }));
|
|
});
|
|
|
|
it("compacts the requested thread with the runtime, job publisher, and model key it was given", async () => {
|
|
const prisma = {} as unknown as PrismaClient;
|
|
const runtime = {} as unknown as AgentRuntime;
|
|
const jobs = {} as unknown as JobPublisher;
|
|
const secretStore = {} as unknown as EncryptedSecretStore;
|
|
const memoryProviders = { resolve: vi.fn(async () => null) };
|
|
const resolveModel = vi.fn();
|
|
const handlers = createBackgroundJobHandlers({
|
|
executor: { resolveModel } as unknown as ReturnType<typeof createRunExecutor>,
|
|
prisma,
|
|
sandbox: {} as unknown as SandboxProvider,
|
|
home: {} as unknown as AgentHomeStore,
|
|
jobs,
|
|
events: {} as unknown as ThreadEvents,
|
|
workerId: "worker-1",
|
|
runtime,
|
|
secretStore,
|
|
memoryProviders,
|
|
deploymentModelKey: "openrouter-key",
|
|
});
|
|
|
|
await handlers["history.compact"]({ threadId: "thread-1" });
|
|
|
|
expect(compactHistory).toHaveBeenCalledWith(
|
|
{
|
|
prisma,
|
|
runtime,
|
|
jobs,
|
|
memoryProviders,
|
|
deploymentModelKey: "openrouter-key",
|
|
resolveModel,
|
|
},
|
|
"thread-1",
|
|
);
|
|
});
|
|
|
|
it("consolidates the requested user's profile with the executor's model resolution", async () => {
|
|
const prisma = {} as unknown as PrismaClient;
|
|
const runtime = {} as unknown as AgentRuntime;
|
|
const jobs = {} as unknown as JobPublisher;
|
|
const memoryProviders = { resolve: vi.fn(async () => null) };
|
|
const resolveModel = vi.fn(async () => ({ provider: "local", id: "qwen3" }));
|
|
const handlers = createBackgroundJobHandlers({
|
|
executor: { resolveModel } as unknown as ReturnType<typeof createRunExecutor>,
|
|
prisma,
|
|
sandbox: {} as unknown as SandboxProvider,
|
|
home: {} as unknown as AgentHomeStore,
|
|
jobs,
|
|
events: {} as unknown as ThreadEvents,
|
|
workerId: "worker-1",
|
|
runtime,
|
|
secretStore: {} as unknown as EncryptedSecretStore,
|
|
memoryProviders,
|
|
});
|
|
|
|
await handlers["profile.consolidate"]({ userId: "user-1" });
|
|
|
|
expect(consolidateProfile).toHaveBeenCalledWith(
|
|
{ prisma, runtime, jobs, memoryProviders, resolveModel: expect.any(Function) },
|
|
"user-1",
|
|
);
|
|
const passed = vi.mocked(consolidateProfile).mock.calls[0]![0];
|
|
await passed.resolveModel({ userId: "user-1", spaceId: "space-1", botId: "bot-1" });
|
|
expect(resolveModel).toHaveBeenCalledWith({
|
|
userId: "user-1",
|
|
spaceId: "space-1",
|
|
botId: "bot-1",
|
|
});
|
|
});
|
|
|
|
it("resolves the deployment model when no user credential is configured", async () => {
|
|
const prisma = {
|
|
spaceModelPreference: { findFirst: vi.fn(async () => null) },
|
|
userModelCredential: { findFirst: vi.fn(async () => null) },
|
|
deploymentSettings: { findUnique: vi.fn(async () => null) },
|
|
} as unknown as PrismaClient;
|
|
const executor = createRunExecutor({
|
|
prisma,
|
|
deploymentModelKey: "deployment-key",
|
|
} as Parameters<typeof createRunExecutor>[0]);
|
|
|
|
await expect(
|
|
executor.resolveModel({ userId: "user-1", spaceId: "workspace-1" }),
|
|
).resolves.toEqual({
|
|
provider: "openrouter",
|
|
id: "openai/gpt-5.6-luna",
|
|
apiKey: "deployment-key",
|
|
baseUrl: undefined,
|
|
thinkingLevel: null,
|
|
oauth: undefined,
|
|
});
|
|
});
|
|
|
|
it("preserves a configured local model when resolving background compaction", async () => {
|
|
const prisma = {
|
|
spaceModelPreference: { findFirst: vi.fn(async () => null) },
|
|
userModelCredential: { findFirst: vi.fn(async () => null) },
|
|
deploymentSettings: {
|
|
findUnique: vi.fn(async () => ({
|
|
defaultModelProvider: "local",
|
|
defaultModelId: "qwen3:4b",
|
|
})),
|
|
},
|
|
} as unknown as PrismaClient;
|
|
const executor = createRunExecutor({
|
|
prisma,
|
|
} as Parameters<typeof createRunExecutor>[0]);
|
|
|
|
await expect(
|
|
executor.resolveModel({ userId: "user-1", spaceId: "workspace-1" }),
|
|
).resolves.toEqual({
|
|
provider: "local",
|
|
id: "qwen3:4b",
|
|
apiKey: undefined,
|
|
baseUrl: undefined,
|
|
thinkingLevel: null,
|
|
oauth: undefined,
|
|
});
|
|
});
|
|
});
|