Skip to content
5 changes: 4 additions & 1 deletion docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,10 @@ ACP clients can defer agent-initiated turns, in which case requests that would
start one are retained as hidden next-turn messages.

`pi.sendUserMessage(content, { deliverAs })` submits text/images without slash-command
or prompt-template expansion. Omit `deliverAs` to start a prompt when idle;
or prompt-template expansion unless you pass `expandPromptTemplates: true`. With it,
a registered `/skill:<name>` command and prompt templates expand on every delivery
path; extension, custom, and file slash commands run only when `deliverAs` is
omitted (or is `"aside"` while idle). Omit `deliverAs` to start a prompt when idle;
while streaming, omission queues a steer. Explicit `"steer"` or `"followUp"`
queues the prompt even when idle instead of starting a turn directly. `"aside"`
injects at the next step boundary while a run is live and starts a turn when
Expand Down
2 changes: 1 addition & 1 deletion docs/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ Behavior:

Related APIs:

- `sendUserMessage(content, { deliverAs?, attribution? })`
- `sendUserMessage(content, { deliverAs?, attribution?, expandPromptTemplates? })`
- `steer(text, images?, { attribution? })`
- `followUp(text, images?, { synthetic?, attribution? })`
- `sendCustomMessage({ customType, content, ... }, { deliverAs?, triggerTurn? })`
Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,7 @@

### Added

- 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))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should-fix: This user-facing entry lacks the external-contribution attribution required by the changelog convention. Append ([#14608](https://github.com/can1357/oh-my-pi/pull/14608) by [@andrebrait](https://github.com/andrebrait)) so release notes credit the contributor and link the PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already in the reviewed commit. In 469f77c98e, CHANGELOG.md:11 ends with ([#14608](https://github.com/can1357/oh-my-pi/pull/14608) by [@andrebrait](https://github.com/andrebrait)). About the blocked verification: tool-views.generated.js is a build-generated file. With it present, bun test packages/coding-agent/test/agent-session-send-user-message-expand.test.ts passes 9/9 locally.

- Added an agents HUD pill counting running subagents, opening the agent hub on click
- 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
- Added `computer.zoom()` and window-local `zoom()` in JavaScript and Python Eval, with native-detail region captures that preserve full-screenshot click coordinates.
Expand Down
4 changes: 3 additions & 1 deletion packages/coding-agent/src/extensibility/extensions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1826,7 +1826,9 @@ export interface ExtensionAPI {

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

/** Append a custom entry to the session for state persistence (not sent to LLM). */
Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/src/session/agent-session-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,8 @@ export interface SendUserMessageOptions {
deliverAs?: "steer" | "followUp" | "aside";
/** Explicit billing/initiator attribution. */
attribution?: MessageAttribution;
/** Expand a registered `/skill:<name>` command and prompt templates (default: false). */
expandPromptTemplates?: boolean;
}

/** Result from a handoff operation. */
Expand Down
97 changes: 80 additions & 17 deletions packages/coding-agent/src/session/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ import { createExtensionModelQuery } from "../extensibility/extensions/model-api
import type { CompactOptions, ContextUsage } from "../extensibility/extensions/types";
import type { CustomCommandContext } from "../extensibility/custom-commands/types";
import { SkillDescriptionCatalog } from "../extensibility/skill-descriptions";
import type { Skill, SkillWarning } from "../extensibility/skills";
import { buildSkillPromptMessage, parseSkillInvocation, type Skill, type SkillWarning } from "../extensibility/skills";
import { expandSlashCommand, type FileSlashCommand, loadSlashCommands } from "../extensibility/slash-commands";
import { normalizeToolEventInput, resolveToolEventInput } from "../extensibility/tool-event-input";
import { GoalRuntime } from "../goals/runtime";
Expand Down Expand Up @@ -7598,13 +7598,13 @@ export class AgentSession implements SettingsScope {
throw new AgentBusyError();
}

await this.#queueCustomMessage(message, streamingBehavior, {
const queued = await this.#queueCustomMessage(message, streamingBehavior, {
queueChipText: options?.queueChipText,
prependMessages: keywordNotices,
onPromptAdmitted: options?.onPromptAdmitted,
});
outcome.sessionClaimed = true;
return true;
outcome.sessionClaimed = queued;
return queued;
}

