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