mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 22:03:45 +00:00
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:
parent
f023af0dab
commit
7210086677
13 changed files with 222 additions and 1 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <message>", "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 <message>", "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 <message>", "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 <message>", "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." },
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export type {
|
|||
RegisteredCommand,
|
||||
RegisteredTool,
|
||||
SendMessageHandler,
|
||||
SendUserMessageHandler,
|
||||
SessionBeforeBranchEvent,
|
||||
SessionBeforeBranchResult,
|
||||
SessionBeforeCompactEvent,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: () => {},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue