From c43177019c298dc86ba4c487aca042fdc1dd5167 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 30 Jun 2026 18:31:42 +0800 Subject: [PATCH] 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 --- .../scripts/check-domain-layers.mjs | 2 + .../src/session-index/sessionIndex.ts | 8 + .../src/session-index/sessionIndexService.ts | 6 + .../src/sessionLegacy/sessionLegacy.ts | 30 +++- .../src/sessionLegacy/sessionLegacyService.ts | 124 +++++++++++++- packages/server-v2/src/routes/sessions.ts | 151 +++++++++++++++++- packages/server-v2/test/sessions.test.ts | 98 +++++++++++- 7 files changed, 405 insertions(+), 14 deletions(-) diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index dd9128953..97cb8a9dd 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -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'; diff --git a/packages/agent-core-v2/src/session-index/sessionIndex.ts b/packages/agent-core-v2/src/session-index/sessionIndex.ts index df5f7fb39..c60c2b9c5 100644 --- a/packages/agent-core-v2/src/session-index/sessionIndex.ts +++ b/packages/agent-core-v2/src/session-index/sessionIndex.ts @@ -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; } export interface SessionListQuery { diff --git a/packages/agent-core-v2/src/session-index/sessionIndexService.ts b/packages/agent-core-v2/src/session-index/sessionIndexService.ts index 6e73a5c7a..8a58f3a9e 100644 --- a/packages/agent-core-v2/src/session-index/sessionIndexService.ts +++ b/packages/agent-core-v2/src/session-index/sessionIndexService.ts @@ -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) + : 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, }; } diff --git a/packages/agent-core-v2/src/sessionLegacy/sessionLegacy.ts b/packages/agent-core-v2/src/sessionLegacy/sessionLegacy.ts index 50754391d..2d344df2e 100644 --- a/packages/agent-core-v2/src/sessionLegacy/sessionLegacy.ts +++ b/packages/agent-core-v2/src/sessionLegacy/sessionLegacy.ts @@ -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`. */ +export interface SessionChildrenPage { + readonly items: readonly SessionWireFields[]; + readonly has_more: boolean; +} + export interface ISessionLegacyService { readonly _serviceBrand: undefined; fork(sessionId: string, body: ForkSessionRequest): Promise; + createChild(sessionId: string, body: CreateSessionChildRequest): Promise; + listChildren(sessionId: string, query: SessionChildrenQuery): Promise; compact(sessionId: string, body: CompactSessionRequest): Promise; undo(sessionId: string, body: UndoSessionRequest): Promise; abort(sessionId: string): Promise; diff --git a/packages/agent-core-v2/src/sessionLegacy/sessionLegacyService.ts b/packages/agent-core-v2/src/sessionLegacy/sessionLegacyService.ts index 619cc5c29..c16a394e8 100644 --- a/packages/agent-core-v2/src/sessionLegacy/sessionLegacyService.ts +++ b/packages/agent-core-v2/src/sessionLegacy/sessionLegacyService.ts @@ -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 { + 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 { + 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 { 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: ` 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). diff --git a/packages/server-v2/src/routes/sessions.ts b/packages/server-v2/src/routes/sessions.ts index 63b82aede..0206a4cd0 100644 --- a/packages/server-v2/src/routes/sessions.ts +++ b/packages/server-v2/src/routes/sessions.ts @@ -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], + ); } // --------------------------------------------------------------------------- diff --git a/packages/server-v2/test/sessions.test.ts b/packages/server-v2/test/sessions.test.ts index 7e432daf2..e6cae2df2 100644 --- a/packages/server-v2/test/sessions.test.ts +++ b/packages/server-v2/test/sessions.test.ts @@ -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); + }); });