From a7a2708aee697dda17767a89c3344b06d1f2d688 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 29 Jun 2026 20:59:02 +0800 Subject: [PATCH] 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 --- .../scripts/check-domain-layers.mjs | 2 + packages/agent-core-v2/src/errors.ts | 9 + packages/agent-core-v2/src/index.ts | 2 + .../agent-core-v2/src/promptLegacy/errors.ts | 11 + .../agent-core-v2/src/promptLegacy/index.ts | 8 + .../src/promptLegacy/promptLegacy.ts | 32 +++ .../src/promptLegacy/promptLegacyService.ts | 238 ++++++++++++++++++ .../promptLegacy/promptLegacyService.test.ts | 181 +++++++++++++ packages/protocol/src/events.ts | 11 + packages/server-v2/src/routes/prompts.ts | 231 +++++++++++++++++ .../src/routes/registerApiV1Routes.ts | 9 + packages/server-v2/src/transport/errors.ts | 3 + packages/server-v2/test/prompts.test.ts | 143 +++++++++++ 13 files changed, 880 insertions(+) create mode 100644 packages/agent-core-v2/src/promptLegacy/errors.ts create mode 100644 packages/agent-core-v2/src/promptLegacy/index.ts create mode 100644 packages/agent-core-v2/src/promptLegacy/promptLegacy.ts create mode 100644 packages/agent-core-v2/src/promptLegacy/promptLegacyService.ts create mode 100644 packages/agent-core-v2/test/promptLegacy/promptLegacyService.test.ts create mode 100644 packages/server-v2/src/routes/prompts.ts create mode 100644 packages/server-v2/test/prompts.test.ts diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index a35d413ce..85f79821e 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -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'; diff --git a/packages/agent-core-v2/src/errors.ts b/packages/agent-core-v2/src/errors.ts index 627273353..d8f420ec9 100644 --- a/packages/agent-core-v2/src/errors.ts +++ b/packages/agent-core-v2/src/errors.ts @@ -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, diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 48114e5b4..0373674cf 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -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'; diff --git a/packages/agent-core-v2/src/promptLegacy/errors.ts b/packages/agent-core-v2/src/promptLegacy/errors.ts new file mode 100644 index 000000000..46e7d5a3b --- /dev/null +++ b/packages/agent-core-v2/src/promptLegacy/errors.ts @@ -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; diff --git a/packages/agent-core-v2/src/promptLegacy/index.ts b/packages/agent-core-v2/src/promptLegacy/index.ts new file mode 100644 index 000000000..5c733e7a4 --- /dev/null +++ b/packages/agent-core-v2/src/promptLegacy/index.ts @@ -0,0 +1,8 @@ +/** + * `promptLegacy` domain barrel — re-exports the legacy prompt scheduler + * contract and implementation. + */ + +import './errors'; +export * from './promptLegacy'; +export * from './promptLegacyService'; diff --git a/packages/agent-core-v2/src/promptLegacy/promptLegacy.ts b/packages/agent-core-v2/src/promptLegacy/promptLegacy.ts new file mode 100644 index 000000000..3c64f8589 --- /dev/null +++ b/packages/agent-core-v2/src/promptLegacy/promptLegacy.ts @@ -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; + steer(promptIds: readonly string[]): Promise; + abort(promptId: string): Promise; +} + +export const IPromptLegacyService: ServiceIdentifier = + createDecorator('promptLegacyService.agent'); diff --git a/packages/agent-core-v2/src/promptLegacy/promptLegacyService.ts b/packages/agent-core-v2/src/promptLegacy/promptLegacyService.ts new file mode 100644 index 000000000..7a8a19a9a --- /dev/null +++ b/packages/agent-core-v2/src/promptLegacy/promptLegacyService.ts @@ -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(); + + 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 { + 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 { + 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 { + 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 { + 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', +); diff --git a/packages/agent-core-v2/test/promptLegacy/promptLegacyService.test.ts b/packages/agent-core-v2/test/promptLegacy/promptLegacyService.test.ts new file mode 100644 index 000000000..19e76943e --- /dev/null +++ b/packages/agent-core-v2/test/promptLegacy/promptLegacyService.test.ts @@ -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((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', + }); + }); +}); diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index a6a3ae7c5..f418d9a1f 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -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', diff --git a/packages/server-v2/src/routes/prompts.ts b/packages/server-v2/src/routes/prompts.ts new file mode 100644 index 000000000..7bb6c30f7 --- /dev/null +++ b/packages/server-v2/src/routes/prompts.ts @@ -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 }, + handler: ( + req: { id: string; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | 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[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[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[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[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, + ), + ); +} diff --git a/packages/server-v2/src/routes/registerApiV1Routes.ts b/packages/server-v2/src/routes/registerApiV1Routes.ts index e0386dcc4..f0a8f2eb6 100644 --- a/packages/server-v2/src/routes/registerApiV1Routes.ts +++ b/packages/server-v2/src/routes/registerApiV1Routes.ts @@ -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[0], core, ); + registerPromptsRoutes( + apiV1 as unknown as Parameters[0], + core, + ); registerWorkspacesRoutes( apiV1 as unknown as Parameters[0], core, ); + registerFilesRoutes(apiV1 as unknown as Parameters[0], core); + registerToolsRoutes(apiV1 as unknown as Parameters[0], core); registerShutdownRoutes(apiV1 as unknown as Parameters[0], { onShutdown: opts.onShutdown, }); diff --git a/packages/server-v2/src/transport/errors.ts b/packages/server-v2/src/transport/errors.ts index 4dfe0cda4..708e51274 100644 --- a/packages/server-v2/src/transport/errors.ts +++ b/packages/server-v2/src/transport/errors.ts @@ -33,6 +33,9 @@ const KIMI_TO_PROTOCOL: Record = { [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, }; /** diff --git a/packages/server-v2/test/prompts.test.ts b/packages/server-v2/test/prompts.test.ts new file mode 100644 index 000000000..a7e0da588 --- /dev/null +++ b/packages/server-v2/test/prompts.test.ts @@ -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 { + 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( + method: 'GET' | 'POST', + path: string, + arg?: unknown, + ): Promise<{ status: number; body: Envelope }> { + const headers: Record = {}; + const init: { method: string; headers: Record; 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 }; + } + + async function createSession(cwd: string): Promise { + 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 { + 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('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('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( + '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('POST', '/api/v1/sessions/nope/prompts', { + content: [{ type: 'text', text: 'hello' }], + }); + expect(body.code).toBe(40401); + }); +});