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
27 changes: 27 additions & 0 deletions backend/__tests__/service/agent-memory-editor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,31 @@ describe('owner memory editor', () => {
expect(memory.sections.long_term.content).toBe('Old note');
expect(memory.content).toBe('Old note');
});

it('index sections carry the section update time and no per-note time', async () => {
await AgentMemory.updateOne({ agentName: 'editor-agent', instanceId: 'machine-1' }, { $set: {
'sections.long_term.content': '## Rules\nNever merge without a GATE line.\n## Overview\nReviews PRs.',
'sections.daily': [{ date: '2026-10-28', content: '#401 approved' }],
} });
const index = await request(app).get('/api/agent-memory/editor-agent/machine-1')
.set('Authorization', `Bearer ${token(owner)}`);
expect(index.status).toBe(200);
const longTerm = index.body.sections.find((s) => s.key === 'long_term');
const daily = index.body.sections.find((s) => s.key === 'daily');
expect(longTerm.updatedAt).toBe(new Date('2026-01-01').toISOString());
expect(daily.updatedAt).toBeNull();
for (const note of [...longTerm.notes, ...daily.notes]) {
expect(Object.keys(note).sort()).toEqual(['header', 'snippet']);
}
});

it('index returns a null section update time when the section has none', async () => {
await AgentMemory.updateOne({ agentName: 'editor-agent', instanceId: 'machine-1' }, {
$set: { 'sections.long_term.content': '## Rules\nNever merge.' },
$unset: { 'sections.long_term.updatedAt': '' },
});
const index = await request(app).get('/api/agent-memory/editor-agent/machine-1')
.set('Authorization', `Bearer ${token(owner)}`);
expect(index.body.sections.find((s) => s.key === 'long_term').updatedAt).toBeNull();
});
});
15 changes: 11 additions & 4 deletions backend/routes/agentMemoryView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ const snip = (s: unknown): string => {
const t = String(s ?? '').replace(/\s+/g, ' ').trim();
return t.length > SNIPPET ? `${t.slice(0, SNIPPET)}…` : t;
};
// Section last-write time as ISO, or null when the stored value is absent or unparseable.
const isoOrNull = (v: unknown): string | null => {
if (v == null || v === '') return null;
const d = new Date(v as string | number | Date);
return Number.isNaN(d.getTime()) ? null : d.toISOString();
};

