import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; import { ServiceCollection } from '#/_base/di/serviceCollection'; import { TestInstantiationService } from '#/_base/di/test'; import { ILogService } from '#/_base/log/log'; import { ISessionIndexMirror } from '#/app/sessionIndex/sessionIndex'; import { ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { SessionMetadata } from '#/session/sessionMetadata/sessionMetadataService'; import { ISessionStateService } from '#/session/state/sessionState'; import { SessionStateService } from '#/session/state/sessionStateService'; import { JsonAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { stubSessionIndexMirror } from '../../app/sessionIndex/stubs'; import { stubLog } from '../../_base/log/stubs'; const META_SCOPE = 'sessions/wd_test/s1/session-meta'; function createFreshMetadata(ix: TestInstantiationService): SessionMetadata { return ix .createChild(new ServiceCollection([ISessionStateService, new SessionStateService()])) .createInstance(SessionMetadata); } function makeContext(): ISessionContext { return makeSessionContext({ sessionId: 's1', workspaceId: 'wd_test', sessionDir: '/tmp/sessions/wd_test/s1', sessionScope: 'sessions/wd_test/s1', metaScope: META_SCOPE, cwd: '/tmp/sessions/wd_test/s1', }); } describe('SessionMetadata', () => { let disposables: DisposableStore; let ix: TestInstantiationService; let mirror: ReturnType; beforeEach(() => { disposables = new DisposableStore(); ix = disposables.add(new TestInstantiationService()); mirror = stubSessionIndexMirror(); ix.stub(ILogService, stubLog()); ix.stub(ISessionContext, makeContext()); ix.stub(ISessionIndexMirror, mirror); ix.set(ISessionStateService, new SyncDescriptor(SessionStateService)); ix.set(IFileSystemStorageService, new SyncDescriptor(InMemoryStorageService)); ix.set(IAtomicDocumentStore, new SyncDescriptor(JsonAtomicDocumentStore)); ix.set(ISessionMetadata, new SyncDescriptor(SessionMetadata)); }); afterEach(() => { disposables.dispose(); vi.restoreAllMocks(); }); it('creates an initial document on first read', async () => { const meta = ix.get(ISessionMetadata); expect(await meta.read()).toMatchObject({ id: 's1', archived: false, agents: {}, custom: {}, }); expect((await meta.read()).createdAt).toBeGreaterThan(0); }); it('update merges fields and bumps updatedAt', async () => { const meta = ix.get(ISessionMetadata); const before = (await meta.read()).updatedAt; await new Promise((r) => setTimeout(r, 2)); await meta.update({ title: 'hello' }); const next = await meta.read(); expect(next.title).toBe('hello'); expect(next.updatedAt).toBeGreaterThanOrEqual(before); }); it('update with touchUpdatedAt:false keeps the previous updatedAt', async () => { const meta = ix.get(ISessionMetadata); const before = (await meta.read()).updatedAt; await meta.update({ title: 'quiet' }, { touchUpdatedAt: false }); const next = await meta.read(); expect(next.title).toBe('quiet'); expect(next.updatedAt).toBe(before); }); it('setTitle / setArchived write through', async () => { const meta = ix.get(ISessionMetadata); await meta.setTitle('t'); await meta.setArchived(true); expect(await meta.read()).toMatchObject({ title: 't', titleKind: 'custom', archived: true }); }); it('sets a generated title while the metadata remains uncustomized', async () => { const meta = ix.get(ISessionMetadata); await expect(meta.setGeneratedTitleIfUncustomized('generated title')).resolves.toBe(true); await expect(meta.read()).resolves.toMatchObject({ title: 'generated title', titleKind: 'generated', }); }); it('setTitle keeps updatedAt (rename must not reorder listings)', async () => { const meta = ix.get(ISessionMetadata); const before = (await meta.read()).updatedAt; await new Promise((r) => setTimeout(r, 2)); await meta.setTitle('renamed'); const next = await meta.read(); expect(next.title).toBe('renamed'); expect(next.updatedAt).toBe(before); }); it('setArchived records archivedAt without touching updatedAt; restore clears it', async () => { const meta = ix.get(ISessionMetadata); const before = (await meta.read()).updatedAt; await new Promise((r) => setTimeout(r, 2)); await meta.setArchived(true); const archived = await meta.read(); expect(archived.archived).toBe(true); expect(archived.archivedAt).toBeGreaterThan(before); expect(archived.updatedAt).toBe(before); await meta.setArchived(false); const restored = await meta.read(); expect(restored.archived).toBe(false); expect(restored.archivedAt).toBeUndefined(); expect(restored.updatedAt).toBe(before); }); it('registerAgent never bumps updatedAt — the agents map is structural', async () => { const meta = ix.get(ISessionMetadata); const before = (await meta.read()).updatedAt; await new Promise((r) => setTimeout(r, 2)); await meta.registerAgent('main', { homedir: '/tmp/h', type: 'main' }); const next = await meta.read(); expect(next.agents?.['main']?.homedir).toBe('/tmp/h'); expect(next.updatedAt).toBe(before); }); it('an explicit patch.updatedAt always wins (fork inherits the source recency)', async () => { const meta = ix.get(ISessionMetadata); await meta.update({ title: 'fork', updatedAt: 1234 }); const next = await meta.read(); expect(next.title).toBe('fork'); expect(next.updatedAt).toBe(1234); }); it('reads a loaded document without the archived field as not-archived', async () => { const store = ix.get(IAtomicDocumentStore); await store.set(META_SCOPE, 'state.json', { id: 's1', version: 2, createdAt: 1700000000000, updatedAt: 1700000000000, agents: {}, custom: {}, }); const meta = ix.get(ISessionMetadata); expect(await meta.read()).toMatchObject({ id: 's1', archived: false }); }); it('mirrors a boolean archived to the read model even when the loaded document lacks the field', async () => { const store = ix.get(IAtomicDocumentStore); await store.set(META_SCOPE, 'state.json', { id: 's1', version: 2, createdAt: 1700000000000, updatedAt: 1700000000000, agents: {}, custom: {}, }); const meta = ix.get(ISessionMetadata); await meta.ready; expect(mirror.recorded).toEqual([]); await meta.update({ title: 'x' }); expect(mirror.recorded).toHaveLength(1); expect(mirror.recorded[0]).toMatchObject({ id: 's1', archived: false }); }); it('persists the authoritative document before recording to the mirror', async () => { const store = ix.get(IAtomicDocumentStore); const persistedAtRecord: Promise | undefined>[] = []; const baseRecord = mirror.record; mirror.record = (summary) => { persistedAtRecord.push(store.get>(META_SCOPE, 'state.json')); baseRecord(summary); }; const meta = ix.get(ISessionMetadata); await meta.ready; await meta.update({ title: 'durable-first' }); expect(persistedAtRecord).toHaveLength(2); const [atCreate, atUpdate] = await Promise.all(persistedAtRecord); expect(atCreate).toMatchObject({ id: 's1', archived: false }); expect(atUpdate).toMatchObject({ title: 'durable-first' }); }); it('a mirror failure degrades the read model but never fails the metadata mutation', async () => { mirror.record = () => { throw new Error('mirror down'); }; const meta = ix.get(ISessionMetadata); await meta.ready; await meta.update({ title: 'still fine' }); expect(await meta.read()).toMatchObject({ title: 'still fine' }); const fresh = createFreshMetadata(ix); expect(await fresh.read()).toMatchObject({ title: 'still fine' }); }); it('persists across instances', async () => { const meta = ix.get(ISessionMetadata); await meta.update({ title: 'persisted' }); const fresh = createFreshMetadata(ix); expect(await fresh.read()).toMatchObject({ id: 's1', title: 'persisted' }); }); it('backfills and persists missing agents/custom maps on a pre-fix document', async () => { const store = ix.get(IAtomicDocumentStore); await store.set(META_SCOPE, 'state.json', { id: 's1', version: 2, createdAt: 1700000000000, updatedAt: 1700000000000, archived: false, }); const meta = ix.get(ISessionMetadata); expect(await meta.read()).toMatchObject({ agents: {}, custom: {} }); const fresh = createFreshMetadata(ix); const healed = await fresh.read(); expect(healed.agents).toEqual({}); expect(healed.custom).toEqual({}); expect(healed.updatedAt).toBe(1700000000000); }); it('normalizes the legacy customTitle field before callers read metadata', async () => { const store = ix.get(IAtomicDocumentStore); await store.set(META_SCOPE, 'state.json', { id: 's1', version: 2, createdAt: 1700000000000, updatedAt: 1700000000000, archived: false, customTitle: 'legacy title', }); const meta = ix.get(ISessionMetadata); await expect(meta.read()).resolves.toMatchObject({ title: 'legacy title', titleKind: 'custom', }); const fresh = createFreshMetadata(ix); await expect(fresh.read()).resolves.toMatchObject({ title: 'legacy title', titleKind: 'custom', }); }); it('trusts modern custom title state over a stale legacy customTitle', async () => { const store = ix.get(IAtomicDocumentStore); await store.set(META_SCOPE, 'state.json', { id: 's1', version: 2, createdAt: 1700000000000, updatedAt: 1700000000000, archived: false, title: 'renamed title', isCustomTitle: true, customTitle: 'legacy custom title', }); const meta = ix.get(ISessionMetadata); await expect(meta.read()).resolves.toMatchObject({ title: 'renamed title', titleKind: 'custom', }); await meta.update({ archived: true }); const fresh = createFreshMetadata(ix); await expect(fresh.read()).resolves.toMatchObject({ title: 'renamed title', titleKind: 'custom', archived: true, }); const persisted = await store.get>(META_SCOPE, 'state.json'); expect(persisted).toMatchObject({ isCustomTitle: true }); expect(persisted).not.toHaveProperty('customTitle'); }); it('migrates a legacy non-custom title to replaceable title state', async () => { const store = ix.get(IAtomicDocumentStore); await store.set(META_SCOPE, 'state.json', { id: 's1', version: 2, createdAt: 1700000000000, updatedAt: 1700000000000, archived: false, title: 'prompt title', isCustomTitle: false, agents: {}, custom: {}, }); const meta = ix.get(ISessionMetadata); await expect(meta.read()).resolves.toMatchObject({ title: 'prompt title', titleKind: 'replaceable', }); const persisted = await store.get>(META_SCOPE, 'state.json'); expect(persisted).toMatchObject({ title: 'prompt title', titleKind: 'replaceable', isCustomTitle: false, }); }); it('honors a legacy writer custom marker over the stale titleKind it left behind', async () => { const store = ix.get(IAtomicDocumentStore); await store.set(META_SCOPE, 'state.json', { id: 's1', version: 2, createdAt: 1700000000000, updatedAt: 1700000000000, archived: false, title: '用户手工标题', titleKind: 'replaceable', isCustomTitle: true, agents: {}, custom: {}, }); const meta = ix.get(ISessionMetadata); await expect(meta.read()).resolves.toMatchObject({ title: '用户手工标题', titleKind: 'custom', }); const persisted = await store.get>(META_SCOPE, 'state.json'); expect(persisted).toMatchObject({ titleKind: 'custom', isCustomTitle: true }); const fresh = createFreshMetadata(ix); await expect(fresh.read()).resolves.toMatchObject({ title: '用户手工标题', titleKind: 'custom', }); await expect(fresh.setGeneratedTitleIfUncustomized('generated title')).resolves.toBe(false); }); it('double-writes the derived isCustomTitle marker for v1 readers', async () => { const store = ix.get(IAtomicDocumentStore); const meta = ix.get(ISessionMetadata); await meta.setGeneratedTitleIfUncustomized('generated title'); await expect(store.get>(META_SCOPE, 'state.json')).resolves.toMatchObject( { titleKind: 'generated', isCustomTitle: false }, ); await meta.setTitle('user title'); await expect(store.get>(META_SCOPE, 'state.json')).resolves.toMatchObject( { titleKind: 'custom', isCustomTitle: true }, ); }); it('does not downgrade a modern titleKind on a legacy false marker', async () => { const store = ix.get(IAtomicDocumentStore); await store.set(META_SCOPE, 'state.json', { id: 's1', version: 2, createdAt: 1700000000000, updatedAt: 1700000000000, archived: false, title: 'generated title', titleKind: 'generated', isCustomTitle: false, agents: {}, custom: {}, }); const meta = ix.get(ISessionMetadata); await expect(meta.read()).resolves.toMatchObject({ title: 'generated title', titleKind: 'generated', }); }); it.each([ [{ isCustomTitle: true, titleKind: 'generated' as const }, 'custom'], [{ isCustomTitle: true, titleKind: 'replaceable' as const }, 'custom'], [{ isCustomTitle: true }, 'custom'], [{ isCustomTitle: false, titleKind: 'custom' as const }, 'custom'], [{ isCustomTitle: false, titleKind: 'generated' as const }, 'generated'], [{ isCustomTitle: false }, 'replaceable'], [{ titleKind: 'generated' as const }, 'generated'], [{ customTitle: 'legacy title' }, 'custom'], [{}, 'replaceable'], ])('normalizes title state %j to titleKind %s', async (fields, expectedKind) => { const store = ix.get(IAtomicDocumentStore); const title = 'customTitle' in fields ? undefined : 'some title'; await store.set(META_SCOPE, 'state.json', { id: 's1', version: 2, createdAt: 1700000000000, updatedAt: 1700000000000, archived: false, ...(title === undefined ? {} : { title }), ...fields, agents: {}, custom: {}, }); const meta = ix.get(ISessionMetadata); expect((await meta.read()).titleKind).toBe(expectedKind); }); it('migrates the title state once, not on every load', async () => { const store = ix.get(IAtomicDocumentStore); await store.set(META_SCOPE, 'state.json', { id: 's1', version: 2, createdAt: 1700000000000, updatedAt: 1700000000000, archived: false, title: '用户手工标题', titleKind: 'replaceable', isCustomTitle: true, agents: {}, custom: {}, }); const first = ix.get(ISessionMetadata); await first.ready; const setSpy = vi.spyOn(store, 'set'); const fresh = createFreshMetadata(ix); await fresh.ready; expect(setSpy).not.toHaveBeenCalled(); expect((await fresh.read()).titleKind).toBe('custom'); }); it('keeps a queued custom title when a generated title is enqueued afterward', async () => { const meta = ix.get(ISessionMetadata); await meta.ready; const store = ix.get(IAtomicDocumentStore); const set = store.set.bind(store); let releaseWrite: (() => void) | undefined; let markWriteStarted: (() => void) | undefined; const writeStarted = new Promise((resolve) => { markWriteStarted = resolve; }); const writeReleased = new Promise((resolve) => { releaseWrite = resolve; }); let shouldBlock = true; vi.spyOn(store, 'set').mockImplementation(async (scope, key, value) => { if (shouldBlock) { shouldBlock = false; markWriteStarted?.(); await writeReleased; } await set(scope, key, value); }); const priorWrite = meta.update({ lastPrompt: 'hello' }); await writeStarted; const rename = meta.setTitle('user title'); const generated = meta.setGeneratedTitleIfUncustomized('generated title'); releaseWrite?.(); await priorWrite; await rename; await expect(generated).resolves.toBe(false); await expect(meta.read()).resolves.toMatchObject({ title: 'user title', titleKind: 'custom', }); }); it('leaves existing agents/custom maps untouched', async () => { const store = ix.get(IAtomicDocumentStore); await store.set(META_SCOPE, 'state.json', { id: 's1', version: 2, createdAt: 1700000000000, updatedAt: 1700000000000, archived: false, agents: { main: { homedir: '/tmp/sessions/wd_test/s1/agents/main', type: 'main' } }, custom: { cwd: '/tmp/work' }, }); const meta = ix.get(ISessionMetadata); expect(await meta.read()).toMatchObject({ agents: { main: { homedir: '/tmp/sessions/wd_test/s1/agents/main', type: 'main' } }, custom: { cwd: '/tmp/work' }, }); }); it('fires onDidChangeMetadata with the changed keys after update', async () => { const meta = ix.get(ISessionMetadata); await meta.ready; let fired = 0; let captured: { readonly changed: readonly string[] } | undefined; const sub = meta.onDidChangeMetadata((e) => { fired++; captured = e; }); await meta.update({ title: 'x' }); expect(fired).toBe(1); expect(captured).toEqual({ changed: ['title'] }); sub.dispose(); }); it('preserves every concurrently registered agent', async () => { const meta = ix.get(ISessionMetadata); await Promise.all([ meta.registerAgent('agent-0', { labels: { swarmItem: 'src/a.ts' }, }), meta.registerAgent('agent-1', { labels: { swarmItem: 'src/b.ts' }, }), ]); expect(Object.keys((await meta.read()).agents ?? {}).sort()).toEqual([ 'agent-0', 'agent-1', ]); }); it('treats re-registering an unchanged agent as a no-op', async () => { const meta = ix.get(ISessionMetadata); await meta.registerAgent('main', { homedir: '/tmp/sessions/wd_test/s1/agents/main', type: 'main', parentAgentId: undefined, forkedFrom: undefined, labels: undefined, }); const before = (await meta.read()).updatedAt; await new Promise((r) => setTimeout(r, 2)); let fired = 0; const sub = meta.onDidChangeMetadata(() => { fired++; }); await meta.registerAgent('main', { homedir: '/tmp/sessions/wd_test/s1/agents/main', type: 'main', parentAgentId: undefined, forkedFrom: undefined, labels: undefined, }); expect(fired).toBe(0); expect((await meta.read()).updatedAt).toBe(before); sub.dispose(); }); it('stays a no-op when re-registering against a persisted document', async () => { const store = ix.get(IAtomicDocumentStore); await store.set(META_SCOPE, 'state.json', { id: 's1', version: 2, createdAt: 1700000000000, updatedAt: 1700000000000, archived: false, agents: { main: { homedir: '/tmp/sessions/wd_test/s1/agents/main', type: 'main', parentAgentId: null, }, }, }); const meta = ix.get(ISessionMetadata); await meta.registerAgent('main', { homedir: '/tmp/sessions/wd_test/s1/agents/main', type: 'main', parentAgentId: undefined, forkedFrom: undefined, labels: undefined, }); expect((await meta.read()).updatedAt).toBe(1700000000000); }); it('updates changed fields on re-registration without bumping updatedAt', async () => { const meta = ix.get(ISessionMetadata); await meta.registerAgent('main', { homedir: '/tmp/sessions/wd_test/s1/agents/main', type: 'main', }); const before = (await meta.read()).updatedAt; await new Promise((r) => setTimeout(r, 2)); await meta.registerAgent('main', { homedir: '/tmp/sessions/wd_test/s1/agents/main', type: 'main', labels: { swarmItem: 'src/a.ts' }, }); const next = await meta.read(); expect(next.agents?.['main']?.labels).toEqual({ swarmItem: 'src/a.ts' }); expect(next.updatedAt).toBe(before); }); it('records the fresh summary into the session index mirror on update', async () => { const meta = ix.get(ISessionMetadata); await meta.ready; expect(mirror.recorded).toHaveLength(1); await meta.update({ title: 'mirrored' }); expect(mirror.recorded).toHaveLength(2); expect(mirror.recorded[1]).toMatchObject({ id: 's1', workspaceId: 'wd_test', title: 'mirrored', archived: false, }); expect(mirror.recorded[1]?.updatedAt).toBe((await meta.read()).updatedAt); }); it('does not re-record when loading an existing document', async () => { const store = ix.get(IAtomicDocumentStore); await store.set(META_SCOPE, 'state.json', { id: 's1', version: 2, createdAt: 1700000000000, updatedAt: 1700000000000, archived: false, agents: {}, custom: {}, }); const meta = ix.get(ISessionMetadata); await meta.ready; expect(mirror.recorded).toEqual([]); await meta.setArchived(true); expect(mirror.recorded).toHaveLength(1); expect(mirror.recorded[0]?.archived).toBe(true); }); });