mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-19 13:45:28 +00:00
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:
parent
ce0e4f2589
commit
d210009877
23 changed files with 1032 additions and 212 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> =
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
7
packages/agent-core-v2/src/sessionLegacy/index.ts
Normal file
7
packages/agent-core-v2/src/sessionLegacy/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* `sessionLegacy` domain barrel — re-exports the v1 session-action adapter
|
||||
* contract and implementation.
|
||||
*/
|
||||
|
||||
export * from './sessionLegacy';
|
||||
export * from './sessionLegacyService';
|
||||
72
packages/agent-core-v2/src/sessionLegacy/sessionLegacy.ts
Normal file
72
packages/agent-core-v2/src/sessionLegacy/sessionLegacy.ts
Normal 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');
|
||||
220
packages/agent-core-v2/src/sessionLegacy/sessionLegacyService.ts
Normal file
220
packages/agent-core-v2/src/sessionLegacy/sessionLegacyService.ts
Normal 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',
|
||||
);
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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' &&
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
149
packages/server-v2/src/routes/_messageProjection.ts
Normal file
149
packages/server-v2/src/routes/_messageProjection.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 (G1–G10).
|
||||
* 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue