From cea2235a66f95d6700a4faa0ac57a00626284225 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Tue, 6 Oct 2026 12:48:40 +0000 Subject: [PATCH 1/7] feat(coding-agent): let extensions expand skills in sendUserMessage Pi's sendUserMessage accepts expandPromptTemplates (default false) so an extension can dispatch a /skill: command or a prompt template like typed input. omp kept the original hardcoded false and had no option, so Pi extensions with skill-alias commands (for example ponytail's /ponytail-review) sent the literal '/skill:...' text and the model never saw the skill. Add the opt-in option. When set, a registered /skill: command is built into a user-attributed skill message and delivered on every path (idle, steer, follow-up, aside); other text gets prompt-template expansion, and only the prompt() path runs extension/custom/file slash commands, matching steer()/followUp(). --- docs/extensions.md | 5 +- docs/sdk.md | 2 +- packages/coding-agent/CHANGELOG.md | 1 + .../src/session/agent-session-types.ts | 2 + .../coding-agent/src/session/agent-session.ts | 43 +++++-- ...t-session-send-user-message-expand.test.ts | 110 ++++++++++++++++++ 6 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 packages/coding-agent/test/agent-session-send-user-message-expand.test.ts diff --git a/docs/extensions.md b/docs/extensions.md index 05470fdcf8f8..9345dc0d5533 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -324,7 +324,10 @@ ACP clients can defer agent-initiated turns, in which case requests that would start one are retained as hidden next-turn messages. `pi.sendUserMessage(content, { deliverAs })` submits text/images without slash-command -or prompt-template expansion. Omit `deliverAs` to start a prompt when idle; +or prompt-template expansion unless you pass `expandPromptTemplates: true`. With it, +a registered `/skill:` command and prompt templates expand on every delivery +path; extension, custom, and file slash commands run only when `deliverAs` is +omitted (or is `"aside"` while idle). Omit `deliverAs` to start a prompt when idle; while streaming, omission queues a steer. Explicit `"steer"` or `"followUp"` queues the prompt even when idle instead of starting a turn directly. `"aside"` injects at the next step boundary while a run is live and starts a turn when diff --git a/docs/sdk.md b/docs/sdk.md index 69b9aebded4a..d453b054a972 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -269,7 +269,7 @@ Behavior: Related APIs: -- `sendUserMessage(content, { deliverAs?, attribution? })` +- `sendUserMessage(content, { deliverAs?, attribution?, expandPromptTemplates? })` - `steer(text, images?, { attribution? })` - `followUp(text, images?, { synthetic?, attribution? })` - `sendCustomMessage({ customType, content, ... }, { deliverAs?, triggerTurn? })` diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6c0717f8a1f2..be9e367c475c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -625,6 +625,7 @@ ### Added +- Extensions can pass `expandPromptTemplates: true` to `pi.sendUserMessage()` so a `/skill:` command or prompt template they send expands like typed input, matching Pi; Pi extensions with skill-alias commands now load the skill instead of sending the literal command text - Added an agents HUD pill counting running subagents, opening the agent hub on click - In Tern the thinking level shows as the composer model chip's icon instead of a separate chip, still cycling on click, while `statusLine.compactThinkingLevel` (Compact Thinking Level, on by default) is on - Added `computer.zoom()` and window-local `zoom()` in JavaScript and Python Eval, with native-detail region captures that preserve full-screenshot click coordinates. diff --git a/packages/coding-agent/src/session/agent-session-types.ts b/packages/coding-agent/src/session/agent-session-types.ts index a80f040671fd..7b45412c9b08 100644 --- a/packages/coding-agent/src/session/agent-session-types.ts +++ b/packages/coding-agent/src/session/agent-session-types.ts @@ -485,6 +485,8 @@ export interface SendUserMessageOptions { deliverAs?: "steer" | "followUp" | "aside"; /** Explicit billing/initiator attribution. */ attribution?: MessageAttribution; + /** Expand a registered `/skill:` command and prompt templates (default: false). */ + expandPromptTemplates?: boolean; } /** Result from a handoff operation. */ diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 760fad6050a6..ddb337ca8b47 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -161,7 +161,7 @@ import { createExtensionModelQuery } from "../extensibility/extensions/model-api import type { CompactOptions, ContextUsage } from "../extensibility/extensions/types"; import type { CustomCommandContext } from "../extensibility/custom-commands/types"; import { SkillDescriptionCatalog } from "../extensibility/skill-descriptions"; -import type { Skill, SkillWarning } from "../extensibility/skills"; +import { buildSkillPromptMessage, parseSkillInvocation, type Skill, type SkillWarning } from "../extensibility/skills"; import { expandSlashCommand, type FileSlashCommand, loadSlashCommands } from "../extensibility/slash-commands"; import { normalizeToolEventInput, resolveToolEventInput } from "../extensibility/tool-event-input"; import { GoalRuntime } from "../goals/runtime"; @@ -8992,6 +8992,11 @@ export class AgentSession implements SettingsScope { * Omitted `deliverAs` starts a turn when idle and queues as a steer while streaming. * Explicit `deliverAs` queues without starting a turn in either state; `aside` at * an idle session instead starts a turn, since there is no live run to inject into. + * + * `expandPromptTemplates` (default false) expands a registered `/skill:` and + * prompt templates on every delivery path. Extension, custom and file slash commands + * run only through prompt() (omitted `deliverAs`, or `aside` at idle); explicit + * steer/follow-up queueing never runs commands, matching steer()/followUp(). */ async sendUserMessage( content: string | (TextContent | ImageContent)[], @@ -9017,25 +9022,49 @@ export class AgentSession implements SettingsScope { if (images.length === 0) images = undefined; } + const expand = options?.expandPromptTemplates === true; + const invocation = expand && this.skillsSettings?.enableSkillCommands ? parseSkillInvocation(text) : undefined; + const skill = invocation && this.skills.find(candidate => candidate.name === invocation.name); + if (invocation && skill) { + const built = await buildSkillPromptMessage(skill, invocation, "user"); + await this.promptCustomMessage( + { + customType: SKILL_PROMPT_MESSAGE_TYPE, + content: images ? [{ type: "text", text: built.message }, ...images] : built.message, + display: true, + details: built.details, + attribution: options?.attribution ?? "user", + }, + { + streamingBehavior: options?.deliverAs ?? "steer", + queueOnly: options?.deliverAs === "steer" || options?.deliverAs === "followUp", + queueChipText: text, + }, + ); + return; + } + const queuedText = expand ? expandPromptTemplate(text, [...this.#promptTemplates]) : text; + const queueOptions = { attribution: options?.attribution, rawText: text }; + let deliveredAsAside = false; if (options?.deliverAs === "aside") { if (this.isStreaming) { - await this.#queueUserMessage(text, images, "aside", { attribution: options.attribution }); + await this.#queueUserMessage(queuedText, images, "aside", queueOptions); return; } // Idle: fall through to the prompt flow below (starts a turn, like an omitted // deliverAs) — there is no live run to inject an aside into. deliveredAsAside = true; } else if (options?.deliverAs === "followUp") { - await this.#queueUserMessage(text, images, "followUp", { attribution: options.attribution }); + await this.#queueUserMessage(queuedText, images, "followUp", queueOptions); return; } else if (options?.deliverAs === "steer") { - await this.#queueUserMessage(text, images, "steer", { attribution: options.attribution }); + await this.#queueUserMessage(queuedText, images, "steer", queueOptions); return; } - // Use prompt() with expandPromptTemplates: false to skip command handling and template - // expansion. prompt() awaits manual-compaction cleanup and (on the non-streaming path) + // prompt() handles commands and templates only when expandPromptTemplates is set. + // It awaits manual-compaction cleanup and (on the non-streaming path) // image normalization/vision description before dispatching, so a stream can start in // that gap; prompt() re-checks isStreaming at each await boundary and queues via // `streamingBehavior` when it does. Passing "aside" through (instead of hard-coding @@ -9043,7 +9072,7 @@ export class AgentSession implements SettingsScope { // tool-batch-aborting steer. await this.prompt(text, { attribution: options?.attribution, - expandPromptTemplates: false, + expandPromptTemplates: expand, images, streamingBehavior: deliveredAsAside ? "aside" : "steer", }); diff --git a/packages/coding-agent/test/agent-session-send-user-message-expand.test.ts b/packages/coding-agent/test/agent-session-send-user-message-expand.test.ts new file mode 100644 index 000000000000..7b9178cbf953 --- /dev/null +++ b/packages/coding-agent/test/agent-session-send-user-message-expand.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as path from "node:path"; +import { Agent } from "@oh-my-pi/pi-agent-core"; +import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; +import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { TempDir } from "@oh-my-pi/pi-utils"; +import { createAssistantMessage } from "./helpers/agent-session-setup"; + +const SKILL_BODY = "Demo skill body: count the widgets."; + +describe("AgentSession.sendUserMessage expandPromptTemplates", () => { + let tempDir: TempDir; + let authStorage: AuthStorage | undefined; + let session: AgentSession; + /** Set by a test to hold the next model turn open until it resolves `release`. */ + let heldTurn: { started: PromiseWithResolvers; release: PromiseWithResolvers } | undefined; + const observedTurns: string[] = []; + + beforeEach(async () => { + tempDir = TempDir.createSync("@pi-send-user-message-expand-"); + observedTurns.length = 0; + heldTurn = undefined; + const skillDir = path.join(tempDir.path(), "demo"); + const skillPath = path.join(skillDir, "SKILL.md"); + await Bun.write(skillPath, `---\nname: demo\ndescription: Demo skill\n---\n\n${SKILL_BODY}\n`); + + authStorage = await AuthStorage.create(":memory:"); + authStorage.keys.setRuntime("anthropic", "test-key"); + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected claude-sonnet-4-5 model to exist"); + + const agent = new Agent({ + getApiKey: () => "test-key", + initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] }, + convertToLlm, + streamFn: (_model, context) => { + const last = context.messages.at(-1); + const content = last?.content; + observedTurns.push( + typeof content === "string" + ? content + : Array.isArray(content) + ? content.map(block => (block.type === "text" ? block.text : "")).join("\n") + : "", + ); + const stream = new AssistantMessageEventStream(); + const finish = () => { + const response = createAssistantMessage("done"); + stream.push({ type: "start", partial: response }); + stream.push({ type: "done", reason: "stop", message: response }); + }; + const hold = heldTurn; + heldTurn = undefined; + if (hold) { + hold.release.promise.then(finish); + hold.started.resolve(); + } else queueMicrotask(finish); + return stream; + }, + }); + + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(tempDir.path()), + settings: Settings.isolated({ "compaction.enabled": false }), + modelRegistry: new ModelRegistry(authStorage), + skills: [{ name: "demo", description: "Demo skill", filePath: skillPath, baseDir: skillDir, source: "test" }], + skillsSettings: { enableSkillCommands: true }, + }); + }); + + afterEach(async () => { + await session?.dispose(); + authStorage?.close(); + authStorage = undefined; + tempDir.removeSync(); + }); + + it("keeps extension text literal by default and expands a /skill: command on opt-in", async () => { + await session.sendUserMessage("/skill:demo first"); + await session.waitForIdle(); + await session.sendUserMessage("/skill:demo second", { expandPromptTemplates: true }); + await session.waitForIdle(); + + expect(observedTurns[0]).toBe("/skill:demo first"); + expect(observedTurns[1]).toContain(SKILL_BODY); + expect(observedTurns[1]).toContain("second"); + }); + + it("expands a /skill: command queued as a follow-up while the agent is streaming", async () => { + const hold = { started: Promise.withResolvers(), release: Promise.withResolvers() }; + heldTurn = hold; + const first = session.sendUserMessage("start"); + await hold.started.promise; + await session.sendUserMessage("/skill:demo queued", { expandPromptTemplates: true, deliverAs: "followUp" }); + hold.release.resolve(); + await first; + await session.waitForIdle(); + + expect(observedTurns).toHaveLength(2); + expect(observedTurns[1]).toContain(SKILL_BODY); + expect(observedTurns[1]).toContain("queued"); + }); +}); From f09914c133b1be3aa161d7ce49e54335bcca243d Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Tue, 6 Oct 2026 12:57:52 +0000 Subject: [PATCH 2/7] fix(coding-agent): admit skill sends before reading SKILL.md; cover expand branches --- .../src/extensibility/extensions/types.ts | 4 ++- .../coding-agent/src/session/agent-session.ts | 36 ++++++++++--------- ...t-session-send-user-message-expand.test.ts | 28 ++++++++++++++- 3 files changed, 50 insertions(+), 18 deletions(-) diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 0a080b30a485..95a02f69273e 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -1830,7 +1830,9 @@ export interface ExtensionAPI { /** Send a user prompt: idle starts a turn; streaming queues as steer unless deliverAs is set. * `deliverAs: "aside"` injects at the next step boundary without interrupting the in-flight tool - * batch while streaming, except that it ends a running interruptible `wait`; idle still starts a turn. */ + * batch while streaming, except that it ends a running interruptible `wait`; idle still starts a turn. + * Text is sent as written unless `expandPromptTemplates` is set, which expands `/skill:` commands + * and prompt templates. */ sendUserMessage(content: string | (TextContent | ImageContent)[], options?: SendUserMessageOptions): void; /** Append a custom entry to the session for state persistence (not sent to LLM). */ diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index ddb337ca8b47..4c5490fb0a96 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -8996,7 +8996,7 @@ export class AgentSession implements SettingsScope { * `expandPromptTemplates` (default false) expands a registered `/skill:` and * prompt templates on every delivery path. Extension, custom and file slash commands * run only through prompt() (omitted `deliverAs`, or `aside` at idle); explicit - * steer/follow-up queueing never runs commands, matching steer()/followUp(). + * steer/follow-up queueing sends any other `/` text to the model as written. */ async sendUserMessage( content: string | (TextContent | ImageContent)[], @@ -9026,21 +9026,25 @@ export class AgentSession implements SettingsScope { const invocation = expand && this.skillsSettings?.enableSkillCommands ? parseSkillInvocation(text) : undefined; const skill = invocation && this.skills.find(candidate => candidate.name === invocation.name); if (invocation && skill) { - const built = await buildSkillPromptMessage(skill, invocation, "user"); - await this.promptCustomMessage( - { - customType: SKILL_PROMPT_MESSAGE_TYPE, - content: images ? [{ type: "text", text: built.message }, ...images] : built.message, - display: true, - details: built.details, - attribution: options?.attribution ?? "user", - }, - { - streamingBehavior: options?.deliverAs ?? "steer", - queueOnly: options?.deliverAs === "steer" || options?.deliverAs === "followUp", - queueChipText: text, - }, - ); + // Admit before reading SKILL.md so shutdown and settle checks see this send, + // as they see an option-off send admitted synchronously by prompt(). + await this.#admitSubmission(async () => { + const built = await buildSkillPromptMessage(skill, invocation, "user"); + await this.promptCustomMessage( + { + customType: SKILL_PROMPT_MESSAGE_TYPE, + content: images ? [{ type: "text", text: built.message }, ...images] : built.message, + display: true, + details: built.details, + attribution: options?.attribution ?? "user", + }, + { + streamingBehavior: options?.deliverAs ?? "steer", + queueOnly: options?.deliverAs === "steer" || options?.deliverAs === "followUp", + queueChipText: text, + }, + ); + }); return; } const queuedText = expand ? expandPromptTemplate(text, [...this.#promptTemplates]) : text; diff --git a/packages/coding-agent/test/agent-session-send-user-message-expand.test.ts b/packages/coding-agent/test/agent-session-send-user-message-expand.test.ts index 7b9178cbf953..30dff0c97126 100644 --- a/packages/coding-agent/test/agent-session-send-user-message-expand.test.ts +++ b/packages/coding-agent/test/agent-session-send-user-message-expand.test.ts @@ -21,11 +21,13 @@ describe("AgentSession.sendUserMessage expandPromptTemplates", () => { /** Set by a test to hold the next model turn open until it resolves `release`. */ let heldTurn: { started: PromiseWithResolvers; release: PromiseWithResolvers } | undefined; const observedTurns: string[] = []; + let skillsSettings: { enableSkillCommands: boolean }; beforeEach(async () => { tempDir = TempDir.createSync("@pi-send-user-message-expand-"); observedTurns.length = 0; heldTurn = undefined; + skillsSettings = { enableSkillCommands: true }; const skillDir = path.join(tempDir.path(), "demo"); const skillPath = path.join(skillDir, "SKILL.md"); await Bun.write(skillPath, `---\nname: demo\ndescription: Demo skill\n---\n\n${SKILL_BODY}\n`); @@ -71,7 +73,8 @@ describe("AgentSession.sendUserMessage expandPromptTemplates", () => { settings: Settings.isolated({ "compaction.enabled": false }), modelRegistry: new ModelRegistry(authStorage), skills: [{ name: "demo", description: "Demo skill", filePath: skillPath, baseDir: skillDir, source: "test" }], - skillsSettings: { enableSkillCommands: true }, + skillsSettings, + promptTemplates: [{ name: "tpl", description: "Demo template", content: "TPL BODY $1", source: "(test)" }], }); }); @@ -93,6 +96,29 @@ describe("AgentSession.sendUserMessage expandPromptTemplates", () => { expect(observedTurns[1]).toContain("second"); }); + it("keeps /skill: text literal when skill commands are disabled", async () => { + skillsSettings.enableSkillCommands = false; + await session.sendUserMessage("/skill:demo off", { expandPromptTemplates: true }); + await session.waitForIdle(); + + expect(observedTurns).toEqual(["/skill:demo off"]); + }); + + it("queues an idle explicit follow-up skill without starting a turn", async () => { + await session.sendUserMessage("/skill:demo later", { expandPromptTemplates: true, deliverAs: "followUp" }); + + expect(observedTurns).toHaveLength(0); + expect(session.queuedMessageCount).toBe(1); + }); + + it("queues an expanded prompt template that the submitted text can still remove", async () => { + await session.sendUserMessage("/tpl x", { expandPromptTemplates: true, deliverAs: "steer" }); + + expect(session.getQueuedMessages().steering).toEqual(["TPL BODY x"]); + expect(session.removeQueuedMessage("/tpl x", "steering")).toBe(true); + expect(session.queuedMessageCount).toBe(0); + }); + it("expands a /skill: command queued as a follow-up while the agent is streaming", async () => { const hold = { started: Promise.withResolvers(), release: Promise.withResolvers() }; heldTurn = hold; From 6fff59c16c37e823523a7cce60de63a4a5091679 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Tue, 6 Oct 2026 13:11:32 +0000 Subject: [PATCH 3/7] fix(coding-agent): drop an expanded skill send when abort or a session change lands during the SKILL.md read --- .../coding-agent/src/session/agent-session.ts | 11 ++++++++++ ...t-session-send-user-message-expand.test.ts | 20 ++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 4c5490fb0a96..f30cb8a20a7c 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -9029,7 +9029,18 @@ export class AgentSession implements SettingsScope { // Admit before reading SKILL.md so shutdown and settle checks see this send, // as they see an option-off send admitted synchronously by prompt(). await this.#admitSubmission(async () => { + // abort() or a session change during the read must drop the send, like prompt()'s + // image preprocessing does, instead of starting a turn the user just stopped. + const promptGeneration = this.#promptGeneration; + const sessionGeneration = this.#sessionGeneration; const built = await buildSkillPromptMessage(skill, invocation, "user"); + if ( + this.#promptGeneration !== promptGeneration || + this.#sessionGeneration !== sessionGeneration || + this.#isDisposed + ) { + return; + } await this.promptCustomMessage( { customType: SKILL_PROMPT_MESSAGE_TYPE, diff --git a/packages/coding-agent/test/agent-session-send-user-message-expand.test.ts b/packages/coding-agent/test/agent-session-send-user-message-expand.test.ts index 30dff0c97126..fb2c40af30a1 100644 --- a/packages/coding-agent/test/agent-session-send-user-message-expand.test.ts +++ b/packages/coding-agent/test/agent-session-send-user-message-expand.test.ts @@ -1,10 +1,11 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import * as skillsModule from "@oh-my-pi/pi-coding-agent/extensibility/skills"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; @@ -79,6 +80,7 @@ describe("AgentSession.sendUserMessage expandPromptTemplates", () => { }); afterEach(async () => { + vi.restoreAllMocks(); await session?.dispose(); authStorage?.close(); authStorage = undefined; @@ -119,6 +121,22 @@ describe("AgentSession.sendUserMessage expandPromptTemplates", () => { expect(session.queuedMessageCount).toBe(0); }); + it("drops a skill send when abort lands while SKILL.md is read", async () => { + const read = Promise.withResolvers(); + const build = skillsModule.buildSkillPromptMessage; + vi.spyOn(skillsModule, "buildSkillPromptMessage").mockImplementation(async (...args) => { + await read.promise; + return build(...args); + }); + const send = session.sendUserMessage("/skill:demo late", { expandPromptTemplates: true }); + await session.abort(); + read.resolve(); + await send; + await session.waitForIdle(); + + expect(observedTurns).toHaveLength(0); + }); + it("expands a /skill: command queued as a follow-up while the agent is streaming", async () => { const hold = { started: Promise.withResolvers(), release: Promise.withResolvers() }; heldTurn = hold; From c24b976f7c5303070948dadc18989fd2ab33fe25 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Tue, 6 Oct 2026 13:26:17 +0000 Subject: [PATCH 4/7] fix(coding-agent): drop a queued custom message when abort or a session change lands during image preparation --- .../coding-agent/src/session/agent-session.ts | 9 +++++++ ...t-session-send-user-message-expand.test.ts | 26 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index f30cb8a20a7c..a8ef1539dd2c 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -8722,6 +8722,7 @@ export class AgentSession implements SettingsScope { ): Promise { // Captured before the normalization await below — see #sessionGeneration's doc comment. const sessionGeneration = this.#sessionGeneration; + const promptGeneration = this.#promptGeneration; const details = options?.queueChipText !== undefined ? ({ @@ -8750,6 +8751,14 @@ export class AgentSession implements SettingsScope { const descriptionNotice = preprocessed ? preprocessed.descriptionNotice : await this.#buildSkillImageDescriptionNotice(normalizedAppMessage); + // abort() clears the steer/follow-up queues; a message still being normalized when it + // lands must not be queued afterwards and restart the stopped run (as in #queueUserMessage). + if ( + deliverAs !== "aside" && + (this.#promptGeneration !== promptGeneration || this.#sessionGeneration !== sessionGeneration) + ) { + return; + } if (deliverAs === "aside") { if (await this.#sessionGenerationChanged(sessionGeneration)) return; // Non-interrupting: rides the same step-boundary aside poll as diff --git a/packages/coding-agent/test/agent-session-send-user-message-expand.test.ts b/packages/coding-agent/test/agent-session-send-user-message-expand.test.ts index fb2c40af30a1..c583a5157d08 100644 --- a/packages/coding-agent/test/agent-session-send-user-message-expand.test.ts +++ b/packages/coding-agent/test/agent-session-send-user-message-expand.test.ts @@ -6,6 +6,7 @@ import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import * as skillsModule from "@oh-my-pi/pi-coding-agent/extensibility/skills"; +import * as imageLoading from "@oh-my-pi/pi-coding-agent/utils/image-loading"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; @@ -137,6 +138,31 @@ describe("AgentSession.sendUserMessage expandPromptTemplates", () => { expect(observedTurns).toHaveLength(0); }); + it("drops a queued skill with images when abort lands during image normalization", async () => { + const normalizing = Promise.withResolvers(); + const normalized = Promise.withResolvers(); + vi.spyOn(imageLoading, "normalizeModelContextImages").mockImplementation(async images => { + normalizing.resolve(); + await normalized.promise; + return images; + }); + const send = session.sendUserMessage( + [ + { type: "text", text: "/skill:demo look" }, + { type: "image", data: "aW1n", mimeType: "image/png" }, + ], + { expandPromptTemplates: true, deliverAs: "followUp" }, + ); + await normalizing.promise; + await session.abort(); + normalized.resolve(); + await send; + await session.waitForIdle(); + + expect(session.queuedMessageCount).toBe(0); + expect(observedTurns).toHaveLength(0); + }); + it("expands a /skill: command queued as a follow-up while the agent is streaming", async () => { const hold = { started: Promise.withResolvers(), release: Promise.withResolvers() }; heldTurn = hold; From 79d6fe8a520d99ad2a81d08d96ad46083a5e3b61 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Tue, 6 Oct 2026 13:58:43 +0000 Subject: [PATCH 5/7] fix(coding-agent): report dropped queued custom messages; keep agent-attributed expanded skills user-role --- .../coding-agent/src/session/agent-session.ts | 35 +++++++++++------- ...t-session-send-user-message-expand.test.ts | 36 +++++++++++++------ 2 files changed, 48 insertions(+), 23 deletions(-) diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index a8ef1539dd2c..b73285634141 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -7675,13 +7675,13 @@ export class AgentSession implements SettingsScope { throw new AgentBusyError(); } - await this.#queueCustomMessage(message, streamingBehavior, { + const queued = await this.#queueCustomMessage(message, streamingBehavior, { queueChipText: options?.queueChipText, prependMessages: keywordNotices, onPromptAdmitted: options?.onPromptAdmitted, }); - outcome.sessionClaimed = true; - return true; + outcome.sessionClaimed = queued; + return queued; } const customMessage: CustomMessage = { @@ -7720,14 +7720,14 @@ export class AgentSession implements SettingsScope { outcome.sessionClaimed = this.agent.state.isStreaming; throw new AgentBusyError(); } - await this.#queueCustomMessage(message, streamingBehavior, { + const queued = await this.#queueCustomMessage(message, streamingBehavior, { queueChipText: options?.queueChipText, preprocessed: { content: preparedMessage.content, descriptionNotice }, prependMessages: keywordNotices, onPromptAdmitted: options?.onPromptAdmitted, }); - outcome.sessionClaimed = true; - return true; + outcome.sessionClaimed = queued; + return queued; } outcome.sessionClaimed = await this.#promptWithMessage(preparedMessage, textContent, { ...options, @@ -8706,7 +8706,8 @@ export class AgentSession implements SettingsScope { } } - /** Queue a custom message without starting a turn, matching steer/follow-up/aside delivery. */ + /** Queue a custom message without starting a turn, matching steer/follow-up/aside delivery. + * Resolves false when an abort or session change during image preparation dropped it. */ async #queueCustomMessage( message: Pick, "customType" | "content" | "display" | "details" | "attribution">, deliverAs: "steer" | "followUp" | "aside", @@ -8719,7 +8720,7 @@ export class AgentSession implements SettingsScope { /** Called synchronously once the message is pushed onto its queue. See {@link PromptOptions.onPromptAdmitted}. */ onPromptAdmitted?: () => void; }, - ): Promise { + ): Promise { // Captured before the normalization await below — see #sessionGeneration's doc comment. const sessionGeneration = this.#sessionGeneration; const promptGeneration = this.#promptGeneration; @@ -8757,10 +8758,10 @@ export class AgentSession implements SettingsScope { deliverAs !== "aside" && (this.#promptGeneration !== promptGeneration || this.#sessionGeneration !== sessionGeneration) ) { - return; + return false; } if (deliverAs === "aside") { - if (await this.#sessionGenerationChanged(sessionGeneration)) return; + if (await this.#sessionGenerationChanged(sessionGeneration)) return false; // Non-interrupting: rides the same step-boundary aside poll as // sendCustomMessage's streaming aside branch — not an agent-core queue // entry, so no drain-retry latch and no idle-queue drain scheduling. @@ -8775,7 +8776,7 @@ export class AgentSession implements SettingsScope { // left to drain it. Resuming here is a no-op while streaming and wakes/folds // correctly once idle, matching #queueUserMessage's aside branch. this.#resumeStrandedIrcAsides(); - return; + return true; } this.#allowQueuedMessageDrainRetry(); // Keyword notices and their user message must enter the queue in one synchronous phase. @@ -8790,6 +8791,7 @@ export class AgentSession implements SettingsScope { } onPromptAdmitted?.(); this.#scheduleIdleQueueDrain(); + return true; } /** @@ -9050,13 +9052,20 @@ export class AgentSession implements SettingsScope { ) { return; } + const skillContent = images ? [{ type: "text" as const, text: built.message }, ...images] : built.message; + if (options?.attribution === "agent") { + // The skill custom message is user-invoked by definition; an agent-attributed send + // stays a plain user-role message, like every other sendUserMessage path. + await this.sendUserMessage(skillContent, { deliverAs: options.deliverAs, attribution: "agent" }); + return; + } await this.promptCustomMessage( { customType: SKILL_PROMPT_MESSAGE_TYPE, - content: images ? [{ type: "text", text: built.message }, ...images] : built.message, + content: skillContent, display: true, details: built.details, - attribution: options?.attribution ?? "user", + attribution: "user", }, { streamingBehavior: options?.deliverAs ?? "steer", diff --git a/packages/coding-agent/test/agent-session-send-user-message-expand.test.ts b/packages/coding-agent/test/agent-session-send-user-message-expand.test.ts index c583a5157d08..1216d9016468 100644 --- a/packages/coding-agent/test/agent-session-send-user-message-expand.test.ts +++ b/packages/coding-agent/test/agent-session-send-user-message-expand.test.ts @@ -9,7 +9,7 @@ import * as skillsModule from "@oh-my-pi/pi-coding-agent/extensibility/skills"; import * as imageLoading from "@oh-my-pi/pi-coding-agent/utils/image-loading"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; -import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; +import { convertToLlm, SKILL_PROMPT_MESSAGE_TYPE } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TempDir } from "@oh-my-pi/pi-utils"; import { createAssistantMessage } from "./helpers/agent-session-setup"; @@ -23,11 +23,13 @@ describe("AgentSession.sendUserMessage expandPromptTemplates", () => { /** Set by a test to hold the next model turn open until it resolves `release`. */ let heldTurn: { started: PromiseWithResolvers; release: PromiseWithResolvers } | undefined; const observedTurns: string[] = []; + const observedRoles: string[] = []; let skillsSettings: { enableSkillCommands: boolean }; beforeEach(async () => { tempDir = TempDir.createSync("@pi-send-user-message-expand-"); observedTurns.length = 0; + observedRoles.length = 0; heldTurn = undefined; skillsSettings = { enableSkillCommands: true }; const skillDir = path.join(tempDir.path(), "demo"); @@ -46,6 +48,7 @@ describe("AgentSession.sendUserMessage expandPromptTemplates", () => { streamFn: (_model, context) => { const last = context.messages.at(-1); const content = last?.content; + observedRoles.push(last?.role ?? ""); observedTurns.push( typeof content === "string" ? content @@ -138,7 +141,7 @@ describe("AgentSession.sendUserMessage expandPromptTemplates", () => { expect(observedTurns).toHaveLength(0); }); - it("drops a queued skill with images when abort lands during image normalization", async () => { + it("reports a queued custom message dropped by abort during image normalization", async () => { const normalizing = Promise.withResolvers(); const normalized = Promise.withResolvers(); vi.spyOn(imageLoading, "normalizeModelContextImages").mockImplementation(async images => { @@ -146,23 +149,36 @@ describe("AgentSession.sendUserMessage expandPromptTemplates", () => { await normalized.promise; return images; }); - const send = session.sendUserMessage( - [ - { type: "text", text: "/skill:demo look" }, - { type: "image", data: "aW1n", mimeType: "image/png" }, - ], - { expandPromptTemplates: true, deliverAs: "followUp" }, + const send = session.promptCustomMessage( + { + customType: SKILL_PROMPT_MESSAGE_TYPE, + content: [ + { type: "text", text: "skill body" }, + { type: "image", data: "aW1n", mimeType: "image/png" }, + ], + display: true, + attribution: "user", + }, + { streamingBehavior: "followUp", queueOnly: true }, ); await normalizing.promise; await session.abort(); normalized.resolve(); - await send; - await session.waitForIdle(); + expect(await send).toBe(false); + await session.waitForIdle(); expect(session.queuedMessageCount).toBe(0); expect(observedTurns).toHaveLength(0); }); + it("delivers an agent-attributed expanded skill as a user-role message", async () => { + await session.sendUserMessage("/skill:demo relay", { expandPromptTemplates: true, attribution: "agent" }); + await session.waitForIdle(); + + expect(observedRoles).toEqual(["user"]); + expect(observedTurns[0]).toContain(SKILL_BODY); + }); + it("expands a /skill: command queued as a follow-up while the agent is streaming", async () => { const hold = { started: Promise.withResolvers(), release: Promise.withResolvers() }; heldTurn = hold; From f41e9b7f553504f79395f8559d7feee35caae597 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Tue, 6 Oct 2026 14:32:34 +0000 Subject: [PATCH 6/7] fix(coding-agent): drop a queued custom message when disposal begins during image preparation --- .../coding-agent/src/session/agent-session.ts | 9 +-- ...t-session-send-user-message-expand.test.ts | 60 ++++++++++--------- 2 files changed, 37 insertions(+), 32 deletions(-) diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index b73285634141..7a90e55bdd89 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -8752,11 +8752,12 @@ export class AgentSession implements SettingsScope { const descriptionNotice = preprocessed ? preprocessed.descriptionNotice : await this.#buildSkillImageDescriptionNotice(normalizedAppMessage); - // abort() clears the steer/follow-up queues; a message still being normalized when it - // lands must not be queued afterwards and restart the stopped run (as in #queueUserMessage). + // abort() clears the steer/follow-up queues and disposal flushes every queue; a message + // still being prepared when either lands must not be queued afterwards (as in #queueUserMessage). if ( - deliverAs !== "aside" && - (this.#promptGeneration !== promptGeneration || this.#sessionGeneration !== sessionGeneration) + this.#isDisposed || + (deliverAs !== "aside" && + (this.#promptGeneration !== promptGeneration || this.#sessionGeneration !== sessionGeneration)) ) { return false; } diff --git a/packages/coding-agent/test/agent-session-send-user-message-expand.test.ts b/packages/coding-agent/test/agent-session-send-user-message-expand.test.ts index 1216d9016468..93d13e737be4 100644 --- a/packages/coding-agent/test/agent-session-send-user-message-expand.test.ts +++ b/packages/coding-agent/test/agent-session-send-user-message-expand.test.ts @@ -141,35 +141,39 @@ describe("AgentSession.sendUserMessage expandPromptTemplates", () => { expect(observedTurns).toHaveLength(0); }); - it("reports a queued custom message dropped by abort during image normalization", async () => { - const normalizing = Promise.withResolvers(); - const normalized = Promise.withResolvers(); - vi.spyOn(imageLoading, "normalizeModelContextImages").mockImplementation(async images => { - normalizing.resolve(); - await normalized.promise; - return images; + for (const [label, interrupt] of [ + ["abort", (target: AgentSession) => target.abort()], + ["disposal", (target: AgentSession) => target.beginDispose()], + ] as const) { + it(`reports a queued custom message dropped by ${label} during image normalization`, async () => { + const normalizing = Promise.withResolvers(); + const normalized = Promise.withResolvers(); + vi.spyOn(imageLoading, "normalizeModelContextImages").mockImplementation(async images => { + normalizing.resolve(); + await normalized.promise; + return images; + }); + const send = session.promptCustomMessage( + { + customType: SKILL_PROMPT_MESSAGE_TYPE, + content: [ + { type: "text", text: "skill body" }, + { type: "image", data: "aW1n", mimeType: "image/png" }, + ], + display: true, + attribution: "user", + }, + { streamingBehavior: "followUp", queueOnly: true }, + ); + await normalizing.promise; + await interrupt(session); + normalized.resolve(); + + expect(await send).toBe(false); + expect(session.queuedMessageCount).toBe(0); + expect(observedTurns).toHaveLength(0); }); - const send = session.promptCustomMessage( - { - customType: SKILL_PROMPT_MESSAGE_TYPE, - content: [ - { type: "text", text: "skill body" }, - { type: "image", data: "aW1n", mimeType: "image/png" }, - ], - display: true, - attribution: "user", - }, - { streamingBehavior: "followUp", queueOnly: true }, - ); - await normalizing.promise; - await session.abort(); - normalized.resolve(); - - expect(await send).toBe(false); - await session.waitForIdle(); - expect(session.queuedMessageCount).toBe(0); - expect(observedTurns).toHaveLength(0); - }); + } it("delivers an agent-attributed expanded skill as a user-role message", async () => { await session.sendUserMessage("/skill:demo relay", { expandPromptTemplates: true, attribution: "agent" }); From cfd24b5355790f1e82ef7e19e01091d8d5f039a8 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Tue, 6 Oct 2026 15:32:36 +0000 Subject: [PATCH 7/7] docs(coding-agent): attribute the sendUserMessage expand changelog entry --- packages/coding-agent/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index be9e367c475c..cb49f3b73f61 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -625,7 +625,7 @@ ### Added -- Extensions can pass `expandPromptTemplates: true` to `pi.sendUserMessage()` so a `/skill:` command or prompt template they send expands like typed input, matching Pi; Pi extensions with skill-alias commands now load the skill instead of sending the literal command text +- Extensions can pass `expandPromptTemplates: true` to `pi.sendUserMessage()` so a `/skill:` command or prompt template they send expands like typed input, matching Pi; Pi extensions with skill-alias commands now load the skill instead of sending the literal command text ([#14608](https://github.com/can1357/oh-my-pi/pull/14608) by [@andrebrait](https://github.com/andrebrait)) - Added an agents HUD pill counting running subagents, opening the agent hub on click - In Tern the thinking level shows as the composer model chip's icon instead of a separate chip, still cycling on click, while `statusLine.compactThinkingLevel` (Compact Thinking Level, on by default) is on - Added `computer.zoom()` and window-local `zoom()` in JavaScript and Python Eval, with native-detail region captures that preserve full-screenshot click coordinates.