kimi-code/packages/agent-core/src/services/session/sessionService.ts
Haozhe ff177155ca
fix(web): stop auto-dismissing pending questions and approvals on a timeout (#1070)
* fix(web): stop dismissing questions after a 60 second timeout

The server's question broker auto-expired AskUserQuestion requests after 60s, which dismissed the question even when the user simply needed more time. Remove the timeout, and the now-unused expires_at field, so a question stays pending until the user answers or explicitly dismisses it.
2026-06-24 19:15:06 +08:00

581 lines
20 KiB
TypeScript

import { Disposable, IInstantiationService, InstantiationType, registerSingleton } from '../../di';
import { Emitter } from '../../base/common/event';
import { ErrorCodes, KimiError } from '../../errors';
import type { AgentContextData, ContextMessage } from '../../agent/context';
import type { JsonObject, ListSessionsPayload, SessionSummary } from '../../rpc';
import type { SessionMeta } from '../../session';
import {
type CompactSessionRequest,
type CompactSessionResponse,
type Event,
type Message,
type PageResponse,
type Session,
type SessionChildCreate,
type SessionCreate,
type SessionFork,
type SessionStatus,
type SessionStatusResponse,
type SessionUpdate,
type SessionWarning,
type UndoSessionRequest,
type UndoSessionResponse,
} from '@moonshot-ai/protocol';
import { IApprovalService } from '../approval/approval';
import { ICoreProcessService } from '../coreProcess/coreProcess';
import { IEventService } from '../event/event';
import { toProtocolMessage } from '../message/message';
import { IPromptService, type AgentStatePatch } from '../prompt/prompt';
import { IQuestionService } from '../question/question';
import {
ISessionService,
SessionNotFoundError,
SessionUndoUnavailableError,
toProtocolSession,
type SessionCreateOptions,
type SessionListQuery,
} from './session';
const DEFAULT_PAGE_SIZE = 20;
const MAX_PAGE_SIZE = 100;
const DEFAULT_UNDO_MESSAGE_PAGE_SIZE = 50;
const MAX_UNDO_MESSAGE_PAGE_SIZE = 100;
const CHILD_SESSION_KIND = 'child';
function asJsonObject(value: Record<string, unknown>): JsonObject {
return value as unknown as JsonObject;
}
function normalizeOptionalString(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed === '' ? undefined : trimmed;
}
function canUndoHistory(history: readonly ContextMessage[], count: number): boolean {
let found = 0;
for (let i = history.length - 1; i >= 0; i--) {
const message = history[i];
if (message === undefined) continue;
if (message.origin?.kind === 'injection') continue;
if (message.origin?.kind === 'compaction_summary') return false;
if (isRealUserPrompt(message)) {
found++;
if (found >= count) 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;
return origin.kind === 'skill_activation' && origin.trigger === 'user-slash';
}
function pageContextMessages(
sessionId: string,
sessionCreatedAtMs: number,
context: AgentContextData,
requestedPageSize: number | undefined,
): PageResponse<Message> {
const pageSize = Math.min(
Math.max(requestedPageSize ?? DEFAULT_UNDO_MESSAGE_PAGE_SIZE, 1),
MAX_UNDO_MESSAGE_PAGE_SIZE,
);
const all = context.history.map((message, index) =>
toProtocolMessage(sessionId, index, message, sessionCreatedAtMs),
);
const desc = all.toReversed();
return {
items: desc.slice(0, pageSize),
has_more: desc.length > pageSize,
};
}
export class SessionService extends Disposable implements ISessionService {
readonly _serviceBrand: undefined;
private readonly _onDidCreate = this._register(new Emitter<{ session: Session }>());
readonly onDidCreate = this._onDidCreate.event;
private readonly _onDidClose = this._register(new Emitter<{ sessionId: string }>());
readonly onDidClose = this._onDidClose.event;
private readonly _statusBySession = new Map<string, SessionStatus>();
private readonly _activeTurns = new Set<string>();
private readonly _abortedTurns = new Set<string>();
private _promptService: IPromptService | undefined;
constructor(
@ICoreProcessService private readonly core: ICoreProcessService,
@IEventService private readonly eventService: IEventService,
@IInstantiationService private readonly instantiation: IInstantiationService,
@IApprovalService private readonly approvalService: IApprovalService,
@IQuestionService private readonly questionService: IQuestionService,
) {
super();
this._register(
this.eventService.onDidPublish((event) => {
this._handleBusEvent(event);
}),
);
}
private get promptService(): IPromptService {
return (this._promptService ??= this.instantiation.invokeFunction((a) => a.get(IPromptService)));
}
/**
* Compute the session lifecycle status from live daemon state.
*
* Priority:
* 1. awaiting_approval — pending approvals exist
* 2. awaiting_question — pending questions exist
* 3. running — active prompt or active turn
* 4. aborted — last turn ended as cancelled/failed and no new work started
* 5. idle — everything else
*/
private _computeStatus(sessionId: string): SessionStatus {
if (this.approvalService.listPending(sessionId).length > 0) {
return 'awaiting_approval';
}
if (this.questionService.listPending(sessionId).length > 0) {
return 'awaiting_question';
}
if (
this.promptService.getCurrentPromptId(sessionId) !== undefined ||
this._activeTurns.has(sessionId)
) {
return 'running';
}
if (this._abortedTurns.has(sessionId)) {
return 'aborted';
}
return 'idle';
}
/**
* Overwrite the placeholder status on a protocol Session with the live value,
* and remember the last status we returned so status-change events can be
* emitted only when the live state actually moves.
*/
private _patchSessionStatus(session: Session): Session {
const status = this._computeStatus(session.id);
session.status = status;
this._statusBySession.set(session.id, status);
return session;
}
/**
* Publish `event.session.status_changed` when the computed status for a
* session differs from the last one we announced. Called after every relevant
* lifecycle event so the session list stays in sync.
*/
private _emitStatusChanged(sessionId: string): void {
const previous = this._statusBySession.get(sessionId) ?? 'idle';
const next = this._computeStatus(sessionId);
if (previous === next) return;
this._statusBySession.set(sessionId, next);
this.eventService.publish({
type: 'event.session.status_changed',
agentId: 'main',
sessionId,
status: next,
previous_status: previous,
current_prompt_id: this.promptService.getCurrentPromptId(sessionId),
} as unknown as Event);
}
private _handleBusEvent(event: Event): void {
const type = (event as { type?: string }).type;
const sessionId = (event as { sessionId?: string }).sessionId;
if (sessionId === undefined || sessionId === '' || type === undefined) return;
switch (type) {
case 'turn.started': {
this._activeTurns.add(sessionId);
this._abortedTurns.delete(sessionId);
this._emitStatusChanged(sessionId);
break;
}
case 'turn.ended': {
this._activeTurns.delete(sessionId);
const reason = (event as { reason?: string }).reason;
if (reason === 'cancelled' || reason === 'failed' || reason === 'filtered') {
this._abortedTurns.add(sessionId);
} else {
this._abortedTurns.delete(sessionId);
}
this._emitStatusChanged(sessionId);
break;
}
case 'prompt.submitted': {
this._abortedTurns.delete(sessionId);
this._emitStatusChanged(sessionId);
break;
}
case 'prompt.completed':
case 'prompt.aborted':
case 'event.approval.requested':
case 'event.approval.resolved':
case 'event.approval.expired':
case 'event.question.requested':
case 'event.question.answered':
case 'event.question.dismissed': {
this._emitStatusChanged(sessionId);
break;
}
}
}
async create(input: SessionCreate, options?: SessionCreateOptions): Promise<Session> {
if (input.metadata === undefined || typeof input.metadata.cwd !== 'string') {
throw new Error('SessionService.create: metadata.cwd is required');
}
const metadataForCore = asJsonObject(input.metadata as Record<string, unknown>);
const summary = await this.core.rpc.createSession({
workDir: input.metadata.cwd,
metadata: metadataForCore,
model: input.agent_config?.model,
client: options?.client,
});
if (input.title !== undefined) {
try {
await this.core.rpc.renameSession({ sessionId: summary.id, title: input.title });
} catch {
}
}
const meta = await this.tryGetMeta(summary.id);
const session = this._patchSessionStatus(toProtocolSession(summary, meta));
this.emitCreated(session);
return session;
}
async list(query: SessionListQuery): Promise<PageResponse<Session>> {
const corePayload: ListSessionsPayload = {
workDir: query.workDir,
includeArchive: query.includeArchive,
};
const all = await this.core.rpc.listSessions(corePayload);
const sorted = all.toSorted((a, b) => b.updatedAt - a.updatedAt);
let pivotIndex = -1;
if (query.before_id !== undefined) {
pivotIndex = sorted.findIndex((s) => s.id === query.before_id);
} else if (query.after_id !== undefined) {
pivotIndex = sorted.findIndex((s) => s.id === query.after_id);
}
let slice: typeof sorted;
if (query.before_id !== undefined && pivotIndex >= 0) {
slice = sorted.slice(pivotIndex + 1);
} else if (query.after_id !== undefined && pivotIndex >= 0) {
slice = sorted.slice(0, pivotIndex);
} else {
slice = sorted;
}
const requestedSize = query.page_size ?? DEFAULT_PAGE_SIZE;
const pageSize = Math.min(Math.max(requestedSize, 1), MAX_PAGE_SIZE);
const pageSummaries = slice.slice(0, pageSize);
const hasMore = slice.length > pageSize;
const items = await Promise.all(
pageSummaries.map(async (s) =>
this._patchSessionStatus(toProtocolSession(s, await this.tryGetMeta(s.id)))
),
);
const filtered =
query.status !== undefined ? items.filter((s) => s.status === query.status) : items;
return { items: filtered, has_more: hasMore };
}
async get(id: string): Promise<Session> {
const all = await this.core.rpc.listSessions({});
const summary = all.find((s) => s.id === id);
if (summary === undefined) {
throw new SessionNotFoundError(id);
}
const meta = await this.tryGetMeta(id);
return this._patchSessionStatus(toProtocolSession(summary, meta));
}
async update(id: string, input: SessionUpdate): Promise<Session> {
const all = await this.core.rpc.listSessions({});
const summary = all.find((s) => s.id === id);
if (summary === undefined) {
throw new SessionNotFoundError(id);
}
if (input.title !== undefined) {
await this.core.rpc.renameSession({ sessionId: id, title: input.title });
}
const metadataPatch = input.metadata;
if (metadataPatch !== undefined && Object.keys(metadataPatch).length > 0) {
await this.core.rpc.updateSessionMetadata({
sessionId: id,
metadata: { custom: metadataPatch as Record<string, unknown> },
});
}
const ac = input.agent_config;
if (ac !== undefined) {
const patch: AgentStatePatch = {};
if (ac.model !== undefined && ac.model !== '') patch.model = ac.model;
if (ac.thinking !== undefined) patch.thinking = ac.thinking;
if (ac.permission_mode !== undefined) patch.permission_mode = ac.permission_mode;
if (ac.plan_mode !== undefined) patch.plan_mode = ac.plan_mode;
if (ac.swarm_mode !== undefined) patch.swarm_mode = ac.swarm_mode;
if (ac.goal_objective !== undefined) patch.goal_objective = ac.goal_objective;
if (ac.goal_control !== undefined) patch.goal_control = ac.goal_control;
if (
patch.model !== undefined ||
patch.thinking !== undefined ||
patch.permission_mode !== undefined ||
patch.plan_mode !== undefined ||
patch.swarm_mode !== undefined ||
patch.goal_objective !== undefined ||
patch.goal_control !== undefined
) {
await this.promptService.applyAgentState(id, patch, 'meta');
}
}
const allAfter = await this.core.rpc.listSessions({});
const summaryAfter = allAfter.find((s) => s.id === id) ?? summary;
const meta = await this.tryGetMeta(id);
return this._patchSessionStatus(toProtocolSession(summaryAfter, meta));
}
async fork(id: string, input: SessionFork): Promise<Session> {
const source = await this.get(id);
const title = input.title ?? `Fork: ${source.title || source.id}`;
const metadata = input.metadata === undefined ? undefined : asJsonObject(input.metadata);
const summary = await this.core.rpc.forkSession({
sessionId: id,
title,
metadata,
});
const meta = await this.tryGetMeta(summary.id);
const session = this._patchSessionStatus(toProtocolSession(summary, meta));
this.emitCreated(session);
return session;
}
async listChildren(id: string, query: SessionListQuery): Promise<PageResponse<Session>> {
await this.get(id);
const all = await this.core.rpc.listSessions({});
const sorted = all.toSorted((a, b) => b.updatedAt - a.updatedAt);
const children = sorted.filter(
(summary) =>
summary.metadata?.['parent_session_id'] === id &&
summary.metadata?.['child_session_kind'] === CHILD_SESSION_KIND,
);
let pivotIndex = -1;
if (query.before_id !== undefined) {
pivotIndex = children.findIndex((s) => s.id === query.before_id);
} else if (query.after_id !== undefined) {
pivotIndex = children.findIndex((s) => s.id === query.after_id);
}
let slice: typeof children;
if (query.before_id !== undefined && pivotIndex >= 0) {
slice = children.slice(pivotIndex + 1);
} else if (query.after_id !== undefined && pivotIndex >= 0) {
slice = children.slice(0, pivotIndex);
} else {
slice = children;
}
const requestedSize = query.page_size ?? DEFAULT_PAGE_SIZE;
const pageSize = Math.min(Math.max(requestedSize, 1), MAX_PAGE_SIZE);
const pageSummaries = slice.slice(0, pageSize);
const items = await Promise.all(
pageSummaries.map(async (s) =>
this._patchSessionStatus(toProtocolSession(s, await this.tryGetMeta(s.id)))
),
);
const filtered =
query.status !== undefined
? items.filter((session) => session.status === query.status)
: items;
return {
items: filtered,
has_more: slice.length > pageSize,
};
}
async createChild(id: string, input: SessionChildCreate): Promise<Session> {
const parent = await this.get(id);
const title = input.title ?? `Child: ${parent.title || parent.id}`;
const metadata = asJsonObject({
...input.metadata,
parent_session_id: id,
child_session_kind: CHILD_SESSION_KIND,
});
const summary = await this.core.rpc.forkSession({
sessionId: id,
title,
metadata,
});
const meta = await this.tryGetMeta(summary.id);
const session = this._patchSessionStatus(toProtocolSession(summary, meta));
this.emitCreated(session);
return session;
}
private emitCreated(session: Session): void {
this._onDidCreate.fire({ session });
this.eventService.publish({
type: 'event.session.created',
agentId: 'main',
sessionId: session.id,
session,
});
}
async getStatus(id: string): Promise<SessionStatusResponse> {
const all = await this.core.rpc.listSessions({});
const summary = all.find((s) => s.id === id);
if (summary === undefined) {
throw new SessionNotFoundError(id);
}
const [config, context, permission, plan] = await Promise.all([
this.core.rpc.getConfig({ sessionId: id, agentId: 'main' }),
this.core.rpc.getContext({ sessionId: id, agentId: 'main' }),
this.core.rpc.getPermission({ sessionId: id, agentId: 'main' }),
this.core.rpc.getPlan({ sessionId: id, agentId: 'main' }),
]);
const maxContextTokens = config.modelCapabilities?.max_context_tokens ?? 0;
const contextTokens = context.tokenCount;
const contextUsage = maxContextTokens > 0 ? contextTokens / maxContextTokens : 0;
const agentState = this.promptService.getAgentStateSnapshot(id);
return {
status: this._computeStatus(id),
model: config.modelAlias ?? config.provider?.model,
thinking_level: config.thinkingLevel,
permission: permission.mode,
plan_mode: plan !== null,
swarm_mode: agentState?.swarmMode ?? false,
context_tokens: contextTokens,
max_context_tokens: maxContextTokens,
context_usage: contextUsage,
};
}
async getSessionWarnings(id: string): Promise<readonly SessionWarning[]> {
const all = await this.core.rpc.listSessions({});
if (!all.some((s) => s.id === id)) {
throw new SessionNotFoundError(id);
}
try {
await this.core.rpc.resumeSession({ sessionId: id });
} catch {
// best-effort: the session may already be loaded in core memory.
}
try {
return await this.core.rpc.getSessionWarnings({ sessionId: id });
} catch {
return [];
}
}
async compact(id: string, input: CompactSessionRequest): Promise<CompactSessionResponse> {
const all = await this.core.rpc.listSessions({});
const summary = all.find((s) => s.id === id);
if (summary === undefined) {
throw new SessionNotFoundError(id);
}
// beginCompaction only sees sessions loaded in core memory — resume first
// (mirrors undo) so compacting a freshly-opened session doesn't throw
// SESSION_NOT_FOUND.
await this.core.rpc.resumeSession({ sessionId: id });
const instruction = normalizeOptionalString(input.instruction);
await this.core.rpc.beginCompaction({
sessionId: id,
agentId: 'main',
instruction,
});
return {};
}
async undo(id: string, input: UndoSessionRequest): Promise<UndoSessionResponse> {
const summary = await this.requireSummary(id);
await this.core.rpc.resumeSession({ sessionId: id });
const before = await this.core.rpc.getContext({ sessionId: id, agentId: 'main' });
if (!canUndoHistory(before.history, input.count)) {
throw new SessionUndoUnavailableError(id);
}
try {
await this.core.rpc.undoHistory({
sessionId: id,
agentId: 'main',
count: input.count,
});
} catch (error) {
if (error instanceof KimiError && error.code === ErrorCodes.REQUEST_INVALID) {
throw new SessionUndoUnavailableError(id, error.message);
}
throw error;
}
const after = await this.core.rpc.getContext({ sessionId: id, agentId: 'main' });
return {
messages: pageContextMessages(id, summary.createdAt, after, input.page_size),
status: await this.getStatus(id),
};
}
async archive(id: string): Promise<{ archived: true }> {
const all = await this.core.rpc.listSessions({});
const summary = all.find((s) => s.id === id);
if (summary === undefined) {
throw new SessionNotFoundError(id);
}
await this.core.rpc.archiveSession({ sessionId: id });
this._onDidClose.fire({ sessionId: id });
this._statusBySession.delete(id);
this._activeTurns.delete(id);
this._abortedTurns.delete(id);
return { archived: true };
}
private async requireSummary(id: string): Promise<SessionSummary> {
const all = await this.core.rpc.listSessions({});
const summary = all.find((s) => s.id === id);
if (summary === undefined) {
throw new SessionNotFoundError(id);
}
return summary;
}
private async tryGetMeta(id: string): Promise<SessionMeta | undefined> {
try {
const meta = await this.core.rpc.getSessionMetadata({ sessionId: id });
return meta;
} catch {
return undefined;
}
}
override dispose(): void {
if (this._store.isDisposed) return;
super.dispose();
}
}
registerSingleton(ISessionService, SessionService, InstantiationType.Delayed);