diff --git a/.changeset/inline-multi-skill-sdk.md b/.changeset/inline-multi-skill-sdk.md new file mode 100644 index 000000000..f7ebe9a1b --- /dev/null +++ b/.changeset/inline-multi-skill-sdk.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-sdk": minor +--- + +Add `session.promptWithSkills(input, skills)` to submit one prompt with one or more skill activations bundled into the same user message — one turn, one undo unit (v2 engine only; rejects on the v1 engine). diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 39ad2f81d..defdae1a2 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -733,6 +733,14 @@ export interface AgentStateSnapshot { readonly turnId: number; readonly origin: /* PromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ /* UserPromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'user'; + readonly skillActivations?: readonly /* BundledSkillActivation — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly activationId: string; + readonly skillName: string; + readonly skillArgs?: string; + readonly skillType?: string; + readonly skillPath?: string; + readonly skillSource?: 'project' | 'user' | 'extra' | 'builtin'; + }[]; } | /* SkillActivationOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'skill_activation'; readonly activationId: string; @@ -858,6 +866,14 @@ export interface AgentStateSnapshot { turnId: number; origin: /* PromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ /* UserPromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'user'; + readonly skillActivations?: readonly /* BundledSkillActivation — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly activationId: string; + readonly skillName: string; + readonly skillArgs?: string; + readonly skillType?: string; + readonly skillPath?: string; + readonly skillSource?: 'project' | 'user' | 'extra' | 'builtin'; + }[]; } | /* SkillActivationOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'skill_activation'; readonly activationId: string; @@ -915,6 +931,14 @@ export interface AgentStateSnapshot { readonly turnId: number; readonly origin: /* PromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ /* UserPromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'user'; + readonly skillActivations?: readonly /* BundledSkillActivation — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly activationId: string; + readonly skillName: string; + readonly skillArgs?: string; + readonly skillType?: string; + readonly skillPath?: string; + readonly skillSource?: 'project' | 'user' | 'extra' | 'builtin'; + }[]; } | /* SkillActivationOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'skill_activation'; readonly activationId: string; diff --git a/packages/agent-core-v2/src/agent/contextMemory/types.ts b/packages/agent-core-v2/src/agent/contextMemory/types.ts index b21fe3c7a..6907ddc18 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/types.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/types.ts @@ -6,10 +6,20 @@ export type SkillSource = 'project' | 'user' | 'extra' | 'builtin'; export interface UserPromptOrigin { readonly kind: 'user'; + readonly skillActivations?: readonly BundledSkillActivation[]; } export const USER_PROMPT_ORIGIN: UserPromptOrigin = { kind: 'user' }; +export interface BundledSkillActivation { + readonly activationId: string; + readonly skillName: string; + readonly skillArgs?: string; + readonly skillType?: string; + readonly skillPath?: string; + readonly skillSource?: SkillSource; +} + export interface SkillActivationOrigin { readonly kind: 'skill_activation'; readonly activationId: string; diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index 68d3f4a70..ea9ca88e0 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -474,7 +474,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { type: 'turn.started', turnId: job.turn.id, origin, - prompt: isDisplayablePromptOrigin(origin) ? turnPromptText(job.seed.input) : undefined, + prompt: isDisplayablePromptOrigin(origin) ? turnPromptText(job.seed.input, origin) : undefined, }); void this.runTurn(job.turn, job.ready).then(job.result.resolve, job.result.reject); } diff --git a/packages/agent-core-v2/src/agent/loop/turnEvents.ts b/packages/agent-core-v2/src/agent/loop/turnEvents.ts index fed477dd9..50a1d7d15 100644 --- a/packages/agent-core-v2/src/agent/loop/turnEvents.ts +++ b/packages/agent-core-v2/src/agent/loop/turnEvents.ts @@ -9,7 +9,9 @@ * prompt rides the event only for displayable user origins * ({@link isDisplayablePromptOrigin}) — a system-triggered turn (goal * continuation, subagent run, cron…) has internal steering text as its input, - * which must never surface in transcripts. + * which must never surface in transcripts. When the turn's prompt bundles + * skill activations, their rendered blocks (prepended to the content, one + * text part per skill) are excluded from the extracted text. */ import type { KimiErrorPayload } from '#/_base/errors/serialize'; @@ -35,9 +37,14 @@ export interface TurnStartedEvent { readonly prompt?: string; } -export function turnPromptText(input: readonly ContentPart[]): string | undefined { +export function turnPromptText( + input: readonly ContentPart[], + origin?: PromptOrigin, +): string | undefined { + const bundledBlocks = origin?.kind === 'user' ? (origin.skillActivations?.length ?? 0) : 0; const text = input .filter((part): part is TextPart => part.type === 'text') + .slice(bundledBlocks) .map((part) => part.text) .join(''); return text.length > 0 ? text : undefined; diff --git a/packages/agent-core-v2/src/agent/skill/skill.ts b/packages/agent-core-v2/src/agent/skill/skill.ts index d616c0606..3d0076d19 100644 --- a/packages/agent-core-v2/src/agent/skill/skill.ts +++ b/packages/agent-core-v2/src/agent/skill/skill.ts @@ -5,8 +5,12 @@ * edge-resolved attachment parts (`content`) that the activation appends after * the rendered skill prompt in its user message. `IAgentSkillService` * delivers activations (`activate` — steered into the running turn when busy, - * launched as a fresh turn when idle) and records model-tool activations - * without a turn (`recordModelToolActivation`). Bound at Agent scope. + * launched as a fresh turn when idle), submits one prompt with one or more + * skill activations bundled into the same user message (`promptWithSkills` — + * the rendered skill blocks precede the caller's parts in the content and the + * activation metadata rides the prompt's origin, so the bundle is a single + * turn and a single undo unit), and records model-tool activations without a + * turn (`recordModelToolActivation`). Bound at Agent scope. */ import { createDecorator } from "#/_base/di/instantiation"; @@ -20,10 +24,21 @@ export interface SkillActivationInput { readonly content?: readonly ContentPart[]; } +export interface PromptSkillActivation { + readonly name: string; + readonly args?: string; +} + +export interface PromptWithSkillsInput { + readonly input: readonly ContentPart[]; + readonly skills: readonly PromptSkillActivation[]; +} + export interface IAgentSkillService { readonly _serviceBrand: undefined; activate(input: SkillActivationInput): 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 218d51e2a..dea30d290 100644 --- a/packages/agent-core-v2/src/agent/skill/skillService.ts +++ b/packages/agent-core-v2/src/agent/skill/skillService.ts @@ -11,9 +11,16 @@ * message after the rendered prompt). It settles `{turn_id}` for the caller, * persists the derived title/lastPrompt through `sessionMetadata` for the * main agent only (publishing the live update through `event`), and reports - * `skill_invoked` / `flow_invoked` through `telemetry`. `wire.replay` - * reapplies the fact as a no-op, so neither the event nor telemetry fires on - * resume (matching the former `restoring` guard). Bound at Agent scope. + * `skill_invoked` / `flow_invoked` through `telemetry`. `promptWithSkills` + * bundles one or more skill activations into the prompt's own user message: + * the rendered skill blocks precede the caller's parts in the content and + * each activation's metadata rides the prompt origin's `skillActivations`, + * so the bundle launches as a single turn and undoes as a single anchor; + * every skill is validated before anything is recorded, so an invalid name + * or an empty skill list rejects the whole submission. The fact is transient + * (`persist: false`), so neither the event nor telemetry fires on resume — + * bundled activations are rebuilt from the prompt origin instead. Bound at + * Agent scope. */ import { randomUUID } from 'node:crypto'; @@ -22,8 +29,13 @@ import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import type { ContentPart } from '#/kosong/contract/message'; -import type { ContextMessage, SkillActivationOrigin } from '#/agent/contextMemory/types'; +import type { + BundledSkillActivation, + ContextMessage, + SkillActivationOrigin, +} from '#/agent/contextMemory/types'; import { promptMetadataTextFromSkill, renderUserSlashSkillPrompt } from './prompt'; +import { promptMetadataTextFromContentParts } from '#/agent/prompt/promptMetadataText'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { Service } from '#/_base/di/service'; import { ErrorCodes, Error2 } from '#/errors'; @@ -32,7 +44,12 @@ import { IAgentPromptService, type PromptLaunchResult } from '#/agent/prompt/pro import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentLoopService, type Turn } from '#/agent/loop/loop'; import { IWireService } from '#/wire/wire'; -import { IAgentSkillService, type SkillActivationInput } from './skill'; +import { + IAgentSkillService, + type PromptSkillActivation, + type PromptWithSkillsInput, + type SkillActivationInput, +} from './skill'; import { skillActivate } from './skillOps'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { IEventService } from '#/app/event/event'; @@ -106,8 +123,6 @@ export class AgentSkillService extends Service implements IAgentSkillService { 'Cannot activate skill while another turn is active', ); } - // Awaited (not fire-and-forget): the caller gets the launched turn id and - // activation failures (unknown skill, busy) surface instead of vanishing. if (this.scopeContext.agentId === MAIN_AGENT_ID) { await applyPromptMetadataUpdate( { @@ -121,10 +136,102 @@ export class AgentSkillService extends Service implements IAgentSkillService { return { turn_id: turn.id }; } + async promptWithSkills(input: PromptWithSkillsInput): Promise { + if (input.input.length === 0) { + throw new Error2(ErrorCodes.REQUEST_INVALID, 'promptWithSkills requires a non-empty prompt'); + } + if (input.skills.length === 0) { + throw new Error2( + ErrorCodes.REQUEST_INVALID, + 'promptWithSkills requires at least one skill', + ); + } + await this.skillCatalog.ready; + const prepared = input.skills.map((skill) => this.prepareBundled(skill)); + if (this.scopeContext.agentId === MAIN_AGENT_ID) { + await applyPromptMetadataUpdate( + { + metadata: this.metadata, + eventService: this.eventService, + sessionId: this.sessionContext.sessionId, + }, + promptMetadataTextFromContentParts(input.input), + ); + } + for (const activation of prepared) { + void this.recordActivation(activation.origin); + } + const handle = await this.prompt.enqueue({ + message: { + role: 'user', + content: [...prepared.map((activation) => activation.part), ...input.input], + toolCalls: [], + origin: { + 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 }; + } + recordModelToolActivation(origin: SkillActivationOrigin): void { void this.recordActivation(origin); } + private prepareBundled(input: PromptSkillActivation): { + readonly origin: SkillActivationOrigin; + readonly part: ContentPart; + readonly entry: BundledSkillActivation; + } { + const skill = this.skillCatalog.catalog.getSkill(input.name); + if (skill === undefined) { + throw new Error2(ErrorCodes.SKILL_NOT_FOUND, `Skill "${input.name}" was not found`); + } + if (!isUserActivatableSkillType(skill.metadata.type)) { + throw new Error2( + ErrorCodes.SKILL_TYPE_UNSUPPORTED, + `Skill "${skill.name}" cannot be activated by the user`, + ); + } + + const skillArgs = input.args ?? ''; + const skillContent = this.renderSkillPrompt(skill, skillArgs); + const origin: SkillActivationOrigin = { + kind: 'skill_activation', + activationId: randomUUID(), + skillName: skill.name, + trigger: 'user-slash', + skillType: skill.metadata.type, + skillPath: skill.path, + skillSource: skill.source, + skillArgs: input.args, + }; + return { + origin, + part: { + type: 'text', + text: renderUserSlashSkillPrompt({ + skillName: skill.name, + skillArgs, + skillContent, + skillSource: skill.source, + skillDir: skill.dir, + }), + }, + entry: { + activationId: origin.activationId, + skillName: origin.skillName, + skillArgs: origin.skillArgs, + skillType: origin.skillType, + skillPath: origin.skillPath, + skillSource: origin.skillSource, + }, + }; + } + private async recordActivation( origin: SkillActivationOrigin, input?: readonly ContentPart[], diff --git a/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts index 45edbab57..8cfff0803 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts @@ -7,7 +7,9 @@ * `digest` title sources: assistant segments keep only the final natural * language text of the turn (tool calls, thinking, and media parts never * contribute; the shared metadata sanitizer redacts secrets and long - * base64-looking runs). The window may be post-compaction — acceptable for + * base64-looking runs; rendered skill blocks bundled into a prompt's + * content are excluded, so titles reflect the caller's own text). The + * window may be post-compaction — acceptable for * title generation: compaction keeps the head user messages, and a title * derived from the surviving tail is a fine degradation. Bound at Agent * scope. @@ -50,7 +52,7 @@ export class AgentTitlePromptSourceService implements IAgentTitlePromptSource { if (seenMessageIds.has(message.id)) return; seenMessageIds.add(message.id); } - const text = promptMetadataTextFromContentParts(message.content); + const text = promptMetadataTextFromUserMessage(message); if (text !== undefined) result.push(text); }; @@ -62,7 +64,7 @@ export class AgentTitlePromptSourceService implements IAgentTitlePromptSource { const all = this.combinedMessages(); const firstUserIndex = all.findIndex(isNaturalLanguagePrompt); if (firstUserIndex < 0) return {}; - const user = promptMetadataTextFromContentParts(all[firstUserIndex]!.content); + const user = promptMetadataTextFromUserMessage(all[firstUserIndex]!); const span: ContextMessage[] = []; for (const message of all.slice(firstUserIndex + 1)) { if (isNaturalLanguagePrompt(message)) break; @@ -82,10 +84,10 @@ export class AgentTitlePromptSourceService implements IAgentTitlePromptSource { break; } } - const firstUser = promptMetadataTextFromContentParts(all[firstUserIndex]!.content); + const firstUser = promptMetadataTextFromUserMessage(all[firstUserIndex]!); const lastUser = lastUserIndex > firstUserIndex - ? promptMetadataTextFromContentParts(all[lastUserIndex]!.content) + ? promptMetadataTextFromUserMessage(all[lastUserIndex]!) : undefined; const assistant = finalAssistantText(all.slice(lastUserIndex + 1)) ?? @@ -108,6 +110,13 @@ function isNaturalLanguagePrompt(message: ContextMessage): boolean { return origin === undefined || origin.kind === 'user'; } +function promptMetadataTextFromUserMessage(message: ContextMessage): string | undefined { + const bundled = message.origin?.kind === 'user' ? (message.origin.skillActivations?.length ?? 0) : 0; + return promptMetadataTextFromContentParts( + bundled === 0 ? message.content : message.content.slice(bundled), + ); +} + function finalAssistantText(messages: readonly ContextMessage[]): string | undefined { for (let index = messages.length - 1; index >= 0; index--) { const message = messages[index]!; diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts index 4649222fb..134733c7b 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts @@ -218,7 +218,11 @@ function promptMetadataFromTurnRecord(record: WireRecord): string | undefined { } const content = message['content']; if (!Array.isArray(content)) return undefined; - return promptMetadataTextFromContentParts(content as readonly ContentPart[]); + const activations = origin?.['skillActivations']; + const bundled = origin?.['kind'] === 'user' && Array.isArray(activations) ? activations.length : 0; + return promptMetadataTextFromContentParts( + (bundled === 0 ? content : content.slice(bundled)) as readonly ContentPart[], + ); } function slashCommandText(command: string, args: unknown): string { 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 179ff4da4..33bed108b 100644 --- a/packages/agent-core-v2/test/agent/skill/activateSkill.test.ts +++ b/packages/agent-core-v2/test/agent/skill/activateSkill.test.ts @@ -6,6 +6,16 @@ * failures (unknown skill, busy agent) surface to the caller instead of * fire-and-forget. Run: `pnpm --filter @moonshot-ai/agent-core-v2 * exec vitest run test/agent/skill/activateSkill.test.ts`. + * + * Scenario: `IAgentSkillService.promptWithSkills` bundles one or more skill + * activations into the prompt's own user message — the rendered skill blocks + * precede the caller's parts in the content (one text part per skill, in + * order) and every activation's metadata rides the prompt origin's + * `skillActivations`. The bundle launches exactly one turn (one LLM call) + * and undoes as a single anchor; `skill.activated` fires per skill before + * `turn.started`. Unknown skill names, an empty skill list, and an empty + * prompt each reject the whole submission with zero side effects (no LLM + * call, no context, no events). */ import { afterEach, describe, expect, it } from 'vitest'; @@ -37,12 +47,9 @@ describe('activateSkill', () => { ctx.mockNextResponse({ type: 'text', text: 'committed' }); const launched = await ctx.rpc.activateSkill({ name: 'commit', args: '-m fix' }); - // Turn ids are 0-based; the point is the launch result came back at all. expect(launched?.turn_id).toBe(0); await ctx.untilTurnEnd(); - // JSON.stringify escapes the block's attribute quotes — assert on the - // quote-free fragments. const llmInput = JSON.stringify(ctx.llmInputs()); expect(llmInput).toContain('skill-loaded'); expect(llmInput).toContain('# Commit body'); @@ -55,3 +62,140 @@ describe('activateSkill', () => { await expect(ctx.rpc.activateSkill({ name: 'missing' })).rejects.toThrow(/not found/i); }); }); + +describe('promptWithSkills', () => { + let ctx: TestAgentContext; + + afterEach(async () => { + try { + await ctx.expectResumeMatches(); + } finally { + await ctx.dispose(); + } + }); + + function agentWithSkills(): TestAgentContext { + const catalog = new InMemorySkillCatalog(); + catalog.register(stubSkill('review', { content: '# Review body' })); + catalog.register(stubSkill('security', { content: '# Security body' })); + return createTestAgent(skillServices(catalog)); + } + + it('bundles every skill into the prompt message and launches exactly one turn', 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' }, { name: 'security' }], + }); + expect(launched?.turn_id).toBe(0); + await ctx.untilTurnEnd(); + + expect(ctx.llmCalls).toHaveLength(1); + const llmInput = JSON.stringify(ctx.llmInputs()); + expect(llmInput).toContain('# Review body'); + expect(llmInput).toContain('# Security body'); + expect(llmInput).toContain('Review this change.'); + + const messages = ctx.context.get(); + const promptMessage = messages.find((message) => message.origin?.kind === 'user'); + expect(messages.filter((message) => message.origin?.kind === 'skill_activation')).toHaveLength( + 0, + ); + expect(promptMessage?.origin).toMatchObject({ + kind: 'user', + skillActivations: [{ skillName: 'review' }, { skillName: 'security' }], + }); + const texts = promptMessage?.content + .filter((part) => part.type === 'text') + .map((part) => part.text); + expect(texts?.[0]).toContain('# Review body'); + expect(texts?.[1]).toContain('# Security body'); + expect(texts?.[2]).toContain('Review this change.'); + + const events = ctx.allEvents.filter( + (event) => + event.type === '[rpc]' && + (event.event === 'skill.activated' || event.event === 'turn.started'), + ); + expect(events.map((event) => event.event)).toEqual([ + 'skill.activated', + 'skill.activated', + 'turn.started', + ]); + expect( + events + .slice(0, 2) + .map((event) => (event.args as { readonly skillName?: string }).skillName), + ).toEqual(['review', 'security']); + const started = events[2]?.args as { readonly prompt?: string }; + expect(started.prompt).toBe('Review this change.'); + }); + + it('rejects the whole submission when any skill is unknown', async () => { + ctx = agentWithSkills(); + + await expect( + ctx.rpc.promptWithSkills({ + input: [{ type: 'text', text: 'Review this change.' }], + skills: [{ name: 'review' }, { name: 'missing' }], + }), + ).rejects.toThrow(/not found/i); + + expect(ctx.llmCalls).toHaveLength(0); + expect(ctx.context.get()).toHaveLength(0); + expect( + ctx.allEvents.some((event) => event.type === '[rpc]' && event.event === 'skill.activated'), + ).toBe(false); + }); + + it('rejects a grouped submission with an empty prompt message', async () => { + ctx = agentWithSkills(); + + await expect( + ctx.rpc.promptWithSkills({ + input: [], + skills: [{ name: 'review' }], + }), + ).rejects.toThrow(/non-empty prompt/i); + + expect(ctx.llmCalls).toHaveLength(0); + expect(ctx.context.get()).toHaveLength(0); + expect( + ctx.allEvents.some((event) => event.type === '[rpc]' && event.event === 'skill.activated'), + ).toBe(false); + }); + + it('rejects a grouped submission without any skills', async () => { + ctx = agentWithSkills(); + + await expect( + ctx.rpc.promptWithSkills({ + input: [{ type: 'text', text: 'Review this change.' }], + skills: [], + }), + ).rejects.toThrow(/at least one skill/i); + + expect(ctx.llmCalls).toHaveLength(0); + expect(ctx.context.get()).toHaveLength(0); + expect( + ctx.allEvents.some((event) => event.type === '[rpc]' && event.event === 'skill.activated'), + ).toBe(false); + }); + + it('undoes the bundled prompt as a single anchor', async () => { + ctx = agentWithSkills(); + ctx.mockNextResponse({ type: 'text', text: 'done' }); + await ctx.rpc.promptWithSkills({ + input: [{ type: 'text', text: 'Review this change.' }], + skills: [{ name: 'review' }, { name: 'security' }], + }); + await ctx.untilTurnEnd(); + expect(ctx.context.get().length).toBeGreaterThan(0); + + const undone = await ctx.rpc.undoHistory({ count: 1 }); + expect(undone).toBe(1); + expect(ctx.context.get()).toHaveLength(0); + }); +}); diff --git a/packages/agent-core-v2/test/agent/skill/skill.test.ts b/packages/agent-core-v2/test/agent/skill/skill.test.ts index ea44d5b0a..24ec373d8 100644 --- a/packages/agent-core-v2/test/agent/skill/skill.test.ts +++ b/packages/agent-core-v2/test/agent/skill/skill.test.ts @@ -234,6 +234,7 @@ describe('SkillTool', () => { return { _serviceBrand: undefined, activate: () => Promise.reject(new Error('not implemented')), + promptWithSkills: () => Promise.reject(new Error('not implemented')), recordModelToolActivation: () => {}, }; } diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 15741e129..f03f37b63 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -87,7 +87,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 SkillActivationInput } from '#/agent/skill/skill'; +import { IAgentSkillService, type PromptWithSkillsInput, type SkillActivationInput } from '#/agent/skill/skill'; import { AgentSkillService } from '#/agent/skill/skillService'; import { IAgentRuntimeBindingSeed } from '#/agent/runtimeBinding/runtimeBinding'; import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; @@ -335,6 +335,7 @@ type RpcPromise = Promise & { interface AgentRpcPassthroughAPI { prompt: (payload: PromptPayload) => Promisable; + promptWithSkills: (payload: PromptWithSkillsInput) => Promisable; steer: (payload: SteerPayload) => Promisable; cancel: (payload: CancelPayload) => void; undoHistory: (payload: UndoHistoryPayload) => Promisable; @@ -2095,6 +2096,7 @@ export class AgentTestContext { private createRpcPassthroughAdapters(): AgentRpcPassthroughAPI { return { prompt: (payload) => this.get(IAgentPromptService).submit(payload), + promptWithSkills: (payload) => this.get(IAgentSkillService).promptWithSkills(payload), steer: (payload) => this.get(IAgentPromptService).submitSteer(payload), cancel: (payload) => this.get(IAgentLoopService).cancelFromUser(payload.turnId), undoHistory: (payload) => this.get(IAgentConversationUndoService).undo(payload.count), diff --git a/packages/agent-core-v2/test/session/sessionTitle/titleExcerpt.integration.test.ts b/packages/agent-core-v2/test/session/sessionTitle/titleExcerpt.integration.test.ts index bd64ba399..428b6bcf3 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/titleExcerpt.integration.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/titleExcerpt.integration.test.ts @@ -91,4 +91,31 @@ describe('title excerpts over the real context memory', () => { assistant: undefined, }); }); + + it('excludes bundled skill blocks from the excerpt of a bundled prompt', async () => { + const context = ctx.get(IAgentContextMemoryService); + context.append({ + role: 'user', + content: [ + { type: 'text', text: 'User activated the skill "review". Follow the loaded skill instructions.' }, + { type: 'text', text: 'User activated the skill "security". Follow the loaded skill instructions.' }, + { type: 'text', text: '检查这次改动的正确性' }, + ], + toolCalls: [], + origin: { + kind: 'user', + skillActivations: [ + { activationId: 'act-1', skillName: 'review' }, + { activationId: 'act-2', skillName: 'security' }, + ], + }, + }); + + const source = ctx.get(IAgentTitlePromptSource); + await expect(source.firstTurnExcerpt()).resolves.toEqual({ + user: '检查这次改动的正确性', + assistant: undefined, + }); + await expect(source.firstUserPrompts(5)).resolves.toEqual(['检查这次改动的正确性']); + }); }); diff --git a/packages/kap-server/src/protocol/events-zod.ts b/packages/kap-server/src/protocol/events-zod.ts index e5d5bb429..c7aa95d76 100644 --- a/packages/kap-server/src/protocol/events-zod.ts +++ b/packages/kap-server/src/protocol/events-zod.ts @@ -11,6 +11,7 @@ import { z } from 'zod'; import { isoDateTimeSchema } from '@moonshot-ai/agent-core-v2/_base/utils/isoDateTime'; import type { TurnEndReason } from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; import type { + BundledSkillActivation, CompactionSummaryOrigin, CronJobOrigin, CronMissedOrigin, @@ -119,8 +120,18 @@ export const permissionModeSchema = z.enum(['manual', 'yolo', 'auto']) satisfies export const skillSourceSchema = z.enum(['project', 'user', 'extra', 'builtin']) satisfies z.ZodType; +export const bundledSkillActivationSchema = z.object({ + activationId: z.string(), + skillName: z.string(), + skillArgs: z.string().optional(), + skillType: z.string().optional(), + skillPath: z.string().optional(), + skillSource: skillSourceSchema.optional(), +}) satisfies z.ZodType; + export const userPromptOriginSchema = z.object({ kind: z.literal('user'), + skillActivations: z.array(bundledSkillActivationSchema).optional(), }) satisfies z.ZodType; export const skillActivationOriginSchema = z.object({ diff --git a/packages/klient/src/contract/agent/schemas.ts b/packages/klient/src/contract/agent/schemas.ts index 8825f8a02..fabcfcd5c 100644 --- a/packages/klient/src/contract/agent/schemas.ts +++ b/packages/klient/src/contract/agent/schemas.ts @@ -41,6 +41,17 @@ export const promptPayloadSchema = z.object({ input: z.array(promptPartSchema), }); +/** Same shape as `PromptSkillActivation` in the engine. */ +export const promptSkillActivationSchema = z.object({ + name: z.string(), + args: z.string().optional(), +}); + +/** Same shape as `PromptWithSkillsInput` in the engine. */ +export const promptWithSkillsPayloadSchema = promptPayloadSchema.extend({ + skills: z.array(promptSkillActivationSchema).min(1), +}); + /** 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 2d4631de1..554d94b72 100644 --- a/packages/klient/src/contract/agent/services.ts +++ b/packages/klient/src/contract/agent/services.ts @@ -18,6 +18,7 @@ import { planDataSchema, promptLaunchResultSchema, promptPayloadSchema, + promptWithSkillsPayloadSchema, runShellCommandPayloadSchema, runtimeBindingSchema, setModelResultSchema, @@ -39,6 +40,10 @@ export const agentPromptContract = { export const agentSkillContract = { activate: { input: z.tuple([activateSkillPayloadSchema]), output: promptLaunchResultSchema }, + promptWithSkills: { + input: z.tuple([promptWithSkillsPayloadSchema]), + output: maybe(promptLaunchResultSchema), + }, } satisfies ServiceContract; export const agentLoopContract = { diff --git a/packages/klient/src/core/facade/agent.ts b/packages/klient/src/core/facade/agent.ts index 9b7c3caa1..3fdb529b6 100644 --- a/packages/klient/src/core/facade/agent.ts +++ b/packages/klient/src/core/facade/agent.ts @@ -17,6 +17,7 @@ import type { IAgentTokenCountingService } from '@moonshot-ai/agent-core-v2/agen import type { IAgentPlanService } from '@moonshot-ai/agent-core-v2/features/plan/plan'; import type { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile'; import type { IAgentShellCommandService } from '@moonshot-ai/agent-core-v2/agent/shellCommand/shellCommand'; +import type { IAgentSkillService } from '@moonshot-ai/agent-core-v2/agent/skill/skill'; import type { IAgentTaskService } from '@moonshot-ai/agent-core-v2/agent/task/task'; import type { IAgentUsageService } from '@moonshot-ai/agent-core-v2/agent/usage/usage'; import type { ContentPart } from '@moonshot-ai/agent-core-v2/kosong/contract/message'; @@ -28,6 +29,7 @@ import type { ScopedCaller } from './session.js'; // Wire-type aliases derived through the engine service interfaces (keeps // klient free of protocol-package imports). export type PromptLaunchResult = Awaited>; +export type PromptWithSkillsInput = Parameters[0]; export type ShellCommandResult = Awaited>; export type SetModelResult = Awaited>; export type ThinkingLevel = ReturnType; @@ -44,6 +46,15 @@ export type McpServerEntry = ReturnType[number]; export interface AgentFacade { prompt(input: { input: readonly ContentPart[] }): Promise; + /** + * Submit one prompt with one or more skill activations bundled into the + * 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. + */ + promptWithSkills(input: PromptWithSkillsInput): Promise; steer(input: { input: readonly ContentPart[] }): Promise; /** * Activate a skill as a user-slash activation: the engine renders the skill @@ -91,6 +102,8 @@ export function createAgentFacade(call: ScopedCaller, scope: ScopeRef): AgentFac return { prompt: (input) => call(scope, 'agentPromptService', 'submit', [input]) as Promise, + promptWithSkills: (input) => + 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 bd02deee3..40eb1745e 100644 --- a/packages/klient/src/index.ts +++ b/packages/klient/src/index.ts @@ -73,6 +73,7 @@ export type { McpServerEntry, PlanData, PromptLaunchResult, + PromptWithSkillsInput, SetModelResult, ShellCommandResult, ThinkingLevel, diff --git a/packages/klient/test/contract-parity.ts b/packages/klient/test/contract-parity.ts index d5238e7da..1738515e1 100644 --- a/packages/klient/test/contract-parity.ts +++ b/packages/klient/test/contract-parity.ts @@ -164,6 +164,8 @@ import { promptLaunchResultSchema, promptPartSchema, promptPayloadSchema, + promptSkillActivationSchema, + promptWithSkillsPayloadSchema, runCommandPayloadSchema, runShellCommandPayloadSchema, runtimeBindingSchema, @@ -533,6 +535,8 @@ type PromptPayload = Parameters[0]; type PromptLaunchResult = NonNullable>>; type SteerPayload = Parameters[0]; type ActivateSkillPayload = Parameters[0]; +type PromptWithSkillsPayload = Parameters[0]; +type PromptSkillActivation = PromptWithSkillsPayload['skills'][number]; type AgentCommandInfo = ReturnType[number]; type RuntimeBinding = ReturnType; type RunShellCommandPayload = Parameters[0]; @@ -558,6 +562,16 @@ const _promptPart: AssertWire = true; // the full `ContentPart` union (also think/audio parts); the wire mirrors the // `PromptPart` subset clients may send, so the reverse direction fails. const _promptPayload: AssertWireToEngine = true; +const _promptSkillActivation: AssertWire< + typeof promptSkillActivationSchema, + PromptSkillActivation +> = true; +// Same one-directional rule as `promptPayload`: the engine's `input` accepts +// the full `ContentPart` union; the wire mirrors the `PromptPart` subset. +const _promptWithSkillsPayload: AssertWireToEngine< + typeof promptWithSkillsPayloadSchema, + PromptWithSkillsPayload +> = true; const _steerPayload: AssertWireToEngine = true; const _activateSkillPayload: AssertWire = true; diff --git a/packages/klient/test/facade.test.ts b/packages/klient/test/facade.test.ts index 24036a593..21b0b21fd 100644 --- a/packages/klient/test/facade.test.ts +++ b/packages/klient/test/facade.test.ts @@ -183,6 +183,33 @@ describe('agent profile routing', () => { }); }); +describe('agent skill routing', () => { + it('promptWithSkills routes to agentSkillService.promptWithSkills with the agent scope', async () => { + const channel = new FakeChannel(); + const klient = createKlientFromChannel(channel); + const agent = klient.session('s1').agent('main'); + + channel.result = { turn_id: 7 }; + 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 }); + expect(channel.calls[0]).toEqual({ + scope: { sessionId: 's1', agentId: 'main' }, + service: 'agentSkillService', + method: 'promptWithSkills', + args: [ + { + input: [{ type: 'text', text: 'Review this change.' }], + skills: [{ name: 'review' }, { name: 'security', args: 'src/app.ts' }], + }, + ], + }); + }); +}); + describe('session skills routing', () => { it('skills.list routes to sessionSkillCatalog.list with the session scope', async () => { const channel = new FakeChannel(); diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index d3a8c52c6..8f122317a 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -57,6 +57,7 @@ import type { SessionStatus, SessionUsage, PromptInput, + PromptSkillActivation, RenameSessionInput, ResumeSessionInput, ResumedSessionSummary, @@ -75,6 +76,10 @@ export interface SessionPromptRpcInput { readonly input: PromptInput; } +export interface SessionPromptWithSkillsRpcInput extends SessionPromptRpcInput { + readonly skills: readonly PromptSkillActivation[]; +} + export interface SessionIdRpcInput { readonly sessionId: string; } @@ -401,6 +406,19 @@ export abstract class SDKRpcClientBase { }); } + /** + * Grouped skill activation + prompt submission. Only the v2 engine + * (`SDKRpcClientV2`) implements it; the v1 route has no combined-submission + * RPC, so the base fails loudly instead of degrading into N+1 turns. + */ + async promptWithSkills(input: SessionPromptWithSkillsRpcInput): Promise { + void input; + throw new KimiError( + ErrorCodes.NOT_IMPLEMENTED, + 'promptWithSkills requires the agent-core-v2 engine.', + ); + } + async runShellCommand(input: { sessionId: string; command: string; diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 847420f39..7a1539d31 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -241,6 +241,7 @@ import { type SessionIdRpcInput, type SwitchSessionRuntimeRpcInput, type SessionPromptRpcInput, + type SessionPromptWithSkillsRpcInput, type SetSessionModelRpcInput, type SetSessionModelRpcResult, type SetSessionPermissionRpcInput, @@ -1819,6 +1820,21 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { await agent.prompt({ input: input.input }); } + /** + * Facade (`agentSkillService.promptWithSkills`) — bundled skill submission: + * the engine renders every skill activation into the prompt's own user + * message, so the bundle launches as one turn and undoes as a single + * anchor. v2-only: the base class rejects this method on the v1 engine. + * The launch result is dropped like `prompt` (v1's RPC shape returns void). + */ + override async promptWithSkills(input: SessionPromptWithSkillsRpcInput): Promise { + const agent = await this.agentFacade(input.sessionId); + await agent.promptWithSkills({ + input: input.input, + skills: input.skills, + }); + } + /** * Facade (`agentPromptService.submitSteer`). Matches v1 on both paths: mid-turn * steers join the running turn, and an idle-session steer degrades to diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index 3c0e0371f..765809265 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -27,6 +27,7 @@ import type { PluginInfo, PluginSummary, PromptInput, + PromptSkillActivation, ReloadSessionOptions, ReloadSummary, ResumedSessionState, @@ -143,6 +144,25 @@ export class Session { }); } + /** + * Submit one prompt with one or more skill activations bundled into the + * same user message: the skills are validated up front (an unknown name + * rejects the whole submission), rendered ahead of the prompt in the same + * turn, and the bundle undoes as a single anchor. Requires the + * agent-core-v2 engine. + */ + async promptWithSkills( + input: string | PromptInput, + skills: readonly PromptSkillActivation[], + ): Promise { + this.ensureOpen(); + await this.rpc.promptWithSkills({ + sessionId: this.id, + input: normalizePromptInput(input), + skills, + }); + } + /** Execute a user-initiated `!` shell command (silent — does not prompt the * model). Resolves with the command's stdout/stderr for immediate display. * Pass `commandId` to receive live `shell.output` events for this command. */ diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index e91783b35..510b6e93d 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -116,6 +116,11 @@ export type PromptPart = Extract void { + const saved: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined && CONFIG_ENV_PATTERN.test(key)) { + saved[key] = value; + delete process.env[key]; + } + } + return () => { + for (const [key, value] of Object.entries(saved)) { + process.env[key] = value; + } + }; +} + beforeEach(() => { fakeProviderState.histories.length = 0; fakeProviderState.responseText = 'skill response'; @@ -80,6 +100,74 @@ afterEach(async () => { }); describe('Session skills', () => { + it('submits multiple skills with a prompt as one grouped turn (v2 engine)', async () => { + const restoreEnv = scrubConfigEnv(); + const homeDir = await makeTempDir(tempDirs, 'kimi-sdk-skills-home-'); + const workDir = await makeTempDir(tempDirs, 'kimi-sdk-skills-work-'); + await writeSkill(workDir, 'review', [ + '---', + 'name: review', + 'description: Review code', + '---', + '', + 'Review the requested file.', + ]); + await writeSkill(workDir, 'security', [ + '---', + 'name: security', + 'description: Check security', + '---', + '', + 'Check the requested file for security issues.', + ]); + const harness = createKimiHarnessV2({ homeDir, identity: TEST_IDENTITY }); + + try { + const session = await harness.createSession({ id: 'ses_sdk_multi_skill', workDir }); + const events: Event[] = []; + const unsubscribe = session.onEvent((event) => { + events.push(event); + }); + // Model-less on purpose: the grouped surface (activation events, single + // turn) settles before the provider-less turn fails asynchronously. + const ended = waitForSDKEvent(session, (event) => event.type === 'turn.ended'); + + await session.promptWithSkills( + 'Review this change.', + [{ name: 'review' }, { name: 'security' }], + ); + await ended; + unsubscribe(); + + const activations = events.filter( + (event): event is Extract => + event.type === 'skill.activated', + ); + expect(activations.map((event) => event.skillName)).toEqual(['review', 'security']); + expect(events.filter((event) => event.type === 'turn.started')).toHaveLength(1); + } finally { + await harness.close(); + restoreEnv(); + } + }); + + it('rejects promptWithSkills on the v1 engine', async () => { + const homeDir = await makeTempDir(tempDirs, 'kimi-sdk-skills-home-'); + const workDir = await makeTempDir(tempDirs, 'kimi-sdk-skills-work-'); + const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY }); + + try { + const session = await harness.createSession({ id: 'ses_sdk_multi_skill_v1', workDir }); + await expect( + session.promptWithSkills('Review this change.', [{ name: 'review' }]), + ).rejects.toMatchObject({ + code: 'not_implemented', + }); + } finally { + await harness.close(); + } + }); + it('lists session skills without exposing content', async () => { const homeDir = await makeTempDir(tempDirs, 'kimi-sdk-skills-home-'); const workDir = await makeTempDir(tempDirs, 'kimi-sdk-skills-work-'); diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index e466bcd98..76caa3ef9 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -45,6 +45,22 @@ export type SkillSource = 'project' | 'user' | 'extra' | 'builtin'; export interface UserPromptOrigin { readonly kind: 'user'; + /** + * Skill activations bundled into this prompt: the rendered skill blocks + * precede the caller's parts in the message content, and every activation + * is listed here so resume / replay can rebuild the per-skill view from + * the single bundled message. + */ + readonly skillActivations?: readonly BundledSkillActivation[]; +} + +export interface BundledSkillActivation { + readonly activationId: string; + readonly skillName: string; + readonly skillArgs?: string; + readonly skillType?: string; + readonly skillPath?: string; + readonly skillSource?: SkillSource; } export interface SkillActivationOrigin { @@ -1042,8 +1058,18 @@ export const permissionModeSchema = z.enum(['manual', 'yolo', 'auto']) satisfies export const skillSourceSchema = z.enum(['project', 'user', 'extra', 'builtin']) satisfies z.ZodType; +export const bundledSkillActivationSchema = z.object({ + activationId: z.string(), + skillName: z.string(), + skillArgs: z.string().optional(), + skillType: z.string().optional(), + skillPath: z.string().optional(), + skillSource: skillSourceSchema.optional(), +}) satisfies z.ZodType; + export const userPromptOriginSchema = z.object({ kind: z.literal('user'), + skillActivations: z.array(bundledSkillActivationSchema).optional(), }) satisfies z.ZodType; export const skillActivationOriginSchema = z.object({ diff --git a/packages/transcript/src/history/groupTurns.ts b/packages/transcript/src/history/groupTurns.ts index bc9c6afd8..c675186f2 100644 --- a/packages/transcript/src/history/groupTurns.ts +++ b/packages/transcript/src/history/groupTurns.ts @@ -200,6 +200,25 @@ export function groupMessagesIntoSnapshot( } continue; } + const bundled = bundledSkillActivations(message); + if (bundled.length > 0) { + // The v2 engine bundles a prompt's inline skill activations into the + // prompt message itself: one rendered text part per skill precedes + // the caller's parts in the content, and the origin carries every + // activation. Expand the persisted bundle back into per-skill markers + // so a cold rebuild shows the same cards the live events produced. + const parts = message.content ?? []; + bundled.forEach((activation, index) => { + const block = parts[index]; + pushMarker('skill', { + text: block !== undefined && block.type === 'text' && 'text' in block ? block.text : '', + origin: { kind: 'skill_activation', trigger: 'user-slash', ...activation }, + }); + }); + const callerMessage = { ...message, content: parts.slice(bundled.length) }; + startTurn(mapOrigin(message), textOf(callerMessage), collectAttachments(callerMessage)); + continue; + } startTurn(mapOrigin(message), textOf(message), collectAttachments(message)); continue; } @@ -322,6 +341,28 @@ function mapOrigin(message: HistoryMessage): TurnOrigin { } } +interface BundledSkillActivation { + readonly activationId: string; + readonly skillName: string; + readonly skillArgs?: string; + readonly skillType?: string; + readonly skillPath?: string; + readonly skillSource?: string; +} + +function bundledSkillActivations(message: HistoryMessage): readonly BundledSkillActivation[] { + if (message.origin?.kind !== 'user') return []; + const activations = (message.origin as { readonly skillActivations?: unknown }).skillActivations; + if (!Array.isArray(activations)) return []; + return activations.filter( + (activation): activation is BundledSkillActivation => + typeof activation === 'object' && + activation !== null && + typeof (activation as { activationId?: unknown }).activationId === 'string' && + typeof (activation as { skillName?: unknown }).skillName === 'string', + ); +} + function textOf(message: HistoryMessage): string { return (message.content ?? []) .filter((part): part is { readonly type: 'text'; readonly text: string } => part.type === 'text' && 'text' in part) diff --git a/packages/transcript/test/layers.test.ts b/packages/transcript/test/layers.test.ts index d6108f523..5726401c4 100644 --- a/packages/transcript/test/layers.test.ts +++ b/packages/transcript/test/layers.test.ts @@ -448,6 +448,45 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { expect(marker?.kind === 'marker' && marker.marker).toBe('compaction'); }); + it('expands a bundled prompt into per-skill markers and a caller-text turn', () => { + const snapshot = groupMessagesIntoSnapshot([ + { + role: 'user', + content: [ + { type: 'text', text: 'rendered review block' }, + { type: 'text', text: 'rendered security block' }, + { type: 'text', text: 'please /skill:review and /skill:security' }, + ], + toolCalls: [], + origin: { + kind: 'user', + skillActivations: [ + { activationId: 'act-1', skillName: 'review' }, + { activationId: 'act-2', skillName: 'security', skillArgs: 'src/app.ts' }, + ], + } as { kind: string }, + }, + { role: 'assistant', content: [{ type: 'text', text: 'done' }], toolCalls: [] }, + ]); + + expect(snapshot.items.map((item) => item.kind)).toEqual(['marker', 'marker', 'turn']); + const first = snapshot.items[0]; + expect(first?.kind === 'marker' && first.marker).toBe('skill'); + expect(first?.kind === 'marker' && first.payload).toMatchObject({ + text: 'rendered review block', + origin: { kind: 'skill_activation', trigger: 'user-slash', skillName: 'review' }, + }); + const second = snapshot.items[1]; + expect(second?.kind === 'marker' && second.payload).toMatchObject({ + text: 'rendered security block', + origin: { skillName: 'security', skillArgs: 'src/app.ts' }, + }); + const turn = snapshot.items[2]; + if (turn?.kind !== 'turn') throw new Error('expected turn'); + expect(turn.prompt).toBe('please /skill:review and /skill:security'); + expect(turn.steps).toHaveLength(1); + }); + it('maps media parts on the opening user message to attachment entities, dropping base64 bytes', () => { const snapshot = groupMessagesIntoSnapshot([ {