fix(coding-agent/session): restricted queued-message editor restore to user-authored messages

- Added `isUserQueuedMessage()` to `agent-session.ts`, treating only plain user turns and visible `attribution: "user"` custom messages (e.g. `/skill`) as restorable, so advisor concern/blocker notes, hidden goal/plan/budget steers, and IRC/extension asides no longer leak into the editor on Esc/Alt+Up.
- Reworked `clearQueue()` to return only user-authored messages while re-queuing advisor cards via `replaceQueues()` (so the user-interrupt abort path still re-records them as advice) and dropping other agent-authored steers to prevent a silent auto-resume on leftover internal context.
- Filtered `getQueuedMessages()` chips and rewrote `popLastQueuedMessage()` to skip agent-authored cards and pull the last user-authored entry, while `queuedMessageCount` still counts all displayable queued work.
- Extended `input-controller-skill-queue.test.ts` with a `queueAdvisorSteer` helper and cases asserting advisor/IRC cards count as pending work but stay out of chips, restore, and `popLastQueuedMessage`, and survive `clearQueue()`.
This commit is contained in:
can1357
2026-06-16 16:05:36 +02:00
parent bbb41a3c93
commit 623e9d3710
2 changed files with 144 additions and 9 deletions
@@ -955,6 +955,19 @@ function isAdvisorCard(message: AgentMessage): message is CustomMessage {
return message.role === "custom" && message.customType === "advisor";
}
/**
* A queued message the user can restore to the editor / pull back as a draft.
* Only genuinely user-authored messages qualify: plain user turns, or custom
* messages explicitly attributed to the user (e.g. `/skill` invocations).
* Agent-authored queued cards — advisor concern/blocker notes, IRC asides,
* extension notices, hidden goal/plan/budget steers — ride the same
* steer/follow-up queues but must never be dumped into the editor on Esc/Alt+Up.
*/
function isUserQueuedMessage(message: AgentMessage): boolean {
if (message.role === "user") return true;
return message.role === "custom" && message.attribution === "user" && message.display !== false;
}
function queueChipText(message: AgentMessage): string {
if (message.role === "custom") {
return readQueueChipText(message.details) ?? queuedTextContent(message) ?? "";
@@ -5874,13 +5887,25 @@ export class AgentSession {
/** Clear queued messages and return them (text plus any attached images). */
clearQueue(): { steering: RestoredQueuedMessage[]; followUp: RestoredQueuedMessage[] } {
const steering = this.agent.peekSteeringQueue().map(toRestoredQueuedMessage);
const followUp = this.agent.peekFollowUpQueue().map(toRestoredQueuedMessage);
this.agent.clearAllQueues();
const steeringAll = this.agent.peekSteeringQueue();
const followUpAll = this.agent.peekFollowUpQueue();
const steering = steeringAll.filter(isUserQueuedMessage).map(toRestoredQueuedMessage);
const followUp = followUpAll.filter(isUserQueuedMessage).map(toRestoredQueuedMessage);
// User-authored messages go back to the editor. Advisor concern/blocker cards
// are kept queued: a resumed stream still delivers them, and on Esc abort()'s
// #extractQueuedAdvisorCards extracts + preserves them as visible advice
// (with auto-resume suppressed). Every other non-user queued message — hidden
// goal/plan/budget steers, IRC/extension asides — is dropped, matching the
// original full clear: keeping them would both leak into the editor and let
// abort()'s #drainStrandedQueuedMessages silently auto-resume the run the user
// just interrupted (the drain only fires while agent.hasQueuedMessages()).
this.agent.replaceQueues(steeringAll.filter(isAdvisorCard), followUpAll.filter(isAdvisorCard));
return { steering, followUp };
}
/** Number of pending displayable messages (includes steering, follow-up, and next-turn messages) */
/** Number of pending displayable messages (includes steering, follow-up, and next-turn messages).
* Reflects actual queued work (advisor cards included) — feeds hasPendingMessages()/RPC and the
* empty-submit abort gate. The user-restorable subset is surfaced by getQueuedMessages()/clearQueue(). */
get queuedMessageCount(): number {
return (
this.agent.peekSteeringQueue().filter(isDisplayableQueuedMessage).length +
@@ -5891,18 +5916,40 @@ export class AgentSession {
getQueuedMessages(): { steering: readonly string[]; followUp: readonly string[] } {
return {
steering: this.agent.peekSteeringQueue().filter(isDisplayableQueuedMessage).map(queueChipText),
followUp: this.agent.peekFollowUpQueue().filter(isDisplayableQueuedMessage).map(queueChipText),
steering: this.agent.peekSteeringQueue().filter(isUserQueuedMessage).map(queueChipText),
followUp: this.agent.peekFollowUpQueue().filter(isUserQueuedMessage).map(queueChipText),
};
}
/**
* Pop the last queued message (steering first, then follow-up).
* Used by dequeue keybinding to restore messages to editor one at a time.
* Steps over agent-authored queued messages (advisor cards, hidden/internal steers).
*/
popLastQueuedMessage(): RestoredQueuedMessage | undefined {
const message = this.agent.popLastSteer() ?? this.agent.popLastFollowUp();
return message ? toRestoredQueuedMessage(message) : undefined;
const steering = this.agent.peekSteeringQueue();
const followUp = this.agent.peekFollowUpQueue();
const lastUserIndex = (queue: readonly AgentMessage[]): number => {
for (let i = queue.length - 1; i >= 0; i--) {
if (isUserQueuedMessage(queue[i])) return i;
}
return -1;
};
const fromSteer = lastUserIndex(steering);
if (fromSteer >= 0) {
const nextSteer = steering.slice();
const [removed] = nextSteer.splice(fromSteer, 1);
this.agent.replaceQueues(nextSteer, followUp.slice());
return toRestoredQueuedMessage(removed);
}
const fromFollowUp = lastUserIndex(followUp);
if (fromFollowUp >= 0) {
const nextFollowUp = followUp.slice();
const [removed] = nextFollowUp.splice(fromFollowUp, 1);
this.agent.replaceQueues(steering.slice(), nextFollowUp);
return toRestoredQueuedMessage(removed);
}
return undefined;
}
get skillsSettings(): SkillsSettings | undefined {
@@ -225,6 +225,7 @@ function queueCustomSteer(session: AgentSession, chip: string, content = "skill
customType: SKILL_PROMPT_MESSAGE_TYPE,
content,
display: true,
attribution: "user",
details: {
name: "foo",
path: "/s.md",
@@ -236,6 +237,18 @@ function queueCustomSteer(session: AgentSession, chip: string, content = "skill
});
}
function queueAdvisorSteer(session: AgentSession, note = "consider X"): void {
session.agent.steer({
role: "custom",
customType: "advisor",
content: `Advisor:\n- [blocker] ${note}`,
display: true,
attribution: "agent",
details: { notes: [{ note, severity: "blocker" }] },
timestamp: Date.now(),
});
}
describe("AgentSession derived queued custom display", () => {
let fixture: SessionFixture | undefined;
@@ -259,7 +272,7 @@ describe("AgentSession derived queued custom display", () => {
expect(session.queuedMessageCount).toBe(1);
});
it("excludes display-suppressed custom messages from pending chips and counts", async () => {
it("excludes display-suppressed custom messages from chips/count and never restores them", async () => {
fixture = await createRealSession();
const { session } = fixture;
session.agent.steer({
@@ -273,6 +286,35 @@ describe("AgentSession derived queued custom display", () => {
expect(session.getQueuedMessages().steering).toEqual([]);
expect(session.queuedMessageCount).toBe(0);
// A hidden agent-authored steer must not leak into the editor, and clearQueue
// must drop it so abort()'s stranded-message drain can't auto-resume the run
// the user just interrupted (the drain gate is agent.hasQueuedMessages()).
expect(session.clearQueue().steering).toEqual([]);
expect(session.agent.hasQueuedMessages()).toBe(false);
});
it("never restores a visible agent-authored custom steer, and drops it on clearQueue", async () => {
fixture = await createRealSession();
const { session } = fixture;
// An IRC aside / extension notice: visible, but agent-authored — editing it
// makes no sense, so it must not ride the Esc/Alt+Up editor-restore path.
session.agent.steer({
role: "custom",
customType: "irc",
content: "peer pinged you",
display: true,
attribution: "agent",
details: {},
timestamp: Date.now(),
});
expect(session.getQueuedMessages().steering).toEqual([]);
// popLast leaves the agent steer untouched (not user-restorable)...
expect(session.popLastQueuedMessage()).toBeUndefined();
expect(session.agent.peekSteeringQueue()).toHaveLength(1);
// ...and clearQueue restores nothing while dropping it (no auto-resume leftover).
expect(session.clearQueue().steering).toEqual([]);
expect(session.agent.hasQueuedMessages()).toBe(false);
});
it("popLastQueuedMessage restores chip text and removes the core queue entry", async () => {
@@ -283,6 +325,52 @@ describe("AgentSession derived queued custom display", () => {
expect(session.popLastQueuedMessage()?.text).toBe("/skill:foo bar");
expect(session.getQueuedMessages().steering).toEqual([]);
});
it("counts a queued advisor card as pending work but keeps it out of chips and restore", async () => {
fixture = await createRealSession();
const { session } = fixture;
queueAdvisorSteer(session, "guard the null path");
// Advisor cards are real pending work (feeds hasPendingMessages/empty-Enter abort)...
expect(session.queuedMessageCount).toBe(1);
// ...but are never editable user input.
expect(session.getQueuedMessages().steering).toEqual([]);
// clearQueue must not surface the advisor note for editor restore, and must
// leave the card queued so the abort/resume path still delivers it.
const cleared = session.clearQueue();
expect(cleared.steering).toEqual([]);
expect(cleared.followUp).toEqual([]);
expect(session.agent.peekSteeringQueue()).toHaveLength(1);
expect(session.popLastQueuedMessage()).toBeUndefined();
});
it("clearQueue restores user messages but preserves a queued advisor card", async () => {
fixture = await createRealSession();
const { session } = fixture;
queueCustomSteer(session, "/skill:foo bar");
queueAdvisorSteer(session, "rename the symbol");
const cleared = session.clearQueue();
expect(cleared.steering).toEqual([{ text: "/skill:foo bar", images: undefined }]);
// The advisor card survives in the agent-core queue; the user's message left.
const remaining = session.agent.peekSteeringQueue();
expect(remaining).toHaveLength(1);
expect(remaining[0]).toMatchObject({ customType: "advisor" });
});
it("popLastQueuedMessage steps over an advisor card to the user message", async () => {
fixture = await createRealSession();
const { session } = fixture;
queueCustomSteer(session, "/skill:foo bar");
queueAdvisorSteer(session, "watch the race");
expect(session.popLastQueuedMessage()?.text).toBe("/skill:foo bar");
// Advisor card remains queued, not restored.
const remaining = session.agent.peekSteeringQueue();
expect(remaining).toHaveLength(1);
expect(remaining[0]).toMatchObject({ customType: "advisor" });
});
});
function createStubInteractiveModeContextForUiHelpers(session: AgentSession) {