Merge branch 'kimi-code-v2' of https://github.com/MoonshotAI/kimi-code into kimi-code-v2

This commit is contained in:
_Kerman 2026-07-09 19:48:04 +08:00
commit e44d181d06
59 changed files with 3035 additions and 1905 deletions

View file

@ -0,0 +1,353 @@
/**
* Output rendering for `kimi -p` (print mode) shared by the v1 driver
* (`run-prompt.ts`) and the native v2 runner (`v2/run-v2-print.ts`).
*
* Both engines feed the same writer classes: v1 via the SDK `Event` stream, v2
* via the main agent's native `IEventBus` (whose `DomainEvent` payloads are
* already v1-protocol-shaped). Keeping the writers here lets v2 reuse them
* without re-implementing rendering, while v1's `runPromptTurn` keeps its own
* event-filtering / completion flow intact.
*/
import type { PromptOutputFormat } from './options';
/**
* Structural hook-result shape the renderer reads. Both the v1 SDK
* `HookResultEvent` and the v2 native `hook.result` `DomainEvent` satisfy it,
* so the renderer stays engine-agnostic without depending on either event
* definition.
*/
interface HookResultEventLike {
readonly hookEvent: string;
readonly content: string;
readonly blocked?: boolean;
}
export interface PromptOutput {
readonly columns?: number | undefined;
write(chunk: string): boolean;
}
const PROMPT_BLOCK_BULLET = '• ';
const PROMPT_BLOCK_INDENT = ' ';
export interface PromptTurnWriter {
writeAssistantDelta(delta: string): void;
writeHookResult(event: HookResultEventLike): void;
writeThinkingDelta(delta: string): void;
writeToolCall(toolCallId: string, name: string, args: unknown): void;
writeToolCallDelta(
toolCallId: string,
name: string | undefined,
argumentsPart: string | undefined,
): void;
writeToolResult(toolCallId: string, output: unknown): void;
flushAssistant(): void;
discardAssistant(): void;
finish(): void;
}
interface PromptJsonToolCall {
type: 'function';
id: string;
function: {
name: string;
arguments: string;
};
}
interface PromptJsonAssistantMessage {
role: 'assistant';
content?: string;
tool_calls?: PromptJsonToolCall[];
}
interface PromptJsonToolMessage {
role: 'tool';
tool_call_id: string;
content: string;
}
export class PromptTranscriptWriter implements PromptTurnWriter {
private readonly assistantWriter: PromptBlockWriter;
private readonly thinkingWriter: PromptBlockWriter;
constructor(stdout: PromptOutput, stderr: PromptOutput) {
this.assistantWriter = new PromptBlockWriter(stdout);
this.thinkingWriter = new PromptBlockWriter(stderr);
}
writeAssistantDelta(delta: string): void {
this.thinkingWriter.finish();
this.assistantWriter.write(delta);
}
writeHookResult(event: HookResultEventLike): void {
this.thinkingWriter.finish();
this.assistantWriter.finish();
this.assistantWriter.write(formatHookResultPlain(event));
this.assistantWriter.finish();
}
writeThinkingDelta(delta: string): void {
this.thinkingWriter.write(delta);
}
writeToolCall(): void {}
writeToolCallDelta(): void {}
writeToolResult(): void {}
flushAssistant(): void {}
discardAssistant(): void {}
finish(): void {
this.thinkingWriter.finish();
this.assistantWriter.finish();
}
}
export class PromptJsonWriter implements PromptTurnWriter {
private assistantText = '';
private readonly toolCalls: PromptJsonToolCall[] = [];
constructor(private readonly stdout: PromptOutput) {}
writeAssistantDelta(delta: string): void {
this.assistantText += delta;
}
writeHookResult(event: HookResultEventLike): void {
this.flushAssistant();
this.writeJsonLine({
role: 'assistant',
content: formatHookResultPlain(event),
});
}
writeThinkingDelta(): void {}
writeToolCall(toolCallId: string, name: string, args: unknown): void {
const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId);
if (existing !== undefined) {
existing.function.name = name;
existing.function.arguments = stringifyJsonValue(args);
return;
}
this.toolCalls.push({
type: 'function',
id: toolCallId,
function: {
name,
arguments: stringifyJsonValue(args),
},
});
}
writeToolCallDelta(
toolCallId: string,
name: string | undefined,
argumentsPart: string | undefined,
): void {
const toolCall = this.findOrCreateToolCall(toolCallId, name ?? '');
if (name !== undefined) {
toolCall.function.name = name;
}
if (argumentsPart !== undefined) {
toolCall.function.arguments += argumentsPart;
}
}
writeToolResult(toolCallId: string, output: unknown): void {
this.flushAssistant();
this.writeJsonLine({
role: 'tool',
tool_call_id: toolCallId,
content: stringifyToolOutput(output),
});
}
flushAssistant(): void {
if (this.assistantText.length === 0 && this.toolCalls.length === 0) return;
const message: PromptJsonAssistantMessage = {
role: 'assistant',
content: this.assistantText.length > 0 ? this.assistantText : undefined,
tool_calls: this.toolCalls.length > 0 ? [...this.toolCalls] : undefined,
};
this.writeJsonLine(message);
this.discardAssistant();
}
discardAssistant(): void {
this.assistantText = '';
this.toolCalls.length = 0;
}
finish(): void {
this.flushAssistant();
}
private findOrCreateToolCall(toolCallId: string, name: string): PromptJsonToolCall {
const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId);
if (existing !== undefined) return existing;
const toolCall: PromptJsonToolCall = {
type: 'function',
id: toolCallId,
function: {
name,
arguments: '',
},
};
this.toolCalls.push(toolCall);
return toolCall;
}
private writeJsonLine(message: PromptJsonAssistantMessage | PromptJsonToolMessage): void {
this.stdout.write(`${JSON.stringify(message)}\n`);
}
}
class PromptBlockWriter {
private started = false;
private atLineStart = false;
private lineWidth = 0;
private readonly wrapWidth: number | undefined;
constructor(private readonly output: PromptOutput) {
this.wrapWidth =
typeof output.columns === 'number' && output.columns > PROMPT_BLOCK_INDENT.length + 1
? output.columns
: undefined;
}
write(chunk: string): void {
if (chunk.length === 0) return;
let rendered = this.start();
for (const char of chunk) {
if (this.atLineStart && char !== '\n') {
rendered += PROMPT_BLOCK_INDENT;
this.atLineStart = false;
this.lineWidth = PROMPT_BLOCK_INDENT.length;
}
const charWidth = visibleCharWidth(char);
if (
this.wrapWidth !== undefined &&
!this.atLineStart &&
char !== '\n' &&
this.lineWidth + charWidth > this.wrapWidth
) {
rendered += `\n${PROMPT_BLOCK_INDENT}`;
this.lineWidth = PROMPT_BLOCK_INDENT.length;
}
rendered += char;
if (char === '\n') {
this.atLineStart = true;
this.lineWidth = 0;
} else {
this.lineWidth += charWidth;
}
}
this.output.write(rendered);
}
finish(): void {
if (!this.started) return;
this.output.write(this.atLineStart ? '\n' : '\n\n');
this.started = false;
this.atLineStart = false;
this.lineWidth = 0;
}
private start(): string {
if (this.started) return '';
this.started = true;
this.atLineStart = false;
this.lineWidth = PROMPT_BLOCK_BULLET.length;
return PROMPT_BLOCK_BULLET;
}
}
function visibleCharWidth(char: string): number {
return char === '\t' ? 4 : 1;
}
function formatHookResultPlain(event: HookResultEventLike): string {
return `${formatHookResultTitle(event)}\n\n${formatHookResultBody(event)}`;
}
function formatHookResultTitle(event: HookResultEventLike): string {
return `${event.hookEvent} hook${event.blocked === true ? ' blocked' : ''}`;
}
function formatHookResultBody(event: HookResultEventLike): string {
const content = event.content.trim();
return content.length === 0 ? '(empty)' : content;
}
function stringifyJsonValue(value: unknown): string {
if (typeof value === 'string') return value;
const json = JSON.stringify(value);
return json ?? '';
}
function stringifyToolOutput(output: unknown): string {
if (typeof output === 'string') return output;
const json = JSON.stringify(output);
return json ?? String(output);
}
interface PromptJsonResumeMetaMessage {
role: 'meta';
type: 'session.resume_hint';
session_id: string;
command: string;
content: string;
}
interface PromptJsonVersionMetaMessage {
role: 'meta';
type: 'system.version';
version: string;
}
export function writeExperimentalVersion(
version: string,
outputFormat: PromptOutputFormat,
stdout: PromptOutput,
stderr: PromptOutput,
): void {
if (outputFormat === 'stream-json') {
const message: PromptJsonVersionMetaMessage = {
role: 'meta',
type: 'system.version',
version,
};
stdout.write(`${JSON.stringify(message)}\n`);
return;
}
stderr.write(`kimi version ${version}\n`);
}
export function writeResumeHint(
sessionId: string,
outputFormat: PromptOutputFormat,
stdout: PromptOutput,
stderr: PromptOutput,
): void {
const command = `kimi -r ${sessionId}`;
const content = `To resume this session: ${command}`;
if (outputFormat === 'stream-json') {
const message: PromptJsonResumeMetaMessage = {
role: 'meta',
type: 'session.resume_hint',
session_id: sessionId,
command,
content,
};
stdout.write(`${JSON.stringify(message)}\n`);
return;
}
stderr.write(`${content}\n`);
}

View file

