Skip to content

Commit 3c1ebd4

Browse files
committed
PR can1357#14608: feat(coding-agent): let extensions expand skills in sendUserMessage
Squashed from origin/fix/send-user-message-expand-templates (397dc39) onto upstream/main.
1 parent d3c7763 commit 3c1ebd4

7 files changed

Lines changed: 296 additions & 20 deletions

File tree

‎docs/extensions.md‎

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

275275
`pi.sendUserMessage(content, { deliverAs })` submits text/images without slash-command
276-
or prompt-template expansion. Omit `deliverAs` to start a prompt when idle;
276+
or prompt-template expansion unless you pass `expandPromptTemplates: true`. With it,
277+
a registered `/skill:<name>` command and prompt templates expand on every delivery
278+
path; extension, custom, and file slash commands run only when `deliverAs` is
279+
omitted (or is `"aside"` while idle). Omit `deliverAs` to start a prompt when idle;
277280
while streaming, omission queues a steer. Explicit `"steer"` or `"followUp"`
278281
queues the prompt even when idle instead of starting a turn directly. `"aside"`
279282
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
@@ -209,6 +209,7 @@
209209

210210
### Added
211211

212+
- 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))
212213
- Added an agents HUD pill counting running subagents, opening the agent hub on click
213214
- 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
214215
- 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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1596,7 +1596,8 @@ export interface ExtensionAPI {
15961596

15971597
/** Send a user prompt: idle starts a turn; streaming queues as steer unless deliverAs is set.
15981598
* `deliverAs: "aside"` injects at the next step boundary without interrupting the in-flight tool
1599-
* batch while streaming; idle still starts a turn. */
1599+
* batch while streaming; idle still starts a turn. Text is sent as written unless
1600+
* `expandPromptTemplates` is set, which expands `/skill:<name>` commands and prompt templates. */
16001601
sendUserMessage(content: string | (TextContent | ImageContent)[], options?: SendUserMessageOptions): void;
16011602

16021603
/** 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
@@ -467,6 +467,8 @@ export interface SendUserMessageOptions {
467467
deliverAs?: "steer" | "followUp" | "aside";
468468
/** Explicit billing/initiator attribution. */
469469
attribution?: MessageAttribution;
470+
/** Expand a registered `/skill:<name>` command and prompt templates (default: false). */
471+
expandPromptTemplates?: boolean;
470472
}
471473

472474
/** 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
@@ -161,7 +161,13 @@ import { createExtensionModelQuery } from "../extensibility/extensions/model-api
161161
import type { CompactOptions, ContextUsage } from "../extensibility/extensions/types";
162162
import type { CustomCommandContext } from "../extensibility/custom-commands/types";
163163
import { 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";
165171
import { expandSlashCommand, type FileSlashCommand, loadSlashCommands } from "../extensibility/slash-commands";
166172
import { normalizeToolEventInput, resolveToolEventInput } from "../extensibility/tool-event-input";
167173
import { 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

Comments
 (0)