@@ -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,
@@ -8745,7 +8751,8 @@ export class AgentSession implements SettingsScope {
87458751 }
87468752 }
87478753
8748- /** Queue a custom message without starting a turn, matching steer/follow-up/aside delivery. */
8754+ /** Queue a custom message without starting a turn, matching steer/follow-up/aside delivery.
8755+ * Resolves false when an abort or session change during image preparation dropped it. */
87498756 async #queueCustomMessage<T = unknown>(
87508757 message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
87518758 deliverAs: "steer" | "followUp" | "aside",
@@ -8758,9 +8765,10 @@ export class AgentSession implements SettingsScope {
87588765 /** Called synchronously once the message is pushed onto its queue. See {@link PromptOptions.onPromptAdmitted}. */
87598766 onPromptAdmitted?: () => void;
87608767 },
8761- ): Promise<void > {
8768+ ): Promise<boolean > {
87628769 // Captured before the normalization await below — see #sessionGeneration's doc comment.
87638770 const sessionGeneration = this.#sessionGeneration;
8771+ const promptGeneration = this.#promptGeneration;
87648772 const details =
87658773 options?.queueChipText !== undefined
87668774 ? ({
@@ -8789,8 +8797,17 @@ export class AgentSession implements SettingsScope {
87898797 const descriptionNotice = preprocessed
87908798 ? preprocessed.descriptionNotice
87918799 : await this.#buildSkillImageDescriptionNotice(normalizedAppMessage);
8800+ // abort() clears the steer/follow-up queues and disposal flushes every queue; a message
8801+ // still being prepared when either lands must not be queued afterwards (as in #queueUserMessage).
8802+ if (
8803+ this.#isDisposed ||
8804+ (deliverAs !== "aside" &&
8805+ (this.#promptGeneration !== promptGeneration || this.#sessionGeneration !== sessionGeneration))
8806+ ) {
8807+ return false;
8808+ }
87928809 if (deliverAs === "aside") {
8793- if (await this.#sessionGenerationChanged(sessionGeneration)) return;
8810+ if (await this.#sessionGenerationChanged(sessionGeneration)) return false ;
87948811 // Non-interrupting: rides the same step-boundary aside poll as
87958812 // sendCustomMessage's streaming aside branch — not an agent-core queue
87968813 // entry, so no drain-retry latch and no idle-queue drain scheduling.
@@ -8805,7 +8822,7 @@ export class AgentSession implements SettingsScope {
88058822 // left to drain it. Resuming here is a no-op while streaming and wakes/folds
88068823 // correctly once idle, matching #queueUserMessage's aside branch.
88078824 this.#resumeStrandedIrcAsides();
8808- return;
8825+ return true ;
88098826 }
88108827 this.#allowQueuedMessageDrainRetry();
88118828 // Keyword notices and their user message must enter the queue in one synchronous phase.
@@ -8820,6 +8837,7 @@ export class AgentSession implements SettingsScope {
88208837 }
88218838 onPromptAdmitted?.();
88228839 this.#scheduleIdleQueueDrain();
8840+ return true;
88238841 }
88248842
88258843 /**
@@ -9031,6 +9049,11 @@ export class AgentSession implements SettingsScope {
90319049 * Omitted `deliverAs` starts a turn when idle and queues as a steer while streaming.
90329050 * Explicit `deliverAs` queues without starting a turn in either state; `aside` at
90339051 * an idle session instead starts a turn, since there is no live run to inject into.
9052+ *
9053+ * `expandPromptTemplates` (default false) expands a registered `/skill:<name>` and
9054+ * prompt templates on every delivery path. Extension, custom and file slash commands
9055+ * run only through prompt() (omitted `deliverAs`, or `aside` at idle); explicit
9056+ * steer/follow-up queueing sends any other `/` text to the model as written.
90349057 */
90359058 async sendUserMessage(
90369059 content: string | (TextContent | ImageContent)[],
@@ -9056,33 +9079,79 @@ export class AgentSession implements SettingsScope {
90569079 if (images.length === 0) images = undefined;
90579080 }
90589081
9082+ const expand = options?.expandPromptTemplates === true;
9083+ const invocation = expand && this.skillsSettings?.enableSkillCommands ? parseSkillInvocation(text) : undefined;
9084+ const skill = invocation && this.skills.find(candidate => candidate.name === invocation.name);
9085+ if (invocation && skill) {
9086+ // Admit before reading SKILL.md so shutdown and settle checks see this send,
9087+ // as they see an option-off send admitted synchronously by prompt().
9088+ await this.#admitSubmission(async () => {
9089+ // abort() or a session change during the read must drop the send, like prompt()'s
9090+ // image preprocessing does, instead of starting a turn the user just stopped.
9091+ const promptGeneration = this.#promptGeneration;
9092+ const sessionGeneration = this.#sessionGeneration;
9093+ const built = await buildSkillPromptMessage(skill, invocation, "user");
9094+ if (
9095+ this.#promptGeneration !== promptGeneration ||
9096+ this.#sessionGeneration !== sessionGeneration ||
9097+ this.#isDisposed
9098+ ) {
9099+ return;
9100+ }
9101+ const skillContent = images ? [{ type: "text" as const, text: built.message }, ...images] : built.message;
9102+ if (options?.attribution === "agent") {
9103+ // The skill custom message is user-invoked by definition; an agent-attributed send
9104+ // stays a plain user-role message, like every other sendUserMessage path.
9105+ await this.sendUserMessage(skillContent, { deliverAs: options.deliverAs, attribution: "agent" });
9106+ return;
9107+ }
9108+ await this.promptCustomMessage(
9109+ {
9110+ customType: SKILL_PROMPT_MESSAGE_TYPE,
9111+ content: skillContent,
9112+ display: true,
9113+ details: built.details,
9114+ attribution: "user",
9115+ },
9116+ {
9117+ streamingBehavior: options?.deliverAs ?? "steer",
9118+ queueOnly: options?.deliverAs === "steer" || options?.deliverAs === "followUp",
9119+ queueChipText: text,
9120+ },
9121+ );
9122+ });
9123+ return;
9124+ }
9125+ const queuedText = expand ? expandPromptTemplate(text, [...this.#promptTemplates]) : text;
9126+ const queueOptions = { attribution: options?.attribution, rawText: text };
9127+
90599128 let deliveredAsAside = false;
90609129 if (options?.deliverAs === "aside") {
90619130 if (this.isStreaming) {
9062- await this.#queueUserMessage(text , images, "aside", { attribution: options.attribution } );
9131+ await this.#queueUserMessage(queuedText , images, "aside", queueOptions );
90639132 return;
90649133 }
90659134 // Idle: fall through to the prompt flow below (starts a turn, like an omitted
90669135 // deliverAs) — there is no live run to inject an aside into.
90679136 deliveredAsAside = true;
90689137 } else if (options?.deliverAs === "followUp") {
9069- await this.#queueUserMessage(text , images, "followUp", { attribution: options.attribution } );
9138+ await this.#queueUserMessage(queuedText , images, "followUp", queueOptions );
90709139 return;
90719140 } else if (options?.deliverAs === "steer") {
9072- await this.#queueUserMessage(text , images, "steer", { attribution: options.attribution } );
9141+ await this.#queueUserMessage(queuedText , images, "steer", queueOptions );
90739142 return;
90749143 }
90759144
9076- // Use prompt() with expandPromptTemplates: false to skip command handling and template
9077- // expansion. prompt() awaits manual-compaction cleanup and (on the non-streaming path)
9145+ // prompt() handles commands and templates only when expandPromptTemplates is set.
9146+ // It awaits manual-compaction cleanup and (on the non-streaming path)
90789147 // image normalization/vision description before dispatching, so a stream can start in
90799148 // that gap; prompt() re-checks isStreaming at each await boundary and queues via
90809149 // `streamingBehavior` when it does. Passing "aside" through (instead of hard-coding
90819150 // "steer") keeps that race from degrading a non-interrupting aside into a
90829151 // tool-batch-aborting steer.
90839152 await this.prompt(text, {
90849153 attribution: options?.attribution,
9085- expandPromptTemplates: false ,
9154+ expandPromptTemplates: expand ,
90869155 images,
90879156 streamingBehavior: deliveredAsAside ? "aside" : "steer",
90889157 });
0 commit comments