From f91b34dc69bec656bfc13554f7ed4bb4e9c06935 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 9 Jul 2026 20:21:52 +0800 Subject: [PATCH] fix(kap-server): align archived session restore --- .../agent-core-v2/src/activity/activity.ts | 3 + .../src/activity/sessionActivityKernel.ts | 4 + .../app/sessionLifecycle/sessionLifecycle.ts | 3 +- .../sessionLifecycleService.ts | 9 ++- packages/agent-core-v2/test/activity/stubs.ts | 1 + .../test/externalHooks/integration.test.ts | 1 + .../test/sessionExport/sessionExport.test.ts | 1 + .../sessionLifecycle/sessionLifecycle.test.ts | 23 ++++++ packages/kap-server/src/routes/sessions.ts | 80 +++++++++++++------ packages/kap-server/test/sessions.test.ts | 73 +++++++++++++++++ .../src/__tests__/rest-session.test.ts | 40 ++++++++++ packages/protocol/src/rest/session.ts | 5 ++ 12 files changed, 218 insertions(+), 25 deletions(-) diff --git a/packages/agent-core-v2/src/activity/activity.ts b/packages/agent-core-v2/src/activity/activity.ts index eada36260..6511bd9f3 100644 --- a/packages/agent-core-v2/src/activity/activity.ts +++ b/packages/agent-core-v2/src/activity/activity.ts @@ -95,6 +95,9 @@ export interface ISessionActivityKernel { lane(): SessionLane; + /** Leaves the restore/materialize window and admits normal session commands. */ + markActive(): void; + /** Admission table for edge (gateway / rpc / legacy) and `agentLifecycle` commands. */ canAccept(command: SessionCommand): boolean; diff --git a/packages/agent-core-v2/src/activity/sessionActivityKernel.ts b/packages/agent-core-v2/src/activity/sessionActivityKernel.ts index e0fba49f7..53ac81677 100644 --- a/packages/agent-core-v2/src/activity/sessionActivityKernel.ts +++ b/packages/agent-core-v2/src/activity/sessionActivityKernel.ts @@ -31,6 +31,10 @@ export class SessionActivityKernel extends Disposable implements ISessionActivit return 'active'; } + markActive(): void { + // Placeholder kernel is already active. + } + canAccept(_command: SessionCommand): boolean { return true; } diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts index 1e5a19030..1a13bda6e 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts @@ -4,7 +4,7 @@ * Defines the public contract of session lifecycle: the `CreateSessionOptions`, * `ForkSessionOptions`, `CreateChildSessionOptions`, and the * `ISessionLifecycleService` used to create sessions (`create`), look up the - * live ones (`get` / `list`), close them (`close`), archive them (`archive`), + * live ones (`get` / `list`), close them (`close`), archive/restore them, * fork them (`fork`), and fork-then-tag them as direct children (`createChild`). Announces * lifecycle transitions through ordered hook slots plus * `onDidCreateSession` / `onDidCloseSession` / `onDidArchiveSession` / @@ -114,6 +114,7 @@ export interface ISessionLifecycleService { resume(sessionId: string): Promise; close(sessionId: string): Promise; archive(sessionId: string): Promise; + restore(sessionId: string): Promise; fork(opts: ForkSessionOptions): Promise; /** * Fork a session and tag it as a direct child of its source (writes the diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts index cddf7eace..f99a42cbf 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -5,7 +5,7 @@ * through the DI scope tree and seeding each with its identity and storage * addressing, running lifecycle hook slots, and tearing them down on * close/archive — archiving flags the session's `sessionMetadata`, removes - * its `agentLifecycle` agents, and + * its `agentLifecycle` agents, restoring clears the archived flag, and * broadcasts through `event`. Materializes the session's initial metadata on * creation by resolving `sessionMetadata`. Bound at App scope. Persisted * sessions are discovered through the `sessionIndex` read model, and workspace @@ -277,6 +277,13 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec this._onDidArchiveSession.fire({ sessionId }); } + async restore(sessionId: string): Promise { + const handle = await this.resume(sessionId); + if (handle === undefined) return undefined; + await handle.accessor.get(ISessionMetadata).setArchived(false); + return handle; + } + private async announceWillClose(event: SessionWillCloseEvent): Promise { await this.hooks.onWillCloseSession.run(event); } diff --git a/packages/agent-core-v2/test/activity/stubs.ts b/packages/agent-core-v2/test/activity/stubs.ts index f104a11a0..51622568b 100644 --- a/packages/agent-core-v2/test/activity/stubs.ts +++ b/packages/agent-core-v2/test/activity/stubs.ts @@ -24,6 +24,7 @@ export function stubSessionActivityKernel( return { _serviceBrand: undefined, lane: () => lane, + markActive: () => undefined, canAccept: (_command: SessionCommand) => lane === 'active', admitTurn(_agentId: string, lease: ActivityLease): IDisposable { leases.add(lease); diff --git a/packages/agent-core-v2/test/externalHooks/integration.test.ts b/packages/agent-core-v2/test/externalHooks/integration.test.ts index 8c8b55674..86b06f401 100644 --- a/packages/agent-core-v2/test/externalHooks/integration.test.ts +++ b/packages/agent-core-v2/test/externalHooks/integration.test.ts @@ -186,6 +186,7 @@ function stubSessionLifecycle(): ISessionLifecycleService { resume: async () => undefined, close: async () => {}, archive: async () => {}, + restore: async () => undefined, fork: async () => { throw new Error('not implemented'); }, diff --git a/packages/agent-core-v2/test/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/sessionExport/sessionExport.test.ts index 487972351..3539e7add 100644 --- a/packages/agent-core-v2/test/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/sessionExport/sessionExport.test.ts @@ -325,6 +325,7 @@ function registerSessionExportServices( resume: async () => options.lifecycleHandle, close: async () => {}, archive: async () => {}, + restore: async () => options.lifecycleHandle, fork: async () => { throw new Error('fork should not be called by session export'); }, diff --git a/packages/agent-core-v2/test/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/sessionLifecycle/sessionLifecycle.test.ts index a6c817b3d..0c62530e2 100644 --- a/packages/agent-core-v2/test/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/sessionLifecycle/sessionLifecycle.test.ts @@ -11,6 +11,7 @@ import { registerScopedService, } from '#/_base/di/scope'; import { type ScopedTestHost, createScopedTestHost, stubPair } from '#/_base/di/test'; +import { ISessionActivityKernel } from '#/activity/activity'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IEventService } from '#/app/event/event'; @@ -35,6 +36,7 @@ import { SessionWorkspaceContextService } from '#/session/workspaceContext/works import { IWorkspaceRegistry, type Workspace } from '#/app/workspaceRegistry/workspaceRegistry'; import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { stubSessionActivityKernel } from '../activity/stubs'; function bootstrapStub(): IBootstrapService { return { @@ -331,6 +333,7 @@ describe('SessionLifecycleService', () => { stubPair(IAtomicDocumentStore, atomicDocumentStoreStub()), stubPair(IEventService, eventStub()), stubPair(IAgentLifecycleService, agentLifecycleStub()), + stubPair(ISessionActivityKernel, stubSessionActivityKernel()), stubPair(IWorkspaceLocalConfigService, workspaceLocalConfigStub()), ...extra, ]); @@ -485,6 +488,26 @@ describe('SessionLifecycleService', () => { expect(svc.get('s1')).toBeUndefined(); }); + it('restore clears the archived flag when the session exists on disk', async () => { + let archived: boolean | undefined; + const svc = build([ + stubPair(ISessionIndex, sessionIndexWithSummary('s1', '/tmp/proj')), + stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), + stubPair(ISessionMetadata, { + ...metadataStub(), + setArchived: (value: boolean) => { + archived = value; + return Promise.resolve(); + }, + }), + ]); + + const restored = await svc.restore('s1'); + + expect(restored?.id).toBe('s1'); + expect(archived).toBe(false); + }); + it('fires onDidCreateSession with the new handle', async () => { const svc = build(); let captured: { readonly sessionId: string } | undefined; diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index b423f8c94..86390af6a 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -9,15 +9,15 @@ * GET /sessions/{session_id}/profile * POST /sessions/{session_id}/profile update title / metadata / agent_config * POST /sessions/{tail} action: fork / compact / undo / - * abort / btw / archive + * abort / btw / archive / restore * GET /sessions/{session_id}/children list child sessions * POST /sessions/{session_id}/children create child session (fork+tag) * GET /sessions/{session_id}/status best-effort * GET /sessions/{session_id}/warnings agents-md-oversized notice * * The `POST /sessions/{tail}` actions split into two groups. The thin - * pass-throughs — `fork` / `compact` / `abort` / `archive` — call the native v2 - * services directly (`ISessionLifecycleService.fork` / `archive`, + * pass-throughs — `fork` / `compact` / `abort` / `archive` / `restore` — call + * the native v2 services directly (`ISessionLifecycleService.fork` / `archive` / `restore`, * `IAgentFullCompactionService.begin`, `IAgentRPCService.cancel`); there is no * v1-only projection to centralize, so no adapter is involved. `undo` likewise * calls `IAgentPromptService.undo` directly (it now throws @@ -55,7 +55,9 @@ * is real on every session-producing endpoint here. `GET /sessions` and * `GET /sessions/{id}/children` filter their projected page by the `status` * query param (post-page, matching v1 — `has_more` reflects the pre-filter - * page). The `aborted` phase is not derived yet (gap G10); v2 never reports it. + * page), except `archived_only` lists filter status before route pagination so + * they can drain archived pages the same way v1 does. The `aborted` phase is not + * derived yet (gap G10); v2 never reports it. * * **cwd resolution (gap G3 closed)**: the session's frozen work dir is * persisted on its metadata document (`ISessionMetadata`) and surfaced on the @@ -145,6 +147,8 @@ const booleanQueryParam = z.preprocess((value) => { return value; }, z.boolean().optional()); +const DEFAULT_SESSION_LIST_PAGE_SIZE = 20; + // NOTE: mirrors v1's `GET /sessions` query. `before_id`/`after_id` id-cursors // and `page_size` ARE applied in the route handler (the `FileSessionIndex` does // not implement `cursor`, so we page over its recency-sorted result); `status` @@ -365,18 +369,20 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void includeArchived: archivedOnly ? true : raw.include_archive, }); - // Filter down to the sequence the client actually sees BEFORE computing - // the cursor position and the page boundary, so a cursor carried over - // from a previous page always resolves to the same index. `cwd` is read - // from the session's own summary first (gap G3 closed — an unregistered - // workspace no longer drops the session); the registry `roots` map is - // only a back-compat fallback for sessions written before `cwd` was - // persisted. A session with no recoverable cwd is still skipped. - const eligible: { summary: (typeof page.items)[number]; cwd: string }[] = []; + // Filter down to the sequence the client can page over BEFORE computing + // the cursor position. `cwd` is read from the session's own summary first + // (gap G3 closed — an unregistered workspace no longer drops the session); + // the registry `roots` map is only a back-compat fallback for sessions + // written before `cwd` was persisted. A session with no recoverable cwd is + // still skipped. + const eligible: { + readonly summary: (typeof page.items)[number]; + readonly cwd: string; + readonly status?: Session['status']; + }[] = []; for (const summary of page.items) { const cwd = summary.cwd ?? roots.get(summary.workspaceId); if (cwd === undefined) continue; - if (archivedOnly && summary.archived !== true) continue; if (raw.exclude_empty === true && (summary.lastPrompt ?? '').length === 0) continue; eligible.push({ summary, cwd }); } @@ -399,18 +405,30 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void } const window = eligible.slice(start, end); - const limit = pageSize ?? window.length; - const hasMore = window.length > limit; - const projected: Session[] = window + let visible = window; + if (archivedOnly) { + visible = + raw.status === undefined + ? window.filter((entry) => entry.summary.archived === true) + : window.flatMap((entry) => { + if (entry.summary.archived !== true) return []; + const status = resolveSessionStatus(core, entry.summary.id); + return status === raw.status ? [{ ...entry, status }] : []; + }); + } + const limit = archivedOnly + ? (pageSize ?? DEFAULT_SESSION_LIST_PAGE_SIZE) + : (pageSize ?? visible.length); + const hasMore = visible.length > limit; + const projected: Session[] = visible .slice(0, limit) - .map(({ summary, cwd }) => - toWireSession(summary, cwd, resolveSessionStatus(core, summary.id)), + .map(({ summary, cwd, status }) => + toWireSession(summary, cwd, status ?? resolveSessionStatus(core, summary.id)), ); - // v1 filters the projected page by `status` (post-page); `has_more` keeps - // reflecting the pre-filter page, so a filtered page may be short or empty - // while `has_more` is still true — match that exactly. + // v1 filters ordinary lists by `status` post-page; `archived_only` already + // applied status before pagination above so it can drain to a full page. const items = - raw.status !== undefined + raw.status !== undefined && !archivedOnly ? projected.filter((session) => session.status === raw.status) : projected; reply.send(okEnvelope({ items, has_more: hasMore }, req.id)); @@ -593,7 +611,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void const { tail } = req.params; const parsed = parseActionSuffix({ tail, - allowedActions: ['fork', 'compact', 'undo', 'abort', 'btw', 'archive'] as const, + allowedActions: ['fork', 'compact', 'undo', 'abort', 'btw', 'archive', 'restore'] as const, resourceLabel: 'session', }); if (parsed.kind !== 'action') { @@ -697,6 +715,22 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void return; } + if (parsed.action === 'restore') { + const restored = await core.accessor.get(ISessionLifecycleService).restore(parsed.id); + if (restored === undefined) { + throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${parsed.id} does not exist`); + } + const meta = await restored.accessor.get(ISessionMetadata).read(); + const ctx = restored.accessor.get(ISessionContext); + const session = toWireSession( + { ...meta, workspaceId: ctx.workspaceId }, + ctx.cwd, + resolveSessionStatus(core, meta.id), + ); + reply.send(okEnvelope(session, req.id)); + return; + } + // archive — `resume` (not `get`) so archiving a freshly-opened cold // session still works; `resume` returns undefined only when the session // is unknown or its workspace is gone, reported as `session.not_found`. diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index 6ee821bd1..c053314b1 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -324,6 +324,28 @@ describe('server-v2 /api/v1/sessions', () => { expect(got.body.data.archived).toBe(true); }); + it('restores an archived session via :restore and returns it to the default list', async () => { + const cwd = home as string; + const created = await postJson('/api/v1/sessions', { metadata: { cwd } }); + const id = created.body.data.id; + + await postJson<{ archived: boolean }>(`/api/v1/sessions/${id}:archive`); + + const restored = await postJson(`/api/v1/sessions/${id}:restore`); + expect(restored.body.code).toBe(0); + expect(restored.body.data.id).toBe(id); + expect(restored.body.data.archived).toBe(false); + + const listed = await getJson('/api/v1/sessions'); + expect(listed.body.code).toBe(0); + expect(listed.body.data.items.find((s) => s.id === id)?.archived).toBe(false); + }); + + it('returns 40401 when restoring a missing session', async () => { + const { body } = await postJson('/api/v1/sessions/sess_missing:restore'); + expect(body.code).toBe(40401); + }); + it('cold-loads a persisted session on :undo instead of 40401', async () => { const cwd = home as string; const created = await postJson('/api/v1/sessions', { metadata: { cwd } }); @@ -482,6 +504,42 @@ describe('server-v2 /api/v1/sessions', () => { expect(all.body.data.items.some((s) => s.id === archivedId)).toBe(true); }); + it('paginates archived_only without returning empty filtered pages', async () => { + const cwd = home as string; + const archivedOlder = await postJson('/api/v1/sessions', { metadata: { cwd } }); + await postJson<{ archived: boolean }>( + `/api/v1/sessions/${archivedOlder.body.data.id}:archive`, + ); + + const archivedNewer = await postJson('/api/v1/sessions', { metadata: { cwd } }); + await postJson<{ archived: boolean }>( + `/api/v1/sessions/${archivedNewer.body.data.id}:archive`, + ); + + await postJson('/api/v1/sessions', { metadata: { cwd } }); + await postJson('/api/v1/sessions', { metadata: { cwd } }); + + const first = await getJson('/api/v1/sessions?archived_only=true&page_size=1'); + expect(first.body.code).toBe(0); + expect(first.body.data.items).toHaveLength(1); + expect(first.body.data.items[0]).toMatchObject({ + id: archivedNewer.body.data.id, + archived: true, + }); + expect(first.body.data.has_more).toBe(true); + + const second = await getJson( + `/api/v1/sessions?archived_only=true&page_size=1&before_id=${archivedNewer.body.data.id}`, + ); + expect(second.body.code).toBe(0); + expect(second.body.data.items).toHaveLength(1); + expect(second.body.data.items[0]).toMatchObject({ + id: archivedOlder.body.data.id, + archived: true, + }); + expect(second.body.data.has_more).toBe(false); + }); + it('rejects archived_only combined with include_archive (40001)', async () => { const { body } = await getJson( '/api/v1/sessions?archived_only=true&include_archive=true', @@ -489,6 +547,21 @@ describe('server-v2 /api/v1/sessions', () => { expect(body.code).toBe(40001); }); + it('returns a terminal empty page when archived_only status filtering finds no match', async () => { + const cwd = home as string; + const first = await postJson('/api/v1/sessions', { metadata: { cwd } }); + const second = await postJson('/api/v1/sessions', { metadata: { cwd } }); + + await postJson<{ archived: boolean }>(`/api/v1/sessions/${first.body.data.id}:archive`); + await postJson<{ archived: boolean }>(`/api/v1/sessions/${second.body.data.id}:archive`); + + const page = await getJson( + '/api/v1/sessions?archived_only=true&status=running&page_size=1', + ); + expect(page.body.code).toBe(0); + expect(page.body.data).toEqual({ items: [], has_more: false }); + }); + it('rejects a malformed workspace_id when listing (40001)', async () => { const { body } = await getJson('/api/v1/sessions?workspace_id=not-a-workspace-id'); expect(body.code).toBe(40001); diff --git a/packages/protocol/src/__tests__/rest-session.test.ts b/packages/protocol/src/__tests__/rest-session.test.ts index d53c4e5e5..88cfbd083 100644 --- a/packages/protocol/src/__tests__/rest-session.test.ts +++ b/packages/protocol/src/__tests__/rest-session.test.ts @@ -14,6 +14,7 @@ import { listSessionChildrenQuerySchema, listSessionChildrenResponseSchema, listSessionsQuerySchema, + restoreSessionResponseSchema, sessionStatusResponseSchema, updateSessionProfileRequestSchema, updateSessionRequestSchema, @@ -100,6 +101,15 @@ describe('listSessionsQuerySchema', () => { }); }); + it('parses archived_only to boolean', () => { + expect(listSessionsQuerySchema.parse({ archived_only: 'true' })).toEqual({ + archived_only: true, + }); + expect(listSessionsQuerySchema.parse({ archived_only: false })).toEqual({ + archived_only: false, + }); + }); + it('parses exclude_empty to boolean', () => { expect(listSessionsQuerySchema.parse({ exclude_empty: 'true' })).toEqual({ exclude_empty: true, @@ -498,6 +508,36 @@ describe('archiveSessionResponseSchema', () => { }); }); +describe('restoreSessionResponseSchema', () => { + it('accepts a restored Session payload', () => { + const parsed = restoreSessionResponseSchema.parse({ + id: 'sess_abc', + workspace_id: 'wd_kimi_0123456789ab', + title: 'Restored', + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + status: 'idle', + archived: false, + metadata: { cwd: '/tmp/foo' }, + agent_config: { model: '' }, + usage: { + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + cache_creation_tokens: 0, + total_cost_usd: 0, + context_tokens: 0, + context_limit: 0, + turn_count: 0, + }, + permission_rules: [], + message_count: 0, + last_seq: 0, + }); + expect(parsed.archived).toBe(false); + }); +}); + describe('deleteSessionResponseSchema (deprecated alias)', () => { it('accepts the canonical { archived: true } shape', () => { expect(deleteSessionResponseSchema.parse({ archived: true })).toEqual({ archived: true }); diff --git a/packages/protocol/src/rest/session.ts b/packages/protocol/src/rest/session.ts index f937de4dd..bcd84181a 100644 --- a/packages/protocol/src/rest/session.ts +++ b/packages/protocol/src/rest/session.ts @@ -12,6 +12,7 @@ * POST /v1/sessions/{id}:compact body: CompactSession data: {} * POST /v1/sessions/{id}:undo body: UndoSession data: UndoSession * POST /v1/sessions/{id}:archive - data: { archived: true } + * POST /v1/sessions/{id}:restore - data: Session */ import { z } from 'zod'; @@ -46,6 +47,7 @@ export const listSessionsQuerySchema = cursorQuerySchema.and( z.object({ status: sessionStatusSchema.optional(), include_archive: booleanQueryParam, + archived_only: booleanQueryParam, exclude_empty: booleanQueryParam, }), ); @@ -161,6 +163,9 @@ export const archiveSessionResponseSchema = z.object({ }); export type ArchiveSessionResponse = z.infer; +export const restoreSessionResponseSchema = sessionSchema; +export type RestoreSessionResponse = z.infer; + /** @deprecated kept as an alias for backward compatibility; prefer archiveSessionResponseSchema. */ export const deleteSessionResponseSchema = archiveSessionResponseSchema; /** @deprecated kept as an alias for backward compatibility; prefer ArchiveSessionResponse. */