Extensions: add pi.sendUserMessage() for sending user messages

Adds sendUserMessage() to the extension API, allowing extensions to send
actual user messages (role: user) rather than custom messages. Unlike
sendMessage(), this always triggers a turn and behaves as if the user
typed the message.

- Add SendUserMessageHandler type and sendUserMessage() to ExtensionAPI
- Wire handler through loader, runner, and all modes
- Implement via prompt() with expandPromptTemplates: false
- Add send-user-message.ts example with /ask, /steer, /followup commands
- Document in extensions.md

fixes #483
This commit is contained in:
Mario Zechner 2026-01-06 13:40:24 +01:00
parent f023af0dab
commit 7210086677
13 changed files with 222 additions and 1 deletions

View file

@ -788,6 +788,45 @@ export class AgentSession {
}
}
/**
* Send a user message to the agent. Always triggers a turn.
* When the agent is streaming, use deliverAs to specify how to queue the message.
*
* @param content User message content (string or content array)
* @param options.deliverAs Delivery mode when streaming: "steer" or "followUp"
*/
async sendUserMessage(
content: string | (TextContent | ImageContent)[],
options?: { deliverAs?: "steer" | "followUp" },
): Promise<void> {
// Normalize content to text string + optional images
let text: string;
let images: ImageContent[] | undefined;
if (typeof content === "string") {
text = content;
} else {
const textParts: string[] = [];
images = [];
for (const part of content) {
if (part.type === "text") {
textParts.push(part.text);
} else {
images.push(part);
}
}
text = textParts.join("\n");
if (images.length === 0) images = undefined;
}
// Use prompt() with expandPromptTemplates: false to skip command handling and template expansion
await this.prompt(text, {
expandPromptTemplates: false,
streamingBehavior: options?.deliverAs,
images,
});
}
/**
* Clear all queued messages and return them.
* Useful for restoring to editor when user aborts.

View file

@ -52,6 +52,7 @@ export type {
RegisteredCommand,
RegisteredTool,
SendMessageHandler,
SendUserMessageHandler,
SessionBeforeBranchEvent,
SessionBeforeBranchResult,
SessionBeforeCompactEvent,

View file

@ -29,6 +29,7 @@ import type {
RegisteredCommand,
RegisteredTool,
SendMessageHandler,
SendUserMessageHandler,
SetActiveToolsHandler,
ToolDefinition,
} from "./types.js";
@ -117,6 +118,7 @@ function createExtensionAPI(
flagValues: Map<string, boolean | string>;
shortcuts: Map<KeyId, ExtensionShortcut>;
setSendMessageHandler: (handler: SendMessageHandler) => void;
setSendUserMessageHandler: (handler: SendUserMessageHandler) => void;
setAppendEntryHandler: (handler: AppendEntryHandler) => void;
setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => void;
setGetAllToolsHandler: (handler: GetAllToolsHandler) => void;
@ -124,6 +126,7 @@ function createExtensionAPI(
setFlagValue: (name: string, value: boolean | string) => void;
} {
let sendMessageHandler: SendMessageHandler = () => {};
let sendUserMessageHandler: SendUserMessageHandler = () => {};
let appendEntryHandler: AppendEntryHandler = () => {};
let getActiveToolsHandler: GetActiveToolsHandler = () => [];
let getAllToolsHandler: GetAllToolsHandler = () => [];
@ -185,6 +188,10 @@ function createExtensionAPI(
sendMessageHandler(message, options);
},
sendUserMessage(content, options): void {
sendUserMessageHandler(content, options);
},
appendEntry(customType: string, data?: unknown): void {
appendEntryHandler(customType, data);
},
@ -218,6 +225,9 @@ function createExtensionAPI(
setSendMessageHandler: (handler: SendMessageHandler) => {
sendMessageHandler = handler;
},
setSendUserMessageHandler: (handler: SendUserMessageHandler) => {
sendUserMessageHandler = handler;
},
setAppendEntryHandler: (handler: AppendEntryHandler) => {
appendEntryHandler = handler;
},
@ -261,6 +271,7 @@ async function loadExtensionWithBun(
flagValues,
shortcuts,
setSendMessageHandler,
setSendUserMessageHandler,
setAppendEntryHandler,
setGetActiveToolsHandler,
setGetAllToolsHandler,
@ -282,6 +293,7 @@ async function loadExtensionWithBun(
flagValues,
shortcuts,
setSendMessageHandler,
setSendUserMessageHandler,
setAppendEntryHandler,
setGetActiveToolsHandler,
setGetAllToolsHandler,
@ -341,6 +353,7 @@ async function loadExtension(
flagValues,
shortcuts,
setSendMessageHandler,
setSendUserMessageHandler,
setAppendEntryHandler,
setGetActiveToolsHandler,
setGetAllToolsHandler,
@ -362,6 +375,7 @@ async function loadExtension(
flagValues,
shortcuts,
setSendMessageHandler,
setSendUserMessageHandler,
setAppendEntryHandler,
setGetActiveToolsHandler,
setGetAllToolsHandler,
@ -396,6 +410,7 @@ export function loadExtensionFromFactory(
flagValues,
shortcuts,
setSendMessageHandler,
setSendUserMessageHandler,
setAppendEntryHandler,
setGetActiveToolsHandler,
setGetAllToolsHandler,
@ -416,6 +431,7 @@ export function loadExtensionFromFactory(
flagValues,
shortcuts,
setSendMessageHandler,
setSendUserMessageHandler,
setAppendEntryHandler,
setGetActiveToolsHandler,
setGetAllToolsHandler,

View file

@ -28,6 +28,7 @@ import type {
RegisteredCommand,
RegisteredTool,
SendMessageHandler,
SendUserMessageHandler,
SessionBeforeCompactResult,
SessionBeforeTreeResult,
SetActiveToolsHandler,
@ -108,6 +109,7 @@ export class ExtensionRunner {
initialize(options: {
getModel: () => Model<any> | undefined;
sendMessageHandler: SendMessageHandler;
sendUserMessageHandler: SendUserMessageHandler;
appendEntryHandler: AppendEntryHandler;
getActiveToolsHandler: GetActiveToolsHandler;
getAllToolsHandler: GetAllToolsHandler;
@ -140,6 +142,7 @@ export class ExtensionRunner {
for (const ext of this.extensions) {
ext.setSendMessageHandler(options.sendMessageHandler);
ext.setSendUserMessageHandler(options.sendUserMessageHandler);
ext.setAppendEntryHandler(options.appendEntryHandler);
ext.setGetActiveToolsHandler(options.getActiveToolsHandler);
ext.setGetAllToolsHandler(options.getAllToolsHandler);

View file

@ -592,6 +592,15 @@ export interface ExtensionAPI {
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
): void;
/**
* Send a user message to the agent. Always triggers a turn.
* When the agent is streaming, use deliverAs to specify how to queue the message.
*/
sendUserMessage(
content: string | (TextContent | ImageContent)[],
options?: { deliverAs?: "steer" | "followUp" },
): void;
/** Append a custom entry to the session for state persistence (not sent to LLM). */
appendEntry<T = unknown>(customType: string, data?: T): void;
@ -645,6 +654,11 @@ export type SendMessageHandler = <T = unknown>(
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
) => void;
export type SendUserMessageHandler = (
content: string | (TextContent | ImageContent)[],
options?: { deliverAs?: "steer" | "followUp" },
) => void;
export type AppendEntryHandler = <T = unknown>(customType: string, data?: T) => void;
export type GetActiveToolsHandler = () => string[];
@ -665,6 +679,7 @@ export interface LoadedExtension {
flagValues: Map<string, boolean | string>;
shortcuts: Map<KeyId, ExtensionShortcut>;
setSendMessageHandler: (handler: SendMessageHandler) => void;
setSendUserMessageHandler: (handler: SendUserMessageHandler) => void;
setAppendEntryHandler: (handler: AppendEntryHandler) => void;
setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => void;
setGetAllToolsHandler: (handler: GetAllToolsHandler) => void;

View file

@ -442,6 +442,11 @@ export class InteractiveMode {
this.showError(`Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`);
});
},
sendUserMessageHandler: (content, options) => {
this.session.sendUserMessage(content, options).catch((err) => {
this.showError(`Extension sendUserMessage failed: ${err instanceof Error ? err.message : String(err)}`);
});
},
appendEntryHandler: (customType, data) => {
this.sessionManager.appendCustomEntry(customType, data);
},

View file

@ -37,6 +37,11 @@ export async function runPrintMode(
console.error(`Extension sendMessage failed: ${e instanceof Error ? e.message : String(e)}`);
});
},
sendUserMessageHandler: (content, options) => {
session.sendUserMessage(content, options).catch((e) => {
console.error(`Extension sendUserMessage failed: ${e instanceof Error ? e.message : String(e)}`);
});
},
appendEntryHandler: (customType, data) => {
session.sessionManager.appendCustomEntry(customType, data);
},

View file

@ -229,6 +229,11 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
output(error(undefined, "extension_send", e.message));
});
},
sendUserMessageHandler: (content, options) => {
session.sendUserMessage(content, options).catch((e) => {
output(error(undefined, "extension_send_user", e.message));
});
},
appendEntryHandler: (customType, data) => {
session.sessionManager.appendCustomEntry(customType, data);
},