diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c98881efa..e4f622e18 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `/skill:` prompts submitted during compaction so they are re-invoked as user-attributed skill prompts instead of being dropped or treated as plain text. + ## [16.2.2] - 2026-06-27 ### Added diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 55c70adaa..fc39b33b9 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -13,9 +13,10 @@ import { TinyTitleDownloadProgressComponent } from "../../modes/components/tiny- import { expandEmoticons } from "../../modes/emoji-autocomplete"; import { materializeImageReferenceLinks, shiftImageMarkers } from "../../modes/image-references"; import { createPromptActionAutocompleteProvider } from "../../modes/prompt-action-autocomplete"; +import { invokeSkillCommandFromText, isKnownSkillCommand } from "../../modes/skill-command"; import type { InteractiveModeContext } from "../../modes/types"; import manualContinuePrompt from "../../prompts/system/manual-continue.md" with { type: "text" }; -import { SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails, USER_INTERRUPT_LABEL } from "../../session/messages"; +import { USER_INTERRUPT_LABEL } from "../../session/messages"; import { executeBuiltinSlashCommand } from "../../slash-commands/builtin-registry"; import { isTinyTitleLocalModelKey } from "../../tiny/models"; import { isLowSignalTitleInput } from "../../tiny/text"; @@ -715,11 +716,18 @@ export class InputController { } // Handle skill commands (/skill:name [args]). Enter ⇒ steer (matches the - // free-text Enter semantics applied a few lines below at the streaming - // branch). Ctrl+Enter routes through `handleFollowUp` and dispatches the - // same helper with `"followUp"`. - if (text && (await this.#invokeSkillCommand(text, "steer"))) { - return; + // free-text Enter semantics below); Ctrl+Enter routes through `handleFollowUp`. + // During compaction, queue immediately so bash/python/loop-mode branches do + // not consume the skill before the compaction-resume path re-parses it. + if (text && isKnownSkillCommand(this.ctx, text)) { + if (this.ctx.session.isCompacting) { + const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined; + this.ctx.queueCompactionMessage(text, "steer", images); + return; + } + if (await this.#invokeSkillCommand(text, "steer")) { + return; + } } // Handle bash command (! for normal, !! for excluded from context) @@ -1086,47 +1094,15 @@ export class InputController { * ignores it. */ async #invokeSkillCommand(text: string, streamingBehavior: "steer" | "followUp"): Promise { - if (!text.startsWith("/skill:")) return false; - const spaceIndex = text.indexOf(" "); - const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); - const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim(); - const skillPath = this.ctx.skillCommands?.get(commandName); - if (!skillPath) return false; + if (!isKnownSkillCommand(this.ctx, text)) return false; this.ctx.editor.addToHistory(text); this.ctx.editor.setText(""); - try { - const content = await Bun.file(skillPath).text(); - const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim(); - const metaLines = [`Skill: ${skillPath}`]; - if (args) { - metaLines.push(`User: ${args}`); - } - const message = `${body}\n\n---\n\n${metaLines.join("\n")}`; - const skillName = commandName.slice("skill:".length); - const details: SkillPromptDetails = { - name: skillName || commandName, - path: skillPath, - args: args || undefined, - lineCount: body ? body.split("\n").length : 0, - }; - await this.ctx.session.promptCustomMessage( - { - customType: SKILL_PROMPT_MESSAGE_TYPE, - content: message, - display: true, - details, - attribution: "user", - }, - { streamingBehavior, queueChipText: text }, - ); - if (this.ctx.session.isStreaming) { - this.ctx.updatePendingMessagesDisplay(); - this.ctx.ui.requestRender(); - } - } catch (err) { - this.ctx.showError(`Failed to load skill: ${err instanceof Error ? err.message : String(err)}`); + const handled = await invokeSkillCommandFromText(this.ctx, text, streamingBehavior); + if (this.ctx.session.isStreaming) { + this.ctx.updatePendingMessagesDisplay(); + this.ctx.ui.requestRender(); } - return true; + return handled; } async handleRetry(): Promise { @@ -1159,9 +1135,8 @@ export class InputController { // Compaction first: while compacting, free text gets queued via // `queueCompactionMessage`, and `/skill:*` rides the same queue so a // skill typed during compaction is not lost or short-circuited through - // `promptCustomMessage`. The skill text is queued verbatim; whether - // the queued entry is later re-parsed into a skill invocation is a - // separate concern owned by the compaction-resume path. + // `promptCustomMessage`. The compaction-resume path re-parses the + // queued text into a user-attributed skill invocation before delivery. if (this.ctx.session.isCompacting) { const images = this.ctx.editor.pendingImages.length > 0 ? [...this.ctx.editor.pendingImages] : undefined; this.ctx.queueCompactionMessage(text, "followUp", images); diff --git a/packages/coding-agent/src/modes/skill-command.ts b/packages/coding-agent/src/modes/skill-command.ts new file mode 100644 index 000000000..151292563 --- /dev/null +++ b/packages/coding-agent/src/modes/skill-command.ts @@ -0,0 +1,109 @@ +import { type CustomMessage, SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "../session/messages"; +import type { InteractiveModeContext } from "./types"; + +type SkillCommandHost = Pick; + +type SkillPromptMessage = Pick< + CustomMessage, + "customType" | "content" | "display" | "details" | "attribution" +> & { + customType: typeof SKILL_PROMPT_MESSAGE_TYPE; + content: string; + display: true; + details: SkillPromptDetails; + attribution: "user"; +}; + +type SkillPromptOptions = { + streamingBehavior: "steer" | "followUp"; + queueChipText: string; +}; + +interface ParsedSkillCommand { + commandName: string; + args: string; +} + +interface InvokeSkillCommandOptions { + propagateErrors?: boolean; +} + +/** Built custom-message payload and delivery options for a `/skill:` command. */ +export interface BuiltSkillCommandPrompt { + message: SkillPromptMessage; + options: SkillPromptOptions; +} + +function parseSkillCommand(text: string): ParsedSkillCommand | undefined { + if (!text.startsWith("/skill:")) return undefined; + const spaceIndex = text.indexOf(" "); + const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); + const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim(); + return { commandName, args }; +} + +/** Return true when `text` names a registered `/skill:` command. */ +export function isKnownSkillCommand(ctx: SkillCommandHost, text: string): boolean { + const parsed = parseSkillCommand(text); + if (!parsed) return false; + return ctx.skillCommands.has(parsed.commandName); +} + +/** Build the user-attributed custom message for a registered `/skill:` command. */ +export async function buildSkillCommandPrompt( + ctx: SkillCommandHost, + text: string, + streamingBehavior: "steer" | "followUp", +): Promise { + const parsed = parseSkillCommand(text); + if (!parsed) return undefined; + const skillPath = ctx.skillCommands.get(parsed.commandName); + if (!skillPath) return undefined; + + const content = await Bun.file(skillPath).text(); + const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim(); + const metaLines = [`Skill: ${skillPath}`]; + if (parsed.args) { + metaLines.push(`User: ${parsed.args}`); + } + const message = `${body}\n\n---\n\n${metaLines.join("\n")}`; + const skillName = parsed.commandName.slice("skill:".length); + const details: SkillPromptDetails = { + name: skillName || parsed.commandName, + path: skillPath, + args: parsed.args || undefined, + lineCount: body ? body.split("\n").length : 0, + }; + + return { + message: { + customType: SKILL_PROMPT_MESSAGE_TYPE, + content: message, + display: true, + details, + attribution: "user", + }, + options: { streamingBehavior, queueChipText: text }, + }; +} + +/** Invoke a registered `/skill:` command as a user-attributed custom message. */ +export async function invokeSkillCommandFromText( + ctx: SkillCommandHost, + text: string, + streamingBehavior: "steer" | "followUp", + options?: InvokeSkillCommandOptions, +): Promise { + try { + const built = await buildSkillCommandPrompt(ctx, text, streamingBehavior); + if (!built) return false; + await ctx.session.promptCustomMessage(built.message, built.options); + return true; + } catch (err) { + if (options?.propagateErrors) { + throw err; + } + ctx.showError(`Failed to load skill: ${err instanceof Error ? err.message : String(err)}`); + return true; + } +} diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index 09d7ce99c..33dec7ec7 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -45,6 +45,7 @@ import { type SkillPromptDetails, } from "../../session/messages"; import type { SessionContext } from "../../session/session-context"; +import { buildSkillCommandPrompt, invokeSkillCommandFromText, isKnownSkillCommand } from "../skill-command"; import { createAssistantMessageComponent } from "./interactive-context-helpers"; import { assistantHasVisibleContent, @@ -667,6 +668,9 @@ export class UiHelpers { } async #deliverQueuedMessage(message: CompactionQueuedMessage): Promise { + if (await invokeSkillCommandFromText(this.ctx, message.text, message.mode, { propagateErrors: true })) { + return; + } if (this.ctx.isKnownSlashCommand(message.text)) { await this.ctx.session.prompt(message.text); return; @@ -754,29 +758,32 @@ export class UiHelpers { await this.#deliverQueuedMessage(message); } - // Pass streamingBehavior so that if the session is still streaming when - // compaction-end fires (race window between isStreaming flipping false and - // the event landing here), prompt() routes the message into the steer/ - // follow-up queue instead of throwing AgentBusyError. When the session is - // genuinely idle, streamingBehavior is ignored and a fresh prompt runs as - // before. This keeps the steer preview honest: if delivery has to be - // deferred, the message lands in the same queue every other consumer - // (Alt+Up dequeue, post-stream drain) already drains, instead of being - // stranded in compactionQueuedMessages with no drainer. - // - // firstPrompt is fire-and-forget — its rejection is funneled through - // `restoreQueue` rather than rethrown, so we use the primitive - // recordLocalSubmission and dispose manually in the catch. - const disposeFirstPrompt = this.ctx.recordLocalSubmission(firstPrompt.text, firstPrompt.images?.length ?? 0); - const promptPromise = this.ctx.session - .prompt(firstPrompt.text, { - streamingBehavior: firstPrompt.mode === "followUp" ? "followUp" : "steer", - images: firstPrompt.images, - }) - .catch((error: unknown) => { - disposeFirstPrompt(); - restoreQueue(error); - }); + // First prompt is fire-and-forget — its rejection is funneled through + // `restoreQueue` rather than rethrown. Plain prompts use primitive + // recordLocalSubmission and dispose manually in the catch. Skill prompts + // are rebuilt as user-attributed custom messages so queued `/skill:` text + // is not sent as a literal prompt after compaction. + let promptPromise: Promise; + if (isKnownSkillCommand(this.ctx, firstPrompt.text)) { + const built = await buildSkillCommandPrompt(this.ctx, firstPrompt.text, firstPrompt.mode); + promptPromise = built + ? this.ctx.session.promptCustomMessage(built.message, built.options).catch(restoreQueue) + : Promise.resolve(); + } else { + const disposeFirstPrompt = this.ctx.recordLocalSubmission( + firstPrompt.text, + firstPrompt.images?.length ?? 0, + ); + promptPromise = this.ctx.session + .prompt(firstPrompt.text, { + streamingBehavior: firstPrompt.mode === "followUp" ? "followUp" : "steer", + images: firstPrompt.images, + }) + .catch((error: unknown) => { + disposeFirstPrompt(); + restoreQueue(error); + }); + } for (const message of rest) { await this.#deliverQueuedMessage(message); diff --git a/packages/coding-agent/test/input-controller-skill-queue.test.ts b/packages/coding-agent/test/input-controller-skill-queue.test.ts index c8a89339a..a00185b38 100644 --- a/packages/coding-agent/test/input-controller-skill-queue.test.ts +++ b/packages/coding-agent/test/input-controller-skill-queue.test.ts @@ -15,7 +15,7 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { EventController } from "@oh-my-pi/pi-coding-agent/modes/controllers/event-controller"; import { InputController } from "@oh-my-pi/pi-coding-agent/modes/controllers/input-controller"; import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; -import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import type { CompactionQueuedMessage, InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; import { UiHelpers } from "@oh-my-pi/pi-coding-agent/modes/utils/ui-helpers"; import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; @@ -35,7 +35,13 @@ type StubEditor = { type PromptCustomMessage = Mock< ( - message: { details: SkillPromptDetails }, + message: { + customType?: string; + content?: string | unknown[]; + display?: boolean; + attribution?: string; + details: SkillPromptDetails; + }, options?: { streamingBehavior?: "steer" | "followUp"; queueChipText?: string }, ) => Promise >; @@ -46,7 +52,11 @@ async function writeSkillFile(dir: string, skillName: string, body: string): Pro return skillPath; } -function createStubInputControllerContext(opts: { skillCommands: Map; isStreaming: boolean }) { +function createStubInputControllerContext(opts: { + skillCommands: Map; + isStreaming: boolean; + isCompacting?: boolean; +}) { let editorText = ""; const editor: StubEditor = { setText(text) { @@ -65,14 +75,14 @@ function createStubInputControllerContext(opts: { skillCommands: Map {}); const ctx = { editor, ui: { requestRender }, skillCommands: opts.skillCommands, session: { isStreaming: opts.isStreaming, - isCompacting: false, + isCompacting: opts.isCompacting ?? false, isBashRunning: false, isEvalRunning: false, extensionRunner: undefined, @@ -92,6 +102,7 @@ function createStubInputControllerContext(opts: { skillCommands: Map(), withLocalSubmission: async (_text: string, fn: () => unknown) => fn(), + queueCompactionMessage, } as unknown as InteractiveModeContext; return { @@ -102,6 +113,7 @@ function createStubInputControllerContext(opts: { skillCommands: Map { expect(requestRender).toHaveBeenCalledTimes(1); }); + it("queues known skill steers during compaction instead of dispatching immediately", async () => { + const { ctx, editor, promptCustomMessage, queueCompactionMessage } = createStubInputControllerContext({ + skillCommands, + isStreaming: false, + isCompacting: true, + }); + const controller = new InputController(ctx); + + controller.setupEditorSubmitHandler(); + editor.setText("/skill:test-skill arg1 arg2"); + await editor.onSubmit?.("/skill:test-skill arg1 arg2"); + + expect(queueCompactionMessage).toHaveBeenCalledWith("/skill:test-skill arg1 arg2", "steer", undefined); + expect(promptCustomMessage).not.toHaveBeenCalled(); + }); + it("passes slash-form queueChipText for streaming skill follow-ups", async () => { const { ctx, editor, promptCustomMessage } = createStubInputControllerContext({ skillCommands, @@ -189,6 +217,79 @@ describe("InputController skill queue chip metadata", () => { }); }); +describe("compaction skill re-invocation", () => { + let tempDir: TempDir; + let skillCommands: Map; + + function firstPromptCustomCall(promptCustomMessage: PromptCustomMessage) { + const call = promptCustomMessage.mock.calls[0]; + if (!call) { + throw new Error("expected promptCustomMessage to be called"); + } + return call; + } + + function createCompactionDrainContext(queuedMessages: CompactionQueuedMessage[]) { + const promptCustomMessageCalled = Promise.withResolvers(); + const promptCustomMessage: PromptCustomMessage = vi.fn(async () => { + promptCustomMessageCalled.resolve(); + }); + const prompt = vi.fn(async (_text: string, _options?: { streamingBehavior?: "steer" | "followUp" }) => {}); + const steer = vi.fn(async (_text: string, _images?: ImageContent[]) => {}); + const followUp = vi.fn(async (_text: string, _images?: ImageContent[]) => {}); + const ctx = { + skillCommands, + compactionQueuedMessages: queuedMessages, + updatePendingMessagesDisplay: vi.fn(), + showError: vi.fn(), + isKnownSlashCommand: vi.fn(() => false), + recordLocalSubmission: vi.fn((_text: string, _imageCount: number) => vi.fn()), + withLocalSubmission: vi.fn(async (_text: string, fn: () => unknown) => Promise.resolve(fn())), + session: { + promptCustomMessage, + prompt, + steer, + followUp, + clearQueue: vi.fn(), + }, + } as unknown as InteractiveModeContext; + return { ctx, promptCustomMessage, promptCustomMessageCalled, prompt, steer, followUp }; + } + + beforeEach(async () => { + tempDir = TempDir.createSync("@pi-skill-compaction-stub-"); + const skillPath = await writeSkillFile(tempDir.path(), "test-skill", "Do the thing."); + skillCommands = new Map([["skill:test-skill", skillPath]]); + }); + + afterEach(() => { + tempDir.removeSync(); + vi.restoreAllMocks(); + }); + + it("re-invokes a queued skill as a user-attributed skill prompt", async () => { + const { ctx, promptCustomMessage, promptCustomMessageCalled, prompt, steer, followUp } = + createCompactionDrainContext([{ text: "/skill:test-skill arg1 arg2", mode: "followUp" }]); + const uiHelpers = new UiHelpers(ctx); + + await uiHelpers.flushCompactionQueue({ willRetry: false }); + await promptCustomMessageCalled; + + const [message, options] = firstPromptCustomCall(promptCustomMessage); + expect(message.customType).toBe(SKILL_PROMPT_MESSAGE_TYPE); + expect(message.attribution).toBe("user"); + expect(message.content).toContain("Do the thing."); + expect(message.details).toMatchObject({ name: "test-skill", args: "arg1 arg2", lineCount: 1 }); + expect(options).toEqual({ + streamingBehavior: "followUp", + queueChipText: "/skill:test-skill arg1 arg2", + }); + expect(prompt).not.toHaveBeenCalled(); + expect(steer).not.toHaveBeenCalled(); + expect(followUp).not.toHaveBeenCalled(); + }); +}); + interface SessionFixture { tempDir: TempDir; authStorage: AuthStorage;