feat(agent): expose expandPromptTemplates in sendUserMessage (#7857)

This commit is contained in:
Duncan Ogilvie
2026-08-11 11:14:28 +02:00
committed by GitHub
parent 4a879dd759
commit b987ead358
4 changed files with 75 additions and 8 deletions
+4
View File
@@ -1426,12 +1426,16 @@ pi.sendUserMessage([
// During streaming - must specify delivery mode
pi.sendUserMessage("Focus on error handling", { deliverAs: "steer" });
pi.sendUserMessage("And then summarize", { deliverAs: "followUp" });
// Opt in to extension command dispatch and skill/prompt template expansion
pi.sendUserMessage("/review src/index.ts", { expandPromptTemplates: true });
```
**Options:**
- `deliverAs` - Required when agent is streaming:
- `"steer"` - Queues the message for delivery after the current assistant turn finishes executing its tool calls
- `"followUp"` - Waits for agent to finish all tools
- `expandPromptTemplates` - Dispatch extension commands and expand skill commands and prompt templates. Defaults to `false`.
When not streaming, the message is sent immediately and triggers a new turn. When streaming without `deliverAs`, throws an error.
@@ -238,7 +238,7 @@ export interface ExtensionBindings {
/** Options for AgentSession.prompt() */
export interface PromptOptions {
/** Whether to expand file-based prompt templates (default: true) */
/** Whether to dispatch extension commands and expand skill commands and prompt templates (default: true) */
expandPromptTemplates?: boolean;
/** Image attachments */
images?: ImageContent[];
@@ -1476,10 +1476,11 @@ export class AgentSession {
*
* @param content User message content (string or content array)
* @param options.deliverAs Delivery mode when streaming: "steer" or "followUp"
* @param options.expandPromptTemplates Whether to dispatch extension commands and expand skill commands and prompt templates. Default: false.
*/
async sendUserMessage(
content: string | (TextContent | ImageContent)[],
options?: { deliverAs?: "steer" | "followUp" },
options?: { deliverAs?: "steer" | "followUp"; expandPromptTemplates?: boolean },
): Promise<void> {
// Normalize content to text string + optional images
let text: string;
@@ -1501,9 +1502,8 @@ export class AgentSession {
if (images.length === 0) images = undefined;
}
// Use prompt() with expandPromptTemplates: false to skip command handling and template expansion
await this.prompt(text, {
expandPromptTemplates: false,
expandPromptTemplates: options?.expandPromptTemplates ?? false,
streamingBehavior: options?.deliverAs,
images,
source: "extension",
@@ -399,7 +399,7 @@ export interface ReplacedSessionContext extends ExtensionCommandContext {
sendUserMessage(
content: string | (TextContent | ImageContent)[],
options?: { deliverAs?: "steer" | "followUp" },
options?: { deliverAs?: "steer" | "followUp"; expandPromptTemplates?: boolean },
): Promise<void>;
}
@@ -1307,10 +1307,11 @@ export interface ExtensionAPI {
/**
* 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.
* Set expandPromptTemplates to dispatch extension commands and expand skill commands and prompt templates.
*/
sendUserMessage(
content: string | (TextContent | ImageContent)[],
options?: { deliverAs?: "steer" | "followUp" },
options?: { deliverAs?: "steer" | "followUp"; expandPromptTemplates?: boolean },
): void;
/** Append a custom entry to the session for state persistence (not sent to LLM). */
@@ -1560,7 +1561,7 @@ export type SendMessageHandler = <T = unknown>(
export type SendUserMessageHandler = (
content: string | (TextContent | ImageContent)[],
options?: { deliverAs?: "steer" | "followUp" },
options?: { deliverAs?: "steer" | "followUp"; expandPromptTemplates?: boolean },
) => void;
export type AppendEntryHandler = <T = unknown>(customType: string, data?: T) => void;
@@ -5,7 +5,7 @@ import type { AgentTool } from "@earendil-works/pi-agent-core";
import { fauxAssistantMessage, fauxToolCall, type Model } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { afterEach, describe, expect, it } from "vitest";
import type { InputEvent } from "../../src/core/extensions/index.ts";
import type { ExtensionAPI, InputEvent } from "../../src/core/extensions/index.ts";
import type { PromptTemplate } from "../../src/core/prompt-templates.ts";
import { createSyntheticSourceInfo } from "../../src/core/source-info.ts";
import { createTestResourceLoader } from "../utilities.ts";
@@ -224,6 +224,39 @@ describe("AgentSession prompt characterization", () => {
expect(expandedPrompt).toBe("Review this code: src/index.ts");
});
it("sendUserMessage can opt into prompt template expansion", async () => {
const template: PromptTemplate = {
name: "review",
description: "Review template",
content: "Review this code: $1",
filePath: "/virtual/review.md",
sourceInfo: createSyntheticSourceInfo("/virtual/review.md", {
source: "local",
scope: "temporary",
origin: "top-level",
}),
};
const resourceLoader = {
...createTestResourceLoader(),
getPrompts: () => ({ prompts: [template], diagnostics: [] }),
};
const harness = await createHarness({ resourceLoader });
harnesses.push(harness);
let expandedPrompt = "";
harness.setResponses([
(context) => {
const user = context.messages.find((message) => message.role === "user");
expandedPrompt = user ? getMessageText(user) : "";
return fauxAssistantMessage("ok");
},
]);
await harness.session.sendUserMessage("/review src/index.ts", { expandPromptTemplates: true });
expect(expandedPrompt).toBe("Review this code: src/index.ts");
});
it("dispatches extension commands without consuming a provider response", async () => {
const commandRuns: string[] = [];
const harness = await createHarness({
@@ -248,6 +281,35 @@ describe("AgentSession prompt characterization", () => {
expect(harness.getPendingResponseCount()).toBe(1);
});
it("extension sendUserMessage can opt into extension command dispatch", async () => {
let extensionApi: ExtensionAPI | undefined;
let resolveCommandRun: (args: string) => void = () => {};
const commandRun = new Promise<string>((resolve) => {
resolveCommandRun = resolve;
});
const harness = await createHarness({
extensionFactories: [
(pi) => {
extensionApi = pi;
pi.registerCommand("testcmd", {
description: "Test command",
handler: async (args) => {
resolveCommandRun(args);
},
});
},
],
});
harnesses.push(harness);
expect(extensionApi).toBeDefined();
extensionApi?.sendUserMessage("/testcmd hello world", { expandPromptTemplates: true });
await expect(commandRun).resolves.toBe("hello world");
expect(harness.session.messages).toEqual([]);
expect(harness.getPendingResponseCount()).toBe(0);
});
it("sendUserMessage while idle triggers a turn", async () => {
const harness = await createHarness();
harnesses.push(harness);