From 7210086677a003d540cb5e8650d34b3f71fd1510 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 6 Jan 2026 13:40:24 +0100 Subject: [PATCH] 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 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/extensions.md | 30 +++++- .../examples/extensions/README.md | 1 + .../examples/extensions/send-user-message.ts | 97 +++++++++++++++++++ .../coding-agent/src/core/agent-session.ts | 39 ++++++++ .../coding-agent/src/core/extensions/index.ts | 1 + .../src/core/extensions/loader.ts | 16 +++ .../src/core/extensions/runner.ts | 3 + .../coding-agent/src/core/extensions/types.ts | 15 +++ .../src/modes/interactive/interactive-mode.ts | 5 + packages/coding-agent/src/modes/print-mode.ts | 5 + .../coding-agent/src/modes/rpc/rpc-mode.ts | 5 + .../test/compaction-extensions.test.ts | 5 + 13 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/examples/extensions/send-user-message.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 79149923..a91b1442 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -7,6 +7,7 @@ - Extensions can now replace the footer with `ctx.ui.setFooter()`, see `examples/extensions/custom-footer.ts` ([#481](https://github.com/badlogic/pi-mono/issues/481)) - Session ID is now forwarded to LLM providers for session-based caching (used by OpenAI Codex for prompt caching). - Added `blockImages` setting to prevent images from being sent to LLM providers ([#492](https://github.com/badlogic/pi-mono/pull/492) by [@jsinge97](https://github.com/jsinge97)) +- Extensions can now send user messages via `pi.sendUserMessage()` ([#483](https://github.com/badlogic/pi-mono/issues/483)) ### Fixed diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 85f344e6..4226df91 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -632,7 +632,7 @@ pi.registerTool({ ### pi.sendMessage(message, options?) -Inject a message into the session: +Inject a custom message into the session: ```typescript pi.sendMessage({ @@ -653,6 +653,34 @@ pi.sendMessage({ - `"nextTurn"` - Queued for next user prompt. Does not interrupt or trigger anything. - `triggerTurn: true` - If agent is idle, trigger an LLM response immediately. Only applies to `"steer"` and `"followUp"` modes (ignored for `"nextTurn"`). +### pi.sendUserMessage(content, options?) + +Send a user message to the agent. Unlike `sendMessage()` which sends custom messages, this sends an actual user message that appears as if typed by the user. Always triggers a turn. + +```typescript +// Simple text message +pi.sendUserMessage("What is 2+2?"); + +// With content array (text + images) +pi.sendUserMessage([ + { type: "text", text: "Describe this image:" }, + { type: "image", source: { type: "base64", mediaType: "image/png", data: "..." } }, +]); + +// During streaming - must specify delivery mode +pi.sendUserMessage("Focus on error handling", { deliverAs: "steer" }); +pi.sendUserMessage("And then summarize", { deliverAs: "followUp" }); +``` + +**Options:** +- `deliverAs` - Required when agent is streaming: + - `"steer"` - Interrupts after current tool, remaining tools skipped + - `"followUp"` - Waits for agent to finish all tools + +When not streaming, the message is sent immediately and triggers a new turn. When streaming without `deliverAs`, throws an error. + +See [send-user-message.ts](../examples/extensions/send-user-message.ts) for a complete example. + ### pi.appendEntry(customType, data?) Persist extension state (does NOT participate in LLM context): diff --git a/packages/coding-agent/examples/extensions/README.md b/packages/coding-agent/examples/extensions/README.md index 649865de..6bb8acd3 100644 --- a/packages/coding-agent/examples/extensions/README.md +++ b/packages/coding-agent/examples/extensions/README.md @@ -42,6 +42,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/ | `qna.ts` | Extracts questions from last response into editor via `ctx.ui.setEditorText()` | | `status-line.ts` | Shows turn progress in footer via `ctx.ui.setStatus()` with themed colors | | `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence | +| `send-user-message.ts` | Demonstrates `pi.sendUserMessage()` for sending user messages from extensions | ### Git Integration diff --git a/packages/coding-agent/examples/extensions/send-user-message.ts b/packages/coding-agent/examples/extensions/send-user-message.ts new file mode 100644 index 00000000..b2efbb7c --- /dev/null +++ b/packages/coding-agent/examples/extensions/send-user-message.ts @@ -0,0 +1,97 @@ +/** + * Send User Message Example + * + * Demonstrates pi.sendUserMessage() for sending user messages from extensions. + * Unlike pi.sendMessage() which sends custom messages, sendUserMessage() sends + * actual user messages that appear in the conversation as if typed by the user. + * + * Usage: + * /ask What is 2+2? - Sends a user message (always triggers a turn) + * /steer Focus on X - Sends while streaming with steer delivery + * /followup And then? - Sends while streaming with followUp delivery + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + // Simple command that sends a user message + pi.registerCommand("ask", { + description: "Send a user message to the agent", + handler: async (args, ctx) => { + if (!args.trim()) { + ctx.ui.notify("Usage: /ask ", "warning"); + return; + } + + // sendUserMessage always triggers a turn when not streaming + // If streaming, it will throw (no deliverAs specified) + if (!ctx.isIdle()) { + ctx.ui.notify("Agent is busy. Use /steer or /followup instead.", "warning"); + return; + } + + pi.sendUserMessage(args); + }, + }); + + // Command that steers the agent mid-conversation + pi.registerCommand("steer", { + description: "Send a steering message (interrupts current processing)", + handler: async (args, ctx) => { + if (!args.trim()) { + ctx.ui.notify("Usage: /steer ", "warning"); + return; + } + + if (ctx.isIdle()) { + // Not streaming, just send normally + pi.sendUserMessage(args); + } else { + // Streaming - use steer to interrupt + pi.sendUserMessage(args, { deliverAs: "steer" }); + } + }, + }); + + // Command that queues a follow-up message + pi.registerCommand("followup", { + description: "Queue a follow-up message (waits for current processing)", + handler: async (args, ctx) => { + if (!args.trim()) { + ctx.ui.notify("Usage: /followup ", "warning"); + return; + } + + if (ctx.isIdle()) { + // Not streaming, just send normally + pi.sendUserMessage(args); + } else { + // Streaming - queue as follow-up + pi.sendUserMessage(args, { deliverAs: "followUp" }); + ctx.ui.notify("Follow-up queued", "info"); + } + }, + }); + + // Example with content array (text + images would go here) + pi.registerCommand("askwith", { + description: "Send a user message with structured content", + handler: async (args, ctx) => { + if (!args.trim()) { + ctx.ui.notify("Usage: /askwith ", "warning"); + return; + } + + if (!ctx.isIdle()) { + ctx.ui.notify("Agent is busy", "warning"); + return; + } + + // sendUserMessage accepts string or (TextContent | ImageContent)[] + pi.sendUserMessage([ + { type: "text", text: `User request: ${args}` }, + { type: "text", text: "Please respond concisely." }, + ]); + }, + }); +} diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index d87bf83e..60868127 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -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 { + // 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. diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index c04e1107..4b7c901b 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -52,6 +52,7 @@ export type { RegisteredCommand, RegisteredTool, SendMessageHandler, + SendUserMessageHandler, SessionBeforeBranchEvent, SessionBeforeBranchResult, SessionBeforeCompactEvent, diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index b64169ae..cd1bb3f7 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -29,6 +29,7 @@ import type { RegisteredCommand, RegisteredTool, SendMessageHandler, + SendUserMessageHandler, SetActiveToolsHandler, ToolDefinition, } from "./types.js"; @@ -117,6 +118,7 @@ function createExtensionAPI( flagValues: Map; shortcuts: Map; 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, diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index a00ab5bd..41ad7883 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -28,6 +28,7 @@ import type { RegisteredCommand, RegisteredTool, SendMessageHandler, + SendUserMessageHandler, SessionBeforeCompactResult, SessionBeforeTreeResult, SetActiveToolsHandler, @@ -108,6 +109,7 @@ export class ExtensionRunner { initialize(options: { getModel: () => Model | 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); diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 4e2af386..44bd7b6f 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -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(customType: string, data?: T): void; @@ -645,6 +654,11 @@ export type SendMessageHandler = ( options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, ) => void; +export type SendUserMessageHandler = ( + content: string | (TextContent | ImageContent)[], + options?: { deliverAs?: "steer" | "followUp" }, +) => void; + export type AppendEntryHandler = (customType: string, data?: T) => void; export type GetActiveToolsHandler = () => string[]; @@ -665,6 +679,7 @@ export interface LoadedExtension { flagValues: Map; shortcuts: Map; setSendMessageHandler: (handler: SendMessageHandler) => void; + setSendUserMessageHandler: (handler: SendUserMessageHandler) => void; setAppendEntryHandler: (handler: AppendEntryHandler) => void; setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => void; setGetAllToolsHandler: (handler: GetAllToolsHandler) => void; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 1784c6fa..6242ef52 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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); }, diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 38857288..ecf6c753 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -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); }, diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 77e9ce80..fb32de59 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -229,6 +229,11 @@ export async function runRpcMode(session: AgentSession): Promise { 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); }, diff --git a/packages/coding-agent/test/compaction-extensions.test.ts b/packages/coding-agent/test/compaction-extensions.test.ts index 064b54bc..e07df288 100644 --- a/packages/coding-agent/test/compaction-extensions.test.ts +++ b/packages/coding-agent/test/compaction-extensions.test.ts @@ -83,6 +83,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => { flagValues: new Map(), shortcuts: new Map(), setSendMessageHandler: () => {}, + setSendUserMessageHandler: () => {}, setAppendEntryHandler: () => {}, setGetActiveToolsHandler: () => {}, setGetAllToolsHandler: () => {}, @@ -111,6 +112,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => { extensionRunner.initialize({ getModel: () => session.model, sendMessageHandler: async () => {}, + sendUserMessageHandler: async () => {}, appendEntryHandler: async () => {}, getActiveToolsHandler: () => [], getAllToolsHandler: () => [], @@ -284,6 +286,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => { flagValues: new Map(), shortcuts: new Map(), setSendMessageHandler: () => {}, + setSendUserMessageHandler: () => {}, setAppendEntryHandler: () => {}, setGetActiveToolsHandler: () => {}, setGetAllToolsHandler: () => {}, @@ -339,6 +342,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => { flagValues: new Map(), shortcuts: new Map(), setSendMessageHandler: () => {}, + setSendUserMessageHandler: () => {}, setAppendEntryHandler: () => {}, setGetActiveToolsHandler: () => {}, setGetAllToolsHandler: () => {}, @@ -376,6 +380,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => { flagValues: new Map(), shortcuts: new Map(), setSendMessageHandler: () => {}, + setSendUserMessageHandler: () => {}, setAppendEntryHandler: () => {}, setGetActiveToolsHandler: () => {}, setGetAllToolsHandler: () => {},