// Sections that are internal housekeeping, never shown as "memory". Shared with
// the service so the public profile's durable/bookkeeping split and this
Expand Down Expand Up @@ -278,7 +284,7 @@ router.get('/:agentName/:instanceId?', auth, async (req: AuthReq, res: Res) => {
.select('sections')
.lean();

const sections: Array<{ key: string; label: string; kind: string; notes: unknown[] }> = [];
const sections: Array<{ key: string; label: string; kind: string; updatedAt: string | null; notes: unknown[] }> = [];
let totalEntries = 0;
const raw = ((record as Record<string, unknown>)?.sections || {}) as Record<string, unknown>;

Expand All @@ -291,7 +297,7 @@ router.get('/:agentName/:instanceId?', auth, async (req: AuthReq, res: Res) => {
.reverse()
.map((d: Record<string, unknown>) => ({ header: String(d.date || ''), snippet: snip(d.content) }))
.filter((n) => n.header || n.snippet);
if (notes.length) { sections.push({ key, label: 'Daily journal', kind: 'log', notes }); totalEntries += notes.length; }
if (notes.length) { sections.push({ key, label: 'Daily journal', kind: 'log', updatedAt: null, notes }); totalEntries += notes.length; }
} else if (key === 'cycles' && value && typeof value === 'object') {
const entries = Array.isArray((value as Record<string, unknown>).entries)
? ((value as Record<string, unknown>).entries as Array<Record<string, unknown>>)
Expand All @@ -300,12 +306,13 @@ router.get('/:agentName/:instanceId?', auth, async (req: AuthReq, res: Res) => {
header: e.ts ? String(e.ts).slice(0, 10) : 'cycle',
snippet: snip(e.content),
}));
if (notes.length) { sections.push({ key, label: 'Heartbeat cycles', kind: 'log', notes }); totalEntries += notes.length; }
if (notes.length) { sections.push({ key, label: 'Heartbeat cycles', kind: 'log', updatedAt: null, notes }); totalEntries += notes.length; }
} else if (value && typeof value === 'object' && 'content' in (value as Record<string, unknown>)) {
// long_term (and any future doc-shaped section) → index its headers.
const notes = indexMarkdown(String((value as Record<string, unknown>).content || ''));
const label = key === 'long_term' ? 'Long-term memory' : key.replace(/_/g, ' ');
if (notes.length) { sections.push({ key, label, kind: 'doc', notes }); totalEntries += notes.length; }
const updatedAt = isoOrNull((value as Record<string, unknown>).updatedAt);
if (notes.length) { sections.push({ key, label, kind: 'doc', updatedAt, notes }); totalEntries += notes.length; }
}
}

Expand Down
2 changes: 2 additions & 0 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1955,6 +1955,7 @@
"viewTag": "{{role}} view · private",
"indexSummary_one": "{{notes}} notes across {{count}} section{{updated}}. Visible to you; hidden from the public profile.",
"indexSummary_other": "{{notes}} notes across {{count}} sections{{updated}}. Visible to you; hidden from the public profile.",
"sectionUpdated": "Section updated {{when}}",
"lastSavedClause": " · last saved to {{section}} {{time}}",
"more": "+{{count}} more",
"entriesRemembered": "entries remembered",
Expand Down Expand Up @@ -2772,6 +2773,7 @@
"tabs": "Agent tabs",
"profile": "Profile",
"settings": "Settings",
"memory": "Memory",
"key": "Key",
"regenerate": "Regenerate key",
"wrapperReconnect": "Replace this computer's key from Control.",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1946,6 +1946,7 @@
"viewTag": "{{role}}视图 · 私有",
"indexSummary_one": "{{notes}}条笔记,分布在{{count}}个板块{{updated}}。仅你可见;对公开资料页隐藏。",
"indexSummary_other": "{{notes}}条笔记,分布在{{count}}个板块{{updated}}。仅你可见;对公开资料页隐藏。",
"sectionUpdated": "板块更新于{{when}}",
"lastSavedClause": " · 最后保存至 {{section}},{{time}}",
"more": "还有{{count}}条",
"entriesRemembered": "条已记住的条目",
Expand Down Expand Up @@ -2775,6 +2776,7 @@
"tabs": "智能体标签页",
"profile": "资料",
"settings": "设置",
"memory": "记忆",
"key": "密钥",
"regenerate": "重新生成密钥",
"wrapperReconnect": "请在「控制」中替换这台电脑的密钥。",
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/v2/V2App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const V2CommunityRedirect = React.lazy(() => import('./components/V2CommunityRed
const V2SettingsPage = React.lazy(() => import('./components/V2SettingsPage'));
const V2AgentProfile = React.lazy(() => import('./agents/V2AgentProfile'));
const V2AgentSettingsPage = React.lazy(() => import('./agents/V2AgentSettingsPage'));
const V2AgentMemory = React.lazy(() => import('./agents/V2AgentMemory'));
const UserProfile = React.lazy(() => import('../components/UserProfile'));
const V2AgentBYO = React.lazy(() => import('./components/V2AgentBYO'));
const V2ConnectPage = React.lazy(() => import('./components/V2ConnectPage'));
Expand Down Expand Up @@ -293,6 +294,10 @@ const V2App: React.FC = () => {
path="agents/:agentName/:instanceId"
element={feature('Agent settings', <V2AgentSettingsPage />, false, false)}
/>
<Route
path="agents/:agentName/:instanceId/memory"
element={feature('Agent memory', <V2AgentMemory />, false, false)}
/>
<Route
path="connect"
element={<V2FeaturePage title="Connect" hint="MCP uses an agent token created during setup."><V2ConnectPage /></V2FeaturePage>}
Expand Down
127 changes: 127 additions & 0 deletions frontend/src/v2/__tests__/V2AgentMemory.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// @ts-nocheck
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import V2AgentMemory, { groupSections } from '../agents/V2AgentMemory';

jest.mock('axios', () => {
const mock = {
get: jest.fn(), post: jest.fn(), patch: jest.fn(), delete: jest.fn(),
defaults: { baseURL: '', headers: { common: {} } },
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
};
return { __esModule: true, default: mock, ...mock };
});
const axios = jest.requireMock('axios').default;

const section = (key, notes = 1) => ({
key, label: key, kind: 'x', notes: Array.from({ length: notes }, (_, i) => ({ header: `${key} ${i}`, snippet: '' })),
});
const named = (key, headers) => ({
key, label: key, kind: 'doc', notes: headers.map((h) => ({ header: h, snippet: '' })),
});
const headersOf = (groups, id) => groups.find((g) => g.id === id).sections.flatMap((s) => s.notes.map((n) => n.header));

describe('groupSections', () => {
it('puts soul first in Rules and journal sections in Journal', () => {
const groups = groupSections([section('daily'), section('soul'), section('cycles')]);
expect(headersOf(groups, 'rules')).toEqual(['soul 0']);
expect(headersOf(groups, 'journal')).toEqual(['daily 0', 'cycles 0']);
});

it('splits long_term by heading: rule-named headings go to Rules, the rest to How I work', () => {
const groups = groupSections([named('long_term', ['Overview', 'Rules', 'Never force-push', 'Host notes', 'Don\'t run find /'])]);
expect(headersOf(groups, 'rules')).toEqual(['Rules', 'Never force-push', 'Don\'t run find /']);
expect(headersOf(groups, 'how')).toEqual(['Overview', 'Host notes']);
});

it('shows a single untitled long_term note under How I work with no empty Rules group', () => {
const groups = groupSections([named('long_term', ['Overview'])]);
expect(groups.find((g) => g.id === 'rules').sections).toEqual([]);
expect(headersOf(groups, 'how')).toEqual(['Overview']);
});

it('falls back to How I work for an unknown section so nothing is hidden', () => {
const groups = groupSections([section('brand_new_key')]);
expect(headersOf(groups, 'how')).toEqual(['brand_new_key 0']);
});

it('carries the section update time onto its group sections and splits it with long_term', () => {
const at = '2026-10-30T12:00:00.000Z';
const groups = groupSections([{ ...named('long_term', ['Rules', 'Overview']), updatedAt: at }]);
expect(groups.find((g) => g.id === 'rules').sections[0].updatedAt).toBe(at);
expect(groups.find((g) => g.id === 'how').sections[0].updatedAt).toBe(at);
});
});

describe('V2AgentMemory', () => {
it('renders the three groups from the owner index', async () => {
axios.get.mockResolvedValue({ data: {
viewerRole: 'owner', totalEntries: 3,
sections: [named('long_term', ['Rules', 'Host notes']), section('daily', 1)],
} });
render(
<MemoryRouter initialEntries={['/v2/agents/maya-claude/default/memory']}>
<Routes>
<Route path="/v2/agents/:agentName/:instanceId/memory" element={<V2AgentMemory />} />
</Routes>
</MemoryRouter>,
);
await waitFor(() => expect(screen.getByRole('heading', { name: /Rules/ })).toBeInTheDocument());
expect(screen.getByRole('heading', { name: /How I work/ })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /Journal/ })).toBeInTheDocument();
expect(axios.get).toHaveBeenCalledWith('/api/agent-memory/maya-claude/default');
});

it('filters notes by the search box and keeps only groups that still match', async () => {
axios.get.mockResolvedValue({ data: {
viewerRole: 'owner', totalEntries: 3,
sections: [named('long_term', ['Rules', 'Host notes']), section('daily', 1)],
} });
render(
<MemoryRouter initialEntries={['/v2/agents/maya-claude/default/memory']}>
<Routes>
<Route path="/v2/agents/:agentName/:instanceId/memory" element={<V2AgentMemory />} />
</Routes>
</MemoryRouter>,
);
await waitFor(() => expect(screen.getByRole('heading', { name: /Journal/ })).toBeInTheDocument());
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'host' } });
expect(screen.getByText('Host notes')).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: /Journal/ })).toBeNull();
});

