mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-18 05:05:28 +00:00
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:
parent
7c91ffc876
commit
a7a2708aee
13 changed files with 880 additions and 0 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
11
packages/agent-core-v2/src/promptLegacy/errors.ts
Normal file
11
packages/agent-core-v2/src/promptLegacy/errors.ts
Normal 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;
|
||||
8
packages/agent-core-v2/src/promptLegacy/index.ts
Normal file
8
packages/agent-core-v2/src/promptLegacy/index.ts
Normal 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';
|
||||
32
packages/agent-core-v2/src/promptLegacy/promptLegacy.ts
Normal file
32
packages/agent-core-v2/src/promptLegacy/promptLegacy.ts
Normal 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');
|
||||
238
packages/agent-core-v2/src/promptLegacy/promptLegacyService.ts
Normal file
238
packages/agent-core-v2/src/promptLegacy/promptLegacyService.ts
Normal 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',
|
||||
);
|
||||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
231
packages/server-v2/src/routes/prompts.ts
Normal file
231
packages/server-v2/src/routes/prompts.ts
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
143
packages/server-v2/test/prompts.test.ts
Normal file
143
packages/server-v2/test/prompts.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue