fix(coding-agent): queued retry-drained skill prompts

Kept compaction-queued skill prompts in the agent queue during retry drains instead of allowing them to start a fresh turn after compaction unwinds.
This commit is contained in:
roboomp
2026-06-28 04:34:48 +00:00
parent 80e772ba4d
commit 9cfbece323
4 changed files with 76 additions and 4 deletions
@@ -26,6 +26,7 @@ interface ParsedSkillCommand {
interface InvokeSkillCommandOptions {
propagateErrors?: boolean;
queueOnly?: boolean;
}
/** Built custom-message payload and delivery options for a `/skill:` command. */
@@ -97,7 +98,8 @@ export async function invokeSkillCommandFromText(
try {
const built = await buildSkillCommandPrompt(ctx, text, streamingBehavior);
if (!built) return false;
await ctx.session.promptCustomMessage(built.message, built.options);
const promptOptions = options?.queueOnly ? { ...built.options, queueOnly: true } : built.options;
await ctx.session.promptCustomMessage(built.message, promptOptions);
return true;
} catch (err) {
if (options?.propagateErrors) {
@@ -668,7 +668,12 @@ export class UiHelpers {
}
async #deliverQueuedMessage(message: CompactionQueuedMessage): Promise<void> {
if (await invokeSkillCommandFromText(this.ctx, message.text, message.mode, { propagateErrors: true })) {
if (
await invokeSkillCommandFromText(this.ctx, message.text, message.mode, {
propagateErrors: true,
queueOnly: true,
})
) {
return;
}
if (this.ctx.isKnownSlashCommand(message.text)) {
@@ -6526,7 +6526,10 @@ export class AgentSession {
async promptCustomMessage<T = unknown>(
message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
options?: Pick<PromptOptions, "streamingBehavior" | "toolChoice"> & { queueChipText?: string },
options?: Pick<PromptOptions, "streamingBehavior" | "toolChoice"> & {
queueChipText?: string;
queueOnly?: boolean;
},
): Promise<void> {
const textContent =
typeof message.content === "string"
@@ -6546,6 +6549,16 @@ export class AgentSession {
keywordNotices = this.#createMagicKeywordNotices(skillArgs);
}
if (options?.queueOnly) {
if (!options.streamingBehavior) {
throw new AgentBusyError();
}
for (const notice of keywordNotices) {
await this.#queueCustomMessage(notice, options.streamingBehavior);
}
await this.#queueCustomMessage(message, options.streamingBehavior, options.queueChipText);
return;
}
if (this.isStreaming) {
if (!options?.streamingBehavior) {
throw new AgentBusyError();
@@ -7111,6 +7124,40 @@ export class AgentSession {
}
}
/** Queue a custom message without starting a turn, matching steer/follow-up delivery. */
async #queueCustomMessage<T = unknown>(
message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
deliverAs: "steer" | "followUp",
queueChipText?: string,
): Promise<void> {
const details =
queueChipText !== undefined
? ({
...((message.details && typeof message.details === "object" ? message.details : {}) as Record<
string,
unknown
>),
__queueChipText: queueChipText,
} as T)
: message.details;
const appMessage: CustomMessage<T> = {
role: "custom",
customType: message.customType,
content: message.content,
display: message.display,
details,
attribution: message.attribution ?? "agent",
timestamp: Date.now(),
};
const normalizedAppMessage = await this.#normalizeAgentMessageImages(appMessage);
if (deliverAs === "followUp") {
this.agent.followUp(normalizedAppMessage);
} else {
this.agent.steer(normalizedAppMessage);
}
this.#scheduleIdleQueueDrain();
}
/**
* Send a custom message to the session. Creates a CustomMessageEntry.
*
@@ -42,7 +42,7 @@ type PromptCustomMessage = Mock<
attribution?: string;
details: SkillPromptDetails;
},
options?: { streamingBehavior?: "steer" | "followUp"; queueChipText?: string },
options?: { streamingBehavior?: "steer" | "followUp"; queueChipText?: string; queueOnly?: boolean },
) => Promise<void>
>;
@@ -288,6 +288,24 @@ describe("compaction skill re-invocation", () => {
expect(steer).not.toHaveBeenCalled();
expect(followUp).not.toHaveBeenCalled();
});
it("queues retry-drained skills without appending them to session history", async () => {
const fixture = await createRealSession();
try {
const { ctx } = createCompactionDrainContext([{ text: "/skill:test-skill retry args", mode: "followUp" }]);
ctx.session = fixture.session;
const uiHelpers = new UiHelpers(ctx);
await uiHelpers.flushCompactionQueue({ willRetry: true });
expect(fixture.session.getQueuedMessages().followUp).toEqual(["/skill:test-skill retry args"]);
expect(fixture.session.messages).toEqual([]);
} finally {
await fixture.session.dispose();
fixture.authStorage.close();
fixture.tempDir.removeSync();
}
});
});
interface SessionFixture {