feat(server-v2): add session children and warnings endpoints

- surface custom metadata in session index summaries so child sessions can be filtered without per-session document reads
- add ISessionLegacyService.createChild/listChildren: children are forks tagged with parent_session_id + child_session_kind, listed by those markers
- wire GET/POST /sessions/{id}/children and GET /sessions/{id}/warnings, reusing the protocol schemas and mapping session.not_found / session.fork_active_turn
- register the sessionLegacy domain at L7 in the domain-layer check
This commit is contained in:
haozhe.yang 2026-06-30 18:31:42 +08:00
parent 78fa333c98
commit c43177019c
7 changed files with 405 additions and 14 deletions

View file

@ -111,6 +111,7 @@ const DOMAIN_LAYER = new Map([
['prompt', 4],
['replayBuilder', 4],
['todoList', 4],
['web', 4],
// L5 — async lifecycle
['background', 5],
['mcp', 5],
@ -132,6 +133,7 @@ const DOMAIN_LAYER = new Map([
['gateway', 7],
['rpc', 7],
['promptLegacy', 7],
['sessionLegacy', 7],
]);
const V1_PACKAGE = '@moonshot-ai/agent-core';

View file

@ -21,6 +21,14 @@ export interface SessionSummary {
readonly createdAt: number;
readonly updatedAt: number;
readonly archived: boolean;
/**
* Free-form custom metadata read from the session's `state.json` (wire
* `Session.metadata` minus reserved keys such as `goal`). Surfaced so the v1
* edge can project it into `Session.metadata` and filter child sessions by
* the `parent_session_id` / `child_session_kind` markers without a per-session
* document read.
*/
readonly custom?: Record<string, unknown>;
}
export interface SessionListQuery {

View file

@ -120,6 +120,11 @@ export class FileSessionIndex implements ISessionIndex {
const meta =
(await this.readMeta(base)) ?? (await this.readMeta(`${base}/${META_SCOPE}`));
if (meta === undefined) return undefined;
const rawCustom = meta['custom'];
const custom =
rawCustom !== null && typeof rawCustom === 'object' && !Array.isArray(rawCustom)
? (rawCustom as Record<string, unknown>)
: undefined;
return {
id: sessionId,
workspaceId,
@ -128,6 +133,7 @@ export class FileSessionIndex implements ISessionIndex {
createdAt: parseTime(meta['createdAt']),
updatedAt: parseTime(meta['updatedAt']),
archived: meta['archived'] === true,
custom,
};
}

View file

@ -2,17 +2,19 @@
* `sessionLegacy` domain (L7 edge adapter) v1-compatible session actions.
*
* Implements the legacy `/api/v1/sessions/{tail}` action contract (`fork` /
* `compact` / `undo` / `abort` / `btw`) on top of the native v2 services
* (`ISessionLifecycleService`, `IAgentRPCService`, `IFullCompaction`,
* `IPromptService`, ). The native services keep serving `/api/v2` and are
* left untouched; this adapter exists only so clients of the v1 server keep
* working against server-v2. Bound at Core scope it is a stateless
* dispatcher that resolves the target session/agent per call.
* `compact` / `undo` / `abort` / `btw`) and the `/sessions/{id}/children`
* endpoints (`createChild` / `listChildren`) on top of the native v2 services
* (`ISessionLifecycleService`, `ISessionIndex`, `IAgentRPCService`,
* `IFullCompaction`, `IPromptService`, ). The native services keep serving
* `/api/v2` and are left untouched; this adapter exists only so clients of the
* v1 server keep working against server-v2. Bound at Core scope it is a
* stateless dispatcher that resolves the target session/agent per call.
*/
import type {
CompactSessionRequest,
CompactSessionResponse,
CreateSessionChildRequest,
ForkSessionRequest,
SessionAbortResponse,
SessionStatus,
@ -59,9 +61,25 @@ export interface UndoResult {
readonly status: SessionStatusData;
}
/** Query mirror of the v1 `GET /sessions/{id}/children` cursor + status filter. */
export interface SessionChildrenQuery {
readonly before_id?: string;
readonly after_id?: string;
readonly page_size?: number;
readonly status?: SessionStatus;
}
/** Page of child sessions, projected by the route into the wire `Page<Session>`. */
export interface SessionChildrenPage {
readonly items: readonly SessionWireFields[];
readonly has_more: boolean;
}
export interface ISessionLegacyService {
readonly _serviceBrand: undefined;
fork(sessionId: string, body: ForkSessionRequest): Promise<SessionWireFields>;
createChild(sessionId: string, body: CreateSessionChildRequest): Promise<SessionWireFields>;
listChildren(sessionId: string, query: SessionChildrenQuery): Promise<SessionChildrenPage>;
compact(sessionId: string, body: CompactSessionRequest): Promise<CompactSessionResponse>;
undo(sessionId: string, body: UndoSessionRequest): Promise<UndoResult>;
abort(sessionId: string): Promise<SessionAbortResponse>;

View file

@ -3,8 +3,10 @@
*
* Stateless Core-scope dispatcher: each method resolves the target session (and
* its main agent) per call, delegates to the native v2 services, and projects
* the result into the v1 wire shape. No business logic is duplicated here; the
* real work stays in the native services.
* the result into the v1 wire shape. Child sessions are implemented as forks
* tagged in `custom` (`parent_session_id` + `child_session_kind`); listing reads
* those markers from the `session-index` summaries. No business logic is
* duplicated here; the real work stays in the native services.
*/
import { InstantiationType } from '#/_base/di/extensions';
@ -22,6 +24,7 @@ import { IPromptService } from '#/prompt';
import { IAgentRPCService } from '#/rpc';
import { ISessionActivity } from '#/session-activity';
import { ISessionContext } from '#/session-context';
import { ISessionIndex, type SessionSummary } from '#/session-index';
import { ISessionLifecycleService } from '#/session-lifecycle';
import { ISessionMetadata } from '#/session-metadata';
import { ISwarmService } from '#/swarm';
@ -29,6 +32,7 @@ import { IWorkspaceRegistry } from '#/workspaceRegistry';
import type {
CompactSessionRequest,
CompactSessionResponse,
CreateSessionChildRequest,
ForkSessionRequest,
SessionAbortResponse,
StartBtwSessionResponse,
@ -37,6 +41,8 @@ import type {
import {
ISessionLegacyService,
type SessionChildrenPage,
type SessionChildrenQuery,
type SessionStatusData,
type SessionWireFields,
type UndoResult,
@ -44,11 +50,23 @@ import {
const MAIN_AGENT_ID = 'main';
/**
* v1 `child_session_kind` marker (`packages/agent-core/.../sessionService.ts`).
* A fork is only listed as a "child" when its metadata carries both
* `parent_session_id` (the parent) and `child_session_kind === 'child'`; a
* spoofed kind is ignored. Reused verbatim so v1/v2 agree on the tag.
*/
const CHILD_SESSION_KIND = 'child';
const CHILDREN_DEFAULT_PAGE_SIZE = 100;
const CHILDREN_MAX_PAGE_SIZE = 100;
export class SessionLegacyService implements ISessionLegacyService {
declare readonly _serviceBrand: undefined;
constructor(
@ISessionLifecycleService private readonly lifecycle: ISessionLifecycleService,
@ISessionIndex private readonly index: ISessionIndex,
@IWorkspaceRegistry private readonly workspaceRegistry: IWorkspaceRegistry,
@IAuthSummaryService private readonly auth: IAuthSummaryService,
) {}
@ -75,6 +93,79 @@ export class SessionLegacyService implements ISessionLegacyService {
};
}
async createChild(sessionId: string, body: CreateSessionChildRequest): Promise<SessionWireFields> {
const parentTitle = await this.resolveParentTitle(sessionId);
const handle = await this.lifecycle.fork({
sourceSessionId: sessionId,
title: body.title ?? `Child: ${parentTitle || sessionId}`,
metadata: {
...(body.metadata ?? {}),
parent_session_id: sessionId,
child_session_kind: CHILD_SESSION_KIND,
},
});
const meta = await handle.accessor.get(ISessionMetadata).read();
const workspaceId = handle.accessor.get(ISessionContext).workspaceId;
const workspace = await this.workspaceRegistry.get(workspaceId);
return {
id: meta.id,
workspaceId,
root: workspace?.root ?? '',
title: meta.title,
lastPrompt: meta.lastPrompt,
createdAt: meta.createdAt,
updatedAt: meta.updatedAt,
archived: meta.archived,
custom: meta.custom,
};
}
async listChildren(sessionId: string, query: SessionChildrenQuery): Promise<SessionChildrenPage> {
const exists =
this.lifecycle.get(sessionId) !== undefined ||
(await this.index.get(sessionId)) !== undefined;
if (!exists) {
throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`);
}
// v1 lists every session then filters by the `parent_session_id` +
// `child_session_kind` markers (carried in `custom`); the index summary
// already surfaces `custom`, so no per-session document read is needed.
const all = await this.index.list({});
const children = all.items.filter(
(s) =>
s.custom?.['parent_session_id'] === sessionId &&
s.custom?.['child_session_kind'] === CHILD_SESSION_KIND,
);
let pivotIndex = -1;
if (query.before_id !== undefined) {
pivotIndex = children.findIndex((s) => s.id === query.before_id);
} else if (query.after_id !== undefined) {
pivotIndex = children.findIndex((s) => s.id === query.after_id);
}
let slice: SessionSummary[];
if (query.before_id !== undefined && pivotIndex >= 0) {
slice = children.slice(pivotIndex + 1);
} else if (query.after_id !== undefined && pivotIndex >= 0) {
slice = children.slice(0, pivotIndex);
} else {
slice = children;
}
const pageSize = Math.min(
Math.max(query.page_size ?? CHILDREN_DEFAULT_PAGE_SIZE, 1),
CHILDREN_MAX_PAGE_SIZE,
);
const page = slice.slice(0, pageSize);
const items = await Promise.all(page.map((s) => this.projectSummary(s)));
// `status` is accepted for wire compatibility but not applied — the v2
// realtime status is not projected into the index summary, and the wire
// projection reports a hardcoded 'idle' (matches `GET /sessions`).
return { items, has_more: slice.length > pageSize };
}
async compact(sessionId: string, body: CompactSessionRequest): Promise<CompactSessionResponse> {
const agent = await this.resolveMainAgent(sessionId);
const instruction = normalizeOptional(body.instruction);
@ -129,6 +220,35 @@ export class SessionLegacyService implements ISessionLegacyService {
// --- internals -------------------------------------------------------------
/**
* Best-effort parent title for the default `Child: <title>` name. Reads the
* live handle first, then falls back to the persisted index. A missing parent
* yields `undefined`; `lifecycle.fork` still throws `SESSION_NOT_FOUND` for
* the real existence check.
*/
private async resolveParentTitle(sessionId: string): Promise<string | undefined> {
const live = this.lifecycle.get(sessionId);
if (live !== undefined) {
return (await live.accessor.get(ISessionMetadata).read()).title;
}
return (await this.index.get(sessionId))?.title;
}
private async projectSummary(summary: SessionSummary): Promise<SessionWireFields> {
const workspace = await this.workspaceRegistry.get(summary.workspaceId);
return {
id: summary.id,
workspaceId: summary.workspaceId,
root: workspace?.root ?? '',
title: summary.title,
lastPrompt: summary.lastPrompt,
createdAt: summary.createdAt,
updatedAt: summary.updatedAt,
archived: summary.archived,
custom: summary.custom,
};
}
/**
* Resolve the session's main agent, creating it on demand (mirrors v1's
* `resumeSession` + the server-v2 `ensureMainAgent` helper).

View file

@ -10,15 +10,21 @@
* POST /sessions/{session_id}/profile update title (partial)
* POST /sessions/{tail} action: fork / compact / undo /
* abort / btw / archive
* 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 empty (no warning sources ported)
*
* The `POST /sessions/{tail}` actions are dispatched to `ISessionLegacyService`
* (a v1 edge adapter over the native v2 services); `archive` stays on the
* native `ISessionLifecycleService`. Both `create` and `fork` publish
* The `POST /sessions/{tail}` actions and the `/sessions/{id}/children`
* endpoints are dispatched to `ISessionLegacyService` (a v1 edge adapter over
* the native v2 services); `archive` stays on the native
* `ISessionLifecycleService`. `create`, `fork`, and child creation publish
* `event.session.created` on the core event bus, matching v1.
*
* Remaining v1 endpoints (children / warnings) are not yet registered see the
* server-v2 sessions gap list.
* `GET /sessions/{id}/warnings` returns `{ warnings: [] }`: the only v1 warning
* (`agents-md-oversized`) is computed by `prepareSystemPromptContext`, which is
* not ported to v2 yet. This is within v1's observable behaviour it falls back
* to `[]` whenever the underlying computation throws.
*
* **Wire fidelity**: mirrors v1's `toProtocolSession`
* (`packages/agent-core/src/services/session/session.ts`), which populates
@ -52,14 +58,17 @@ import {
archiveSessionResponseSchema,
compactSessionRequestSchema,
compactSessionResponseSchema,
createSessionChildRequestSchema,
createSessionRequestSchema,
emptySessionUsage,
forkSessionRequestSchema,
listSessionChildrenResponseSchema,
pageResponseSchema,
sessionAbortResponseSchema,
sessionSchema,
sessionStatusResponseSchema,
sessionStatusSchema,
sessionWarningsResponseSchema,
startBtwSessionResponseSchema,
undoSessionRequestSchema,
undoSessionResponseSchema,
@ -127,6 +136,27 @@ const sessionIdParamSchema = z.object({
session_id: z.string().min(1),
});
// Mirrors v1's children query: id-cursors + page_size + status. `status` is
// accepted for wire compatibility but not applied by `ISessionLegacyService`
// (the wire projection reports a hardcoded 'idle'; see gap G10 / G5).
const sessionChildrenListQueryCoercion = z
.object({
before_id: z.string().min(1).optional(),
after_id: z.string().min(1).optional(),
page_size: z.coerce.number().int().min(1).max(100).optional(),
status: sessionStatusSchema.optional(),
})
.superRefine((value, ctx) => {
if (value.before_id !== undefined && value.after_id !== undefined) {
ctx.addIssue({
code: 'custom',
message: 'before_id and after_id are mutually exclusive',
path: ['before_id'],
params: { code: ErrorCode.VALIDATION_FAILED },
});
}
});
const sessionActionTailParamSchema = z.object({
tail: z.string().min(1),
});
@ -534,6 +564,82 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void
sessionActionRoute.handler as Parameters<SessionRouteHost['post']>[2],
);
const listChildrenRoute = defineRoute(
{
method: 'GET',
path: '/sessions/{session_id}/children',
params: sessionIdParamSchema,
querystring: sessionChildrenListQueryCoercion,
success: { data: listSessionChildrenResponseSchema },
errors: {
[ErrorCode.VALIDATION_FAILED]: { detailsSchema },
[ErrorCode.SESSION_NOT_FOUND]: {},
},
description: 'List child sessions',
tags: ['sessions'],
},
async (req, reply) => {
try {
const { session_id } = req.params;
const page = await core.accessor.get(ISessionLegacyService).listChildren(session_id, req.query);
reply.send(
okEnvelope(
{
items: page.items.map((fields) => toWireSession(fields, fields.root)),
has_more: page.has_more,
},
req.id,
),
);
} catch (error) {
sendMappedError(reply, req.id, error);
}
},
);
app.get(
listChildrenRoute.path,
listChildrenRoute.options,
listChildrenRoute.handler as Parameters<SessionRouteHost['get']>[2],
);
const createChildRoute = defineRoute(
{
method: 'POST',
path: '/sessions/{session_id}/children',
params: sessionIdParamSchema,
body: createSessionChildRequestSchema,
success: { data: sessionSchema },
errors: {
[ErrorCode.VALIDATION_FAILED]: { detailsSchema },
[ErrorCode.SESSION_NOT_FOUND]: {},
[ErrorCode.SESSION_BUSY]: {},
},
description: 'Create a child session',
tags: ['sessions'],
},
async (req, reply) => {
try {
const { session_id } = req.params;
const fields = await core
.accessor.get(ISessionLegacyService)
.createChild(session_id, req.body);
const session = toWireSession(fields, fields.root);
core.accessor.get(IEventService).publish({
type: 'event.session.created',
payload: { agentId: 'main', sessionId: session.id, session },
});
reply.send(okEnvelope(session, req.id));
} catch (error) {
sendMappedError(reply, req.id, error);
}
},
);
app.post(
createChildRoute.path,
createChildRoute.options,
createChildRoute.handler as Parameters<SessionRouteHost['post']>[2],
);
const statusRoute = defineRoute(
{
method: 'GET',
@ -582,6 +688,41 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void
statusRoute.options,
statusRoute.handler as Parameters<SessionRouteHost['get']>[2],
);
const sessionWarningsRoute = defineRoute(
{
method: 'GET',
path: '/sessions/{session_id}/warnings',
params: sessionIdParamSchema,
success: { data: sessionWarningsResponseSchema },
errors: {
[ErrorCode.VALIDATION_FAILED]: { detailsSchema },
[ErrorCode.SESSION_NOT_FOUND]: {},
},
description: 'Get session-level warnings (e.g. oversized AGENTS.md)',
tags: ['sessions'],
},
async (req, reply) => {
const { session_id } = req.params;
const summary = await core.accessor.get(ISessionIndex).get(session_id);
if (summary === undefined) {
reply.send(
errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id),
);
return;
}
// No warning sources are ported to v2 yet (the v1 `agents-md-oversized`
// detection lives in `prepareSystemPromptContext`, which is not wired
// here). Return an empty list — within v1's own observable behaviour,
// since it falls back to `[]` whenever the underlying computation throws.
reply.send(okEnvelope({ warnings: [] }, req.id));
},
);
app.get(
sessionWarningsRoute.path,
sessionWarningsRoute.options,
sessionWarningsRoute.handler as Parameters<SessionRouteHost['get']>[2],
);
}
// ---------------------------------------------------------------------------

View file

@ -210,7 +210,103 @@ describe('server-v2 /api/v1/sessions', () => {
it('rejects an unsupported action suffix (40001)', async () => {
const cwd = home as string;
const created = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
const { body } = await postJson<null>(`/api/v1/sessions/${created.body.data.id}:fork`);
const { body } = await postJson<null>(`/api/v1/sessions/${created.body.data.id}:restart`);
expect(body.code).toBe(40001);
});
it('creates a child session tagged with parent_session_id and child_session_kind', async () => {
const cwd = home as string;
const parent = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
expect(parent.body.code).toBe(0);
const parentId = parent.body.data.id;
const child = await postJson<SessionWire>(`/api/v1/sessions/${parentId}/children`, {
title: 'child-title',
metadata: { branch: 'direct-child' },
});
expect(child.status).toBe(200);
expect(child.body.code).toBe(0);
expect(child.body.data.id).not.toBe(parentId);
expect(child.body.data.title).toBe('child-title');
expect(child.body.data.metadata['parent_session_id']).toBe(parentId);
expect(child.body.data.metadata['child_session_kind']).toBe('child');
// caller-supplied metadata is preserved alongside the markers, and cwd wins.
expect(child.body.data.metadata['branch']).toBe('direct-child');
expect(child.body.data.metadata.cwd).toBe(cwd);
});
it('defaults the child title to "Child: <parent title>"', async () => {
const cwd = home as string;
const parent = await postJson<SessionWire>('/api/v1/sessions', {
title: 'parent-title',
metadata: { cwd },
});
const child = await postJson<SessionWire>(
`/api/v1/sessions/${parent.body.data.id}/children`,
{},
);
expect(child.body.code).toBe(0);
expect(child.body.data.title).toBe('Child: parent-title');
});
it('lists direct children and omits grandchildren', async () => {
const cwd = home as string;
const parent = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
const parentId = parent.body.data.id;
const child = await postJson<SessionWire>(`/api/v1/sessions/${parentId}/children`, {
metadata: { branch: 'child' },
});
const childId = child.body.data.id;
const grandchild = await postJson<SessionWire>(`/api/v1/sessions/${childId}/children`, {
metadata: { branch: 'grandchild' },
});
const grandchildId = grandchild.body.data.id;
const parentChildren = await getJson<PageWire>(`/api/v1/sessions/${parentId}/children`);
expect(parentChildren.body.code).toBe(0);
expect(parentChildren.body.data.items.some((s) => s.id === childId)).toBe(true);
expect(parentChildren.body.data.items.some((s) => s.id === grandchildId)).toBe(false);
const childChildren = await getJson<PageWire>(`/api/v1/sessions/${childId}/children`);
expect(childChildren.body.code).toBe(0);
expect(childChildren.body.data.items.some((s) => s.id === grandchildId)).toBe(true);
});
it('does not list a plain fork as a child (kind must be "child")', async () => {
const cwd = home as string;
const parent = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
const parentId = parent.body.data.id;
const forked = await postJson<SessionWire>(`/api/v1/sessions/${parentId}:fork`, {});
expect(forked.body.code).toBe(0);
const children = await getJson<PageWire>(`/api/v1/sessions/${parentId}/children`);
expect(children.body.code).toBe(0);
expect(children.body.data.items.some((s) => s.id === forked.body.data.id)).toBe(false);
});
it('returns 40401 when listing children of a missing parent', async () => {
const { body } = await getJson<null>('/api/v1/sessions/sess_missing_parent/children');
expect(body.code).toBe(40401);
});
it('returns 40401 when creating a child for a missing parent', async () => {
const { body } = await postJson<null>('/api/v1/sessions/sess_missing_parent/children', {});
expect(body.code).toBe(40401);
});
it('returns an empty warnings list for an existing session', async () => {
const cwd = home as string;
const created = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
const { status, body } = await getJson<{ warnings: unknown[] }>(
`/api/v1/sessions/${created.body.data.id}/warnings`,
);
expect(status).toBe(200);
expect(body.code).toBe(0);
expect(body.data).toEqual({ warnings: [] });
});
it('returns 40401 for warnings of a missing session', async () => {
const { body } = await getJson<null>('/api/v1/sessions/sess_missing_warnings/warnings');
expect(body.code).toBe(40401);
});
});