mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
Merge branch 'kimi-code-v2' of https://github.com/MoonshotAI/kimi-code into kimi-code-v2
This commit is contained in:
commit
e44d181d06
59 changed files with 3035 additions and 1905 deletions
353
apps/kimi-code/src/cli/prompt-render.ts
Normal file
353
apps/kimi-code/src/cli/prompt-render.ts
Normal 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`);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 v2→v1 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;
|
||||
}
|
||||
|
|
@ -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'],
|
||||
};
|
||||
}
|
||||
514
apps/kimi-code/src/cli/v2/run-v2-print.ts
Normal file
514
apps/kimi-code/src/cli/v2/run-v2-print.ts
Normal 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 v2→v1 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}`;
|
||||
}
|
||||
|
|
@ -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'];
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
223
apps/kimi-code/test/cli/v2-run-print.test.ts
Normal file
223
apps/kimi-code/test/cli/v2-run-print.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -132,6 +132,7 @@ const DOMAIN_LAYER = new Map([
|
|||
['modelCatalog', 3],
|
||||
['agentProfileCatalog', 3],
|
||||
// L4 — agent behaviour
|
||||
['activity', 4],
|
||||
['context', 4],
|
||||
['message', 4],
|
||||
['turn', 4],
|
||||
|
|
|
|||
121
packages/agent-core-v2/src/activity/activity.ts
Normal file
121
packages/agent-core-v2/src/activity/activity.ts
Normal 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');
|
||||
75
packages/agent-core-v2/src/activity/activityOps.ts
Normal file
75
packages/agent-core-v2/src/activity/activityOps.ts
Normal 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;
|
||||
}
|
||||
281
packages/agent-core-v2/src/activity/agentActivityService.ts
Normal file
281
packages/agent-core-v2/src/activity/agentActivityService.ts
Normal 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',
|
||||
);
|
||||
28
packages/agent-core-v2/src/activity/errors.ts
Normal file
28
packages/agent-core-v2/src/activity/errors.ts
Normal 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);
|
||||
64
packages/agent-core-v2/src/activity/sessionActivityKernel.ts
Normal file
64
packages/agent-core-v2/src/activity/sessionActivityKernel.ts
Normal 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',
|
||||
);
|
||||
|
|
@ -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[];
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> =
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ const CODER_TOOLS = [
|
|||
'WebSearch',
|
||||
'FetchURL',
|
||||
'Write',
|
||||
'mcp__*',
|
||||
] as const;
|
||||
|
||||
const EXPLORE_TOOLS = [
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
109
packages/agent-core-v2/test/activity/activity.test.ts
Normal file
109
packages/agent-core-v2/test/activity/activity.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
37
packages/agent-core-v2/test/activity/stubs.ts
Normal file
37
packages/agent-core-v2/test/activity/stubs.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
104
packages/agent-core-v2/test/contextMemory/undoPrecheck.test.ts
Normal file
104
packages/agent-core-v2/test/contextMemory/undoPrecheck.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 }),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -189,6 +189,9 @@ function stubSessionLifecycle(): ISessionLifecycleService {
|
|||
fork: async () => {
|
||||
throw new Error('not implemented');
|
||||
},
|
||||
createChild: async () => {
|
||||
throw new Error('not implemented');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue