Skip to content
Merged
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
84 changes: 83 additions & 1 deletion backend/__tests__/service/native-memory-append.test.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,40 @@
const { setupMongoDb, closeMongoDb } = require('../utils/testUtils');
const { execFileSync } = require('child_process');
const path = require('path');
const { pathToFileURL } = require('url');

jest.mock('axios', () => ({ __esModule: true, default: { post: jest.fn() } }));
jest.mock('../../models/Pod', () => ({
findById: jest.fn(() => ({ lean: async () => ({ name: 'Memory handoff' }) })),
}));
jest.mock('../../models/User', () => ({
findOne: jest.fn(() => ({ select: () => ({ lean: async () => null }) })),
findById: jest.fn(() => ({ select: () => ({ lean: async () => ({ isBot: false }) }) })),
}));
jest.mock('../../models/pg/Message', () => ({ findByPodId: jest.fn().mockResolvedValue([]) }));
jest.mock('../../services/agentTypingService', () => ({
emitAgentTypingStart: jest.fn(), emitAgentTypingStop: jest.fn(),
}));
jest.mock('../../services/agentMessageService', () => ({
postMessage: jest.fn().mockResolvedValue({ success: true }),
}));
jest.mock('../../services/messageClaimService', () => ({
claim: jest.fn().mockResolvedValue({ claimed: true }),
release: jest.fn().mockResolvedValue({ released: true }),
}));
jest.mock('../../services/nativeOutsiderGrantPolicy', () => ({
podHasOutsiderWritableGrant: jest.fn().mockResolvedValue(false),
}));

const AgentMemory = require('../../models/AgentMemory');
const { appendLongTermMemory } = require('../../services/agentMemoryService');
jest.mock('../../services/agentComputerBroker', () => ({
agentComputerBroker: {},
agentComputerEnabled: false,
}));
const { dispatchTool } = require('../../services/nativeRuntimeService');
const { dispatchTool, runAgent } = require('../../services/nativeRuntimeService');
const { GivenAddresses } = require('../../services/webAddressProvenance');
const axios = require('axios').default;

jest.setTimeout(60000);

Expand All @@ -24,6 +51,61 @@ describe('native long-term memory appends', () => {
await AgentMemory.deleteMany({});
});

it('hands a hosted atomic write from real Mongo to the actual CLI memory reader', async () => {
const previous = {
LITELLM_BASE_URL: process.env.LITELLM_BASE_URL,
LITELLM_MASTER_KEY: process.env.LITELLM_MASTER_KEY,
HOSTED_TURNS_PER_DAY: process.env.HOSTED_TURNS_PER_DAY,
};
process.env.LITELLM_BASE_URL = 'http://litellm.test';
process.env.LITELLM_MASTER_KEY = 'test-only';
delete process.env.HOSTED_TURNS_PER_DAY;
try {
await AgentMemory.create({
agentName: 'hosted-handoff', instanceId: 'default', content: 'legacy seed',
});
axios.post.mockReset()
.mockResolvedValueOnce({ data: {
choices: [{ message: { role: 'assistant', content: null, tool_calls: [{
id: 'memory-write', type: 'function', function: {
name: 'commonly_write_memory', arguments: JSON.stringify({ content: 'hosted handoff marker' }),
},
}] } }],
} })
.mockResolvedValueOnce({ data: {
choices: [{ message: { role: 'assistant', content: 'Saved.' } }],
} });
const result = await runAgent({
podId: '507f1f77bcf86cd799439011', agentName: 'hosted-handoff', instanceId: 'default',
installedBy: '507f1f77bcf86cd799439099', displayName: 'Hosted handoff',
config: { runtime: { runtimeType: 'hosted' }, hosted: { engine: 'native', provisionedAt: new Date() } },
}, { type: 'chat.mention', payload: { content: 'Remember the handoff marker.' } });
expect(result).toEqual(expect.objectContaining({ status: 'succeeded' }));

const saved = await AgentMemory.findOne({ agentName: 'hosted-handoff', instanceId: 'default' }).lean();
expect(saved.sections.long_term.content).toMatch(/^legacy seed\n\n\[[^\]]+\] hosted handoff marker$/);
expect(saved.content).toBe(saved.sections.long_term.content);
expect(saved.sections.long_term.source).toMatchObject({ runtime: 'native', via: 'native-tool' });
// Run the CLI's ESM reader in Node, outside Jest's CommonJS transform.
// Its CAP client receives the document read from real Mongo above.
const bridgeUrl = pathToFileURL(path.resolve(__dirname, '../../../cli/src/lib/memory-bridge.js')).href;
const cliMemory = JSON.parse(execFileSync(process.execPath, ['--input-type=module', '-e', `
const { readLongTerm } = await import(process.argv[1]);
let body = '';
for await (const chunk of process.stdin) body += chunk;
const memory = await readLongTerm({ get: async () => JSON.parse(body) });
process.stdout.write(JSON.stringify(memory));
`, bridgeUrl], { input: JSON.stringify(saved), encoding: 'utf8' }));
expect(cliMemory).toBe(saved.sections.long_term.content);
expect(cliMemory).toContain('hosted handoff marker');
} finally {
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
});
}
});

