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 @@ -530,6 +530,11 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
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.)
updatedAt: sourceMeta?.updatedAt ?? Date.now(),
Comment thread
liruifengv marked this conversation as resolved.
Outdated
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 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,26 @@ 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('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
3 changes: 3 additions & 0 deletions packages/klient/src/contract/session/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const sessionMetaSchema = z.object({
createdAt: z.number(),
updatedAt: z.number(),
archived: z.boolean(),
archivedAt: z.number().optional(),
cwd: z.string().optional(),
forkedFrom: z.string().optional(),
agents: z.record(z.string(), agentMetaSchema).optional(),
Expand All @@ -42,6 +43,7 @@ export const sessionMetaPatchSchema = z.object({
lastPrompt: z.string().optional(),
updatedAt: z.number().optional(),
archived: z.boolean().optional(),
archivedAt: z.number().optional(),
cwd: z.string().optional(),
forkedFrom: z.string().optional(),
agents: z.record(z.string(), agentMetaSchema).optional(),
Expand All @@ -59,6 +61,7 @@ export const sessionMetaKeySchema = z.enum([
'createdAt',
'updatedAt',
'archived',
'archivedAt',
'cwd',
'forkedFrom',
'agents',
Expand Down
3 changes: 3 additions & 0 deletions packages/protocol/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ export const sessionSchema = z.object({
* reason is cancelled/failed). */
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
Loading