chore: merge origin/main into mintlify/af82bda4 and resolve conflicts
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Offline mock of the Mem0 SDK so these tests never touch the network.
|
||||
const mockSearch = vi.fn();
|
||||
const mockAdd = vi.fn();
|
||||
vi.mock("mem0ai", () => ({
|
||||
MemoryClient: class {
|
||||
search = mockSearch;
|
||||
add = mockAdd;
|
||||
},
|
||||
}));
|
||||
|
||||
// The real `@deepseek-ai/dsh-tools` runtime transitively imports harness peer
|
||||
// packages the host provides at runtime but which aren't installed here. For
|
||||
// these unit tests we only need `defineTool` to hand back the definition it was
|
||||
// given, so the registered tool's `execute`/`name` can be exercised directly.
|
||||
vi.mock("@deepseek-ai/dsh-tools", () => ({
|
||||
defineTool: (options: unknown) => options,
|
||||
}));
|
||||
|
||||
import { apply, type Config } from "../src/index.ts";
|
||||
|
||||
interface RegisteredTool {
|
||||
name: string;
|
||||
execute(args: unknown, exec: unknown): Promise<unknown>;
|
||||
}
|
||||
|
||||
type HarnessListener = (...args: any[]) => unknown;
|
||||
|
||||
function applyAndCollect(config: Config): Map<string, RegisteredTool> {
|
||||
const tools = new Map<string, RegisteredTool>();
|
||||
const ctx = {
|
||||
tools: { register: (t: RegisteredTool) => tools.set(t.name, t) },
|
||||
on: vi.fn(),
|
||||
};
|
||||
apply(ctx as never, config);
|
||||
return tools;
|
||||
}
|
||||
|
||||
function applyAndCollectListeners(config: Config): Map<string, HarnessListener> {
|
||||
const listeners = new Map<string, HarnessListener>();
|
||||
const ctx = {
|
||||
tools: { register: vi.fn() },
|
||||
on: (event: string, listener: HarnessListener) => listeners.set(event, listener),
|
||||
};
|
||||
apply(ctx as never, config);
|
||||
return listeners;
|
||||
}
|
||||
|
||||
let savedKey: string | undefined;
|
||||
let savedTelemetry: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
savedKey = process.env.MEM0_API_KEY;
|
||||
savedTelemetry = process.env.MEM0_TELEMETRY;
|
||||
process.env.MEM0_TELEMETRY = "false";
|
||||
mockSearch.mockReset();
|
||||
mockAdd.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (savedKey === undefined) delete process.env.MEM0_API_KEY;
|
||||
else process.env.MEM0_API_KEY = savedKey;
|
||||
if (savedTelemetry === undefined) delete process.env.MEM0_TELEMETRY;
|
||||
else process.env.MEM0_TELEMETRY = savedTelemetry;
|
||||
});
|
||||
|
||||
describe("apply() config validation", () => {
|
||||
it("throws when no apiKey is set and MEM0_API_KEY is absent", () => {
|
||||
delete process.env.MEM0_API_KEY;
|
||||
expect(() => applyAndCollect({ userId: "u" } as Config)).toThrow(/apiKey|MEM0_API_KEY/);
|
||||
});
|
||||
|
||||
it("throws when userId is missing", () => {
|
||||
expect(() => applyAndCollect({ apiKey: "k", userId: "" } as Config)).toThrow(/userId/);
|
||||
});
|
||||
|
||||
it("registers both memory tools", () => {
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
expect([...tools.keys()].sort()).toEqual(["add_memory", "search_memory"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Harness lifecycle", () => {
|
||||
it("automatically recalls memory into the prompt for the latest human message", async () => {
|
||||
mockSearch.mockResolvedValue({
|
||||
results: [{ id: "m1", memory: "Likes tea" }],
|
||||
});
|
||||
const listeners = applyAndCollectListeners({ apiKey: "k", userId: "u" });
|
||||
const assemble = listeners.get("system-prompt/assemble")!;
|
||||
const base = { sections: [], contexts: [], tools: [], variables: {} };
|
||||
|
||||
const result = await assemble(
|
||||
base,
|
||||
{
|
||||
agent: {
|
||||
session: {
|
||||
deriveMessages: () => [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "What do I drink?" }],
|
||||
source: { kind: "user" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
async () => base,
|
||||
);
|
||||
|
||||
expect(mockSearch).toHaveBeenCalledWith("What do I drink?", {
|
||||
filters: { user_id: "u" },
|
||||
topK: 5,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
contexts: [{ name: "mem0:recall", text: expect.stringContaining("Likes tea") }],
|
||||
});
|
||||
});
|
||||
|
||||
it("automatically captures a completed human and assistant turn", async () => {
|
||||
mockAdd.mockResolvedValue({ eventId: "evt-1", status: "PENDING" });
|
||||
const listeners = applyAndCollectListeners({ apiKey: "k", userId: "u" });
|
||||
const onSessionEvent = listeners.get("session/event")!;
|
||||
const session = {};
|
||||
|
||||
onSessionEvent(session, { type: "turn/start", data: { turn: 1 } });
|
||||
onSessionEvent(session, {
|
||||
type: "user/message",
|
||||
data: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Remember I like tea" }],
|
||||
source: { kind: "user" },
|
||||
},
|
||||
});
|
||||
onSessionEvent(session, {
|
||||
type: "assistant/message",
|
||||
data: {
|
||||
turn: 1,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "I will remember that." }],
|
||||
source: { kind: "model" },
|
||||
},
|
||||
},
|
||||
});
|
||||
onSessionEvent(session, {
|
||||
type: "turn/end",
|
||||
data: { turn: 1, reason: { kind: "completed" } },
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockAdd).toHaveBeenCalledWith(
|
||||
[
|
||||
{ role: "user", content: "Remember I like tea" },
|
||||
{ role: "assistant", content: "I will remember that." },
|
||||
],
|
||||
{ userId: "u", source: "DEEPSEEK_HARNESS" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("can disable automatic recall and capture without removing the memory tools", () => {
|
||||
const listeners = applyAndCollectListeners({
|
||||
apiKey: "k",
|
||||
userId: "u",
|
||||
autoRecall: false,
|
||||
autoCapture: false,
|
||||
});
|
||||
|
||||
expect(listeners.has("system-prompt/assemble")).toBe(false);
|
||||
expect(listeners.has("session/event")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("search_memory tool", () => {
|
||||
it("returns a formatted list scoped to the configured user", async () => {
|
||||
mockSearch.mockResolvedValue({
|
||||
results: [{ id: "m1", memory: "Likes tea", categories: ["preference"] }],
|
||||
});
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
|
||||
const out = await tools.get("search_memory")!.execute({ query: "drink" }, {});
|
||||
|
||||
expect(out).toContain("Likes tea");
|
||||
expect(out).toContain("[mem0:m1]");
|
||||
expect(mockSearch).toHaveBeenCalledWith("drink", {
|
||||
filters: { user_id: "u" },
|
||||
topK: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("honors a per-call userId override and limit", async () => {
|
||||
mockSearch.mockResolvedValue({ results: [] });
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u", allowUserOverride: true });
|
||||
|
||||
await tools.get("search_memory")!.execute({ query: "x", userId: "alice", limit: 3 }, {});
|
||||
|
||||
expect(mockSearch).toHaveBeenCalledWith("x", {
|
||||
filters: { user_id: "alice" },
|
||||
topK: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a graceful failure line instead of rejecting on error", async () => {
|
||||
mockSearch.mockRejectedValue(new Error("network down"));
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
|
||||
const out = await tools.get("search_memory")!.execute({ query: "x" }, {});
|
||||
|
||||
expect(out).toContain("search_memory failed");
|
||||
expect(out).toContain("network down");
|
||||
});
|
||||
});
|
||||
|
||||
describe("add_memory tool", () => {
|
||||
it("reports the write as queued on the async PENDING response, with camelCase scope + source", async () => {
|
||||
// The real /v3/memories/add/ response — not an array of memories. The SDK
|
||||
// camel-cases response keys, so it surfaces as `eventId`, not `event_id`.
|
||||
mockAdd.mockResolvedValue({ eventId: "evt-123", status: "PENDING" });
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
|
||||
const out = await tools.get("add_memory")!.execute({ text: "remember this" }, {});
|
||||
|
||||
expect(out).toContain("queued");
|
||||
expect(out).toContain("evt-123");
|
||||
expect(out).not.toContain("No new distinct memory");
|
||||
expect(mockAdd).toHaveBeenCalledWith(
|
||||
[{ role: "user", content: "remember this" }],
|
||||
{ userId: "u", source: "DEEPSEEK_HARNESS" },
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a list when the backend returns memories", async () => {
|
||||
mockAdd.mockResolvedValue([{ id: "m1", memory: "Fact" }]);
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
|
||||
const out = await tools.get("add_memory")!.execute({ text: "x" }, {});
|
||||
|
||||
expect(out).toContain("Stored 1 memory");
|
||||
expect(out).toContain("[mem0:m1]");
|
||||
});
|
||||
|
||||
it("redacts credentials before storing explicit memory", async () => {
|
||||
mockAdd.mockResolvedValue([]);
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
|
||||
await tools.get("add_memory")!.execute({ text: "api_key=do-not-store-this" }, {});
|
||||
|
||||
expect(mockAdd).toHaveBeenCalledWith(
|
||||
[{ role: "user", content: "api_key=[REDACTED]" }],
|
||||
{ userId: "u", source: "DEEPSEEK_HARNESS" },
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a graceful failure line on error", async () => {
|
||||
mockAdd.mockRejectedValue(new Error("boom"));
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
|
||||
const out = await tools.get("add_memory")!.execute({ text: "x" }, {});
|
||||
|
||||
expect(out).toContain("add_memory failed");
|
||||
expect(out).toContain("boom");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool user ownership", () => {
|
||||
it("rejects cross-user reads and writes before contacting Mem0", async () => {
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
await expect(tools.get("search_memory")!.execute({ query: "x", userId: "other" }, {})).rejects.toThrow(/allowUserOverride/);
|
||||
await expect(tools.get("add_memory")!.execute({ text: "x", userId: "other" }, {})).rejects.toThrow(/allowUserOverride/);
|
||||
expect(mockSearch).not.toHaveBeenCalled();
|
||||
expect(mockAdd).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
formatAge,
|
||||
formatMemoryCompact,
|
||||
formatMemoryList,
|
||||
formatAddResult,
|
||||
} from "../src/formatting.ts";
|
||||
|
||||
describe("formatAge", () => {
|
||||
it("formats minutes, hours, and days", () => {
|
||||
expect(formatAge(new Date(Date.now() - 30 * 60_000))).toBe("30m ago");
|
||||
expect(formatAge(new Date(Date.now() - 3 * 3_600_000))).toBe("3h ago");
|
||||
expect(formatAge(new Date(Date.now() - 5 * 86_400_000))).toBe("5d ago");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMemoryCompact", () => {
|
||||
it("renders one line with category, text, and id", () => {
|
||||
const line = formatMemoryCompact({
|
||||
id: "abc-123",
|
||||
memory: "User prefers dark mode",
|
||||
categories: ["preference"],
|
||||
createdAt: new Date(),
|
||||
});
|
||||
expect(line).toContain("[preference]");
|
||||
expect(line).toContain("User prefers dark mode");
|
||||
expect(line).toContain("[mem0:abc-123]");
|
||||
});
|
||||
|
||||
it("falls back to uncategorized and (empty)", () => {
|
||||
expect(formatMemoryCompact({ id: "x" })).toContain("[uncategorized]");
|
||||
expect(formatMemoryCompact({ id: "x" })).toContain("(empty)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMemoryList", () => {
|
||||
it("numbers multiple memories", () => {
|
||||
const output = formatMemoryList([
|
||||
{ id: "id-1", memory: "Fact one", categories: ["insight"] },
|
||||
{ id: "id-2", memory: "Fact two", categories: ["convention"] },
|
||||
]);
|
||||
expect(output).toContain("1.");
|
||||
expect(output).toContain("2.");
|
||||
});
|
||||
|
||||
it("returns a plain message when there are no memories", () => {
|
||||
expect(formatMemoryList([])).toBe("No memories found.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatAddResult", () => {
|
||||
it("reports queued for the async PENDING response, with the event id", () => {
|
||||
// SDK camel-cases response keys, so the real shape is `eventId`.
|
||||
const out = formatAddResult({ eventId: "evt-9", status: "PENDING" });
|
||||
expect(out).toContain("queued");
|
||||
expect(out).toContain("evt-9");
|
||||
});
|
||||
|
||||
it("reports the stored count when the backend returns memories", () => {
|
||||
expect(formatAddResult([{ id: "1", memory: "A" }])).toContain("Stored 1 memory");
|
||||
expect(formatAddResult([{ id: "1" }, { id: "2" }])).toContain("Stored 2 memories");
|
||||
});
|
||||
|
||||
it("unwraps a { results: [...] } envelope", () => {
|
||||
expect(formatAddResult({ results: [{ id: "1", memory: "A" }] })).toContain("Stored 1 memory");
|
||||
});
|
||||
|
||||
it("handles an empty result", () => {
|
||||
expect(formatAddResult([])).toBe("Memory stored.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { truncateOutput, MAX_OUTPUT_LINES } from "../src/output.ts";
|
||||
|
||||
describe("truncateOutput", () => {
|
||||
it("passes small output through untouched", () => {
|
||||
expect(truncateOutput("a\nb\nc")).toBe("a\nb\nc");
|
||||
});
|
||||
|
||||
it("caps output at MAX_OUTPUT_LINES and appends a notice", () => {
|
||||
const many = Array.from({ length: MAX_OUTPUT_LINES + 50 }, (_, i) => `line ${i}`).join("\n");
|
||||
const out = truncateOutput(many);
|
||||
expect(out.split("\n").length).toBeLessThanOrEqual(MAX_OUTPUT_LINES + 3);
|
||||
expect(out).toContain("[Output truncated:");
|
||||
expect(out).toContain(`of ${MAX_OUTPUT_LINES + 50} lines`);
|
||||
});
|
||||
|
||||
it("caps output that is few lines but very large by bytes", () => {
|
||||
const huge = "x".repeat(60_000);
|
||||
const out = truncateOutput(huge);
|
||||
expect(out.length).toBeLessThan(huge.length);
|
||||
expect(out).toContain("[Output truncated:");
|
||||
});
|
||||
|
||||
it("reports both reasons when the line cap and the byte cap fire together", () => {
|
||||
const wide = Array.from({ length: MAX_OUTPUT_LINES + 50 }, () => "x".repeat(300)).join("\n");
|
||||
const out = truncateOutput(wide);
|
||||
expect(out).toContain(`of ${MAX_OUTPUT_LINES + 50} lines`);
|
||||
expect(out).toContain("cut at 50KB");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveSearchFilters, resolveAddParams } from "../src/scoping.ts";
|
||||
|
||||
describe("resolveSearchFilters (snake_case, for filters)", () => {
|
||||
it("falls back to the configured default userId", () => {
|
||||
expect(resolveSearchFilters({}, "default-user")).toEqual({ user_id: "default-user" });
|
||||
});
|
||||
|
||||
it("lets a per-call userId override the default", () => {
|
||||
expect(resolveSearchFilters({ userId: "alice" }, "default-user")).toEqual({
|
||||
user_id: "alice",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a blank/whitespace userId as absent and falls back", () => {
|
||||
expect(resolveSearchFilters({ userId: " " }, "default-user")).toEqual({
|
||||
user_id: "default-user",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes snake_case agent/run scope only when provided", () => {
|
||||
expect(
|
||||
resolveSearchFilters({ userId: "alice", agentId: "agent-1", runId: "run-9" }, "d"),
|
||||
).toEqual({ user_id: "alice", agent_id: "agent-1", run_id: "run-9" });
|
||||
});
|
||||
|
||||
it("omits blank agent/run scope", () => {
|
||||
const f = resolveSearchFilters({ agentId: " ", runId: "run-9" }, "default");
|
||||
expect(f).toEqual({ user_id: "default", run_id: "run-9" });
|
||||
expect(f).not.toHaveProperty("agent_id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAddParams (camelCase, for top-level add params)", () => {
|
||||
it("uses camelCase keys and falls back to the default userId", () => {
|
||||
expect(resolveAddParams({}, "default-user")).toEqual({ userId: "default-user" });
|
||||
});
|
||||
|
||||
it("includes camelCase agent/run scope only when provided", () => {
|
||||
expect(
|
||||
resolveAddParams({ userId: "alice", agentId: "agent-1", runId: "run-9" }, "d"),
|
||||
).toEqual({ userId: "alice", agentId: "agent-1", runId: "run-9" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
const mockSearch = vi.fn();
|
||||
const mockAdd = vi.fn();
|
||||
vi.mock("mem0ai", () => ({
|
||||
MemoryClient: class {
|
||||
telemetryId = "dev@example.com";
|
||||
search = mockSearch;
|
||||
add = mockAdd;
|
||||
},
|
||||
}));
|
||||
vi.mock("@deepseek-ai/dsh-tools", () => ({ defineTool: (options: unknown) => options }));
|
||||
|
||||
import { apply, type Config } from "../src/index.ts";
|
||||
import {
|
||||
captureEvent,
|
||||
errorKind,
|
||||
flushEvents,
|
||||
isTelemetryEnabled,
|
||||
_queueForTesting,
|
||||
_resetForTesting,
|
||||
} from "../src/telemetry.ts";
|
||||
|
||||
interface RegisteredTool {
|
||||
name: string;
|
||||
execute(args: unknown, exec: unknown): Promise<unknown>;
|
||||
}
|
||||
|
||||
function applyAndCollect(config: Config): Map<string, RegisteredTool> {
|
||||
const tools = new Map<string, RegisteredTool>();
|
||||
apply({
|
||||
tools: { register: (t: RegisteredTool) => tools.set(t.name, t) },
|
||||
on: vi.fn(),
|
||||
} as never, config);
|
||||
return tools;
|
||||
}
|
||||
|
||||
function queued(): Record<string, any>[] {
|
||||
return _queueForTesting() as Record<string, any>[];
|
||||
}
|
||||
|
||||
let home: string;
|
||||
let savedHome: string | undefined;
|
||||
let savedTelemetry: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
savedHome = process.env.HOME;
|
||||
savedTelemetry = process.env.MEM0_TELEMETRY;
|
||||
delete process.env.MEM0_TELEMETRY;
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-deepseek-"));
|
||||
process.env.HOME = home;
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true }));
|
||||
mockSearch.mockReset();
|
||||
mockAdd.mockReset();
|
||||
_resetForTesting();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
_resetForTesting();
|
||||
vi.unstubAllGlobals();
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
if (savedHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = savedHome;
|
||||
if (savedTelemetry === undefined) delete process.env.MEM0_TELEMETRY;
|
||||
else process.env.MEM0_TELEMETRY = savedTelemetry;
|
||||
});
|
||||
|
||||
describe("opt-out", () => {
|
||||
it("queues nothing for every documented off value", () => {
|
||||
for (const value of ["false", "0", "no", "OFF"]) {
|
||||
process.env.MEM0_TELEMETRY = value;
|
||||
expect(isTelemetryEnabled()).toBe(false);
|
||||
captureEvent("deepseek.tool.search_memory", {}, { telemetryId: "dev@example.com" });
|
||||
}
|
||||
expect(queued()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is on by default", () => {
|
||||
expect(isTelemetryEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("identity", () => {
|
||||
it("keys events on the account email the SDK resolved", () => {
|
||||
captureEvent("deepseek.tool.add_memory", {}, { telemetryId: "dev@example.com" });
|
||||
expect(queued()[0].distinct_id).toBe("dev@example.com");
|
||||
});
|
||||
|
||||
it("falls back to a persisted anonymous id before the SDK has pinged", () => {
|
||||
captureEvent("deepseek.tool.add_memory", {}, {});
|
||||
const first = queued()[0].distinct_id as string;
|
||||
expect(first).toMatch(/^deepseek-anon-/);
|
||||
|
||||
_resetForTesting();
|
||||
captureEvent("deepseek.tool.add_memory", {}, {});
|
||||
expect(queued()[0].distinct_id).toBe(first);
|
||||
});
|
||||
|
||||
it("aliases the anonymous history onto the email exactly once", () => {
|
||||
captureEvent("deepseek.tool.add_memory", {}, {});
|
||||
const anonymous = queued()[0].distinct_id;
|
||||
_resetForTesting();
|
||||
|
||||
captureEvent("deepseek.tool.add_memory", {}, { telemetryId: "dev@example.com" });
|
||||
const [identify, event] = queued();
|
||||
expect(identify.event).toBe("$identify");
|
||||
expect(identify.distinct_id).toBe("dev@example.com");
|
||||
expect(identify.properties.$anon_distinct_id).toBe(anonymous);
|
||||
expect(event.distinct_id).toBe("dev@example.com");
|
||||
|
||||
captureEvent("deepseek.tool.add_memory", {}, { telemetryId: "dev@example.com" });
|
||||
expect(queued().filter((e) => e.event === "$identify")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("event shape", () => {
|
||||
it("stamps every event with the integration source and runtime baseline", () => {
|
||||
captureEvent("deepseek.tool.search_memory", { success: true }, { telemetryId: "d@e.com" });
|
||||
const { properties } = queued()[0];
|
||||
expect(properties.source).toBe("DEEPSEEK_HARNESS");
|
||||
expect(properties.language).toBe("node");
|
||||
expect(properties.$process_person_profile).toBe(false);
|
||||
expect(properties.os).toBe(process.platform);
|
||||
expect(properties.plugin_version).not.toBe("unknown");
|
||||
expect(properties.success).toBe(true);
|
||||
});
|
||||
|
||||
it("flushes one batch to PostHog and empties the queue", async () => {
|
||||
captureEvent("deepseek.tool.search_memory", {}, { telemetryId: "d@e.com" });
|
||||
flushEvents();
|
||||
|
||||
const [url, init] = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toContain("posthog.com");
|
||||
expect(JSON.parse(init.body).batch).toHaveLength(1);
|
||||
expect(queued()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("never throws when the network is gone", () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("fetch failed")));
|
||||
captureEvent("deepseek.tool.search_memory", {}, { telemetryId: "d@e.com" });
|
||||
expect(() => flushEvents()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("errorKind", () => {
|
||||
it("buckets failures without leaking the message", () => {
|
||||
expect(errorKind(new Error("Request failed with status 429"))).toBe("rate-limited");
|
||||
expect(errorKind(new Error("401 Unauthorized"))).toBe("auth");
|
||||
expect(errorKind(new Error("503 Service Unavailable"))).toBe("server-error");
|
||||
expect(errorKind(new Error("The operation was aborted due to timeout"))).toBe("timeout");
|
||||
expect(errorKind(new Error("fetch failed"))).toBe("network");
|
||||
expect(errorKind(new Error("token sk-abcdef is invalid"))).toBe("Error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool instrumentation", () => {
|
||||
it("records a mount, then a successful search with its shape but not its query", async () => {
|
||||
mockSearch.mockResolvedValue({ results: [{ id: "m1", memory: "Likes tea" }] });
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
|
||||
await tools.get("search_memory")!.execute({ query: "what drink", limit: 3 }, {});
|
||||
|
||||
const [mounted, search] = queued();
|
||||
expect(mounted.event).toBe("deepseek.plugin.mounted");
|
||||
expect(mounted.properties.has_host).toBe(false);
|
||||
expect(search.event).toBe("deepseek.tool.search_memory");
|
||||
expect(search.properties).toMatchObject({
|
||||
success: true,
|
||||
top_k: 3,
|
||||
result_count: 1,
|
||||
query_chars: 10,
|
||||
scope_overridden: false,
|
||||
has_agent_id: false,
|
||||
has_run_id: false,
|
||||
});
|
||||
expect(typeof search.properties.duration_ms).toBe("number");
|
||||
expect(JSON.stringify(search)).not.toContain("what drink");
|
||||
expect(JSON.stringify(search)).not.toContain("Likes tea");
|
||||
});
|
||||
|
||||
it("records a failed search with a coarse error kind", async () => {
|
||||
mockSearch.mockRejectedValue(new Error("429 rate limit exceeded"));
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
|
||||
await tools.get("search_memory")!.execute({ query: "x" }, {});
|
||||
|
||||
const search = queued().at(-1)!;
|
||||
expect(search.properties).toMatchObject({ success: false, error_kind: "rate-limited" });
|
||||
});
|
||||
|
||||
it("records a write with its size but not its text", async () => {
|
||||
mockAdd.mockResolvedValue([{ id: "m1", memory: "Fact" }]);
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u", allowUserOverride: true });
|
||||
|
||||
await tools.get("add_memory")!.execute({ text: "secret fact", userId: "alice" }, {});
|
||||
|
||||
const add = queued().at(-1)!;
|
||||
expect(add.event).toBe("deepseek.tool.add_memory");
|
||||
expect(add.properties).toMatchObject({
|
||||
success: true,
|
||||
text_chars: 11,
|
||||
memory_count: 1,
|
||||
scope_overridden: true,
|
||||
});
|
||||
expect(JSON.stringify(add)).not.toContain("secret fact");
|
||||
expect(JSON.stringify(add)).not.toContain("alice");
|
||||
});
|
||||
|
||||
it("records a failed write and still returns the graceful failure line", async () => {
|
||||
mockAdd.mockRejectedValue(new Error("boom"));
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
|
||||
const out = await tools.get("add_memory")!.execute({ text: "x" }, {});
|
||||
|
||||
expect(out).toContain("add_memory failed");
|
||||
expect(queued().at(-1)!.properties).toMatchObject({ success: false, error_kind: "Error" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user