feat(server-v2): port v1 /sessions/:sid/prompts routes

- add PromptLegacyService (per-agent v1 scheduler: prompt_id, FIFO queue, steer/abort, auto-start-next) on top of IPromptService
- register prompt.not_found / session.busy / prompt.already_completed error codes in protocol and agent-core-v2
- expose GET/POST /api/v1/sessions/:sid/prompts, prompts::steer, prompts/:pid:abort|:steer
This commit is contained in:
haozhe.yang 2026-06-29 20:59:02 +08:00
parent 7c91ffc876
commit a7a2708aee
13 changed files with 880 additions and 0 deletions

View file

@ -96,6 +96,7 @@ const DOMAIN_LAYER = new Map([
['fullCompaction', 4],
['microCompaction', 4],
['loop', 4],
['media', 4],
['llmRequester', 4],
['llmRequestLog', 4],
['externalHooks', 4],
@ -123,6 +124,7 @@ const DOMAIN_LAYER = new Map([
['question', 7],
['gateway', 7],
['rpc', 7],
['promptLegacy', 7],
]);
const V1_PACKAGE = '@moonshot-ai/agent-core';

View file

@ -13,6 +13,8 @@ import { AuthErrors } from '#/auth/errors';
import { BackgroundErrors } from '#/background/errors';
import { ChatProviderErrors } from '#/chatProvider/errors';
import { ConfigErrors } from '#/config/errors';
import { FileErrors } from '#/filestore/errors';
import { FsErrors } from '#/agentFs/errors';
import { FullCompactionErrors } from '#/fullCompaction/errors';
import { GoalErrors } from '#/goal/errors';
import { LoopErrors } from '#/loop/errors';
@ -21,6 +23,7 @@ import { ModelCatalogErrors } from '#/modelCatalog/errors';
import { PluginErrors } from '#/plugin/errors';
import { ProfileErrors } from '#/profile/errors';
import { PromptErrors } from '#/prompt/errors';
import { PromptLegacyErrors } from '#/promptLegacy/errors';
import { SessionErrors } from '#/session/errors';
import { SkillErrors } from '#/skill/errors';
import { TerminalErrors } from '#/terminal/errors';
@ -33,6 +36,8 @@ export { AuthErrors } from '#/auth/errors';
export { BackgroundErrors } from '#/background/errors';
export { ChatProviderErrors } from '#/chatProvider/errors';
export { ConfigErrors } from '#/config/errors';
export { FileErrors } from '#/filestore/errors';
export { FsErrors } from '#/agentFs/errors';
export { FullCompactionErrors } from '#/fullCompaction/errors';
export { GoalErrors } from '#/goal/errors';
export { LoopErrors } from '#/loop/errors';
@ -41,6 +46,7 @@ export { ModelCatalogErrors } from '#/modelCatalog/errors';
export { PluginErrors } from '#/plugin/errors';
export { ProfileErrors } from '#/profile/errors';
export { PromptErrors } from '#/prompt/errors';
export { PromptLegacyErrors } from '#/promptLegacy/errors';
export { SessionErrors } from '#/session/errors';
export { SkillErrors } from '#/skill/errors';
export { TerminalErrors } from '#/terminal/errors';
@ -54,6 +60,8 @@ export const ErrorCodes = {
...BackgroundErrors.codes,
...ChatProviderErrors.codes,
...ConfigErrors.codes,
...FileErrors.codes,
...FsErrors.codes,
...FullCompactionErrors.codes,
...GoalErrors.codes,
...LoopErrors.codes,
@ -62,6 +70,7 @@ export const ErrorCodes = {
...PluginErrors.codes,
...ProfileErrors.codes,
...PromptErrors.codes,
...PromptLegacyErrors.codes,
...SessionErrors.codes,
...SkillErrors.codes,
...TerminalErrors.codes,

View file

@ -56,6 +56,7 @@ export * from './agentFs/index';
export * from './process/index';
export * from './terminal/index';
export * from './storage/index';
export * from './filestore/index';
export * from './auth/index';
// Ported agent services. These keep the current service boundaries during the migration.
@ -78,6 +79,7 @@ export * from './permissionPolicy/index';
export * from './permissionRules/index';
export * from './profile/index';
export * from './prompt/index';
export * from './promptLegacy/index';
export * from './replayBuilder/index';
export * from './rpc/index';
export * from './subagentHost/index';

View file

@ -0,0 +1,11 @@
/**
* `promptLegacy` domain error codes v1-compatible prompt failures.
*/
export const PromptLegacyErrors = {
codes: {
PROMPT_NOT_FOUND: 'prompt.not_found',
SESSION_BUSY: 'session.busy',
PROMPT_ALREADY_COMPLETED: 'prompt.already_completed',
},
} as const;

View file

@ -0,0 +1,8 @@
/**
* `promptLegacy` domain barrel re-exports the legacy prompt scheduler
* contract and implementation.
*/
import './errors';
export * from './promptLegacy';
export * from './promptLegacyService';

View file

@ -0,0 +1,32 @@
/**
* `promptLegacy` domain (L7 edge adapter) v1-compatible prompt scheduler.
*
* Implements the legacy `/api/v1` prompt contract (`submit` / `list` / `steer`
* / `abort` with `prompt_id`, a FIFO queue, and `prompt.*` lifecycle events) on
* top of the v2 turn-driver (`IPromptService`). v2's native `IPromptService`
* (turn-is-the-submission, no queue) is untouched and continues to serve
* `/api/v2`. This service exists purely so clients of the v1 server keep
* working against server-v2. Bound at Agent scope the queue and the active
* submission are per-agent state.
*/
import type {
PromptAbortResponse,
PromptListResponse,
PromptSteerResult,
PromptSubmission,
PromptSubmitResult,
} from '@moonshot-ai/protocol';
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface IPromptLegacyService {
readonly _serviceBrand: undefined;
list(): PromptListResponse;
submit(body: PromptSubmission): Promise<PromptSubmitResult>;
steer(promptIds: readonly string[]): Promise<PromptSteerResult>;
abort(promptId: string): Promise<PromptAbortResponse>;
}
export const IPromptLegacyService: ServiceIdentifier<IPromptLegacyService> =
createDecorator<IPromptLegacyService>('promptLegacyService.agent');

View file

@ -0,0 +1,238 @@
/**
* `promptLegacy` domain `IPromptLegacyService` implementation.
*
* Per-agent v1-compatible scheduler. Owns the active submission and a FIFO
* queue; launches turns through `IPromptService` and observes them to
* auto-start the next queued prompt. Legacy `prompt.*` lifecycle events are
* not emitted (they are not part of the v2 `AgentEvent` union); the HTTP
* responses carry the same information.
*/
import { randomUUID } from 'node:crypto';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { ErrorCodes, KimiError } from '#/errors';
import { IProfileService } from '#/profile';
import { IPromptService } from '#/prompt';
import { ITurnService, type Turn, type TurnResult } from '#/turn';
import { IPermissionModeService } from '#/permissionMode';
import type { ContentPart } from '@moonshot-ai/kosong';
import type {
PromptAbortResponse,
PromptItem,
PromptListResponse,
PromptSteerResult,
PromptSubmission,
PromptSubmitResult,
} from '@moonshot-ai/protocol';
import { IPromptLegacyService } from './promptLegacy';
interface PromptRecord {
readonly promptId: string;
readonly userMessageId: string;
readonly body: PromptSubmission;
readonly createdAt: string;
}
interface ActivePrompt extends PromptRecord {
readonly turn: Turn;
}
export class PromptLegacyService implements IPromptLegacyService {
declare readonly _serviceBrand: undefined;
private active: ActivePrompt | undefined;
private readonly queued: PromptRecord[] = [];
/** Prompts whose abort was requested; their turn settles asynchronously. */
private readonly abortedPromptIds = new Set<string>();
constructor(
@IPromptService private readonly prompt: IPromptService,
@ITurnService private readonly turnService: ITurnService,
@IProfileService private readonly profile: IProfileService,
@IPermissionModeService private readonly permissionMode: IPermissionModeService,
) {}
list(): PromptListResponse {
return {
active: this.active === undefined ? null : toItem(this.active, 'running'),
queued: this.queued.map((record) => toItem(record, 'queued')),
};
}
async submit(body: PromptSubmission): Promise<PromptSubmitResult> {
await this.applyOverrides(body);
const record = this.createRecord(body);
if (this.active !== undefined) {
this.queued.push(record);
return toItem(record, 'queued');
}
const turn = this.launch(record);
return toItem(record, turn === undefined ? 'queued' : 'running');
}
async steer(promptIds: readonly string[]): Promise<PromptSteerResult> {
if (promptIds.length === 0) {
throw new KimiError(ErrorCodes.REQUEST_INVALID, 'prompt_ids must not be empty');
}
if (this.active === undefined) {
throw new KimiError(ErrorCodes.PROMPT_NOT_FOUND, 'no active prompt to steer into');
}
const selectedIds = new Set(promptIds);
const selected: PromptRecord[] = [];
for (let i = this.queued.length - 1; i >= 0; i--) {
const record = this.queued[i]!;
if (selectedIds.has(record.promptId)) {
selected.push(record);
this.queued.splice(i, 1);
}
}
if (selected.length !== selectedIds.size) {
throw new KimiError(ErrorCodes.PROMPT_NOT_FOUND, 'one or more prompts are not queued');
}
selected.reverse();
const content = selected.flatMap((record) => contentToCoreParts(record.body.content));
this.prompt.steer({
role: 'user',
content,
toolCalls: [],
origin: { kind: 'user' },
});
return { steered: true, prompt_ids: [...promptIds] };
}
async abort(promptId: string): Promise<PromptAbortResponse> {
if (this.active?.promptId === promptId) {
// 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('prompt aborted');
return { aborted: true };
}
const index = this.queued.findIndex((item) => item.promptId === promptId);
if (index >= 0) {
this.queued.splice(index, 1);
return { aborted: true };
}
throw new KimiError(ErrorCodes.PROMPT_NOT_FOUND, `prompt ${promptId} not found`);
}
// --- internals -------------------------------------------------------------
private createRecord(body: PromptSubmission): PromptRecord {
const promptId = `prompt_${randomUUID()}`;
return {
promptId,
userMessageId: `msg_${promptId}`,
body,
createdAt: new Date().toISOString(),
};
}
private launch(record: PromptRecord): Turn | undefined {
const parts = contentToCoreParts(record.body.content);
if (parts.length === 0) {
throw new KimiError(ErrorCodes.REQUEST_INVALID, 'prompt content has no supported parts');
}
const turn = this.prompt.prompt({
role: 'user',
content: parts,
toolCalls: [],
origin: { kind: 'user' },
});
if (turn === undefined) {
// Busy with a turn started outside the legacy service (e.g. via /api/v2);
// keep the record queued so it runs once the agent is idle.
this.queued.unshift(record);
return undefined;
}
this.active = { ...record, turn };
void turn.result.then((result) => this.onTurnSettled(record.promptId, result));
return turn;
}
private onTurnSettled(promptId: string, result: TurnResult): void {
if (this.active?.promptId !== promptId) return;
this.active = undefined;
this.abortedPromptIds.delete(promptId);
void result;
this.startNextQueued();
}
private startNextQueued(): void {
if (this.active !== undefined) return;
const next = this.queued.shift();
if (next === undefined) return;
this.launch(next);
}
private async applyOverrides(body: PromptSubmission): Promise<void> {
if (body.model !== undefined) {
await this.profile.setModel(body.model);
}
if (body.thinking !== undefined) {
this.profile.setThinking(body.thinking);
}
if (body.permission_mode !== undefined) {
this.permissionMode.setMode(body.permission_mode);
}
}
}
function toItem(record: PromptRecord, status: 'running' | 'queued'): PromptItem {
return {
prompt_id: record.promptId,
user_message_id: record.userMessageId,
status,
content: record.body.content,
created_at: record.createdAt,
};
}
function contentToCoreParts(content: PromptSubmission['content']): ContentPart[] {
const parts: ContentPart[] = [];
for (const part of content) {
switch (part.type) {
case 'text':
parts.push({ type: 'text', text: part.text });
break;
case 'image':
if (part.source.kind === 'url') {
parts.push({ type: 'image_url', imageUrl: { url: part.source.url } });
} else if (part.source.kind === 'base64') {
parts.push({
type: 'image_url',
imageUrl: { url: `data:${part.source.media_type};base64,${part.source.data}` },
});
}
break;
case 'video':
if (part.source.kind === 'url') {
parts.push({ type: 'video_url', videoUrl: { url: part.source.url } });
} else if (part.source.kind === 'base64') {
parts.push({
type: 'video_url',
videoUrl: { url: `data:${part.source.media_type};base64,${part.source.data}` },
});
}
break;
// tool_use / tool_result / file / thinking are not valid user-prompt input.
}
}
return parts;
}
registerScopedService(
LifecycleScope.Agent,
IPromptLegacyService,
PromptLegacyService,
InstantiationType.Delayed,
'promptLegacy',
);

View file

@ -0,0 +1,181 @@
import { describe, expect, it } from 'vitest';
import type { IPermissionModeService } from '#/permissionMode';
import type { IProfileService } from '#/profile';
import type { IPromptService } from '#/prompt';
import type { ITurnService, Turn, TurnResult } from '#/turn';
import type { PromptSubmission } from '@moonshot-ai/protocol';
import { PromptLegacyService } from '#/promptLegacy';
interface ControlledTurn {
readonly turn: Turn;
readonly settle: (result: TurnResult) => void;
}
function controlledTurn(id: number): ControlledTurn {
let settle!: (result: TurnResult) => void;
const result = new Promise<TurnResult>((resolve) => {
settle = resolve;
});
const turn: Turn = {
id,
abortController: new AbortController(),
ready: Promise.resolve(),
result,
};
return { turn, settle };
}
function textBody(text: string): PromptSubmission {
return { content: [{ type: 'text', text }] };
}
interface Harness {
readonly service: PromptLegacyService;
readonly turns: Turn[];
readonly settleActive: (result: TurnResult) => void;
readonly steered: string[];
}
function createHarness(): Harness {
let nextTurnId = 0;
let activeTurn: Turn | undefined;
let activeSettle: ((result: TurnResult) => void) | undefined;
const turns: Turn[] = [];
const steered: string[] = [];
const prompt: IPromptService = {
prompt: () => {
if (activeTurn !== undefined) return undefined;
const { turn, settle } = controlledTurn(nextTurnId++);
activeTurn = turn;
activeSettle = settle;
turns.push(turn);
void turn.result.then(() => {
if (activeTurn === turn) {
activeTurn = undefined;
activeSettle = undefined;
}
});
return turn;
},
steer: (message) => {
for (const part of message.content) {
if (part.type === 'text') steered.push(part.text);
}
return undefined;
},
retry: () => undefined,
undo: () => 0,
clear: () => {},
};
const turnService: ITurnService = {
launch: () => {
throw new Error('not used');
},
getActiveTurn: () => activeTurn,
hooks: {
onLaunched: { run: async () => {} },
onEnded: { run: async () => {} },
beforeStep: { run: async () => {} },
afterStep: { run: async () => {} },
},
} as unknown as ITurnService;
const profile = {
setModel: () => Promise.resolve({ model: '' }),
setThinking: () => {},
} as unknown as IProfileService;
const permissionMode = {
setMode: () => {},
} as unknown as IPermissionModeService;
const service = new PromptLegacyService(prompt, turnService, profile, permissionMode);
return {
service,
turns,
steered,
settleActive: (result) => activeSettle?.(result),
};
}
describe('PromptLegacyService', () => {
it('launches a turn on submit and reports running', async () => {
const { service, turns } = createHarness();
const result = await service.submit(textBody('hi'));
expect(result.status).toBe('running');
expect(result.prompt_id).toMatch(/^prompt_/);
expect(turns).toHaveLength(1);
expect(service.list().active?.prompt_id).toBe(result.prompt_id);
});
it('queues a second submit while a turn is active', async () => {
const { service, turns } = createHarness();
const first = await service.submit(textBody('first'));
const second = await service.submit(textBody('second'));
expect(second.status).toBe('queued');
expect(turns).toHaveLength(1);
const list = service.list();
expect(list.active?.prompt_id).toBe(first.prompt_id);
expect(list.queued.map((q) => q.prompt_id)).toEqual([second.prompt_id]);
});
it('auto-launches the next queued prompt when the active turn settles', async () => {
const { service, turns, settleActive } = createHarness();
await service.submit(textBody('first'));
const second = await service.submit(textBody('second'));
settleActive({ reason: 'completed' });
await Promise.resolve();
expect(turns).toHaveLength(2);
expect(service.list().active?.prompt_id).toBe(second.prompt_id);
});
it('aborts the active prompt and starts the next queued on settle', async () => {
const { service, turns, settleActive } = createHarness();
const first = await service.submit(textBody('first'));
const second = await service.submit(textBody('second'));
const aborted = await service.abort(first.prompt_id);
expect(aborted.aborted).toBe(true);
settleActive({ reason: 'cancelled' });
await Promise.resolve();
expect(turns).toHaveLength(2);
expect(service.list().active?.prompt_id).toBe(second.prompt_id);
});
it('removes a queued prompt on abort', async () => {
const { service } = createHarness();
await service.submit(textBody('first'));
const second = await service.submit(textBody('second'));
const aborted = await service.abort(second.prompt_id);
expect(aborted.aborted).toBe(true);
expect(service.list().queued).toEqual([]);
});
it('steers queued prompts into the active turn', async () => {
const { service, steered } = createHarness();
await service.submit(textBody('first'));
const second = await service.submit(textBody('second'));
const result = await service.steer([second.prompt_id]);
expect(result.steered).toBe(true);
expect(result.prompt_ids).toEqual([second.prompt_id]);
expect(steered).toEqual(['second']);
expect(service.list().queued).toEqual([]);
});
it('throws PROMPT_NOT_FOUND when aborting an unknown prompt', async () => {
const { service } = createHarness();
await expect(service.abort('prompt_missing')).rejects.toMatchObject({
code: 'prompt.not_found',
});
});
it('throws PROMPT_NOT_FOUND when steering with no active turn', async () => {
const { service } = createHarness();
await expect(service.steer(['prompt_x'])).rejects.toMatchObject({
code: 'prompt.not_found',
});
});
});

View file

@ -224,11 +224,20 @@ export type KimiErrorCode =
| 'request.invalid'
| 'request.work_dir_required'
| 'request.prompt_input_empty'
| 'prompt.not_found'
| 'prompt.already_completed'
| 'session.busy'
| 'shell.git_bash_not_found'
| 'workspace.not_found'
| 'terminal.not_found'
| 'file.not_found'
| 'file.too_large'
| 'fs.path_not_found'
| 'fs.permission_denied'
| 'fs.path_escapes'
| 'fs.too_many_results'
| 'fs.grep_timeout'
| 'fs.git_unavailable'
| 'validation.failed'
| 'not_implemented'
| 'internal';
@ -861,6 +870,8 @@ export const kimiErrorCodeSchema = z.enum([
'shell.git_bash_not_found',
'workspace.not_found',
'terminal.not_found',
'file.not_found',
'file.too_large',
'fs.path_not_found',
'fs.permission_denied',
'validation.failed',

View file

@ -0,0 +1,231 @@
/**
* `/api/v1` prompt routes v1-compatible prompt surface backed by
* `IPromptLegacyService` (the per-agent v1 scheduler). Paths and wire shapes
* mirror `packages/server/src/routes/prompts.ts` so existing clients keep
* working against server-v2.
*/
import {
IAgentLifecycleService,
IPromptLegacyService,
ISessionLifecycleService,
isKimiError,
KimiError,
type Scope,
} from '@moonshot-ai/agent-core-v2';
import {
ErrorCode,
promptAbortResponseSchema,
promptListResponseSchema,
promptSteerRequestSchema,
promptSteerResultSchema,
promptSubmissionSchema,
promptSubmitResultSchema,
} from '@moonshot-ai/protocol';
import { z } from 'zod';
import { errEnvelope, okEnvelope } from '../envelope';
import { defineRoute } from '../middleware/defineRoute';
import { parseActionSuffix } from './action-suffix';
interface PromptRouteHost {
get(
path: string,
options: { preHandler: unknown[]; schema?: Record<string, unknown> },
handler: (
req: { id: string; params: unknown },
reply: { send(payload: unknown): unknown },
) => Promise<void> | void,
): unknown;
post(
path: string,
options: { preHandler: unknown[]; schema?: Record<string, unknown> },
handler: (
req: { id: string; body: unknown; params: unknown },
reply: { send(payload: unknown): unknown },
) => Promise<void> | void,
): unknown;
}
const sessionIdParamSchema = z.object({
session_id: z.string().min(1),
});
const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() }));
const MAIN_AGENT_ID = 'main';
function resolveLegacy(core: Scope, sessionId: string): IPromptLegacyService {
const session = core.accessor.get(ISessionLifecycleService).get(sessionId);
if (session === undefined) {
throw new KimiError('session.not_found', `session ${sessionId} does not exist`);
}
const agent = session.accessor.get(IAgentLifecycleService).getHandle(MAIN_AGENT_ID);
if (agent === undefined) {
throw new KimiError('agent.not_found', `main agent not found for session ${sessionId}`);
}
return agent.accessor.get(IPromptLegacyService);
}
export function registerPromptsRoutes(app: PromptRouteHost, core: Scope): void {
const listRoute = defineRoute(
{
method: 'GET',
path: '/sessions/{session_id}/prompts',
params: sessionIdParamSchema,
success: { data: promptListResponseSchema },
errors: { [ErrorCode.SESSION_NOT_FOUND]: {} },
description: 'List the active prompt and queued prompts for a session',
tags: ['prompts'],
operationId: 'listPrompts',
},
async (req, reply) => {
try {
const { session_id } = req.params;
const result = resolveLegacy(core, session_id).list();
reply.send(okEnvelope(result, req.id));
} catch (error) {
sendMappedError(reply, req.id, error);
}
},
);
app.get(listRoute.path, listRoute.options, listRoute.handler as Parameters<PromptRouteHost['get']>[2]);
const submitRoute = defineRoute(
{
method: 'POST',
path: '/sessions/{session_id}/prompts',
body: promptSubmissionSchema,
params: sessionIdParamSchema,
success: { data: promptSubmitResultSchema },
errors: {
[ErrorCode.VALIDATION_FAILED]: { detailsSchema },
[ErrorCode.SESSION_NOT_FOUND]: {},
[ErrorCode.SESSION_BUSY]: {},
[ErrorCode.PROMPT_ALREADY_COMPLETED]: { dataSchema: z.object({ aborted: z.literal(false) }) },
},
description: 'Submit a prompt to a session',
tags: ['prompts'],
operationId: 'submitPrompt',
},
async (req, reply) => {
try {
const { session_id } = req.params;
const result = await resolveLegacy(core, session_id).submit(req.body);
reply.send(okEnvelope(result, req.id));
} catch (error) {
sendMappedError(reply, req.id, error);
}
},
);
app.post(submitRoute.path, submitRoute.options, submitRoute.handler as Parameters<PromptRouteHost['post']>[2]);
const steerManyRoute = defineRoute(
{
method: 'POST',
path: '/sessions/{session_id}/prompts::steer',
body: promptSteerRequestSchema,
params: sessionIdParamSchema,
success: { data: promptSteerResultSchema },
errors: {
[ErrorCode.VALIDATION_FAILED]: {},
[ErrorCode.SESSION_NOT_FOUND]: {},
[ErrorCode.PROMPT_NOT_FOUND]: {},
},
description: 'Steer queued prompts into the active turn',
tags: ['prompts'],
operationId: 'steerPrompts',
},
async (req, reply) => {
try {
const { session_id } = req.params;
const result = await resolveLegacy(core, session_id).steer(req.body.prompt_ids);
reply.send(okEnvelope(result, req.id));
} catch (error) {
sendMappedError(reply, req.id, error);
}
},
);
app.post(steerManyRoute.path, steerManyRoute.options, steerManyRoute.handler as Parameters<PromptRouteHost['post']>[2]);
const actionRoute = defineRoute(
{
method: 'POST',
path: '/sessions/{session_id}/prompts/{tail}',
success: { data: z.union([promptAbortResponseSchema, promptSteerResultSchema]) },
errors: {
[ErrorCode.VALIDATION_FAILED]: {},
[ErrorCode.SESSION_NOT_FOUND]: {},
[ErrorCode.PROMPT_NOT_FOUND]: {},
[ErrorCode.PROMPT_ALREADY_COMPLETED]: { dataSchema: z.object({ aborted: z.literal(false) }) },
},
description: 'Abort a running prompt or steer a queued prompt',
tags: ['prompts'],
operationId: 'promptAction',
},
async (req, reply) => {
try {
const { session_id, tail } = req.params as { session_id: string; tail: string };
const parsed = parseActionSuffix({
tail,
allowedActions: ['abort', 'steer'] as const,
resourceLabel: 'prompt',
});
if (parsed.kind !== 'action') {
const message = parsed.kind === 'invalid' ? parsed.reason : `unsupported action: ${tail}`;
reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, message, req.id));
return;
}
const legacy = resolveLegacy(core, session_id);
const result =
parsed.action === 'abort'
? await legacy.abort(parsed.id)
: await legacy.steer([parsed.id]);
reply.send(okEnvelope(result, req.id));
} catch (error) {
sendMappedError(reply, req.id, error);
}
},
);
app.post(actionRoute.path, actionRoute.options, actionRoute.handler as Parameters<PromptRouteHost['post']>[2]);
}
function sendMappedError(
reply: { send(payload: unknown): unknown },
requestId: string,
err: unknown,
): void {
if (isKimiError(err)) {
switch (err.code) {
case 'session.not_found':
case 'agent.not_found':
reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId));
return;
case 'prompt.not_found':
reply.send(errEnvelope(ErrorCode.PROMPT_NOT_FOUND, err.message, requestId));
return;
case 'session.busy':
reply.send(errEnvelope(ErrorCode.SESSION_BUSY, err.message, requestId));
return;
case 'prompt.already_completed':
reply.send({
code: ErrorCode.PROMPT_ALREADY_COMPLETED,
msg: err.message,
data: { aborted: false },
request_id: requestId,
});
return;
case 'request.invalid':
case 'validation.failed':
reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, err.message, requestId));
return;
}
}
reply.send(
errEnvelope(
ErrorCode.INTERNAL_ERROR,
err instanceof Error ? err.message : String(err),
requestId,
),
);
}

