feat: simplify turn service

This commit is contained in:
_Kerman 2026-07-04 15:04:06 +08:00
parent 95901922e4
commit 2471ae088f
36 changed files with 345 additions and 266 deletions

View file

@ -3,10 +3,11 @@
* hook commands.
*
* Listens to hook slots owned by the agent behavior/lifecycle domains
* (`toolExecutor`, `permissionGate`, `turn`, `loop`, `fullCompaction`, and
* (`toolExecutor`, `permissionGate`, `prompt`, `turn`, `loop`, `fullCompaction`, and
* `task`) and translates those minimal contexts into the configured external
* HookEngine events. Appends Stop hook continuation prompts through
* `contextMemory`. The `SubagentStart` / `SubagentStop` pair is the one
* HookEngine events. Appends UserPromptSubmit hook results and Stop hook
* continuation prompts through `contextMemory`. The `SubagentStart` /
* `SubagentStop` pair is the one
* exception: the `agentLifecycle` tool wrapper has no hook service of its own,
* so `mirrorAgentRun` invokes `runAgentTaskStart` / `notifyAgentTaskStop` on
* this service directly.
@ -18,7 +19,7 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { isUserCancellation } from '#/_base/utils/abort';
import { isPlainRecord } from '#/_base/utils/canonical-args';
import { IAgentTaskService, type AgentTaskNotificationContext } from '#/agent/task';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentContextMemoryService, USER_PROMPT_ORIGIN } from '#/agent/contextMemory';
import {
IAgentFullCompactionService,
type FullCompactionDidCompactContext,
@ -29,6 +30,11 @@ import {
IAgentPermissionGate,
type PermissionApprovalResultContext,
} from '#/agent/permissionGate';
import {
IAgentPromptService,
type PromptSubmitContext,
} from '#/agent/prompt';
import { IAgentRecordService } from '#/agent/record';
import type {
ExecutableToolResult,
ToolDidExecuteContext,
@ -38,8 +44,6 @@ import { IAgentToolExecutorService } from '#/agent/toolExecutor';
import {
IAgentTurnService,
type TurnEndedContext,
type TurnUserPromptDecision,
type TurnUserPromptSubmitContext,
} from '#/agent/turn';
import { IBootstrapService } from '#/app/bootstrap';
import { IConfigService } from '#/app/config';
@ -84,6 +88,7 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter
constructor(
private readonly options: ExternalHooksServiceOptions = {},
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
@IAgentRecordService private readonly record: IAgentRecordService,
@IInstantiationService private readonly instantiation: IInstantiationService,
@IConfigService private readonly config: IConfigService,
@IBootstrapService private readonly bootstrap: IBootstrapService,
@ -115,6 +120,10 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter
this.instantiation.invokeFunction((accessor) => accessor.get(IAgentPermissionGate)),
);
this.registerPromptHooks(
this.instantiation.invokeFunction((accessor) => accessor.get(IAgentPromptService)),
);
this.registerTurnHooks(
this.instantiation.invokeFunction((accessor) => accessor.get(IAgentTurnService)),
);
@ -180,17 +189,19 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter
);
}
private registerTurnHooks(turn: IAgentTurnService): void {
private registerPromptHooks(prompt: IAgentPromptService): void {
this._register(
turn.hooks.onWillSubmitUserPrompt.register('externalHooks', async (ctx, next) => {
const decision = await this.runUserPromptSubmit(ctx);
if (decision !== undefined) {
ctx.decision = decision;
if (decision.action === 'block') return;
prompt.hooks.onWillSubmitPrompt.register('externalHooks', async (ctx, next) => {
if (await this.runPromptSubmitHook(ctx)) {
ctx.block = true;
return;
}
await next();
}),
);
}
private registerTurnHooks(turn: IAgentTurnService): void {
this._register(
turn.hooks.onEnded.register('externalHooks', async (ctx, next) => {
this.notifyTurnEnded(ctx);
@ -293,24 +304,53 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter
);
}
private async runUserPromptSubmit(
ctx: TurnUserPromptSubmitContext,
): Promise<TurnUserPromptDecision | undefined> {
const signal = ctx.turn.abortController.signal;
private async runPromptSubmitHook(
ctx: PromptSubmitContext,
): Promise<boolean> {
if ((ctx.promptMessage.origin ?? USER_PROMPT_ORIGIN).kind !== 'user') return false;
const signal = new AbortController().signal;
const input = ctx.promptMessage.content;
signal.throwIfAborted();
const results = await this.engine()?.trigger('UserPromptSubmit', {
matcherValue: input,
signal,
inputData: { prompt: input },
inputData: { prompt: input, isSteer: ctx.isSteer },
});
signal.throwIfAborted();
const block = renderUserPromptHookBlockResult(results);
if (block !== undefined) return { action: 'block', ...block };
if (block !== undefined) {
this.context.splice(this.context.get().length, 0, [{
role: 'assistant',
content: [{ type: 'text', text: block.text }],
toolCalls: [],
origin: { kind: 'hook_result', event: block.event, blocked: true },
}]);
this.record.signal({
type: 'hook.result',
hookEvent: block.event,
content: block.message,
blocked: true,
});
return true;
}
const append = renderUserPromptHookResult(results);
return append === undefined ? undefined : { action: 'append', ...append };
if (append !== undefined) {
this.context.splice(this.context.get().length, 0, [{
role: 'user',
content: [{ type: 'text', text: append.text }],
toolCalls: [],
origin: { kind: 'hook_result', event: append.event },
}]);
this.record.signal({
type: 'hook.result',
hookEvent: append.event,
content: append.message,
});
}
return false;
}
private notifyTurnEnded(ctx: TurnEndedContext): void {

View file

@ -1,15 +1,25 @@
import { createDecorator } from "#/_base/di";
import type { ContextMessage } from "#/agent/contextMemory";
import type { Turn } from "#/agent/turn";
import type { Hooks } from '#/hooks';
export interface PromptSubmitContext {
readonly promptMessage: ContextMessage;
readonly isSteer: boolean;
block: boolean;
}
export interface IAgentPromptService {
readonly _serviceBrand: undefined;
prompt(message: ContextMessage): Turn | undefined;
steer(message: ContextMessage): Turn | undefined;
prompt(message: ContextMessage): Promise<Turn | undefined>;
steer(message: ContextMessage): Promise<Turn | undefined>;
retry(trigger?: string): Turn | undefined;
undo(count: number): number;
clear(): void;
readonly hooks: Hooks<{
onWillSubmitPrompt: PromptSubmitContext;
}>;
}
export const IAgentPromptService = createDecorator<IAgentPromptService>('agentPromptService');

View file

@ -11,13 +11,21 @@ import {
import { IAgentLoopService } from '#/agent/loop';
import { IAgentRecordService } from '#/agent/record';
import { IAgentTurnService, type Turn } from '#/agent/turn';
import { IAgentPromptService } from './prompt';
import { OrderedHookSlot } from '#/hooks';
import {
IAgentPromptService,
type PromptSubmitContext,
} from './prompt';
export class AgentPromptService implements IAgentPromptService {
declare readonly _serviceBrand: undefined;
private readonly steerQueue: ContextMessage[] = [];
private observedTurn: Turn | undefined;
readonly hooks = {
onWillSubmitPrompt: new OrderedHookSlot<PromptSubmitContext>(),
};
constructor(
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
@IAgentTurnService private readonly turnService: IAgentTurnService,
@ -36,25 +44,27 @@ export class AgentPromptService implements IAgentPromptService {
});
}
prompt(message: ContextMessage): Turn | undefined {
async prompt(message: ContextMessage): Promise<Turn | undefined> {
if (this.emitBusyIfActive()) return undefined;
const stamped = ensureMessageId(message);
this.append(stamped);
if (await this.applyPromptHook(stamped, false)) return undefined;
const turn = this.turnService.launch(stamped.origin ?? USER_PROMPT_ORIGIN, stamped.id);
this.observe(turn);
return turn;
}
steer(message: ContextMessage): Turn | undefined {
async steer(message: ContextMessage): Promise<Turn | undefined> {
const stamped = ensureMessageId(message);
const activeTurn = this.turnService.getActiveTurn();
if (activeTurn !== undefined) {
this.steerQueue.push(ensureMessageId(message));
this.steerQueue.push(stamped);
this.observe(activeTurn);
return undefined;
}
const stamped = ensureMessageId(message);
this.append(stamped);
if (await this.applyPromptHook(stamped, true)) return undefined;
const turn = this.turnService.launch(stamped.origin ?? USER_PROMPT_ORIGIN, stamped.id);
this.observe(turn);
return turn;
@ -118,6 +128,16 @@ export class AgentPromptService implements IAgentPromptService {
this.context.splice(this.context.get().length, 0, messages);
}
private async applyPromptHook(promptMessage: ContextMessage, isSteer: boolean): Promise<boolean> {
const hookContext: PromptSubmitContext = {
promptMessage,
isSteer,
block: false,
};
await this.hooks.onWillSubmitPrompt.run(hookContext);
return hookContext.block;
}
private observe(turn: Turn): void {
if (this.observedTurn === turn) return;
this.observedTurn = turn;

View file

@ -21,6 +21,7 @@ import type {
PromptAbortResponse,
PromptItem,
PromptListResponse,
PromptStatus,
PromptSteerResult,
PromptSubmission,
PromptSubmitResult,
@ -69,8 +70,8 @@ export class AgentPromptLegacyService implements IAgentPromptLegacyService {
this.queued.push(record);
return toItem(record, 'queued');
}
const turn = this.launch(record);
return toItem(record, turn === undefined ? 'queued' : 'running');
const status = await this.launch(record);
return toItem(record, status);
}
async steer(promptIds: readonly string[]): Promise<PromptSteerResult> {
@ -96,7 +97,7 @@ export class AgentPromptLegacyService implements IAgentPromptLegacyService {
selected.reverse();
const content = selected.flatMap((record) => contentToCoreParts(record.body.content));
this.prompt.steer({
await this.prompt.steer({
role: 'user',
content,
toolCalls: [],
@ -138,12 +139,12 @@ export class AgentPromptLegacyService implements IAgentPromptLegacyService {
};
}
private launch(record: PromptRecord): Turn | undefined {
private async launch(record: PromptRecord): Promise<PromptStatus> {
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({
const turn = await this.prompt.prompt({
id: record.promptId,
role: 'user',
content: parts,
@ -151,14 +152,17 @@ export class AgentPromptLegacyService implements IAgentPromptLegacyService {
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;
if (this.turnService.getActiveTurn() !== 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 'queued';
}
return 'blocked';
}
this.active = { ...record, turn };
void turn.result.then((result) => this.onTurnSettled(record.promptId, result));
return turn;
return 'running';
}
private onTurnSettled(promptId: string, result: TurnResult): void {
@ -173,7 +177,7 @@ export class AgentPromptLegacyService implements IAgentPromptLegacyService {
if (this.active !== undefined) return;
const next = this.queued.shift();
if (next === undefined) return;
this.launch(next);
void this.launch(next);
}
private async applyOverrides(body: PromptSubmission): Promise<void> {
@ -189,7 +193,7 @@ export class AgentPromptLegacyService implements IAgentPromptLegacyService {
}
}
function toItem(record: PromptRecord, status: 'running' | 'queued'): PromptItem {
function toItem(record: PromptRecord, status: PromptStatus): PromptItem {
return {
prompt_id: record.promptId,
user_message_id: record.userMessageId,

View file

@ -369,7 +369,8 @@ export interface RemoveKimiProviderPayload {
* Result returned when a prompt/steer submission is accepted. The turn is the
* submission's identity and lifecycle (`turn.started` / `turn.ended` carry the
* rest over the event stream), so the handle is just the turn id. `undefined`
* means the submission was not accepted (e.g. the agent was busy).
* means no turn was launched (e.g. the agent was busy, or a prompt hook blocked
* before launch).
*/
export interface PromptLaunchResult {
readonly turn_id: number;

View file

@ -91,8 +91,8 @@ export class AgentRPCService implements IAgentRPCService {
@ISessionMetadata private readonly metadata: ISessionMetadata,
) { }
prompt(payload: PromptPayload): PromptLaunchResult | undefined {
const turn = this.promptService.prompt({
async prompt(payload: PromptPayload): Promise<PromptLaunchResult | undefined> {
const turn = await this.promptService.prompt({
role: 'user',
content: [...payload.input],
toolCalls: [],
@ -169,9 +169,9 @@ export class AgentRPCService implements IAgentRPCService {
this.shellCommandControllers.get(payload.commandId)?.abort(userCancellationReason());
}
steer(payload: SteerPayload): PromptLaunchResult | undefined {
async steer(payload: SteerPayload): Promise<PromptLaunchResult | undefined> {
this.telemetry.track('input_steer', { parts: payload.input.length });
const turn = this.promptService.steer({
const turn = await this.promptService.steer({
role: 'user',
content: [...payload.input],
toolCalls: [],
@ -312,7 +312,7 @@ export class AgentRPCService implements IAgentRPCService {
commandArgs: origin.commandArgs,
trigger: origin.trigger,
});
this.promptService.prompt({
await this.promptService.prompt({
role: 'user',
content: [{ type: 'text', text: expanded }],
toolCalls: [],

View file

@ -71,7 +71,7 @@ export class AgentSkillService extends Disposable implements IAgentSkillService
},
];
return this.recordActivation(
const turn = await this.recordActivation(
{
kind: 'skill_activation',
activationId: randomUUID(),
@ -83,17 +83,24 @@ export class AgentSkillService extends Disposable implements IAgentSkillService
skillArgs: input.args,
},
content,
)!;
);
if (turn === undefined) {
throw new KimiError(
ErrorCodes.TURN_AGENT_BUSY,
'Cannot activate skill while another turn is active',
);
}
return turn;
}
recordModelToolActivation(origin: SkillActivationOrigin): void {
this.recordActivation(origin);
void this.recordActivation(origin);
}
private recordActivation(
private async recordActivation(
origin: SkillActivationOrigin,
input?: readonly ContentPart[],
): Turn | undefined {
): Promise<Turn | undefined> {
this.records.append({ type: 'skill.activate', origin });
this.publishActivation(origin);

View file

@ -173,7 +173,7 @@ export async function executeModelSkill(
origin,
};
skillService.recordModelToolActivation(origin);
prompt.steer(message);
await prompt.steer(message);
return {
output: `Skill "${skill.name}" loaded inline. Follow its instructions.`,
};

View file

@ -813,7 +813,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
private async notifyAgentTask(info: AgentTaskInfo): Promise<void> {
const context = await this.buildAgentTaskNotificationContext(info);
if (context === undefined) return;
this.prompt.steer({
await this.prompt.steer({
role: 'user',
content: [...context.content],
toolCalls: [],

View file

@ -1,6 +1,6 @@
import { createDecorator } from "#/_base/di";
import type { TurnResult } from '#/agent/loop';
import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory';
import type { PromptOrigin } from '#/agent/contextMemory';
import type { Hooks } from '#/hooks';
export type { TurnResult } from '#/agent/loop';
@ -18,33 +18,6 @@ export interface Turn {
readonly result: Promise<TurnResult>;
}
export interface TurnRunContext {
readonly turn: Turn;
readonly origin: PromptOrigin;
readonly promptMessage?: ContextMessage;
result?: TurnResult;
}
export type TurnUserPromptDecision =
| {
readonly action: 'append';
readonly event: string;
readonly message: string;
readonly text: string;
}
| {
readonly action: 'block';
readonly event: string;
readonly message: string;
readonly text: string;
};
export interface TurnUserPromptSubmitContext {
readonly turn: Turn;
readonly promptMessage: ContextMessage;
decision?: TurnUserPromptDecision;
}
export interface TurnEndedContext {
readonly turn: Turn;
readonly result: TurnResult;
@ -63,7 +36,6 @@ export interface IAgentTurnService {
readonly hooks: Hooks<{
onLaunched: { turn: Turn };
onWillSubmitUserPrompt: TurnUserPromptSubmitContext;
onEnded: TurnEndedContext;
}>;
}

View file

@ -2,9 +2,8 @@ import { createControlledPromise } from '@antfu/utils';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { toKimiErrorPayload, type KimiErrorPayload } from '#/errors';
import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory';
import { IAgentContextMemoryService, USER_PROMPT_ORIGIN } from '#/agent/contextMemory';
import { toKimiErrorPayload } from '#/errors';
import type { PromptOrigin } from '#/agent/contextMemory';
import { OrderedHookSlot } from '#/hooks';
import { IAgentLoopService } from '#/agent/loop';
import { IAgentTelemetryContextService, ITelemetryService } from '#/app/telemetry';
@ -12,7 +11,6 @@ import { IAgentRecordService } from '#/agent/record';
import type {
Turn,
TurnEndedContext,
TurnUserPromptSubmitContext,
TurnResult,
} from './turn';
import { IAgentTurnService } from './turn';
@ -35,14 +33,12 @@ export class AgentTurnService implements IAgentTurnService {
readonly hooks = {
onLaunched: new OrderedHookSlot<{ turn: Turn }>(),
onWillSubmitUserPrompt: new OrderedHookSlot<TurnUserPromptSubmitContext>(),
onEnded: new OrderedHookSlot<TurnEndedContext>(),
};
constructor(
@IAgentLoopService private readonly loop: IAgentLoopService,
@IAgentRecordService private readonly record: IAgentRecordService,
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IAgentTelemetryContextService private readonly telemetryContext: IAgentTelemetryContextService,
) {
@ -105,11 +101,6 @@ export class AgentTurnService implements IAgentTurnService {
origin,
promptMessageId: turn.promptMessageId,
});
const promptHookResult = await this.applyUserPromptHook(turn, origin);
if (promptHookResult !== undefined) {
result = promptHookResult;
return result;
}
result = await this.loop.runTurn(turn.id, {
signal: turn.abortController.signal,
onStepStarted: () => ready.resolve(),
@ -123,19 +114,25 @@ export class AgentTurnService implements IAgentTurnService {
result = { reason: 'failed', error };
return result;
} finally {
ready.reject(createTurnReadyError(result));
ready.reject(new Error('Turn ended before first step', { cause: result?.error }));
if (this.activeTurn === turn) {
this.activeTurn = undefined;
}
if (result !== undefined) {
this.lastEndedReasonValue = result.reason;
const ended = toTurnEndedEvent(turn, result, Date.now() - startedAt);
this.record.signal(ended);
if (ended.error !== undefined) {
this.record.signal({ type: 'error', ...ended.error });
const error = result.error !== undefined ? toKimiErrorPayload(result.error) : undefined;
this.record.signal({
type: 'turn.ended',
turnId: turn.id,
reason: result.reason,
error,
durationMs: Date.now() - startedAt,
});
if (error !== undefined) {
this.record.signal({ type: 'error', ...error });
}
if (ended.reason !== 'completed') {
turnTelemetry.track('turn_interrupted', { at_step: result.steps ?? 0 });
if (result.reason !== 'completed') {
turnTelemetry.track('turn_interrupted', { at_step: result.steps ?? null });
}
}
if (result !== undefined) {
@ -149,117 +146,12 @@ export class AgentTurnService implements IAgentTurnService {
this.nextTurnId = turnId + 1;
}
}
private async applyUserPromptHook(
turn: Turn,
origin: PromptOrigin,
): Promise<TurnResult | undefined> {
if (origin.kind !== 'user') return undefined;
const promptMessage = this.context.get().at(-1);
if (!shouldRunUserPromptHook(promptMessage)) return undefined;
const hookContext: TurnUserPromptSubmitContext = { turn, promptMessage };
await this.hooks.onWillSubmitUserPrompt.run(hookContext);
const hookResult = hookContext.decision;
if (hookResult?.action === 'block') {
this.append({
role: 'assistant',
content: [{ type: 'text', text: hookResult.text }],
toolCalls: [],
origin: { kind: 'hook_result', event: hookResult.event, blocked: true },
});
this.record.signal({
type: 'hook.result',
turnId: turn.id,
hookEvent: hookResult.event,
content: hookResult.message,
blocked: true,
});
return { reason: 'blocked' };
}
if (hookResult?.action === 'append') {
this.append({
role: 'user',
content: [{ type: 'text', text: hookResult.text }],
toolCalls: [],
origin: { kind: 'hook_result', event: hookResult.event },
});
this.record.signal({
type: 'hook.result',
turnId: turn.id,
hookEvent: hookResult.event,
content: hookResult.message,
});
}
return undefined;
}
private append(...messages: ContextMessage[]): void {
if (messages.length === 0) return;
this.context.splice(this.context.get().length, 0, messages);
}
}
function shouldRunUserPromptHook(message: ContextMessage | undefined): message is ContextMessage {
if (message === undefined || message.role !== 'user') return false;
return (message.origin ?? USER_PROMPT_ORIGIN).kind === 'user';
}
function toTurnEndedEvent(
turn: Turn,
result: TurnResult,
durationMs: number,
): {
type: 'turn.ended';
turnId: number;
reason: TurnResult['reason'];
error?: KimiErrorPayload;
durationMs: number;
} {
if (result.reason !== 'failed' || result.error === undefined) {
return { type: 'turn.ended', turnId: turn.id, reason: result.reason, durationMs };
}
return {
type: 'turn.ended',
turnId: turn.id,
reason: result.reason,
error: summarizeTurnError(result.error, turn.id),
durationMs,
};
}
const LLM_NOT_SET_MESSAGE = 'LLM not set, send "/login" to login';
function summarizeTurnError(error: unknown, turnId: number): KimiErrorPayload {
const payload = toKimiErrorPayload(error);
const details = { ...payload.details, turnId };
// Substitute a friendlier, login-aware message for model-not-configured. The
// raw "Model not set" / "Provider not set" text is not actionable.
if (payload.code === 'model.not_configured') {
return { ...payload, message: LLM_NOT_SET_MESSAGE, details };
}
return { ...payload, details };
}
type MutableTurn = {
-readonly [K in keyof Turn]: Turn[K];
};
function createTurnReadyError(result: TurnResult | undefined): Error {
const cause = turnReadyFailureCause(result);
return cause === undefined
? new Error('Turn ended before first step')
: new Error('Turn ended before first step', { cause });
}
function turnReadyFailureCause(result: TurnResult | undefined): unknown {
if (result === undefined) return undefined;
if ('error' in result && result.error !== undefined) return result.error;
return result.reason;
}
registerScopedService(
LifecycleScope.Agent,
IAgentTurnService,

View file

@ -38,33 +38,33 @@ export class RestGateway implements IRestGateway {
return agent;
}
prompt(
async prompt(
sessionId: string,
agentId: string,
input: string,
): Promise<{ readonly turn_id: number } | undefined> {
const turn = this.agent(sessionId, agentId).accessor.get(IAgentPromptService).prompt({
const turn = await this.agent(sessionId, agentId).accessor.get(IAgentPromptService).prompt({
role: 'user',
content: [{ type: 'text', text: input }],
toolCalls: [],
origin: { kind: 'user' },
});
return Promise.resolve(turn === undefined ? undefined : { turn_id: turn.id });
return turn === undefined ? undefined : { turn_id: turn.id };
}
steer(
async steer(
sessionId: string,
agentId: string,
content: string,
): Promise<{ readonly turn_id: number } | undefined> {
const agent = this.agent(sessionId, agentId);
const turn = agent.accessor.get(IAgentPromptService).steer({
const turn = await agent.accessor.get(IAgentPromptService).steer({
role: 'user',
content: [{ type: 'text', text: content }],
toolCalls: [],
origin: { kind: 'user' },
});
const id = turn?.id ?? agent.accessor.get(IAgentTurnService).getActiveTurn()?.id;
return Promise.resolve(id === undefined ? undefined : { turn_id: id });
return id === undefined ? undefined : { turn_id: id };
}
cancel(sessionId: string, agentId: string, reason?: string): Promise<void> {
const activeTurn = this.agent(sessionId, agentId).accessor.get(IAgentTurnService).getActiveTurn();

View file

@ -119,7 +119,7 @@ export interface IAgentLifecycleService {
* surface this run (the `Agent` tool, the swarm) mirrors it itself. Throws
* when the agent does not exist or a turn cannot be started (busy / no head).
*/
run(agentId: string, request: AgentRunRequest, opts: RunAgentOptions): AgentRunHandle;
run(agentId: string, request: AgentRunRequest, opts: RunAgentOptions): Promise<AgentRunHandle>;
getHandle(agentId: string): IAgentScopeHandle | undefined;
list(filter?: AgentListFilter): readonly IAgentScopeHandle[];
remove(agentId: string): Promise<void>;

View file

@ -200,7 +200,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
return child;
}
run(agentId: string, request: AgentRunRequest, opts: RunAgentOptions): AgentRunHandle {
run(agentId: string, request: AgentRunRequest, opts: RunAgentOptions): Promise<AgentRunHandle> {
const handle = this.handles.get(agentId);
if (handle === undefined) throw new Error(`Agent "${agentId}" does not exist`);
return runAgentTurn(handle, request, {

View file

@ -58,20 +58,20 @@ export interface RunAgentTurnOptions {
}
/**
* Submit a prompt (or a retry) to `target` and return the running `Turn` plus
* a promise of the distilled summary/usage. Throws when the underlying
* Submit a prompt (or a retry) to `target` and resolve to the running `Turn`
* plus a promise of the distilled summary/usage. Throws when the underlying
* `IAgentPromptService.prompt/retry` refuses to launch a turn (busy / no head).
*/
export function runAgentTurn(
export async function runAgentTurn(
target: IAgentScopeHandle,
request: AgentRunRequest,
options: RunAgentTurnOptions,
): AgentRunHandle {
): Promise<AgentRunHandle> {
options.signal.throwIfAborted();
const promptService = target.accessor.get(IAgentPromptService);
const turn =
request.kind === 'prompt'
? promptService.prompt({
? await promptService.prompt({
role: 'user',
content: [{ type: 'text', text: request.prompt }],
toolCalls: [],
@ -140,7 +140,7 @@ async function distillSummary(
const promptService = target.accessor.get(IAgentPromptService);
for (let attempt = 0; attempt < policy.retries; attempt++) {
const turn = promptService.prompt({
const turn = await promptService.prompt({
role: 'user',
content: [{ type: 'text', text: policy.continuationPrompt }],
toolCalls: [],

View file

@ -270,7 +270,7 @@ export class AgentTool implements BuiltinTool<AgentToolInput> {
runInBackground,
});
const run = this.lifecycle.run(
const run = await this.lifecycle.run(
agentId,
{ kind: 'prompt', prompt: promptText },
{ signal: controller.signal },

View file

@ -392,9 +392,9 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe
toolCalls: [],
origin,
};
const turn = promptService.steer(message);
void promptService.steer(message);
this.telemetry.track(CRON_MISSED, { count: tasks.length });
return turn;
return undefined;
}
emitScheduled(task: CronTask): void {
@ -448,14 +448,15 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe
origin,
};
this.signalRecord({ type: 'cron.fired', origin, prompt: task.prompt });
const turn = promptService.steer(message);
const buffered = mainHandle.accessor.get(IAgentTurnService).getActiveTurn() !== undefined;
void promptService.steer(message);
this.telemetry.track(CRON_FIRED, {
recurring: task.recurring !== false,
coalesced_count: ctx.coalescedCount,
stale: origin.stale,
buffered: turn === undefined,
buffered,
});
return turn;
return undefined;
}
private advanceCursor(id: string, lastFiredAt: number): void {

View file

@ -172,14 +172,14 @@ export class SessionSwarmService implements ISessionSwarmService {
return this.observe(caller, child.id, profileName, request, options);
}
private observe(
private async observe(
caller: IAgentScopeHandle,
agentId: string,
profileName: string,
request: { kind: 'prompt'; prompt: string } | { kind: 'retry' },
options: AgentRunAttemptOptions,
): AgentRunAttemptHandle {
const run = this.lifecycle.run(agentId, request, {
): Promise<AgentRunAttemptHandle> {
const run = await this.lifecycle.run(agentId, request, {
signal: options.signal,
onReady: options.onReady,
});

View file

@ -82,12 +82,12 @@ describe('Cron — session E2E (P1.9)', () => {
}> = [];
vi.spyOn(prompt, 'steer').mockImplementation((message: ContextMessage) => {
steerCalls.push({ content: message.content, origin: message.origin });
return {
return Promise.resolve({
id: 1,
abortController: new AbortController(),
ready: Promise.resolve(),
result: Promise.resolve({ reason: 'completed' as const }),
};
});
});
// Schedule via the full tool surface — the scheduling path goes

View file

@ -75,10 +75,10 @@ describe('SessionCronService', () => {
};
ix.stub(IAgentPromptService, {
prompt: () => undefined,
prompt: () => Promise.resolve(undefined),
steer: (message) => {
steered.push(message);
return fakeTurn();
return Promise.resolve(fakeTurn());
},
retry: () => undefined,
undo: () => 0,

View file

@ -61,7 +61,7 @@ function createSteerSpy(
const calls: Array<{ content: readonly ContentPart[]; origin: PromptOrigin }> = [];
vi.spyOn(prompt, 'steer').mockImplementation((message: ContextMessage) => {
calls.push({ content: message.content, origin: message.origin as PromptOrigin });
return returnValue;
return Promise.resolve(returnValue);
});
return calls;
}
@ -610,7 +610,7 @@ describe('SessionCronService', () => {
vi.spyOn(prompt, 'steer').mockImplementation((message: ContextMessage) => {
if (shouldThrow) throw new Error('steer boom');
delivered.push(message);
return undefined;
return Promise.resolve(undefined);
});
cron.addTask({ cron: '*/5 * * * *', prompt: 'retry me' });
harness.advance(6 * 60_000);

View file

@ -31,7 +31,7 @@ function createClocks(initial: number = WALL_ANCHOR): ClockHarness {
}
function spySteer(prompt: IAgentPromptService) {
return vi.spyOn(prompt, 'steer').mockImplementation((_message: ContextMessage) => ({
return vi.spyOn(prompt, 'steer').mockImplementation((_message: ContextMessage) => Promise.resolve({
id: 1,
abortController: new AbortController(),
ready: Promise.resolve(),

View file

@ -64,7 +64,7 @@ function captureSteer(prompt: IAgentPromptService): SteerCall[] {
const calls: SteerCall[] = [];
prompt.steer = (message: ContextMessage) => {
calls.push({ content: message.content, origin: message.origin as PromptOrigin });
return undefined;
return Promise.resolve(undefined);
};
return calls;
}

View file

@ -23,6 +23,8 @@ import {
import { IAgentFullCompactionService } from '#/agent/fullCompaction';
import { IAgentLoopService, type TurnAfterStepContext } from '#/agent/loop';
import { IAgentPermissionGate } from '#/agent/permissionGate';
import { IAgentPromptService } from '#/agent/prompt';
import { IAgentRecordService } from '#/agent/record';
import { IAgentToolExecutorService } from '#/agent/toolExecutor';
import { IAgentTurnService, type Turn } from '#/agent/turn';
import { IBootstrapService } from '#/app/bootstrap';
@ -31,6 +33,7 @@ import { IPluginService } from '#/app/plugin';
import { createHooks } from '#/hooks';
import { stubBootstrap } from '../bootstrap/stubs';
import { stubRecord } from '../contextMemory/stubs';
import { stubLoopWithHooks, stubToolExecutor, stubTurnWithHooks } from '../turn/stubs';
function nodeCommand(source: string): string {
@ -156,7 +159,11 @@ describe('HookEngine integration', () => {
reg.definePartialInstance(IConfigService, {});
reg.definePartialInstance(IPluginService, {});
reg.defineInstance(IAgentContextMemoryService, context);
reg.defineInstance(IAgentRecordService, stubRecord());
reg.defineInstance(IAgentLoopService, loop);
reg.definePartialInstance(IAgentPromptService, {
hooks: createHooks(['onWillSubmitPrompt']),
});
reg.defineInstance(IAgentTurnService, turnService);
reg.defineInstance(IAgentToolExecutorService, stubToolExecutor());
reg.definePartialInstance(IAgentPermissionGate, {

View file

@ -13,6 +13,7 @@ import { ILogService } from '#/app/log';
import { IAgentPromptService } from '#/agent/prompt';
import { ISessionLifecycleService } from '#/app/sessionLifecycle';
import { IAgentTurnService } from '#/agent/turn';
import { createHooks } from '#/hooks';
import { stubLog } from '../log/stubs';
import { stubTurn } from '../turn/stubs';
@ -51,12 +52,13 @@ describe('RestGateway', () => {
_serviceBrand: undefined,
prompt: (message) => {
promptCalls.push(message);
return undefined;
return Promise.resolve(undefined);
},
steer: () => undefined,
steer: () => Promise.resolve(undefined),
retry: () => undefined,
undo: () => 0,
clear: () => {},
hooks: createHooks(['onWillSubmitPrompt']) as IAgentPromptService['hooks'],
};
const agentHandle: IAgentScopeHandle = {

View file

@ -0,0 +1,85 @@
import { describe, expect, it, onTestFinished } from 'vitest';
import { DisposableStore } from '#/_base/di/lifecycle';
import { createServices } from '#/_base/di/test';
import { IAgentLoopService } from '#/agent/loop';
import { AgentPromptService, IAgentPromptService } from '#/agent/prompt';
import type { PromptSubmitContext } from '#/agent/prompt';
import { IAgentContextMemoryService, type ContextMessage } from '#/agent/contextMemory';
import { IAgentRecordService } from '#/agent/record';
import { IAgentTurnService } from '#/agent/turn';
import { stubContextMemory, stubRecord } from '../contextMemory/stubs';
import { stubLoopWithHooks, stubTurn } from '../turn/stubs';
function userMessage(text: string, origin: ContextMessage['origin']): ContextMessage {
return {
role: 'user',
content: [{ type: 'text', text }],
toolCalls: [],
origin,
};
}
function createHarness() {
const disposables = new DisposableStore();
onTestFinished(() => disposables.dispose());
const context = stubContextMemory();
const turn = stubTurn();
const ix = createServices(disposables, {
strict: true,
additionalServices: (reg) => {
reg.defineInstance(IAgentContextMemoryService, context);
reg.defineInstance(IAgentTurnService, turn);
reg.defineInstance(IAgentRecordService, stubRecord());
reg.defineInstance(IAgentLoopService, stubLoopWithHooks());
reg.define(IAgentPromptService, AgentPromptService);
},
});
return {
context,
prompt: ix.get(IAgentPromptService),
turn,
};
}
describe('AgentPromptService', () => {
it('runs submit hooks for any prompt and steer origin', async () => {
const { prompt, turn } = createHarness();
const seen: Array<Pick<PromptSubmitContext, 'isSteer'> & {
readonly originKind: string | undefined;
}> = [];
prompt.hooks.onWillSubmitPrompt.register('capture', async (ctx, next) => {
seen.push({ isSteer: ctx.isSteer, originKind: ctx.promptMessage.origin?.kind });
await next();
});
await prompt.prompt(userMessage('from prompt', { kind: 'system_trigger', name: 'test_prompt' }));
await prompt.steer(userMessage('from steer', { kind: 'system_trigger', name: 'test_steer' }));
expect(seen).toEqual([
{ isSteer: false, originKind: 'system_trigger' },
{ isSteer: true, originKind: 'system_trigger' },
]);
expect(turn.launches).toHaveLength(2);
});
it('blocks launch when the hook sets block', async () => {
const { context, prompt, turn } = createHarness();
prompt.hooks.onWillSubmitPrompt.register('block', async (ctx) => {
ctx.block = true;
});
const result = await prompt.prompt(
userMessage('blocked', { kind: 'system_trigger', name: 'test_block' }),
);
expect(result).toBeUndefined();
expect(turn.launches).toEqual([]);
expect(context.messages).toHaveLength(1);
});
});

View file

@ -6,6 +6,7 @@ import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMo
import { IAgentProfileService } from '#/agent/profile/profile';
import { IAgentPromptService } from '#/agent/prompt/prompt';
import { IAgentTurnService, type Turn, type TurnResult } from '#/agent/turn/turn';
import { createHooks } from '#/hooks';
import type { PromptSubmission } from '@moonshot-ai/protocol';
import { IAgentPromptLegacyService, AgentPromptLegacyService } from '#/agent/promptLegacy';
@ -40,7 +41,7 @@ interface Harness {
readonly steered: string[];
}
function createHarness(): Harness {
function createHarness(options: { readonly blockPrompt?: boolean } = {}): Harness {
const disposables = new DisposableStore();
onTestFinished(() => disposables.dispose());
@ -53,7 +54,8 @@ function createHarness(): Harness {
const prompt: IAgentPromptService = {
_serviceBrand: undefined,
prompt: () => {
if (activeTurn !== undefined) return undefined;
if (options.blockPrompt === true) return Promise.resolve(undefined);
if (activeTurn !== undefined) return Promise.resolve(undefined);
const { turn, settle } = controlledTurn(nextTurnId++);
activeTurn = turn;
activeSettle = settle;
@ -64,17 +66,18 @@ function createHarness(): Harness {
activeSettle = undefined;
}
});
return turn;
return Promise.resolve(turn);
},
steer: (message) => {
for (const part of message.content) {
if (part.type === 'text') steered.push(part.text);
}
return undefined;
return Promise.resolve(undefined);
},
retry: () => undefined,
undo: () => 0,
clear: () => {},
hooks: createHooks(['onWillSubmitPrompt']) as IAgentPromptService['hooks'],
};
const turnService: IAgentTurnService = {
@ -136,12 +139,21 @@ describe('AgentPromptLegacyService', () => {
expect(list.queued.map((q) => q.prompt_id)).toEqual([second.prompt_id]);
});
it('reports blocked without queueing when no turn is launched', async () => {
const { service, turns } = createHarness({ blockPrompt: true });
const result = await service.submit(textBody('blocked'));
expect(result.status).toBe('blocked');
expect(turns).toHaveLength(0);
expect(service.list()).toEqual({ active: null, queued: [] });
});
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();
await Promise.resolve();
expect(turns).toHaveLength(2);
expect(service.list().active?.prompt_id).toBe(second.prompt_id);
});
@ -154,6 +166,7 @@ describe('AgentPromptLegacyService', () => {
expect(aborted.aborted).toBe(true);
settleActive({ reason: 'cancelled' });
await Promise.resolve();
await Promise.resolve();
expect(turns).toHaveLength(2);
expect(service.list().active?.prompt_id).toBe(second.prompt_id);
});

View file

@ -55,11 +55,11 @@ describe('AgentSkillService', () => {
reg.definePartialInstance(IAgentPromptService, {
prompt: (message) => {
prompted.push(message);
return fakeTurn();
return Promise.resolve(fakeTurn());
},
steer: (message) => {
prompted.push(message);
return undefined;
return Promise.resolve(undefined);
},
retry: () => undefined,
undo: () => 0,
@ -152,11 +152,11 @@ describe('SkillTool', () => {
reg.definePartialInstance(IAgentPromptService, {
prompt: (message: ContextMessage) => {
prompted.push(message);
return fakeTurn();
return Promise.resolve(fakeTurn());
},
steer: (message: ContextMessage) => {
prompted.push(message);
return undefined;
return Promise.resolve(undefined);
},
retry: () => undefined,
undo: () => 0,

View file

@ -205,7 +205,9 @@ function createAgentTaskService(options: {
emittedEvents.push(event as { type: string; info?: unknown });
});
const steerSpy = vi.spyOn(ctx.get(IAgentPromptService), 'steer').mockReturnValue(undefined);
const steerSpy = vi
.spyOn(ctx.get(IAgentPromptService), 'steer')
.mockResolvedValue(undefined);
const context = ctx.get(IAgentContextMemoryService);
const spliceHistorySpy = vi.spyOn(context, 'splice');

View file

@ -49,7 +49,7 @@ describe('AgentTaskService', () => {
ix.stub(IAgentToolRegistryService, {
register: () => toDisposable(() => {}),
});
ix.stub(IAgentPromptService, { steer: () => undefined });
ix.stub(IAgentPromptService, { steer: () => Promise.resolve(undefined) });
ix.stub(IConfigRegistry, { registerSection: () => {} });
ix.stub(IConfigService, {
get: (() => undefined) as IConfigService['get'],

View file

@ -43,7 +43,6 @@ function makeTurn(id: number): Turn {
function makeHooks(): IAgentTurnService['hooks'] {
return createHooks([
'onLaunched',
'onWillSubmitUserPrompt',
'onEnded',
]) as IAgentTurnService['hooks'];
}

View file

@ -619,7 +619,7 @@ describe('Agent turn flow', () => {
);
});
it('emits a friendly model.not_configured error when no model is configured', async () => {
it('emits a model.not_configured error when no model is configured', async () => {
const ctx = testAgent(configServices(() => ({ providers: {} })), { autoConfigure: false });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Hello without login' }] });
@ -628,10 +628,10 @@ describe('Agent turn flow', () => {
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Hello without login" } ], "toolCalls": [], "id": "<msg-1>" } ], "time": "<time>" }
[wire] turn.launch { "turnId": 0, "origin": { "kind": "user" }, "promptMessageId": "<msg-1>", "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" }, "promptMessageId": "<msg-1>" }
[emit] turn.ended { "turnId": 0, "reason": "failed", "error": { "code": "model.not_configured", "message": "LLM not set, send \\"/login\\" to login", "name": "KimiError", "details": { "turnId": 0 }, "retryable": false } }
[emit] turn.ended { "turnId": 0, "reason": "failed", "error": { "code": "model.not_configured", "message": "Model not set", "name": "KimiError", "details": { "turnId": 0 }, "retryable": false } }
`);
expect(ctx.newEvents()).toMatchInlineSnapshot(
`[emit] error { "code": "model.not_configured", "message": "LLM not set, send \\"/login\\" to login", "name": "KimiError", "details": { "turnId": 0 }, "retryable": false }`,
`[emit] error { "code": "model.not_configured", "message": "Model not set", "name": "KimiError", "details": { "turnId": 0 }, "retryable": false }`,
);
});
@ -741,7 +741,7 @@ describe('Agent turn flow', () => {
expect(events).toContainEqual(
expect.objectContaining({
event: 'turn.ended',
args: expect.objectContaining({ reason: 'blocked' }),
args: expect.objectContaining({ reason: 'completed' }),
}),
);
expect(ctx.contextData().history).toMatchObject([
@ -780,11 +780,17 @@ describe('Agent turn flow', () => {
const ctx = testAgent({ hookEngine });
ctx.configure();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'bad words here' }] });
const events = await ctx.untilTurnEnd();
const result = await ctx.rpc.prompt({ input: [{ type: 'text', text: 'bad words here' }] });
const events = ctx.newEvents();
const hookResult = '<hook_result hook_event="UserPromptSubmit">\nno profanity\n</hook_result>';
expect(result).toBeUndefined();
expect(ctx.llmCalls).toHaveLength(0);
expect(events).not.toContainEqual(
expect.objectContaining({
event: 'turn.started',
}),
);
expect(events).toContainEqual(
expect.objectContaining({
event: 'hook.result',
@ -824,31 +830,30 @@ describe('Agent turn flow', () => {
`);
});
it('cancels while waiting for a UserPromptSubmit hook without appending stale output', async () => {
it('ignores timed out UserPromptSubmit hook output before launching the turn', async () => {
const hookEngine = new HookEngine([
{
event: 'UserPromptSubmit',
command: 'node -e "setTimeout(() => process.stdout.write(\\"late hook\\"), 250)"',
timeout: 5,
timeout: 0.01,
},
]);
const ctx = testAgent({ hookEngine });
ctx.configure();
ctx.mockNextResponse({ type: 'text', text: 'model after timeout' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hook will sleep' }] });
await ctx.rpc.cancel({ turnId: 0 });
const events = await ctx.untilTurnEnd();
expect(events).toContainEqual(
expect.objectContaining({
event: 'turn.ended',
args: expect.objectContaining({ reason: 'cancelled' }),
args: expect.objectContaining({ reason: 'completed' }),
}),
);
expect(events).not.toContainEqual(
expect.objectContaining({
event: 'assistant.delta',
args: expect.objectContaining({ delta: expect.stringContaining('late hook') }),
event: 'hook.result',
}),
);
expect(ctx.contextData().history).toMatchObject([
@ -857,6 +862,11 @@ describe('Agent turn flow', () => {
content: [{ type: 'text', text: 'hook will sleep' }],
toolCalls: [],
},
{
role: 'assistant',
content: [{ type: 'text', text: 'model after timeout' }],
toolCalls: [],
},
]);
});

View file

@ -110,13 +110,14 @@ describe('events / display re-exports', () => {
sessionId: 'sess_1',
promptId: 'prompt_1',
userMessageId: 'msg_1',
status: 'running',
status: 'blocked',
content: [{ type: 'text', text: 'hello' }],
createdAt: '2026-06-11T00:00:00.000Z',
});
expect(parsed.type).toBe('prompt.submitted');
expect((parsed as { promptId: string }).promptId).toBe('prompt_1');
expect((parsed as { status: string }).status).toBe('blocked');
});
it('preserves detached on task events', () => {

View file

@ -135,6 +135,18 @@ describe('promptSubmitResultSchema', () => {
expect(parsed.status).toBe('running');
});
it('parses a blocked prompt result shape', () => {
const parsed = promptSubmitResultSchema.parse({
prompt_id: 'prompt_blocked',
user_message_id: 'msg_blocked',
status: 'blocked',
content: [{ type: 'text', text: 'blocked' }],
created_at: '2026-06-09T00:00:00.000Z',
});
expect(parsed.status).toBe('blocked');
});
it('rejects empty prompt_id', () => {
expect(
promptSubmitResultSchema.safeParse({ prompt_id: '', user_message_id: 'msg' })

View file

@ -547,7 +547,7 @@ export interface AssistantDeltaEvent {
export interface HookResultEvent {
readonly type: 'hook.result';
readonly turnId: number;
readonly turnId?: number;
readonly hookEvent: string;
readonly content: string;
readonly blocked?: boolean;
@ -693,7 +693,7 @@ export interface PromptSubmittedEvent {
readonly type: 'prompt.submitted';
readonly promptId: string;
readonly userMessageId: string;
readonly status: 'running' | 'queued';
readonly status: 'running' | 'queued' | 'blocked';
readonly content: readonly MessageContent[];
readonly createdAt: string;
}
@ -1253,7 +1253,7 @@ export const assistantDeltaEventSchema = z.object({
export const hookResultEventSchema = z.object({
type: z.literal('hook.result'),
turnId: z.number(),
turnId: z.number().optional(),
hookEvent: z.string(),
content: z.string(),
blocked: z.boolean().optional(),
@ -1389,7 +1389,7 @@ export const promptSubmittedEventSchema = z.object({
type: z.literal('prompt.submitted'),
promptId: z.string(),
userMessageId: z.string(),
status: z.enum(['running', 'queued']),
status: z.enum(['running', 'queued', 'blocked']),
content: z.array(messageContentSchema),
createdAt: isoDateTimeSchema,
}) satisfies z.ZodType<PromptSubmittedEvent>;

View file

@ -10,7 +10,8 @@
* }
* Reply: PromptSubmitResult { prompt_id, user_message_id, status, content, created_at }
* status='running' when sent immediately, status='queued' when
* another prompt is already active.
* another prompt is already active, status='blocked' when rejected
* before a turn is launched.
*
* GET /v1/sessions/{sid}/prompts
* Reply: { active: PromptItem | null, queued: PromptItem[] }
@ -54,7 +55,7 @@ export const promptSubmissionSchema = z.object({
});
export type PromptSubmission = z.infer<typeof promptSubmissionSchema>;
export const promptStatusSchema = z.enum(['running', 'queued']);
export const promptStatusSchema = z.enum(['running', 'queued', 'blocked']);
export type PromptStatus = z.infer<typeof promptStatusSchema>;
export const promptItemSchema = z.object({