feat(server-v2): port v1 /sessions/{tail} action routes

- implement SessionLifecycleService.fork (active-turn guard, per-agent
  wire-log copy, metadata rewrite, forked marker, closed-session fork)
- add SessionLegacyService edge adapter for compact/undo/abort/btw over
  the native v2 services
- persist agent transcripts via per-agent homedir and extend SessionMeta
  (isCustomTitle/lastPrompt/agents/custom) for fork parity
- add aborted session status and IWireRecord.getRecords()
- register session.undo_unavailable error code
- dispatch all six /sessions/{tail} actions with v1 error mapping and
  emit session.created on create/fork
This commit is contained in:
haozhe.yang 2026-06-30 17:24:04 +08:00
parent ce0e4f2589
commit d210009877
23 changed files with 1032 additions and 212 deletions

View file

@ -6,15 +6,22 @@
* with the session.
*/
import { join } from 'pathe';
import { InstantiationType } from '#/_base/di/extensions';
import { IInstantiationService } from '#/_base/di/instantiation';
import { Disposable } from '#/_base/di/lifecycle';
import { Emitter } from '#/_base/event';
import { SyncDescriptor } from '#/_base/di/descriptors';
import {
createScopedChildHandle,
type IScopeHandle,
LifecycleScope,
registerScopedService,
} from '#/_base/di/scope';
import { ISessionContext } from '#/session-context';
import { ISessionMetadata } from '#/session-metadata';
import { IWireRecord, WireRecordService } from '#/wireRecord';
import { type CreateAgentOptions, IAgentLifecycleService } from './agentLifecycle';
@ -23,20 +30,43 @@ let nextAgentId = 0;
export class AgentLifecycleService extends Disposable implements IAgentLifecycleService {
declare readonly _serviceBrand: undefined;
private readonly handles = new Map<string, IScopeHandle>();
private readonly onDidCreateEmitter = this._register(new Emitter<IScopeHandle>());
private readonly onDidDisposeEmitter = this._register(new Emitter<string>());
constructor(@IInstantiationService private readonly instantiation: IInstantiationService) {
get onDidCreate() {
return this.onDidCreateEmitter.event;
}
get onDidDispose() {
return this.onDidDisposeEmitter.event;
}
constructor(
@IInstantiationService private readonly instantiation: IInstantiationService,
@ISessionContext private readonly ctx: ISessionContext,
@ISessionMetadata private readonly sessionMetadata: ISessionMetadata,
) {
super();
}
create(opts: CreateAgentOptions): Promise<IScopeHandle> {
async create(opts: CreateAgentOptions): Promise<IScopeHandle> {
const agentId = opts.agentId ?? `agent-${nextAgentId++}`;
// Per-agent homedir → the wire-record persistence key (`hashKey(homedir)`).
// Co-located under the session dir, mirroring v1's `<sessionDir>/agents/<id>`.
const agentHomedir = join(this.ctx.sessionDir, 'agents', agentId);
const handle = createScopedChildHandle(
this.instantiation,
LifecycleScope.Agent,
agentId,
{
extra: [[IWireRecord, new SyncDescriptor(WireRecordService, [{ homedir: agentHomedir }])]],
},
);
this.handles.set(agentId, handle);
return Promise.resolve(handle);
// Record the agent in the session registry so a closed-session fork can
// enumerate every agent and relocate its wire log.
await this.sessionMetadata.registerAgent(agentId, { homedir: agentHomedir });
this.onDidCreateEmitter.fire(handle);
return handle;
}
createMain(): Promise<IScopeHandle> {
@ -56,6 +86,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
if (handle === undefined) return Promise.resolve();
this.handles.delete(agentId);
handle.dispose();
this.onDidDisposeEmitter.fire(agentId);
return Promise.resolve();
}
}

View file

@ -39,6 +39,7 @@ import './cron/index';
export * from './agent-lifecycle/index';
export * from './session-lifecycle/index';
export * from './sessionLegacy/index';
export * from './interaction/index';
export * from './session-context/index';
export * from './session-activity/index';

View file

@ -10,7 +10,7 @@
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export type SessionStatus = 'running' | 'idle' | 'awaiting_approval' | 'awaiting_question';
export type SessionStatus = 'running' | 'idle' | 'awaiting_approval' | 'awaiting_question' | 'aborted';
export interface ISessionActivity {
readonly _serviceBrand: undefined;

View file

@ -27,6 +27,7 @@ export class SessionActivity implements ISessionActivity {
if (this.interaction.listPending('approval').length > 0) return 'awaiting_approval';
if (this.interaction.listPending('question').length > 0) return 'awaiting_question';
if (this.hasActiveTurn()) return 'running';
if (this.hasAbortedTurn()) return 'aborted';
return 'idle';
}
@ -41,6 +42,16 @@ export class SessionActivity implements ISessionActivity {
}
return false;
}
private hasAbortedTurn(): boolean {
for (const handle of this.agents.list()) {
const reason = handle.accessor.get(ITurnService).lastEndedReason();
if (reason === 'cancelled' || reason === 'failed' || reason === 'filtered') {
return true;
}
}
return false;
}
}
registerScopedService(LifecycleScope.Session, ISessionActivity, SessionActivity, InstantiationType.Delayed, 'session-activity');

View file

@ -17,6 +17,7 @@ export interface SessionSummary {
readonly id: string;
readonly workspaceId: string;
readonly title?: string;
readonly lastPrompt?: string;
readonly createdAt: number;
readonly updatedAt: number;
readonly archived: boolean;

View file

@ -3,12 +3,19 @@
*
* Reads the persisted session set through the `storage` access-pattern stores,
* rooted at the `sessionsDir` path layout fact from `bootstrap`. The directory
* tree `<sessionsDir>/<workspaceId>/<sessionId>/session-meta/state.json` is the
* index: workspace and session ids are enumerated via `IStorageService.list`,
* and each session's `state.json` is read via `IAtomicDocumentStore` to build
* its summary. This is the local-deployment backend of `ISessionIndex`; a
* server deployment would substitute a database-backed `DbSessionIndex`. Bound
* at Core scope.
* tree `<sessionsDir>/<workspaceId>/<sessionId>/` is the index: workspace and
* session ids are enumerated via `IStorageService.list`, and each session's
* metadata document is read via `IAtomicDocumentStore` to build its summary.
*
* The session metadata document lives at `<sessionDir>/state.json`, a layout
* shared by v1 and v2; the `version` field distinguishes them (`2` = v2,
* epoch-ms timestamps; absent = v1, ISO-string timestamps). The reader also
* falls back to the legacy `<sessionDir>/session-meta/state.json` path for v2
* sessions written before the layouts were unified. Both timestamp
* representations are normalized to epoch ms.
*
* This is the local-deployment backend of `ISessionIndex`; a server deployment
* would substitute a database-backed `DbSessionIndex`. Bound at Core scope.
*/
import { relative } from 'pathe';
@ -23,6 +30,16 @@ import { ISessionIndex, type SessionListQuery, type SessionSummary } from './ses
const META_SCOPE = 'session-meta';
const META_KEY = 'state.json';
/** Accept both v2 (epoch ms number) and v1 (ISO string) timestamps. */
function parseTime(value: unknown): number {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string') {
const parsed = Date.parse(value);
if (!Number.isNaN(parsed)) return parsed;
}
return 0;
}
export class FileSessionIndex implements ISessionIndex {
declare readonly _serviceBrand: undefined;
@ -95,23 +112,32 @@ export class FileSessionIndex implements ISessionIndex {
workspaceId: string,
sessionId: string,
): Promise<SessionSummary | undefined> {
const scope = `${this.sessionsScope}/${workspaceId}/${sessionId}/${META_SCOPE}`;
let meta: Record<string, unknown> | undefined;
try {
meta = await this.docs.get<Record<string, unknown>>(scope, META_KEY);
} catch {
return undefined;
}
const base = `${this.sessionsScope}/${workspaceId}/${sessionId}`;
// `<sessionDir>/state.json` is the unified metadata document: v2 (tagged
// `version: 2`) and v1 (no version) both write here. Fall back to the
// legacy v2 `session-meta/` subdir for sessions written before the layouts
// were unified.
const meta =
(await this.readMeta(base)) ?? (await this.readMeta(`${base}/${META_SCOPE}`));
if (meta === undefined) return undefined;
return {
id: sessionId,
workspaceId,
title: typeof meta['title'] === 'string' ? meta['title'] : undefined,
createdAt: typeof meta['createdAt'] === 'number' ? meta['createdAt'] : 0,
updatedAt: typeof meta['updatedAt'] === 'number' ? meta['updatedAt'] : 0,
lastPrompt: typeof meta['lastPrompt'] === 'string' ? meta['lastPrompt'] : undefined,
createdAt: parseTime(meta['createdAt']),
updatedAt: parseTime(meta['updatedAt']),
archived: meta['archived'] === true,
};
}
private async readMeta(scope: string): Promise<Record<string, unknown> | undefined> {
try {
return await this.docs.get<Record<string, unknown>>(scope, META_KEY);
} catch {
return undefined;
}
}
}
registerScopedService(

View file

@ -21,6 +21,10 @@ export interface CreateSessionOptions {
export interface ForkSessionOptions {
readonly sourceSessionId: string;
readonly newSessionId?: string;
/** Title for the forked session. Defaults to `Fork: <source title or id>`. */
readonly title?: string;
/** Custom metadata merged (minus reserved `goal`) into the forked session. */
readonly metadata?: Record<string, unknown>;
}
export interface ISessionLifecycleService {

View file

@ -9,6 +9,7 @@
*/
import { join, relative } from 'pathe';
import { randomUUID } from 'node:crypto';
import { InstantiationType } from '#/_base/di/extensions';
import { IInstantiationService } from '#/_base/di/instantiation';
@ -19,13 +20,24 @@ import {
registerScopedService,
} from '#/_base/di/scope';
import { encodeWorkDirKey } from '#/_base/utils/workdir-slug';
import { IAgentLifecycleService } from '#/agent-lifecycle';
import { IBootstrapService } from '#/bootstrap';
import { NotImplementedError } from '#/errors';
import { ErrorCodes, KimiError } from '#/errors';
import { IKaos, IKaosFactory } from '#/kaos';
import { ISessionActivity } from '#/session-activity';
import { ISessionIndex } from '#/session-index';
import { IAtomicDocumentStore, IAppendLogStore } from '#/storage';
import { IWorkspaceRegistry } from '#/workspaceRegistry';
import { ISessionService } from '#/session';
import { type ISessionContext, sessionContextSeed } from '#/session-context';
import { ISessionMetadata } from '#/session-metadata';
import { ISessionContext, sessionContextSeed } from '#/session-context';
import { ISessionMetadata, type SessionMeta } from '#/session-metadata';
import { ISkillCatalog } from '#/skill';
import {
AGENT_WIRE_PROTOCOL_VERSION,
IWireRecord,
wireRecordPersistKey,
type PersistedWireRecord,
} from '#/wireRecord';
import {
type CreateSessionOptions,
@ -41,12 +53,19 @@ export class SessionLifecycleService implements ISessionLifecycleService {
@IInstantiationService private readonly instantiation: IInstantiationService,
@IBootstrapService private readonly bootstrap: IBootstrapService,
@IKaosFactory private readonly kaosFactory: IKaosFactory,
@ISessionIndex private readonly index: ISessionIndex,
@IAppendLogStore private readonly appendLogStore: IAppendLogStore,
@IAtomicDocumentStore private readonly docs: IAtomicDocumentStore,
@IWorkspaceRegistry private readonly workspaceRegistry: IWorkspaceRegistry,
) {}
async create(opts: CreateSessionOptions): Promise<IScopeHandle> {
const workspaceId = encodeWorkDirKey(opts.workDir);
const sessionDir = join(this.bootstrap.sessionsDir, workspaceId, opts.sessionId);
const metaScope = join(relative(this.bootstrap.homeDir, sessionDir), 'session-meta');
// Metadata lives at `<sessionDir>/state.json` (shared with v1's layout; the
// v2 document is tagged with `version: 2`). `metaScope` is therefore the
// session directory itself, homeDir-relative.
const metaScope = relative(this.bootstrap.homeDir, sessionDir);
const ctx: ISessionContext = {
_serviceBrand: undefined,
sessionId: opts.sessionId,
@ -95,8 +114,136 @@ export class SessionLifecycleService implements ISessionLifecycleService {
handle.dispose();
}
fork(_opts: ForkSessionOptions): Promise<IScopeHandle> {
throw new NotImplementedError('SessionLifecycleService.fork');
async fork(opts: ForkSessionOptions): Promise<IScopeHandle> {
const sourceId = opts.sourceSessionId;
// 1. Resolve the source: prefer a live handle, otherwise fall back to the
// persisted index (so a closed session can still be forked, like v1).
const sourceHandle = this.sessions.get(sourceId);
const indexSummary = await this.index.get(sourceId);
if (sourceHandle === undefined && indexSummary === undefined) {
throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${sourceId} does not exist`);
}
const workspaceId =
sourceHandle !== undefined
? sourceHandle.accessor.get(ISessionContext).workspaceId
: indexSummary!.workspaceId;
// 2. Reject forking a live session with an active turn (v1 parity). Any
// phase other than idle/aborted implies a turn is still in progress
// (including turns paused on an approval/question).
if (sourceHandle !== undefined) {
const status = sourceHandle.accessor.get(ISessionActivity).status();
if (status !== 'idle' && status !== 'aborted') {
throw new KimiError(
ErrorCodes.SESSION_FORK_ACTIVE_TURN,
`Session "${sourceId}" cannot be forked while a turn is running`,
{ details: { sessionId: sourceId } },
);
}
}
// 3. Resolve the work dir the fork inherits (same workspace as the source).
const workspace = await this.workspaceRegistry.get(workspaceId);
if (workspace === undefined) {
throw new KimiError('workspace.not_found', `workspace ${workspaceId} does not exist`);
}
// 4. Read the source metadata (live handle or disk).
const sourceMeta =
sourceHandle !== undefined
? await sourceHandle.accessor.get(ISessionMetadata).read()
: await this.readMetaFromDisk(workspaceId, sourceId);
// 5. Mint the target id and reject collisions.
const targetId = opts.newSessionId ?? randomUUID();
if (this.sessions.has(targetId) || (await this.index.get(targetId)) !== undefined) {
throw new KimiError(ErrorCodes.SESSION_ALREADY_EXISTS, `Session "${targetId}" already exists`);
}
// 6. Materialize the target session scope (fresh metadata + storage).
const target = await this.create({ sessionId: targetId, workDir: workspace.root });
const targetCtx = target.accessor.get(ISessionContext);
const targetMeta = target.accessor.get(ISessionMetadata);
// 7. Copy every source agent's wire log into the target's per-agent log
// (BEFORE the target agents are created, so the logs are in place when
// their WireRecordService restores them in step 9).
const sourceAgents = sourceMeta?.agents ?? {};
const agentIds = Object.keys(sourceAgents);
for (const agentId of agentIds) {
const sourceHomedir = sourceAgents[agentId]!.homedir;
await this.copyAgentWire({
sourceHandle,
sourceHomedir,
agentId,
targetSessionDir: targetCtx.sessionDir,
});
}
// 8. Rewrite the target metadata to reflect fork provenance.
const title = opts.title ?? `Fork: ${sourceMeta?.title || sourceId}`;
await targetMeta.update({
title,
isCustomTitle: opts.title !== undefined ? true : sourceMeta?.isCustomTitle === true,
forkedFrom: sourceId,
archived: false,
custom: forkCustomMetadata(sourceMeta?.custom, opts.metadata),
});
// 9. Create the target agents (same ids) and restore each from its copied
// log. Creating them registers fresh agent entries with TARGET homedirs.
for (const agentId of agentIds) {
const agentHandle = await target.accessor.get(IAgentLifecycleService).create({ agentId });
await agentHandle.accessor.get(IWireRecord).restore();
}
return target;
}
/**
* Copy one agent's wire log from the source into the target session's
* per-agent log, appending a `forked` boundary record. Works for both live
* sources (flush then read) and closed sources (read the persisted log).
*/
private async copyAgentWire(args: {
readonly sourceHandle: IScopeHandle | undefined;
readonly sourceHomedir: string;
readonly agentId: string;
readonly targetSessionDir: string;
}): Promise<void> {
// Flush the live agent so its persisted log is current before reading.
if (args.sourceHandle !== undefined) {
const agentHandle = args.sourceHandle
.accessor.get(IAgentLifecycleService)
.getHandle(args.agentId);
if (agentHandle !== undefined) {
await agentHandle.accessor.get(IWireRecord).flush();
}
}
const sourceKey = wireRecordPersistKey(args.sourceHomedir);
const records = await collect(this.appendLogStore.read<PersistedWireRecord>('wire', sourceKey));
// Ensure the log starts with a metadata envelope (restore() requires it).
if (records.length === 0) {
records.push(freshMetadataRecord());
} else if (records[0]?.type !== 'metadata') {
records.unshift(freshMetadataRecord());
}
records.push(forkedRecord());
const targetHomedir = join(args.targetSessionDir, 'agents', args.agentId);
const targetKey = wireRecordPersistKey(targetHomedir);
await this.appendLogStore.rewrite('wire', targetKey, records);
}
private async readMetaFromDisk(
workspaceId: string,
sessionId: string,
): Promise<SessionMeta | undefined> {
const sessionsScope = relative(this.bootstrap.homeDir, this.bootstrap.sessionsDir);
const scope = `${sessionsScope}/${workspaceId}/${sessionId}`;
return this.docs.get<SessionMeta>(scope, 'state.json');
}
}
@ -107,3 +254,39 @@ registerScopedService(
InstantiationType.Delayed,
'session-lifecycle',
);
async function collect<T>(iterable: AsyncIterable<T>): Promise<T[]> {
const items: T[] = [];
for await (const item of iterable) items.push(item);
return items;
}
function freshMetadataRecord(): PersistedWireRecord {
return {
type: 'metadata',
protocol_version: AGENT_WIRE_PROTOCOL_VERSION,
created_at: Date.now(),
};
}
function forkedRecord(): PersistedWireRecord {
return { type: 'forked', time: Date.now() } as PersistedWireRecord;
}
/**
* Merge the source session's custom metadata with the caller-supplied metadata,
* dropping the reserved `goal` key from both (matches v1's `forkCustomMetadata`).
*/
function forkCustomMetadata(
source: Record<string, unknown> | undefined,
input: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
const merged = { ...withoutGoal(source), ...withoutGoal(input) };
return Object.keys(merged).length === 0 ? undefined : merged;
}
function withoutGoal(value: Record<string, unknown> | undefined): Record<string, unknown> {
if (value === undefined) return {};
const { goal: _drop, ...rest } = value as { goal?: unknown; [key: string]: unknown };
return rest;
}

View file

@ -12,13 +12,37 @@
import type { Event } from '#/_base/event';
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface AgentMeta {
/** Per-agent directory used as the wire-record `homedir` (persistence key). */
readonly homedir: string;
}
/**
* Metadata document schema version written by this build. Stored on each
* session's `state.json` so readers can tell which layout a document follows:
* `2` = written by v2 (epoch-ms timestamps); absent = legacy v1 (ISO-string
* timestamps). Both v1 and v2 write the document to `<sessionDir>/state.json`;
* the version field is what distinguishes them.
*/
export const SESSION_META_VERSION = 2;
export interface SessionMeta {
readonly id: string;
/** Metadata schema version — `2` for documents written by v2. */
readonly version?: number;
readonly title?: string;
/** True when the title was explicitly set by the user (rename), false/undefined for auto titles. */
readonly isCustomTitle?: boolean;
/** Last user prompt text, surfaced on the wire `Session.last_prompt`. */
readonly lastPrompt?: string;
readonly createdAt: number;
readonly updatedAt: number;
readonly archived: boolean;
readonly forkedFrom?: string;
/** Registry of agents belonging to this session, keyed by agent id. */
readonly agents?: Readonly<Record<string, AgentMeta>>;
/** Free-form custom metadata (wire `Session.metadata` minus reserved keys like `goal`). */
readonly custom?: Record<string, unknown>;
}
export type SessionMetaPatch = Partial<Omit<SessionMeta, 'id' | 'createdAt'>>;
@ -31,6 +55,8 @@ export interface ISessionMetadata {
update(patch: SessionMetaPatch): Promise<void>;
setTitle(title: string): Promise<void>;
setArchived(archived: boolean): Promise<void>;
/** Register (or replace) an agent entry in the session's agent registry. */
registerAgent(agentId: string, meta: AgentMeta): Promise<void>;
}
export const ISessionMetadata: ServiceIdentifier<ISessionMetadata> =

View file

@ -16,7 +16,13 @@ import { ILogService } from '#/log';
import { ISessionContext } from '#/session-context';
import { IAtomicDocumentStore } from '#/storage';
import { ISessionMetadata, type SessionMeta, type SessionMetaPatch } from './sessionMetadata';
import {
ISessionMetadata,
SESSION_META_VERSION,
type AgentMeta,
type SessionMeta,
type SessionMetaPatch,
} from './sessionMetadata';
const META_KEY = 'state.json';
@ -53,13 +59,19 @@ export class SessionMetadata extends Disposable implements ISessionMetadata {
}
async setTitle(title: string): Promise<void> {
await this.update({ title });
await this.update({ title, isCustomTitle: true });
}
async setArchived(archived: boolean): Promise<void> {
await this.update({ archived });
}
async registerAgent(agentId: string, meta: AgentMeta): Promise<void> {
await this.ready;
const agents = { ...(this.data.agents ?? {}), [agentId]: meta };
await this.update({ agents });
}
private async load(): Promise<void> {
const existing = await this.store.get<SessionMeta>(this.scope, META_KEY);
if (existing !== undefined) {
@ -67,7 +79,13 @@ export class SessionMetadata extends Disposable implements ISessionMetadata {
return;
}
const now = Date.now();
this.data = { id: this.ctx.sessionId, createdAt: now, updatedAt: now, archived: false };
this.data = {
id: this.ctx.sessionId,
version: SESSION_META_VERSION,
createdAt: now,
updatedAt: now,
archived: false,
};
await this.store.set(this.scope, META_KEY, this.data);
this.log.debug('session metadata created', { sessionId: this.ctx.sessionId });
}

View file

@ -11,6 +11,7 @@ export const SessionErrors = {
SESSION_ID_INVALID: 'session.id_invalid',
SESSION_CLOSED: 'session.closed',
SESSION_FORK_ACTIVE_TURN: 'session.fork_active_turn',
SESSION_UNDO_UNAVAILABLE: 'session.undo_unavailable',
},
retryable: ['session.fork_active_turn'],
} as const satisfies ErrorDomain;

View file

@ -0,0 +1,7 @@
/**
* `sessionLegacy` domain barrel re-exports the v1 session-action adapter
* contract and implementation.
*/
export * from './sessionLegacy';
export * from './sessionLegacyService';

View file

@ -0,0 +1,72 @@
/**
* `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.
*/
import type {
CompactSessionRequest,
CompactSessionResponse,
ForkSessionRequest,
SessionAbortResponse,
SessionStatus,
StartBtwSessionResponse,
UndoSessionRequest,
} from '@moonshot-ai/protocol';
import type { ContextMessage } from '#/contextMemory';
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
/**
* Raw fields the route projects into the wire `Session` (via `toWireSession`).
* Kept protocol-free so the edge projection stays in the server layer.
*/
export interface SessionWireFields {
readonly id: string;
readonly workspaceId: string;
/** Workspace root — used as `cwd` when projecting to the wire `Session`. */
readonly root: string;
readonly title?: string;
readonly lastPrompt?: string;
readonly createdAt: number;
readonly updatedAt: number;
readonly archived: boolean;
readonly custom?: Record<string, unknown>;
}
/** Plain-data mirror of the protocol `SessionStatusResponse`. */
export interface SessionStatusData {
readonly status: SessionStatus;
readonly model?: string;
readonly thinking_level: string;
readonly permission: string;
readonly plan_mode: boolean;
readonly swarm_mode: boolean;
readonly context_tokens: number;
readonly max_context_tokens: number;
readonly context_usage: number;
}
export interface UndoResult {
/** Post-undo context history; the route projects it into the message page. */
readonly history: readonly ContextMessage[];
readonly status: SessionStatusData;
}
export interface ISessionLegacyService {
readonly _serviceBrand: undefined;
fork(sessionId: string, body: ForkSessionRequest): Promise<SessionWireFields>;
compact(sessionId: string, body: CompactSessionRequest): Promise<CompactSessionResponse>;
undo(sessionId: string, body: UndoSessionRequest): Promise<UndoResult>;
abort(sessionId: string): Promise<SessionAbortResponse>;
startBtw(sessionId: string): Promise<StartBtwSessionResponse>;
}
export const ISessionLegacyService: ServiceIdentifier<ISessionLegacyService> =
createDecorator<ISessionLegacyService>('sessionLegacyService');

View file

@ -0,0 +1,220 @@
/**
* `sessionLegacy` domain `ISessionLegacyService` implementation.
*
* 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.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { type IScopeHandle, LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentLifecycleService } from '#/agent-lifecycle';
import { IAuthSummaryService } from '#/auth';
import { IContextMemory, type ContextMessage } from '#/contextMemory';
import { IContextSizeService } from '#/contextSize';
import { ErrorCodes, isKimiError, KimiError } from '#/errors';
import { IFullCompaction } from '#/fullCompaction';
import { IPermissionModeService } from '#/permissionMode';
import { IPlanService } from '#/plan';
import { IProfileService } from '#/profile';
import { IPromptService } from '#/prompt';
import { IAgentRPCService } from '#/rpc';
import { ISessionActivity } from '#/session-activity';
import { ISessionContext } from '#/session-context';
import { ISessionLifecycleService } from '#/session-lifecycle';
import { ISessionMetadata } from '#/session-metadata';
import { ISwarmService } from '#/swarm';
import { IWorkspaceRegistry } from '#/workspaceRegistry';
import type {
CompactSessionRequest,
CompactSessionResponse,
ForkSessionRequest,
SessionAbortResponse,
StartBtwSessionResponse,
UndoSessionRequest,
} from '@moonshot-ai/protocol';
import {
ISessionLegacyService,
type SessionStatusData,
type SessionWireFields,
type UndoResult,
} from './sessionLegacy';
const MAIN_AGENT_ID = 'main';
export class SessionLegacyService implements ISessionLegacyService {
declare readonly _serviceBrand: undefined;
constructor(
@ISessionLifecycleService private readonly lifecycle: ISessionLifecycleService,
@IWorkspaceRegistry private readonly workspaceRegistry: IWorkspaceRegistry,
@IAuthSummaryService private readonly auth: IAuthSummaryService,
) {}
async fork(sessionId: string, body: ForkSessionRequest): Promise<SessionWireFields> {
const handle = await this.lifecycle.fork({
sourceSessionId: sessionId,
title: body.title,
metadata: body.metadata as Record<string, unknown> | undefined,
});
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 compact(sessionId: string, body: CompactSessionRequest): Promise<CompactSessionResponse> {
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(IFullCompaction).begin({ source: 'manual', instruction });
return {};
}
async undo(sessionId: string, body: UndoSessionRequest): Promise<UndoResult> {
const agent = await this.resolveMainAgent(sessionId);
const context = agent.accessor.get(IContextMemory);
const before = context.get();
const { count } = body;
if (!canUndoHistory(before, count)) {
throw new KimiError(
ErrorCodes.SESSION_UNDO_UNAVAILABLE,
`Nothing to undo in session ${sessionId}`,
);
}
try {
agent.accessor.get(IPromptService).undo(count);
} catch (error) {
if (isKimiError(error) && error.code === ErrorCodes.REQUEST_INVALID) {
throw new KimiError(ErrorCodes.SESSION_UNDO_UNAVAILABLE, error.message);
}
throw error;
}
const history = context.get();
const status = await this.assembleStatus(sessionId, agent);
return { history, status };
}
async abort(sessionId: string): Promise<SessionAbortResponse> {
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 startBtw(sessionId: string): Promise<StartBtwSessionResponse> {
if (this.lifecycle.get(sessionId) === undefined) {
throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`);
}
await this.auth.ensureReady();
const agent = await this.resolveMainAgent(sessionId);
const agentId = await agent.accessor.get(IAgentRPCService).startBtw({});
return { agent_id: agentId };
}
// --- internals -------------------------------------------------------------
/**
* Resolve the session's main agent, creating it on demand (mirrors v1's
* `resumeSession` + the server-v2 `ensureMainAgent` helper).
*/
private async resolveMainAgent(sessionId: string): Promise<IScopeHandle> {
const session = this.lifecycle.get(sessionId);
if (session === undefined) {
throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`);
}
const agents = session.accessor.get(IAgentLifecycleService);
const existing = agents.getHandle(MAIN_AGENT_ID);
if (existing !== undefined) return existing;
return agents.createMain();
}
private async assembleStatus(sessionId: string, agent: IScopeHandle): Promise<SessionStatusData> {
const session = this.lifecycle.get(sessionId);
const profile = agent.accessor.get(IProfileService);
const contextSize = agent.accessor.get(IContextSizeService);
const permission = agent.accessor.get(IPermissionModeService);
const plan = agent.accessor.get(IPlanService);
const swarm = agent.accessor.get(ISwarmService);
const profileData = profile.data();
const model = profile.getModel();
const caps = profile.getModelCapabilities() as { max_context_tokens?: number };
const maxTokens = caps.max_context_tokens ?? 0;
const tokens = contextSize.getStatus().contextTokens;
const planData = await plan.status();
return {
status: session?.accessor.get(ISessionActivity).status() ?? 'idle',
model: model === '' ? undefined : model,
thinking_level: profileData.thinkingLevel,
permission: permission.mode,
plan_mode: planData !== null,
swarm_mode: swarm.isActive,
context_tokens: tokens,
max_context_tokens: maxTokens,
context_usage: maxTokens > 0 ? tokens / maxTokens : 0,
};
}
}
function normalizeOptional(value: string | undefined): string | undefined {
if (value === undefined) return undefined;
const trimmed = value.trim();
return trimmed.length === 0 ? undefined : trimmed;
}
/**
* v1 `canUndoHistory`: scan from the end, skipping injections, stopping at a
* compaction summary, and counting real user prompts until `count` is met.
*/
function canUndoHistory(history: readonly ContextMessage[], count: number): boolean {
let remaining = count;
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 (isRealUserPrompt(message)) {
remaining -= 1;
if (remaining === 0) return true;
}
}
return false;
}
function isRealUserPrompt(message: ContextMessage): boolean {
if (message.role !== 'user') return false;
const origin = message.origin;
if (origin === undefined || origin.kind === 'user') return true;
if (
origin.kind === 'skill_activation' &&
(origin as { trigger?: string }).trigger === 'user-slash'
) {
return true;
}
return false;
}
registerScopedService(
LifecycleScope.Core,
ISessionLegacyService,
SessionLegacyService,
InstantiationType.Delayed,
'sessionLegacy',
);

View file

@ -41,6 +41,12 @@ export interface ITurnService {
readonly _serviceBrand: undefined;
launch(origin: PromptOrigin): Turn;
getActiveTurn(): Turn | undefined;
/**
* Reason the most recently finished turn ended with, or `undefined` when no
* turn has ended yet (or after a new turn launches). Used by session-activity
* to surface an `aborted` session status, mirroring v1's `_abortedTurns`.
*/
lastEndedReason(): TurnResult['reason'] | undefined;
readonly hooks: Hooks<{
onLaunched: { turn: Turn };

View file

@ -32,6 +32,7 @@ export class TurnService implements ITurnService {
declare readonly _serviceBrand: undefined;
private nextTurnId = 0;
private activeTurn: Turn | undefined;
private lastEndedReasonValue: TurnResult['reason'] | undefined;
private readonly readyControllers = new WeakMap<Turn, ControlledPromise<void>>();
private readonly readySettled = new WeakSet<Turn>();
private readonly interruptedTelemetryTurnIds = new Set<number>();
@ -79,6 +80,10 @@ export class TurnService implements ITurnService {
throw new Error(`Cannot launch a new turn while turn ${this.activeTurn.id} is active`);
}
// A new turn clears the previous `aborted`/`failed` memory (mirrors v1
// clearing `_abortedTurns` on `turn.started` / `prompt.submitted`).
this.lastEndedReasonValue = undefined;
const turnId = this.nextTurnId;
this.wireRecord.append({ type: 'turn.launch', turnId, origin });
this.restoreLaunch(turnId);
@ -102,6 +107,10 @@ export class TurnService implements ITurnService {
return this.activeTurn;
}
lastEndedReason(): TurnResult['reason'] | undefined {
return this.lastEndedReasonValue;
}
private async runTurn(turn: Turn, origin: PromptOrigin): Promise<TurnResult> {
const startedAt = Date.now();
const telemetryMode = this.telemetryMode();
@ -139,6 +148,7 @@ export class TurnService implements ITurnService {
this.activeTurn = undefined;
}
if (result !== undefined) {
this.lastEndedReasonValue = result.reason;
const ended = toTurnEndedEvent(turn, result, Date.now() - startedAt);
if (
ended.reason === 'cancelled' &&

View file

@ -54,6 +54,13 @@ export interface IWireRecord {
readonly postRestoring: boolean;
append(record: WireRecord): void;
/**
* Snapshot of every record currently held in memory (live-appended and
* restored), in order, excluding the leading `metadata` envelope record.
* Intended for callers that need to replay the same history into another
* agent via {@link restore} (e.g. session fork).
*/
getRecords(): readonly PersistedWireRecord[];
register<T extends keyof WireRecordMap>(
type: T,
resumer: (data: WireRecord<T>) => void | Promise<void>,

View file

@ -80,6 +80,10 @@ export class WireRecordService extends Disposable implements IWireRecord {
this.appendPersistent(stamped);
}
getRecords(): readonly PersistedWireRecord[] {
return [...this.records];
}
register<T extends keyof WireRecordMap>(
type: T,
resumer: (data: WireRecord<T>) => void | Promise<void>,
@ -350,3 +354,12 @@ function isWireRecordMetadata(record: PersistedWireRecord): record is WireRecord
function hashKey(homedir: string): string {
return createHash('sha256').update(homedir).digest('hex').slice(0, 16);
}
/**
* Persistence key of an agent's wire log, derived from its homedir. Used by
* cross-session operations (e.g. fork) to read / rewrite a wire log through
* `IAppendLogStore` without holding a live agent handle.
*/
export function wireRecordPersistKey(homedir: string): string {
return hashKey(homedir);
}

View file

@ -355,6 +355,24 @@ describe('AgentRecords persistence metadata', () => {
});
});
describe('IWireRecord.records()', () => {
it('returns restored and appended records in order, excluding metadata', async () => {
const persistence = new InMemoryWireRecordPersistence([
{ type: 'metadata', protocol_version: AGENT_WIRE_PROTOCOL_VERSION, created_at: 1 },
{ type: 'context.splice', start: 0, deleteCount: 0, messages: [userMessage('restored')] },
]);
const records = testAgent({ persistence }).wireRecord;
await records.restore();
records.append({ type: 'turn.launch', turnId: 0, origin: { kind: 'user' } });
const snapshot = records.getRecords();
expect(snapshot.map((record) => record.type)).toEqual(['context.splice', 'turn.launch']);
// A copy is returned, so mutating it must not affect the service.
(snapshot as unknown as PersistedWireRecord[]).pop();
expect(records.getRecords()).toHaveLength(2);
});
});
describe('agent replay range build', () => {
it('returns the complete replay when no range is requested', async () => {
const firstMessage = userMessage('first');

View file

@ -178,6 +178,7 @@ export type KimiErrorCode =
| 'session.state_not_found'
| 'session.state_invalid'
| 'session.fork_active_turn'
| 'session.undo_unavailable'
| 'session.export_not_found'
| 'session.export_missing_version'
| 'session.closed'
@ -825,6 +826,7 @@ export const kimiErrorCodeSchema = z.enum([
'session.state_not_found',
'session.state_invalid',
'session.fork_active_turn',
'session.undo_unavailable',
'session.export_not_found',
'session.export_missing_version',
'session.closed',

View file

@ -0,0 +1,149 @@
/**
* Shared projection from a v2 `ContextMessage` history entry to the wire
* `Message` shape. Mirrors v1's `toProtocolMessage`
* (`packages/agent-core/src/services/message/message.ts`) so both the
* `/messages` routes and the `undo` session action produce byte-compatible
* message objects.
*/
import type { IContextMemory } from '@moonshot-ai/agent-core-v2';
import type { Message, MessageContent, MessageRole, ToolUseContent } from '@moonshot-ai/protocol';
/** One entry from the main agent's live history. */
type MemoryMessage = ReturnType<IContextMemory['get']>[number];
/** Derive a stable opaque message id from (sessionId, index). */
function deriveMessageId(sessionId: string, index: number): string {
const padded = String(index).padStart(6, '0');
return `msg_${sessionId}_${padded}`;
}
/**
* Inverse of `deriveMessageId`: parse `msg_<sessionId>_<index>` back into
* `{sessionId, index}`. Returns `undefined` when the id does not match the
* derived contract. The session id may itself contain underscores, so the split
* is taken from the RIGHT on `_`.
*/
export function parseMessageId(
messageId: string,
): { sessionId: string; index: number } | undefined {
if (!messageId.startsWith('msg_')) return undefined;
const rest = messageId.slice('msg_'.length);
const lastUnderscore = rest.lastIndexOf('_');
if (lastUnderscore <= 0) return undefined;
const sessionId = rest.slice(0, lastUnderscore);
const indexStr = rest.slice(lastUnderscore + 1);
if (!/^\d+$/.test(indexStr)) return undefined;
const index = Number.parseInt(indexStr, 10);
if (!Number.isFinite(index) || index < 0) return undefined;
return { sessionId, index };
}
/** kosong's `Role` already matches the wire `MessageRole` — pass through. */
function toProtocolRole(role: MemoryMessage['role']): MessageRole {
return role as MessageRole;
}
/** Translate one kosong content part to a wire content part. */
function mapContentPart(part: MemoryMessage['content'][number]): MessageContent {
switch (part.type) {
case 'text':
return { type: 'text', text: part.text };
case 'think': {
const sig = part.encrypted;
return sig !== undefined
? { type: 'thinking', thinking: part.think, signature: sig }
: { type: 'thinking', thinking: part.think };
}
case 'image_url':
return {
type: 'image',
source: { kind: 'url', url: part.imageUrl.url },
};
case 'audio_url':
return { type: 'text', text: `[audio:${part.audioUrl.url}]` };
case 'video_url':
return { type: 'text', text: `[video:${part.videoUrl.url}]` };
}
}
/**
* Build the protocol-shaped `Message.content[]` for one history entry:
* 1. `tool` role a single `tool_result` part.
* 2. other roles each mapped content part, then one `tool_use` part per
* `ToolCall` (assistant only).
*/
function buildProtocolContent(msg: MemoryMessage): MessageContent[] {
if (msg.role === 'tool') {
if (msg.toolCallId === undefined) {
return msg.content.map((p) => mapContentPart(p));
}
const flattenedOutput = msg.content
.map((p) => (p.type === 'text' ? p.text : ''))
.join('');
const part: MessageContent =
msg.isError === true
? {
type: 'tool_result',
tool_call_id: msg.toolCallId,
output: flattenedOutput,
is_error: true,
}
: {
type: 'tool_result',
tool_call_id: msg.toolCallId,
output: flattenedOutput,
};
return [part];
}
const base = msg.content.map((p) => mapContentPart(p));
if (msg.role === 'assistant' && msg.toolCalls.length > 0) {
for (const call of msg.toolCalls) {
let parsedInput: unknown = call.arguments;
if (typeof call.arguments === 'string') {
try {
parsedInput = JSON.parse(call.arguments);
} catch {
parsedInput = call.arguments;
}
}
const part: ToolUseContent = {
type: 'tool_use',
tool_call_id: call.id,
tool_name: call.name,
input: parsedInput,
};
base.push(part);
}
}
return base;
}
/**
* Convert one history entry into the protocol's `Message` shape. `created_at`
* is synthesized from the session's `createdAt` plus the entry index so it
* increases monotonically across the array.
*/
export function toProtocolMessage(
sessionId: string,
index: number,
msg: MemoryMessage,
sessionCreatedAtMs: number,
): Message {
const id = deriveMessageId(sessionId, index);
const role = toProtocolRole(msg.role);
const content = buildProtocolContent(msg);
const createdAtMs = sessionCreatedAtMs + index;
const metadata = msg.origin !== undefined ? { origin: msg.origin } : undefined;
return {
id,
session_id: sessionId,
role,
content,
created_at: new Date(createdAtMs).toISOString(),
...(metadata !== undefined ? { metadata } : {}),
};
}

View file

@ -50,9 +50,7 @@ import { z } from 'zod';
import { errEnvelope, okEnvelope } from '../envelope';
import { defineRoute } from '../middleware/defineRoute';
import { ensureMainAgent } from '../transport/mainAgent';
/** One entry from the main agent's live history. */
type MemoryMessage = ReturnType<IContextMemory['get']>[number];
import { parseMessageId, toProtocolMessage } from './_messageProjection';
const DEFAULT_PAGE_SIZE = 50;
const MAX_PAGE_SIZE = 100;
@ -236,155 +234,6 @@ async function loadProtocolMessages(core: Scope, sid: string): Promise<Message[]
return history.map((msg, index) => toProtocolMessage(sid, index, msg, summary.createdAt));
}
// ---------------------------------------------------------------------------
// API body wrapper — pure projection from a `ContextMessage` history entry to
// the wire `Message` shape. Mirrors v1's `toProtocolMessage` and helpers so the
// REST contract is byte-for-byte compatible for the fields both share.
// ---------------------------------------------------------------------------
/** Derive a stable opaque message id from (sessionId, index). */
function deriveMessageId(sessionId: string, index: number): string {
const padded = String(index).padStart(6, '0');
return `msg_${sessionId}_${padded}`;
}
/**
* Inverse of `deriveMessageId`: parse `msg_<sessionId>_<index>` back into
* `{sessionId, index}`. Returns `undefined` when the id does not match the
* derived contract. The session id may itself contain underscores, so the split
* is taken from the RIGHT on `_`.
*/
function parseMessageId(messageId: string): { sessionId: string; index: number } | undefined {
if (!messageId.startsWith('msg_')) return undefined;
const rest = messageId.slice('msg_'.length);
const lastUnderscore = rest.lastIndexOf('_');
if (lastUnderscore <= 0) return undefined;
const sessionId = rest.slice(0, lastUnderscore);
const indexStr = rest.slice(lastUnderscore + 1);
if (!/^\d+$/.test(indexStr)) return undefined;
const index = Number.parseInt(indexStr, 10);
if (!Number.isFinite(index) || index < 0) return undefined;
return { sessionId, index };
}
/** kosong's `Role` already matches SCHEMAS §3's `MessageRole` — pass through. */
function toProtocolRole(role: MemoryMessage['role']): MessageRole {
return role as MessageRole;
}
/** Translate one kosong content part to a SCHEMAS §3 content part. */
function mapContentPart(part: MemoryMessage['content'][number]): MessageContent {
switch (part.type) {
case 'text':
return { type: 'text', text: part.text };
case 'think': {
const sig = part.encrypted;
return sig !== undefined
? { type: 'thinking', thinking: part.think, signature: sig }
: { type: 'thinking', thinking: part.think };
}
case 'image_url':
return {
type: 'image',
source: { kind: 'url', url: part.imageUrl.url },
};
case 'audio_url':
// SCHEMAS §3 has no audio content variant; flatten to a `text` marker so
// the wire shape stays well-typed without inventing a new schema.
return { type: 'text', text: `[audio:${part.audioUrl.url}]` };
case 'video_url':
// Same as audio — no video variant in §3.
return { type: 'text', text: `[video:${part.videoUrl.url}]` };
}
}
/**
* Build the protocol-shaped `Message.content[]` for one history entry:
* 1. `tool` role a single `tool_result` part (flattened text output;
* `is_error` from `ContextMessage.isError`).
* 2. other roles each mapped content part, then one `tool_use` part per
* `ToolCall` (assistant only).
*/
function buildProtocolContent(msg: MemoryMessage): MessageContent[] {
if (msg.role === 'tool') {
if (msg.toolCallId === undefined) {
// Defensive — kosong tool messages always carry toolCallId. If absent,
// fall back to text passthrough so user-visible content is not lost.
return msg.content.map((p) => mapContentPart(p));
}
const flattenedOutput = msg.content
.map((p) => (p.type === 'text' ? p.text : ''))
.join('');
const part: MessageContent =
msg.isError === true
? {
type: 'tool_result',
tool_call_id: msg.toolCallId,
output: flattenedOutput,
is_error: true,
}
: {
type: 'tool_result',
tool_call_id: msg.toolCallId,
output: flattenedOutput,
};
return [part];
}
const base = msg.content.map((p) => mapContentPart(p));
if (msg.role === 'assistant' && msg.toolCalls.length > 0) {
for (const call of msg.toolCalls) {
let parsedInput: unknown = call.arguments;
if (typeof call.arguments === 'string') {
try {
parsedInput = JSON.parse(call.arguments);
} catch {
parsedInput = call.arguments;
}
}
const part: ToolUseContent = {
type: 'tool_use',
tool_call_id: call.id,
tool_name: call.name,
input: parsedInput,
};
base.push(part);
}
}
return base;
}
/**
* Convert one history entry into the protocol's `Message` shape.
* `created_at` is synthesized from the session's `createdAt` plus the entry
* index so it increases monotonically across the array.
*/
function toProtocolMessage(
sessionId: string,
index: number,
msg: MemoryMessage,
sessionCreatedAtMs: number,
): Message {
const id = deriveMessageId(sessionId, index);
const role = toProtocolRole(msg.role);
const content = buildProtocolContent(msg);
const createdAtMs = sessionCreatedAtMs + index;
// Expose the message origin via metadata so REST clients (e.g. the web UI)
// can hide injected / system user turns the same way the TUI does. Absent for
// plain user / assistant / tool messages with no origin.
const metadata = msg.origin !== undefined ? { origin: msg.origin } : undefined;
return {
id,
session_id: sessionId,
role,
content,
created_at: new Date(createdAtMs).toISOString(),
...(metadata !== undefined ? { metadata } : {}),
};
}
// ---------------------------------------------------------------------------
// Error envelopes
// ---------------------------------------------------------------------------

View file

@ -2,26 +2,31 @@
* `/sessions` route handlers server-v2 port.
*
* Implements the v1 `/api/v1/sessions` wire contract on top of
* `agent-core-v2` services. Only the endpoints v2 can back today are
* registered:
* `agent-core-v2` services:
* POST /sessions create
* GET /sessions list
* GET /sessions/{session_id} get
* GET /sessions/{session_id}/profile
* POST /sessions/{session_id}/profile update title (partial)
* POST /sessions/{tail} ::archive action
* POST /sessions/{tail} action: fork / compact / undo /
* abort / btw / archive
* GET /sessions/{session_id}/status best-effort
*
* The remaining v1 actions (fork / compact / undo / abort / btw / children /
* warnings) are not registered because `agent-core-v2` does not yet expose
* the backing capabilities see the server-v2 sessions gap list (G1G10).
* 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
* `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.
*
* **Wire fidelity**: mirrors v1's `toProtocolSession`
* (`packages/agent-core/src/services/session/session.ts`), which populates
* only the index/metadata fields and returns placeholders for the heavy ones
* (`agent_config:{model:''}`, `usage:zeros`, `permission_rules:[]`,
* `message_count:0`, `last_seq:0`, hardcoded `status:'idle'`). v2 produces the
* same placeholder shape from `ISessionIndex` + `IWorkspaceRegistry`.
* same placeholder shape from `ISessionIndex` + `IWorkspaceRegistry`, and now
* also surfaces `last_prompt` and the merged custom `metadata`.
*
* **cwd resolution (gap G3)**: v2 does not store the original work dir on the
* session; we recover `metadata.cwd` from `IWorkspaceRegistry`
@ -35,18 +40,29 @@ import {
ISessionIndex,
ISessionLifecycleService,
ISessionMetadata,
ISessionLegacyService,
IEventService,
IWorkspaceRegistry,
isKimiError,
KimiError,
type Scope,
} from '@moonshot-ai/agent-core-v2';
import {
ErrorCode,
archiveSessionResponseSchema,
compactSessionRequestSchema,
compactSessionResponseSchema,
createSessionRequestSchema,
emptySessionUsage,
forkSessionRequestSchema,
pageResponseSchema,
sessionAbortResponseSchema,
sessionSchema,
sessionStatusResponseSchema,
sessionStatusSchema,
startBtwSessionResponseSchema,
undoSessionRequestSchema,
undoSessionResponseSchema,
updateSessionProfileRequestSchema,
} from '@moonshot-ai/protocol';
import type { Session } from '@moonshot-ai/protocol';
@ -56,6 +72,7 @@ import { z } from 'zod';
import { errEnvelope, okEnvelope } from '../envelope';
import { defineRoute } from '../middleware/defineRoute';
import { parseActionSuffix } from './action-suffix';
import { toProtocolMessage } from './_messageProjection';
interface SessionRouteHost {
post(
@ -114,6 +131,22 @@ const sessionActionTailParamSchema = z.object({
tail: z.string().min(1),
});
/**
* Combined body schema for `POST /sessions/{tail}`. Each action parses its own
* fields from this superset (mirrors v1's `sessionActionRequestSchema`, which is
* also a server-side superset the per-action wire schemas live in protocol).
*/
const sessionActionRequestSchema = z.preprocess(
(value) => (value === undefined ? {} : value),
z.object({
title: z.string().min(1).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
instruction: z.string().optional(),
count: z.number().int().positive().optional(),
page_size: z.number().int().min(1).max(100).optional(),
}),
);
const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() }));
export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void {
@ -189,9 +222,12 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void
await handle.accessor.get(ISessionMetadata).setTitle(body.title);
}
const meta = await handle.accessor.get(ISessionMetadata).read();
reply.send(
okEnvelope(toWireSession({ ...meta, workspaceId: touched.id }, touched.root), req.id),
);
const session = toWireSession({ ...meta, workspaceId: touched.id }, touched.root);
core.accessor.get(IEventService).publish({
type: 'event.session.created',
payload: { agentId: 'main', sessionId: session.id, session },
});
reply.send(okEnvelope(session, req.id));
},
);
app.post(
@ -387,35 +423,109 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void
method: 'POST',
path: '/sessions/{tail}',
params: sessionActionTailParamSchema,
success: { data: archiveSessionResponseSchema },
body: sessionActionRequestSchema,
success: {
data: z.union([
sessionSchema,
compactSessionResponseSchema,
undoSessionResponseSchema,
sessionAbortResponseSchema,
startBtwSessionResponseSchema,
archiveSessionResponseSchema,
]),
},
errors: {
[ErrorCode.VALIDATION_FAILED]: { detailsSchema },
[ErrorCode.SESSION_NOT_FOUND]: {},
[ErrorCode.SESSION_BUSY]: {},
[ErrorCode.COMPACTION_UNABLE]: {},
[ErrorCode.SESSION_UNDO_UNAVAILABLE]: {},
[ErrorCode.AUTH_TOKEN_MISSING]: {},
},
description: 'Run a session action (only ::archive is supported in this slice)',
description: 'Run a session action',
tags: ['sessions'],
operationId: 'runSessionAction',
},
async (req, reply) => {
const { tail } = req.params;
const parsed = parseActionSuffix({
tail,
allowedActions: ['archive'] as const,
resourceLabel: 'session',
});
if (parsed.kind !== 'action') {
const message = parsed.kind === 'invalid' ? parsed.reason : `unsupported action: ${tail}`;
reply.send(buildValidationEnvelope([{ path: 'session_id', message }], req.id));
return;
try {
const { tail } = req.params;
const parsed = parseActionSuffix({
tail,
allowedActions: ['fork', 'compact', 'undo', 'abort', 'btw', 'archive'] as const,
resourceLabel: 'session',
});
if (parsed.kind !== 'action') {
const message = parsed.kind === 'invalid' ? parsed.reason : `unsupported action: ${tail}`;
reply.send(buildValidationEnvelope([{ path: 'session_id', message }], req.id));
return;
}
const legacy = core.accessor.get(ISessionLegacyService);
if (parsed.action === 'fork') {
const body = forkSessionRequestSchema.parse(req.body);
const fields = await legacy.fork(parsed.id, 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));
return;
}
if (parsed.action === 'compact') {
const body = compactSessionRequestSchema.parse(req.body);
const result = await legacy.compact(parsed.id, body);
reply.send(okEnvelope(result, req.id));
return;
}
if (parsed.action === 'undo') {
const body = undoSessionRequestSchema.parse(req.body);
const { history, status } = await legacy.undo(parsed.id, body);
const pageSize = Math.min(Math.max(body.page_size ?? 50, 1), 100);
const summary = await core.accessor.get(ISessionIndex).get(parsed.id);
const createdAt = summary?.createdAt ?? 0;
const all = history.map((msg, index) => toProtocolMessage(parsed.id, index, msg, createdAt));
const desc = [...all].reverse();
reply.send(
okEnvelope(
{
messages: { items: desc.slice(0, pageSize), has_more: desc.length > pageSize },
status,
},
req.id,
),
);
return;
}
if (parsed.action === 'abort') {
const result = await legacy.abort(parsed.id);
reply.send(okEnvelope(result, req.id));
return;
}
if (parsed.action === 'btw') {
const result = await legacy.startBtw(parsed.id);
reply.send(okEnvelope(result, req.id));
return;
}
// archive
const handle = core.accessor.get(ISessionLifecycleService).get(parsed.id);
if (handle === undefined) {
reply.send(
errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${parsed.id} does not exist`, req.id),
);
return;
}
await core.accessor.get(ISessionLifecycleService).archive(parsed.id);
reply.send(okEnvelope({ archived: true as const }, req.id));
} catch (error) {
sendMappedError(reply, req.id, error);
}
const handle = core.accessor.get(ISessionLifecycleService).get(parsed.id);
if (handle === undefined) {
reply.send(
errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${parsed.id} does not exist`, req.id),
);
return;
}
await core.accessor.get(ISessionLifecycleService).archive(parsed.id);
reply.send(okEnvelope({ archived: true as const }, req.id));
},
);
app.post(
@ -484,9 +594,11 @@ interface SessionWireFields {
readonly id: string;
readonly workspaceId: string;
readonly title?: string;
readonly lastPrompt?: string;
readonly createdAt: number;
readonly updatedAt: number;
readonly archived: boolean;
readonly custom?: Record<string, unknown>;
}
function toWireSession(fields: SessionWireFields, cwd: string): Session {
@ -498,7 +610,8 @@ function toWireSession(fields: SessionWireFields, cwd: string): Session {
updated_at: new Date(fields.updatedAt).toISOString(),
status: 'idle',
archived: fields.archived,
metadata: { cwd },
last_prompt: fields.lastPrompt,
metadata: buildWireMetadata(fields.custom, cwd),
agent_config: { model: '' },
usage: emptySessionUsage(),
permission_rules: [],
@ -507,6 +620,20 @@ function toWireSession(fields: SessionWireFields, cwd: string): Session {
};
}
/**
* Build the wire `Session.metadata`: caller-supplied custom fields (minus the
* reserved `goal` key, matching v1's `toProtocolSession`) overlaid with the
* required `cwd`. `cwd` always wins so the resolved work dir is authoritative.
*/
function buildWireMetadata(
custom: Record<string, unknown> | undefined,
cwd: string,
): { cwd: string; [key: string]: unknown } {
if (custom === undefined) return { cwd };
const { goal: _drop, ...rest } = custom as { goal?: unknown; [key: string]: unknown };
return { ...rest, cwd };
}
function buildValidationEnvelope(
details: { path: string; message: string }[],
requestId: string,
@ -532,3 +659,41 @@ function buildValidationEnvelope(
details,
};
}
function sendMappedError(
reply: { send(payload: unknown): unknown },
requestId: string,
err: unknown,
): void {
if (isKimiError(err)) {
switch (err.code) {
case 'session.not_found':
case 'agent.not_found':
reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId));
return;
case 'session.fork_active_turn':
reply.send(errEnvelope(ErrorCode.SESSION_BUSY, err.message, requestId));
return;
case 'compaction.unable':
reply.send(errEnvelope(ErrorCode.COMPACTION_UNABLE, err.message, requestId));
return;
case 'session.undo_unavailable':
reply.send(errEnvelope(ErrorCode.SESSION_UNDO_UNAVAILABLE, err.message, requestId));
return;
case 'auth.login_required':
reply.send(errEnvelope(ErrorCode.AUTH_TOKEN_MISSING, err.message, requestId));
return;
case 'request.invalid':
case 'validation.failed':
reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, err.message, requestId));
return;
}
}
reply.send(
errEnvelope(
ErrorCode.INTERNAL_ERROR,
err instanceof Error ? err.message : String(err),
requestId,
),
);
}