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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user