mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
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:
parent
78fa333c98
commit
c43177019c
7 changed files with 405 additions and 14 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue