Skip to content

Commit a998321

Browse files
committed
PR can1357#14608: feat(coding-agent): let extensions expand skills in sendUserMessage
Squashed from origin/fix/send-user-message-expand-templates (16aa9d4) onto upstream/main.
1 parent 12c937d commit a998321

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
@@ -583,6 +583,7 @@
583583

584584
### Added
585585

586+
- 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))
586587
- Added an agents HUD pill counting running subagents, opening the agent hub on click
587588
- 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
588589
- 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
@@ -1826,7 +1826,9 @@ export interface ExtensionAPI {
18261826

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

18321834
/** 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,
@@ -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

Comments
 (0)