View file

@ -15,13 +15,16 @@ import { okEnvelope } from '../envelope';
import { registerApprovalsRoutes } from './approvals';
import { registerAuthRoute } from './auth';
import { registerConfigRoutes } from './config';
import { registerFilesRoutes } from './files';
import { registerMessagesRoutes } from './messages';
import { registerMetaRoute } from './meta';
import { registerModelCatalogRoutes } from './modelCatalog';
import { registerOAuthRoutes } from './oauth';
import { registerPromptsRoutes } from './prompts';
import { registerQuestionsRoutes } from './questions';
import { registerSessionsRoutes } from './sessions';
import { registerShutdownRoutes } from './shutdown';
import { registerToolsRoutes } from './tools';
import { registerWorkspacesRoutes } from './workspaces';
interface ApiV1AppHost {
@ -83,10 +86,16 @@ export async function registerApiV1Routes(
apiV1 as unknown as Parameters<typeof registerQuestionsRoutes>[0],
core,
);
registerPromptsRoutes(
apiV1 as unknown as Parameters<typeof registerPromptsRoutes>[0],
core,
);
registerWorkspacesRoutes(
apiV1 as unknown as Parameters<typeof registerWorkspacesRoutes>[0],
core,
);
registerFilesRoutes(apiV1 as unknown as Parameters<typeof registerFilesRoutes>[0], core);
registerToolsRoutes(apiV1 as unknown as Parameters<typeof registerToolsRoutes>[0], core);
registerShutdownRoutes(apiV1 as unknown as Parameters<typeof registerShutdownRoutes>[0], {
onShutdown: opts.onShutdown,
});

View file

@ -33,6 +33,9 @@ const KIMI_TO_PROTOCOL: Record<string, ErrorCode> = {
[ErrorCodes.SESSION_NOT_FOUND]: ErrorCode.SESSION_NOT_FOUND,
[ErrorCodes.REQUEST_INVALID]: ErrorCode.VALIDATION_FAILED,
[ErrorCodes.NOT_IMPLEMENTED]: ErrorCode.INTERNAL_ERROR,
[ErrorCodes.PROMPT_NOT_FOUND]: ErrorCode.PROMPT_NOT_FOUND,
[ErrorCodes.SESSION_BUSY]: ErrorCode.SESSION_BUSY,
[ErrorCodes.PROMPT_ALREADY_COMPLETED]: ErrorCode.PROMPT_ALREADY_COMPLETED,
};
/**

View file

@ -0,0 +1,143 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
IAgentLifecycleService,
ISessionLifecycleService,
} from '@moonshot-ai/agent-core-v2';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { type RunningServer, startServer } from '../src/start';
interface Envelope<T> {
code: number;
msg: string;
data: T;
request_id: string;
details?: { path: string; message: string }[];
}
interface PromptItemWire {
prompt_id: string;
user_message_id: string;
status: 'running' | 'queued';
content: unknown;
created_at: string;
}
describe('server-v2 /api/v1 prompts', () => {
let server: RunningServer | undefined;
let home: string | undefined;
let base: string;
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-prompts-'));
server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' });
base = `http://127.0.0.1:${server.port}`;
});
afterEach(async () => {
if (server !== undefined) {
await server.close();
server = undefined;
}
if (home !== undefined) {
await rm(home, { recursive: true, force: true });
home = undefined;
}
});
async function call<T>(
method: 'GET' | 'POST',
path: string,
arg?: unknown,
): Promise<{ status: number; body: Envelope<T> }> {
const headers: Record<string, string> = {};
const init: { method: string; headers: Record<string, string>; body?: string } = {
method,
headers,
};
if (arg !== undefined) {
headers['content-type'] = 'application/json';
init.body = JSON.stringify(arg);
}
const res = await fetch(`${base}${path}`, init);
return { status: res.status, body: (await res.json()) as Envelope<T> };
}
async function createSession(cwd: string): Promise<string> {
const res = await fetch(`${base}/api/v1/sessions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ metadata: { cwd } }),
});
const body = (await res.json()) as Envelope<{ id: string }>;
expect(body.code).toBe(0);
return body.data.id;
}
// The main agent scope is not created automatically on session creation
// (server-v2 gap G10); create it here so the prompt route resolves.
async function createMainAgent(sessionId: string): Promise<void> {
const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId);
if (session === undefined) throw new Error(`session ${sessionId} not found`);
await session.accessor.get(IAgentLifecycleService).createMain();
}
it('submits a prompt and lists it as active', async () => {
const id = await createSession(home as string);
await createMainAgent(id);
const submitted = await call<PromptItemWire>('POST', `/api/v1/sessions/${id}/prompts`, {
content: [{ type: 'text', text: 'hello' }],
});
expect(submitted.body.code).toBe(0);
expect(submitted.body.data.prompt_id).toMatch(/^prompt_/);
expect(submitted.body.data.status).toBe('running');
expect(submitted.body.data.user_message_id).toBeTruthy();
const list = await call<{ active: PromptItemWire | null; queued: PromptItemWire[] }>(
'GET',
`/api/v1/sessions/${id}/prompts`,
);
expect(list.body.code).toBe(0);
expect(list.body.data.active?.prompt_id).toBe(submitted.body.data.prompt_id);
expect(list.body.data.queued).toEqual([]);
});
it('aborts the active prompt', async () => {
const id = await createSession(home as string);
await createMainAgent(id);
const submitted = await call<PromptItemWire>('POST', `/api/v1/sessions/${id}/prompts`, {
content: [{ type: 'text', text: 'hello' }],
});
const promptId = submitted.body.data.prompt_id;
const aborted = await call<{ aborted: boolean }>(
'POST',
`/api/v1/sessions/${id}/prompts/${promptId}:abort`,
);
expect(aborted.body.code).toBe(0);
expect(aborted.body.data.aborted).toBe(true);
});
it('returns 40402 when aborting an unknown prompt', async () => {
const id = await createSession(home as string);
await createMainAgent(id);
const { body } = await call<null>(
'POST',
`/api/v1/sessions/${id}/prompts/prompt_does_not_exist:abort`,
);
expect(body.code).toBe(40402);
});
it('returns 40401 for an unknown session', async () => {
const { body } = await call<null>('POST', '/api/v1/sessions/nope/prompts', {
content: [{ type: 'text', text: 'hello' }],
});
expect(body.code).toBe(40401);
});
});