it('shows the section update time once per group and never on a note', async () => {
axios.get.mockResolvedValue({ data: {
viewerRole: 'owner', totalEntries: 3,
sections: [
{ ...named('long_term', ['Rules', 'Host notes']), updatedAt: '2026-10-30T12:00:00.000Z' },
section('daily', 1),
],
} });
render(
<MemoryRouter initialEntries={['/v2/agents/maya-claude/default/memory']}>
<Routes>
<Route path="/v2/agents/:agentName/:instanceId/memory" element={<V2AgentMemory />} />
</Routes>
</MemoryRouter>,
);
await waitFor(() => expect(screen.getByRole('heading', { name: /Journal/ })).toBeInTheDocument());
// Rules and How I work each carry the long_term time; Journal has none.
expect(document.querySelectorAll('.v2-agent-memory__gmeta')).toHaveLength(2);
expect(document.querySelectorAll('.v2-agent-memory__mnote .v2-agent-memory__gmeta')).toHaveLength(0);
});

it('shows a denied notice instead of the index when the viewer is not owner or admin', async () => {
axios.get.mockRejectedValue(new Error('403'));
render(
<MemoryRouter initialEntries={['/v2/agents/maya-claude/default/memory']}>
<Routes>
<Route path="/v2/agents/:agentName/:instanceId/memory" element={<V2AgentMemory />} />
</Routes>
</MemoryRouter>,
);
await waitFor(() => expect(screen.getByRole('status')).toBeInTheDocument());
expect(screen.queryByRole('heading', { name: /Rules/ })).toBeNull();
});
});
19 changes: 19 additions & 0 deletions frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5058,6 +5058,25 @@ describe('agent Identity editor', () => {
});
});

