diff --git a/backend/__tests__/service/native-memory-append.test.js b/backend/__tests__/service/native-memory-append.test.js index 8a55d49b39..5f8a27b51b 100644 --- a/backend/__tests__/service/native-memory-append.test.js +++ b/backend/__tests__/service/native-memory-append.test.js @@ -1,4 +1,30 @@ 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'); @@ -6,8 +32,9 @@ 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); @@ -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', diff --git a/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js b/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js index 74356df161..6dd62afcae 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js @@ -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) }) })), @@ -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'); @@ -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; @@ -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); diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index 9ab792cbd4..099c043c0f 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -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': { diff --git a/cli/__tests__/memory-bridge.test.mjs b/cli/__tests__/memory-bridge.test.mjs index aa0f4d33d3..579181cbfb 100644 --- a/cli/__tests__/memory-bridge.test.mjs +++ b/cli/__tests__/memory-bridge.test.mjs @@ -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 = { diff --git a/cli/package-lock.json b/cli/package-lock.json index 5e45570af7..0f0d3a089a 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@commonlyai/cli", - "version": "0.1.147", + "version": "0.1.152", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@commonlyai/cli", - "version": "0.1.147", + "version": "0.1.152", "license": "Apache-2.0", "dependencies": { "commander": "^12.0.0" diff --git a/cli/package.json b/cli/package.json index b4f3e410d9..777a407bf5 100644 --- a/cli/package.json +++ b/cli/package.json @@ -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", diff --git a/cli/src/lib/memory-bridge.js b/cli/src/lib/memory-bridge.js index 0de61e9404..813dc675c7 100644 --- a/cli/src/lib/memory-bridge.js +++ b/cli/src/lib/memory-bridge.js @@ -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. @@ -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. @@ -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