@@ -163,7 +163,13 @@ import { createExtensionModelQuery } from "../extensibility/extensions/model-api
163163import type { CompactOptions, ContextUsage } from "../extensibility/extensions/types";
164164import type { CustomCommandContext } from "../extensibility/custom-commands/types";
165165import { SkillDescriptionCatalog } from "../extensibility/skill-descriptions";
166- import type { Skill, SkillDiagnostic, SkillWarning } from "../extensibility/skills";
166+ import {
167+ buildSkillPromptMessage,
168+ parseSkillInvocation,
169+ type Skill,
170+ type SkillDiagnostic,
171+ type SkillWarning,
172+ } from "../extensibility/skills";
167173import { expandSlashCommand, type FileSlashCommand, loadSlashCommands } from "../extensibility/slash-commands";
168174import { normalizeToolEventInput, resolveToolEventInput } from "../extensibility/tool-event-input";
169175import { GoalRuntime } from "../goals/runtime";
@@ -7715,13 +7721,13 @@ export class AgentSession implements SettingsScope {
77157721 throw new AgentBusyError();
77167722 }
77177723
7718- await this.#queueCustomMessage(message, streamingBehavior, {
7724+ const queued = await this.#queueCustomMessage(message, streamingBehavior, {
77197725 queueChipText: options?.queueChipText,
77207726 prependMessages: keywordNotices,
77217727 onPromptAdmitted: options?.onPromptAdmitted,
77227728 });
7723- outcome.sessionClaimed = true ;
7724- return true ;
7729+ outcome.sessionClaimed = queued ;
7730+ return queued ;
77257731 }
77267732
77277733 const customMessage: CustomMessage<T> = {
@@ -7760,14 +7766,14 @@ export class AgentSession implements SettingsScope {
77607766 outcome.sessionClaimed = this.agent.state.isStreaming;
77617767 throw new AgentBusyError();
77627768 }
7763- await this.#queueCustomMessage(message, streamingBehavior, {
7769+ const queued = await this.#queueCustomMessage(message, streamingBehavior, {
77647770 queueChipText: options?.queueChipText,
77657771 preprocessed: { content: preparedMessage.content, descriptionNotice },
77667772 prependMessages: keywordNotices,
77677773 onPromptAdmitted: options?.onPromptAdmitted,
77687774 });
7769- outcome.sessionClaimed = true ;
7770- return true ;
7775+ outcome.sessionClaimed = queued ;
7776+ return queued ;
77717777 }
77727778 outcome.sessionClaimed = await this.#promptWithMessage(preparedMessage, textContent, {
77737779 ...options,
@@ -8746,7 +8752,8 @@ export class AgentSession implements SettingsScope {
87468752 }
87478753 }
87488754
8749- /** Queue a custom message without starting a turn, matching steer/follow-up/aside delivery. */
8755+ /** Queue a custom message without starting a turn, matching steer/follow-up/aside delivery.
8756+ * Resolves false when an abort or session change during image preparation dropped it. */
87508757 async #queueCustomMessage<T = unknown>(
87518758 message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
87528759 deliverAs: "steer" | "followUp" | "aside",
@@ -8759,9 +8766,10 @@ export class AgentSession implements SettingsScope {
87598766 /** Called synchronously once the message is pushed onto its queue. See {@link PromptOptions.onPromptAdmitted}. */
87608767 onPromptAdmitted?: () => void;
87618768 },
8762- ): Promise<void > {
8769+ ): Promise<boolean > {
87638770 // Captured before the normalization await below — see #sessionGeneration's doc comment.
87648771 const sessionGeneration = this.#sessionGeneration;
8772+ const promptGeneration = this.#promptGeneration;
87658773 const details =
87668774 options?.queueChipText !== undefined
87678775 ? ({
@@ -8790,8 +8798,17 @@ export class AgentSession implements SettingsScope {
87908798 const descriptionNotice = preprocessed
87918799 ? preprocessed.descriptionNotice
87928800 : await this.#buildSkillImageDescriptionNotice(normalizedAppMessage);
8801+ // abort() clears the steer/follow-up queues and disposal flushes every queue; a message
8802+ // still being prepared when either lands must not be queued afterwards (as in #queueUserMessage).
8803+ if (
8804+ this.#isDisposed ||
8805+ (deliverAs !== "aside" &&
8806+ (this.#promptGeneration !== promptGeneration || this.#sessionGeneration !== sessionGeneration))
8807+ ) {
8808+ return false;
8809+ }
87938810 if (deliverAs === "aside") {
8794- if (await this.#sessionGenerationChanged(sessionGeneration)) return;
8811+ if (await this.#sessionGenerationChanged(sessionGeneration)) return false ;
87958812 // Non-interrupting: rides the same step-boundary aside poll as
87968813 // sendCustomMessage's streaming aside branch — not an agent-core queue
87978814 // entry, so no drain-retry latch and no idle-queue drain scheduling.
@@ -8806,7 +8823,7 @@ export class AgentSession implements SettingsScope {
88068823 // left to drain it. Resuming here is a no-op while streaming and wakes/folds
88078824 // correctly once idle, matching #queueUserMessage's aside branch.
88088825 this.#resumeStrandedIrcAsides();
8809- return;
8826+ return true ;
88108827 }
88118828 this.#allowQueuedMessageDrainRetry();
88128829 // Keyword notices and their user message must enter the queue in one synchronous phase.
@@ -8821,6 +8838,7 @@ export class AgentSession implements SettingsScope {
88218838 }
88228839 onPromptAdmitted?.();
88238840 this.#scheduleIdleQueueDrain();
8841+ return true;
88248842 }
88258843
88268844 /**
@@ -9032,6 +9050,11 @@ export class AgentSession implements SettingsScope {
90329050 * Omitted `deliverAs` starts a turn when idle and queues as a steer while streaming.
90339051 * Explicit `deliverAs` queues without starting a turn in either state; `aside` at
90349052 * an idle session instead starts a turn, since there is no live run to inject into.
9053+ *
9054+ * `expandPromptTemplates` (default false) expands a registered `/skill:<name>` and
9055+ * prompt templates on every delivery path. Extension, custom and file slash commands
9056+ * run only through prompt() (omitted `deliverAs`, or `aside` at idle); explicit
9057+ * steer/follow-up queueing sends any other `/` text to the model as written.
90359058 */
90369059 async sendUserMessage(
90379060 content: string | (TextContent | ImageContent)[],
@@ -9057,33 +9080,79 @@ export class AgentSession implements SettingsScope {
90579080 if (images.length === 0) images = undefined;
90589081 }
90599082
9083+ const expand = options?.expandPromptTemplates === true;
9084+ const invocation = expand && this.skillsSettings?.enableSkillCommands ? parseSkillInvocation(text) : undefined;
9085+ const skill = invocation && this.skills.find(candidate => candidate.name === invocation.name);
9086+ if (invocation && skill) {
9087+ // Admit before reading SKILL.md so shutdown and settle checks see this send,
9088+ // as they see an option-off send admitted synchronously by prompt().
9089+ await this.#admitSubmission(async () => {
9090+ // abort() or a session change during the read must drop the send, like prompt()'s
9091+ // image preprocessing does, instead of starting a turn the user just stopped.
9092+ const promptGeneration = this.#promptGeneration;
9093+ const sessionGeneration = this.#sessionGeneration;
9094+ const built = await buildSkillPromptMessage(skill, invocation, "user");
9095+ if (
9096+ this.#promptGeneration !== promptGeneration ||
9097+ this.#sessionGeneration !== sessionGeneration ||
9098+ this.#isDisposed
9099+ ) {
9100+ return;
9101+ }
9102+ const skillContent = images ? [{ type: "text" as const, text: built.message }, ...images] : built.message;
9103+ if (options?.attribution === "agent") {
9104+ // The skill custom message is user-invoked by definition; an agent-attributed send
9105+ // stays a plain user-role message, like every other sendUserMessage path.
9106+ await this.sendUserMessage(skillContent, { deliverAs: options.deliverAs, attribution: "agent" });
9107+ return;
9108+ }
9109+ await this.promptCustomMessage(
9110+ {
9111+ customType: SKILL_PROMPT_MESSAGE_TYPE,
9112+ content: skillContent,
9113+ display: true,
9114+ details: built.details,
9115+ attribution: "user",
9116+ },
9117+ {
9118+ streamingBehavior: options?.deliverAs ?? "steer",
9119+ queueOnly: options?.deliverAs === "steer" || options?.deliverAs === "followUp",
9120+ queueChipText: text,
9121+ },
9122+ );
9123+ });
9124+ return;
9125+ }
9126+ const queuedText = expand ? expandPromptTemplate(text, [...this.#promptTemplates]) : text;
9127+ const queueOptions = { attribution: options?.attribution, rawText: text };
9128+
90609129 let deliveredAsAside = false;
90619130 if (options?.deliverAs === "aside") {
90629131 if (this.isStreaming) {
9063- await this.#queueUserMessage(text , images, "aside", { attribution: options.attribution } );
9132+ await this.#queueUserMessage(queuedText , images, "aside", queueOptions );
90649133 return;
90659134 }
90669135 // Idle: fall through to the prompt flow below (starts a turn, like an omitted
90679136 // deliverAs) — there is no live run to inject an aside into.
90689137 deliveredAsAside = true;
90699138 } else if (options?.deliverAs === "followUp") {
9070- await this.#queueUserMessage(text , images, "followUp", { attribution: options.attribution } );
9139+ await this.#queueUserMessage(queuedText , images, "followUp", queueOptions );
90719140 return;
90729141 } else if (options?.deliverAs === "steer") {
9073- await this.#queueUserMessage(text , images, "steer", { attribution: options.attribution } );
9142+ await this.#queueUserMessage(queuedText , images, "steer", queueOptions );
90749143 return;
90759144 }
90769145
9077- // Use prompt() with expandPromptTemplates: false to skip command handling and template
9078- // expansion. prompt() awaits manual-compaction cleanup and (on the non-streaming path)
9146+ // prompt() handles commands and templates only when expandPromptTemplates is set.
9147+ // It awaits manual-compaction cleanup and (on the non-streaming path)
90799148 // image normalization/vision description before dispatching, so a stream can start in
90809149 // that gap; prompt() re-checks isStreaming at each await boundary and queues via
90819150 // `streamingBehavior` when it does. Passing "aside" through (instead of hard-coding
90829151 // "steer") keeps that race from degrading a non-interrupting aside into a
90839152 // tool-batch-aborting steer.
90849153 await this.prompt(text, {
90859154 attribution: options?.attribution,
9086- expandPromptTemplates: false ,
9155+ expandPromptTemplates: expand ,
90879156 images,
90889157 streamingBehavior: deliveredAsAside ? "aside" : "steer",
90899158 });
0 commit comments