diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index d2a6b27c50..9652d9d084 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -109,6 +109,29 @@ interface Record extends PromptSnapshot { handle: PromptHandle; } +function bundledSkillBlockCount(message: ContextMessage): number { + return message.origin?.kind === 'user' ? (message.origin.skillActivations?.length ?? 0) : 0; +} + +function stripBundledSkillBlocks(message: ContextMessage): ContentPart[] { + return message.content.slice(bundledSkillBlockCount(message)); +} + +function mergeSteerMessages(records: readonly Record[]): ContextMessage { + const skillActivations = records.flatMap((item) => + item.message.origin?.kind === 'user' ? (item.message.origin.skillActivations ?? []) : [], + ); + return { + role: 'user', + content: [ + ...records.flatMap((item) => item.message.content.slice(0, bundledSkillBlockCount(item.message))), + ...records.flatMap((item) => stripBundledSkillBlocks(item.message)), + ], + toolCalls: [], + origin: skillActivations.length === 0 ? USER_PROMPT_ORIGIN : { kind: 'user', skillActivations }, + }; +} + export const promptLaunchingKey = defineState('prompt.launching', () => false); export class AgentPromptService implements IAgentPromptService { @@ -117,6 +140,7 @@ export class AgentPromptService implements IAgentPromptService { private readonly pending: Record[] = []; private readonly steered = new Map(); private readonly reservedPromptIds = new Set(); + private steering = 0; private fullCompactionService: IAgentFullCompactionService | undefined; readonly hooks = { onBeforeSubmitPrompt: new OrderedHookSlot() }; @@ -284,22 +308,41 @@ export class AgentPromptService implements IAgentPromptService { throw new Error2(ErrorCodes.PROMPT_NOT_FOUND, 'one or more prompts are not pending'); } const selected = this.pending.filter((item) => ids.has(item.id)); - for (const item of selected) this.pending.splice(this.pending.indexOf(item), 1); - const message: ContextMessage = { - role: 'user', content: selected.flatMap((item) => item.message.content), toolCalls: [], origin: USER_PROMPT_ORIGIN, - }; - const { message: rerouted, captions } = this.extractCompressionCaptions(message); + const activeAtEntry = this.active; + const { message: rerouted, captions } = this.extractCompressionCaptions(mergeSteerMessages(selected)); + await this.materializeDaemonRefs(rerouted); + if (selected.some((item) => !this.pending.includes(item)) || this.active !== activeAtEntry) { + throw new Error2(ErrorCodes.PROMPT_NOT_FOUND, 'one or more prompts are no longer pending'); + } + this.steering++; + const removed: { readonly item: Record; readonly index: number }[] = []; + for (const item of selected) { + const index = this.pending.indexOf(item); + removed.push({ item, index }); + this.pending.splice(index, 1); + } const request = new SteerStepRequest(rerouted, captions, this.reminders, (materialized) => { void this.dispatcher.dispatch( new TurnSteer({ input: materialized.content, origin: materialized.origin ?? USER_PROMPT_ORIGIN }), ); }, () => {}); - const turn = (await this.loop.enqueue(request).assigned).turn; - if (turn === undefined) throw new Error2(ErrorCodes.PROMPT_NOT_FOUND, 'no active turn to steer into'); + let turn: Turn | undefined; + try { + turn = (await this.loop.enqueue(request).assigned).turn; + } catch { + turn = undefined; + } finally { + this.steering--; + } + if (turn === undefined || this.active !== activeAtEntry) { + for (const { item, index } of removed.reverse()) this.pending.splice(index, 0, item); + if (this.active === undefined) void this.startNext(); + throw new Error2(ErrorCodes.PROMPT_NOT_FOUND, 'no active turn to steer into'); + } for (const item of selected) { item.state = 'steered'; item.launchedDeferred.resolve(turn); } this.steered.set(this.active.id, [...(this.steered.get(this.active.id) ?? []), ...selected]); void this.dispatcher.dispatch( - new PromptSteered({ activePromptId: this.active.id, promptIds: selected.map((x) => x.id), content: rerouted.content as ContentPart[], steeredAt: new Date().toISOString() }), + new PromptSteered({ activePromptId: this.active.id, promptIds: selected.map((x) => x.id), content: selected.flatMap((item) => stripBundledSkillBlocks(item.message)), steeredAt: new Date().toISOString() }), ); return selected.map((item) => item.handle); } @@ -322,6 +365,7 @@ export class AgentPromptService implements IAgentPromptService { async inject(message: ContextMessage): Promise { const { message: rerouted, captions } = this.extractCompressionCaptions(message); + await this.materializeDaemonRefs(rerouted); const request = new SteerStepRequest(rerouted, captions, this.reminders, (materialized) => { void this.dispatcher.dispatch( new TurnSteer({ input: materialized.content, origin: materialized.origin ?? USER_PROMPT_ORIGIN }), @@ -339,17 +383,13 @@ export class AgentPromptService implements IAgentPromptService { } private async startNext(): Promise { - if (this.active !== undefined || this.launching) return; + if (this.active !== undefined || this.launching || this.steering > 0) return; const item = this.pending.shift(); if (item === undefined) return; this.launching = true; try { if (this.fullCompaction.compacting !== null && this.loop.status().state !== 'running') { this.pending.unshift(item); return; } const { message, captions } = this.extractCompressionCaptions(item.message); - if (message.content.some((part) => daemonFileRefFromPart(part) !== undefined)) { - const files = this.instantiation.invokeFunction((accessor) => accessor.get(IFileService)); - const mediaStore = this.instantiation.invokeFunction((accessor) => accessor.get(ISessionMediaStore)); - await materializePromptDaemonRefs(message.content, { files, mediaStore }); - } + await this.materializeDaemonRefs(message); if (await this.blockedByHook(message, false)) { this.appendPrompt(message, captions); item.state = 'blocked'; item.launchedDeferred.resolve(undefined); item.completionDeferred.resolve({ promptId: item.id, result: undefined, state: 'blocked' }); @@ -381,6 +421,13 @@ export class AgentPromptService implements IAgentPromptService { void this.startNext(); } + private async materializeDaemonRefs(message: ContextMessage): Promise { + if (!message.content.some((part) => daemonFileRefFromPart(part) !== undefined)) return; + const files = this.instantiation.invokeFunction((accessor) => accessor.get(IFileService)); + const mediaStore = this.instantiation.invokeFunction((accessor) => accessor.get(ISessionMediaStore)); + await materializePromptDaemonRefs(message.content, { files, mediaStore }); + } + private async blockedByHook(promptMessage: ContextMessage, isSteer: boolean): Promise { const ctx = { promptMessage, isSteer, block: false }; await this.hooks.onBeforeSubmitPrompt.run(ctx); return ctx.block; } @@ -420,7 +467,7 @@ export class AgentPromptService implements IAgentPromptService { private publishCompleted(promptId: string, reason: 'completed' | 'failed' | 'blocked'): void { void this.dispatcher.dispatch(new PromptCompleted({ promptId, finishedAt: new Date().toISOString(), reason })); } private publishQueued(record: Record): void { if ((record.message.origin ?? USER_PROMPT_ORIGIN).kind !== 'user') return; - void this.dispatcher.dispatch(new PromptQueued({ promptId: record.id, content: record.message.content, queueLength: this.pending.length })); + void this.dispatcher.dispatch(new PromptQueued({ promptId: record.id, content: stripBundledSkillBlocks(record.message), queueLength: this.pending.length })); } private publishAborted(promptId: string): void { void this.dispatcher.dispatch(new PromptAborted({ promptId, abortedAt: new Date().toISOString() })); } } diff --git a/packages/agent-core-v2/src/agent/skill/skill.ts b/packages/agent-core-v2/src/agent/skill/skill.ts index 95b58170f1..4d5c18b243 100644 --- a/packages/agent-core-v2/src/agent/skill/skill.ts +++ b/packages/agent-core-v2/src/agent/skill/skill.ts @@ -19,11 +19,18 @@ export interface PromptWithSkillsInput { readonly skills: readonly PromptSkillActivation[]; } +export interface PromptWithSkillsResult { + readonly turn_id?: number; + readonly prompt_id: string; + readonly created_at: string; + readonly state: 'running' | 'queued' | 'blocked'; +} + export interface IAgentSkillService { readonly _serviceBrand: undefined; activate(input: SkillActivationInput): Promise; - promptWithSkills(input: PromptWithSkillsInput): Promise; + promptWithSkills(input: PromptWithSkillsInput): Promise; recordModelToolActivation(origin: SkillActivationOrigin): void; } diff --git a/packages/agent-core-v2/src/agent/skill/skillService.ts b/packages/agent-core-v2/src/agent/skill/skillService.ts index 35114a7ed6..aa25fe55c8 100644 --- a/packages/agent-core-v2/src/agent/skill/skillService.ts +++ b/packages/agent-core-v2/src/agent/skill/skillService.ts @@ -15,7 +15,7 @@ import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { Service } from '#/_base/di/service'; import { ErrorCodes, Error2 } from '#/errors'; import { isUserActivatableSkillType, type SkillDefinition } from '#/app/skillCatalog/types'; -import { IAgentPromptService, type PromptLaunchResult } from '#/agent/prompt/prompt'; +import { IAgentPromptService, reservePrompt, type PromptLaunchResult } from '#/agent/prompt/prompt'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentLoopService, type Turn } from '#/agent/loop/loop'; import { IAgentStateService } from '#/agent/state/agentState'; @@ -24,6 +24,7 @@ import { IAgentSkillService, type PromptSkillActivation, type PromptWithSkillsInput, + type PromptWithSkillsResult, type SkillActivationInput, } from './skill'; import { SkillActivate, skillKey } from './skillOps'; @@ -114,7 +115,7 @@ export class AgentSkillService extends Service implements IAgentSkillService { return { turn_id: turn.id }; } - async promptWithSkills(input: PromptWithSkillsInput): Promise { + async promptWithSkills(input: PromptWithSkillsInput): Promise { if (input.input.length === 0) { throw new Error2(ErrorCodes.REQUEST_INVALID, 'promptWithSkills requires a non-empty prompt'); } @@ -139,8 +140,9 @@ export class AgentSkillService extends Service implements IAgentSkillService { for (const activation of prepared) { void this.recordActivation(activation.origin); } - const handle = await this.prompt.enqueue({ - message: { + const reservation = reservePrompt(this.prompt); + try { + const handle = await reservation.submit({ role: 'user', content: [...prepared.map((activation) => activation.part), ...input.input], toolCalls: [], @@ -148,11 +150,23 @@ export class AgentSkillService extends Service implements IAgentSkillService { kind: 'user', skillActivations: prepared.map((activation) => activation.entry), }, - }, - }); - if (handle.state === 'pending') return undefined; - const turn = await handle.launched; - return turn === undefined ? undefined : { turn_id: turn.id }; + }); + if (handle.state === 'pending') { + return { prompt_id: handle.id, created_at: handle.createdAt, state: 'queued' }; + } + const turn = await handle.launched; + if (turn === undefined && handle.state !== 'blocked') { + throw new Error2(ErrorCodes.INTERNAL, 'promptWithSkills failed to launch a turn'); + } + return { + turn_id: turn?.id, + prompt_id: handle.id, + created_at: handle.createdAt, + state: handle.state === 'blocked' ? 'blocked' : 'running', + }; + } finally { + reservation.dispose(); + } } recordModelToolActivation(origin: SkillActivationOrigin): void { diff --git a/packages/agent-core-v2/test/agent/loop/stubs.ts b/packages/agent-core-v2/test/agent/loop/stubs.ts index a1b1210101..12dc196d6a 100644 --- a/packages/agent-core-v2/test/agent/loop/stubs.ts +++ b/packages/agent-core-v2/test/agent/loop/stubs.ts @@ -1,6 +1,6 @@ import { toDisposable } from '#/_base/di/lifecycle'; import { Event } from '#/_base/event'; -import type { IAgentLoopService, LoopErrorHandler, LoopErrorHandlerRegistrationOptions, Step, Turn } from '#/agent/loop/loop'; +import type { IAgentLoopService, LoopErrorHandler, LoopErrorHandlerRegistrationOptions, Step, Turn, TurnResult } from '#/agent/loop/loop'; import type { StepRequest } from '#/agent/loop/stepRequest'; import { StepRequestQueue, type StepRequestBatch } from '#/agent/loop/stepRequestQueue'; import type { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; @@ -10,12 +10,13 @@ import type { ContextMessage } from '#/agent/contextMemory/types'; import { createHooks } from '#/hooks'; import type { IWireService } from '#/wire/wire'; -export interface StubLoopOptions { readonly hasActiveTurn?: boolean; readonly currentId?: string | number; readonly pendingTurnResult?: boolean } +export interface StubLoopOptions { readonly hasActiveTurn?: boolean; readonly currentId?: string | number; readonly pendingTurnResult?: boolean; readonly manualTurnResult?: boolean } export type StubLoop = IAgentLoopService & { readonly queue: StepRequestQueue; readonly launches: readonly number[]; readonly cancels: readonly { readonly turnId?: number; readonly reason?: unknown }[]; startTurn(): Turn; + settleActive(result?: TurnResult): void; drainNextBatch(context: { append(...messages: ContextMessage[]): void }): StepRequestBatch | undefined; }; const turnControllers = new WeakMap(); @@ -44,14 +45,18 @@ export function stubLoopWithHooks(options: StubLoopOptions = {}): StubLoop { const hooks = createHooks(['onWillBeginStep', 'onDidFinishStep']) as IAgentLoopService['hooks']; const queue = new StepRequestQueue(); const errorHandlers = registry(); const launches: number[] = []; const cancels: { turnId?: number; reason?: unknown }[] = []; let active: Turn | undefined; let nextId = typeof options.currentId === 'number' ? options.currentId : 0; + let releaseActiveResult: ((result: TurnResult) => void) | undefined; const startTurn = () => { const turn = makeTurn(nextId++); - const result = options.pendingTurnResult === true ? new Promise(() => {}) : turn.result; + const result = options.manualTurnResult === true + ? new Promise((resolve) => { releaseActiveResult = resolve; }) + : options.pendingTurnResult === true ? new Promise(() => {}) : turn.result; const configured = { ...turn, result }; launches.push(configured.id); active = configured; return configured; }; const stub: StubLoop = { _serviceBrand: undefined, hooks, queue, launches, cancels, startTurn, + settleActive(result = { type: 'completed', steps: 0, truncated: false }) { releaseActiveResult?.(result); }, enqueue(request, enqueueOptions) { let turn = active; if (request.admission === 'newTurn' || (request.admission === 'activeOrNewTurn' && turn === undefined)) turn = startTurn(); diff --git a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts index d922d4b4af..3734815244 100644 --- a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts +++ b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts @@ -1,15 +1,18 @@ import { describe, expect, it, onTestFinished, vi } from 'vitest'; +import { Readable } from 'node:stream'; + import { DisposableStore } from '#/_base/di/lifecycle'; import { createServices } from '#/_base/di/test'; import { Event } from '#/_base/event'; import { IAgentBlobService } from '#/agent/blob/agentBlobService'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; +import type { ContentPart } from '#/kosong/contract/message'; import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentPromptService } from '#/agent/prompt/prompt'; -import { AgentPromptService, PromptQueued } from '#/agent/prompt/promptService'; +import { AgentPromptService, PromptQueued, PromptSteered } from '#/agent/prompt/promptService'; import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService'; @@ -26,15 +29,27 @@ import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { IEventDispatcher } from '#/state/eventDispatcher'; import { EventDispatcherService } from '#/state/eventDispatcherService'; import { IWireService } from '#/wire/wire'; +import { IFileService } from '#/app/file/fileService'; +import { ISessionMediaStore } from '#/agent/media/sessionMediaStore'; import { stubContextMemory } from '../contextMemory/stubs'; -import { stubLoopWithHooks, stubToolExecutor, stubWire } from '../loop/stubs'; +import { stubLoopWithHooks, stubToolExecutor, stubWire, type StubLoopOptions } from '../loop/stubs'; import { registerStateServices } from '../../state/stubs'; +import { SteerStepRequest } from '#/agent/prompt/promptStepRequests'; function message(text: string): ContextMessage { return { role: 'user', content: [{ type: 'text', text }], toolCalls: [], origin: { kind: 'user' } }; } +function bundledMessage(skillName: string, user: string, extra: readonly ContentPart[] = []): ContextMessage { + return { + role: 'user', + content: [{ type: 'text', text: `${skillName}` }, { type: 'text', text: user }, ...extra], + toolCalls: [], + origin: { kind: 'user', skillActivations: [{ activationId: `act-${skillName}`, skillName }] }, + }; +} + const noopBlob: IAgentBlobService = { _serviceBrand: undefined, offloadParts: async (parts) => parts, @@ -42,11 +57,11 @@ const noopBlob: IAgentBlobService = { isBlobRef: () => false, }; -function harness() { +function harness(loopOptions: StubLoopOptions = { pendingTurnResult: true }) { const disposables = new DisposableStore(); onTestFinished(() => disposables.dispose()); const context = stubContextMemory(); - const loop = stubLoopWithHooks({ pendingTurnResult: true }); + const loop = stubLoopWithHooks(loopOptions); const fullCompaction = { _serviceBrand: undefined, compacting: null, @@ -54,6 +69,19 @@ function harness() { hooks: createHooks(['onWillCompact']), onDidFinishCompaction: Event.None, } as unknown as IAgentFullCompactionService; + const intake = { + get: vi.fn(async () => ({ + meta: { + id: 'file_1', + size: 3, + name: 'pic.png', + media_type: 'image/png', + created_at: '2026-01-01T00:00:00.000Z', + }, + stream: () => Readable.from([new Uint8Array([1, 2, 3])]), + })), + materialize: vi.fn(async (): Promise => undefined), + }; const ix = createServices(disposables, { strict: true, additionalServices: (reg) => { registerStateServices(reg); @@ -76,9 +104,11 @@ function harness() { reg.definePartialInstance(IEventService, { publish: () => {} }); reg.definePartialInstance(ISessionContext, { sessionId: 'test-session' }); reg.defineInstance(IAgentScopeContext, makeAgentScopeContext({ agentId: 'main', agentScope: '' })); + reg.definePartialInstance(IFileService, { get: intake.get }); + reg.definePartialInstance(ISessionMediaStore, { materialize: intake.materialize }); } }); - return { prompt: ix.get(IAgentPromptService), loop, context, fullCompaction, eventBus: ix.get(IEventBus) }; + return { prompt: ix.get(IAgentPromptService), loop, context, fullCompaction, eventBus: ix.get(IEventBus), intake }; } describe('AgentPromptService', () => { @@ -239,4 +269,210 @@ describe('AgentPromptService', () => { parts.some((part) => part.type === 'text' && part.text.includes('image/avif')), ).toBe(true); }); + + it('materializes daemon-ref media at steer intake', async () => { + const { prompt, intake } = harness(); + const active = await prompt.enqueue({ message: message('active') }); + await active.launched; + const queued = await prompt.enqueue({ + id: 'prompt-steer-daemon', + message: { + role: 'user', + content: [{ type: 'image_url', imageUrl: { url: 'kimi-file://file_1' } }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }); + + await prompt.steer([queued.id]); + + expect(intake.get).toHaveBeenCalledWith('file_1'); + expect(intake.materialize).toHaveBeenCalledWith( + expect.objectContaining({ fileId: 'file_1', name: 'pic.png' }), + ); + }); + + it('publishes each record’s user parts when steering bundled prompts', async () => { + const { prompt, eventBus } = harness(); + const steered: ContentPart[][] = []; + eventBus.subscribe(PromptSteered, (event) => steered.push(event.content)); + const active = await prompt.enqueue({ message: message('active') }); + await active.launched; + const one = await prompt.enqueue({ message: bundledMessage('review', 'first user text') }); + const two = await prompt.enqueue({ message: bundledMessage('security', 'second user text') }); + + await prompt.steer([one.id, two.id]); + + expect(steered).toHaveLength(1); + expect(steered[0]).toEqual([ + { type: 'text', text: 'first user text' }, + { type: 'text', text: 'second user text' }, + ]); + }); + + it('restores failed steers to their original queue positions', async () => { + const { prompt, loop } = harness(); + const active = await prompt.enqueue({ message: message('active') }); + await active.launched; + await prompt.enqueue({ id: 'a', message: message('a') }); + await prompt.enqueue({ id: 'b', message: message('b') }); + await prompt.enqueue({ id: 'c', message: message('c') }); + vi.spyOn(loop, 'enqueue').mockImplementation(() => { + throw new Error('boom'); + }); + + await expect(prompt.steer(['b'])).rejects.toMatchObject({ code: 'prompt.not_found' }); + + expect(prompt.list().pending.map((item) => item.id)).toEqual(['a', 'b', 'c']); + }); + + it('publishes only caller parts when a bundled prompt queues', async () => { + const { prompt, eventBus } = harness(); + const queued: Array<{ promptId: string; content: ContentPart[] }> = []; + eventBus.subscribe(PromptQueued, (event) => { + queued.push({ promptId: event.promptId, content: event.content }); + }); + const active = await prompt.enqueue({ message: message('active') }); + await active.launched; + + await prompt.enqueue({ id: 'bundled', message: bundledMessage('review', 'user text') }); + + expect(queued).toEqual([ + { promptId: 'bundled', content: [{ type: 'text', text: 'user text' }] }, + ]); + }); + + it('rejects the whole steer when a selected prompt is aborted during intake', async () => { + const { prompt, intake } = harness(); + const active = await prompt.enqueue({ message: message('active') }); + await active.launched; + let releaseIntake!: () => void; + intake.get.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseIntake = () => + resolve({ + meta: { + id: 'file_1', + size: 3, + name: 'pic.png', + media_type: 'image/png', + created_at: '2026-01-01T00:00:00.000Z', + }, + stream: () => Readable.from([new Uint8Array([1, 2, 3])]), + }); + }), + ); + await prompt.enqueue({ + id: 'a', + message: bundledMessage('review', 'a text', [ + { type: 'image_url', imageUrl: { url: 'kimi-file://file_1' } }, + ]), + }); + await prompt.enqueue({ id: 'b', message: message('b') }); + + const steerPromise = prompt.steer(['a', 'b']); + prompt.abort('a'); + releaseIntake(); + + await expect(steerPromise).rejects.toMatchObject({ code: 'prompt.not_found' }); + expect(prompt.list().pending.map((item) => item.id)).toEqual(['b']); + }); + + it('keeps bundled skill blocks at the merged message prefix when steering', async () => { + const { prompt, context, loop } = harness(); + const active = await prompt.enqueue({ message: message('active') }); + await active.launched; + const one = await prompt.enqueue({ message: bundledMessage('review', 'user A') }); + const two = await prompt.enqueue({ message: bundledMessage('security', 'user B') }); + + await prompt.steer([one.id, two.id]); + loop.drainNextBatch(context); + + const merged = context + .get() + .find( + (entry) => entry.origin?.kind === 'user' && entry.origin.skillActivations !== undefined, + ); + expect(merged?.content).toEqual([ + { type: 'text', text: 'review' }, + { type: 'text', text: 'security' }, + { type: 'text', text: 'user A' }, + { type: 'text', text: 'user B' }, + ]); + }); + + it('restarts the queue after restoring a steer raced by the active turn settling', async () => { + const { prompt, loop } = harness({ manualTurnResult: true }); + const active = await prompt.enqueue({ message: message('active') }); + await active.launched; + const queued = await prompt.enqueue({ id: 'queued', message: message('queued') }); + let steerEnqueued!: () => void; + const enqueued = new Promise((resolve) => { + steerEnqueued = resolve; + }); + let rejectSteer!: (reason?: unknown) => void; + const original = loop.enqueue.bind(loop); + vi.spyOn(loop, 'enqueue').mockImplementation((request, options) => { + if (request instanceof SteerStepRequest) { + return { + assigned: new Promise((_, reject) => { + rejectSteer = reject; + steerEnqueued(); + }), + abort: () => true, + }; + } + return original(request, options); + }); + + const steerPromise = prompt.steer([queued.id]); + await enqueued; + loop.settleActive(); + rejectSteer(new Error('held')); + + await expect(steerPromise).rejects.toMatchObject({ code: 'prompt.not_found' }); + await expect(queued.launched).resolves.toBeDefined(); + expect(prompt.list().active?.id).toBe('queued'); + }); + + it('does not advance the queue while a steer assignment is in flight', async () => { + const { prompt, loop } = harness({ manualTurnResult: true }); + const active = await prompt.enqueue({ message: message('active') }); + await active.launched; + const a = await prompt.enqueue({ id: 'a', message: message('a') }); + await prompt.enqueue({ id: 'b', message: message('b') }); + let steerEnqueued!: () => void; + const enqueued = new Promise((resolve) => { + steerEnqueued = resolve; + }); + let rejectSteer!: (reason?: unknown) => void; + const original = loop.enqueue.bind(loop); + vi.spyOn(loop, 'enqueue').mockImplementation((request, options) => { + if (request instanceof SteerStepRequest) { + return { + assigned: new Promise((_, reject) => { + rejectSteer = reject; + steerEnqueued(); + }), + abort: () => true, + }; + } + return original(request, options); + }); + + const steerPromise = prompt.steer([a.id]); + await enqueued; + loop.settleActive(); + await new Promise((resolve) => { + setImmediate(resolve); + }); + expect(loop.launches).toHaveLength(1); + rejectSteer(new Error('held')); + + await expect(steerPromise).rejects.toMatchObject({ code: 'prompt.not_found' }); + await expect(a.launched).resolves.toBeDefined(); + expect(prompt.list().active?.id).toBe('a'); + expect(prompt.list().pending.map((item) => item.id)).toEqual(['b']); + }); }); diff --git a/packages/agent-core-v2/test/agent/skill/activateSkill.test.ts b/packages/agent-core-v2/test/agent/skill/activateSkill.test.ts index dc11086fc7..a1b1b9c4d5 100644 --- a/packages/agent-core-v2/test/agent/skill/activateSkill.test.ts +++ b/packages/agent-core-v2/test/agent/skill/activateSkill.test.ts @@ -69,7 +69,9 @@ describe('promptWithSkills', () => { input: [{ type: 'text', text: 'Review this change.' }], skills: [{ name: 'review' }, { name: 'security' }], }); - expect(launched?.turn_id).toBe(0); + expect(launched.turn_id).toBe(0); + expect(launched.prompt_id).toBeTruthy(); + expect(launched.state).toBe('running'); await ctx.untilTurnEnd(); expect(ctx.llmCalls).toHaveLength(1); @@ -178,4 +180,21 @@ describe('promptWithSkills', () => { expect(undone).toBe(1); expect(ctx.context.get()).toHaveLength(0); }); + + it('reserves the bundled prompt id against later prompt_id reuse', async () => { + ctx = agentWithSkills(); + ctx.mockNextResponse({ type: 'text', text: 'done' }); + const launched = await ctx.rpc.promptWithSkills({ + input: [{ type: 'text', text: 'Review this change.' }], + skills: [{ name: 'review' }], + }); + await ctx.untilTurnEnd(); + + await expect( + ctx.rpc.prompt({ + input: [{ type: 'text', text: 'again' }], + promptId: launched.prompt_id, + }), + ).rejects.toThrow(/already in use/i); + }); }); diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index c8e71acbfc..e0ff7ac586 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -84,7 +84,7 @@ interface StopTaskPayload { readonly taskId: string; readonly reason?: string } interface UndoHistoryPayload { readonly count: number } interface UnregisterToolPayload { readonly name: string } import { type UsageStatus } from '#/agent/usage/usage'; -import { IAgentSkillService, type PromptWithSkillsInput, type SkillActivationInput } from '#/agent/skill/skill'; +import { IAgentSkillService, type PromptWithSkillsInput, type PromptWithSkillsResult, type SkillActivationInput } from '#/agent/skill/skill'; import { AgentSkillService } from '#/agent/skill/skillService'; import { IAgentRuntimeBindingSeed } from '#/agent/runtimeBinding/runtimeBinding'; import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; @@ -338,7 +338,7 @@ type RpcPromise = Promise & { interface AgentRpcPassthroughAPI { prompt: (payload: PromptPayload) => Promisable; - promptWithSkills: (payload: PromptWithSkillsInput) => Promisable; + promptWithSkills: (payload: PromptWithSkillsInput) => Promisable; steer: (payload: SteerPayload) => Promisable; cancel: (payload: CancelPayload) => void; undoHistory: (payload: UndoHistoryPayload) => Promisable; diff --git a/packages/kap-server/src/protocol/rest-prompt.ts b/packages/kap-server/src/protocol/rest-prompt.ts index 842fe72a25..96207416c6 100644 --- a/packages/kap-server/src/protocol/rest-prompt.ts +++ b/packages/kap-server/src/protocol/rest-prompt.ts @@ -10,6 +10,12 @@ import { export { promptPermissionModeSchema, promptThinkingSchema }; export type { PromptPermissionMode, PromptThinking } from '@moonshot-ai/agent-core-v2/app/sessionLegacy/sessionProtocol'; +export const promptSkillActivationSchema = z.object({ + name: z.string().min(1), + args: z.string().optional(), +}); +export type PromptSkillActivation = z.infer; + export const promptSubmissionSchema = z.object({ content: z.array(messageContentSchema).min(1), metadata: z.record(z.string(), z.unknown()).optional(), @@ -24,6 +30,7 @@ export const promptSubmissionSchema = z.object({ goal_control: z.enum(['pause', 'resume', 'cancel']).optional(), disabled_tools: z.array(z.string()).optional(), prompt_id: z.string().min(1).optional(), + skills: z.array(promptSkillActivationSchema).min(1).optional(), }); export type PromptSubmission = z.infer; diff --git a/packages/kap-server/src/routes/prompts.ts b/packages/kap-server/src/routes/prompts.ts index db9613e88f..644e7332cb 100644 --- a/packages/kap-server/src/routes/prompts.ts +++ b/packages/kap-server/src/routes/prompts.ts @@ -7,16 +7,21 @@ import { IAgentProfileService, IAgentToolPolicyService, IAgentPromptService, + IAgentSkillService, IAuthSummaryService, + IEventBus, IEventService, IFileService, ISessionMediaStore, ISessionMetadata, + ISessionSkillCatalog, + isUserActivatableSkillType, promptMetadataTextFromContentParts, ProfileError, type PromptHandle, type PromptQueueSnapshot, type PromptReservation, + type PromptWithSkillsResult, reservePrompt, ISessionContext, resumeSessionById, @@ -38,6 +43,7 @@ import { promptSteerResultSchema, promptSubmissionSchema, promptSubmitResultSchema, + type PromptSkillActivation, } from '../protocol/rest-prompt'; import { z } from 'zod'; @@ -103,6 +109,8 @@ async function resolvePromptFromSession(session: ISessionScopeHandle, agentId?: } return { prompt: agent.accessor.get(IAgentPromptService), + skill: agent.accessor.get(IAgentSkillService), + events: agent.accessor.get(IEventBus), auth: agent.accessor.get(IAuthSummaryService), profile: agent.accessor.get(IAgentProfileService), toolPolicy: agent.accessor.get(IAgentToolPolicyService), @@ -110,6 +118,25 @@ async function resolvePromptFromSession(session: ISessionScopeHandle, agentId?: }; } +async function assertActivatableSkills( + catalog: ISessionSkillCatalog, + skills: readonly PromptSkillActivation[], +): Promise { + await catalog.ready; + for (const skill of skills) { + const definition = catalog.catalog.getSkill(skill.name); + if (definition === undefined) { + throw new Error2(ErrorCodes.SKILL_NOT_FOUND, `Skill "${skill.name}" was not found`); + } + if (!isUserActivatableSkillType(definition.metadata.type)) { + throw new Error2( + ErrorCodes.SKILL_TYPE_UNSUPPORTED, + `Skill "${definition.name}" cannot be activated by the user`, + ); + } + } +} + async function applyProfileSelection( profile: IAgentProfileService, profileName: string, @@ -166,6 +193,8 @@ export function registerPromptsRoutes(app: PromptRouteHost, core: Scope): void { success: { data: promptSubmitResultSchema }, errors: { [ErrorCode.VALIDATION_FAILED]: { detailsSchema: validationDetailsSchema }, + [ErrorCode.SKILL_NOT_FOUND]: {}, + [ErrorCode.SKILL_NOT_ACTIVATABLE]: {}, [ErrorCode.AUTH_PROVISIONING_REQUIRED]: {}, [ErrorCode.AUTH_TOKEN_MISSING]: { detailsSchema: authProviderDetailsSchema }, [ErrorCode.AUTH_TOKEN_UNAUTHORIZED]: { detailsSchema: authProviderDetailsSchema }, @@ -186,6 +215,18 @@ export function registerPromptsRoutes(app: PromptRouteHost, core: Scope): void { try { await assertPromptFileRefs(req.body.content, core.accessor.get(IFileService)); const session = await resolveSession(core, session_id); + if (req.body.skills !== undefined) { + if (req.body.prompt_id !== undefined) { + throw new Error2( + ErrorCodes.REQUEST_INVALID, + 'prompt_id cannot be combined with a bundled skill submission', + ); + } + await assertActivatableSkills( + session.accessor.get(ISessionSkillCatalog), + req.body.skills, + ); + } await assertPromptSessionMediaRefs( req.body.content, session.accessor.get(ISessionMediaStore), @@ -240,6 +281,41 @@ export function registerPromptsRoutes(app: PromptRouteHost, core: Scope): void { } } const parts = contentToCoreParts(resolvedContent); + if (req.body.skills !== undefined) { + if (req.body.agent_id !== undefined && req.body.agent_id !== MAIN_AGENT_ID) { + await applyPromptMetadataUpdate({ + metadata: session.accessor.get(ISessionMetadata), + eventService: core.accessor.get(IEventService), + sessionId: session_id, + }, promptMetadataTextFromContentParts(parts)); + } + const settlement = watchPromptSettlements(resolved.events); + let result: PromptWithSkillsResult; + try { + result = await resolved.skill.promptWithSkills({ + input: parts, + skills: req.body.skills, + }); + } catch (error) { + settlement.dispose(); + throw error; + } + enqueued = true; + settlement.settle(result.prompt_id, () => preparedMedia?.discard()); + reply.send( + okEnvelope( + { + prompt_id: result.prompt_id, + user_message_id: result.prompt_id, + status: result.state, + content: projectPromptContentParts(parts), + created_at: result.created_at, + }, + req.id, + ), + ); + return; + } await applyPromptMetadataUpdate({ metadata: session.accessor.get(ISessionMetadata), eventService: core.accessor.get(IEventService), @@ -353,19 +429,72 @@ function projectPromptHandle(handle: PromptHandle) { return projectPromptSnapshot(handle); } -function projectPromptSnapshot(prompt: PromptQueueSnapshot['pending'][number]) { +export function projectPromptSnapshot(prompt: PromptQueueSnapshot['pending'][number]) { const status = prompt.state === 'running' || prompt.state === 'steered' ? 'running' : prompt.state === 'blocked' ? 'blocked' : 'queued'; + const origin = prompt.message.origin; + const bundled = origin?.kind === 'user' ? (origin.skillActivations?.length ?? 0) : 0; + const content = bundled === 0 ? prompt.message.content : prompt.message.content.slice(bundled); return { prompt_id: prompt.id, user_message_id: prompt.userMessageId, status, - content: projectPromptContentParts(prompt.message.content), + content: projectPromptContentParts(content), created_at: prompt.createdAt, }; } +export function watchPromptSettlements(events: IEventBus): { + settle(promptId: string, discard: () => void | Promise): void; + dispose(): void; +} { + const settledIds = new Set(); + const parentOf = new Map(); + let armed: { id: string; discard: () => void | Promise } | undefined; + const subscription = events.subscribe((event) => { + if (event.type === 'prompt.steered') { + const steered = event as { + readonly promptIds?: unknown; + readonly activePromptId?: unknown; + }; + if (Array.isArray(steered.promptIds) && typeof steered.activePromptId === 'string') { + for (const childId of steered.promptIds) { + if (typeof childId === 'string') parentOf.set(childId, steered.activePromptId); + } + if (armed !== undefined && steered.promptIds.includes(armed.id)) { + armed = { id: steered.activePromptId, discard: armed.discard }; + } + } + return; + } + if (event.type !== 'prompt.completed' && event.type !== 'prompt.aborted') return; + const id = (event as { readonly promptId?: unknown }).promptId; + if (typeof id !== 'string') return; + settledIds.add(id); + if (armed !== undefined && armed.id === id) { + const { discard } = armed; + armed = undefined; + subscription.dispose(); + void discard(); + } + }); + return { + settle(promptId: string, discard: () => void | Promise): void { + if (settledIds.has(promptId) || settledIds.has(parentOf.get(promptId) ?? '')) { + subscription.dispose(); + void discard(); + return; + } + armed = { id: promptId, discard }; + }, + dispose(): void { + armed = undefined; + subscription.dispose(); + }, + }; +} + function sendMappedError( reply: { send(payload: unknown): unknown }, req: { id: string }, @@ -404,6 +533,12 @@ function sendMappedError( case 'validation.failed': reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, err.message, requestId, err.stack)); return; + case 'skill.not_found': + reply.send(errEnvelope(ErrorCode.SKILL_NOT_FOUND, err.message, requestId, err.stack)); + return; + case 'skill.type_unsupported': + reply.send(errEnvelope(ErrorCode.SKILL_NOT_ACTIVATABLE, err.message, requestId, err.stack)); + return; case 'auth.provisioning_required': reply.send({ code: ErrorCode.AUTH_PROVISIONING_REQUIRED, diff --git a/packages/kap-server/test/prompts.test.ts b/packages/kap-server/test/prompts.test.ts index 771474cd90..28391f03bb 100644 --- a/packages/kap-server/test/prompts.test.ts +++ b/packages/kap-server/test/prompts.test.ts @@ -7,6 +7,7 @@ import { IAgentTitlePromptSource, IAgentContextMemoryService, IAgentLifecycleService, + IAgentPermissionModeService, IAgentProfileService, IAgentToolPolicyService, IBootstrapService, @@ -19,6 +20,7 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; +import { projectPromptSnapshot, watchPromptSettlements } from '../src/routes/prompts'; import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authHeaders } from './helpers/auth'; @@ -237,6 +239,66 @@ describe('server-v2 /api/v1 prompts', () => { expect(Array.isArray(list.body.data.queued)).toBe(true); }); + it('submits a bundled skill prompt through the skills field', 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: 'Review this change.' }], + skills: [{ name: 'update-config' }, { name: 'check-kimi-code-docs' }], + }); + expect(submitted.body.code).toBe(0); + expect(submitted.body.data.prompt_id).toMatch(/^msg_/); + expect(['running', 'queued']).toContain(submitted.body.data.status); + expect(submitted.body.data.content).toEqual([{ type: 'text', text: 'Review this change.' }]); + + const session = getLiveSessionById(server!.core.accessor, id); + const agent = session!.accessor.get(IAgentLifecycleService).get('main'); + const history = agent!.accessor.get(IAgentContextMemoryService).get(); + const bundled = history.find((message) => message.origin?.kind === 'user'); + expect(bundled?.origin).toMatchObject({ + kind: 'user', + skillActivations: [{ skillName: 'update-config' }, { skillName: 'check-kimi-code-docs' }], + }); + const texts = bundled?.content + .filter((part) => part.type === 'text') + .map((part) => part.text); + expect(texts?.[texts.length - 1]).toBe('Review this change.'); + + const projected = projectPromptSnapshot({ + id: 'msg_1', + userMessageId: 'msg_1', + createdAt: '2026-01-01T00:00:00.000Z', + state: 'running', + message: { + role: 'user', + content: [ + { type: 'text', text: 'rendered skill block' }, + { type: 'text', text: 'Review this change.' }, + ], + toolCalls: [], + origin: { + kind: 'user', + skillActivations: [{ activationId: 'a1', skillName: 'update-config' }], + }, + }, + }); + expect(projected.content).toEqual([{ type: 'text', text: 'Review this change.' }]); + const plain = projectPromptSnapshot({ + id: 'msg_2', + userMessageId: 'msg_2', + createdAt: '2026-01-01T00:00:00.000Z', + state: 'pending', + message: { + role: 'user', + content: [{ type: 'text', text: 'plain question' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }); + expect(plain.content).toEqual([{ type: 'text', text: 'plain question' }]); + }); + it('honors a client-chosen prompt_id on submit', async () => { const id = await createSession(home as string); await createMainAgent(id); @@ -250,6 +312,26 @@ describe('server-v2 /api/v1 prompts', () => { expect(submitted.body.data.user_message_id).toBe('submission-1'); }); + it('updates session metadata for a bundled prompt routed to a non-main agent', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const session = getLiveSessionById(server!.core.accessor, id); + if (session === undefined) throw new Error(`session ${id} not found`); + const child = await session.accessor.get(IAgentLifecycleService).fork('main'); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'bundled side question' }], + agent_id: child.id, + skills: [{ name: 'update-config' }], + }); + expect(submitted.body.code).toBe(0); + + expect((await session.accessor.get(ISessionMetadata).read()).lastPrompt).toBe( + 'bundled side question', + ); + }); + it('rejects a reused prompt_id live and after cold resume without changing metadata', async () => { const id = await createSession(home as string); await createMainAgent(id); @@ -281,6 +363,118 @@ describe('server-v2 /api/v1 prompts', () => { expect((await resumed!.accessor.get(ISessionMetadata).read()).lastPrompt).toBe('first prompt'); }); + it('rejects a bundled submission with an unknown skill and records nothing', 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: 'Review this change.' }], + skills: [{ name: 'does-not-exist' }], + }); + expect(submitted.body.code).toBe(40415); + + const session = getLiveSessionById(server!.core.accessor, id); + const agent = session!.accessor.get(IAgentLifecycleService).get('main'); + const history = agent!.accessor.get(IAgentContextMemoryService).get(); + expect(history.filter((message) => message.origin?.kind === 'user')).toHaveLength(0); + }); + + it('rejects an unknown bundled skill before any control override binds', 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: 'Review this change.' }], + permission_mode: 'yolo', + skills: [{ name: 'does-not-exist' }], + }); + expect(submitted.body.code).toBe(40415); + + const session = getLiveSessionById(server!.core.accessor, id); + const agent = session!.accessor.get(IAgentLifecycleService).get('main'); + expect(agent!.accessor.get(IAgentPermissionModeService).mode).toBe('manual'); + const history = agent!.accessor.get(IAgentContextMemoryService).get(); + expect(history.filter((message) => message.origin?.kind === 'user')).toHaveLength(0); + }); + + it('rejects an unknown bundled skill without materializing the main agent', async () => { + const id = await createSession(home as string); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'Review this change.' }], + skills: [{ name: 'does-not-exist' }], + }); + expect(submitted.body.code).toBe(40415); + + const session = getLiveSessionById(server!.core.accessor, id); + expect(session!.accessor.get(IAgentLifecycleService).get('main')).toBeUndefined(); + }); + + it('rejects a bundled prompt_id combination before any override or agent materialization', async () => { + const id = await createSession(home as string); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'Review this change.' }], + permission_mode: 'yolo', + prompt_id: 'submission-1', + skills: [{ name: 'update-config' }], + }); + expect(submitted.body.code).toBe(40001); + + const session = getLiveSessionById(server!.core.accessor, id); + expect(session!.accessor.get(IAgentLifecycleService).get('main')).toBeUndefined(); + }); + + it('cleans bundled staging through the settlement tracker', async () => { + const handlers: Array<(event: { type: string; promptId?: string; promptIds?: string[]; activePromptId?: string }) => void> = []; + const events = { + subscribe( + handler: (event: { type: string; promptId?: string; promptIds?: string[]; activePromptId?: string }) => void, + ) { + handlers.push(handler); + return { dispose: vi.fn() }; + }, + }; + + const discard = vi.fn(); + const tracker = watchPromptSettlements(events as never); + tracker.settle('msg_1', discard); + handlers[0]!({ type: 'prompt.completed', promptId: 'msg_other' }); + handlers[0]!({ type: 'turn.started' }); + expect(discard).not.toHaveBeenCalled(); + handlers[0]!({ type: 'prompt.completed', promptId: 'msg_1' }); + expect(discard).toHaveBeenCalledTimes(1); + + const blockedDiscard = vi.fn(); + const blockedTracker = watchPromptSettlements(events as never); + handlers[1]!({ type: 'prompt.completed', promptId: 'msg_blocked' }); + blockedTracker.settle('msg_blocked', blockedDiscard); + expect(blockedDiscard).toHaveBeenCalledTimes(1); + + const steered = vi.fn(); + const steeredTracker = watchPromptSettlements(events as never); + steeredTracker.settle('msg_3', steered); + handlers[2]!({ type: 'prompt.steered', promptIds: ['msg_3'], activePromptId: 'msg_parent' }); + expect(steered).not.toHaveBeenCalled(); + handlers[2]!({ type: 'prompt.completed', promptId: 'msg_other' }); + expect(steered).not.toHaveBeenCalled(); + handlers[2]!({ type: 'prompt.completed', promptId: 'msg_parent' }); + expect(steered).toHaveBeenCalledTimes(1); + + const aborted = vi.fn(); + const abortedTracker = watchPromptSettlements(events as never); + abortedTracker.settle('msg_4', aborted); + handlers[3]!({ type: 'prompt.aborted', promptId: 'msg_4' }); + expect(aborted).toHaveBeenCalledTimes(1); + + const rejected = vi.fn(); + const rejectedTracker = watchPromptSettlements(events as never); + rejectedTracker.settle('msg_5', rejected); + rejectedTracker.dispose(); + handlers[4]!({ type: 'prompt.completed', promptId: 'msg_5' }); + expect(rejected).not.toHaveBeenCalled(); + }); + it('makes the first three REST prompts available to title generation', async () => { const id = await createSession(home as string); await createMainAgent(id); diff --git a/packages/klient/src/contract/agent/schemas.ts b/packages/klient/src/contract/agent/schemas.ts index c5301d868f..35cd686b42 100644 --- a/packages/klient/src/contract/agent/schemas.ts +++ b/packages/klient/src/contract/agent/schemas.ts @@ -58,6 +58,14 @@ export const promptWithSkillsPayloadSchema = promptPayloadSchema.extend({ skills: z.array(promptSkillActivationSchema).min(1), }); +/** Same shape as `PromptWithSkillsResult` in the engine. */ +export const promptWithSkillsResultSchema = z.object({ + turn_id: z.number().optional(), + prompt_id: z.string(), + created_at: z.string(), + state: z.enum(['running', 'queued', 'blocked']), +}); + /** Same shape as `SteerPayload` in the engine. */ export const steerPayloadSchema = z.object({ input: z.array(promptPartSchema), diff --git a/packages/klient/src/contract/agent/services.ts b/packages/klient/src/contract/agent/services.ts index 554d94b724..54755a7ab5 100644 --- a/packages/klient/src/contract/agent/services.ts +++ b/packages/klient/src/contract/agent/services.ts @@ -19,6 +19,7 @@ import { promptLaunchResultSchema, promptPayloadSchema, promptWithSkillsPayloadSchema, + promptWithSkillsResultSchema, runShellCommandPayloadSchema, runtimeBindingSchema, setModelResultSchema, @@ -42,7 +43,7 @@ export const agentSkillContract = { activate: { input: z.tuple([activateSkillPayloadSchema]), output: promptLaunchResultSchema }, promptWithSkills: { input: z.tuple([promptWithSkillsPayloadSchema]), - output: maybe(promptLaunchResultSchema), + output: promptWithSkillsResultSchema, }, } satisfies ServiceContract; diff --git a/packages/klient/src/core/facade/agent.ts b/packages/klient/src/core/facade/agent.ts index 14305c8b7d..fa4de5d1f0 100644 --- a/packages/klient/src/core/facade/agent.ts +++ b/packages/klient/src/core/facade/agent.ts @@ -30,6 +30,7 @@ import type { ScopedCaller } from './session.js'; // klient free of protocol-package imports). export type PromptLaunchResult = Awaited>; export type PromptWithSkillsInput = Parameters[0]; +export type PromptWithSkillsResult = Awaited>; export type ShellCommandResult = Awaited>; export type SetModelResult = Awaited>; export type ThinkingLevel = ReturnType; @@ -55,10 +56,11 @@ export interface AgentFacade { * same user message: the skills are validated up front (an unknown name or * an empty list rejects the whole submission), rendered ahead of the * caller's parts in the same turn, and the bundle undoes as a single - * anchor. Resolves with the launched turn id, or `undefined` when the - * submission queued behind a running turn. + * anchor. Resolves with the submitted bundle's queue identity (`prompt_id` + * / `created_at` / `state`), plus `turn_id` once launched — `state` is + * `queued` when the submission queued behind a running turn. */ - promptWithSkills(input: PromptWithSkillsInput): Promise; + promptWithSkills(input: PromptWithSkillsInput): Promise; steer(input: { input: readonly ContentPart[] }): Promise; /** * Activate a skill as a user-slash activation: the engine renders the skill @@ -107,7 +109,7 @@ export function createAgentFacade(call: ScopedCaller, scope: ScopeRef): AgentFac prompt: (input) => call(scope, 'agentPromptService', 'submit', [input]) as Promise, promptWithSkills: (input) => - call(scope, 'agentSkillService', 'promptWithSkills', [input]) as Promise, + call(scope, 'agentSkillService', 'promptWithSkills', [input]) as Promise, steer: (input) => call(scope, 'agentPromptService', 'submitSteer', [input]) as Promise, activateSkill: (input) => diff --git a/packages/klient/src/index.ts b/packages/klient/src/index.ts index 6ea629ac61..07bfff732d 100644 --- a/packages/klient/src/index.ts +++ b/packages/klient/src/index.ts @@ -76,6 +76,7 @@ export type { PlanData, PromptLaunchResult, PromptWithSkillsInput, + PromptWithSkillsResult, SetModelResult, ShellCommandResult, ThinkingLevel, diff --git a/packages/klient/test/contract-parity.ts b/packages/klient/test/contract-parity.ts index 21ebb30eee..601fd28660 100644 --- a/packages/klient/test/contract-parity.ts +++ b/packages/klient/test/contract-parity.ts @@ -170,6 +170,7 @@ import { promptPayloadSchema, promptSkillActivationSchema, promptWithSkillsPayloadSchema, + promptWithSkillsResultSchema, runCommandPayloadSchema, runShellCommandPayloadSchema, runtimeBindingSchema, @@ -589,6 +590,11 @@ const _steerPayload: AssertWireToEngine const _activateSkillPayload: AssertWire = true; const _promptLaunchResult: AssertWire = true; +type PromptWithSkillsResult = Awaited>; +const _promptWithSkillsResult: AssertWire< + typeof promptWithSkillsResultSchema, + PromptWithSkillsResult +> = true; const _cancelPayload: AssertWire = true; const _runShellCommandPayload: AssertWire< typeof runShellCommandPayloadSchema, diff --git a/packages/klient/test/facade.test.ts b/packages/klient/test/facade.test.ts index 5a7e7ff915..26d8ab82f6 100644 --- a/packages/klient/test/facade.test.ts +++ b/packages/klient/test/facade.test.ts @@ -189,13 +189,23 @@ describe('agent skill routing', () => { const klient = createKlientFromChannel(channel); const agent = klient.session('s1').agent('main'); - channel.result = { turn_id: 7 }; + channel.result = { + turn_id: 7, + prompt_id: 'p1', + created_at: '2026-01-01T00:00:00.000Z', + state: 'running', + }; await expect( agent.promptWithSkills({ input: [{ type: 'text', text: 'Review this change.' }], skills: [{ name: 'review' }, { name: 'security', args: 'src/app.ts' }], }), - ).resolves.toEqual({ turn_id: 7 }); + ).resolves.toEqual({ + turn_id: 7, + prompt_id: 'p1', + created_at: '2026-01-01T00:00:00.000Z', + state: 'running', + }); expect(channel.calls[0]).toEqual({ scope: { sessionId: 's1', agentId: 'main' }, service: 'agentSkillService',