diff --git a/.changeset/session-resume-updated-at.md b/.changeset/session-resume-updated-at.md new file mode 100644 index 0000000000..dda0464c75 --- /dev/null +++ b/.changeset/session-resume-updated-at.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix a resumed session being marked as just updated and jumping to the top of the session list without any new activity. diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index c3bd1a2516..239abe87b8 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -10,7 +10,10 @@ * and persisted on load for documents written before the seeding existed * (without touching `updatedAt`, so a format heal never reorders session * listings) — keeping sessions on a shared `KIMI_CODE_HOME` resumable by - * released v1 builds. Bound at Session scope. + * released v1 builds. Re-registering an agent whose metadata is unchanged is + * a no-op (no write, no mirror, no event), so resuming a session — which + * re-registers its agents as they materialize — never bumps `updatedAt` and + * never reorders session listings. Bound at Session scope. * * Read-model mirroring (flag `persistence_minidb_readmodel`): after a metadata * update is persisted, the fresh summary is mirrored into the `IQueryStore` @@ -99,6 +102,8 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { async registerAgent(agentId: string, meta: AgentMeta): Promise { return this.enqueueUpdate(async () => { await this.ready; + const existing = this.data.agents?.[agentId]; + if (existing !== undefined && agentMetaEquals(existing, meta)) return; const agents = { ...this.data.agents, [agentId]: meta }; await this.applyUpdate({ agents }); }); @@ -162,6 +167,25 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { } } +function agentMetaEquals(a: AgentMeta, b: AgentMeta): boolean { + return ( + a.homedir === b.homedir && + a.type === b.type && + (a.parentAgentId ?? null) === (b.parentAgentId ?? null) && + a.forkedFrom === b.forkedFrom && + a.swarmItem === b.swarmItem && + recordEquals(a.labels, b.labels) + ); +} + +function recordEquals(a: AgentMeta['labels'], b: AgentMeta['labels']): boolean { + const entriesA = Object.entries(a ?? {}); + const entriesB = Object.entries(b ?? {}); + return ( + entriesA.length === entriesB.length && entriesA.every(([key, value]) => b?.[key] === value) + ); +} + export function normalizeSessionMeta(raw: SessionMeta, sessionId: string): SessionMeta { const legacy = raw as unknown as { createdAt?: unknown; diff --git a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts index 38fc10f116..b98e6b7c80 100644 --- a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts +++ b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts @@ -163,4 +163,88 @@ describe('SessionMetadata', () => { '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)); + + // A resumed session re-registers its materialized agents; with identical + // metadata that must not write, bump updatedAt, or fire an event. + 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 () => { + // The document as it lands on disk: keys with undefined values are gone, + // and a legacy writer stored parentAgentId: null. A server restart then + // re-registers `main` with explicit undefineds — still no update. + 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 when re-registering with changed fields', 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).toBeGreaterThan(before); + }); });