Skip to content

Commit 487a1b3

Browse files
committed
PR can1357#14608: feat(coding-agent): let extensions expand skills in sendUserMessage
Squashed from origin/fix/send-user-message-expand-templates (5a7f7a6) onto upstream/main.
1 parent 9109af6 commit 487a1b3

7 files changed

Lines changed: 297 additions & 20 deletions

File tree

‎docs/extensions.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,10 @@ ACP clients can defer agent-initiated turns, in which case requests that would
324324
start one are retained as hidden next-turn messages.
325325

326326
`pi.sendUserMessage(content, { deliverAs })` submits text/images without slash-command
327-
or prompt-template expansion. Omit `deliverAs` to start a prompt when idle;
327+
or prompt-template expansion unless you pass `expandPromptTemplates: true`. With it,
328+
a registered `/skill:<name>` command and prompt templates expand on every delivery
329+
path; extension, custom, and file slash commands run only when `deliverAs` is
330+
omitted (or is `"aside"` while idle). Omit `deliverAs` to start a prompt when idle;
328331
while streaming, omission queues a steer. Explicit `"steer"` or `"followUp"`
329332
queues the prompt even when idle instead of starting a turn directly. `"aside"`
330333
injects at the next step boundary while a run is live and starts a turn when

‎docs/sdk.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ Behavior:
269269

270270
Related APIs:
271271

272-
- `sendUserMessage(content, { deliverAs?, attribution? })`
272+
- `sendUserMessage(content, { deliverAs?, attribution?, expandPromptTemplates? })`
273273
- `steer(text, images?, { attribution? })`
274274
- `followUp(text, images?, { synthetic?, attribution? })`
275275
- `sendCustomMessage({ customType, content, ... }, { deliverAs?, triggerTurn? })`

‎packages/coding-agent/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,7 @@
579579

580580
### Added
581581

582+
- Extensions can pass `expandPromptTemplates: true` to `pi.sendUserMessage()` so a `/skill:<name>` command or prompt template they send expands like typed input, matching Pi; Pi extensions with skill-alias commands now load the skill instead of sending the literal command text ([#14608](https://github.com/can1357/oh-my-pi/pull/14608) by [@andrebrait](https://github.com/andrebrait))
582583
- Added an agents HUD pill counting running subagents, opening the agent hub on click
583584
- In Tern the thinking level shows as the composer model chip's icon instead of a separate chip, still cycling on click, while `statusLine.compactThinkingLevel` (Compact Thinking Level, on by default) is on
584585
- Added `computer.zoom()` and window-local `zoom()` in JavaScript and Python Eval, with native-detail region captures that preserve full-screenshot click coordinates.

‎packages/coding-agent/src/extensibility/extensions/types.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1803,7 +1803,9 @@ export interface ExtensionAPI {
18031803

18041804
/** Send a user prompt: idle starts a turn; streaming queues as steer unless deliverAs is set.
18051805
* `deliverAs: "aside"` injects at the next step boundary without interrupting the in-flight tool
1806-
* batch while streaming, except that it ends a running interruptible `wait`; idle still starts a turn. */
1806+
* batch while streaming, except that it ends a running interruptible `wait`; idle still starts a turn.
1807+
* Text is sent as written unless `expandPromptTemplates` is set, which expands `/skill:<name>` commands
1808+
* and prompt templates. */
18071809
sendUserMessage(content: string | (TextContent | ImageContent)[], options?: SendUserMessageOptions): void;
18081810

18091811
/** Append a custom entry to the session for state persistence (not sent to LLM). */

‎packages/coding-agent/src/session/agent-session-types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,8 @@ export interface SendUserMessageOptions {
486486
deliverAs?: "steer" | "followUp" | "aside";
487487
/** Explicit billing/initiator attribution. */
488488
attribution?: MessageAttribution;
489+
/** Expand a registered `/skill:<name>` command and prompt templates (default: false). */
490+
expandPromptTemplates?: boolean;
489491
}
490492

491493
/** Result from a handoff operation. */

‎packages/coding-agent/src/session/agent-session.ts‎

Lines changed: 86 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,13 @@ import { createExtensionModelQuery } from "../extensibility/extensions/model-api
163163
import type { CompactOptions, ContextUsage } from "../extensibility/extensions/types";
164164
import type { CustomCommandContext } from "../extensibility/custom-commands/types";
165165
import { 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";
167173
import { expandSlashCommand, type FileSlashCommand, loadSlashCommands } from "../extensibility/slash-commands";
168174
import { normalizeToolEventInput, resolveToolEventInput } from "../extensibility/tool-event-input";
169175
import { 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

Comments
 (0)