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
1 change: 1 addition & 0 deletions backend/__tests__/service/agent-memory-envelope.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,7 @@ describe('AgentMemory envelope — GET/PUT /memory + backfill', () => {
const matched = dailyArr.find((d) => d?.date === today);
expect(matched).toBeTruthy();
expect(matched.content).toBe('natural write without date');
expect(Date.now() - new Date(matched.updatedAt).getTime()).toBeLessThan(60_000);
});

it('full mode: replaces the entire sections envelope', async () => {
Expand Down
83 changes: 83 additions & 0 deletions backend/__tests__/service/agent-profile-memory-write.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
const express = require('express');
const request = require('supertest');
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');

// This anonymous profile route does not exercise JWT behavior. Mock it so the
// route's unrelated auth-module import stays runnable on Node 26, where this
// repo's legacy jsonwebtoken transitive dependency fails during module load.
jest.mock('jsonwebtoken', () => ({
sign: jest.fn(),
verify: jest.fn(),
decode: jest.fn(),
}));

const { MONGO_BINARY_VERSION, MONGOMS_DOWNLOAD_DIR } = require('../utils/mongoBinaryConfig');
const User = require('../../models/User');
const AgentMemory = require('../../models/AgentMemory');
const agentProfileRoutes = require('../../routes/agentProfile');

describe('Agent profile memory activity', () => {
let app;
let mongoServer;

beforeAll(async () => {
mongoServer = await MongoMemoryServer.create({
binary: { version: MONGO_BINARY_VERSION, downloadDir: MONGOMS_DOWNLOAD_DIR, skipMD5: true },
instance: { dbName: 'agent-profile-memory-write' },
});
await mongoose.connect(mongoServer.getUri());
app = express();
app.use('/api/agent-profile', agentProfileRoutes);
});

afterEach(async () => {
await mongoose.connection.dropDatabase();
});

afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});

it('reports the latest agent-authored write as a KIND, not a newer system exchange', async () => {
await User.create({
username: 'memory-observer',
email: 'memory-observer@test.com',
password: 'Password123!',
isBot: true,
botMetadata: { agentName: 'claude-code', instanceId: 'observer' },
});
await AgentMemory.create({
agentName: 'claude-code',
instanceId: 'observer',
sections: {
long_term: {
content: 'Durable review state',
visibility: 'private',
updatedAt: new Date('2026-08-26T09:00:00Z'),
},
system_exchanges: {
entries: [],
visibility: 'private',
updatedAt: new Date('2026-08-26T10:00:00Z'),
},
},
});

const res = await request(app).get('/api/agent-profile/claude-code/observer');

expect(res.status).toBe(200);
// This route is unauthenticated: it reports THAT the seat wrote something
// durable, never WHICH section. Selection still has to pick long_term over
// the newer system_exchanges bump — that is what a real store exercises
// here, on top of the mocked unit cover in
// __tests__/unit/routes/agentProfile.memoryWrite.test.js.
expect(res.body.memory.lastAgentWrite).toEqual({
kind: 'durable',
updatedAt: '2026-08-26T09:00:00.000Z',
});
expect(res.body.memory.lastAgentWrite.section).toBeUndefined();
expect(res.body.memory.updatedAt).toBeUndefined();
});
});
18 changes: 18 additions & 0 deletions backend/__tests__/unit/models/AgentMemory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,24 @@ describe('AgentMemory (ADR-003 v2 schema)', () => {
expect(doc.schemaVersion).toBe(2);
});

it('does not fabricate write times while hydrating legacy section records', async () => {
await AgentMemory.collection.insertOne({
agentName: 'openclaw',
instanceId: 'legacy-no-section-stamps',
content: '',
sections: {
long_term: { content: 'legacy durable state', visibility: 'private', byteSize: 20 },
daily: [{ date: '2026-08-01', content: 'legacy journal', visibility: 'private' }],
relationships: [{ otherInstanceId: 'nova', notes: 'legacy note', visibility: 'private' }],
},
});

const doc = await AgentMemory.findOne({ instanceId: 'legacy-no-section-stamps' });
expect(doc.sections?.long_term?.updatedAt).toBeUndefined();
expect(doc.sections?.daily?.[0]?.updatedAt).toBeUndefined();
expect(doc.sections?.relationships?.[0]?.updatedAt).toBeUndefined();
});

