fix(coding-agent): preserved queued skill invocations

Rebuilt compaction-queued /skill: commands as user-attributed skill prompts when the queue drains.

Fixes #3697
This commit is contained in:
roboomp
2026-06-28 04:21:07 +00:00
parent 2708c1edb8
commit 80e772ba4d
5 changed files with 271 additions and 75 deletions
+4
View File
@@ -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
@@ -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<boolean> {
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<void> {
@@ -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);
@@ -0,0 +1,109 @@
import { type CustomMessage, SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "../session/messages";
import type { InteractiveModeContext } from "./types";
type SkillCommandHost = Pick<InteractiveModeContext, "skillCommands" | "session" | "showError">;
type SkillPromptMessage = Pick<
CustomMessage<SkillPromptDetails>,
"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:<name>` 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:<name>` command. */
export async function buildSkillCommandPrompt(
ctx: SkillCommandHost,
text: string,
streamingBehavior: "steer" | "followUp",
): Promise<BuiltSkillCommandPrompt | undefined> {
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:<name>` command as a user-attributed custom message. */
export async function invokeSkillCommandFromText(
ctx: SkillCommandHost,
text: string,
streamingBehavior: "steer" | "followUp",
options?: InvokeSkillCommandOptions,
): Promise<boolean> {
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;
}
}
@@ -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<void> {
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<unknown>;
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);
@@ -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<void>
>;
@@ -46,7 +52,11 @@ async function writeSkillFile(dir: string, skillName: string, body: string): Pro
return skillPath;
}
function createStubInputControllerContext(opts: { skillCommands: Map<string, string>; isStreaming: boolean }) {
function createStubInputControllerContext(opts: {
skillCommands: Map<string, string>;
isStreaming: boolean;
isCompacting?: boolean;
}) {
let editorText = "";
const editor: StubEditor = {
setText(text) {
@@ -65,14 +75,14 @@ function createStubInputControllerContext(opts: { skillCommands: Map<string, str
const updatePendingMessagesDisplay = vi.fn();
const requestRender = vi.fn();
const showError = vi.fn();
const queueCompactionMessage = vi.fn((_text: string, _mode: "steer" | "followUp", _images?: ImageContent[]) => {});
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<string, str
compactionQueuedMessages: [],
locallySubmittedUserSignatures: new Set<string>(),
withLocalSubmission: async (_text: string, fn: () => unknown) => fn(),
queueCompactionMessage,
} as unknown as InteractiveModeContext;
return {
@@ -102,6 +113,7 @@ function createStubInputControllerContext(opts: { skillCommands: Map<string, str
handleGoalModeCommand,
updatePendingMessagesDisplay,
requestRender,
queueCompactionMessage,
};
}
@@ -139,6 +151,22 @@ describe("InputController skill queue chip metadata", () => {
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<string, string>;
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<void>();
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<string, string>([["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;