describe('agent Memory tab (TASK-690 slice 1 and 2)', () => {
const css = read('../agents/v2-agent-settings.css');
const desktop = blockContaining(css, '@media (min-width: 768px)', '.v2-agent-memory');
const phone = blockContaining(css, '@media (max-width: 767px)', '.v2-agent-memory__nav ul');
test('desktop keeps the group nav beside the notes at 200px', () => {
expect(ruleBody(css, '.v2-agent-memory')).toContain('display: grid');
expect(ruleBody(css, '.v2-agent-memory', { within: desktop })).toContain('grid-template-columns: 200px minmax(0, 1fr)');
});
test('phone hides the group nav and makes the groups collapsible', () => {
expect(ruleBody(css, '.v2-agent-memory__nav ul', { within: phone })).toContain('display: none');
expect(ruleBody(css, '.v2-agent-memory__gtitle', { within: phone })).toContain('cursor: pointer');
});
test('desktop group titles do not take pointer input, and the chevron is phone-only', () => {
const titles = blockContaining(css, '@media (min-width: 768px)', '.v2-agent-memory__gtitle');
expect(ruleBody(css, '.v2-agent-memory__gtitle', { within: titles })).toContain('pointer-events: none');
expect(ruleBody(css, '.v2-agent-memory__chev')).toContain('display: none');
});
});

describe('task priority and lease signals (TASK-443)', () => {
const css = read('../v2.css');
test('priority remains a monochrome text badge', () => {
Expand Down
Loading
Loading