it('defaults visibility to "private" on sections', async () => {
const doc = await AgentMemory.create({
agentName: 'openclaw',
Expand Down
124 changes: 124 additions & 0 deletions backend/__tests__/unit/routes/agentProfile.memoryWrite.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* The public agent profile is mounted WITHOUT auth (see the file header of
* routes/agentProfile.ts), so it may report THAT an agent saved and when —
* never WHICH section. The owner/admin memory view keeps the exact section;
* both derive from the same max() over AGENT_WRITABLE_SECTIONS.
*/

jest.mock('../../../models/User', () => ({ findOne: jest.fn(), findById: jest.fn() }));
jest.mock('../../../models/Pod', () => ({ find: jest.fn(), countDocuments: jest.fn() }));
// Keep the real AGENT_WRITABLE_SECTIONS — the selection rule under test reads
// it — and stub only the query.
jest.mock('../../../models/AgentMemory', () => ({
...jest.requireActual('../../../models/AgentMemory'),
findOne: jest.fn(),
}));
jest.mock('../../../models/AgentRun', () => ({ find: jest.fn() }));
jest.mock('../../../models/PodAsset', () => ({ find: jest.fn() }));
jest.mock('../../../models/pg/Message', () => ({}));
jest.mock('../../../models/AgentRegistry', () => ({
AgentInstallation: { findOne: jest.fn(), find: jest.fn() },
AgentRegistry: { findOne: jest.fn(), updateOne: jest.fn() },
}));
jest.mock('../../../services/agentIdentityService', () => ({
resolveAgentDisplayLabel: jest.fn((u, f) => f),
syncUserToPostgreSQL: jest.fn(),
}));
jest.mock('../../../middleware/auth', () => jest.fn((req, res, next) => next()));

const User = require('../../../models/User');
const Pod = require('../../../models/Pod');
const AgentMemory = require('../../../models/AgentMemory');
const AgentRun = require('../../../models/AgentRun');
const PodAsset = require('../../../models/PodAsset');
const { AgentInstallation } = require('../../../models/AgentRegistry');
const router = require('../../../routes/agentProfile');

// The route swallows handler errors into a 500. Capture what it logged so a
// wiring failure names itself instead of arriving as a passing "no section
// name" assertion on an empty body.
let handlerError;
jest.spyOn(console, 'error').mockImplementation((...args) => { handlerError = args; });

const getHandler = (method, path) => {
const layer = router.stack.find((entry) => (
entry.route && entry.route.path === path && entry.route.methods[method]
));
if (!layer) throw new Error(`${method.toUpperCase()} ${path} handler not found`);
return layer.route.stack[layer.route.stack.length - 1].handle;
};

const response = () => {
const res = { statusCode: 200, body: undefined };
res.status = (code) => { res.statusCode = code; return res; };
res.json = (payload) => { res.body = payload; return res; };
return res;
};

const lean = (value) => ({ lean: jest.fn().mockResolvedValue(value) });
const selectLean = (value) => ({ select: jest.fn().mockReturnValue(lean(value)) });

const UPDATED_AT = new Date('2026-08-26T10:00:00Z');

const runProfile = async (sections) => {
User.findOne.mockReturnValue(selectLean({
_id: 'agent-user', username: 'observer', isBot: true, profilePicture: 'default',
botMetadata: { agentName: 'claude-code', instanceId: 'observer', capabilities: [] },
createdAt: new Date('2026-01-01T00:00:00Z'),
}));
PodAsset.find.mockReturnValue(selectLean([]));
AgentInstallation.find.mockReturnValue(selectLean([]));
Pod.find.mockReturnValue(selectLean([]));
AgentMemory.findOne.mockReturnValue(selectLean({ sections }));
AgentRun.find.mockReturnValue({
sort: () => ({ limit: () => selectLean([]) }),
});

const res = response();
await getHandler('get', '/:agentName/:instanceId?')(
{ params: { agentName: 'claude-code', instanceId: 'observer' }, query: {} },
res,
);
if (res.statusCode !== 200) {
throw new Error(`handler ${res.statusCode}: ${JSON.stringify(handlerError)}`);
}
return res;
};

describe('GET /:agentName/:instanceId — last agent write', () => {
beforeEach(() => jest.clearAllMocks());

it('reports a durable write as a kind, without the section name', async () => {
const res = await runProfile({
long_term: { content: 'durable decision', updatedAt: UPDATED_AT },
});

expect(res.statusCode).toBe(200);
expect(res.body.memory.lastAgentWrite).toEqual({ kind: 'durable', updatedAt: UPDATED_AT });
});

it('reports a housekeeping write as bookkeeping, without the section name', async () => {
const res = await runProfile({
runtime_meta: { content: 'runtime snapshot', updatedAt: UPDATED_AT },
});

expect(res.body.memory.lastAgentWrite).toEqual({ kind: 'bookkeeping', updatedAt: UPDATED_AT });
});

it('never emits a section name on this unauthenticated route', async () => {
const res = await runProfile({
dedup_state: { content: 'message ids', updatedAt: UPDATED_AT },
});

expect(JSON.stringify(res.body)).not.toContain('dedup_state');
expect(Object.keys(res.body.memory.lastAgentWrite).sort()).toEqual(['kind', 'updatedAt']);
});

it('omits the field entirely when only automated writers have touched the envelope', async () => {
const res = await runProfile({
system_exchanges: { entries: [], visibility: 'private', updatedAt: UPDATED_AT },
});

expect(res.body.memory.lastAgentWrite).toBeNull();
});
});
112 changes: 110 additions & 2 deletions backend/__tests__/unit/services/agentMemoryService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ const {
buildSectionsFromLegacyContent,
mirrorContentFromSections,
stampSectionsForWrite,
getLastAgentMemoryWrite,
coarsenAgentMemoryWrite,
classifyAgentWriteSection,
mergePatchSections,
computeSyncDedupKey,
isValidYMD,
Expand Down Expand Up @@ -192,7 +195,7 @@ describe('stampSectionsForWrite', () => {
expect(out.soul).toBeUndefined();
});

it('stamps daily entries without byteSize/updatedAt (per ADR shape)', () => {
it('server-stamps daily entries without byteSize', () => {
const out = stampSectionsForWrite({
daily: [{ date: '2026-04-14', content: 'today', visibility: 'pod' }],
}, FIXED);
Expand All @@ -201,7 +204,7 @@ describe('stampSectionsForWrite', () => {
expect(out.daily[0].content).toBe('today');
expect(out.daily[0].visibility).toBe('pod');
expect(out.daily[0].byteSize).toBeUndefined();
expect(out.daily[0].updatedAt).toBeUndefined();
expect(out.daily[0].updatedAt).toEqual(FIXED);
});

it('stamps relationships entries with updatedAt but no byteSize', () => {
Expand Down Expand Up @@ -229,6 +232,111 @@ describe('stampSectionsForWrite', () => {
});
});

describe('getLastAgentMemoryWrite', () => {
it('uses the newest normal agent-save section, not newer automated entries', () => {
const latest = getLastAgentMemoryWrite({
long_term: { content: 'durable state', updatedAt: new Date('2026-08-26T08:00:00Z') },
relationships: [{
otherInstanceId: 'architect',
notes: 'reviewer',
updatedAt: new Date('2026-08-26T09:00:00Z'),
}],
daily: [{
date: '2026-08-26',
content: 'shipped memory observability',
updatedAt: new Date('2026-08-26T10:00:00Z'),
}],
system_exchanges: {
entries: [],
visibility: 'private',
updatedAt: new Date('2026-08-26T11:00:00Z'),
},
cycles: {
entries: [],
visibility: 'private',
updatedAt: new Date('2026-08-26T12:00:00Z'),
},
});

expect(latest).toEqual({
section: 'daily',
updatedAt: new Date('2026-08-26T10:00:00Z'),
});
});

it('returns null when the envelope contains only automated or journal writes', () => {
expect(getLastAgentMemoryWrite({
system_exchanges: {
entries: [], visibility: 'private', updatedAt: new Date('2026-08-26T11:00:00Z'),
},
cycles: {
entries: [], visibility: 'private', updatedAt: new Date('2026-08-26T12:00:00Z'),
},
})).toBeNull();
});

// A tie is the common case: one /memory/sync stamps every section it carries
// with the same `now`. The previous fixture here omitted `soul`, so it read as
// pinning a preference for long_term when the code was only doing array order
// — `soul` sorts first and would have won. Both facts are now stated.
// The fixture has to DISCRIMINATE. `soul` and `long_term` are both durable and
// both sort ahead of every bookkeeping section, so any tie including them is
// won by array order alone and passes with the rule deleted — verified by
// mutation, which left an earlier version of this test green. `dedup_state`
// (index 2, bookkeeping) ahead of `shared` (index 3, durable) is the only
// shape where the two rules disagree.
it('gives a tie to a durable section over a bookkeeping one that sorts ahead of it', () => {
const updatedAt = new Date('2026-08-26T10:00:00Z');
expect(getLastAgentMemoryWrite({
dedup_state: { content: 'message ids', updatedAt },
shared: { content: 'durable note', updatedAt },
})).toEqual({ section: 'shared', updatedAt });
expect(classifyAgentWriteSection('shared')).toBe('durable');
});

it('still reports a bookkeeping section when nothing durable is that recent', () => {
const updatedAt = new Date('2026-08-26T10:00:00Z');
expect(getLastAgentMemoryWrite({
long_term: { content: 'older', updatedAt: new Date('2026-08-25T10:00:00Z') },
dedup_state: { content: 'message ids', updatedAt },
})).toEqual({ section: 'dedup_state', updatedAt });
});

it('breaks a durable-vs-durable tie by section order, which nothing may depend on', () => {
const updatedAt = new Date('2026-08-26T10:00:00Z');
expect(getLastAgentMemoryWrite({
soul: { content: 'who I am', updatedAt },
long_term: { content: 'durable decision', updatedAt },
})).toEqual({ section: 'soul', updatedAt });
});
});

describe('coarsenAgentMemoryWrite', () => {
// The profile route is mounted without auth, so it may report THAT an agent
// saved and when, never WHICH section. Same max(), two shapes.
const updatedAt = new Date('2026-08-26T10:00:00Z');

it.each(['long_term', 'soul', 'shared', 'daily', 'relationships'])(
'reports %s as durable',
(section) => {
expect(coarsenAgentMemoryWrite({ section, updatedAt })).toEqual({ kind: 'durable', updatedAt });
},
);

it.each(['dedup_state', 'runtime_meta'])('reports %s as bookkeeping', (section) => {
expect(coarsenAgentMemoryWrite({ section, updatedAt })).toEqual({ kind: 'bookkeeping', updatedAt });
});

it('never carries the section name through', () => {
const coarse = coarsenAgentMemoryWrite({ section: 'runtime_meta', updatedAt });
expect(Object.keys(coarse).sort()).toEqual(['kind', 'updatedAt']);
});

it('passes a null write straight through', () => {
expect(coarsenAgentMemoryWrite(null)).toBeNull();
});
});

describe('isValidYMD', () => {
it('accepts valid YYYY-MM-DD', () => {
expect(isValidYMD('2026-04-14')).toBe(true);
Expand Down
Loading
Loading