@@ -161,7 +161,13 @@ import { createExtensionModelQuery } from "../extensibility/extensions/model-api
161161import type { CompactOptions, ContextUsage } from "../extensibility/extensions/types";
162162import type { CustomCommandContext } from "../extensibility/custom-commands/types";
163163import { SkillDescriptionCatalog } from "../extensibility/skill-descriptions";
164- import type { Skill, SkillDiagnostic, SkillWarning } from "../extensibility/skills";
164+ import {
165+ buildSkillPromptMessage,
166+ parseSkillInvocation,
167+ type Skill,
168+ type SkillDiagnostic,
169+ type SkillWarning,
170+ } from "../extensibility/skills";
165171import { expandSlashCommand, type FileSlashCommand, loadSlashCommands } from "../extensibility/slash-commands";
166172import { normalizeToolEventInput, resolveToolEventInput } from "../extensibility/tool-event-input";
167173import { GoalRuntime } from "../goals/runtime";
@@ -7573,13 +7579,13 @@ export class AgentSession implements SettingsScope {
75737579 throw new AgentBusyError();
75747580 }
75757581
7576- await this.#queueCustomMessage(message, streamingBehavior, {
7582+ const queued = await this.#queueCustomMessage(message, streamingBehavior, {
75777583 queueChipText: options?.queueChipText,
75787584 prependMessages: keywordNotices,
75797585 onPromptAdmitted: options?.onPromptAdmitted,
75807586 });
7581- outcome.sessionClaimed = true ;
7582- return true ;
7587+ outcome.sessionClaimed = queued ;
7588+ return queued ;
75837589 }
75847590
75857591 const customMessage: CustomMessage<T> = {
@@ -7618,14 +7624,14 @@ export class AgentSession implements SettingsScope {
76187624 outcome.sessionClaimed = this.agent.state.isStreaming;
76197625 throw new AgentBusyError();
76207626 }
7621- await this.#queueCustomMessage(message, streamingBehavior, {
7627+ const queued = await this.#queueCustomMessage(message, streamingBehavior, {
76227628 queueChipText: options?.queueChipText,
76237629 preprocessed: { content: preparedMessage.content, descriptionNotice },
76247630 prependMessages: keywordNotices,
76257631 onPromptAdmitted: options?.onPromptAdmitted,
76267632 });
7627- outcome.sessionClaimed = true ;
7628- return true ;
7633+ outcome.sessionClaimed = queued ;
7634+ return queued ;
76297635 }
76307636 outcome.sessionClaimed = await this.#promptWithMessage(preparedMessage, textContent, {
76317637 ...options,
@@ -8575,7 +8581,8 @@ export class AgentSession implements SettingsScope {
85758581 }
85768582 }
85778583
8578- /** Queue a custom message without starting a turn, matching steer/follow-up/aside delivery. */
8584+ /** Queue a custom message without starting a turn, matching steer/follow-up/aside delivery.
8585+ * Resolves false when an abort or session change during image preparation dropped it. */
85798586 async #queueCustomMessage<T = unknown>(
85808587 message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
85818588 deliverAs: "steer" | "followUp" | "aside",
@@ -8588,9 +8595,10 @@ export class AgentSession implements SettingsScope {
85888595 /** Called synchronously once the message is pushed onto its queue. See {@link PromptOptions.onPromptAdmitted}. */
85898596 onPromptAdmitted?: () => void;
85908597 },
8591- ): Promise<void > {
8598+ ): Promise<boolean > {
85928599 // Captured before the normalization await below — see #sessionGeneration's doc comment.
85938600 const sessionGeneration = this.#sessionGeneration;
8601+ const promptGeneration = this.#promptGeneration;
85948602 const details =
85958603 options?.queueChipText !== undefined
85968604 ? ({
@@ -8619,8 +8627,17 @@ export class AgentSession implements SettingsScope {
86198627 const descriptionNotice = preprocessed
86208628 ? preprocessed.descriptionNotice
86218629 : await this.#buildSkillImageDescriptionNotice(normalizedAppMessage);
8630+ // abort() clears the steer/follow-up queues and disposal flushes every queue; a message
8631+ // still being prepared when either lands must not be queued afterwards (as in #queueUserMessage).
8632+ if (
8633+ this.#isDisposed ||
8634+ (deliverAs !== "aside" &&
8635+ (this.#promptGeneration !== promptGeneration || this.#sessionGeneration !== sessionGeneration))
8636+ ) {
8637+ return false;
8638+ }
86228639 if (deliverAs === "aside") {
8623- if (await this.#sessionGenerationChanged(sessionGeneration)) return;
8640+ if (await this.#sessionGenerationChanged(sessionGeneration)) return false ;
86248641 // Non-interrupting: rides the same step-boundary aside poll as
86258642 // sendCustomMessage's streaming aside branch — not an agent-core queue
86268643 // entry, so no drain-retry latch and no idle-queue drain scheduling.
@@ -8635,7 +8652,7 @@ export class AgentSession implements SettingsScope {
86358652 // left to drain it. Resuming here is a no-op while streaming and wakes/folds
86368653 // correctly once idle, matching #queueUserMessage's aside branch.
86378654 this.#resumeStrandedIrcAsides();
8638- return;
8655+ return true ;
86398656 }
86408657 this.#allowQueuedMessageDrainRetry();
86418658 // Keyword notices and their user message must enter the queue in one synchronous phase.
@@ -8650,6 +8667,7 @@ export class AgentSession implements SettingsScope {
86508667 }
86518668 onPromptAdmitted?.();
86528669 this.#scheduleIdleQueueDrain();
8670+ return true;
86538671 }
86548672
86558673 /**
@@ -8861,6 +8879,11 @@ export class AgentSession implements SettingsScope {
88618879 * Omitted `deliverAs` starts a turn when idle and queues as a steer while streaming.
88628880 * Explicit `deliverAs` queues without starting a turn in either state; `aside` at
88638881 * an idle session instead starts a turn, since there is no live run to inject into.
8882+ *
8883+ * `expandPromptTemplates` (default false) expands a registered `/skill:<name>` and
8884+ * prompt templates on every delivery path. Extension, custom and file slash commands
8885+ * run only through prompt() (omitted `deliverAs`, or `aside` at idle); explicit
8886+ * steer/follow-up queueing sends any other `/` text to the model as written.
88648887 */
88658888 async sendUserMessage(
88668889 content: string | (TextContent | ImageContent)[],
@@ -8886,33 +8909,79 @@ export class AgentSession implements SettingsScope {
88868909 if (images.length === 0) images = undefined;
88878910 }
88888911
8912+ const expand = options?.expandPromptTemplates === true;
8913+ const invocation = expand && this.skillsSettings?.enableSkillCommands ? parseSkillInvocation(text) : undefined;
8914+ const skill = invocation && this.skills.find(candidate => candidate.name === invocation.name);
8915+ if (invocation && skill) {
8916+ // Admit before reading SKILL.md so shutdown and settle checks see this send,
8917+ // as they see an option-off send admitted synchronously by prompt().
8918+ await this.#admitSubmission(async () => {
8919+ // abort() or a session change during the read must drop the send, like prompt()'s
8920+ // image preprocessing does, instead of starting a turn the user just stopped.
8921+ const promptGeneration = this.#promptGeneration;
8922+ const sessionGeneration = this.#sessionGeneration;
8923+ const built = await buildSkillPromptMessage(skill, invocation, "user");
8924+ if (
8925+ this.#promptGeneration !== promptGeneration ||
8926+ this.#sessionGeneration !== sessionGeneration ||
8927+ this.#isDisposed
8928+ ) {
8929+ return;
8930+ }
8931+ const skillContent = images ? [{ type: "text" as const, text: built.message }, ...images] : built.message;
8932+ if (options?.attribution === "agent") {
8933+ // The skill custom message is user-invoked by definition; an agent-attributed send
8934+ // stays a plain user-role message, like every other sendUserMessage path.
8935+ await this.sendUserMessage(skillContent, { deliverAs: options.deliverAs, attribution: "agent" });
8936+ return;
8937+ }
8938+ await this.promptCustomMessage(
8939+ {
8940+ customType: SKILL_PROMPT_MESSAGE_TYPE,
8941+ content: skillContent,
8942+ display: true,
8943+ details: built.details,
8944+ attribution: "user",
8945+ },
8946+ {
8947+ streamingBehavior: options?.deliverAs ?? "steer",
8948+ queueOnly: options?.deliverAs === "steer" || options?.deliverAs === "followUp",
8949+ queueChipText: text,
8950+ },
8951+ );
8952+ });
8953+ return;
8954+ }
8955+ const queuedText = expand ? expandPromptTemplate(text, [...this.#promptTemplates]) : text;
8956+ const queueOptions = { attribution: options?.attribution, rawText: text };
8957+
88898958 let deliveredAsAside = false;
88908959 if (options?.deliverAs === "aside") {
88918960 if (this.isStreaming) {
8892- await this.#queueUserMessage(text , images, "aside", { attribution: options.attribution } );
8961+ await this.#queueUserMessage(queuedText , images, "aside", queueOptions );
88938962 return;
88948963 }
88958964 // Idle: fall through to the prompt flow below (starts a turn, like an omitted
88968965 // deliverAs) — there is no live run to inject an aside into.
88978966 deliveredAsAside = true;
88988967 } else if (options?.deliverAs === "followUp") {
8899- await this.#queueUserMessage(text , images, "followUp", { attribution: options.attribution } );
8968+ await this.#queueUserMessage(queuedText , images, "followUp", queueOptions );
89008969 return;
89018970 } else if (options?.deliverAs === "steer") {
8902- await this.#queueUserMessage(text , images, "steer", { attribution: options.attribution } );
8971+ await this.#queueUserMessage(queuedText , images, "steer", queueOptions );
89038972 return;
89048973 }
89058974
8906- // Use prompt() with expandPromptTemplates: false to skip command handling and template
8907- // expansion. prompt() awaits manual-compaction cleanup and (on the non-streaming path)
8975+ // prompt() handles commands and templates only when expandPromptTemplates is set.
8976+ // It awaits manual-compaction cleanup and (on the non-streaming path)
89088977 // image normalization/vision description before dispatching, so a stream can start in
89098978 // that gap; prompt() re-checks isStreaming at each await boundary and queues via
89108979 // `streamingBehavior` when it does. Passing "aside" through (instead of hard-coding
89118980 // "steer") keeps that race from degrading a non-interrupting aside into a
89128981 // tool-batch-aborting steer.
89138982 await this.prompt(text, {
89148983 attribution: options?.attribution,
8915- expandPromptTemplates: false ,
8984+ expandPromptTemplates: expand ,
89168985 images,
89178986 streamingBehavior: deliveredAsAside ? "aside" : "steer",
89188987 });
0 commit comments