it('serializes overlapping appends and keeps v1/v2 memory aligned', async () => {
await AgentMemory.create({
agentName: 'native-race',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ jest.mock('../../../models/Pod', () => ({

jest.mock('../../../models/AgentRun', () => ({ create: jest.fn(), countDocuments: jest.fn() }));

jest.mock('../../../models/AgentMemory', () => ({
findOne: jest.fn(),
findOneAndUpdate: jest.fn(),
}));

jest.mock('../../../services/agentMemoryService', () => ({
...jest.requireActual('../../../services/agentMemoryService'),
appendLongTermMemory: jest.fn().mockResolvedValue(true),
}));

let mockOwnerPro = false;
jest.mock('../../../models/User', () => ({
findOne: jest.fn(() => ({ select: () => ({ lean: () => Promise.resolve(null) }) })),
Expand Down Expand Up @@ -101,6 +111,8 @@ jest.mock('../../../models/Message', () => {

const axios = require('axios').default;
const AgentRun = require('../../../models/AgentRun');
const AgentMemory = require('../../../models/AgentMemory');
const { appendLongTermMemory } = require('../../../services/agentMemoryService');
const { runAgent } = require('../../../services/nativeRuntimeService');
const { HOSTED_NATIVE_TOOLS } = require('../../../services/hostedEngine');

Expand Down Expand Up @@ -158,6 +170,8 @@ describe('nativeRuntimeService — hosted installs on the native engine', () =>
mockSandboxRun.mockResolvedValue({ ok: true, stdout: '4\n', stderr: '' });
mockSandboxClose.mockResolvedValue(0);
AgentRun.countDocuments.mockResolvedValue(0);
AgentMemory.findOne.mockResolvedValue(null);
AgentMemory.findOneAndUpdate.mockResolvedValue({});
AgentRun.create.mockImplementation(async (doc) => {
savedRun = { _id: 'run-1', ...doc, save: jest.fn().mockResolvedValue(undefined) };
return savedRun;
Expand Down Expand Up @@ -417,6 +431,37 @@ describe('nativeRuntimeService — hosted installs on the native engine', () =>
expect(axios.post.mock.calls.length).toBeGreaterThanOrEqual(2);
});

test('a hosted memory write uses the atomic append with its own identity', async () => {
axios.post.mockResolvedValueOnce(toolCalls(call('c1', 'commonly_write_memory', { content: 'hosted note' })));

const result = await runAgent(hostedInstall(), mention);

expect(result.status).toBe('succeeded');
expect(appendLongTermMemory).toHaveBeenCalledWith({
agentName: 'maya-agent', instanceId: 'default', content: 'hosted note',
});
expect(AgentMemory.findOneAndUpdate).not.toHaveBeenCalled();
});

test('native memory reads prefer canonical long_term over the legacy copy', async () => {
AgentMemory.findOne.mockImplementationOnce(() => ({ lean: () => Promise.resolve({
content: 'stale legacy copy',
sections: { long_term: { content: 'current memory' } },
}) }));
axios.post
.mockResolvedValueOnce(toolCalls(call('c1', 'commonly_read_memory', {})))
.mockResolvedValueOnce(toolCalls(call('c2', 'commonly_write_memory', { content: 'new note' })));

const result = await runAgent(hostedInstall(), mention);

expect(result.status).toBe('succeeded');
const secondRequest = axios.post.mock.calls[1][1];
expect(secondRequest.messages).toEqual(expect.arrayContaining([
expect.objectContaining({ role: 'tool', content: expect.stringContaining('current memory') }),
]));
expect(appendLongTermMemory).toHaveBeenCalledWith(expect.objectContaining({ content: 'new note' }));
});

test('in a pod holding an outsider-writable grant, a hosted turn is narrowed like any native one', async () => {
const { podHasOutsiderWritableGrant } = require('../../../services/nativeOutsiderGrantPolicy');
podHasOutsiderWritableGrant.mockResolvedValue(true);
Expand Down
2 changes: 1 addition & 1 deletion backend/services/nativeRuntimeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -756,7 +756,7 @@ export async function dispatchTool(
agentName: ctx.agentName,
instanceId: ctx.instanceId,
}).lean();
return { content: { content: (doc?.content as string) || '' } };
return { content: { content: doc?.sections?.long_term?.content ?? doc?.content ?? '' } };
}

case 'commonly_write_memory': {
Expand Down
12 changes: 12 additions & 0 deletions cli/__tests__/memory-bridge.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,18 @@ describe('readLongTerm', () => {
expect(await readLongTerm(client)).toBe('');
});

test('reads legacy content when the long_term section is missing', async () => {
const client = makeClient({ memoryPayload: { content: '[2026-10-24] Hosted memory' } });
expect(await readLongTerm(client)).toBe('[2026-10-24] Hosted memory');
});

test('an explicit empty long_term section takes precedence over legacy content', async () => {
const client = makeClient({
memoryPayload: { content: 'stale legacy memory', sections: { long_term: { content: '' } } },
});
expect(await readLongTerm(client)).toBe('');
});

test('returns empty string on 404 and does NOT surface via onError (fresh agent)', async () => {
const err404 = Object.assign(new Error('not found'), { status: 404 });
const client = {
Expand Down
4 changes: 2 additions & 2 deletions cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@commonlyai/cli",
"version": "0.1.147",
"version": "0.1.152",
"license": "Apache-2.0",
"description": "The Commonly CLI — connect agents, manage pods, iterate fast",
"type": "module",
Expand Down
9 changes: 5 additions & 4 deletions cli/src/lib/memory-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
* Two thin CAP shims the run loop calls around every spawn cycle:
*
* readLongTerm(client) — GET /api/agents/runtime/memory,
* returns sections.long_term.content or ''
* returns sections.long_term.content,
* falling back to legacy content
* syncBack(client, { summary }) — POST /api/agents/runtime/memory/sync
* with mode:'patch', sourceRuntime:'local-cli'.
* No-op when summary is falsy.
Expand Down Expand Up @@ -33,8 +34,8 @@ export const SOURCE_RUNTIME = 'local-cli';
* cannot adopt a habit whose surface it has no evidence exists, so the empty
* case now says so.
*
* It names `long_term` specifically because that is the ONLY section read back
* (`readLongTerm` returns `sections.long_term.content`). A write to `daily` —
* It names `long_term` specifically because that is the only v2 section read
* back. Legacy `content` is a fallback for records without that section. A write to `daily` —
* the section whose name most invites exactly this use — succeeds, returns 200,
* and is never seen again; the write and the silence are indistinguishable from
* a correct round trip. Naming the section is the whole point of the cue.
Expand Down Expand Up @@ -92,7 +93,7 @@ export const buildMemoryPreamble = (prompt, memoryLongTerm, { freshSession = fal
export const readLongTerm = async (client, { onError } = {}) => {
try {
const body = await client.get('/api/agents/runtime/memory');
return body?.sections?.long_term?.content || '';
return body?.sections?.long_term?.content ?? body?.content ?? '';
} catch (err) {
// A 404 is the ONLY error that means what '' means: a fresh agent with no
// memory row yet. The kernel upserts on first write, so this is genuine
Expand Down
Loading