diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts index cd83a6969..2feb2605b 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts @@ -1,25 +1,26 @@ /** * `sessionLegacy` domain (L7 edge adapter) — v1-compatible session actions. * - * Implements the legacy `/api/v1/sessions/{tail}` action contract (`fork` / - * `compact` / `undo` / `abort` / `btw`), the `/sessions/{id}/children` - * endpoints (`createChild` / `listChildren`), and `POST /sessions/{id}/profile` + * Implements the legacy `undo` action, the `/sessions/{id}/children` endpoints + * (`createChild` / `listChildren`), and `POST /sessions/{id}/profile` * (`updateProfile` — title rename, metadata merge, and the cross-domain * `agent_config` patch) on top of the native v2 services - * (`ISessionLifecycleService`, `ISessionIndex`, `IAgentRPCService`, - * `IAgentFullCompactionService`, `IAgentPromptService`, …). 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 App scope — it is a - * stateless dispatcher that resolves the target session/agent per call. + * (`ISessionLifecycleService`, `ISessionIndex`, `IAgentPromptService`, …). + * + * The thin pass-through actions (`fork` / `compact` / `abort` / `archive`) are + * deliberately NOT wrapped here: the edge route calls the native services + * (`ISessionLifecycleService.fork` / `archive`, `IAgentFullCompactionService.begin`, + * `IAgentRPCService.cancel`) directly, because none of them carries v1-only + * projection worth centralizing. Only the actions above hold real adaptation + * logic (wire projection, child tagging, undo precheck), so they stay in this + * adapter. 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 App scope — it is a stateless dispatcher that resolves + * the target session/agent per call. */ import type { - ArchiveSessionResponse, - CompactSessionRequest, - CompactSessionResponse, CreateSessionChildRequest, - ForkSessionRequest, - SessionAbortResponse, SessionStatus, SessionStatusResponse, UndoSessionRequest, @@ -64,13 +65,9 @@ export interface ISessionLegacyService { readonly _serviceBrand: undefined; updateProfile(sessionId: string, body: UpdateSessionProfileRequest): Promise; - 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; - archive(sessionId: string): Promise; status(sessionId: string): Promise; } diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts index f8f2a6556..b5ff905b1 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts @@ -18,7 +18,6 @@ import { toProtocolMessage } from '#/agent/contextMemory/messageProjection'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentContextSizeService } from '#/agent/contextSize/contextSize'; import { ErrorCodes, isKimiError, KimiError } from '#/errors'; -import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; import { IAgentGoalService } from '#/agent/goal/goal'; import { IModelResolver } from '#/app/model/modelResolver'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; @@ -26,7 +25,7 @@ import type { PermissionMode } from '#/agent/permissionPolicy/types'; import { IAgentPlanService } from '#/agent/plan/plan'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentPromptService } from '#/agent/prompt/prompt'; -import { IAgentRPCService } from '#/agent/rpc/rpc'; +import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord'; import { ISessionActivity } from '#/session/sessionActivity/sessionActivity'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; @@ -35,12 +34,7 @@ import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { IAgentSwarmService } from '#/agent/swarm/swarm'; import { IWorkspaceRegistry } from '#/app/workspaceRegistry/workspaceRegistry'; import type { - ArchiveSessionResponse, - CompactSessionRequest, - CompactSessionResponse, CreateSessionChildRequest, - ForkSessionRequest, - SessionAbortResponse, SessionStatusResponse, UndoSessionRequest, UndoSessionResponse, @@ -125,27 +119,6 @@ export class SessionLegacyService implements ISessionLegacyService { }; } - async fork(sessionId: string, body: ForkSessionRequest): Promise { - const handle = await this.lifecycle.fork({ - sourceSessionId: sessionId, - title: body.title, - metadata: body.metadata as Record | undefined, - }); - const meta = await handle.accessor.get(ISessionMetadata).read(); - const ctx = handle.accessor.get(ISessionContext); - return { - id: meta.id, - workspaceId: ctx.workspaceId, - root: ctx.cwd, - title: meta.title, - lastPrompt: meta.lastPrompt, - createdAt: meta.createdAt, - updatedAt: meta.updatedAt, - archived: meta.archived, - custom: meta.custom, - }; - } - async createChild(sessionId: string, body: CreateSessionChildRequest): Promise { const parentTitle = await this.resolveParentTitle(sessionId); const handle = await this.lifecycle.fork({ @@ -219,25 +192,29 @@ export class SessionLegacyService implements ISessionLegacyService { 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); - // `begin` returns false when busy / over the per-turn limit — v1 treats - // that as a silent success. It throws `compaction.unable` when there is no - // compactable prefix, which we let propagate. - agent.accessor.get(IAgentFullCompactionService).begin({ source: 'manual', instruction }); - return {}; - } - async undo(sessionId: string, body: UndoSessionRequest): Promise { const agent = await this.resolveMainAgent(sessionId); const context = agent.accessor.get(IAgentContextMemoryService); const before = context.get(); const { count } = body; - if (!canUndoHistory(before, count)) { + const precheck = precheckUndo(before, count); + if (!precheck.ok) { + const wireRecord = agent.accessor.get(IAgentWireRecordService); + const records = wireRecord.getRecords(); + const contextRecordTypes = records + .filter((r) => r.type.startsWith('context.')) + .map((r) => r.type); throw new KimiError( ErrorCodes.SESSION_UNDO_UNAVAILABLE, - `Nothing to undo in session ${sessionId}`, + undoUnavailableMessage(sessionId, precheck), + { + details: { + historyLength: before.length, + historyRoles: before.map((m) => `${m.role}:${m.origin?.kind ?? 'none'}`), + wireRecordCount: records.length, + contextRecordTypes, + }, + }, ); } try { @@ -262,28 +239,6 @@ export class SessionLegacyService implements ISessionLegacyService { }; } - async abort(sessionId: string): Promise { - const agent = await this.resolveMainAgent(sessionId); - // No turnId → cancel whatever turn is active; a safe no-op when idle. - await agent.accessor.get(IAgentRPCService).cancel({}); - // v1 always reports success once the session exists. - return { aborted: true }; - } - - async archive(sessionId: string): Promise { - // Native `ISessionLifecycleService.archive` is a no-op for sessions that - // are not live, so gate on the live handle (matches the previous route - // behaviour): a missing live session is reported as `session.not_found`. - // `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, which is reported as `session.not_found`. - if ((await this.lifecycle.resume(sessionId)) === undefined) { - throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); - } - await this.lifecycle.archive(sessionId); - return { archived: true }; - } - // --- internals ------------------------------------------------------------- /** @@ -438,12 +393,6 @@ export class SessionLegacyService implements ISessionLegacyService { } } -function normalizeOptional(value: string | undefined): string | undefined { - if (value === undefined) return undefined; - const trimmed = value.trim(); - return trimmed.length === 0 ? undefined : trimmed; -} - /** * Resolve the configured default model's context window for the status line * when the main agent has no model bound yet (fresh session before the first @@ -484,23 +433,56 @@ function pageContextMessages( }; } +type UndoPrecheck = + | { readonly ok: true } + | { + readonly ok: false; + readonly reason: 'empty' | 'compaction_boundary' | 'insufficient'; + readonly requested: number; + readonly undoable: number; + }; + /** - * v1 `canUndoHistory`: scan from the end, skipping injections, stopping at a - * compaction summary, and counting real user prompts until `count` is met. + * v1 `canUndoHistory`, returning a structured reason instead of a bare boolean + * so the wire error can say *why* an undo is unavailable. Scan from the end, + * skipping injections, stopping at a compaction summary, and counting real user + * prompts until `count` is met. `reason` is `empty` when no real user prompt + * exists, `insufficient` when some exist but fewer than `count`, and + * `compaction_boundary` when the scan hits a compaction summary first. */ -function canUndoHistory(history: readonly ContextMessage[], count: number): boolean { +export function precheckUndo(history: readonly ContextMessage[], count: number): UndoPrecheck { let remaining = count; + let undoable = 0; for (let i = history.length - 1; i >= 0; i--) { const message = history[i]!; const originKind = message.origin?.kind; if (originKind === 'injection') continue; - if (originKind === 'compaction_summary') return false; + if (originKind === 'compaction_summary') { + return { ok: false, reason: 'compaction_boundary', requested: count, undoable }; + } if (isRealUserPrompt(message)) { remaining -= 1; - if (remaining === 0) return true; + undoable += 1; + if (remaining === 0) return { ok: true }; } } - return false; + return undoable === 0 + ? { ok: false, reason: 'empty', requested: count, undoable: 0 } + : { ok: false, reason: 'insufficient', requested: count, undoable }; +} + +function undoUnavailableMessage( + sessionId: string, + precheck: Extract, +): string { + switch (precheck.reason) { + case 'empty': + return `Nothing to undo: no user message to undo in session ${sessionId}`; + case 'compaction_boundary': + return `Nothing to undo: would cross a compaction boundary in session ${sessionId}`; + case 'insufficient': + return `Nothing to undo: only ${precheck.undoable} of ${precheck.requested} requested turn(s) available in session ${sessionId}`; + } } function isRealUserPrompt(message: ContextMessage): boolean { diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index 2a6b8eac5..ae01b6934 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -15,10 +15,15 @@ * GET /sessions/{session_id}/status best-effort * GET /sessions/{session_id}/warnings agents-md-oversized notice * - * The `POST /sessions/{tail}` actions (`fork` / `compact` / `undo` / `abort` / - * `btw` / `archive`) and the `/sessions/{id}/children` endpoints are dispatched - * to `ISessionLegacyService` (a v1 edge adapter over the native v2 services); - * the route forwards each adapter result verbatim, mirroring v1's thin handler. + * 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`, + * `IAgentFullCompactionService.begin`, `IAgentRPCService.cancel`); there is no + * v1-only projection to centralize, so no adapter is involved. The actions that + * DO carry v1 adaptation logic — `undo`, the `/sessions/{id}/children` + * endpoints, and `POST /sessions/{id}/profile` (`updateProfile`) — go through + * `ISessionLegacyService` (a v1 edge adapter over the native services); the + * route forwards each adapter result verbatim, mirroring v1's thin handler. * `create`, `fork`, and child creation publish `event.session.created` on the * core event bus, matching v1. * @@ -58,9 +63,12 @@ import { ErrorCodes, IAgentProfileService, + IAgentFullCompactionService, + IAgentRPCService, IAuthSummaryService, ISessionBtwService, ISessionActivity, + ISessionContext, ISessionIndex, ISessionLifecycleService, ISessionMetadata, @@ -69,6 +77,7 @@ import { IWorkspaceRegistry, isKimiError, KimiError, + type IAgentScopeHandle, type Scope, } from '@moonshot-ai/agent-core-v2'; import { @@ -589,8 +598,20 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void if (parsed.action === 'fork') { const body = forkSessionRequestSchema.parse(req.body); - const fields = await legacy.fork(parsed.id, body); - const session = toWireSession(fields, fields.root, resolveSessionStatus(core, fields.id)); + // `lifecycle.fork` throws `session.not_found` for an unknown source, + // so no explicit existence check is needed here. + const handle = await core.accessor.get(ISessionLifecycleService).fork({ + sourceSessionId: parsed.id, + title: body.title, + metadata: body.metadata, + }); + const meta = await handle.accessor.get(ISessionMetadata).read(); + const ctx = handle.accessor.get(ISessionContext); + const session = toWireSession( + { ...meta, workspaceId: ctx.workspaceId }, + ctx.cwd, + resolveSessionStatus(core, meta.id), + ); core.accessor.get(IEventService).publish({ type: 'event.session.created', payload: { agentId: 'main', sessionId: session.id, session }, @@ -601,8 +622,14 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void if (parsed.action === 'compact') { const body = compactSessionRequestSchema.parse(req.body); - const result = await legacy.compact(parsed.id, body); - reply.send(okEnvelope(result, req.id)); + const agent = await resolveMainAgent(core, parsed.id); + // `begin` returns false when busy / over the per-turn limit — v1 + // treats that as a silent success. It throws `compaction.unable` + // when there is no compactable prefix, which propagates. + agent.accessor + .get(IAgentFullCompactionService) + .begin({ source: 'manual', instruction: normalizeOptional(body.instruction) }); + reply.send(okEnvelope({}, req.id)); return; } @@ -614,8 +641,11 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void } if (parsed.action === 'abort') { - const result = await legacy.abort(parsed.id); - reply.send(okEnvelope(result, req.id)); + const agent = await resolveMainAgent(core, parsed.id); + // No turnId → cancel whatever turn is active; a safe no-op when idle. + // v1 always reports success once the session exists. + await agent.accessor.get(IAgentRPCService).cancel({}); + reply.send(okEnvelope({ aborted: true }, req.id)); return; } @@ -635,9 +665,15 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void return; } - // archive - const result = await legacy.archive(parsed.id); - reply.send(okEnvelope(result, req.id)); + // 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`. + const archived = await core.accessor.get(ISessionLifecycleService).resume(parsed.id); + if (archived === undefined) { + throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${parsed.id} does not exist`); + } + await core.accessor.get(ISessionLifecycleService).archive(parsed.id); + reply.send(okEnvelope({ archived: true }, req.id)); } catch (error) { sendMappedError(reply, req.id, error); } @@ -864,6 +900,28 @@ function resolveSessionStatus(core: Scope, sessionId: string): SessionStatus { return handle.accessor.get(ISessionActivity).status(); } +/** + * Resume the session (cold-load if needed) and resolve its main agent, throwing + * `session.not_found` when the session is unknown or its workspace is gone. + * Shared by the `compact` / `abort` actions, which both operate on the main + * agent but carry no v1-specific projection worth keeping in + * `ISessionLegacyService`. + */ +async function resolveMainAgent(core: Scope, sessionId: string): Promise { + const session = await core.accessor.get(ISessionLifecycleService).resume(sessionId); + if (session === undefined) { + throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); + } + return ensureMainAgent(session); +} + +/** Trim a compaction instruction; treat an empty/blank value as absent. */ +function normalizeOptional(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const trimmed = value.trim(); + return trimmed.length === 0 ? undefined : trimmed; +} + /** * Build the wire `Session.metadata`: caller-supplied custom fields (minus the * reserved `goal` key, matching v1's `toProtocolSession`) overlaid with the