godcrm/backend/services/agent-tools/__tests__/memory-tools.test.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

129 lines
5.1 KiB
JavaScript

// @vitest-environment node
/**
* Unit guard for the Hindsight memory_retain wrapper.
*
* Pins the bug @marketer hit live: a retain call 422'd with
* `body.items.0.content: Field required`. Root cause = the memorized string
* arrived under the API's own field name `content` (not the tool's `text`),
* so `item.content` was undefined and an empty item shipped to FastAPI.
*
* Pins:
* - happy path: `text` → POST { items: [{ content }] }
* - alias: `content` (no `text`) is accepted as the memorized string
* - empty/missing content → fail fast LOCALLY, never round-trip an empty item
* - 422 array `detail` surfaces as a human string, not "[object Object]"
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
vi.mock('../../../utils/logger.js', () => ({
aiLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
const { memoryToolHandlers } = await import('../memory-tools.js');
const { memory_retain, memory_recall } = memoryToolHandlers;
function recallResponse(results = []) {
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ results }),
};
}
function okResponse(body = { items: [{ id: 'm1' }], items_count: 1 }) {
return {
ok: true,
status: 200,
text: async () => JSON.stringify(body),
};
}
function errResponse(status, body) {
return {
ok: false,
status,
text: async () => JSON.stringify(body),
};
}
let fetchMock;
beforeEach(() => {
fetchMock = vi.fn().mockResolvedValue(okResponse());
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function lastBody() {
return JSON.parse(fetchMock.mock.calls.at(-1)[1].body);
}
describe('memory_retain', () => {
it('wraps `text` into items:[{ content }] and reports success', async () => {
const res = await memory_retain({ text: 'a fact worth keeping', bank_id: 'godcrm-main' }, 1, {});
expect(res.success).toBe(true);
expect(lastBody()).toEqual({ items: [{ content: 'a fact worth keeping' }] });
});
it('accepts the memorized string under the `content` alias (the live 422 bug)', async () => {
const res = await memory_retain({ content: 'stored under the api field name' }, 1, {});
expect(res.success).toBe(true);
expect(lastBody().items[0].content).toBe('stored under the api field name');
});
it('fails fast locally on empty content — never ships an empty item to the API', async () => {
await expect(memory_retain({ text: ' ' }, 1, {})).rejects.toThrow(/text.*required|required.*text/i);
await expect(memory_retain({}, 1, {})).rejects.toThrow(/required/i);
expect(fetchMock).not.toHaveBeenCalled();
});
it('surfaces a FastAPI 422 array `detail` as a human string, not [object Object]', async () => {
fetchMock.mockResolvedValueOnce(
errResponse(422, { detail: [{ loc: ['body', 'items', 0, 'content'], msg: 'Field required', type: 'missing' }] })
);
await expect(memory_retain({ text: 'x' }, 1, {})).rejects.toThrow('body.items.0.content: Field required');
});
});
describe('memory_recall — ADR-157 scope-router', () => {
it('does NOT route when auto_scope is off (default behaviour unchanged)', async () => {
fetchMock.mockResolvedValue(recallResponse([{ id: 'm1', text: 'x' }]));
await memory_recall({ query: 'how does jwt login token session work' }, 1, {});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(lastBody().room).toBeUndefined();
});
it('routes a confident query to its room when auto_scope is on', async () => {
fetchMock.mockResolvedValue(recallResponse([{ id: 'm1', text: 'x' }]));
await memory_recall({ query: 'how does jwt login token session work', auto_scope: true }, 1, {});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(lastBody().room).toEqual(['auth']);
});
it('honours an explicit room and skips the router even with auto_scope', async () => {
fetchMock.mockResolvedValue(recallResponse([{ id: 'm1', text: 'x' }]));
await memory_recall({ query: 'jwt login token', room: 'pipeline', auto_scope: true }, 1, {});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(lastBody().room).toEqual(['pipeline']);
});
it('widens by one level: a routed room that returns nothing retries unscoped', async () => {
fetchMock
.mockResolvedValueOnce(recallResponse([])) // routed room → empty
.mockResolvedValueOnce(recallResponse([{ id: 'm9', text: 'y' }])); // widened → hit
const res = await memory_recall({ query: 'jwt login token session', auto_scope: true }, 1, {});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(JSON.parse(fetchMock.mock.calls[0][1].body).room).toEqual(['auth']);
expect(JSON.parse(fetchMock.mock.calls[1][1].body).room).toBeUndefined();
expect(res.count).toBe(1);
});
it('does not widen when a low-confidence query was never scoped', async () => {
fetchMock.mockResolvedValue(recallResponse([]));
await memory_recall({ query: 'what about the api', auto_scope: true }, 1, {});
// single weak hit → widened up front (no room), so no second retry call
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(lastBody().room).toBeUndefined();
});
});