@ -11,7 +11,6 @@ import {
log,
type Event,
type GoalSnapshot,
type HookResultEvent,
type SessionStatus,
type TelemetryClient,
} from '@moonshot-ai/kimi-code-sdk';
@ -29,6 +28,7 @@ import {
type HeadlessGoalCreate,
} from './goal-prompt';
import type { PromptHarness, PromptSession } from './prompt-session';
import { PromptJsonWriter, PromptTranscriptWriter, writeResumeHint } from './prompt-render';
import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry';
import { createKimiCodeHostIdentity } from './version';
@ -50,7 +50,7 @@ import { createKimiCodeHostIdentity } from './version';
* alive until it fires, then gives the rejection a chance to surface. A wedged
* cleanup is still bounded by `timeoutMs`, so this can't hang the run forever.
*/
async function raceWithTimeout(promise: Promise<void>, timeoutMs: number): Promise<void> {
export async function raceWithTimeout(promise: Promise<void>, timeoutMs: number): Promise<void> {
let timedOut = false;
let timer: ReturnType<typeof setTimeout> | undefined;
// Attach the catch eagerly (synchronously) so `promise` is always consumed and
@ -78,13 +78,13 @@ interface PromptOutput {
write(chunk: string): boolean;
}
interface PromptRunIO {
export interface PromptRunIO {
readonly stdout?: PromptOutput;
readonly stderr?: PromptOutput;
readonly process?: PromptProcess;
}
interface PromptProcess {
export interface PromptProcess {
once(signal: NodeJS.Signals, listener: () => Promise<void>): unknown;
off(signal: NodeJS.Signals, listener: () => Promise<void>): unknown;
exit(code?: number): never | void;
@ -92,26 +92,27 @@ interface PromptProcess {
const PROMPT_UI_MODE = 'print';
const PROMPT_MAIN_AGENT_ID = 'main';
const PROMPT_BLOCK_BULLET = '• ';
const PROMPT_BLOCK_INDENT = ' ';
export async function runPrompt(
opts: CLIOptions,
version: string,
io: PromptRunIO = {},
): Promise<void> {
if (isKimiV2Enabled()) {
// The experimental agent-core-v2 engine runs on its own native DI service
// runtime (see v2/run-v2-print.ts); it does not share the v1 PromptHarness
// path below. Loaded lazily so the v2 module graph stays off the default
// (v1) path.
const { runV2Print } = await import('./v2/run-v2-print');
await runV2Print(opts, version, io);
return;
}
const startedAt = Date.now();
const stdout = io.stdout ?? process.stdout;
const stderr = io.stderr ?? process.stderr;
const promptProcess = io.process ?? process;
const outputFormat = opts.outputFormat ?? 'text';
// The experimental agent-core-v2 engine (selected by KIMI_CODE_EXPERIMENTAL_FLAG)
// announces the running version as the very first output so headless consumers
// can identify which build produced the stream, in both `text` and `stream-json`
// formats. The default v1 path keeps its established output unchanged.
if (isKimiV2Enabled()) {
writeExperimentalVersion(version, outputFormat, stdout, stderr);
}
const workDir = process.cwd();
const telemetryBootstrap = createCliTelemetryBootstrap();
const telemetryClient: TelemetryClient = {
@ -214,20 +215,11 @@ export async function runPrompt(
}
}
/**
* Select the engine that backs `kimi -p`.
*
* Default (flag off): the v1 engine via the SDK `createKimiHarness`. When
* `KIMI_CODE_EXPERIMENTAL_FLAG` is set, the agent-core-v2 engine is loaded
* through a lazy import so the v2 module graph stays off the default path.
*/
async function createPromptHarness(
options: Parameters<typeof createKimiHarness>[0],
): Promise<PromptHarness> {
if (isKimiV2Enabled()) {
const { createV2Harness } = await import('./v2/create-v2-harness');
return createV2Harness(options);
}
// The v2 engine is dispatched earlier in `runPrompt` (see the
// `isKimiV2Enabled()` branch) and never reaches here; this is the v1 path.
return createKimiHarness(options);
}
@ -398,7 +390,7 @@ async function forcePromptPermission(
return restorePermission;
}
function requireConfiguredModel(...models: readonly (string | undefined)[]): string {
export function requireConfiguredModel(...models: readonly (string | undefined)[]): string {
const model = configuredModel(...models);
if (model === undefined) {
throw new Error(
@ -408,7 +400,7 @@ function requireConfiguredModel(...models: readonly (string | undefined)[]): str
return model;
}
function configuredModel(...models: readonly (string | undefined)[]): string | undefined {
export function configuredModel(...models: readonly (string | undefined)[]): string | undefined {
return models.find((model) => model !== undefined && model.trim().length > 0);
}
@ -417,7 +409,7 @@ function installHeadlessHandlers(session: PromptSession): void {
session.setQuestionHandler(() => null);
}
function installPromptTerminationCleanup(
export function installPromptTerminationCleanup(
promptProcess: PromptProcess,
cleanup: () => Promise<void>,
): () => void {
@ -444,7 +436,7 @@ function installPromptTerminationCleanup(
};
}
function signalExitCode(signal: NodeJS.Signals): number {
export function signalExitCode(signal: NodeJS.Signals): number {
if (signal === 'SIGINT') return 130;
if (signal === 'SIGHUP') return 129;
return 143;
@ -587,327 +579,6 @@ function runPromptTurn(
});
}
interface PromptTurnWriter {
writeAssistantDelta(delta: string): void;
writeHookResult(event: HookResultEvent): void;
writeThinkingDelta(delta: string): void;
writeToolCall(toolCallId: string, name: string, args: unknown): void;
writeToolCallDelta(
toolCallId: string,
name: string | undefined,
argumentsPart: string | undefined,
): void;
writeToolResult(toolCallId: string, output: unknown): void;
flushAssistant(): void;
discardAssistant(): void;
finish(): void;
}
class PromptTranscriptWriter implements PromptTurnWriter {
private readonly assistantWriter: PromptBlockWriter;
private readonly thinkingWriter: PromptBlockWriter;
constructor(stdout: PromptOutput, stderr: PromptOutput) {
this.assistantWriter = new PromptBlockWriter(stdout);
this.thinkingWriter = new PromptBlockWriter(stderr);
}
writeAssistantDelta(delta: string): void {
this.thinkingWriter.finish();
this.assistantWriter.write(delta);
}
writeHookResult(event: HookResultEvent): void {
this.thinkingWriter.finish();
this.assistantWriter.finish();
this.assistantWriter.write(formatHookResultPlain(event));
this.assistantWriter.finish();
}
writeThinkingDelta(delta: string): void {
this.thinkingWriter.write(delta);
}
writeToolCall(): void {}
writeToolCallDelta(): void {}
writeToolResult(): void {}
flushAssistant(): void {}
discardAssistant(): void {}
finish(): void {
this.thinkingWriter.finish();
this.assistantWriter.finish();
}
}
interface PromptJsonToolCall {
type: 'function';
id: string;
function: {
name: string;
arguments: string;
};
}
interface PromptJsonAssistantMessage {
role: 'assistant';
content?: string;
tool_calls?: PromptJsonToolCall[];
}
interface PromptJsonToolMessage {
role: 'tool';
tool_call_id: string;
content: string;
}
interface PromptJsonResumeMetaMessage {
role: 'meta';
type: 'session.resume_hint';
session_id: string;
command: string;
content: string;
}
interface PromptJsonVersionMetaMessage {
role: 'meta';
type: 'system.version';
version: string;
}
function writeExperimentalVersion(
version: string,
outputFormat: PromptOutputFormat,
stdout: PromptOutput,
stderr: PromptOutput,
): void {
if (outputFormat === 'stream-json') {
const message: PromptJsonVersionMetaMessage = {
role: 'meta',
type: 'system.version',
version,
};
stdout.write(`${JSON.stringify(message)}\n`);
return;
}
stderr.write(`kimi version ${version}\n`);
}
function writeResumeHint(
sessionId: string,
outputFormat: PromptOutputFormat,
stdout: PromptOutput,
stderr: PromptOutput,
): void {
const command = `kimi -r ${sessionId}`;
const content = `To resume this session: ${command}`;
if (outputFormat === 'stream-json') {
const message: PromptJsonResumeMetaMessage = {
role: 'meta',
type: 'session.resume_hint',
session_id: sessionId,
command,
content,
};
stdout.write(`${JSON.stringify(message)}\n`);
return;
}
stderr.write(`${content}\n`);
}
class PromptJsonWriter implements PromptTurnWriter {
private assistantText = '';
private readonly toolCalls: PromptJsonToolCall[] = [];
constructor(private readonly stdout: PromptOutput) {}
writeAssistantDelta(delta: string): void {
this.assistantText += delta;
}
writeHookResult(event: HookResultEvent): void {
this.flushAssistant();
this.writeJsonLine({
role: 'assistant',
content: formatHookResultPlain(event),
});
}
writeThinkingDelta(): void {}
writeToolCall(toolCallId: string, name: string, args: unknown): void {
const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId);
if (existing !== undefined) {
existing.function.name = name;
existing.function.arguments = stringifyJsonValue(args);
return;
}
this.toolCalls.push({
type: 'function',
id: toolCallId,
function: {
name,
arguments: stringifyJsonValue(args),
},
});
}
writeToolCallDelta(
toolCallId: string,
name: string | undefined,
argumentsPart: string | undefined,
): void {
const toolCall = this.findOrCreateToolCall(toolCallId, name ?? '');
if (name !== undefined) {
toolCall.function.name = name;
}
if (argumentsPart !== undefined) {
toolCall.function.arguments += argumentsPart;
}
}
writeToolResult(toolCallId: string, output: unknown): void {
this.flushAssistant();
this.writeJsonLine({
role: 'tool',
tool_call_id: toolCallId,
content: stringifyToolOutput(output),
});
}
flushAssistant(): void {
if (this.assistantText.length === 0 && this.toolCalls.length === 0) return;
const message: PromptJsonAssistantMessage = {
role: 'assistant',
content: this.assistantText.length > 0 ? this.assistantText : undefined,
tool_calls: this.toolCalls.length > 0 ? [...this.toolCalls] : undefined,
};
this.writeJsonLine(message);
this.discardAssistant();
}
discardAssistant(): void {
this.assistantText = '';
this.toolCalls.length = 0;
}
finish(): void {
this.flushAssistant();
}
private findOrCreateToolCall(toolCallId: string, name: string): PromptJsonToolCall {
const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId);
if (existing !== undefined) return existing;
const toolCall: PromptJsonToolCall = {
type: 'function',
id: toolCallId,
function: {
name,
arguments: '',
},
};
this.toolCalls.push(toolCall);
return toolCall;
}
private writeJsonLine(message: PromptJsonAssistantMessage | PromptJsonToolMessage): void {
this.stdout.write(`${JSON.stringify(message)}\n`);
}
}
class PromptBlockWriter {
private started = false;
private atLineStart = false;
private lineWidth = 0;
private readonly wrapWidth: number | undefined;
constructor(private readonly output: PromptOutput) {
this.wrapWidth =
typeof output.columns === 'number' && output.columns > PROMPT_BLOCK_INDENT.length + 1
? output.columns
: undefined;
}
write(chunk: string): void {
if (chunk.length === 0) return;
let rendered = this.start();
for (const char of chunk) {
if (this.atLineStart && char !== '\n') {
rendered += PROMPT_BLOCK_INDENT;
this.atLineStart = false;
this.lineWidth = PROMPT_BLOCK_INDENT.length;
}
const charWidth = visibleCharWidth(char);
if (
this.wrapWidth !== undefined &&
!this.atLineStart &&
char !== '\n' &&
this.lineWidth + charWidth > this.wrapWidth
) {
rendered += `\n${PROMPT_BLOCK_INDENT}`;
this.lineWidth = PROMPT_BLOCK_INDENT.length;
}
rendered += char;
if (char === '\n') {
this.atLineStart = true;
this.lineWidth = 0;
} else {
this.lineWidth += charWidth;
}
}
this.output.write(rendered);
}
finish(): void {
if (!this.started) return;
this.output.write(this.atLineStart ? '\n' : '\n\n');
this.started = false;
this.atLineStart = false;
this.lineWidth = 0;
}
private start(): string {
if (this.started) return '';
this.started = true;
this.atLineStart = false;
this.lineWidth = PROMPT_BLOCK_BULLET.length;
return PROMPT_BLOCK_BULLET;
}
}
function visibleCharWidth(char: string): number {
return char === '\t' ? 4 : 1;
}
function formatHookResultPlain(event: HookResultEvent): string {
return `${formatHookResultTitle(event)}\n\n${formatHookResultBody(event)}`;
}
function formatHookResultTitle(event: HookResultEvent): string {
return `${event.hookEvent} hook${event.blocked === true ? ' blocked' : ''}`;
}
function formatHookResultBody(event: HookResultEvent): string {
const content = event.content.trim();
return content.length === 0 ? '(empty)' : content;
}
function stringifyJsonValue(value: unknown): string {
if (typeof value === 'string') return value;
const json = JSON.stringify(value);
return json ?? '';
}
function stringifyToolOutput(output: unknown): string {
if (typeof output === 'string') return output;
const json = JSON.stringify(output);
return json ?? String(output);
}
function hasTurnId(event: Event): event is Event & { readonly turnId: number } {
return 'turnId' in event;
}

View file

@ -1,38 +0,0 @@
/**
* Adapt agent-core-v2 `IEventBus` events into the v1 SDK `Event` union so the
* existing `kimi -p` driver (`run-prompt.ts`) can consume them unchanged.
*
* v2 `DomainEvent` payloads are already v1-protocol-shaped by construction
* (they were ported from the v1 `record.signal(agentEvent)` sites); the v1
* `Event` type is just `AgentEvent & { agentId; sessionId }`. So adaptation is
* attaching those two fields plus a couple of v2v1 type-name remaps. Mirrors
* `packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts:391-421`.
*/
import type { DomainEvent, IEventBus } from '@moonshot-ai/agent-core-v2';
import type { Event, Unsubscribe } from '@moonshot-ai/kimi-code-sdk';
export function subscribeAgentEvents(
eventBus: IEventBus,
sessionId: string,
agentId: string,
listener: (event: Event) => void,
): Unsubscribe {
const disposable = eventBus.subscribe((event: DomainEvent) => {
listener(adaptEvent(event, sessionId, agentId));
});
return () => disposable.dispose();
}
function adaptEvent(event: DomainEvent, sessionId: string, agentId: string): Event {
// v2 emits `task.started` / `task.terminated`; the v1 protocol stream spells
// them `background.task.*` (payload shape is identical: `{ info }`). Remap so
// consumers see a single stream across engines.
if (event.type === 'task.started') {
return { ...event, type: 'background.task.started', agentId, sessionId } as unknown as Event;
}
if (event.type === 'task.terminated') {
return { ...event, type: 'background.task.terminated', agentId, sessionId } as unknown as Event;
}
return { ...event, agentId, sessionId } as unknown as Event;
}

View file

@ -1,209 +0,0 @@
/**
* agent-core-v2 in-process harness for `kimi -p` (print mode).
*
* Selected by `createPromptHarness` when `KIMI_CODE_EXPERIMENTAL_FLAG` is set.
* Builds the v2 engine via `bootstrap()` and exposes it through the narrow
* {@link PromptHarness} surface that the print-mode driver consumes. Imported
* lazily so the v2 module graph stays off the default (v1) path.
*/
import {
IAgentPermissionModeService,
IAgentProfileService,
IConfigService,
ISessionIndex,
ISessionLifecycleService,
bootstrap,
ensureMainAgent,
hostRequestHeadersSeed,
logSeed,
resolveLoggingConfig,
type Scope,
} from '@moonshot-ai/agent-core-v2';
import { createKimiDefaultHeaders } from '@moonshot-ai/kimi-code-oauth';
import {
KimiAuthFacade,
resolveConfigPath,
resolveKimiHome,
type ConfigDiagnostics,
type CreateSessionOptions,
type KimiConfig,
type KimiHarnessOptions,
type ListSessionsOptions,
type ResumeSessionInput,
type SessionSummary,
type TelemetryProperties,
} from '@moonshot-ai/kimi-code-sdk';
import { mkdir, open } from 'node:fs/promises';
import { dirname } from 'node:path';
import type { PromptHarness, PromptSession } from '../prompt-session';
import { V2Session } from './v2-session';
const DEFAULT_CONFIG_FILE_TEXT = `# ~/.kimi-code/config.toml
# Runtime settings for Kimi Code.
# This file starts empty so built-in defaults can apply.
# Login will populate managed Kimi provider and model entries.
`;
export async function createV2Harness(options: KimiHarnessOptions): Promise<PromptHarness> {
const homeDir = resolveKimiHome(options.homeDir);
const configPath = resolveConfigPath({ homeDir, configPath: options.configPath });
const logging = resolveLoggingConfig({ homeDir, env: process.env });
const hostHeaders =
options.identity === undefined
? {}
: createKimiDefaultHeaders({ homeDir, ...options.identity });
const { app: core } = bootstrap({ homeDir, configPath }, [
...logSeed(logging),
...hostRequestHeadersSeed(hostHeaders),
]);
const auth = new KimiAuthFacade({
homeDir,
configPath,
identity: options.identity,
onRefresh: options.onOAuthRefresh,
});
return new V2PromptHarness({
core,
homeDir,
configPath,
auth,
track: (event, properties) => options.telemetry?.track(event, properties),
});
}
interface V2PromptHarnessContext {
readonly core: Scope;
readonly homeDir: string;
readonly configPath: string;
readonly auth: KimiAuthFacade;
readonly track: (event: string, properties?: TelemetryProperties) => void;
}
class V2PromptHarness implements PromptHarness {
readonly homeDir: string;
readonly auth: KimiAuthFacade;
private readonly core: Scope;
private readonly configPath: string;
private readonly trackImpl: (event: string, properties?: TelemetryProperties) => void;
constructor(context: V2PromptHarnessContext) {
this.core = context.core;
this.homeDir = context.homeDir;
this.configPath = context.configPath;
this.auth = context.auth;
this.trackImpl = context.track;
}
track(event: string, properties?: TelemetryProperties): void {
this.trackImpl(event, properties);
}
async ensureConfigFile(): Promise<void> {
await mkdir(dirname(this.configPath), { recursive: true, mode: 0o700 });
let handle: Awaited<ReturnType<typeof open>> | undefined;
try {
handle = await open(this.configPath, 'wx', 0o600);
await handle.writeFile(DEFAULT_CONFIG_FILE_TEXT, 'utf-8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') return;
throw error;
} finally {
await handle?.close();
}
}
async getConfig(): Promise<Pick<KimiConfig, 'defaultModel' | 'telemetry'>> {
const config = this.core.accessor.get(IConfigService);
await config.ready;
const defaultModel = config.get<string>('defaultModel') ?? undefined;
let telemetry: KimiConfig['telemetry'];
try {
telemetry = config.get<KimiConfig['telemetry']>('telemetry');
} catch {
telemetry = undefined;
}
return { defaultModel, telemetry };
}
async getConfigDiagnostics(): Promise<ConfigDiagnostics> {
const config = this.core.accessor.get(IConfigService);
const diagnostics = config.diagnostics();
return {
warnings: diagnostics.filter((d) => d.severity === 'warning').map((d) => d.message),
};
}
async listSessions(options: ListSessionsOptions): Promise<readonly SessionSummary[]> {
const index = this.core.accessor.get(ISessionIndex);
const page = await index.list({});
let items = page.items;
if (options.sessionId !== undefined) {
items = items.filter((summary) => summary.id === options.sessionId);
}
if (options.workDir !== undefined) {
items = items.filter((summary) => summary.cwd === options.workDir);
}
return items.map(toSdkSessionSummary);
}
async createSession(options: CreateSessionOptions): Promise<PromptSession> {
const session = await this.core.accessor.get(ISessionLifecycleService).create({
workDir: options.workDir,
additionalDirs: options.additionalDirs,
});
const agent = await ensureMainAgent(session);
if (options.model !== undefined) {
await agent.accessor.get(IAgentProfileService).setModel(options.model);
}
agent.accessor.get(IAgentPermissionModeService).setMode(options.permission ?? 'auto');
return new V2Session({
core: this.core,
session,
agent,
drainAgentTasksOnStop: options.drainAgentTasksOnStop,
});
}
async resumeSession(input: ResumeSessionInput): Promise<PromptSession> {
const session = await this.core.accessor.get(ISessionLifecycleService).resume(input.id);
if (session === undefined) {
throw new Error(`Session "${input.id}" not found.`);
}
const agent = await ensureMainAgent(session);
return new V2Session({ core: this.core, session, agent });
}
async close(): Promise<void> {
this.core.dispose();
}
}
function toSdkSessionSummary(summary: {
readonly id: string;
readonly title?: string;
readonly lastPrompt?: string;
readonly cwd?: string;
readonly createdAt: number;
readonly updatedAt: number;
readonly archived: boolean;
readonly custom?: Record<string, unknown>;
}): SessionSummary {
return {
id: summary.id,
title: summary.title,
lastPrompt: summary.lastPrompt,
workDir: summary.cwd ?? '',
// v2 does not persist a separate sessionDir on the index summary; the print
// driver never reads it from list results, so expose the workDir as a
// stand-in to satisfy the SDK shape.
sessionDir: summary.cwd ?? '',
createdAt: summary.createdAt,
updatedAt: summary.updatedAt,
archived: summary.archived,
metadata: summary.custom as SessionSummary['metadata'],
};
}

View file

@ -0,0 +1,514 @@
/**
* Native v2 `kimi -p` (print mode) runner.
*
* Unlike the v1 path (and the former `V2PromptHarness` / `V2Session` shim), this
* runner talks to agent-core-v2's native DI services directly no
* `PromptHarness`, no SDK-shaped session, no v2v1 event translation. It:
* - `bootstrap()`s the app scope,
* - creates / resumes a session and its main agent via native services,
* - subscribes to the main agent's per-agent `IEventBus` and renders the
* native `DomainEvent` stream (payloads are already v1-protocol-shaped),
* - drives a turn through `IAgentPromptService.prompt()` and awaits
* `Turn.result` for authoritative completion,
* - drains background tasks (config-driven) before exiting.
*
* Selected by `runPrompt` when `KIMI_CODE_EXPERIMENTAL_FLAG` is set.
*/
import {
CloudAppender,
IAgentGoalService,
IAgentLifecycleService,
IAgentPermissionModeService,
IAgentProfileService,
IAgentPromptService,
IAgentTaskService,
IAuthSummaryService,
IConfigService,
IEventBus,
IFileSystemStorageService,
IOAuthToolkit,
ISessionIndex,
ISessionLifecycleService,
ITelemetryService,
bootstrap,
ensureMainAgent,
hostRequestHeadersSeed,
logSeed,
resolveKimiHome,
resolveLoggingConfig,
type DomainEvent,
type IAgentScopeHandle,
type ISessionScopeHandle,
type Scope,
} from '@moonshot-ai/agent-core-v2';
import { createKimiDefaultHeaders, createKimiDeviceId } from '@moonshot-ai/kimi-code-oauth';
import { resolve } from 'pathe';
import {
CLI_SHUTDOWN_TIMEOUT_MS,
CLI_USER_AGENT_PRODUCT,
PROMPT_CLEANUP_TIMEOUT_MS,
} from '#/constant/app';
import {
formatGoalSummaryText,
goalExitCode,
goalSummaryJson,
parseHeadlessGoalCreate,
type HeadlessGoalCreate,
} from '../goal-prompt';
import {
type PromptProcess,
type PromptRunIO,
configuredModel,
installPromptTerminationCleanup,
raceWithTimeout,
requireConfiguredModel,
} from '../run-prompt';
import { createKimiCodeHostIdentity } from '../version';
import type { CLIOptions, PromptOutputFormat } from '../options';
import {
type PromptOutput,
PromptJsonWriter,
type PromptTurnWriter,
PromptTranscriptWriter,
writeExperimentalVersion,
writeResumeHint,
} from '../prompt-render';
const PROMPT_UI_MODE = 'print';
const DEFAULT_PRINT_WAIT_CEILING_S = 3600;
const TASK_CONFIG_SECTION = 'task';
const LEGACY_BACKGROUND_CONFIG_SECTION = 'background';
interface TaskPrintWaitConfig {
readonly printWaitCeilingS?: number;
}
export async function runV2Print(
opts: CLIOptions,
version: string,
io: PromptRunIO = {},
): Promise<void> {
const startedAt = Date.now();
const stdout = io.stdout ?? process.stdout;
const stderr = io.stderr ?? process.stderr;
const promptProcess = io.process ?? process;
const outputFormat = opts.outputFormat ?? 'text';
const workDir = process.cwd();
writeExperimentalVersion(version, outputFormat, stdout, stderr);
const homeDir = resolveKimiHome();
let firstLaunch = false;
const deviceId = createKimiDeviceId(homeDir, {
onFirstLaunch: () => {
firstLaunch = true;
},
});
const logging = resolveLoggingConfig({ homeDir, env: process.env });
const identity = createKimiCodeHostIdentity(version);
const hostHeaders = createKimiDefaultHeaders({ homeDir, ...identity });
const { app } = bootstrap({ homeDir }, [
...logSeed(logging),
...hostRequestHeadersSeed(hostHeaders),
]);
const auth = app.accessor.get(IOAuthToolkit);
const configService = app.accessor.get(IConfigService);
await configService.ready;
const defaultModel = configService.get<string>('defaultModel') ?? undefined;
let telemetryEnabled = true;
try {
telemetryEnabled = configService.get('telemetry') !== false;
} catch {
telemetryEnabled = true;
}
for (const diagnostic of configService.diagnostics()) {
if (diagnostic.severity === 'warning') {
stderr.write(`Warning: ${diagnostic.message}\n`);
}
}
let restorePermission = async (): Promise<void> => {};
let removeTerminationCleanup: (() => void) | undefined;
let cleanupPromise: Promise<void> | undefined;
let telemetryService: ITelemetryService | undefined;
const cleanup = async (): Promise<void> => {
const pending = (cleanupPromise ??= (async () => {
removeTerminationCleanup?.();
try {
await restorePermission();
} finally {
if (telemetryService !== undefined) {
await raceWithTimeout(telemetryService.shutdown(), CLI_SHUTDOWN_TIMEOUT_MS);
}
app.dispose();
}
})());
await raceWithTimeout(pending, PROMPT_CLEANUP_TIMEOUT_MS);
};
removeTerminationCleanup = installPromptTerminationCleanup(promptProcess, cleanup);
try {
const resolved = await resolveNativeSession(app, opts, workDir, defaultModel, stderr);
restorePermission = resolved.restorePermission;
telemetryService = app.accessor.get(ITelemetryService);
if (telemetryEnabled) {
telemetryService.setAppender(
new CloudAppender({
storage: app.accessor.get(IFileSystemStorageService),
deviceId,
appName: CLI_USER_AGENT_PRODUCT,
version,
uiMode: PROMPT_UI_MODE,
model: resolved.telemetryModel,
getAccessToken: async () => (await auth.getCachedAccessToken()) ?? null,
env: process.env,
}),
);
}
telemetryService.setContext({ sessionId: resolved.session.id });
if (firstLaunch) {
telemetryService.track('first_launch');
}
const goalCreate = parseHeadlessGoalCreate(opts.prompt!);
if (goalCreate !== undefined) {
await runNativeGoal(
app,
resolved.session,
resolved.agent,
goalCreate,
resolved.goalModel,
outputFormat,
stdout,
stderr,
);
} else {
await runNativeTurn(
app,
resolved.session,
resolved.agent,
opts.prompt!,
outputFormat,
stdout,
stderr,
);
}
writeResumeHint(resolved.session.id, outputFormat, stdout, stderr);
telemetryService.withContext({ sessionId: resolved.session.id }).track('exit', {
duration_ms: Date.now() - startedAt,
});
} finally {
await cleanup();
}
}
interface ResolvedNativeSession {
readonly session: ISessionScopeHandle;
readonly agent: IAgentScopeHandle;
readonly restorePermission: () => Promise<void>;
readonly telemetryModel: string | undefined;
readonly goalModel: string | undefined;
}
async function resolveNativeSession(
app: Scope,
opts: CLIOptions,
workDir: string,
defaultModel: string | undefined,
stderr: PromptOutput,
): Promise<ResolvedNativeSession> {
const lifecycle = app.accessor.get(ISessionLifecycleService);
const index = app.accessor.get(ISessionIndex);
const resumeById = async (id: string): Promise<ISessionScopeHandle> => {
const session = await lifecycle.resume(id);
if (session === undefined) {
throw new Error(`Session "${id}" not found.`);
}
return session;
};
const forceAuto = (
agent: IAgentScopeHandle,
): { readonly restorePermission: () => Promise<void> } => {
const permissionMode = agent.accessor.get(IAgentPermissionModeService);
const previous = permissionMode.mode;
permissionMode.setMode('auto');
return {
restorePermission: async () => {
permissionMode.setMode(previous);
},
};
};
if (opts.session !== undefined) {
const page = await index.list({});
const target = page.items.find((summary) => summary.id === opts.session);
if (target === undefined) {
throw new Error(`Session "${opts.session}" not found.`);
}
if (target.cwd !== undefined && resolve(target.cwd) !== resolve(workDir)) {
stderr.write(
`Session "${opts.session}" was created under a different directory.\n` +
` cd "${target.cwd}" && kimi -r ${opts.session}\n\n`,
);
throw new Error(`Session "${opts.session}" was created under a different directory.`);
}
const session = await resumeById(opts.session);
const agent = await ensureMainAgent(session);
const profile = agent.accessor.get(IAgentProfileService);
if (opts.model !== undefined) {
await profile.setModel(opts.model);
}
const currentModel = profile.getModel();
const { restorePermission } = forceAuto(agent);
return {
session,
agent,
restorePermission,
telemetryModel: configuredModel(opts.model, currentModel, defaultModel),
goalModel: configuredModel(opts.model, currentModel),
};
}
if (opts.continue) {
const page = await index.list({});
const previous = page.items.find((summary) => summary.cwd === workDir);
if (previous !== undefined) {
const session = await resumeById(previous.id);
const agent = await ensureMainAgent(session);
const profile = agent.accessor.get(IAgentProfileService);
if (opts.model !== undefined) {
await profile.setModel(opts.model);
}
const currentModel = profile.getModel();
const { restorePermission } = forceAuto(agent);
return {
session,
agent,
restorePermission,
telemetryModel: configuredModel(opts.model, currentModel, defaultModel),
goalModel: configuredModel(opts.model, currentModel),
};
}
stderr.write(`No sessions to continue under "${workDir}"; starting a fresh session.\n`);
}
const model = requireConfiguredModel(opts.model, defaultModel);
const session = await lifecycle.create({
workDir,
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
});
const agent = await ensureMainAgent(session);
await agent.accessor.get(IAgentProfileService).setModel(model);
agent.accessor.get(IAgentPermissionModeService).setMode('auto');
return {
session,
agent,
restorePermission: async () => {},
telemetryModel: model,
goalModel: model,
};
}
async function runNativeTurn(
app: Scope,
session: ISessionScopeHandle,
agent: IAgentScopeHandle,
prompt: string,
outputFormat: PromptOutputFormat,
stdout: PromptOutput,
stderr: PromptOutput,
): Promise<void> {
const writer: PromptTurnWriter =
outputFormat === 'stream-json'
? new PromptJsonWriter(stdout)
: new PromptTranscriptWriter(stdout, stderr);
await agent.accessor.get(IAuthSummaryService).ensureReady();
const subscription = agent.accessor.get(IEventBus).subscribe((event: DomainEvent) => {
dispatchNativeEvent(writer, event, stderr);
});
try {
const turn = await agent.accessor.get(IAgentPromptService).prompt({
role: 'user',
content: [{ type: 'text', text: prompt }],
toolCalls: [],
origin: { kind: 'user' },
});
if (turn === undefined) {
throw new Error('Prompt turn could not be started');
}
const result = await turn.result;
// Turn settled, but `-p` is not done until any background work the turn
// spawned has drained (config-bounded). Flush the buffered assistant
// message first so a long drain does not withhold the final message.
writer.flushAssistant();
if (result.reason === 'completed') {
try {
await drainBackgroundTasks(app, session);
} catch {
// Draining is best-effort; a wedged background task must not fail the
// (already completed) turn. Swallow and proceed to finish.
}
writer.finish();
return;
}
writer.finish();
throw new Error(formatNativeTurnFailure(result));
} catch (error) {
writer.finish();
throw error instanceof Error ? error : new Error(String(error));
} finally {
subscription.dispose();
}
}
async function runNativeGoal(
app: Scope,
session: ISessionScopeHandle,
agent: IAgentScopeHandle,
goal: HeadlessGoalCreate,
model: string | undefined,
outputFormat: PromptOutputFormat,
stdout: PromptOutput,
stderr: PromptOutput,
): Promise<void> {
requireConfiguredModel(model);
const goalService = agent.accessor.get(IAgentGoalService);
await goalService.createGoal({
objective: goal.objective,
replace: goal.replace,
});
let completedSnapshot: { readonly status: string } | null = null;
const subscription = agent.accessor.get(IEventBus).subscribe((event: DomainEvent) => {
if (
event.type === 'goal.updated' &&
event.change?.kind === 'completion' &&
event.snapshot !== null
) {
completedSnapshot = event.snapshot;
}
});
try {
await runNativeTurn(app, session, agent, goal.objective, outputFormat, stdout, stderr);
} finally {
subscription.dispose();
const snapshot = completedSnapshot ?? (await goalService.getGoal()).goal;
if (outputFormat === 'stream-json') {
stdout.write(`${JSON.stringify(goalSummaryJson(snapshot))}\n`);
} else {
stderr.write(`${formatGoalSummaryText(snapshot)}\n`);
}
if (snapshot !== null && snapshot.status !== 'complete') {
process.exitCode = goalExitCode(snapshot.status);
}
}
}
function dispatchNativeEvent(
writer: PromptTurnWriter,
event: DomainEvent,
stderr: PromptOutput,
): void {
switch (event.type) {
case 'turn.step.started':
case 'turn.step.interrupted':
writer.flushAssistant();
return;
case 'turn.step.retrying':
writer.discardAssistant();
return;
case 'assistant.delta':
writer.writeAssistantDelta(event.delta);
return;
case 'hook.result':
writer.writeHookResult(event);
return;
case 'thinking.delta':
writer.writeThinkingDelta(event.delta);
return;
case 'tool.call.started':
writer.writeToolCall(event.toolCallId, event.name, event.args);
return;
case 'tool.call.delta':
writer.writeToolCallDelta(event.toolCallId, event.name, event.argumentsPart);
return;
case 'tool.result':
writer.writeToolResult(event.toolCallId, event.output);
return;
case 'tool.progress':
if (event.update.text !== undefined && event.update.text.length > 0) {
stderr.write(event.update.text.endsWith('\n') ? event.update.text : `${event.update.text}\n`);
}
return;
}
}
async function drainBackgroundTasks(app: Scope, session: ISessionScopeHandle): Promise<void> {
const config = app.accessor.get(IConfigService);
const section =
config.get<TaskPrintWaitConfig>(TASK_CONFIG_SECTION) ??
config.get<TaskPrintWaitConfig>(LEGACY_BACKGROUND_CONFIG_SECTION);
const ceilingS = section?.printWaitCeilingS;
const ceilingMs =
typeof ceilingS === 'number' && Number.isFinite(ceilingS) && ceilingS > 0
? ceilingS * 1000
: DEFAULT_PRINT_WAIT_CEILING_S * 1000;
const deadline = Date.now() + ceilingMs;
const seen = new Set<string>();
const allWaiters: Promise<unknown>[] = [];
while (Date.now() < deadline) {
const batch: Promise<unknown>[] = [];
const suppressions: Promise<void>[] = [];
let activeCount = 0;
for (const handle of session.accessor.get(IAgentLifecycleService).list()) {
const taskService = handle.accessor.get(IAgentTaskService);
for (const task of taskService.list(true)) {
activeCount++;
if (seen.has(task.taskId)) continue;
seen.add(task.taskId);
suppressions.push(taskService.suppressTerminalNotification(task.taskId));
const remaining = Math.max(1, deadline - Date.now());
const waiter = taskService.wait(task.taskId, remaining);
batch.push(waiter);
allWaiters.push(waiter);
}
}
if (suppressions.length > 0) await Promise.all(suppressions);
if (activeCount === 0 || batch.length === 0) break;
await Promise.all(batch);
}
if (allWaiters.length > 0) await Promise.all(allWaiters);
}
function formatNativeTurnFailure(result: {
readonly reason: string;
readonly error?: unknown;
}): string {
const error = result.error as { readonly code?: string; readonly message?: string } | undefined;
if (error?.code === 'provider.filtered') {
return 'Provider safety policy blocked the response.';
}
if (error?.code !== undefined) {
return `${error.code}: ${error.message ?? ''}`.trimEnd();
}
if (result.error instanceof Error) {
return result.error.message;
}
if (result.reason === 'blocked') {
return 'Prompt hook blocked the request.';
}
return `Prompt turn ended with reason: ${result.reason}`;
}

View file

@ -1,262 +0,0 @@
/**
* `PromptSession` backed by an agent-core-v2 session + main agent.
*
* Created by {@link V2PromptHarness}. Resolves v2 services off the session /
* main-agent scopes and adapts them to the narrow print-mode surface. The
* event stream is adapted in {@link subscribeAgentEvents} (see adapt-events.ts).
*/
import {
IAgentGoalService,
IAgentLifecycleService,
IAgentLoopService,
IAgentPermissionModeService,
IAgentProfileService,
IAgentPromptService,
IAgentPromptLegacyService,
IAgentTaskService,
IConfigService,
IEventBus,
ISessionLegacyService,
type IAgentScopeHandle,
type ISessionScopeHandle,
type Scope,
} from '@moonshot-ai/agent-core-v2';
import type {
ApprovalHandler,
CreateGoalInput,
Event,
GoalSnapshot,
GoalToolResult,
PermissionMode,
PromptInput,
QuestionHandler,
SessionStatus,
Unsubscribe,
} from '@moonshot-ai/kimi-code-sdk';
import type { PromptSession } from '../prompt-session';
import { subscribeAgentEvents } from './adapt-events';
const DEFAULT_PRINT_WAIT_CEILING_S = 3600;
const TASK_CONFIG_SECTION = 'task';
const LEGACY_BACKGROUND_CONFIG_SECTION = 'background';
interface TaskPrintWaitConfig {
readonly keepAliveOnExit?: boolean;
readonly printWaitCeilingS?: number;
}
export interface V2SessionContext {
readonly core: Scope;
readonly session: ISessionScopeHandle;
readonly agent: IAgentScopeHandle;
readonly drainAgentTasksOnStop?: boolean;
}
export class V2Session implements PromptSession {
readonly id: string;
readonly workDir: string;
private readonly core: Scope;
private readonly session: ISessionScopeHandle;
private readonly agent: IAgentScopeHandle;
constructor(context: V2SessionContext) {
this.core = context.core;
this.session = context.session;
this.agent = context.agent;
this.id = context.session.id;
this.workDir = resolveWorkDir(context.session);
if (context.drainAgentTasksOnStop === true) this.installPrintDrainHook();
}
async getStatus(): Promise<SessionStatus> {
const raw = await this.core.accessor.get(ISessionLegacyService).status(this.id);
return {
model: raw.model === '' ? undefined : raw.model,
thinkingEffort: raw.thinking_level ?? '',
permission: raw.permission as PermissionMode,
planMode: raw.plan_mode,
swarmMode: raw.swarm_mode,
contextTokens: raw.context_tokens,
maxContextTokens: raw.max_context_tokens,
contextUsage: raw.context_usage,
};
}
async setModel(model: string): Promise<void> {
await this.agent.accessor.get(IAgentProfileService).setModel(model);
}
async setPermission(mode: PermissionMode): Promise<void> {
this.agent.accessor
.get(IAgentPermissionModeService)
.setMode(mode as 'yolo' | 'manual' | 'auto');
}
setApprovalHandler(_handler: ApprovalHandler | undefined): void {
// Print mode forces the main agent into 'auto' permission (see
// forcePromptPermission), so the v2 policy layer auto-approves normal
// tools and denies AskUserQuestion before any interaction is parked.
// Subagents inherit the session workspace; if a future scenario parks an
// approval outside 'auto', a session-wide `ISessionInteractionService`
// backstop (auto-decide / dismiss) would be added here.
}
setQuestionHandler(_handler: QuestionHandler | undefined): void {
// See setApprovalHandler — 'auto' mode denies the AskUserQuestion tool at
// the policy layer, so no question interaction is created in print mode.
}
onEvent(listener: (event: Event) => void): Unsubscribe {
const eventBus = this.agent.accessor.get(IEventBus);
return subscribeAgentEvents(eventBus, this.id, this.agent.id, listener);
}
async prompt(input: string | PromptInput): Promise<void> {
const content = normalizePromptInput(input);
await this.agent.accessor.get(IAgentPromptLegacyService).submit({ content });
}
async waitForBackgroundTasksOnPrint(): Promise<void> {
if (!this.readKeepAliveOnExit()) return;
// Drain background tasks (background bash and background subagents) spawned
// during the turn before a `kimi -p` run exits. `-p` must be able to run
// long tasks to completion, so we wait until every active task across every
// agent reaches a terminal state — bounded only by `[task]/[background]
// print_wait_ceiling_s` (default 1h) so a genuinely wedged task cannot keep
// the process alive forever.
//
// Tasks are re-enumerated each round: a subagent may fan out new background
// tasks after a previous enumeration, and a single pass could return while
// those later tasks are still running. Terminal notifications are suppressed
// for each task while we wait, so a task completing cannot `turn.steer` the
// (already finished) main agent into launching a new turn.
const deadline = Date.now() + this.readPrintWaitCeilingMs();
const seen = new Set<string>();
const allWaiters: Promise<unknown>[] = [];
while (Date.now() < deadline) {
const batch: Promise<unknown>[] = [];
const suppressions: Promise<void>[] = [];
let activeCount = 0;
for (const handle of this.session.accessor.get(IAgentLifecycleService).list()) {
const taskService = handle.accessor.get(IAgentTaskService);
for (const task of taskService.list(true)) {
activeCount++;
if (seen.has(task.taskId)) continue;
seen.add(task.taskId);
suppressions.push(taskService.suppressTerminalNotification(task.taskId));
const remaining = Math.max(1, deadline - Date.now());
const waiter = taskService.wait(task.taskId, remaining);
batch.push(waiter);
allWaiters.push(waiter);
}
}
if (suppressions.length > 0) await Promise.all(suppressions);
if (activeCount === 0 || batch.length === 0) break;
await Promise.all(batch);
}
if (allWaiters.length > 0) await Promise.all(allWaiters);
}
private installPrintDrainHook(): void {
this.agent.accessor.get(IAgentPromptService);
const loop = this.agent.accessor.get(IAgentLoopService);
const tasks = this.agent.accessor.get(IAgentTaskService);
loop.hooks.afterStep.register(
'print-drain-agent-tasks',
async (ctx, next) => {
await next();
if (ctx.continue || ctx.finishReason === 'tool_calls') return;
if (await waitForActiveAgentTasks(tasks, ctx.signal)) ctx.continue = true;
},
{ after: 'prompt-service-steer' },
);
}
private readKeepAliveOnExit(): boolean {
const section = this.readTaskPrintWaitConfig();
return section?.keepAliveOnExit === true;
}
private readPrintWaitCeilingMs(): number {
const section = this.readTaskPrintWaitConfig();
const ceilingS = section?.printWaitCeilingS;
if (typeof ceilingS === 'number' && Number.isFinite(ceilingS) && ceilingS > 0) {
return ceilingS * 1000;
}
return DEFAULT_PRINT_WAIT_CEILING_S * 1000;
}
private readTaskPrintWaitConfig(): TaskPrintWaitConfig | undefined {
const config = this.core.accessor.get(IConfigService);
return (
config.get<TaskPrintWaitConfig>(TASK_CONFIG_SECTION) ??
config.get<TaskPrintWaitConfig>(LEGACY_BACKGROUND_CONFIG_SECTION)
);
}
async createGoal(input: CreateGoalInput): Promise<GoalSnapshot> {
return (await this.agent.accessor
.get(IAgentGoalService)
.createGoal(input)) as unknown as GoalSnapshot;
}
async getGoal(): Promise<GoalToolResult> {
return this.agent.accessor.get(IAgentGoalService).getGoal() as unknown as GoalToolResult;
}
}
async function waitForActiveAgentTasks(
taskService: IAgentTaskService,
signal: AbortSignal,
): Promise<boolean> {
let waited = false;
while (true) {
signal.throwIfAborted();
const active = taskService.list(true).filter((task) => task.kind === 'agent');
if (active.length === 0) return waited;
waited = true;
const batch = Promise.all(active.map((task) => taskService.wait(task.taskId)));
await Promise.race([batch, abortRejecter(signal)]);
}
}
function abortRejecter(signal: AbortSignal): Promise<never> {
if (signal.aborted) {
return Promise.reject(signal.reason ?? new Error('Aborted'));
}
return new Promise<never>((_, reject) => {
signal.addEventListener(
'abort',
() => reject(signal.reason ?? new Error('Aborted')),
{ once: true },
);
});
}
function resolveWorkDir(session: ISessionScopeHandle): string {
// The session scope does not eagerly expose workDir; the index summary does
// (cwd). We read it lazily from the session context if available, else ''.
// Print mode only uses workDir for display / resume-hint, both of which come
// from the harness's createSession input, so this is a best-effort fallback.
const maybe = (session as { readonly workDir?: string }).workDir;
return maybe ?? '';
}
function normalizePromptInput(
input: string | PromptInput,
): Parameters<IAgentPromptLegacyService['submit']>[0]['content'] {
if (typeof input === 'string') {
if (input.trim().length === 0) {
throw new Error('Prompt input cannot be empty');
}
return [{ type: 'text', text: input }];
}
if (input.length === 0) {
throw new Error('Prompt input cannot be empty');
}
return [...input] as Parameters<IAgentPromptLegacyService['submit']>[0]['content'];
}

View file

@ -64,19 +64,42 @@ const mocks = vi.hoisted(() => {
harnessClose: vi.fn(),
harnessTrack: vi.fn(),
harnessGetCachedAccessToken: vi.fn(),
createV2Harness: vi.fn(async () => ({
homeDir: '/tmp/kimi-code-test-home',
auth: { getCachedAccessToken: mocks.harnessGetCachedAccessToken },
ensureConfigFile: mocks.harnessEnsureConfigFile,
getConfig: mocks.harnessGetConfig,
getConfigDiagnostics: mocks.harnessGetConfigDiagnostics,
getExperimentalFeatures: mocks.harnessGetExperimentalFeatures,
createSession: mocks.harnessCreateSession,
resumeSession: mocks.harnessResumeSession,
listSessions: mocks.harnessListSessions,
close: mocks.harnessClose,
track: mocks.harnessTrack,
})),
runV2Print: vi.fn(
async (
opts: { readonly outputFormat?: string },
version: string,
io?: {
readonly stdout?: { write(chunk: string): boolean };
readonly stderr?: { write(chunk: string): boolean };
},
) => {
// Mirror the native runner's output protocol so the version-banner
// assertions stay meaningful: version first, then the assistant
// message, then the resume hint — in the active output format.
const stdout = io?.stdout ?? process.stdout;
const stderr = io?.stderr ?? process.stderr;
const outputFormat = opts?.outputFormat ?? 'text';
if (outputFormat === 'stream-json') {
stdout.write(
`${JSON.stringify({ role: 'meta', type: 'system.version', version })}\n`,
);
stdout.write(`${JSON.stringify({ role: 'assistant', content: 'hello world' })}\n`);
stdout.write(
`${JSON.stringify({
role: 'meta',
type: 'session.resume_hint',
session_id: 'ses_prompt',
command: 'kimi -r ses_prompt',
content: 'To resume this session: kimi -r ses_prompt',
})}\n`,
);
return;
}
stderr.write(`kimi version ${version}\n`);
stdout.write('• hello world\n\n');
stderr.write('To resume this session: kimi -r ses_prompt\n');
},
),
initializeTelemetry: vi.fn(),
setCrashPhase: vi.fn(),
shutdownTelemetry: vi.fn(),
@ -140,11 +163,11 @@ vi.mock('@moonshot-ai/kimi-telemetry', () => ({
}));
// The experimental v2 engine is loaded via a dynamic import from run-prompt.ts
// when KIMI_CODE_EXPERIMENTAL_FLAG is set. Mock it so tests that flip that flag
// can exercise the experimental path without pulling in the real agent-core-v2
// graph. The returned harness mirrors the v1 mock shape above.
vi.mock('../../src/cli/v2/create-v2-harness', () => ({
createV2Harness: mocks.createV2Harness,
// when KIMI_CODE_EXPERIMENTAL_FLAG is set. Mock the native v2 runner so tests
// that flip that flag can exercise the dispatch without pulling in the real
// agent-core-v2 graph.
vi.mock('../../src/cli/v2/run-v2-print', () => ({
runV2Print: mocks.runV2Print,
}));
function opts(overrides: Partial<Parameters<typeof runPrompt>[0]> = {}) {
@ -1069,7 +1092,7 @@ describe('runPrompt', () => {
// The experimental engine is selected and the version banner is the very
// first write, ahead of any assistant output or the resume hint.
expect(mocks.createV2Harness).toHaveBeenCalled();
expect(mocks.runV2Print).toHaveBeenCalled();
expect(mocks.kimiHarnessConstructor).not.toHaveBeenCalled();
expect(stderr.write).toHaveBeenNthCalledWith(1, 'kimi version 1.2.3-test\n');
expect(stderr.text().startsWith('kimi version 1.2.3-test\n')).toBe(true);
@ -1086,7 +1109,7 @@ describe('runPrompt', () => {
stderr,
});
expect(mocks.createV2Harness).toHaveBeenCalled();
expect(mocks.runV2Print).toHaveBeenCalled();
expect(mocks.kimiHarnessConstructor).not.toHaveBeenCalled();
const lines = stdout.text().split('\n');
expect(lines[0]).toBe(
@ -1102,7 +1125,7 @@ describe('runPrompt', () => {
await runPrompt(opts(), '1.2.3-test', { stdout, stderr });
expect(mocks.createV2Harness).not.toHaveBeenCalled();
expect(mocks.runV2Print).not.toHaveBeenCalled();
expect(mocks.kimiHarnessConstructor).toHaveBeenCalled();
expect(stderr.text()).not.toContain('kimi version');
});

View file

@ -0,0 +1,223 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
IAgentGoalService,
IAgentLifecycleService,
IAgentPermissionModeService,
IAgentProfileService,
IAgentPromptService,
IAgentTaskService,
IAuthSummaryService,
IConfigService,
IEventBus,
IFileSystemStorageService,
IOAuthToolkit,
ISessionIndex,
ISessionLifecycleService,
ITelemetryService,
type DomainEvent,
} from '@moonshot-ai/agent-core-v2';
import { runV2Print } from '../../src/cli/v2/run-v2-print';
const mocks = vi.hoisted(() => ({
bootstrap: vi.fn(),
ensureMainAgent: vi.fn(),
createKimiDefaultHeaders: vi.fn(() => ({})),
resolveKimiHome: vi.fn((homeDir?: string) => homeDir ?? '/tmp/kimi-code-test-home'),
createKimiDeviceId: vi.fn(() => 'device-1'),
}));
vi.mock('@moonshot-ai/agent-core-v2', async (importOriginal) => {
const actual = await importOriginal<typeof import('@moonshot-ai/agent-core-v2')>();
return {
...actual,
bootstrap: mocks.bootstrap,
ensureMainAgent: mocks.ensureMainAgent,
};
});
vi.mock('@moonshot-ai/kimi-code-oauth', async () => {
const actual = await vi.importActual<typeof import('@moonshot-ai/kimi-code-oauth')>(
'@moonshot-ai/kimi-code-oauth',
);
return {
...actual,
createKimiDefaultHeaders: mocks.createKimiDefaultHeaders,
createKimiDeviceId: mocks.createKimiDeviceId,
};
});
vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => {
const actual = await importOriginal<typeof import('@moonshot-ai/kimi-code-sdk')>();
return {
...actual,
resolveKimiHome: mocks.resolveKimiHome,
};
});
vi.mock('@moonshot-ai/kimi-telemetry', () => ({
initializeTelemetry: vi.fn(),
setCrashPhase: vi.fn(),
shutdownTelemetry: vi.fn(),
track: vi.fn(),
setTelemetryContext: vi.fn(),
withTelemetryContext: vi.fn(() => ({ track: vi.fn() })),
}));
interface FakeScope {
readonly id: string;
readonly accessor: { readonly get: (token: unknown) => unknown };
readonly dispose: ReturnType<typeof vi.fn>;
}
function fakeScope(id: string, services: Map<unknown, unknown>): FakeScope {
return {
id,
accessor: {
get: (token: unknown) => {
if (!services.has(token)) throw new Error(`unexpected service request: ${String(token)}`);
return services.get(token);
},
},
dispose: vi.fn(),
};
}
function writer() {
let text = '';
return {
write: vi.fn((chunk: string) => {
text += chunk;
return true;
}),
text: () => text,
};
}
function opts(overrides: Record<string, unknown> = {}) {
return {
session: undefined,
continue: false,
yolo: false,
auto: false,
plan: false,
model: undefined,
outputFormat: undefined,
prompt: 'say hello',
skillsDirs: [],
addDirs: [],
...overrides,
} as const;
}
describe('runV2Print', () => {
beforeEach(() => {
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '1');
});
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllEnvs();
});
it('submits a prompt, renders native events, awaits completion, and drains', async () => {
const stdout = writer();
const stderr = writer();
// Native event listeners registered on the main agent's IEventBus; the turn
// emits a streaming assistant delta before completing.
const eventListeners = new Set<(event: DomainEvent) => void>();
const agentServices = new Map<unknown, unknown>([
[IAgentProfileService, { setModel: vi.fn(async () => ({ model: 'k2' })), getModel: () => 'k2' }],
[IAgentPermissionModeService, { mode: 'auto', setMode: vi.fn() }],
[IAuthSummaryService, { ensureReady: vi.fn(async () => {}) }],
[
IEventBus,
{
subscribe: vi.fn((handler: (event: DomainEvent) => void) => {
eventListeners.add(handler);
return { dispose: () => eventListeners.delete(handler) };
}),
},
],
[
IAgentPromptService,
{
prompt: vi.fn(async () => {
// Emit a native assistant delta on the main agent bus, then complete.
for (const listener of [...eventListeners]) {
listener({ type: 'assistant.delta', turnId: 1, delta: 'hello world' } as DomainEvent);
}
return {
id: 1,
result: Promise.resolve({ reason: 'completed' }),
};
}),
},
],
[IAgentTaskService, { list: vi.fn(() => []) }],
[IAgentGoalService, { createGoal: vi.fn(), getGoal: vi.fn() }],
]);
const agent = fakeScope('main', agentServices);
const sessionServices = new Map<unknown, unknown>([
// drain enumerates agents; empty → no background work to wait on.
[IAgentLifecycleService, { list: vi.fn(() => []) }],
]);
const session = fakeScope('ses_v2', sessionServices);
const appServices = new Map<unknown, unknown>([
[
IConfigService,
{
ready: Promise.resolve(),
get: vi.fn((section: string) => (section === 'defaultModel' ? 'k2' : undefined)),
diagnostics: vi.fn(() => []),
},
],
[
ISessionLifecycleService,
{
create: vi.fn(async () => session),
resume: vi.fn(async () => session),
},
],
[ISessionIndex, { list: vi.fn(async () => ({ items: [] })) }],
[IOAuthToolkit, { getCachedAccessToken: vi.fn(async () => undefined) }],
[IFileSystemStorageService, {}],
[
ITelemetryService,
(() => {
const svc = {
setAppender: vi.fn(),
setContext: vi.fn(),
track: vi.fn(),
shutdown: vi.fn(async () => {}),
withContext: vi.fn(() => svc),
};
return svc;
})(),
],
]);
const app = fakeScope('app', appServices);
mocks.bootstrap.mockReturnValue({ app });
mocks.ensureMainAgent.mockResolvedValue(agent);
await runV2Print(opts() as never, '1.2.3-test', { stdout, stderr });
const promptService = agentServices.get(IAgentPromptService) as { prompt: ReturnType<typeof vi.fn> };
expect(promptService.prompt).toHaveBeenCalledWith({
role: 'user',
content: [{ type: 'text', text: 'say hello' }],
toolCalls: [],
origin: { kind: 'user' },
});
// Version banner is first, then the rendered assistant output.
expect(stderr.write).toHaveBeenNthCalledWith(1, 'kimi version 1.2.3-test\n');
expect(stdout.text()).toContain('hello world');
expect(app.dispose).toHaveBeenCalled();
});
});

View file

@ -1,92 +0,0 @@
import type { DomainEvent, IEventBus } from '@moonshot-ai/agent-core-v2';
import type { Event } from '@moonshot-ai/kimi-code-sdk';
import { describe, expect, it } from 'vitest';
import { subscribeAgentEvents } from '../../../src/cli/v2/adapt-events';
class MockEventBus {
private readonly listeners = new Set<(event: DomainEvent) => void>();
publish(event: DomainEvent): void {
for (const listener of this.listeners) listener(event);
}
subscribe(handler: (event: DomainEvent) => void): { dispose: () => void } {
this.listeners.add(handler);
return {
dispose: () => {
this.listeners.delete(handler);
},
};
}
}
function asBus(bus: MockEventBus): IEventBus {
return bus as unknown as IEventBus;
}
describe('subscribeAgentEvents', () => {
it('attaches agentId and sessionId to a streaming event', () => {
const bus = new MockEventBus();
const received: Event[] = [];
subscribeAgentEvents(asBus(bus), 'sess-1', 'main', (event) => received.push(event));
bus.publish({ type: 'assistant.delta', turnId: 1, delta: 'hi' } as DomainEvent);
expect(received).toEqual([
{ type: 'assistant.delta', turnId: 1, delta: 'hi', agentId: 'main', sessionId: 'sess-1' },
]);
});
it('passes through turn.ended unchanged apart from agentId/sessionId', () => {
const bus = new MockEventBus();
const received: Event[] = [];
subscribeAgentEvents(asBus(bus), 'sess-2', 'main', (event) => received.push(event));
bus.publish({ type: 'turn.ended', turnId: 3, reason: 'completed' } as DomainEvent);
expect(received[0]).toMatchObject({
type: 'turn.ended',
turnId: 3,
reason: 'completed',
agentId: 'main',
sessionId: 'sess-2',
});
});
it('remaps task.started to background.task.started', () => {
const bus = new MockEventBus();
const received: Event[] = [];
subscribeAgentEvents(asBus(bus), 's', 'main', (event) => received.push(event));
bus.publish({ type: 'task.started', info: { taskId: 't1' } } as unknown as DomainEvent);
expect(received[0]!.type).toBe('background.task.started');
expect(received[0]!).toMatchObject({ agentId: 'main', sessionId: 's' });
});
it('remaps task.terminated to background.task.terminated', () => {
const bus = new MockEventBus();
const received: Event[] = [];
subscribeAgentEvents(asBus(bus), 's', 'main', (event) => received.push(event));
bus.publish({ type: 'task.terminated', info: { taskId: 't2' } } as unknown as DomainEvent);
expect(received[0]!.type).toBe('background.task.terminated');
});
it('stops delivering after unsubscribe', () => {
const bus = new MockEventBus();
const received: Event[] = [];
const unsubscribe = subscribeAgentEvents(asBus(bus), 's', 'main', (event) =>
received.push(event),
);
bus.publish({ type: 'assistant.delta', turnId: 1, delta: 'a' } as DomainEvent);
unsubscribe();
bus.publish({ type: 'assistant.delta', turnId: 1, delta: 'b' } as DomainEvent);
expect(received).toHaveLength(1);
expect((received[0] as { delta: string }).delta).toBe('a');
});
});

View file

@ -1,288 +0,0 @@
import {
IAgentLifecycleService,
IAgentLoopService,
IAgentPromptService,
IAgentTaskService,
IConfigService,
type AgentTaskInfo,
type IAgentScopeHandle,
type ISessionScopeHandle,
type Scope,
} from '@moonshot-ai/agent-core-v2';
import { describe, expect, it } from 'vitest';
import { V2Session } from '../../../src/cli/v2/v2-session';
interface FakeTask {
readonly taskId: string;
readonly kind?: AgentTaskInfo['kind'];
/** ms until this task completes once `wait` is called on it. */
readonly completesInMs: number;
/** Optional task to spawn (append to the active list) when this task completes. */
readonly spawnsOnComplete?: FakeTask;
active: boolean;
}
class FakeTaskService {
readonly suppressed: string[] = [];
readonly waitCalls: Array<{ taskId: string; timeoutMs: number | undefined }> = [];
constructor(private readonly tasks: FakeTask[]) {}
list(activeOnly?: boolean): readonly AgentTaskInfo[] {
return this.tasks
.filter((task) => !activeOnly || task.active)
.map(
(task) =>
({
taskId: task.taskId,
kind: task.kind ?? 'process',
status: 'running',
}) as unknown as AgentTaskInfo,
);
}
suppressTerminalNotification(taskId: string): Promise<void> {
this.suppressed.push(taskId);
return Promise.resolve();
}
wait(taskId: string, timeoutMs?: number): Promise<AgentTaskInfo | undefined> {
this.waitCalls.push({ taskId, timeoutMs });
const task = this.tasks.find((entry) => entry.taskId === taskId);
const completesInMs = task?.completesInMs ?? 0;
const completed =
task !== undefined && completesInMs <= (timeoutMs ?? Number.POSITIVE_INFINITY);
const waitMs = timeoutMs === undefined ? completesInMs : Math.min(completesInMs, timeoutMs);
return new Promise((resolve) => {
setTimeout(() => {
if (completed && task !== undefined) {
task.active = false;
if (task.spawnsOnComplete !== undefined) this.tasks.push(task.spawnsOnComplete);
}
resolve({
taskId,
status: completed ? 'completed' : 'running',
} as unknown as AgentTaskInfo);
}, waitMs);
});
}
}
function fakeAccessor(map: Map<unknown, unknown>) {
return { get: (token: unknown) => map.get(token) };
}
class FakeAfterStepSlot {
registration:
| {
id: string;
handler: (ctx: Record<string, unknown>, next: () => Promise<void>) => Promise<void>;
options: unknown;
}
| undefined;
register(
id: string,
handler: (ctx: Record<string, unknown>, next: () => Promise<void>) => Promise<void>,
options?: unknown,
) {
this.registration = { id, handler, options };
return { dispose: () => {} };
}
async run(ctx: Record<string, unknown>): Promise<void> {
if (this.registration === undefined) return;
await this.registration.handler(ctx, async () => {});
}
}
class FakeLoopService {
readonly afterStep = new FakeAfterStepSlot();
readonly hooks = {
beforeStep: { register: () => ({ dispose: () => {} }) },
afterStep: this.afterStep,
onError: { register: () => ({ dispose: () => {} }) },
};
}
function buildSession(options: {
ceilingS?: number;
keepAliveOnExit?: boolean;
taskServices: FakeTaskService[];
drainAgentTasksOnStop?: boolean;
loop?: FakeLoopService;
}): V2Session {
const taskConfig =
options.ceilingS !== undefined || options.keepAliveOnExit !== undefined
? {
keepAliveOnExit: options.keepAliveOnExit,
printWaitCeilingS: options.ceilingS,
}
: undefined;
const coreMap = new Map<unknown, unknown>([
[
IConfigService,
{
get: (section: string) => (section === 'task' ? taskConfig : undefined),
},
],
]);
const agentHandles: IAgentScopeHandle[] = options.taskServices.map((service) => {
const agentMap = new Map<unknown, unknown>([[IAgentTaskService, service]]);
return { accessor: fakeAccessor(agentMap) } as unknown as IAgentScopeHandle;
});
const mainAgentMap = new Map<unknown, unknown>();
if (options.taskServices[0] !== undefined) {
mainAgentMap.set(IAgentTaskService, options.taskServices[0]);
}
if (options.loop !== undefined) {
mainAgentMap.set(IAgentLoopService, options.loop);
mainAgentMap.set(IAgentPromptService, {});
}
const sessionMap = new Map<unknown, unknown>([
[
IAgentLifecycleService,
{
list: () => agentHandles,
},
],
]);
return new V2Session({
core: { accessor: fakeAccessor(coreMap) } as unknown as Scope,
session: { id: 'sess-1', accessor: fakeAccessor(sessionMap) } as unknown as ISessionScopeHandle,
agent: { id: 'main', accessor: fakeAccessor(mainAgentMap) } as unknown as IAgentScopeHandle,
drainAgentTasksOnStop: options.drainAgentTasksOnStop,
});
}
describe('V2Session.waitForBackgroundTasksOnPrint', () => {
it('returns immediately when there are no active background tasks', async () => {
const service = new FakeTaskService([]);
const session = buildSession({ keepAliveOnExit: true, taskServices: [service] });
await session.waitForBackgroundTasksOnPrint();
expect(service.waitCalls).toHaveLength(0);
});
it('returns immediately when keepAliveOnExit is not enabled', async () => {
const service = new FakeTaskService([{ taskId: 'a', completesInMs: 20, active: true }]);
const session = buildSession({ taskServices: [service] });
await session.waitForBackgroundTasksOnPrint();
expect(service.waitCalls).toHaveLength(0);
expect(service.suppressed).toHaveLength(0);
});
it('waits for a background task to complete and bounds the wait by the default ceiling, not 30s', async () => {
const service = new FakeTaskService([{ taskId: 'a', completesInMs: 20, active: true }]);
const session = buildSession({ keepAliveOnExit: true, taskServices: [service] });
await session.waitForBackgroundTasksOnPrint();
expect(service.waitCalls).toHaveLength(1);
expect(service.waitCalls[0]?.taskId).toBe('a');
// The old implementation hardcoded a 30s cap; the drain must use the 1h
// default ceiling so long tasks are allowed to finish.
expect(service.waitCalls[0]?.timeoutMs).toBeGreaterThan(30_000);
expect(service.suppressed).toContain('a');
});
it('honors [task].print_wait_ceiling_s as the wait bound', async () => {
const service = new FakeTaskService([
{ taskId: 'stuck', completesInMs: Number.POSITIVE_INFINITY, active: true },
]);
const session = buildSession({ ceilingS: 1, keepAliveOnExit: true, taskServices: [service] });
const startedAt = Date.now();
await session.waitForBackgroundTasksOnPrint();
const elapsed = Date.now() - startedAt;
expect(service.waitCalls[0]?.timeoutMs).toBeLessThanOrEqual(1000);
expect(service.waitCalls[0]?.timeoutMs).toBeGreaterThan(0);
// Returns near the 1s ceiling, never hangs until the (infinite) task.
expect(elapsed).toBeLessThan(5_000);
});
it('re-enumerates to drain tasks spawned by a completing task', async () => {
const spawned: FakeTask = { taskId: 'b', completesInMs: 20, active: true };
const service = new FakeTaskService([
{ taskId: 'a', completesInMs: 20, active: true, spawnsOnComplete: spawned },
]);
const session = buildSession({ keepAliveOnExit: true, taskServices: [service] });
await session.waitForBackgroundTasksOnPrint();
const waitedIds = service.waitCalls.map((call) => call.taskId);
expect(waitedIds).toContain('a');
expect(waitedIds).toContain('b');
expect(service.suppressed).toEqual(expect.arrayContaining(['a', 'b']));
});
});
describe('V2Session print drain hook', () => {
it('waits for active background subagents before the print turn ends', async () => {
const loop = new FakeLoopService();
const spawned: FakeTask = {
taskId: 'agent-b',
kind: 'agent',
completesInMs: 20,
active: true,
};
const service = new FakeTaskService([
{
taskId: 'agent-a',
kind: 'agent',
completesInMs: 20,
active: true,
spawnsOnComplete: spawned,
},
]);
buildSession({
taskServices: [service],
drainAgentTasksOnStop: true,
loop,
});
expect(loop.afterStep.registration?.id).toBe('print-drain-agent-tasks');
expect(loop.afterStep.registration?.options).toEqual({ after: 'prompt-service-steer' });
const ctx = {
signal: new AbortController().signal,
finishReason: 'completed',
continue: false,
};
await loop.afterStep.run(ctx);
expect(service.waitCalls.map((call) => call.taskId)).toEqual(['agent-a', 'agent-b']);
expect(ctx.continue).toBe(true);
});
it('does not hold the print turn for non-agent background tasks', async () => {
const loop = new FakeLoopService();
const service = new FakeTaskService([
{ taskId: 'proc-a', kind: 'process', completesInMs: 20, active: true },
]);
buildSession({
taskServices: [service],
drainAgentTasksOnStop: true,
loop,
});
const ctx = {
signal: new AbortController().signal,
finishReason: 'completed',
continue: false,
};
await loop.afterStep.run(ctx);
expect(service.waitCalls).toHaveLength(0);
expect(ctx.continue).toBe(false);
});
});

View file

@ -132,6 +132,7 @@ const DOMAIN_LAYER = new Map([
['modelCatalog', 3],
['agentProfileCatalog', 3],
// L4 — agent behaviour
['activity', 4],
['context', 4],
['message', 4],
['turn', 4],

View file

@ -0,0 +1,121 @@
/**
* `activity` domain (L4) Agent / Session activity kernel contracts.
*
* Defines the authoritative activity state machines shared by the Agent and
* Session scopes. `IAgentActivityService` is the Agent-scope lane machine: it
* owns turn admission (`begin`/`tryBegin`), cancellation, background-activity
* registration and disposal settlement, and is the sole dispatcher of the
* `activityLane` wire Model (`activityOps`). `ISessionActivityKernel` is the
* Session-scope lifecycle lane + admission table that the Agent kernel consults
* synchronously on every `begin` (child-injects-parent), so admission stays
* atomic inside a single event-loop turn. The `ActivityLease` returned by
* `begin` carries the turn's `AbortSignal` and is the only path back to `idle`
* (`lease.end`). Multi-scope domain: `IAgentActivityService` bound at Agent
* scope, `ISessionActivityKernel` bound at Session scope.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { IDisposable } from '#/_base/di/lifecycle';
import type { PromptOrigin } from '#/agent/contextMemory/types';
export type AgentLane = 'initializing' | 'idle' | 'turn' | 'disposing' | 'disposed';
export interface BeginOptions {
/** Turn source, forwarded to the lease and the snapshot; admission is origin-agnostic. */
readonly origin?: PromptOrigin;
}
export interface ActivityLease {
readonly kind: 'turn';
readonly turnId: number;
readonly origin: PromptOrigin;
/** Cancellation flows one way from the kernel: `cancel()` aborts this signal. */
readonly signal: AbortSignal;
/** True once `cancel()` has been issued and the turn is draining. */
readonly ending: boolean;
/** Must be called in a `finally`; idempotent. Returns the lane to `idle` and records the outcome. */
end(outcome: 'completed' | 'cancelled' | 'failed', detail?: { error?: unknown }): void;
}
export interface BackgroundActivityRef {
readonly kind: 'compaction' | 'task' | (string & {});
readonly id: string;
readonly since: number;
readonly signal: AbortSignal;
}
export interface IAgentActivityService {
readonly _serviceBrand: undefined;
lane(): AgentLane;
/**
* Atomic admission: synchronously performs "session admission consult own
* lane check enter turn lane issue lease register with the session
* kernel". Any failing step throws a coded error with no state residue. The
* synchronous shape (no `await`) is what makes admission atomic under the
* single-threaded event loop.
*/
begin(kind: 'turn', opts?: BeginOptions): ActivityLease;
/** Non-throwing variant: returns `undefined` when admission fails. */
tryBegin(kind: 'turn', opts?: BeginOptions): ActivityLease | undefined;
/** Unified cancel: `turn(active)` → `turn(ending)` and aborts the lease signal. Idempotent. */
cancel(reason?: unknown): boolean;
/** Registers a background activity (compaction etc.): visible, cancellable, aborted on disposal. */
registerBackground(kind: string, controller: AbortController): IDisposable & { readonly id: string };
/** Enters `disposing`: rejects new `begin`, aborts every lease and background activity. */
beginDisposal(): void;
/** Resolves once every lease and background activity has drained. Awaited by `agentLifecycle`. */
settled(): Promise<void>;
}
export const IAgentActivityService: ServiceIdentifier<IAgentActivityService> =
createDecorator<IAgentActivityService>('agentActivityService');
export type SessionLane = 'restoring' | 'active' | 'quiescing' | 'closing' | 'disposed';
export type SessionCommand =
| 'turn.begin'
| 'agent.create'
| 'session.fork'
| 'session.archive'
| 'session.close'
| (string & {});
export interface SessionQuiesceLease extends IDisposable {
readonly reason: string;
}
export interface ISessionActivityKernel {
readonly _serviceBrand: undefined;
lane(): SessionLane;
/** Admission table for edge (gateway / rpc / legacy) and `agentLifecycle` commands. */
canAccept(command: SessionCommand): boolean;
/**
* Called synchronously by the Agent kernel on `begin` (child-injects-parent):
* throws `activity.session_rejected` while `quiescing` / `closing` /
* `restoring`; otherwise registers the lease for settle tracking and returns
* its unregister handle.
*/
admitTurn(agentId: string, lease: ActivityLease): IDisposable;
/**
* Atomically acquires global quiescence: synchronously flips the lane to
* `quiescing` (closing the door so subsequent `admitTurn` calls reject), then
* awaits every in-flight lease to drain.
*/
quiesce(reason: string): Promise<SessionQuiesceLease>;
beginClosing(): void;
settled(): Promise<void>;
}
export const ISessionActivityKernel: ServiceIdentifier<ISessionActivityKernel> =
createDecorator<ISessionActivityKernel>('sessionActivityKernel');

View file

@ -0,0 +1,75 @@
/**
* `activity` domain (L4) wire Model (`LaneModel`) and the `activity.set_lane`
* Op that holds the Agent activity lane.
*
* The lane is a live-only Model (`persist: false`): nothing is persisted or
* replayed, so a resumed agent starts back at `idle`. The Agent kernel
* (`agentActivityService`) is the sole dispatcher of `setLane`; `apply` returns
* the SAME reference when the incoming state is unchanged under `laneEqual`
* (which ignores the `since` / `at` timestamps) so redundant dispatches do not
* flood subscribers. The Op derives no event here the outward snapshot event
* is emitted by the projector so there is a single event source (PR5). The
* initial lane is `idle` (fresh agents accept turns immediately); the
* half-replay window is gated at the Session kernel (`restoring`), not here.
* Consumed by the Agent-scope `agentActivityService` and (PR5) the projector.
*/
import { defineModel } from '#/wire/model';
import { defineOp } from '#/wire/op';
import type { PromptOrigin } from '#/agent/contextMemory/types';
import type { AgentLane, BackgroundActivityRef } from './activity';
export interface LaneTurnState {
readonly turnId: number;
readonly origin: PromptOrigin;
readonly ending: boolean;
readonly endingReason?: 'aborted' | 'max_steps' | 'error';
readonly since: number;
}
export interface LaneLastTurnState {
readonly turnId: number;
readonly reason: 'completed' | 'cancelled' | 'failed';
readonly at: number;
}
export interface LaneModelState {
readonly lane: AgentLane;
readonly turn?: LaneTurnState;
readonly lastTurn?: LaneLastTurnState;
readonly background: readonly BackgroundActivityRef[];
}
export const LaneModel = defineModel<LaneModelState>('activityLane', () => ({
lane: 'idle',
background: [],
}));
export const setLane = defineOp(LaneModel, 'activity.set_lane', {
persist: false,
apply: (s, p: { next: LaneModelState }): LaneModelState =>
laneEqual(s, p.next) ? s : p.next,
});
export function laneEqual(a: LaneModelState, b: LaneModelState): boolean {
if (a.lane !== b.lane) return false;
if (a.background.length !== b.background.length) return false;
if ((a.turn === undefined) !== (b.turn === undefined)) return false;
if (a.turn !== undefined && b.turn !== undefined) {
if (
a.turn.turnId !== b.turn.turnId ||
a.turn.ending !== b.turn.ending ||
a.turn.endingReason !== b.turn.endingReason
) {
return false;
}
}
if ((a.lastTurn === undefined) !== (b.lastTurn === undefined)) return false;
if (a.lastTurn !== undefined && b.lastTurn !== undefined) {
if (a.lastTurn.turnId !== b.lastTurn.turnId || a.lastTurn.reason !== b.lastTurn.reason) {
return false;
}
}
return true;
}

View file

@ -0,0 +1,281 @@
/**
* `activity` domain (L4) `IAgentActivityService` implementation.
*
* Owns the Agent activity lane (`idle ⇄ turn(active|ending)`, plus `disposing`
* / `disposed`) and is the sole dispatcher of the `activityLane` wire Model
* (`activity.set_lane`). `begin('turn')` atomically consults the Session kernel
* (`ISessionActivityKernel.admitTurn`, child-injects-parent), reads the next
* turn id from the `turn` `TurnModel`, enters the turn lane and returns an
* `ActivityLease`; the lease's `AbortSignal` is the only cancellation channel,
* and `lease.end()` is the only path back to `idle`. Background activities
* (`registerBackground`) are tracked so disposal can abort and await them. The
* lane starts at `idle` so fresh agents accept turns immediately; the
* half-replay window is gated by the Session kernel (`restoring`), not here.
* Bound at Agent scope.
*/
import { Disposable, type IDisposable } from '#/_base/di/lifecycle';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { userCancellationReason } from '#/_base/utils/abort';
import { ErrorCodes, KimiError } from '#/errors';
import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types';
import type { PromptOrigin } from '#/agent/contextMemory/types';
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { TurnModel } from '#/agent/turn/turnOps';
import { IAgentWireService } from '#/wire/tokens';
import type { IWireService } from '#/wire/wireService';
import type {
ActivityLease,
AgentLane,
BackgroundActivityRef,
BeginOptions,
} from './activity';
import { IAgentActivityService, ISessionActivityKernel } from './activity';
import { type LaneLastTurnState, LaneModel, setLane } from './activityOps';
let nextBackgroundId = 0;
interface BackgroundEntry {
readonly ref: BackgroundActivityRef;
readonly controller: AbortController;
}
class LeaseImpl implements ActivityLease {
readonly kind = 'turn' as const;
readonly origin: PromptOrigin;
readonly turnId: number;
readonly since: number;
private readonly controller = new AbortController();
private _ending = false;
private _ended = false;
private _endingReason: 'aborted' | 'max_steps' | 'error' | undefined;
registration: IDisposable = Disposable.None;
constructor(
turnId: number,
origin: PromptOrigin,
private readonly owner: AgentActivityService,
) {
this.turnId = turnId;
this.origin = origin;
this.since = Date.now();
}
get signal(): AbortSignal {
return this.controller.signal;
}
get ending(): boolean {
return this._ending;
}
get endingReason(): 'aborted' | 'max_steps' | 'error' | undefined {
return this._endingReason;
}
markEnding(reason?: unknown): void {
if (this._ending || this._ended) return;
this._ending = true;
this._endingReason = 'aborted';
this.controller.abort(reason ?? userCancellationReason());
}
end(outcome: 'completed' | 'cancelled' | 'failed', detail?: { error?: unknown }): void {
if (this._ended) return;
this._ended = true;
if (outcome === 'failed' && this._endingReason === undefined) {
this._endingReason = 'error';
}
this.owner.onLeaseEnd(this, outcome, detail);
}
}
export class AgentActivityService extends Disposable implements IAgentActivityService {
declare readonly _serviceBrand: undefined;
private _lane: AgentLane = 'idle';
private activeLease: LeaseImpl | undefined;
private lastTurn: LaneLastTurnState | undefined;
private readonly background = new Map<string, BackgroundEntry>();
private readonly settleWaiters: Array<() => void> = [];
constructor(
@IAgentWireService private readonly wire: IWireService,
@ISessionActivityKernel private readonly sessionKernel: ISessionActivityKernel,
@IAgentScopeContext private readonly scopeContext: IAgentScopeContext,
) {
super();
this._register(this.wire.onRestored(() => this.onRestored()));
}
lane(): AgentLane {
return this._lane;
}
begin(kind: 'turn', opts?: BeginOptions): ActivityLease {
if (kind !== 'turn') {
throw new KimiError(ErrorCodes.NOT_IMPLEMENTED, `Unsupported activity kind: ${kind}`);
}
switch (this._lane) {
case 'turn':
throw new KimiError(
ErrorCodes.ACTIVITY_AGENT_BUSY,
`Cannot begin a new turn while turn ${this.activeLease?.turnId ?? '?'} is active`,
{ details: { turnId: this.activeLease?.turnId } },
);
case 'disposing':
throw new KimiError(ErrorCodes.ACTIVITY_DISPOSING, 'Agent is disposing');
case 'disposed':
throw new KimiError(ErrorCodes.ACTIVITY_DISPOSED, 'Agent is disposed');
case 'initializing':
throw new KimiError(ErrorCodes.ACTIVITY_INITIALIZING, 'Agent is still restoring');
case 'idle':
break;
}
const turnId = this.wire.getModel(TurnModel).nextTurnId;
const origin = opts?.origin ?? USER_PROMPT_ORIGIN;
const lease = new LeaseImpl(turnId, origin, this);
// Session admission consult + lease registration. Throws `activity.session_rejected`
// when the session is restoring / quiescing / closing; no lane state is touched yet.
lease.registration = this.sessionKernel.admitTurn(this.scopeContext.agentId, lease);
this.activeLease = lease;
this._lane = 'turn';
this.publishLane();
return lease;
}
tryBegin(kind: 'turn', opts?: BeginOptions): ActivityLease | undefined {
try {
return this.begin(kind, opts);
} catch (error) {
if (error instanceof KimiError) return undefined;
throw error;
}
}
cancel(reason?: unknown): boolean {
const lease = this.activeLease;
if (lease === undefined) return false;
if (lease.ending) return true;
lease.markEnding(reason);
this.publishLane();
return true;
}
registerBackground(kind: string, controller: AbortController): IDisposable & { readonly id: string } {
const id = `bg-${nextBackgroundId++}`;
const ref: BackgroundActivityRef = {
kind,
id,
since: Date.now(),
signal: controller.signal,
};
this.background.set(id, { ref, controller });
this.publishLane();
const dispose = (): void => {
if (this.background.delete(id)) {
this.publishLane();
}
this.maybeSettle();
};
return { id, dispose };
}
beginDisposal(): void {
if (this._lane === 'disposing' || this._lane === 'disposed') return;
this._lane = 'disposing';
this.activeLease?.markEnding();
for (const entry of this.background.values()) {
entry.controller.abort();
}
this.publishLane();
this.maybeSettle();
}
settled(): Promise<void> {
if (this._lane === 'disposed') return Promise.resolve();
if (
this._lane !== 'disposing' &&
this.activeLease === undefined &&
this.background.size === 0
) {
return Promise.resolve();
}
return new Promise<void>((resolve) => {
this.settleWaiters.push(resolve);
});
}
onLeaseEnd(
lease: LeaseImpl,
outcome: 'completed' | 'cancelled' | 'failed',
_detail?: { error?: unknown },
): void {
if (this.activeLease !== lease) return;
this.activeLease = undefined;
lease.registration.dispose();
lease.registration = Disposable.None;
this.lastTurn = { turnId: lease.turnId, reason: outcome, at: Date.now() };
if (this._lane === 'disposing') {
this.maybeSettle();
return;
}
this._lane = 'idle';
this.publishLane();
this.maybeSettle();
}
private onRestored(): void {
// Resume returns the live-only lane to idle. No-op when already idle.
if (this._lane === 'idle') return;
if (this._lane === 'turn' || this._lane === 'disposing' || this._lane === 'disposed') return;
this._lane = 'idle';
this.publishLane();
}
private maybeSettle(): void {
if (this.activeLease !== undefined || this.background.size > 0) return;
if (this._lane === 'disposing') {
this._lane = 'disposed';
this.publishLane();
}
if (this.settleWaiters.length === 0) return;
const waiters = this.settleWaiters.splice(0);
for (const resolve of waiters) resolve();
}
private publishLane(): void {
const lease = this.activeLease;
this.wire.dispatch(
setLane({
next: {
lane: this._lane,
turn:
lease === undefined
? undefined
: {
turnId: lease.turnId,
origin: lease.origin,
ending: lease.ending,
endingReason: lease.endingReason,
since: lease.since,
},
lastTurn: this.lastTurn,
background: [...this.background.values()].map((entry) => entry.ref),
},
}),
);
}
}
registerScopedService(
LifecycleScope.Agent,
IAgentActivityService,
AgentActivityService,
InstantiationType.Delayed,
'activity',
);

View file

@ -0,0 +1,28 @@
/**
* `activity` domain error codes.
*
* `activity.agent_busy` inherits the retryable semantics of the legacy
* `turn.agent_busy`; the two coexist during migration, and `turn.*` callers
* move to the new code before the legacy one is retired.
*/
import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes';
export const ActivityErrors = {
codes: {
ACTIVITY_AGENT_BUSY: 'activity.agent_busy',
ACTIVITY_CANCELLING: 'activity.cancelling',
ACTIVITY_DISPOSING: 'activity.disposing',
ACTIVITY_DISPOSED: 'activity.disposed',
ACTIVITY_INITIALIZING: 'activity.initializing',
ACTIVITY_SESSION_REJECTED: 'activity.session_rejected',
},
retryable: [
'activity.agent_busy',
'activity.cancelling',
'activity.initializing',
'activity.session_rejected',
],
} as const satisfies ErrorDomain;
registerErrorDomain(ActivityErrors);

View file

@ -0,0 +1,64 @@
/**
* `activity` domain (L4) `ISessionActivityKernel` implementation (PR1 placeholder).
*
* PR1 only needs the Session kernel to exist so the Agent kernel can consult it
* on `begin`; the real lifecycle lane machine (`restoring → active ⇄ quiescing
* closing disposed`), the admission table and `quiesce` arrive in PR3. This
* placeholder is always `active`: `canAccept` admits every command, `admitTurn`
* registers the lease for settle tracking and never rejects, and `quiesce` /
* `settled` resolve immediately. Lease registration is kept so the PR3 swap is
* a behavior change rather than a structural one. Bound at Session scope.
*/
import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import type {
ActivityLease,
SessionCommand,
SessionLane,
SessionQuiesceLease,
} from './activity';
import { ISessionActivityKernel } from './activity';
export class SessionActivityKernel extends Disposable implements ISessionActivityKernel {
declare readonly _serviceBrand: undefined;
private readonly leases = new Set<ActivityLease>();
lane(): SessionLane {
return 'active';
}
canAccept(_command: SessionCommand): boolean {
return true;
}
admitTurn(_agentId: string, lease: ActivityLease): IDisposable {
this.leases.add(lease);
return toDisposable(() => {
this.leases.delete(lease);
});
}
quiesce(reason: string): Promise<SessionQuiesceLease> {
return Promise.resolve({ reason, dispose: () => undefined });
}
beginClosing(): void {
// PR3 drives the closing cascade.
}
settled(): Promise<void> {
return Promise.resolve();
}
}
registerScopedService(
LifecycleScope.Session,
ISessionActivityKernel,
SessionActivityKernel,
InstantiationType.Delayed,
'activity',
);

View file

@ -139,7 +139,8 @@ export const contextAppendLoopEvent = defineOp(ContextModel, 'context.append_loo
});
export const contextClear = defineOp(ContextModel, 'context.clear', {
apply: (state): ContextMessage[] => (state.length === 0 ? state : resetFold([]) as ContextMessage[]),
apply: (state): ContextMessage[] =>
state.length === 0 ? state : (resetFold([]) as ContextMessage[]),
});
interface ContextCompactionBasePayload {
@ -318,12 +319,63 @@ export function computeUndoCut(state: readonly ContextMessage[], count: number):
return { cutIndex, removedCount, stoppedAtCompaction };
}
/** Whether a {@link computeUndoCut} result satisfied the full requested `count`. */
export function isFullyUndoable(cut: UndoCut, count: number): boolean {
return cut.cutIndex >= 0 && cut.removedCount >= count;
}
/** Structured reason an undo cannot proceed, derived from a {@link UndoCut}. */
export type UndoUnavailableReason = 'empty' | 'compaction_boundary' | 'insufficient';
/**
* Result of checking whether `count` real-user prompts can be undone. Returns
* `{ ok: true }` when the cut is fully undoable, otherwise a structured reason
* (`empty` when no real-user prompt exists, `compaction_boundary` when the scan
* hits a compaction summary first, `insufficient` when some exist but fewer
* than `count`) plus the number that *could* be undone. Shared by the live
* `IAgentPromptService.undo` (which throws on `!ok`) and tests.
*/
export type UndoPrecheck =
| { readonly ok: true }
| {
readonly ok: false;
readonly reason: UndoUnavailableReason;
readonly requested: number;
readonly undoable: number;
};
/** Classify a history against an undo `count` (wraps {@link computeUndoCut}). */
export function precheckUndo(history: readonly ContextMessage[], count: number): UndoPrecheck {
const cut = computeUndoCut(history, count);
if (isFullyUndoable(cut, count)) return { ok: true };
const reason: UndoUnavailableReason = cut.stoppedAtCompaction
? 'compaction_boundary'
: cut.removedCount === 0
? 'empty'
: 'insufficient';
return { ok: false, reason, requested: count, undoable: cut.removedCount };
}
/** Wire-facing message for a failed {@link precheckUndo} (`session.undo_unavailable`). */
export function formatUndoUnavailableMessage(
precheck: Extract<UndoPrecheck, { ok: false }>,
): string {
switch (precheck.reason) {
case 'empty':
return 'Nothing to undo: no user message to undo';
case 'compaction_boundary':
return 'Nothing to undo: would cross a compaction boundary';
case 'insufficient':
return `Nothing to undo: only ${precheck.undoable} of ${precheck.requested} requested turn(s) available`;
}
}
export const contextUndo = defineOp(ContextModel, 'context.undo', {
apply: (state, p: ContextUndoPayload): ContextMessage[] => {
if (p.count <= 0 || state.length === 0) return state;
const { cutIndex, removedCount } = computeUndoCut(state, p.count);
if (cutIndex < 0 || removedCount < p.count) return state;
return resetFold(state.slice(0, cutIndex)) as ContextMessage[];
const cut = computeUndoCut(state, p.count);
if (!isFullyUndoable(cut, p.count)) return state;
return resetFold(state.slice(0, cut.cutIndex)) as ContextMessage[];
},
});

View file

@ -20,6 +20,13 @@ export interface IAgentPromptService {
prompt(message: ContextMessage): Promise<Turn | undefined>;
steer(message: ContextMessage): PromptSteerHandle;
retry(): Turn | undefined;
/**
* Remove the trailing `count` real-user prompts and the exchange that follows
* them. Returns the number of prompts removed. Throws
* `session.undo_unavailable` (with a structured `reason` of `empty` /
* `compaction_boundary` / `insufficient`) when fewer than `count` prompts can
* be undone no state is removed in that case.
*/
undo(count: number): number;
clear(): void;

View file

@ -1,23 +1,20 @@
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { extractImageCompressionCaptions } from '#/_base/tools/support/image-compress';
import type { ContentPart } from '#/app/llmProtocol/message';
import { ErrorCodes, KimiError } from '#/errors';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import { formatUndoUnavailableMessage, precheckUndo } from '#/agent/contextMemory/contextOps';
import { USER_PROMPT_ORIGIN, type ContextMessage } from '#/agent/contextMemory/types';
import { IAgentLoopService } from '#/agent/loop/loop';
import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder';
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
import { IAgentTurnService, type Turn, type TurnPromptInfo } from '#/agent/turn/turn';
import type { ExecutableToolResult } from '#/agent/tool/toolContract';
import type { ToolDidExecuteContext } from '#/agent/tool/toolHooks';
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
import { IAgentTurnService, type Turn, type TurnPromptInfo } from '#/agent/turn/turn';
import type { ContentPart } from '#/app/llmProtocol/message';
import { ErrorCodes, KimiError } from '#/errors';
import { OrderedHookSlot } from '#/hooks';
import {
IAgentPromptService,
type PromptSubmitContext,
type PromptSteerHandle,
} from './prompt';
import { IAgentPromptService, type PromptSubmitContext, type PromptSteerHandle } from './prompt';
interface QueuedSteer {
readonly message: ContextMessage;
@ -41,10 +38,13 @@ export class AgentPromptService implements IAgentPromptService {
@IAgentLoopService loopService: IAgentLoopService,
@IAgentToolExecutorService toolExecutor: IAgentToolExecutorService,
) {
loopService.hooks.beforeStep.register('prompt-service-steer-before-step', async (_ctx, next) => {
this.flushSteerQueue();
await next();
});
loopService.hooks.beforeStep.register(
'prompt-service-steer-before-step',
async (_ctx, next) => {
this.flushSteerQueue();
await next();
},
);
loopService.hooks.afterStep.register('prompt-service-steer', async (ctx, next) => {
if (this.flushSteerQueue()) {
ctx.continue = true;
@ -120,22 +120,26 @@ export class AgentPromptService implements IAgentPromptService {
undo(count: number): number {
if (count <= 0) return 0;
const { removedCount, stoppedAtCompaction } = this.context.undo(count);
if (removedCount < count) {
// Precheck on the live history so a request that cannot be fully satisfied
// fails with `session.undo_unavailable` (and a structured reason) BEFORE any
// state is removed. `context.undo` is a no-op when the cut is short, but
// surfacing *why* (`empty` / `compaction_boundary` / `insufficient`) is the
// caller's signal — mirrors v1's `canUndoHistory` gate.
const precheck = precheckUndo(this.context.get(), count);
if (!precheck.ok) {
throw new KimiError(
ErrorCodes.REQUEST_INVALID,
formatUndoUnavailableMessage(count, removedCount, stoppedAtCompaction),
ErrorCodes.SESSION_UNDO_UNAVAILABLE,
formatUndoUnavailableMessage(precheck),
{
details: {
reason: 'undo_limit',
reason: precheck.reason,
requestedCount: count,
undoableCount: removedCount,
stoppedAtCompaction,
undoableCount: precheck.undoable,
},
},
);
}
return removedCount;
return this.context.undo(count).removedCount;
}
clear(): void {
@ -294,19 +298,6 @@ function splitImageCompressionCaptions(content: readonly ContentPart[]): {
return { captions, parts };
}
function formatUndoUnavailableMessage(
requestedCount: number,
undoableCount: number,
stoppedAtCompaction: boolean,
): string {
const reason = stoppedAtCompaction ? ' after the last compaction' : '';
return `Cannot undo ${formatPromptCount(requestedCount)}; only ${formatPromptCount(undoableCount)} can be undone in the active context${reason}.`;
}
function formatPromptCount(count: number): string {
return `${String(count)} ${count === 1 ? 'prompt' : 'prompts'}`;
}
registerScopedService(
LifecycleScope.Agent,
IAgentPromptService,

View file

@ -19,12 +19,44 @@ import type {
} from '@moonshot-ai/protocol';
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { TurnResult } from '#/agent/turn/turn';
/**
* Outcome of a prompt that was launched (or queued and later launched) by
* {@link IAgentPromptLegacyService.submitAndSettle}. `result` is the underlying
* turn's settled `TurnResult` the same signal the legacy scheduler already
* observes internally to advance its queue, now exposed to in-process callers
* so they can await turn completion authoritatively instead of reverse
* engineering it from the event stream.
*/
export interface PromptCompletion {
readonly promptId: string;
readonly result: TurnResult;
}
export interface PromptSettleResult {
readonly submit: PromptSubmitResult;
/**
* Resolves when the submitted prompt's turn settles (covering prompts that
* were queued and run later). Rejects if the prompt is dropped before it ever
* launches (e.g. the agent is busy and the submission is `blocked`, or it is
* aborted while still queued).
*/
readonly completion: Promise<PromptCompletion>;
}
export interface IAgentPromptLegacyService {
readonly _serviceBrand: undefined;
list(): PromptListResponse;
submit(body: PromptSubmission): Promise<PromptSubmitResult>;
/**
* Submit like {@link submit}, but also return a `completion` promise of the
* launched turn's settled result. Used by in-process callers (e.g. `kimi -p`)
* that need to await turn completion authoritatively; server callers that
* only need the serializable `PromptSubmitResult` keep using {@link submit}.
*/
submitAndSettle(body: PromptSubmission): Promise<PromptSettleResult>;
steer(promptIds: readonly string[]): Promise<PromptSteerResult>;
abort(promptId: string): Promise<PromptAbortResponse>;
}

View file

@ -39,7 +39,11 @@ import type {
PromptSubmitResult,
} from '@moonshot-ai/protocol';
import { IAgentPromptLegacyService } from './promptLegacy';
import {
IAgentPromptLegacyService,
type PromptCompletion,
type PromptSettleResult,
} from './promptLegacy';
interface PromptRecord {
readonly promptId: string;
@ -59,6 +63,12 @@ export class AgentPromptLegacyService implements IAgentPromptLegacyService {
private readonly queued: PromptRecord[] = [];
/** Prompts whose abort was requested; their turn settles asynchronously. */
private readonly abortedPromptIds = new Set<string>();
/**
* Per-prompt completion deferreds created by {@link submitAndSettle}; resolved
* when the prompt's turn settles, rejected if the prompt is dropped before it
* launches. Only populated for in-process callers that asked for completion.
*/
private readonly completions = new Map<string, Deferred<PromptCompletion>>();
constructor(
@IAgentPromptService private readonly prompt: IAgentPromptService,
@ -79,15 +89,40 @@ export class AgentPromptLegacyService implements IAgentPromptLegacyService {
}
async submit(body: PromptSubmission): Promise<PromptSubmitResult> {
return this.submitInternal(body, undefined);
}
async submitAndSettle(body: PromptSubmission): Promise<PromptSettleResult> {
const deferred = makeDeferred<PromptCompletion>();
const submit = await this.submitInternal(body, deferred);
return { submit, completion: deferred.promise };
}
private async submitInternal(
body: PromptSubmission,
completion: Deferred<PromptCompletion> | undefined,
): Promise<PromptSubmitResult> {
await this.authSummary.ensureReady();
await this.applyOverrides(body);
const record = this.createRecord(body);
if (completion !== undefined) {
this.completions.set(record.promptId, completion);
}
if (this.active !== undefined) {
this.queued.push(record);
return toItem(record, 'queued');
}
const status = await this.launch(record);
if (status === 'blocked') {
// `launch` drops the record (does not queue it) when it cannot start a
// turn, so it will never settle — reject the completion instead of
// leaving it pending forever.
this.rejectCompletion(
record.promptId,
new Error('Prompt submission was blocked and will not run'),
);
}
return toItem(record, status);
}
@ -128,13 +163,16 @@ export class AgentPromptLegacyService implements IAgentPromptLegacyService {
// Mark and cancel; the turn settles asynchronously and `onTurnSettled`
// clears `active` and starts the next queued prompt.
this.abortedPromptIds.add(promptId);
this.active.turn.abortController.abort(userCancellationReason());
this.turnService.cancel(this.active.turn.id, userCancellationReason());
return { aborted: true };
}
const index = this.queued.findIndex((item) => item.promptId === promptId);
if (index >= 0) {
this.queued.splice(index, 1);
// The prompt never launched, so no turn will settle it — reject any
// completion waiter instead of leaving it pending.
this.rejectCompletion(promptId, userCancellationReason());
return { aborted: true };
}
@ -198,10 +236,24 @@ export class AgentPromptLegacyService implements IAgentPromptLegacyService {
if (this.active?.promptId !== promptId) return;
this.active = undefined;
this.abortedPromptIds.delete(promptId);
void result;
this.resolveCompletion(promptId, result);
this.startNextQueued();
}
private resolveCompletion(promptId: string, result: TurnResult): void {
const deferred = this.completions.get(promptId);
if (deferred === undefined) return;
this.completions.delete(promptId);
deferred.resolve({ promptId, result });
}
private rejectCompletion(promptId: string, reason: unknown): void {
const deferred = this.completions.get(promptId);
if (deferred === undefined) return;
this.completions.delete(promptId);
deferred.reject(reason);
}
private startNextQueued(): void {
if (this.active !== undefined) return;
const next = this.queued.shift();
@ -265,6 +317,22 @@ function contentToCoreParts(content: PromptSubmission['content']): ContentPart[]
return parts;
}
interface Deferred<T> {
readonly promise: Promise<T>;
resolve(value: T): void;
reject(reason: unknown): void;
}
function makeDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
registerScopedService(
LifecycleScope.Agent,
IAgentPromptLegacyService,

View file

@ -1,5 +1,9 @@
/**
* `turn` domain error codes.
*
* `TURN_AGENT_BUSY` is deprecated: busy admission now throws
* `activity.agent_busy` from the `activity` kernel. It stays registered until
* the remaining `turn.*` callers (e.g. `skill`) move to the new code.
*/
import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes';

View file

@ -7,7 +7,12 @@ export type { LoopRunResult as TurnResult } from '#/agent/loop/loop';
export interface Turn {
readonly id: number;
readonly abortController: AbortController;
/**
* Cancellation signal owned by the `activity` kernel's turn lease. Abort it
* through `IAgentTurnService.cancel(...)` rather than holding a controller;
* the kernel is the single authority for turn cancellation.
*/
readonly signal: AbortSignal;
/**
* Resolves on the first model response event for the first loop step, or at
* step completion; rejects if the turn ends earlier.

View file

@ -4,12 +4,16 @@
* Owns the agent's turn lifecycle: the next-turn-id counter lives in the `wire`
* `TurnModel` (advanced only through the `turn.prompt` Op via `wire.dispatch`,
* read through `wire.getModel`), while the per-turn runtime (the active `Turn`,
* its `AbortController` and `ready`/`result` promises, and the `turn.started` /
* `turn.ended` / `error` events) stays live-only. `turn.started` is emitted
* through `wire.signal` (legacy channel); `turn.ended` / `error` publish to
* `IEventBus` and are also emitted through `wire.signal`. `wire.replay` rebuilds
* the counter silently so resumed sessions keep allocating fresh ids without
* re-firing anything. Bound at Agent scope.
* its `ready`/`result` promises, and the `turn.started` / `turn.ended` / `error`
* events) stays live-only. Admission, cancellation and the turn `AbortSignal`
* are delegated to the `activity` kernel (`IAgentActivityService`): `launch`
* goes through `activity.begin('turn')` and the returned lease owns the signal
* and the path back to `idle` (`lease.end()`). `activeTurn` is kept as a handle
* cache for `getActiveTurn()` but no longer carries the mutual-exclusion duty.
* `turn.started` is emitted through `wire.signal` (legacy channel); `turn.ended`
* / `error` publish to `IEventBus` and are also emitted through `wire.signal`.
* `wire.replay` rebuilds the counter silently so resumed sessions keep
* allocating fresh ids without re-firing anything. Bound at Agent scope.
*/
import { createControlledPromise } from '@antfu/utils';
@ -19,10 +23,12 @@ import type { TurnEndedEvent, TurnStartedEvent } from '@moonshot-ai/protocol';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { userCancellationReason } from '#/_base/utils/abort';
import { ErrorCodes, KimiError, toKimiErrorPayload } from '#/errors';
import { toKimiErrorPayload } from '#/errors';
import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types';
import type { ContentPart } from '#/app/llmProtocol/message';
import type { PromptOrigin } from '#/agent/contextMemory/types';
import type { ActivityLease } from '#/activity/activity';
import { IAgentActivityService } from '#/activity/activity';
import { IAgentLoopService } from '#/agent/loop/loop';
import { IEventBus } from '#/app/event/eventBus';
import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext';
@ -52,37 +58,28 @@ export class AgentTurnService implements IAgentTurnService {
@IEventBus private readonly eventBus: IEventBus,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IAgentTelemetryContextService private readonly telemetryContext: IAgentTelemetryContextService,
@IAgentActivityService private readonly activity: IAgentActivityService,
) {}
launch(prompt?: TurnPromptInfo): Turn {
if (this.activeTurn !== undefined) {
throw new KimiError(
ErrorCodes.TURN_AGENT_BUSY,
`Cannot launch a new turn while another turn (ID ${this.activeTurn.id}) is active`,
{ details: { turnId: this.activeTurn.id } },
);
}
const turnId = this.wire.getModel(TurnModel).nextTurnId;
const origin = prompt?.origin ?? USER_PROMPT_ORIGIN;
const lease = this.activity.begin('turn', { origin: prompt?.origin ?? USER_PROMPT_ORIGIN });
this.wire.dispatch(
promptTurn({
input: prompt?.input ?? [],
origin,
origin: lease.origin,
}),
);
const abortController = new AbortController();
const ready = createControlledPromise<void>();
const turn: MutableTurn = {
id: turnId,
abortController,
id: lease.turnId,
signal: lease.signal,
ready,
result: Promise.resolve({ reason: 'failed' }),
};
void ready.catch(() => undefined);
this.activeTurn = turn;
this.eventBus.publish({ type: 'turn.started', turnId: turn.id, origin });
turn.result = this.runTurn(turn, ready);
this.eventBus.publish({ type: 'turn.started', turnId: turn.id, origin: lease.origin });
turn.result = this.runTurn(turn, lease, ready);
return turn;
}
@ -99,12 +96,12 @@ export class AgentTurnService implements IAgentTurnService {
const turn = this.activeTurn;
if (turn === undefined) return false;
if (turnId !== undefined && turn.id !== turnId) return false;
turn.abortController.abort(reason ?? userCancellationReason());
return true;
return this.activity.cancel(reason ?? userCancellationReason());
}
private async runTurn(
turn: Turn,
lease: ActivityLease,
ready: ReturnType<typeof createControlledPromise<void>>,
): Promise<TurnResult> {
const startedAt = Date.now();
@ -114,12 +111,12 @@ export class AgentTurnService implements IAgentTurnService {
turnTelemetry.track('turn_started');
result = await this.loop.run({
turnId: turn.id,
signal: turn.abortController.signal,
signal: lease.signal,
onStarted: () => ready.resolve(),
});
return result;
} catch (error) {
if (turn.abortController.signal.aborted) {
if (lease.signal.aborted) {
result = { reason: 'cancelled' };
return result;
}
@ -130,6 +127,13 @@ export class AgentTurnService implements IAgentTurnService {
if (this.activeTurn === turn) {
this.activeTurn = undefined;
}
const outcome: 'completed' | 'cancelled' | 'failed' =
result?.reason === 'completed'
? 'completed'
: result?.reason === 'cancelled'
? 'cancelled'
: 'failed';
lease.end(outcome, result?.error === undefined ? undefined : { error: result.error });
if (result !== undefined) {
const error = result.error !== undefined ? toKimiErrorPayload(result.error) : undefined;
this.eventBus.publish({

View file

@ -13,6 +13,23 @@
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { Page } from '#/persistence/interface/queryStore';
/**
* v1 `custom` metadata key linking a forked session back to its parent
* (`packages/agent-core/.../sessionService.ts`). Written by
* `ISessionLifecycleService.createChild`; read here to answer child queries.
*/
export const PARENT_SESSION_ID_KEY = 'parent_session_id';
/**
* v1 `custom` metadata key tagging a fork as a direct "child" (as opposed to a
* plain fork). Only sessions carrying both {@link PARENT_SESSION_ID_KEY} and
* `child_session_kind === CHILD_SESSION_KIND` count as children.
*/
export const CHILD_SESSION_KIND_KEY = 'child_session_kind';
/** The `child_session_kind` value that marks a direct child session. */
export const CHILD_SESSION_KIND = 'child';
export interface SessionSummary {
readonly id: string;
readonly workspaceId: string;
@ -46,6 +63,13 @@ export interface SessionListQuery {
readonly includeArchived?: boolean;
readonly cursor?: string;
readonly limit?: number;
/**
* Restrict to direct child sessions of this parent id: summaries whose
* `custom` carries both `parent_session_id === childOf` and
* `child_session_kind === 'child'` (the v1 child markers). A plain fork
* (no `child_session_kind`) is excluded.
*/
readonly childOf?: string;
}
export interface ISessionIndex {

View file

@ -36,7 +36,14 @@ import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStor
import { IQueryStore, type Page } from '#/persistence/interface/queryStore';
import { IFileSystemStorageService } from '#/persistence/interface/storage';
import { ISessionIndex, type SessionListQuery, type SessionSummary } from './sessionIndex';
import {
CHILD_SESSION_KIND,
CHILD_SESSION_KIND_KEY,
ISessionIndex,
PARENT_SESSION_ID_KEY,
type SessionListQuery,
type SessionSummary,
} from './sessionIndex';
const META_SCOPE = 'session-meta';
const META_KEY = 'state.json';
@ -73,6 +80,21 @@ function recoverCwd(meta: Record<string, unknown>): string | undefined {
return undefined;
}
/**
* Whether a summary is a direct child of `parentId` per the v1 child markers:
* `custom.parent_session_id === parentId` AND `custom.child_session_kind ===
* 'child'`. A missing/blank `parentId` (no `childOf` filter) matches every
* summary. A spoofed kind is ignored.
*/
function matchesChildOf(summary: SessionSummary, parentId: string | undefined): boolean {
if (parentId === undefined) return true;
const custom = summary.custom;
return (
custom?.[PARENT_SESSION_ID_KEY] === parentId &&
custom?.[CHILD_SESSION_KIND_KEY] === CHILD_SESSION_KIND
);
}
export class FileSessionIndex implements ISessionIndex {
declare readonly _serviceBrand: undefined;
@ -107,6 +129,7 @@ export class FileSessionIndex implements ISessionIndex {
const summary = await this.getCachedSummary(workspaceId, sessionId);
if (summary === undefined) continue;
if (summary.archived && query.includeArchived !== true) continue;
if (!matchesChildOf(summary, query.childOf)) continue;
items.push(summary);
}
}
@ -193,6 +216,7 @@ export class FileSessionIndex implements ISessionIndex {
const summary = await this.readSummary(workspaceId, sessionId);
if (summary === undefined) continue;
if (summary.archived && query.includeArchived !== true) continue;
if (!matchesChildOf(summary, query.childOf)) continue;
items.push(summary);
}
}
@ -252,8 +276,7 @@ export class FileSessionIndex implements ISessionIndex {
// `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}`));
const meta = (await this.readMeta(base)) ?? (await this.readMeta(`${base}/${META_SCOPE}`));
if (meta === undefined) return undefined;
const rawCustom = meta['custom'];
const custom =

View file

@ -1,31 +1,27 @@
/**
* `sessionLegacy` domain (L7 edge adapter) v1-compatible session actions.
*
* Implements the legacy `/api/v1/sessions/{tail}` action contract (`fork` /
* `compact` / `undo` / `abort` / `btw`), the `/sessions/{id}/children`
* endpoints (`createChild` / `listChildren`), and `POST /sessions/{id}/profile`
* (`updateProfile` title rename, metadata merge, and the cross-domain
* `agent_config` patch) on top of the native v2 services
* (`ISessionLifecycleService`, `ISessionIndex`, `IAgentRPCService`,
* `IAgentFullCompactionService`, `IAgentPromptService`, ). The native services keep serving
* `/api/v2` and are left untouched; this adapter exists only so clients of the
* v1 server keep working against server-v2. Bound at App scope it is a
* stateless dispatcher that resolves the target session/agent per call.
* Implements `POST /sessions/{id}/profile` (`updateProfile` title rename,
* metadata merge, and the cross-domain `agent_config` patch) and
* `GET /sessions/{id}/status` (`status`) on top of the native v2 services
* (`ISessionLifecycleService`, `IAgentProfileService`, ).
*
* The thin pass-through actions (`fork` / `compact` / `abort` / `archive`), the
* `:undo` action, and the `/sessions/{id}/children` endpoints are deliberately
* NOT wrapped here: the edge route calls the native services directly
* `ISessionLifecycleService.fork` / `archive` / `createChild`,
* `IAgentFullCompactionService.begin`, `IAgentRPCService.cancel`,
* `IAgentPromptService.undo`, and `ISessionIndex.list({ childOf })` because
* none of them carries v1-only projection worth centralizing beyond what the
* native services already provide. Only `updateProfile` and `status` hold real
* cross-domain adaptation logic (the `agent_config` patch and the
* best-effort status rollup), so they stay in this adapter. The native services
* keep serving `/api/v2` and are left untouched; this adapter exists only so
* clients of the v1 server keep working against server-v2. Bound at App scope
* it is a stateless dispatcher that resolves the target session/agent per call.
*/
import type {
ArchiveSessionResponse,
CompactSessionRequest,
CompactSessionResponse,
CreateSessionChildRequest,
ForkSessionRequest,
SessionAbortResponse,
SessionStatus,
SessionStatusResponse,
UndoSessionRequest,
UndoSessionResponse,
UpdateSessionProfileRequest,
} from '@moonshot-ai/protocol';
import type { SessionStatusResponse, UpdateSessionProfileRequest } from '@moonshot-ai/protocol';
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
@ -46,31 +42,10 @@ export interface SessionWireFields {
readonly custom?: Record<string, unknown>;
}
/** Query mirror of the v1 `GET /sessions/{id}/children` cursor + status filter. */
export interface SessionChildrenQuery {
readonly before_id?: string;
readonly after_id?: string;
readonly page_size?: number;
readonly status?: SessionStatus;
}
/** Page of child sessions, projected by the route into the wire `Page<Session>`. */
export interface SessionChildrenPage {
readonly items: readonly SessionWireFields[];
readonly has_more: boolean;
}
export interface ISessionLegacyService {
readonly _serviceBrand: undefined;
updateProfile(sessionId: string, body: UpdateSessionProfileRequest): Promise<SessionWireFields>;
fork(sessionId: string, body: ForkSessionRequest): Promise<SessionWireFields>;
createChild(sessionId: string, body: CreateSessionChildRequest): Promise<SessionWireFields>;
listChildren(sessionId: string, query: SessionChildrenQuery): Promise<SessionChildrenPage>;
compact(sessionId: string, body: CompactSessionRequest): Promise<CompactSessionResponse>;
undo(sessionId: string, body: UndoSessionRequest): Promise<UndoSessionResponse>;
abort(sessionId: string): Promise<SessionAbortResponse>;
archive(sessionId: string): Promise<ArchiveSessionResponse>;
status(sessionId: string): Promise<SessionStatusResponse>;
}

View file

@ -3,80 +3,41 @@
*
* Stateless App-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. Child sessions are implemented as forks
* tagged in `custom` (`parent_session_id` + `child_session_kind`); listing reads
* those markers from the `sessionIndex` summaries. No business logic is
* duplicated here; the real work stays in the native services.
* the result into the v1 wire shape. Only `updateProfile` (the cross-domain
* `agent_config` patch) and `status` (the best-effort status rollup) live here;
* the `:undo`, `fork`-as-child, and child-listing actions were pushed down into
* the native services (`IAgentPromptService.undo`,
* `ISessionLifecycleService.createChild`, `ISessionIndex.list({ childOf })`) and
* are called by the edge route directly. No business logic is duplicated here;
* the real work stays in the native services.
*/
import type { SessionStatusResponse, UpdateSessionProfileRequest } from '@moonshot-ai/protocol';
import { InstantiationType } from '#/_base/di/extensions';
import { type IAgentScopeHandle, LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent';
import { IConfigService } from '#/app/config/config';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import { toProtocolMessage } from '#/agent/contextMemory/messageProjection';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { IAgentContextSizeService } from '#/agent/contextSize/contextSize';
import { ErrorCodes, isKimiError, KimiError } from '#/errors';
import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction';
import { IAgentGoalService } from '#/agent/goal/goal';
import { IModelResolver } from '#/app/model/modelResolver';
import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode';
import type { PermissionMode } from '#/agent/permissionPolicy/types';
import { IAgentPlanService } from '#/agent/plan/plan';
import { IAgentProfileService } from '#/agent/profile/profile';
import { IAgentPromptService } from '#/agent/prompt/prompt';
import { IAgentRPCService } from '#/agent/rpc/rpc';
import { IAgentSwarmService } from '#/agent/swarm/swarm';
import { IConfigService } from '#/app/config/config';
import { IModelResolver } from '#/app/model/modelResolver';
import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle';
import { ErrorCodes, KimiError } from '#/errors';
import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent';
import { ISessionActivity } from '#/session/sessionActivity/sessionActivity';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex';
import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle';
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
import { IAgentSwarmService } from '#/agent/swarm/swarm';
import { IWorkspaceRegistry } from '#/app/workspaceRegistry/workspaceRegistry';
import type {
ArchiveSessionResponse,
CompactSessionRequest,
CompactSessionResponse,
CreateSessionChildRequest,
ForkSessionRequest,
SessionAbortResponse,
SessionStatusResponse,
UndoSessionRequest,
UndoSessionResponse,
UpdateSessionProfileRequest,
} from '@moonshot-ai/protocol';
import {
ISessionLegacyService,
type SessionChildrenPage,
type SessionChildrenQuery,
type SessionWireFields,
} from './sessionLegacy';
/**
* v1 `child_session_kind` marker (`packages/agent-core/.../sessionService.ts`).
* A fork is only listed as a "child" when its metadata carries both
* `parent_session_id` (the parent) and `child_session_kind === 'child'`; a
* spoofed kind is ignored. Reused verbatim so v1/v2 agree on the tag.
*/
const CHILD_SESSION_KIND = 'child';
const CHILDREN_DEFAULT_PAGE_SIZE = 100;
const CHILDREN_MAX_PAGE_SIZE = 100;
/** v1 `:undo` page-size clamp (`packages/agent-core/.../sessionService.ts`). */
const DEFAULT_UNDO_MESSAGE_PAGE_SIZE = 50;
const MAX_UNDO_MESSAGE_PAGE_SIZE = 100;
import { ISessionLegacyService, type SessionWireFields } from './sessionLegacy';
export class SessionLegacyService implements ISessionLegacyService {
declare readonly _serviceBrand: undefined;
constructor(
@ISessionLifecycleService private readonly lifecycle: ISessionLifecycleService,
@ISessionIndex private readonly index: ISessionIndex,
@IWorkspaceRegistry private readonly workspaceRegistry: IWorkspaceRegistry,
) {}
constructor(@ISessionLifecycleService private readonly lifecycle: ISessionLifecycleService) {}
async updateProfile(
sessionId: string,
@ -125,199 +86,8 @@ export class SessionLegacyService implements ISessionLegacyService {
};
}
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 ctx = handle.accessor.get(ISessionContext);
return {
id: meta.id,
workspaceId: ctx.workspaceId,
root: ctx.cwd,
title: meta.title,
lastPrompt: meta.lastPrompt,
createdAt: meta.createdAt,
updatedAt: meta.updatedAt,
archived: meta.archived,
custom: meta.custom,
};
}
async createChild(sessionId: string, body: CreateSessionChildRequest): Promise<SessionWireFields> {
const parentTitle = await this.resolveParentTitle(sessionId);
const handle = await this.lifecycle.fork({
sourceSessionId: sessionId,
title: body.title ?? `Child: ${parentTitle || sessionId}`,
metadata: Object.assign({}, body.metadata, {
parent_session_id: sessionId,
child_session_kind: CHILD_SESSION_KIND,
}),
});
const meta = await handle.accessor.get(ISessionMetadata).read();
const ctx = handle.accessor.get(ISessionContext);
return {
id: meta.id,
workspaceId: ctx.workspaceId,
root: ctx.cwd,
title: meta.title,
lastPrompt: meta.lastPrompt,
createdAt: meta.createdAt,
updatedAt: meta.updatedAt,
archived: meta.archived,
custom: meta.custom,
};
}
async listChildren(sessionId: string, query: SessionChildrenQuery): Promise<SessionChildrenPage> {
const exists =
this.lifecycle.get(sessionId) !== undefined ||
(await this.index.get(sessionId)) !== undefined;
if (!exists) {
throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`);
}
// v1 lists every session then filters by the `parent_session_id` +
// `child_session_kind` markers (carried in `custom`); the index summary
// already surfaces `custom`, so no per-session document read is needed.
const all = await this.index.list({});
const children = all.items.filter(
(s) =>
s.custom?.['parent_session_id'] === sessionId &&
s.custom?.['child_session_kind'] === CHILD_SESSION_KIND,
);
let pivotIndex = -1;
if (query.before_id !== undefined) {
pivotIndex = children.findIndex((s) => s.id === query.before_id);
} else if (query.after_id !== undefined) {
pivotIndex = children.findIndex((s) => s.id === query.after_id);
}
let slice: SessionSummary[];
if (query.before_id !== undefined && pivotIndex >= 0) {
slice = children.slice(pivotIndex + 1);
} else if (query.after_id !== undefined && pivotIndex >= 0) {
slice = children.slice(0, pivotIndex);
} else {
slice = children;
}
const pageSize = Math.min(
Math.max(query.page_size ?? CHILDREN_DEFAULT_PAGE_SIZE, 1),
CHILDREN_MAX_PAGE_SIZE,
);
const page = slice.slice(0, pageSize);
const items = await Promise.all(page.map((s) => this.projectSummary(s)));
// `status` is layered on at the route edge: this adapter returns
// protocol-free fields, and the route projects the live
// `ISessionActivity.status()` onto each item and filters the page by the
// `status` query (post-page, matching v1). `has_more` reflects the
// pre-filter page.
return { items, has_more: slice.length > pageSize };
}
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(IAgentFullCompactionService).begin({ source: 'manual', instruction });
return {};
}
async undo(sessionId: string, body: UndoSessionRequest): Promise<UndoSessionResponse> {
const agent = await this.resolveMainAgent(sessionId);
const context = agent.accessor.get(IAgentContextMemoryService);
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(IAgentPromptService).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();
// Mirrors v1 `SessionService.undo`: project the post-undo history into a
// wire `Page<Message>` (newest-first, page-size clamped) and pair it with
// the live status. The route forwards this shape verbatim.
const [summary, status] = await Promise.all([
this.index.get(sessionId),
this.assembleStatus(sessionId, agent),
]);
return {
messages: pageContextMessages(sessionId, summary?.createdAt ?? 0, history, body.page_size),
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 archive(sessionId: string): Promise<ArchiveSessionResponse> {
// Native `ISessionLifecycleService.archive` is a no-op for sessions that
// are not live, so gate on the live handle (matches the previous route
// behaviour): a missing live session is reported as `session.not_found`.
// `resume` (not `get`) so archiving a freshly-opened cold session still
// works; `resume` returns undefined only when the session is unknown or its
// workspace is gone, which is reported as `session.not_found`.
if ((await this.lifecycle.resume(sessionId)) === undefined) {
throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`);
}
await this.lifecycle.archive(sessionId);
return { archived: true };
}
// --- internals -------------------------------------------------------------
/**
* Best-effort parent title for the default `Child: <title>` name. Reads the
* live handle first, then falls back to the persisted index. A missing parent
* yields `undefined`; `lifecycle.fork` still throws `SESSION_NOT_FOUND` for
* the real existence check.
*/
private async resolveParentTitle(sessionId: string): Promise<string | undefined> {
const live = this.lifecycle.get(sessionId);
if (live !== undefined) {
return (await live.accessor.get(ISessionMetadata).read()).title;
}
return (await this.index.get(sessionId))?.title;
}
private async projectSummary(summary: SessionSummary): Promise<SessionWireFields> {
// Prefer the cwd persisted on the session summary (gap G3 closed); fall
// back to the registry only for sessions written before `cwd` was stored.
const root =
summary.cwd ?? (await this.workspaceRegistry.get(summary.workspaceId))?.root ?? '';
return {
id: summary.id,
workspaceId: summary.workspaceId,
root,
title: summary.title,
lastPrompt: summary.lastPrompt,
createdAt: summary.createdAt,
updatedAt: summary.updatedAt,
archived: summary.archived,
custom: summary.custom,
};
}
/**
* Apply the v1 `agent_config` patch onto the main agent. Mirrors v1's
* `IPromptService.applyAgentState` (`promptService.ts:650-743`) in both order
@ -340,8 +110,8 @@ export class SessionLegacyService implements ISessionLegacyService {
profile.setThinking(agentConfig.thinking);
}
if (agentConfig.permission_mode !== undefined) {
agent
.accessor.get(IAgentPermissionModeService)
agent.accessor
.get(IAgentPermissionModeService)
.setMode(agentConfig.permission_mode as PermissionMode);
}
if (agentConfig.plan_mode !== undefined) {
@ -360,8 +130,8 @@ export class SessionLegacyService implements ISessionLegacyService {
}
}
if (agentConfig.goal_objective !== undefined) {
await agent
.accessor.get(IAgentGoalService)
await agent.accessor
.get(IAgentGoalService)
.createGoal({ objective: agentConfig.goal_objective });
}
if (agentConfig.goal_control !== undefined) {
@ -399,7 +169,10 @@ export class SessionLegacyService implements ISessionLegacyService {
return this.assembleStatus(sessionId, agent);
}
private async assembleStatus(sessionId: string, agent: IAgentScopeHandle): Promise<SessionStatusResponse> {
private async assembleStatus(
sessionId: string,
agent: IAgentScopeHandle,
): Promise<SessionStatusResponse> {
const session = this.lifecycle.get(sessionId);
const profile = agent.accessor.get(IAgentProfileService);
const contextSize = agent.accessor.get(IAgentContextSizeService);
@ -438,12 +211,6 @@ export class SessionLegacyService implements ISessionLegacyService {
}
}
function normalizeOptional(value: string | undefined): string | undefined {
if (value === undefined) return undefined;
const trimmed = value.trim();
return trimmed.length === 0 ? undefined : trimmed;
}
/**
* Resolve the configured default model's context window for the status line
* when the main agent has no model bound yet (fresh session before the first
@ -460,68 +227,6 @@ function resolveDefaultModelContextTokens(agent: IAgentScopeHandle): number {
}
}
/**
* Mirror of v1 `pageContextMessages`: project the post-undo history into a
* newest-first wire page, clamping `page_size` to `[1, 100]` (default 50).
*/
function pageContextMessages(
sessionId: string,
sessionCreatedAtMs: number,
history: readonly ContextMessage[],
requestedPageSize: number | undefined,
): { items: ReturnType<typeof toProtocolMessage>[]; has_more: boolean } {
const pageSize = Math.min(
Math.max(requestedPageSize ?? DEFAULT_UNDO_MESSAGE_PAGE_SIZE, 1),
MAX_UNDO_MESSAGE_PAGE_SIZE,
);
const all = history.map((message, index) =>
toProtocolMessage(sessionId, index, message, sessionCreatedAtMs),
);
const desc = all.toReversed();
return {
items: desc.slice(0, pageSize),
has_more: desc.length > pageSize,
};
}
/**
* 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;
}
if (
origin.kind === 'plugin_command' &&
(origin as { trigger?: string }).trigger === 'user-slash'
) {
return true;
}
return false;
}
registerScopedService(
LifecycleScope.App,
ISessionLegacyService,

View file

@ -2,9 +2,10 @@
* `sessionLifecycle` domain (L6) creates and tracks sessions at the process root.
*
* Defines the public contract of session lifecycle: the `CreateSessionOptions`,
* `ForkSessionOptions`, and the `ISessionLifecycleService` used to create
* sessions (`create`), look up the live ones (`get` / `list`), close them
* (`close`), archive them (`archive`), and fork them (`fork`). Announces
* `ForkSessionOptions`, `CreateChildSessionOptions`, and the
* `ISessionLifecycleService` used to create sessions (`create`), look up the
* live ones (`get` / `list`), close them (`close`), archive them (`archive`),
* fork them (`fork`), and fork-then-tag them as direct children (`createChild`). Announces
* lifecycle transitions through ordered hook slots plus
* `onDidCreateSession` / `onDidCloseSession` / `onDidArchiveSession` /
* `onDidForkSession`. App-scoped a single
@ -39,6 +40,19 @@ export interface ForkSessionOptions {
readonly metadata?: Record<string, unknown>;
}
export interface CreateChildSessionOptions {
readonly sourceSessionId: string;
readonly newSessionId?: string;
/** Title for the child session. Defaults to `Child: <source title or id>`. */
readonly title?: string;
/**
* Custom metadata merged into the child session. The `parent_session_id` and
* `child_session_kind` markers are added automatically (and win over any
* caller-supplied values) so the child is discoverable via the session index.
*/
readonly metadata?: Record<string, unknown>;
}
export interface SessionCreatedEvent {
readonly sessionId: string;
readonly handle: ISessionScopeHandle;
@ -101,6 +115,13 @@ export interface ISessionLifecycleService {
close(sessionId: string): Promise<void>;
archive(sessionId: string): Promise<void>;
fork(opts: ForkSessionOptions): Promise<ISessionScopeHandle>;
/**
* Fork a session and tag it as a direct child of its source (writes the
* `parent_session_id` / `child_session_kind` markers into `custom`). The
* default title is `Child: <source title or id>`. Throws `session.not_found`
* when the source is unknown (delegates to {@link fork}).
*/
createChild(opts: CreateChildSessionOptions): Promise<ISessionScopeHandle>;
}
export const ISessionLifecycleService: ServiceIdentifier<ISessionLifecycleService> =

View file

@ -16,44 +16,50 @@ import { randomUUID } from 'node:crypto';
import { InstantiationType } from '#/_base/di/extensions';
import { IInstantiationService } from '#/_base/di/instantiation';
import { Disposable } from '#/_base/di/lifecycle';
import {
createScopedChildHandle,
type ISessionScopeHandle,
LifecycleScope,
registerScopedService,
} from '#/_base/di/scope';
import { Disposable } from '#/_base/di/lifecycle';
import { Emitter, type Event } from '#/_base/event';
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
import { ensureMainAgent, MAIN_AGENT_ID } from '#/session/agentLifecycle/mainAgent';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IEventService } from '#/app/event/event';
import { ISessionActivityKernel } from '#/activity/activity';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import { ErrorCodes, KimiError } from '#/errors';
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
import { ISessionActivity } from '#/session/sessionActivity/sessionActivity';
import { labelsFromAgentMeta } from '#/session/agentLifecycle/subagentMetadata';
import { ISessionIndex } from '#/app/sessionIndex/sessionIndex';
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
import { IWorkspaceRegistry } from '#/app/workspaceRegistry/workspaceRegistry';
import { ISessionExternalHooksService } from '#/session/externalHooks/externalHooks';
import { ISessionContext, sessionContextSeed } from '#/session/sessionContext/sessionContext';
import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata';
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
import { IWorkspaceLocalConfigService } from '#/app/workspaceLocalConfig/workspaceLocalConfig';
import { createHooks } from '#/hooks';
import {
AGENT_WIRE_PROTOCOL_VERSION,
IAgentWireRecordService,
type PersistedWireRecord,
} from '#/agent/wireRecord/wireRecord';
import { WIRE_RECORD_FILENAME, wireRecordScope } from '#/agent/wireRecord/wireRecordService';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IEventService } from '#/app/event/event';
import {
CHILD_SESSION_KIND,
CHILD_SESSION_KIND_KEY,
ISessionIndex,
PARENT_SESSION_ID_KEY,
} from '#/app/sessionIndex/sessionIndex';
import { IWorkspaceLocalConfigService } from '#/app/workspaceLocalConfig/workspaceLocalConfig';
import { IWorkspaceRegistry } from '#/app/workspaceRegistry/workspaceRegistry';
import { ErrorCodes, KimiError } from '#/errors';
import { createHooks } from '#/hooks';
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
import { ensureMainAgent, MAIN_AGENT_ID } from '#/session/agentLifecycle/mainAgent';
import { labelsFromAgentMeta } from '#/session/agentLifecycle/subagentMetadata';
import { ISessionExternalHooksService } from '#/session/externalHooks/externalHooks';
import { ISessionContext, sessionContextSeed } from '#/session/sessionContext/sessionContext';
import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata';
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
import { IAgentWireService } from '#/wire/tokens';
import type { PersistedRecord } from '#/wire/wireService';
import {
type CreateChildSessionOptions,
type CreateSessionOptions,
type ForkSessionOptions,
type SessionArchivedEvent,
@ -155,6 +161,10 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
extra: [...sessionContextSeed(ctx)],
},
) as ISessionScopeHandle;
// Construct the Session activity kernel eagerly so its lane is `restoring`
// for the whole materialize / replay window — edge commands that arrive
// before `markActive()` are rejected with `activity.session_rejected`.
handle.accessor.get(ISessionActivityKernel);
if (additionalDirs.length > 0) {
// De-duplication happens inside setAdditionalDirs (resolve + Set),
// matching v1's normalizeAdditionalDirs.
@ -171,6 +181,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
private async announceCreated(event: SessionCreatedEvent): Promise<void> {
await this.hooks.onDidCreateSession.run(event);
this._onDidCreateSession.fire(event);
event.handle.accessor.get(ISessionActivityKernel).markActive();
}
get(sessionId: string): ISessionScopeHandle | undefined {
@ -178,10 +189,16 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
}
resume(sessionId: string): Promise<ISessionScopeHandle | undefined> {
const live = this.sessions.get(sessionId);
if (live !== undefined) return Promise.resolve(live);
// Check in-flight resumes FIRST: `materializeSession` adds the session to
// `this.sessions` before `doResume` finishes restore/replay, so a concurrent
// caller that checks `sessions` first would get a half-initialized handle
// whose main agent has no context. Checking `resuming` first ensures
// concurrent callers wait for the full resume (including restore + replay)
// to complete.
const inflight = this.resuming.get(sessionId);
if (inflight !== undefined) return inflight;
const live = this.sessions.get(sessionId);
if (live !== undefined) return Promise.resolve(live);
const promise = this.doResume(sessionId).finally(() => this.resuming.delete(sessionId));
this.resuming.set(sessionId, promise);
return promise;
@ -196,9 +213,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
const summary = await this.index.get(sessionId);
if (summary === undefined) return undefined;
const workspace =
summary.cwd === undefined
? await this.workspaceRegistry.get(summary.workspaceId)
: undefined;
summary.cwd === undefined ? await this.workspaceRegistry.get(summary.workspaceId) : undefined;
const workDir = summary.cwd ?? workspace?.root;
if (workDir === undefined) return undefined;
@ -213,12 +228,18 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
// Resolve context memory BEFORE restoring so its reducers are registered;
// otherwise the wire replay applies context records into a void and the
// restored transcript never lands in context memory.
main.accessor.get(IAgentContextMemoryService);
const contextMemory = main.accessor.get(IAgentContextMemoryService);
const mainWireRecord = main.accessor.get(IAgentWireRecordService);
await mainWireRecord.restore();
await main
.accessor.get(IAgentWireService)
.replay(...(mainWireRecord.getRecords() as readonly PersistedRecord[]));
const records = mainWireRecord.getRecords() as readonly PersistedRecord[];
await main.accessor.get(IAgentWireService).replay(...records);
const contextAfter = contextMemory.get();
console.log(
`[doResume] session=${sessionId} wireRecords=${records.length}` +
` contextRecordTypes=[${records.filter((r) => r.type.startsWith('context.')).map((r) => r.type)}]` +
` contextAfterReplay=${contextAfter.length}` +
` roles=[${contextAfter.map((m) => `${m.role}:${m.origin?.kind ?? 'none'}`)}]`,
);
}
await this.announceCreated({ sessionId, handle, source: 'resume' });
return handle;
@ -233,6 +254,8 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
if (handle === undefined) return;
await this.announceWillClose({ sessionId, handle, reason: 'exit' });
this.sessions.delete(sessionId);
handle.accessor.get(ISessionActivityKernel).beginClosing();
await this.drainAgents(handle);
handle.dispose();
this._onDidCloseSession.fire({ sessionId });
}
@ -241,11 +264,9 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
const handle = this.sessions.get(sessionId);
if (handle === undefined) return;
const meta = handle.accessor.get(ISessionMetadata);
const agentLifecycle = handle.accessor.get(IAgentLifecycleService);
await meta.setArchived(true);
for (const agent of agentLifecycle.list()) {
await agentLifecycle.remove(agent.id);
}
handle.accessor.get(ISessionActivityKernel).beginClosing();
await this.drainAgents(handle);
this.event.publish({
type: 'event.session.archived',
payload: { sessionId },
@ -260,6 +281,13 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
await this.hooks.onWillCloseSession.run(event);
}
private async drainAgents(handle: ISessionScopeHandle): Promise<void> {
const agentLifecycle = handle.accessor.get(IAgentLifecycleService);
for (const agent of agentLifecycle.list()) {
await agentLifecycle.remove(agent.id);
}
}
async fork(opts: ForkSessionOptions): Promise<ISessionScopeHandle> {
const sourceId = opts.sourceSessionId;
@ -275,92 +303,129 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
? sourceHandle.accessor.get(ISessionContext).workspaceId
: indexSummary!.workspaceId;
// 2. Reject forking a live session with an active turn or a pending
// interaction.
if (sourceHandle !== undefined) {
const status = sourceHandle.accessor.get(ISessionActivity).status();
if (status !== 'idle') {
// 2. Quiesce the live source so no new turn begins while the fork copies
// its wire logs — this closes the check-then-act window (矛盾 k) that the
// old `status() !== 'idle'` check suffered from. A closed source has no
// kernel to quiesce.
const quiesce =
sourceHandle !== undefined
? await sourceHandle.accessor.get(ISessionActivityKernel).quiesce('fork')
: undefined;
try {
// 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 ?? createSessionId();
if (this.sessions.has(targetId) || (await this.index.get(targetId)) !== undefined) {
throw new KimiError(
ErrorCodes.SESSION_FORK_ACTIVE_TURN,
`Session "${sourceId}" cannot be forked while a turn is running`,
{ details: { sessionId: sourceId } },
ErrorCodes.SESSION_ALREADY_EXISTS,
`Session "${targetId}" already exists`,
);
}
}
// 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 ?? createSessionId();
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.materializeSession({ 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 AgentWireRecordService 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,
targetWorkspaceId: targetCtx.workspaceId,
targetSessionId: targetCtx.sessionId,
// 6. Materialize the target session scope (fresh metadata + storage).
const target = await this.materializeSession({
sessionId: targetId,
workDir: workspace.root,
});
}
const targetCtx = target.accessor.get(ISessionContext);
const targetMeta = target.accessor.get(ISessionMetadata);
// 8. Rewrite the target metadata to reflect fork provenance.
const title = opts.title ?? `Fork: ${sourceMeta?.title || sourceId}`;
await targetMeta.update({
// 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 AgentWireRecordService 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,
targetWorkspaceId: targetCtx.workspaceId,
targetSessionId: targetCtx.sessionId,
});
}
// 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,
lastPrompt: sourceMeta?.lastPrompt,
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 sourceAgent = sourceAgents[agentId]!;
const agentHandle = await target.accessor.get(IAgentLifecycleService).create({
agentId,
forkedFrom: sourceAgent.forkedFrom,
labels: labelsFromAgentMeta(sourceAgent),
});
const forkWireRecord = agentHandle.accessor.get(IAgentWireRecordService);
await forkWireRecord.restore();
const forkRecords = forkWireRecord.getRecords() as readonly PersistedRecord[];
await agentHandle.accessor.get(IAgentWireService).replay(...forkRecords);
}
this._onDidForkSession.fire({
sourceSessionId: sourceId,
sessionId: targetId,
handle: target,
});
await this.announceCreated({ sessionId: targetId, handle: target, source: 'fork' });
return target;
} finally {
quiesce?.dispose();
}
}
async createChild(opts: CreateChildSessionOptions): Promise<ISessionScopeHandle> {
const title =
opts.title ??
`Child: ${(await this.resolveSourceTitle(opts.sourceSessionId)) ?? opts.sourceSessionId}`;
// The child markers win over any caller-supplied values so a forged
// `parent_session_id` / `child_session_kind` cannot reparent a session.
const metadata = {
...opts.metadata,
[PARENT_SESSION_ID_KEY]: opts.sourceSessionId,
[CHILD_SESSION_KIND_KEY]: CHILD_SESSION_KIND,
};
return this.fork({
sourceSessionId: opts.sourceSessionId,
newSessionId: opts.newSessionId,
title,
isCustomTitle: opts.title !== undefined ? true : sourceMeta?.isCustomTitle === true,
forkedFrom: sourceId,
archived: false,
lastPrompt: sourceMeta?.lastPrompt,
custom: forkCustomMetadata(sourceMeta?.custom, opts.metadata),
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 sourceAgent = sourceAgents[agentId]!;
const agentHandle = await target.accessor.get(IAgentLifecycleService).create({
agentId,
forkedFrom: sourceAgent.forkedFrom,
labels: labelsFromAgentMeta(sourceAgent),
});
const forkWireRecord = agentHandle.accessor.get(IAgentWireRecordService);
await forkWireRecord.restore();
await agentHandle
.accessor.get(IAgentWireService)
.replay(...(forkWireRecord.getRecords() as readonly PersistedRecord[]));
/**
* Best-effort source title for the default `Child: <title>` name. Reads the
* live handle first, then the persisted index. A missing source yields
* `undefined`; `fork` still throws `session.not_found` for the real
* existence check.
*/
private async resolveSourceTitle(sourceId: string): Promise<string | undefined> {
const live = this.sessions.get(sourceId);
if (live !== undefined) {
return (await live.accessor.get(ISessionMetadata).read()).title;
}
this._onDidForkSession.fire({
sourceSessionId: sourceId,
sessionId: targetId,
handle: target,
});
await this.announceCreated({ sessionId: targetId, handle: target, source: 'fork' });
return target;
return (await this.index.get(sourceId))?.title;
}
/**
@ -377,8 +442,8 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
}): 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)
const agentHandle = args.sourceHandle.accessor
.get(IAgentLifecycleService)
.getHandle(args.agentId);
if (agentHandle !== undefined) {
await agentHandle.accessor.get(IAgentWireRecordService).flush();

View file

@ -9,6 +9,7 @@
import { CoreErrors } from '#/_base/errors/codes';
import { AgentLifecycleErrors } from '#/session/agentLifecycle/errors';
import { ActivityErrors } from '#/activity/errors';
import { AuthErrors } from '#/app/auth/errors';
import { TaskErrors } from '#/agent/task/errors';
import { ChatProviderErrors } from '#/app/protocol/errors';
@ -39,6 +40,7 @@ export * from '#/_base/errors/errors';
export * from '#/_base/errors/serialize';
export * from '#/_base/errors/unexpectedError';
export { AgentLifecycleErrors } from '#/session/agentLifecycle/errors';
export { ActivityErrors } from '#/activity/errors';
export { AuthErrors } from '#/app/auth/errors';
export { TaskErrors } from '#/agent/task/errors';
export { ChatProviderErrors } from '#/app/protocol/errors';
@ -66,6 +68,7 @@ export { WireRecordErrors } from '#/agent/wireRecord/errors';
export const ErrorCodes = {
...CoreErrors.codes,
...AgentLifecycleErrors.codes,
...ActivityErrors.codes,
...AuthErrors.codes,
...TaskErrors.codes,
...ChatProviderErrors.codes,

View file

@ -167,6 +167,10 @@ export * from '#/app/multiServer/flag';
import '#/agent/turn/turn';
import '#/agent/turn/turnService';
export * from '#/activity/activity';
export * from '#/activity/activityOps';
import '#/activity/agentActivityService';
import '#/activity/sessionActivityKernel';
import '#/agent/plan/profile/plan';
import '#/agent/plan/tools/enter-plan-mode';
import '#/agent/plan/tools/exit-plan-mode';

View file

@ -45,6 +45,7 @@ import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { IAgentActivityService } from '#/activity/activity';
import { IAgentProfileService } from '#/agent/profile/profile';
import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
@ -379,13 +380,19 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
return all.filter((handle) => handle.id.startsWith(prefix));
}
remove(agentId: string): Promise<void> {
async remove(agentId: string): Promise<void> {
const handle = this.handles.get(agentId);
if (handle === undefined) return Promise.resolve();
if (handle === undefined) return;
this.handles.delete(agentId);
// Drive the agent activity kernel through disposal: reject new begins and
// abort any in-flight turn / background activity, then wait for it to drain
// (including the tool-execution grace window) before releasing the scope.
// This guarantees no async work keeps running on a disposed agent.
const activity = handle.accessor.get(IAgentActivityService);
activity.beginDisposal();
await activity.settled();
handle.dispose();
this.onDidDisposeEmitter.fire(agentId);
return Promise.resolve();
}
}

View file

@ -74,6 +74,7 @@ const CODER_TOOLS = [
'WebSearch',
'FetchURL',
'Write',
'mcp__*',
] as const;
const EXPLORE_TOOLS = [

View file

@ -26,8 +26,8 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'
import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types';
import { ErrorCodes, toKimiErrorPayload, type KimiErrorPayload } from '#/errors';
import { IAgentPromptService } from '#/agent/prompt/prompt';
import { IAgentTurnService, type Turn } from '#/agent/turn/turn';
import { IAgentUsageService } from '#/agent/usage/usage';
import type { Turn } from '#/agent/turn/turn';
import type { AgentProfileSummaryPolicy } from '#/app/agentProfileCatalog/agentProfileCatalog';
import { IEventBus } from '#/app/event/eventBus';
@ -102,9 +102,13 @@ async function awaitRun(
): Promise<{ summary: string; usage?: TokenUsage }> {
const controller = new AbortController();
const unlink = linkAbortSignal(options.signal, controller);
const turnService = target.accessor.get(IAgentTurnService);
const cancelTurn = (reason: unknown): void => {
turnService.cancel(undefined, reason);
};
let turnRef: Turn = turn;
try {
const result = await awaitTurn(turnRef, controller);
const result = await awaitTurn(turnRef, controller, cancelTurn);
classifyTurnResult(result, finishObserver.finishReasonFor(turnRef.id));
const summary = await distillSummary(
target,
@ -114,6 +118,7 @@ async function awaitRun(
turnRef = t;
},
finishObserver,
cancelTurn,
);
const usage = target.accessor.get(IAgentUsageService)?.status().total;
return { summary, usage };
@ -121,7 +126,7 @@ async function awaitRun(
finishObserver.dispose();
unlink();
if (controller.signal.aborted) {
turnRef.abortController.abort(controller.signal.reason);
cancelTurn(controller.signal.reason);
}
}
}
@ -129,9 +134,10 @@ async function awaitRun(
async function awaitTurn(
turn: Turn,
controller: AbortController,
cancelTurn: (reason: unknown) => void,
): Promise<{ reason: string; error?: unknown }> {
const onAbort = (): void => {
turn.abortController.abort(controller.signal.reason);
cancelTurn(controller.signal.reason);
};
controller.signal.addEventListener('abort', onAbort, { once: true });
try {
@ -147,6 +153,7 @@ async function distillSummary(
policy: AgentProfileSummaryPolicy | undefined,
setTurn: (turn: Turn) => void,
finishObserver: TurnFinishObserver,
cancelTurn: (reason: unknown) => void,
): Promise<string> {
const memory = target.accessor.get(IAgentContextMemoryService);
let summary = latestAssistantText(memory.get());
@ -163,7 +170,7 @@ async function distillSummary(
});
if (turn === undefined) break;
setTurn(turn);
const result = await awaitTurn(turn, controller);
const result = await awaitTurn(turn, controller, cancelTurn);
throwIfTruncatedSummary(result, finishObserver.finishReasonFor(turn.id));
if (result.reason !== 'completed') break;
const continued = latestAssistantText(memory.get());

View file

@ -0,0 +1,109 @@
/**
* `activity` kernel unit tests drives the real `AgentActivityService` with a
* stub Session kernel and an in-memory wire service.
*
* Asserts the PR1 turn-lane contract: `begin` admits a turn and rejects a
* concurrent one with `activity.agent_busy`, `cancel` moves the lane to
* `turn(ending)` and aborts the lease signal, and `lease.end` returns the lane
* to `idle` (idempotently). Run:
* `pnpm test -- test/activity/activity.test.ts`
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DisposableStore } from '#/_base/di/lifecycle';
import { createServices, TestInstantiationService } from '#/_base/di/test';
import { IAgentActivityService, ISessionActivityKernel } from '#/activity/activity';
import { AgentActivityService } from '#/activity/agentActivityService';
import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { ErrorCodes } from '#/errors';
import { IAgentWireService } from '#/wire/tokens';
import { WireService } from '#/wire/wireServiceImpl';
import { stubSessionActivityKernel } from './stubs';
describe('AgentActivityService (turn lane)', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
let activity: IAgentActivityService;
beforeEach(() => {
disposables = new DisposableStore();
ix = createServices(disposables, {
additionalServices: (reg) => {
reg.defineInstance(
IAgentWireService,
disposables.add(new WireService({ logScope: 'wire', logKey: 'activity' })),
);
reg.defineInstance(ISessionActivityKernel, stubSessionActivityKernel());
reg.defineInstance(
IAgentScopeContext,
makeAgentScopeContext({ agentId: 'agent', agentScope: 'agent' }),
);
reg.define(IAgentActivityService, AgentActivityService);
},
});
activity = ix.get(IAgentActivityService);
});
afterEach(() => {
disposables.dispose();
});
it('starts idle and admits a turn', () => {
expect(activity.lane()).toBe('idle');
const lease = activity.begin('turn');
expect(lease.kind).toBe('turn');
expect(lease.signal.aborted).toBe(false);
expect(activity.lane()).toBe('turn');
lease.end('completed');
expect(activity.lane()).toBe('idle');
});
it('rejects a concurrent begin with activity.agent_busy', () => {
const lease = activity.begin('turn');
expect(() => activity.begin('turn')).toThrowError(
expect.objectContaining({ code: ErrorCodes.ACTIVITY_AGENT_BUSY }),
);
lease.end('completed');
});
it('tryBegin returns undefined when busy', () => {
const lease = activity.begin('turn');
expect(activity.tryBegin('turn')).toBeUndefined();
lease.end('completed');
});
it('cancel aborts the lease signal and keeps the lane until end', () => {
const lease = activity.begin('turn');
expect(activity.cancel('stop')).toBe(true);
expect(lease.signal.aborted).toBe(true);
expect(lease.ending).toBe(true);
// Lane stays `turn` (ending) until the lease is returned.
expect(activity.lane()).toBe('turn');
lease.end('cancelled');
expect(activity.lane()).toBe('idle');
});
it('cancel is a no-op when idle', () => {
expect(activity.cancel()).toBe(false);
});
it('lease.end is idempotent', () => {
const lease = activity.begin('turn');
lease.end('completed');
expect(() => lease.end('completed')).not.toThrow();
expect(activity.lane()).toBe('idle');
});
it('beginDisposal aborts the in-flight lease and settles after end', async () => {
const lease = activity.begin('turn');
activity.beginDisposal();
expect(lease.signal.aborted).toBe(true);
expect(activity.lane()).toBe('disposing');
const settled = activity.settled();
lease.end('cancelled');
await settled;
expect(activity.lane()).toBe('disposed');
});
});

View file

@ -0,0 +1,37 @@
/**
* `activity` test stubs shared `ISessionActivityKernel` stubs for unit tests.
*
* Lives under `test/` (not `src/`) so test-support code stays out of the
* production tree. Import from a relative path. The default stub admits every
* turn (`active` lane), mirroring the PR1 placeholder Session kernel so
* Agent-scope unit tests can construct the real `AgentActivityService` without
* a Session scope tree.
*/
import type { IDisposable } from '#/_base/di/lifecycle';
import type {
ActivityLease,
ISessionActivityKernel,
SessionCommand,
SessionLane,
SessionQuiesceLease,
} from '#/activity/activity';
export function stubSessionActivityKernel(
lane: SessionLane = 'active',
): ISessionActivityKernel {
const leases = new Set<ActivityLease>();
return {
_serviceBrand: undefined,
lane: () => lane,
canAccept: (_command: SessionCommand) => lane === 'active',
admitTurn(_agentId: string, lease: ActivityLease): IDisposable {
leases.add(lease);
return { dispose: () => leases.delete(lease) };
},
quiesce: (reason: string): Promise<SessionQuiesceLease> =>
Promise.resolve({ reason, dispose: () => undefined }),
beginClosing: () => undefined,
settled: () => Promise.resolve(),
};
}

View file

@ -10,6 +10,8 @@ import { McpConnectionManager } from '#/agent/mcp/connection-manager';
import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode';
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
import { AgentLifecycleService } from '#/session/agentLifecycle/agentLifecycleService';
import '#/activity/agentActivityService';
import { ISessionActivityKernel } from '#/activity/activity';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IConfigService } from '#/app/config/config';
import { IAgentBlobService } from '#/agent/blob/agentBlobService';
@ -28,6 +30,8 @@ import { IAgentMediaToolsRegistrar } from '#/agent/media/mediaTools';
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
import type { OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
import { stubSessionActivityKernel } from '../activity/stubs';
const noopLog = {
_serviceBrand: undefined,
level: 'off',
@ -126,6 +130,7 @@ describe('AgentLifecycleService', () => {
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
ix.stub(IAppendLogStore, recordingAppendLog().store);
ix.stub(ISessionActivityKernel, stubSessionActivityKernel());
stubBlobPassThrough(ix);
registerAgent = vi.fn(() => Promise.resolve());
atomicDocs = new Map();

View file

@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest';
import { precheckUndo } from '#/agent/contextMemory/contextOps';
import type { ContextMessage } from '#/agent/contextMemory/types';
function text(value: string): { type: 'text'; text: string } {
return { type: 'text', text: value };
}
function user(origin?: ContextMessage['origin']): ContextMessage {
return {
role: 'user',
content: [text('u')],
toolCalls: [],
...(origin === undefined ? {} : { origin }),
};
}
function assistant(): ContextMessage {
return { role: 'assistant', content: [text('a')], toolCalls: [] };
}
function injection(): ContextMessage {
return {
role: 'user',
content: [text('i')],
toolCalls: [],
origin: { kind: 'injection', variant: 'system_reminder' },
};
}
function compaction(): ContextMessage {
return {
role: 'user',
content: [text('sum')],
toolCalls: [],
origin: { kind: 'compaction_summary' },
};
}
const USER_ORIGIN: ContextMessage['origin'] = { kind: 'user' };
describe('precheckUndo', () => {
it('returns ok when enough real user prompts exist', () => {
expect(precheckUndo([user(USER_ORIGIN), assistant()], 1)).toEqual({ ok: true });
});
it('skips trailing non-user messages while scanning', () => {
expect(precheckUndo([user(USER_ORIGIN), assistant(), assistant()], 1)).toEqual({ ok: true });
});
it('treats a user message without origin as a real prompt (legacy)', () => {
expect(precheckUndo([user(), assistant()], 1)).toEqual({ ok: true });
});
it('returns empty when the history has no real user prompt', () => {
expect(precheckUndo([], 1)).toEqual({
ok: false,
reason: 'empty',
requested: 1,
undoable: 0,
});
});
it('returns empty when only injections are present', () => {
expect(precheckUndo([injection(), assistant()], 1)).toEqual({
ok: false,
reason: 'empty',
requested: 1,
undoable: 0,
});
});
it('returns insufficient when some but fewer than count prompts exist', () => {
const history = [user(USER_ORIGIN), assistant(), user(USER_ORIGIN), assistant()];
expect(precheckUndo(history, 3)).toEqual({
ok: false,
reason: 'insufficient',
requested: 3,
undoable: 2,
});
});
it('returns compaction_boundary when a summary is hit before count is met', () => {
expect(precheckUndo([user(USER_ORIGIN), compaction(), assistant()], 1)).toEqual({
ok: false,
reason: 'compaction_boundary',
requested: 1,
undoable: 0,
});
});
it('reports compaction_boundary over insufficient when the boundary stops the scan', () => {
// One real user prompt sits after the summary, but count=2 needs more and the
// scan is stopped by the summary before reaching the older prompts.
const history = [user(USER_ORIGIN), compaction(), user(USER_ORIGIN), assistant()];
expect(precheckUndo(history, 2)).toEqual({
ok: false,
reason: 'compaction_boundary',
requested: 2,
undoable: 1,
});
});
});

View file

@ -86,7 +86,7 @@ describe('Cron — session E2E (P1.9)', () => {
removeFromQueue: () => {},
launched: Promise.resolve({
id: 1,
abortController: new AbortController(),
signal: new AbortController().signal,
ready: Promise.resolve(),
result: Promise.resolve({ reason: 'completed' as const }),
}),

View file

@ -33,7 +33,7 @@ const FAR_FUTURE_MS = 10 * 366 * 24 * 60 * 60 * 1000;
function fakeTurn(): Turn {
return {
id: 1,
abortController: new AbortController(),
signal: new AbortController().signal,
ready: Promise.resolve(),
result: Promise.resolve({ reason: 'completed' }),
};

View file

@ -54,7 +54,7 @@ function createSteerSpy(
) {
const returnValue = args.length === 0 ? {
id: 1,
abortController: new AbortController(),
signal: new AbortController().signal,
ready: Promise.resolve(),
result: Promise.resolve({ reason: 'completed' as const }),
} : args[0];
@ -150,7 +150,7 @@ describe('SessionCronService', () => {
telemetryRecords = captureTelemetry(telemetry);
steerCalls = createSteerSpy(prompt, {
id: 7,
abortController: new AbortController(),
signal: new AbortController().signal,
ready: Promise.resolve(),
result: Promise.resolve({ reason: 'completed' as const }),
});
@ -466,7 +466,7 @@ describe('SessionCronService', () => {
return hasActiveTurn
? {
id: 1,
abortController: new AbortController(),
signal: new AbortController().signal,
ready: Promise.resolve(),
result: Promise.resolve({ reason: 'completed' as const }),
}

View file

@ -35,7 +35,7 @@ function spySteer(prompt: IAgentPromptService) {
removeFromQueue: () => {},
launched: Promise.resolve({
id: 1,
abortController: new AbortController(),
signal: new AbortController().signal,
ready: Promise.resolve(),
result: Promise.resolve({ reason: 'completed' as const }),
}),

View file

@ -189,6 +189,9 @@ function stubSessionLifecycle(): ISessionLifecycleService {
fork: async () => {
throw new Error('not implemented');
},
createChild: async () => {
throw new Error('not implemented');
},
};
}

View file

@ -129,7 +129,7 @@ describe('RestGateway', () => {
const turn = turnService.launch();
await gw.cancel('s1', 'main', 'bye');
expect(turn.abortController.signal.aborted).toBe(true);
expect(turn.abortController.signal.reason).toBe('bye');
expect(turn.signal.aborted).toBe(true);
expect(turn.signal.reason).toBe('bye');
});
});

View file

@ -50,7 +50,7 @@ async function restoreGoalRecords(
function makeTurn(id: number): Turn {
return {
id,
abortController: new AbortController(),
signal: new AbortController().signal,
ready: Promise.resolve(),
result: Promise.resolve({ reason: 'completed' }),
};
@ -60,12 +60,12 @@ async function runGoalStep(loopService: IAgentLoopService, turn: Turn): Promise<
const step = {
turnId: turn.id,
step: 1,
signal: turn.abortController.signal,
signal: turn.signal,
};
const afterStep: AfterStepContext = {
turnId: turn.id,
step: 1,
signal: turn.abortController.signal,
signal: turn.signal,
usage: zeroUsage,
finishReason: 'completed' as const,
continue: false,
@ -84,7 +84,7 @@ async function runStepUsageHooks(
const afterStep: AfterStepContext = {
turnId: turn.id,
step: 1,
signal: turn.abortController.signal,
signal: turn.signal,
usage,
finishReason: 'completed' as const,
continue: false,
@ -679,12 +679,12 @@ describe('AgentGoalService core workflow hooks', () => {
const step = {
turnId: turn.id,
step: 1,
signal: turn.abortController.signal,
signal: turn.signal,
};
const afterStep: AfterStepContext = {
turnId: turn.id,
step: 1,
signal: turn.abortController.signal,
signal: turn.signal,
usage: zeroUsage,
finishReason: 'completed' as const,
continue: false,

View file

@ -3,12 +3,12 @@ import { describe, expect, it, onTestFinished } from 'vitest';
import { DisposableStore } from '#/_base/di/lifecycle';
import { createServices } from '#/_base/di/test';
import { buildImageCompressionCaption } from '#/_base/tools/support/image-compress';
import { IAgentLoopService } from '#/agent/loop/loop';
import { IAgentPromptService } from '#/agent/prompt/prompt';
import { AgentPromptService } from '#/agent/prompt/promptService';
import type { PromptSubmitContext } from '#/agent/prompt/prompt';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { IAgentLoopService } from '#/agent/loop/loop';
import { IAgentPromptService } from '#/agent/prompt/prompt';
import type { PromptSubmitContext } from '#/agent/prompt/prompt';
import { AgentPromptService } from '#/agent/prompt/promptService';
import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder';
import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService';
import type { ToolDidExecuteContext } from '#/agent/tool/toolHooks';
@ -60,23 +60,27 @@ async function flushSteers(loop: IAgentLoopService, turn: Turn): Promise<void> {
await loop.hooks.beforeStep.run({
turnId: turn.id,
step: 1,
signal: turn.abortController.signal,
signal: turn.signal,
});
}
describe('AgentPromptService', () => {
it('delegates inactive steer to prompt', async () => {
const { prompt, turn } = createHarness();
const seen: Array<Pick<PromptSubmitContext, 'isSteer'> & {
readonly originKind: string | undefined;
}> = [];
const seen: Array<
Pick<PromptSubmitContext, 'isSteer'> & {
readonly originKind: string | undefined;
}
> = [];
prompt.hooks.onWillSubmitPrompt.register('capture', async (ctx, next) => {
seen.push({ isSteer: ctx.isSteer, originKind: ctx.promptMessage.origin?.kind });
await next();
});
await prompt.prompt(userMessage('from prompt', { kind: 'system_trigger', name: 'test_prompt' }));
await prompt.prompt(
userMessage('from prompt', { kind: 'system_trigger', name: 'test_prompt' }),
);
const steer = prompt.steer(
userMessage('from steer', { kind: 'system_trigger', name: 'test_steer' }),
);
@ -87,9 +91,11 @@ describe('AgentPromptService', () => {
{ isSteer: false, originKind: 'system_trigger' },
]);
expect(turn.launches).toHaveLength(2);
expect(() => steer.removeFromQueue()).toThrow(expect.objectContaining({
code: 'request.invalid',
}));
expect(() => steer.removeFromQueue()).toThrow(
expect.objectContaining({
code: 'request.invalid',
}),
);
});
it('launches the turn before appending the user message', async () => {
@ -118,9 +124,11 @@ describe('AgentPromptService', () => {
it('runs submit hooks before queuing active steers', async () => {
const { context, loop, prompt, turn } = createHarness({ hasActiveTurn: true });
const activeTurn = turn.launch();
const seen: Array<Pick<PromptSubmitContext, 'isSteer'> & {
readonly originKind: string | undefined;
}> = [];
const seen: Array<
Pick<PromptSubmitContext, 'isSteer'> & {
readonly originKind: string | undefined;
}
> = [];
prompt.hooks.onWillSubmitPrompt.register('capture', async (ctx, next) => {
seen.push({ isSteer: ctx.isSteer, originKind: ctx.promptMessage.origin?.kind });
@ -155,9 +163,11 @@ describe('AgentPromptService', () => {
kind: 'system_trigger',
name: 'test_emitted',
});
expect(() => emitted.removeFromQueue()).toThrow(expect.objectContaining({
code: 'request.invalid',
}));
expect(() => emitted.removeFromQueue()).toThrow(
expect.objectContaining({
code: 'request.invalid',
}),
);
});
it('does not queue active steers blocked by hooks', async () => {
@ -210,7 +220,7 @@ describe('AgentPromptService', () => {
} as const;
const didCtx: ToolDidExecuteContext = {
turnId: activeTurn.id,
signal: activeTurn.abortController.signal,
signal: activeTurn.signal,
toolCall: { type: 'function', id: 'call_skill', name: 'Skill', arguments: '{}' },
toolCalls: [],
args: {},
@ -243,6 +253,51 @@ describe('AgentPromptService', () => {
});
});
describe('undo', () => {
function assistantMessage(text: string): ContextMessage {
return { role: 'assistant', content: [{ type: 'text', text }], toolCalls: [] };
}
it('removes the trailing turn and returns the number of prompts removed', () => {
const { context, prompt } = createHarness();
context.append(userMessage('q', { kind: 'user' }));
context.append(assistantMessage('a'));
expect(prompt.undo(1)).toBe(1);
expect(context.messages).toEqual([]);
});
it('throws session.undo_unavailable (empty) when no real user prompt exists', () => {
const { prompt } = createHarness();
expect(() => prompt.undo(1)).toThrow(
expect.objectContaining({
code: 'session.undo_unavailable',
details: expect.objectContaining({ reason: 'empty' }),
}),
);
});
it('throws session.undo_unavailable (insufficient) and removes nothing when count exceeds the history', () => {
const { context, prompt } = createHarness();
context.append(userMessage('q', { kind: 'user' }));
context.append(assistantMessage('a'));
expect(() => prompt.undo(2)).toThrow(
expect.objectContaining({
code: 'session.undo_unavailable',
details: expect.objectContaining({
reason: 'insufficient',
requestedCount: 2,
undoableCount: 1,
}),
}),
);
// The precheck fails before any state is removed.
expect(context.messages).toHaveLength(2);
});
});
describe('image-compression caption rerouting', () => {
const CAPTION = buildImageCompressionCaption({
original: { width: 3264, height: 666, byteLength: 344 * 1024, mimeType: 'image/png' },

View file

@ -28,7 +28,7 @@ function controlledTurn(id: number): ControlledTurn {
});
const turn: Turn = {
id,
abortController: new AbortController(),
signal: new AbortController().signal,
ready: Promise.resolve(),
result,
};
@ -94,6 +94,8 @@ function createHarness(options: { readonly blockPrompt?: boolean } = {}): Harnes
throw new Error('not used');
},
getActiveTurn: () => activeTurn,
recordSteer: () => {},
cancel: () => activeTurn !== undefined,
hooks: {
onLaunched: { run: async () => {} },
onEnded: { run: async () => {} },
@ -239,4 +241,58 @@ describe('AgentPromptLegacyService', () => {
code: 'prompt.not_found',
});
});
it('submitAndSettle resolves completion with the turn result', async () => {
const { service, settleActive } = createHarness();
const { submit, completion } = await service.submitAndSettle(textBody('hi'));
expect(submit.status).toBe('running');
settleActive({ reason: 'completed' });
await expect(completion).resolves.toMatchObject({
promptId: submit.prompt_id,
result: { reason: 'completed' },
});
});
it('submitAndSettle resolves a queued prompt after it launches and settles', async () => {
const { service, settleActive } = createHarness();
await service.submitAndSettle(textBody('first'));
const second = await service.submitAndSettle(textBody('second'));
expect(second.submit.status).toBe('queued');
// Settle the active (first) turn so the queued second prompt launches.
settleActive({ reason: 'completed' });
await vi.waitFor(() =>
expect(service.list().active?.prompt_id).toBe(second.submit.prompt_id),
);
// Settle the now-active second turn; its completion should resolve.
settleActive({ reason: 'completed' });
await expect(second.completion).resolves.toMatchObject({
promptId: second.submit.prompt_id,
result: { reason: 'completed' },
});
});
it('submitAndSettle rejects completion when the prompt is blocked', async () => {
const { service } = createHarness({ blockPrompt: true });
const { submit, completion } = await service.submitAndSettle(textBody('blocked'));
expect(submit.status).toBe('blocked');
const outcome = completion.then(
() => 'resolved' as const,
() => 'rejected' as const,
);
expect(await outcome).toBe('rejected');
});
it('submitAndSettle rejects completion when a queued prompt is aborted', async () => {
const { service } = createHarness();
await service.submitAndSettle(textBody('first'));
const second = await service.submitAndSettle(textBody('second'));
const outcome = second.completion.then(
() => 'resolved' as const,
() => 'rejected' as const,
);
await service.abort(second.submit.prompt_id);
expect(await outcome).toBe('rejected');
});
});

View file

@ -19,7 +19,7 @@ import { stubTurn } from '../turn/stubs';
function makeTurn(id: number): Turn {
return {
id,
abortController: new AbortController(),
signal: new AbortController().signal,
ready: Promise.resolve(),
result: Promise.resolve({ reason: 'completed' }),
};

View file

@ -328,6 +328,9 @@ function registerSessionExportServices(
fork: async () => {
throw new Error('fork should not be called by session export');
},
createChild: async () => {
throw new Error('createChild should not be called by session export');
},
});
reg.defineInstance(IWorkspaceRegistry, {
_serviceBrand: undefined,

View file

@ -1,13 +1,17 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { promises as fsp } from 'node:fs';
import os from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { InstantiationType } from '#/_base/di/extensions';
import { ILogService } from '#/_base/log/log';
import { LifecycleScope, _clearScopedRegistryForTests, registerScopedService } from '#/_base/di/scope';
import {
LifecycleScope,
_clearScopedRegistryForTests,
registerScopedService,
} from '#/_base/di/scope';
import { createScopedTestHost, stubPair } from '#/_base/di/test';
import { ILogService } from '#/_base/log/log';
import { encodeWorkDirKey } from '#/_base/utils/workdir-slug';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IFlagService } from '#/app/flag/flag';
@ -19,6 +23,7 @@ import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageSe
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
import { IQueryStore } from '#/persistence/interface/queryStore';
import { IFileSystemStorageService } from '#/persistence/interface/storage';
import { stubBootstrap } from '../bootstrap/stubs';
import { stubFlag } from '../flag/stubs';
import { stubLog } from '../log/stubs';
@ -35,7 +40,13 @@ describe('FileSessionIndex (legacy)', () => {
beforeEach(async () => {
_clearScopedRegistryForTests();
registerScopedService(LifecycleScope.App, ISessionIndex, FileSessionIndex, InstantiationType.Delayed, 'sessionIndex');
registerScopedService(
LifecycleScope.App,
ISessionIndex,
FileSessionIndex,
InstantiationType.Delayed,
'sessionIndex',
);
homeDir = await fsp.mkdtemp(join(os.tmpdir(), 'ws-sessions-'));
sessionsDir = join(homeDir, 'sessions');
workspaceId = encodeWorkDirKey(WORK_DIR);
@ -56,7 +67,9 @@ describe('FileSessionIndex (legacy)', () => {
stubPair(IQueryStore, stubQueryStore()),
stubPair(IFlagService, stubFlag(false)),
]);
disposeHost = () => { host.dispose(); };
disposeHost = () => {
host.dispose();
};
return host.app.accessor.get(ISessionIndex);
}
@ -133,6 +146,36 @@ describe('FileSessionIndex (legacy)', () => {
expect(archivedIncluded.items.map((s) => s.id)).toEqual(['archived']);
});
it('list filters by childOf using the parent_session_id + child_session_kind markers', async () => {
await seedSession('parent', { createdAt: 1, updatedAt: 10 });
await seedSession('child-a', {
createdAt: 2,
updatedAt: 9,
custom: { parent_session_id: 'parent', child_session_kind: 'child' },
});
await seedSession('child-b', {
createdAt: 3,
updatedAt: 8,
custom: { parent_session_id: 'parent', child_session_kind: 'child' },
});
// A plain fork carries `parent_session_id` but no `child_session_kind` — excluded.
await seedSession('fork', {
createdAt: 4,
updatedAt: 7,
custom: { parent_session_id: 'parent' },
});
// A grandchild points at `child-a`, not `parent` — excluded.
await seedSession('grandchild', {
createdAt: 5,
updatedAt: 6,
custom: { parent_session_id: 'child-a', child_session_kind: 'child' },
});
const store = build();
const page = await store.list({ childOf: 'parent' });
expect(page.items.map((s) => s.id).toSorted()).toEqual(['child-a', 'child-b']);
});
it('countActive counts non-archived sessions', async () => {
await seedSession('a', {});
await seedSession('b', {});
@ -154,8 +197,20 @@ describe('FileSessionIndex (read model)', () => {
beforeEach(async () => {
_clearScopedRegistryForTests();
registerScopedService(LifecycleScope.App, ISessionIndex, FileSessionIndex, InstantiationType.Delayed, 'sessionIndex');
registerScopedService(LifecycleScope.App, IQueryStore, MiniDbQueryStore, InstantiationType.Delayed, 'storage');
registerScopedService(
LifecycleScope.App,
ISessionIndex,
FileSessionIndex,
InstantiationType.Delayed,
'sessionIndex',
);
registerScopedService(
LifecycleScope.App,
IQueryStore,
MiniDbQueryStore,
InstantiationType.Delayed,
'storage',
);
homeDir = await fsp.mkdtemp(join(os.tmpdir(), 'ws-sessions-rm-'));
sessionsDir = join(homeDir, 'sessions');
workspaceId = encodeWorkDirKey(WORK_DIR);
@ -176,7 +231,9 @@ describe('FileSessionIndex (read model)', () => {
stubPair(ILogService, stubLog()),
stubPair(IFlagService, stubFlag(true)),
]);
disposeHost = () => { host.dispose(); };
disposeHost = () => {
host.dispose();
};
queryStore = host.app.accessor.get(IQueryStore);
return host.app.accessor.get(ISessionIndex);
}
@ -213,7 +270,11 @@ describe('FileSessionIndex (read model)', () => {
// A second list is served from the read model: mutate the read model to
// prove the disk is not re-read.
await queryStore.put(SESSION_COLLECTION, 'active', summary('active', { title: 'renamed', updatedAt: 3 }));
await queryStore.put(
SESSION_COLLECTION,
'active',
summary('active', { title: 'renamed', updatedAt: 3 }),
);
const second = await store.list({ workspaceId });
expect(second.items[0]?.title).toBe('renamed');
});
@ -226,6 +287,34 @@ describe('FileSessionIndex (read model)', () => {
expect(got?.title).toBe('cached');
});
it('list filters by childOf from the read model', async () => {
await seedSession('child-a', {
createdAt: 2,
updatedAt: 9,
custom: { parent_session_id: 'parent', child_session_kind: 'child' },
});
await seedSession('child-b', {
createdAt: 3,
updatedAt: 8,
custom: { parent_session_id: 'parent', child_session_kind: 'child' },
});
// Plain fork (no kind) and a grandchild (different parent) are excluded.
await seedSession('fork', {
createdAt: 4,
updatedAt: 7,
custom: { parent_session_id: 'parent' },
});
await seedSession('grandchild', {
createdAt: 5,
updatedAt: 6,
custom: { parent_session_id: 'child-a', child_session_kind: 'child' },
});
const store = build();
const page = await store.list({ childOf: 'parent' });
expect(page.items.map((s) => s.id).toSorted()).toEqual(['child-a', 'child-b']);
});
it('countActive reflects read-model updates', async () => {
await seedSession('a', {});
await seedSession('b', { archived: true });

View file

@ -48,7 +48,7 @@ function stubSessionContext(sessionId = 'test-session'): ISessionContext {
function fakeTurn(): Turn {
return {
id: 1,
abortController: new AbortController(),
signal: new AbortController().signal,
ready: Promise.resolve(),
result: Promise.resolve({ reason: 'completed' }),
};

View file

@ -39,13 +39,18 @@ export type StubTurn = IAgentTurnService & {
readonly launches: readonly number[];
};
const turnControllers = new WeakMap<Turn, AbortController>();
function makeTurn(id: number): Turn {
return {
const controller = new AbortController();
const turn: Turn = {
id,
abortController: new AbortController(),
signal: controller.signal,
ready: Promise.resolve(),
result: Promise.resolve({ reason: 'completed' }),
};
turnControllers.set(turn, controller);
return turn;
}
function makeAgentLoopHookSlots(): IAgentLoopService['hooks'] {
@ -88,7 +93,7 @@ export function stubTurn(options: StubTurnOptions = {}): StubTurn {
const turn = this.getActiveTurn();
if (turn === undefined) return false;
if (turnId !== undefined && turn.id !== turnId) return false;
turn.abortController.abort(reason);
turnControllers.get(turn)?.abort(reason);
return true;
},
prompts: [],

View file

@ -13,6 +13,9 @@ import { IAgentTurnService } from '#/agent/turn/turn';
import { AgentTurnService } from '#/agent/turn/turnService';
import { TurnModel } from '#/agent/turn/turnOps';
import { IAgentUsageService } from '#/agent/usage/usage';
import { IAgentActivityService, ISessionActivityKernel } from '#/activity/activity';
import { AgentActivityService } from '#/activity/agentActivityService';
import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { IConfigService } from '#/app/config/config';
import { IEventBus } from '#/app/event/eventBus';
import { emptyUsage } from '#/app/llmProtocol/usage';
@ -31,6 +34,7 @@ import { WireService } from '#/wire/wireServiceImpl';
import { stubContextMemory } from '../contextMemory/stubs';
import { stubLog } from '../log/stubs';
import { recordingTelemetry } from '../telemetry/stubs';
import { stubSessionActivityKernel } from '../activity/stubs';
import { stubLoopWithHooks, stubToolExecutor } from './stubs';
const noopEventBus: IEventBus = {
@ -62,6 +66,12 @@ describe('AgentTurnService ready', () => {
disposables.add(new WireService({ logScope: 'wire', logKey: 'turn-ready' })),
);
reg.defineInstance(IEventBus, noopEventBus);
reg.defineInstance(ISessionActivityKernel, stubSessionActivityKernel());
reg.defineInstance(
IAgentScopeContext,
makeAgentScopeContext({ agentId: 'turn-ready', agentScope: 'turn-ready' }),
);
reg.define(IAgentActivityService, AgentActivityService);
reg.define(IAgentTurnService, AgentTurnService);
},
});
@ -155,7 +165,7 @@ describe('AgentTurnService ready', () => {
expect(error).toBeInstanceOf(KimiError);
expect(error).toMatchObject({
code: ErrorCodes.TURN_AGENT_BUSY,
code: ErrorCodes.ACTIVITY_AGENT_BUSY,
details: { turnId: turn.id },
});
await expect(turn.result).resolves.toMatchObject({ reason: 'completed', steps: 1 });
@ -371,6 +381,9 @@ describe('AgentTurnService wire state', () => {
set: () => {},
});
ix.stub(IEventBus, noopEventBus);
ix.stub(ISessionActivityKernel, stubSessionActivityKernel());
ix.stub(IAgentScopeContext, makeAgentScopeContext({ agentId: 'turn-ready', agentScope: 'turn-ready' }));
ix.set(IAgentActivityService, new SyncDescriptor(AgentActivityService));
ix.set(IAgentTurnService, new SyncDescriptor(AgentTurnService));
log = ix.get(IAppendLogStore);
turnService = ix.get(IAgentTurnService);

View file

@ -1128,7 +1128,7 @@ describe('Agent turn flow', () => {
// A programmatic abort (e.g. a subagent deadline timeout) carries a plain
// AbortError as its reason, not a UserCancellationError, so it must not be
// reported as a user interrupt.
ctx.get(IAgentTurnService).getActiveTurn()?.abortController.abort(abortError());
ctx.get(IAgentTurnService).cancel(undefined, abortError());
await ctx.untilTurnEnd();
expect(triggered).toEqual([]);
@ -2003,7 +2003,7 @@ describe('Agent turn flow', () => {
await expect(
ctx.rpc.prompt({ input: [{ type: 'text', text: 'This should not start a new turn' }] }),
).rejects.toMatchObject({
code: ErrorCodes.TURN_AGENT_BUSY,
code: ErrorCodes.ACTIVITY_AGENT_BUSY,
details: { turnId: 0 },
});

View file

@ -15,9 +15,20 @@
* GET /sessions/{session_id}/status best-effort
* GET /sessions/{session_id}/warnings agents-md-oversized notice
*
* The `POST /sessions/{tail}` actions (`fork` / `compact` / `undo` / `abort` /
* `btw` / `archive`) and the `/sessions/{id}/children` endpoints are dispatched
* to `ISessionLegacyService` (a v1 edge adapter over the native v2 services);
* The `POST /sessions/{tail}` actions split into two groups. The thin
* pass-throughs `fork` / `compact` / `abort` / `archive` call the native v2
* services directly (`ISessionLifecycleService.fork` / `archive`,
* `IAgentFullCompactionService.begin`, `IAgentRPCService.cancel`); there is no
* v1-only projection to centralize, so no adapter is involved. `undo` likewise
* calls `IAgentPromptService.undo` directly (it now throws
* `session.undo_unavailable` with a structured reason) and only borrows
* `ISessionLegacyService.status` for the cross-domain status rollup. The
* `/sessions/{id}/children` endpoints call `ISessionLifecycleService.createChild`
* and `ISessionIndex.list({ childOf })` directly the child markers and
* parent-title default live in the lifecycle, and the child filter lives in the
* index. Only `POST /sessions/{id}/profile` (`updateProfile`) and
* `GET /sessions/{id}/status` go through `ISessionLegacyService` (the
* `agent_config` patch and the status rollup hold real cross-domain adaptation);
* the route forwards each adapter result verbatim, mirroring v1's thin handler.
* `create`, `fork`, and child creation publish `event.session.created` on the
* core event bus, matching v1.
@ -57,10 +68,15 @@
import {
ErrorCodes,
IAgentContextMemoryService,
IAgentProfileService,
IAgentPromptService,
IAgentFullCompactionService,
IAgentRPCService,
IAuthSummaryService,
ISessionBtwService,
ISessionActivity,
ISessionContext,
ISessionIndex,
ISessionLifecycleService,
ISessionMetadata,
@ -69,6 +85,9 @@ import {
IWorkspaceRegistry,
isKimiError,
KimiError,
toProtocolMessage,
type ContextMessage,
type IAgentScopeHandle,
type Scope,
} from '@moonshot-ai/agent-core-v2';
import {
@ -169,8 +188,8 @@ const sessionIdParamSchema = z.object({
// Mirrors v1's children query: id-cursors + page_size + status. The route
// projects the live `ISessionActivity.status()` onto each child and filters the
// page by `status` (post-page, matching v1); `ISessionLegacyService` stays
// protocol-free and does not apply the filter itself.
// page by `status` (post-page, matching v1); the child-marker filtering lives in
// `ISessionIndex.list({ childOf })`, the status filter stays at the edge.
const sessionChildrenListQueryCoercion = z
.object({
before_id: z.string().min(1).optional(),
@ -426,8 +445,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void
return;
}
const cwd =
summary.cwd ??
(await core.accessor.get(IWorkspaceRegistry).get(summary.workspaceId))?.root;
summary.cwd ?? (await core.accessor.get(IWorkspaceRegistry).get(summary.workspaceId))?.root;
if (cwd === undefined) {
// Persisted session with no `cwd` on disk and no registered workspace
// to fall back to (predates gap-G3 persistence) — cannot project cwd.
@ -474,8 +492,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void
return;
}
const cwd =
summary.cwd ??
(await core.accessor.get(IWorkspaceRegistry).get(summary.workspaceId))?.root;
summary.cwd ?? (await core.accessor.get(IWorkspaceRegistry).get(summary.workspaceId))?.root;
if (cwd === undefined) {
reply.send(
errEnvelope(
@ -514,8 +531,8 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void
async (req, reply) => {
try {
const { session_id } = req.params;
const fields = await core
.accessor.get(ISessionLegacyService)
const fields = await core.accessor
.get(ISessionLegacyService)
.updateProfile(session_id, req.body);
const session = toWireSession(fields, fields.root, resolveSessionStatus(core, fields.id));
// Broadcast the title change to every connection (including clients not
@ -589,8 +606,20 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void
if (parsed.action === 'fork') {
const body = forkSessionRequestSchema.parse(req.body);
const fields = await legacy.fork(parsed.id, body);
const session = toWireSession(fields, fields.root, resolveSessionStatus(core, fields.id));
// `lifecycle.fork` throws `session.not_found` for an unknown source,
// so no explicit existence check is needed here.
const handle = await core.accessor.get(ISessionLifecycleService).fork({
sourceSessionId: parsed.id,
title: body.title,
metadata: body.metadata,
});
const meta = await handle.accessor.get(ISessionMetadata).read();
const ctx = handle.accessor.get(ISessionContext);
const session = toWireSession(
{ ...meta, workspaceId: ctx.workspaceId },
ctx.cwd,
resolveSessionStatus(core, meta.id),
);
core.accessor.get(IEventService).publish({
type: 'event.session.created',
payload: { agentId: 'main', sessionId: session.id, session },
@ -601,21 +630,54 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void
if (parsed.action === 'compact') {
const body = compactSessionRequestSchema.parse(req.body);
const result = await legacy.compact(parsed.id, body);
reply.send(okEnvelope(result, req.id));
const agent = await resolveMainAgent(core, parsed.id);
// `begin` returns false when busy / over the per-turn limit — v1
// treats that as a silent success. It throws `compaction.unable`
// when there is no compactable prefix, which propagates.
agent.accessor
.get(IAgentFullCompactionService)
.begin({ source: 'manual', instruction: normalizeOptional(body.instruction) });
reply.send(okEnvelope({}, req.id));
return;
}
if (parsed.action === 'undo') {
const body = undoSessionRequestSchema.parse(req.body);
const result = await legacy.undo(parsed.id, body);
reply.send(okEnvelope(result, req.id));
const agent = await resolveMainAgent(core, parsed.id);
// `prompt.undo` throws `session.undo_unavailable` (with a structured
// `reason`) when the history cannot satisfy `count`; it is a no-op
// until the precheck passes, so the post-undo read below always sees
// the cut applied. The status rollup stays in the legacy adapter
// (cross-domain) and is reused here verbatim.
agent.accessor.get(IAgentPromptService).undo(body.count);
const history = agent.accessor.get(IAgentContextMemoryService).get();
const [summary, status] = await Promise.all([
core.accessor.get(ISessionIndex).get(parsed.id),
legacy.status(parsed.id),
]);
reply.send(
okEnvelope(
{
messages: pageUndoMessages(
parsed.id,
summary?.createdAt ?? 0,
history,
body.page_size,
),
status,
},
req.id,
),
);
return;
}
if (parsed.action === 'abort') {
const result = await legacy.abort(parsed.id);
reply.send(okEnvelope(result, req.id));
const agent = await resolveMainAgent(core, parsed.id);
// No turnId → cancel whatever turn is active; a safe no-op when idle.
// v1 always reports success once the session exists.
await agent.accessor.get(IAgentRPCService).cancel({});
reply.send(okEnvelope({ aborted: true }, req.id));
return;
}
@ -635,9 +697,15 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void
return;
}
// archive
const result = await legacy.archive(parsed.id);
reply.send(okEnvelope(result, req.id));
// archive — `resume` (not `get`) so archiving a freshly-opened cold
// session still works; `resume` returns undefined only when the session
// is unknown or its workspace is gone, reported as `session.not_found`.
const archived = await core.accessor.get(ISessionLifecycleService).resume(parsed.id);
if (archived === undefined) {
throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${parsed.id} does not exist`);
}
await core.accessor.get(ISessionLifecycleService).archive(parsed.id);
reply.send(okEnvelope({ archived: true }, req.id));
} catch (error) {
sendMappedError(reply, req.id, error);
}
@ -666,11 +734,53 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void
async (req, reply) => {
try {
const { session_id } = req.params;
const page = await core
.accessor.get(ISessionLegacyService)
.listChildren(session_id, req.query);
const projected = page.items.map((fields) =>
toWireSession(fields, fields.root, resolveSessionStatus(core, fields.id)),
// 404 when the parent is unknown — the live handle wins, otherwise the
// persisted index (a closed parent can still list children, like v1).
const exists =
core.accessor.get(ISessionLifecycleService).get(session_id) !== undefined ||
(await core.accessor.get(ISessionIndex).get(session_id)) !== undefined;
if (!exists) {
throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${session_id} does not exist`);
}
// The index filters by the child markers (`parent_session_id` +
// `child_session_kind`) and returns the recency-sorted children. The
// id-cursor, page-size, and status projection/filter stay at the edge
// (v1 wire concerns; status needs live handles).
const children = (await core.accessor.get(ISessionIndex).list({ childOf: session_id }))
.items;
let pivotIndex = -1;
if (req.query.before_id !== undefined) {
pivotIndex = children.findIndex((s) => s.id === req.query.before_id);
} else if (req.query.after_id !== undefined) {
pivotIndex = children.findIndex((s) => s.id === req.query.after_id);
}
let slice: typeof children;
if (req.query.before_id !== undefined && pivotIndex >= 0) {
slice = children.slice(pivotIndex + 1);
} else if (req.query.after_id !== undefined && pivotIndex >= 0) {
slice = children.slice(0, pivotIndex);
} else {
slice = children;
}
// `page_size` is already clamped to [1, 100] by the query coercion; 100
// is the v1 default when omitted.
const pageSize = req.query.page_size ?? 100;
const window = slice.slice(0, pageSize);
// `cwd` is read from the child's own summary first (gap G3 closed); the
// registry is only a back-compat fallback for sessions written before
// `cwd` was persisted, defaulting to '' (matches the prior adapter).
const roots = new Map(
(await core.accessor.get(IWorkspaceRegistry).list()).map((w) => [w.id, w.root]),
);
const projected = window.map((summary) =>
toWireSession(
summary,
summary.cwd ?? roots.get(summary.workspaceId) ?? '',
resolveSessionStatus(core, summary.id),
),
);
// v1 filters the projected page by `status` (post-page); `has_more`
// reflects the pre-filter page.
@ -678,7 +788,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void
req.query.status !== undefined
? projected.filter((session) => session.status === req.query.status)
: projected;
reply.send(okEnvelope({ items, has_more: page.has_more }, req.id));
reply.send(okEnvelope({ items, has_more: slice.length > pageSize }, req.id));
} catch (error) {
sendMappedError(reply, req.id, error);
}
@ -708,10 +818,22 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void
async (req, reply) => {
try {
const { session_id } = req.params;
const fields = await core
.accessor.get(ISessionLegacyService)
.createChild(session_id, req.body);
const session = toWireSession(fields, fields.root, resolveSessionStatus(core, fields.id));
// `createChild` throws `session.not_found` for an unknown source (via
// `fork`), so no explicit existence check is needed here. The child
// markers (`parent_session_id` / `child_session_kind`) and the default
// `Child: <parent>` title are applied by the lifecycle.
const handle = await core.accessor.get(ISessionLifecycleService).createChild({
sourceSessionId: session_id,
title: req.body.title,
metadata: req.body.metadata,
});
const meta = await handle.accessor.get(ISessionMetadata).read();
const ctx = handle.accessor.get(ISessionContext);
const session = toWireSession(
{ ...meta, workspaceId: ctx.workspaceId },
ctx.cwd,
resolveSessionStatus(core, meta.id),
);
core.accessor.get(IEventService).publish({
type: 'event.session.created',
payload: { agentId: 'main', sessionId: session.id, session },
@ -864,6 +986,56 @@ function resolveSessionStatus(core: Scope, sessionId: string): SessionStatus {
return handle.accessor.get(ISessionActivity).status();
}
/**
* Resume the session (cold-load if needed) and resolve its main agent, throwing
* `session.not_found` when the session is unknown or its workspace is gone.
* Shared by the `compact` / `abort` actions, which both operate on the main
* agent but carry no v1-specific projection worth keeping in
* `ISessionLegacyService`.
*/
async function resolveMainAgent(core: Scope, sessionId: string): Promise<IAgentScopeHandle> {
const session = await core.accessor.get(ISessionLifecycleService).resume(sessionId);
if (session === undefined) {
throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`);
}
return ensureMainAgent(session);
}
/** Trim a compaction instruction; treat an empty/blank value as absent. */
function normalizeOptional(value: string | undefined): string | undefined {
if (value === undefined) return undefined;
const trimmed = value.trim();
return trimmed.length === 0 ? undefined : trimmed;
}
/** v1 `:undo` message page-size clamp (`packages/agent-core/.../sessionService.ts`). */
const DEFAULT_UNDO_MESSAGE_PAGE_SIZE = 50;
const MAX_UNDO_MESSAGE_PAGE_SIZE = 100;
/**
* Mirror of v1 `pageContextMessages`: project the post-undo history into a
* newest-first wire page, clamping `page_size` to `[1, 100]` (default 50).
*/
function pageUndoMessages(
sessionId: string,
sessionCreatedAtMs: number,
history: readonly ContextMessage[],
requestedPageSize: number | undefined,
): { items: ReturnType<typeof toProtocolMessage>[]; has_more: boolean } {
const pageSize = Math.min(
Math.max(requestedPageSize ?? DEFAULT_UNDO_MESSAGE_PAGE_SIZE, 1),
MAX_UNDO_MESSAGE_PAGE_SIZE,
);
const all = history.map((message, index) =>
toProtocolMessage(sessionId, index, message, sessionCreatedAtMs),
);
const desc = all.toReversed();
return {
items: desc.slice(0, pageSize),
has_more: desc.length > pageSize,
};
}
/**
* Build the wire `Session.metadata`: caller-supplied custom fields (minus the
* reserved `goal` key, matching v1's `toProtocolSession`) overlaid with the
@ -946,7 +1118,9 @@ function sendMappedError(
reply.send(errEnvelope(ErrorCode.GOAL_OBJECTIVE_EMPTY, err.message, requestId, err.stack));
return;
case ErrorCodes.GOAL_OBJECTIVE_TOO_LONG:
reply.send(errEnvelope(ErrorCode.GOAL_OBJECTIVE_TOO_LONG, err.message, requestId, err.stack));
reply.send(
errEnvelope(ErrorCode.GOAL_OBJECTIVE_TOO_LONG, err.message, requestId, err.stack),
);
return;
case 'request.invalid':
case 'validation.failed':

View file

@ -29,6 +29,7 @@ export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
const KIMI_TO_PROTOCOL: Record<string, ErrorCode> = {
[ErrorCodes.SESSION_NOT_FOUND]: ErrorCode.SESSION_NOT_FOUND,
[ErrorCodes.SESSION_UNDO_UNAVAILABLE]: ErrorCode.SESSION_UNDO_UNAVAILABLE,
[ErrorCodes.REQUEST_INVALID]: ErrorCode.VALIDATION_FAILED,
[ErrorCodes.NOT_IMPLEMENTED]: ErrorCode.INTERNAL_ERROR,
[ErrorCodes.PROMPT_NOT_FOUND]: ErrorCode.PROMPT_NOT_FOUND,

View file

@ -340,8 +340,9 @@ describe('server-v2 /api/v1/sessions', () => {
// (40911), not the pre-fix "session does not exist" (40401).
expect(res.body.code).toBe(40911);
expect(res.body.msg).toMatch(/nothing to undo/i);
// The thrown KimiError's stack is surfaced so operators can locate the source.
expect(res.body.stack).toEqual(expect.stringContaining('sessionLegacyService'));
// The thrown KimiError's stack is surfaced so operators can locate the
// source — the precheck/throw now lives in the native prompt service.
expect(res.body.stack).toEqual(expect.stringContaining('promptService'));
});
it('rejects an unsupported action suffix (40001)', async () => {

View file

@ -229,6 +229,12 @@ export type KimiErrorCode =
| 'session.question_handler_error'
| 'session.init_failed'
| 'agent.not_found'
| 'activity.agent_busy'
| 'activity.cancelling'
| 'activity.disposing'
| 'activity.disposed'
| 'activity.initializing'
| 'activity.session_rejected'
| 'turn.agent_busy'
| 'goal.already_exists'
| 'goal.not_found'
@ -1079,6 +1085,12 @@ export const kimiErrorCodeSchema = z.enum([
'session.question_handler_error',
'session.init_failed',
'agent.not_found',
'activity.agent_busy',
'activity.cancelling',
'activity.disposing',
'activity.disposed',
'activity.initializing',
'activity.session_rejected',
'turn.agent_busy',
'goal.already_exists',
'goal.not_found',