const customMessage: CustomMessage<T> = {
Expand Down Expand Up @@ -7643,14 +7643,14 @@ export class AgentSession implements SettingsScope {
outcome.sessionClaimed = this.agent.state.isStreaming;
throw new AgentBusyError();
}
await this.#queueCustomMessage(message, streamingBehavior, {
const queued = await this.#queueCustomMessage(message, streamingBehavior, {
queueChipText: options?.queueChipText,
preprocessed: { content: preparedMessage.content, descriptionNotice },
prependMessages: keywordNotices,
onPromptAdmitted: options?.onPromptAdmitted,
});
outcome.sessionClaimed = true;
return true;
outcome.sessionClaimed = queued;
return queued;
}
outcome.sessionClaimed = await this.#promptWithMessage(preparedMessage, textContent, {
...options,
Expand Down Expand Up @@ -8629,7 +8629,8 @@ export class AgentSession implements SettingsScope {
}
}

/** Queue a custom message without starting a turn, matching steer/follow-up/aside delivery. */
/** Queue a custom message without starting a turn, matching steer/follow-up/aside delivery.
* Resolves false when an abort or session change during image preparation dropped it. */
async #queueCustomMessage<T = unknown>(
message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
deliverAs: "steer" | "followUp" | "aside",
Expand All @@ -8642,9 +8643,10 @@ export class AgentSession implements SettingsScope {
/** Called synchronously once the message is pushed onto its queue. See {@link PromptOptions.onPromptAdmitted}. */
onPromptAdmitted?: () => void;
},
): Promise<void> {
): Promise<boolean> {
// Captured before the normalization await below — see #sessionGeneration's doc comment.
const sessionGeneration = this.#sessionGeneration;
const promptGeneration = this.#promptGeneration;
const details =
options?.queueChipText !== undefined
? ({
Expand Down Expand Up @@ -8673,8 +8675,17 @@ export class AgentSession implements SettingsScope {
const descriptionNotice = preprocessed
? preprocessed.descriptionNotice
: await this.#buildSkillImageDescriptionNotice(normalizedAppMessage);
// abort() clears the steer/follow-up queues and disposal flushes every queue; a message
// still being prepared when either lands must not be queued afterwards (as in #queueUserMessage).
if (
this.#isDisposed ||
(deliverAs !== "aside" &&
(this.#promptGeneration !== promptGeneration || this.#sessionGeneration !== sessionGeneration))
) {
return false;
}
if (deliverAs === "aside") {
if (await this.#sessionGenerationChanged(sessionGeneration)) return;
if (await this.#sessionGenerationChanged(sessionGeneration)) return false;
// Non-interrupting: rides the same step-boundary aside poll as
// sendCustomMessage's streaming aside branch — not an agent-core queue
// entry, so no drain-retry latch and no idle-queue drain scheduling.
Expand All @@ -8689,7 +8700,7 @@ export class AgentSession implements SettingsScope {
// left to drain it. Resuming here is a no-op while streaming and wakes/folds
// correctly once idle, matching #queueUserMessage's aside branch.
this.#resumeStrandedIrcAsides();
return;
return true;
}
this.#allowQueuedMessageDrainRetry();
// Keyword notices and their user message must enter the queue in one synchronous phase.
Expand All @@ -8704,6 +8715,7 @@ export class AgentSession implements SettingsScope {
}
onPromptAdmitted?.();
this.#scheduleIdleQueueDrain();
return true;
}

/**
Expand Down Expand Up @@ -8915,6 +8927,11 @@ export class AgentSession implements SettingsScope {
* Omitted `deliverAs` starts a turn when idle and queues as a steer while streaming.
* Explicit `deliverAs` queues without starting a turn in either state; `aside` at
* an idle session instead starts a turn, since there is no live run to inject into.
*
* `expandPromptTemplates` (default false) expands a registered `/skill:<name>` and
* prompt templates on every delivery path. Extension, custom and file slash commands
* run only through prompt() (omitted `deliverAs`, or `aside` at idle); explicit
* steer/follow-up queueing sends any other `/` text to the model as written.
*/
async sendUserMessage(
content: string | (TextContent | ImageContent)[],
Expand All @@ -8940,33 +8957,79 @@ export class AgentSession implements SettingsScope {
if (images.length === 0) images = undefined;
}

const expand = options?.expandPromptTemplates === true;
const invocation = expand && this.skillsSettings?.enableSkillCommands ? parseSkillInvocation(text) : undefined;
const skill = invocation && this.skills.find(candidate => candidate.name === invocation.name);
if (invocation && skill) {
// Admit before reading SKILL.md so shutdown and settle checks see this send,
// as they see an option-off send admitted synchronously by prompt().
await this.#admitSubmission(async () => {
// abort() or a session change during the read must drop the send, like prompt()'s
// image preprocessing does, instead of starting a turn the user just stopped.
const promptGeneration = this.#promptGeneration;
const sessionGeneration = this.#sessionGeneration;
const built = await buildSkillPromptMessage(skill, invocation, "user");
if (
this.#promptGeneration !== promptGeneration ||
this.#sessionGeneration !== sessionGeneration ||
this.#isDisposed
) {
return;
}
const skillContent = images ? [{ type: "text" as const, text: built.message }, ...images] : built.message;
if (options?.attribution === "agent") {
// The skill custom message is user-invoked by definition; an agent-attributed send
// stays a plain user-role message, like every other sendUserMessage path.
await this.sendUserMessage(skillContent, { deliverAs: options.deliverAs, attribution: "agent" });
return;
}
await this.promptCustomMessage(
{
customType: SKILL_PROMPT_MESSAGE_TYPE,
content: skillContent,
display: true,
details: built.details,
attribution: "user",
},
{
streamingBehavior: options?.deliverAs ?? "steer",
queueOnly: options?.deliverAs === "steer" || options?.deliverAs === "followUp",
queueChipText: text,
},
);
});
return;
}
const queuedText = expand ? expandPromptTemplate(text, [...this.#promptTemplates]) : text;
const queueOptions = { attribution: options?.attribution, rawText: text };

let deliveredAsAside = false;
if (options?.deliverAs === "aside") {
if (this.isStreaming) {
await this.#queueUserMessage(text, images, "aside", { attribution: options.attribution });
await this.#queueUserMessage(queuedText, images, "aside", queueOptions);
return;
}
// Idle: fall through to the prompt flow below (starts a turn, like an omitted
// deliverAs) — there is no live run to inject an aside into.
deliveredAsAside = true;
} else if (options?.deliverAs === "followUp") {
await this.#queueUserMessage(text, images, "followUp", { attribution: options.attribution });
await this.#queueUserMessage(queuedText, images, "followUp", queueOptions);
return;
} else if (options?.deliverAs === "steer") {
await this.#queueUserMessage(text, images, "steer", { attribution: options.attribution });
await this.#queueUserMessage(queuedText, images, "steer", queueOptions);
return;
}

// Use prompt() with expandPromptTemplates: false to skip command handling and template
// expansion. prompt() awaits manual-compaction cleanup and (on the non-streaming path)
// prompt() handles commands and templates only when expandPromptTemplates is set.
// It awaits manual-compaction cleanup and (on the non-streaming path)
// image normalization/vision description before dispatching, so a stream can start in
// that gap; prompt() re-checks isStreaming at each await boundary and queues via
// `streamingBehavior` when it does. Passing "aside" through (instead of hard-coding
// "steer") keeps that race from degrading a non-interrupting aside into a
// tool-batch-aborting steer.
await this.prompt(text, {
attribution: options?.attribution,
expandPromptTemplates: false,
expandPromptTemplates: expand,
images,
streamingBehavior: deliveredAsAside ? "aside" : "steer",
});
Expand Down
Loading