From b987ead3583d2d2d796868f062b811abfa9ccb52 Mon Sep 17 00:00:00 2001 From: Duncan Ogilvie Date: Tue, 11 Aug 2026 11:14:28 +0200 Subject: [PATCH] feat(agent): expose `expandPromptTemplates` in `sendUserMessage` (#7857) --- packages/coding-agent/docs/extensions.md | 4 ++ .../coding-agent/src/core/agent-session.ts | 8 +-- .../coding-agent/src/core/extensions/types.ts | 7 +- .../test/suite/agent-session-prompt.test.ts | 64 ++++++++++++++++++- 4 files changed, 75 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 00e92a1d6..9ec65f89b 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -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. diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index b210c5ace..179a55219 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -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 { // 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", diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index a87322ce9..db0ebacaa 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -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; } @@ -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 = ( export type SendUserMessageHandler = ( content: string | (TextContent | ImageContent)[], - options?: { deliverAs?: "steer" | "followUp" }, + options?: { deliverAs?: "steer" | "followUp"; expandPromptTemplates?: boolean }, ) => void; export type AppendEntryHandler = (customType: string, data?: T) => void; diff --git a/packages/coding-agent/test/suite/agent-session-prompt.test.ts b/packages/coding-agent/test/suite/agent-session-prompt.test.ts index 0e9c587fd..1fa23fbe2 100644 --- a/packages/coding-agent/test/suite/agent-session-prompt.test.ts +++ b/packages/coding-agent/test/suite/agent-session-prompt.test.ts @@ -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((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);