Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/session-meta-updated-at.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Keep session list order stable when renaming, archiving, restoring, or forking a session.
1 change: 1 addition & 0 deletions packages/agent-core-v2/docs/state-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,7 @@ export interface SessionStateSnapshot {
readonly createdAt: number;
readonly updatedAt: number;
readonly archived: boolean;
readonly archivedAt?: number;
readonly cwd?: string;
readonly forkedFrom?: string;
readonly agents?: Readonly<Record<string, /* AgentMeta — packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts */ {
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ export interface SessionSummary {
readonly createdAt: number;
readonly updatedAt: number;
readonly archived: boolean;
/** Archive time (epoch ms); absent for sessions archived before the field
* existed — callers fall back to `updatedAt` for display. */
Comment thread
liruifengv marked this conversation as resolved.
readonly archivedAt?: number;
readonly custom?: Record<string, unknown>;
readonly lastTurnReason?: 'completed' | 'cancelled' | 'failed';
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export function buildSessionSummary(fields: {
createdAt: number;
updatedAt: number;
archived: boolean;
archivedAt?: number;
custom?: Record<string, unknown>;
lastTurnReason?: 'completed' | 'cancelled' | 'failed';
}): SessionSummary {
Expand All @@ -78,6 +79,7 @@ export function buildSessionSummary(fields: {
createdAt: fields.createdAt,
updatedAt: fields.updatedAt,
archived: fields.archived,
archivedAt: fields.archivedAt,
custom: fields.custom,
lastTurnReason: fields.lastTurnReason,
};
Expand Down Expand Up @@ -108,6 +110,7 @@ export function summaryEquals(a: SessionSummary, b: SessionSummary): boolean {
a.createdAt === b.createdAt &&
a.updatedAt === b.updatedAt &&
a.archived === b.archived &&
a.archivedAt === b.archivedAt &&
a.lastTurnReason === b.lastTurnReason &&
JSON.stringify(a.custom) === JSON.stringify(b.custom)
);
Expand Down Expand Up @@ -159,6 +162,7 @@ export async function readSessionSummary(
createdAt: parseTime(meta['createdAt']),
updatedAt: parseTime(meta['updatedAt']),
archived: meta['archived'] === true,
archivedAt: meta['archivedAt'] === undefined ? undefined : parseTime(meta['archivedAt']),
custom,
lastTurnReason: parseTurnOutcome(meta['lastTurnReason']),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface SessionWireFields {
readonly createdAt: number;
readonly updatedAt: number;
readonly archived: boolean;
readonly archivedAt?: number;
readonly custom?: Record<string, unknown>;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export class SessionLegacyService implements ISessionLegacyService {
createdAt: meta.createdAt,
updatedAt: meta.updatedAt,
archived: meta.archived,
archivedAt: meta.archivedAt,
custom: meta.custom,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ export interface SessionMeta {
readonly createdAt: number;
readonly updatedAt: number;
readonly archived: boolean;
/** When the session was archived (epoch ms); cleared on restore. Management
* writes (title / archived) never touch `updatedAt` — it tracks content
* activity only, so listings stay put on rename/archive/restore. */
readonly archivedAt?: number;
readonly cwd?: string;
readonly forkedFrom?: string;
readonly agents?: Readonly<Record<string, AgentMeta>>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,11 @@ export class SessionMetadata extends Service implements ISessionMetadata {
): Promise<void> {
await this.ready;
if (this.disposed) return;
const updatedAt = opts?.touchUpdatedAt === false ? this.data.updatedAt : Date.now();
// An explicit patch.updatedAt always wins (fork inherits the source's
// recency); otherwise management writes (touchUpdatedAt: false — rename,
// archive/restore) keep the current value so listings don't reorder.
const updatedAt =
Comment thread
liruifengv marked this conversation as resolved.
Outdated
patch.updatedAt ?? (opts?.touchUpdatedAt === false ? this.data.updatedAt : Date.now());
this.data = { ...this.data, ...patch, updatedAt };
await this.store.set(this.scope, META_KEY, this.data);
if (this.disposed) return;
Expand All @@ -135,11 +139,14 @@ export class SessionMetadata extends Service implements ISessionMetadata {
}

async setTitle(title: string): Promise<void> {
await this.update({ title, isCustomTitle: true });
await this.update({ title, isCustomTitle: true }, { touchUpdatedAt: false });
}

async setArchived(archived: boolean): Promise<void> {
await this.update({ archived });
await this.update(
archived ? { archived: true, archivedAt: Date.now() } : { archived: false, archivedAt: undefined },
{ touchUpdatedAt: false },
Comment thread
liruifengv marked this conversation as resolved.
Comment thread
liruifengv marked this conversation as resolved.
Comment thread
liruifengv marked this conversation as resolved.
Comment thread
liruifengv marked this conversation as resolved.
);
}

async registerAgent(agentId: string, meta: AgentMeta): Promise<void> {
Expand Down Expand Up @@ -173,6 +180,7 @@ export class SessionMetadata extends Service implements ISessionMetadata {
createdAt: this.data.createdAt,
updatedAt: this.data.updatedAt,
archived: this.data.archived === true,
archivedAt: this.data.archivedAt,
custom: this.data.custom,
lastTurnReason: this.data.lastTurnReason,
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ import {
type SessionLifecycleHookSlots,
} from '#/session/sessionLifecycleHooks/sessionLifecycleHooks';
import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata';
import { drainSessionMetadataWrites } from '#/session/sessionMetadata/sessionMetadataService';
import { drainSessionMetadataWrites, toEpochMs } from '#/session/sessionMetadata/sessionMetadataService';
import { ISessionProcessRunner } from '#/session/process/processRunner';
import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy';
import { IWireService } from '#/wire/wire';
Expand Down Expand Up @@ -525,11 +525,32 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
}

const title = opts.title ?? `Fork: ${sourceMeta?.title || sourceId}`;

await this.duplicateCronTasks(sourceId, targetId);
Comment thread
liruifengv marked this conversation as resolved.
Outdated

for (const agentId of agentIds) {
const sourceAgent = sourceAgents[agentId]!;
await target.accessor.get(IAgentLifecycleService).create({
agentId,
forkedFrom: sourceAgent.forkedFrom,
labels: labelsFromAgentMeta(sourceAgent),
});
}

// Write the fork metadata AFTER the agent recreation loop: registering
// each copied agent performs an ordinary metadata update that bumps
// updatedAt, so the recency restore must come last to survive.
await targetMeta.update({
title,
isCustomTitle: opts.title !== undefined ? true : sourceMeta?.isCustomTitle === true,
forkedFrom: sourceId,
archived: false,
// The fork is a copy of the source's content as of now, not fresh
// activity: inherit the source's recency so the fork lands next to it
// instead of floating to the top of the list. (createdAt stays now —
// that's the creation fact.) A cold legacy source read from disk can
// still carry an ISO-string updatedAt — normalize to epoch ms.
updatedAt: toEpochMs(sourceMeta?.updatedAt) || Date.now(),
Comment thread
liruifengv marked this conversation as resolved.
lastPrompt: sourceMeta?.lastPrompt,
// The fork continues the source's conversation, so it inherits the
// last turn's outcome too — otherwise a restart would drop a failure
Expand All @@ -538,17 +559,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
custom: forkCustomMetadata(sourceMeta?.custom, opts.metadata),
});

await this.duplicateCronTasks(sourceId, targetId);

for (const agentId of agentIds) {
const sourceAgent = sourceAgents[agentId]!;
await target.accessor.get(IAgentLifecycleService).create({
agentId,
forkedFrom: sourceAgent.forkedFrom,
labels: labelsFromAgentMeta(sourceAgent),
});
}

await this.appendSessionIndexEntry(targetId, this.workspaceContext.cwd);
this._onDidForkSession.fire({
sourceSessionId: sourceId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,44 @@ describe('SessionMetadata', () => {
expect(await meta.read()).toMatchObject({ title: 't', archived: true });
});

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('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('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', {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1524,6 +1524,96 @@ describe('SessionLifecycleService', () => {
expect(forkUpdate?.lastTurnReason).toBe('failed');
});

it('fork inherits the source session\'s updatedAt (a copy is not fresh activity)', async () => {
const updates: { readonly updatedAt?: unknown }[] = [];
const metaStub: ISessionMetadata = {
...metadataStub(),
read: () =>
Promise.resolve({ updatedAt: 9876, agents: {} } as never),
update: (patch) => {
updates.push(patch);
return Promise.resolve();
},
};
const svc = await build([stubPair(ISessionMetadata, metaStub)]);

await svc.create({ sessionId: 'src', workDir: '/tmp/proj' });
await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' });

const forkUpdate = updates.find((u) => 'forkedFrom' in u);
expect(forkUpdate?.updatedAt).toBe(9876);
});

it('fork normalizes a legacy ISO-string updatedAt from a cold source to epoch ms', async () => {
const updates: { readonly updatedAt?: unknown }[] = [];
const metaStub: ISessionMetadata = {
...metadataStub(),
// A cold legacy/v1 document read from disk can still carry an ISO
// string — the fork must not persist it as the v2 updatedAt.
read: () =>
Promise.resolve({ updatedAt: '2026-08-10T23:00:00.000Z', agents: {} } as never),
update: (patch) => {
updates.push(patch);
return Promise.resolve();
},
};
const svc = await build([stubPair(ISessionMetadata, metaStub)]);

await svc.create({ sessionId: 'src', workDir: '/tmp/proj' });
await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' });

const forkUpdate = updates.find((u) => 'forkedFrom' in u);
expect(forkUpdate?.updatedAt).toBe(Date.parse('2026-08-10T23:00:00.000Z'));
});

it('fork writes the inherited updatedAt AFTER agent registration (registering bumps it)', async () => {
const calls: string[] = [];
const metaStub: ISessionMetadata = {
...metadataStub(),
read: () =>
Promise.resolve({
updatedAt: 9876,
agents: { main: { type: 'main', homedir: '/tmp/h' } },
} as never),
update: (patch) => {
calls.push('forkedFrom' in patch ? 'update:fork' : 'update:other');
return Promise.resolve();
},
registerAgent: () => {
calls.push('registerAgent');
return Promise.resolve();
},
};
const agentHandle = {
id: 'main',
kind: LifecycleScope.Agent,
accessor: {
get: () => {
throw new Error('unexpected service access');
},
},
dispose: () => {},
} as unknown as IAgentScopeHandle;
const svc = await build([
stubPair(ISessionMetadata, metaStub),
stubPair(IAgentLifecycleService, {
...agentLifecycleStub(),
create: async () => {
// Mirror the real doCreate: recreation registers the agent, which
// is an ordinary metadata write (it bumps updatedAt).
await metaStub.registerAgent('main', {});
return agentHandle;
},
}),
]);

await svc.create({ sessionId: 'src', workDir: '/tmp/proj' });
await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' });

expect(calls).toContain('registerAgent');
expect(calls.indexOf('update:fork')).toBeGreaterThan(calls.lastIndexOf('registerAgent'));
});

it('copies blobs, plans, background tasks, and media originals into the fork', async () => {
const root = await makeTmpRoot();
const svc = await build([
Expand Down
3 changes: 3 additions & 0 deletions packages/kap-server/src/protocol/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ export const sessionSchema = z.object({
/** Outcome of the main agent's most recent turn. */
last_turn_reason: z.enum(['completed', 'cancelled', 'failed']).optional(),
archived: z.boolean().optional(),
/** When the session was archived (ISO 8601); absent for sessions archived
* before the field existed — clients fall back to `updated_at`. */
archived_at: isoDateTimeSchema.optional(),
current_prompt_id: z.string().min(1).optional(),
/** Text of the most recent user prompt, for search/preview. Absent for empty sessions. */
last_prompt: z.string().optional(),
Expand Down
5 changes: 5 additions & 0 deletions packages/kap-server/src/routes/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1108,6 +1108,7 @@ export interface SessionWireFields {
readonly createdAt: number;
readonly updatedAt: number;
readonly archived: boolean;
readonly archivedAt?: number;
readonly custom?: Record<string, unknown>;
readonly lastTurnReason?: 'completed' | 'cancelled' | 'failed';
}
Expand All @@ -1123,6 +1124,10 @@ export function toWireSession(
title: fields.title ?? '',
created_at: new Date(fields.createdAt).toISOString(),
updated_at: new Date(fields.updatedAt).toISOString(),
// Archive moment; sessions archived before the field existed report
// nothing (clients fall back to updated_at for display).
archived_at:
fields.archivedAt === undefined ? undefined : new Date(fields.archivedAt).toISOString(),
busy: facts.busy,
main_turn_active: facts.mainTurnActive,
pending_interaction: facts.pendingInteraction,
Expand Down
4 changes: 4 additions & 0 deletions packages/kap-server/src/routes/v2/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ const v2SessionSchema = z.object({
created_at: z.number().int(),
updated_at: z.number().int(),
archived: z.boolean(),
/** Unix ms; null when absent (never archived, or archived before the
* field existed — clients fall back to updated_at for display). */
archived_at: z.number().int().nullable(),
}),
activity: z.object({ status: v2ActivityStatusSchema }),
git: v2GitDomainSchema.optional(),
Expand Down Expand Up @@ -492,6 +495,7 @@ export function registerV2SessionsRoutes(app: V2SessionsRouteHost, core: Scope):
created_at: summary.createdAt,
updated_at: summary.updatedAt,
archived: summary.archived,
archived_at: summary.archivedAt ?? null,
},
activity: { status: mapActivityStatus(factsOf(summary.id), summary.lastTurnReason) },
git:
Expand Down
1 change: 1 addition & 0 deletions packages/kap-server/test/v2Sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ describe('server /api/v2/sessions', () => {
created_at: 3_000,
updated_at: 5_000,
archived: false,
archived_at: null,
});
// Stubbed sessions are cold → always idle.
expect(first.activity).toEqual({ status: 'idle' });
Expand Down
1 change: 1 addition & 0 deletions packages/klient/src/contract/global/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const sessionSummarySchema = z.object({
createdAt: z.number(),
updatedAt: z.number(),
archived: z.boolean(),
archivedAt: z.number().optional(),
custom: z.record(z.string(), z.unknown()).optional(),
lastTurnReason: z.enum(['completed', 'cancelled', 'failed']).optional(),
});
Expand Down
Loading
Loading